diff --git a/design-qa.md b/design-qa.md index 2ec73430a..caa03d415 100644 --- a/design-qa.md +++ b/design-qa.md @@ -61,3 +61,7 @@ Final result: **blocked**. Blocker: an independent source visual target is required to certify concept fidelity. Implementation-level responsive, interaction, accessibility, and regression QA passed, but the design cannot honestly be declared pixel-faithful to an external approved concept that was not supplied. + +## Follow-up audit — 2026-07-17 + +The exhaustive line-level design, accessibility, UX, interaction, and route-coverage follow-up is documented in [`docs/design-audit-2026-07-17.md`](docs/design-audit-2026-07-17.md). It preserves the fixes above, avoids duplicating this pass, and records the new P1/P2/P3 fixes plus the exact local verification limitations. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b5d2bb9ca..7c94ea76d 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -609,6 +609,8 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #835) | d46f381ac27b53b1bd5ac0ef77962fbd48cf3aa7 | PWA manifest and install-UX polish (plan Phase 2) | Implemented Phase 2 of the PWA plan with cache semantics untouched: `launch_handler` navigate-existing/auto and `display_override` standalone/minimal-ui in `manifest.ts`; `monochrome-192/512` icon variants rendered as a white alpha-only silhouette from the shared brand mark via `BRAND_MONOCHROME`; a one-time iOS/iPadOS Add to Home Screen hint in `pwa-lifecycle.tsx` (30-day dismissal key, never in standalone, timer-deferred eligibility for the set-state-in-effect lint rule). Docs Installability section updated; manifest screenshots remain deferred per the production-capture precondition. Phase 2 re-scout confirmed zero upstream drift before implementation. | Focused Vitest 55/55. `verify:cheap` 2773 passed/1 failed and `verify:pr-local` unit stage identical — the lone failure is the known container-only `pdf-extraction-budget` artifact (clean-main baselined; hosted CI green on #826). `test:e2e:pwa`: privacy journey passed; icon probes validated both new monochrome PNGs; sole installability error remains the container `in-incognito` artifact. `verify:ui` 219 passed/2 failed — exactly the two clean-main-baselined container artifacts, no new failures. `format:check` clean. Build/bundle stages deferred to the blocking hosted CI Build job on PR #835. No provider-backed checks run. | | 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (Phase 3, PR #872, final reviewed head ba46c1581a3c4e87d5d5989f3eb77483c9aa8aa5) | ba46c1581a3c4e87d5d5989f3eb77483c9aa8aa5 | PWA offline-page design upgrade (plan Phase 3) | Rebuilt the `public/offline.html` visual shell on mirrored Clinical White / Aegean Graphite tokens (each value annotated with its source token): pure-white canvas, aligned text/border/hover values, system UI font stack, and the clinical-accent focus ring replacing the off-contract amber. Privacy copy, structure, forced-colors behavior, safe-area insets, and target sizes unchanged. `CACHE_VERSION` bumped to the new unique `2026-07-18-v1` with the offline.html sha256 pairing updated — the Phase 1 binding guard exercised for real and enforced the paired move. A transient typecheck failure from stale `.next/dev` route types (cross-branch dev-server state) self-resolved after server regeneration; nothing was deleted. | Focused Vitest 55/55 including the binding guard on the new pairing. `verify:cheap` 2778 passed/1 failed and `verify:pr-local` unit stage identical — the lone failure is the known container-only `pdf-extraction-budget` artifact (clean-main baselined; hosted CI green on #826/#835). `test:e2e:pwa`: the cold-offline journey rendered and asserted the redesigned page through the new-version worker; sole installability error remains the container `in-incognito` artifact. `verify:ui` 219 passed/2 failed — the same two clean-main-baselined container artifacts, no new failures. Build/bundle stages deferred to the blocking hosted CI Build job. No provider-backed checks run. | | 2026-07-18 | codex/rag-merge-final-20260718-655a | 20e5964bbfdcac0311b89c96931a43544be545a6 | RAG recovery, grounding, and latency merge closure | Reviewed the recovered historical RAG fix series against fresh `origin/main`. No additional source change was needed: current main already contains the stricter source-scoped numeric grounding, blocked-retrieval-only extractive recovery, generic LAI deterministic recovery, per-case answer latency diagnostics, and agitation lexical-path protections. The only residual branch diff was proven unreachable because every medication-chart trigger yields explicit search terms; it was intentionally not carried forward. | Full local Vitest: 301 files / 2,779 tests passed. Offline RAG fixture and production-contract validation: 36 golden cases, 21 suites, 294 tests passed. Prettier and `git diff --check` passed. `verify:cheap` reached its lint stage after all preceding static guards passed, but was blocked twice by active heavyweight jobs in other registered worktrees; it was not retried. No Supabase, OpenAI, live retrieval, production-readiness, deployment, or hosted CI command was run. | +| 2026-07-17 | codex/design-audit-20260716 | 47b58ddf95826231a6057b400e7f601c403a129d + reviewed working diff | exhaustive design, accessibility, UX, interaction, route, responsive, HTML/CSS/JS/TSX, and test-coverage audit with remediation | No P0. Fixed two P1 trust/responsive defects and the confirmed P2/P3 set: false Favourites provenance/demo leakage/dead controls, Therapy phone reflow/load recovery/contrast, semantic tabs/search/filters, error focus/theme, deterministic Forms back navigation, honest unavailable actions, application dialog semantics, duplicate differential keys, skip-link target, Playwright project isolation, and route coverage. External visual-target fidelity remains unavailable. | Static 261-file/~67,944-line inventory; independent combined-diff review; focused Vitest 21/21; jsdom 24/24; architecture 6/6; accessibility browser 5/5; focused route/browser recovery; lint; TypeScript; runtime/workflow/sitemap/brand/type/icon checks; production build with 1,043 pages and client-secret scan; `git diff --check`. Canonical aggregate Vitest was Windows-runner inconclusive and full UI was not repeated after focused remediation. No provider/API checks. | | 2026-07-18 | codex/main-merge-51278-final-20260718-late | 45fa3c6c7, 14a0a898c | merge integration of 51278a70d onto fresh origin/main | Replayed the requested historical design-audit commit onto fresh `origin/main`. Kept current-main versions for five conflicts, including the regenerated drift manifest and current schema assertions. Fixed the duplicate sitemap-generator declaration, corrected redirect-section coverage, and restored the current `Clinical Guide` UI contract. | `git diff --check` and `npm run sitemap:check` passed. `npm run test` was attempted twice but blocked by the repository-wide heavy-command lock held by a separate Playwright worktree; no provider-backed checks ran. | | 2026-07-18 | origin/main framework and dependency modernization snapshot | 4057677c8b92a5e1d997ec44958764fa91f5d424 | parallel build/infra, backend, and frontend modernization audit | Changes requested. No P0. Confirmed two P1 defects: Supabase SSR 0.12 auth-cookie responses discard mandatory anti-cache headers, and reindex bypasses the server-only-aware TSX runner after mutation-capable setup. Five P2 blockers cover the Webpack-to-Turbopack production cutover, incomplete Railway image-build watch ownership, missing clean `next typegen`, Node 26 types over a Node 24 runtime, and App Router retry actions that reset without re-fetching. P3 removal-readiness debt remains in Zod and Next Image APIs. Manual rewrite zones are auth response ownership, bundler/CSP/artifact consumers, JSZip resource limits, OpenAI request typing, and React Compiler adoption in the stateful dashboard/viewer roots. | Parallel read-only source/config/test audit against the exact snapshot; Node 24.18.0/npm 11.17.0; exact-version Next 16.2.10 bundled upgrade, Turbopack, error-boundary, and codemod guidance; TypeScript 6 backend no-emit analysis and Node import probes via a separately installed exact-version local dependency tree; `git diff --check`. No install, registry outdated/audit, full repo lint/typecheck/test/build/browser/Docker, Supabase/OpenAI, deployment, or hosted CI run; this worktree had no `node_modules`. | | 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #890, plan Phase 4; single-batch implementation+ledger commit — Phase 3 landed as squash 44a4c511bc1381168474794d6f89273564659ead) | ceabc04d75fd0cea2a120d504418d44edd704bfb | App-wide performance pass: audit-then-fix + budget ratchet (plan Phase 4) | Audit on post-#872 main: enforced gzip budget +3.13% over the 1,363,382-byte baseline; analyzer treemap showed pdfjs-dist (123 KB) and cross-mode-differentials data (121 KB) already correctly lazy, and disproved the suspected forms-catalog dashboard leak (type-only import). Confirmed one real defect: the 143-line `/services` client home page value-imported `defaultServiceSlug` from `@/lib/services`, compiling the ~915 KB services snapshot (~100 KB gzip) into its route chunk. Fixed by computing the slug in the server page (`src/app/services/page.tsx`) and passing it as a prop; the client component builds its task cards from the prop. Measured result: budget swung from +3.13% to −3.97%; baseline ratcheted down 1,363,382 → 1,309,286 bytes gzip so CI locks the win. pdfjs/differentials/#718 paths deliberately untouched per audit rules. Two transient `.next/dev` generated-type corruptions from the long-lived dev server were resolved by stopping the server and setting the generated dir aside (reversible); nothing hand-deleted. | Build + `check:bundle-budget --json` before/after (artifacts in session scratchpad); `check:bundle-budget -- --update` for the ratchet; `npm run test` 2789 passed/1 failed (known container-only `pdf-extraction-budget` artifact, hosted-CI-green through #826/#835/#872); `verify:cheap` green to the same artifact; `verify:ui` 219 passed/2 failed (the two long-established container artifacts; ui-tools spec covering the services surface passed); `verify:pr-local` unit stage identical; typecheck/lint/format clean. Lighthouse not run (dev-server churn; bundle evidence sufficed for this fix set). No provider-backed checks run. | +| 2026-07-18 | PR #871 / codex/design-audit-final-pr-20260718 | bb85b546e + ledger closeout | final current-main design/accessibility audit integration, hosted review resolution, and PR-readiness review | No remaining high-confidence P0-P2 defect after current-main reconciliation, two independent read-only reviews, and remediation of every confirmed review finding. The follow-ups made client demo state fail closed in production for both prototype items and set suggestions, separated local no-auth upload capability from Favourites demo treatment, kept unavailable Favourites controls focusable with accessible reasons, restored visible skip-target focus, limited Favourites selection to the wide layout that exposes its workspace, aligned upload/index semantics with each responsive rendering mode, exposed filtered Tools results as a labelled group, and kept Therapy Compass in an honest loading state until its catalogue resolves. The audit also distinguishes completed browser assertions from an incomplete valid document-source redirect case. External design-target fidelity remains unverified without an approved target. | Canonical local PR verification completed through runtime, changed-file formatting, full ESLint, TypeScript, full Vitest, webpack production build/client-secret scan, and offline RAG fixtures. Focused Therapy, navigation/auth, demo-boundary, and unavailable-control regressions passed after correcting one test-order issue; the final demo/upload boundary selection passed 4/4; scoped zero-warning ESLint, changed-file formatting, and `git diff --check` passed. The full local Chromium sweep completed 236/237 with one hydration-timing failure: the app-mode test clicked before React attached its handler. The existing handler-readiness pattern was applied; focused rerun attempts were then lock-blocked by another registered worktree, so exact-head hosted Chromium remains the required proof. Initial hosted checks, including Production UI, passed before these final follow-ups. No OpenAI, Supabase, production-data, deployment, or live clinical workflow ran. | diff --git a/docs/design-audit-2026-07-17.md b/docs/design-audit-2026-07-17.md new file mode 100644 index 000000000..1c1fd1054 --- /dev/null +++ b/docs/design-audit-2026-07-17.md @@ -0,0 +1,104 @@ +# Design, accessibility, and interaction audit — 2026-07-17 + +## Outcome + +The repository-wide design audit is complete. The implementation pass fixed the two P1 findings, the confirmed P2/P3 design and interaction defects, and three defects found by an independent adversarial review of the combined diff. No P0 was found. + +This pass deliberately built on the 2026-07-15 live design audit instead of repeating it. The earlier fixes for phone scroll ownership, app-mode Tab dismissal, decorative icon semantics, reduced-motion press feedback, Sheet backdrop tokens, and forced-colour tokens were treated as resolved. + +## Scope and method + +- Static line-level inventory: 261 user-facing TSX/CSS files under `src/app` and `src/components`, approximately 67,944 lines. +- Route and interaction inventory: 37 rendered/redirect route families and approximately 410 actionable controls. +- Parallel reviews: static product design, accessibility/UX, interaction/test coverage, plus an independent combined-diff review. +- Live proof: local demo-mode Next.js app only, with external requests blocked or provider variables removed. +- Breakpoints exercised by this and the inherited audit evidence: 320, 390, 639, 768, 1280/1440, 1536, and 1920 CSS pixels. +- Source visual target: unavailable. The existing product design system was the consistency baseline; this audit cannot certify pixel fidelity to an external Figma or approved mockup that was not supplied. + +## Findings and fixes + +| ID | Severity | Detailed issue and user impact | Implemented solution | Principal files | +| ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| DQA-01 | P1 | Favourites rendered the same acamprosate evidence and lithium note beneath unrelated selected items, creating false item-level provenance. | Evidence and notes now derive from the selected item; absent content has an explicit honest empty state. Copy actions use the item citation and return accessible status. | `favourites-command-library-page.tsx`, `ui-smoke.spec.ts` | +| DQA-02 | P1 | Therapy Compass used fixed desktop grid tracks wider than a phone while the global shell clipped page overflow, making several production screens unreachable at 320–390 px. | Added semantic responsive layout classes, one-column phone reflow, wrapping actions/metrics, and a contract test across all Therapy Compass screens. | `therapy-compass/*`, `therapy-compass-responsive-contract.test.ts` | +| DQA-03 | P2 | White text/icons on the dark clinical accent measured materially below accessible contrast and bypassed the existing contrast token. | Replaced hard-coded white foregrounds with `--clinical-accent-contrast`, including answer status and Therapy Compass controls. | `answer-status.tsx`, `therapy-compass/*` | +| DQA-04 | P2 | Prototype favourites appeared as genuine saved user content in normal mode, including item and set suggestions through universal search; an unconfigured browser auth client could also fail open to demo state in production. | Gated fixture items and sets behind one production-safe demo decision across the direct server route and client shells. Auth-unavailable fallback is non-production only, local no-auth remains consistent, and explicit production demo opt-in is retained. Normal mode shows only real saved items. | `client-env.ts`, `favourites/page.tsx`, `ClinicalDashboard.tsx`, `favourites-hub.tsx`, `global-search-shell.tsx`, `master-search-header.tsx`, `universal-search-command-surface.tsx` | +| DQA-05 | P2 | Favourites exposed invisible selection below `2xl`, nested/synthetic mobile card controls, no-op edit/sort actions, incomplete menu keyboard behavior, clipboard focus loss, and title-only reasons on unfocusable unavailable controls. | Kept explicit native links and separate buttons on mobile, limited selection to the visible workspace, implemented real citation copy with status/focus restoration and complete menu keyboard behavior, and made unfinished actions honestly `aria-disabled`, focusable, and accessibly described. | `favourites-command-library-page.tsx`, `favourites-hub.tsx`, `favourites-hub-unavailable-controls.dom.test.tsx` | +| DQA-06 | P2 | Differential presentation controls advertised editing, filtering, reorder/remove behavior, and a fixed `+2` count that did not exist. | Removed false instructions, derived the remaining count from state, and disabled unfinished controls with availability explanations while preserving the read-only comparison. | `differential-presentation-workflow-page.tsx` | +| DQA-07 | P2 | Services selection led to enabled-looking Edit/Review/View controls that did nothing, while Compare stayed unavailable without an accurate reason. A later diff briefly disabled the real Clear action. | Removed the false actions and restored Clear. During current-main integration, the later functional checklist, confidence-detail, and selected-service comparison controls were preserved instead of replacing them with disabled placeholders. | `services-navigator-page.tsx`, `ui-smoke.spec.ts` | +| DQA-08 | P2 | Evidence records with no URL were still keyboard-focusable `href="#"` links announced as “Open source”. | Missing-source rows now render as non-links with unavailable semantics; only valid destinations render a Link. | `visual-evidence.tsx`, `visual-evidence-tabs.dom.test.tsx` | +| DQA-09 | P2 | Therapy cards presented an enabled Favourite button with no handler or state change. | The unavailable feature is now disabled and labelled as coming soon, preventing false save confirmation. | `therapy-card.tsx` | +| DQA-10 | P2 | Route/global error fallbacks did not reliably move focus or announce a route failure; the global fallback was hard-coded light-only. | Added programmatic heading focus and alert announcement, and made the self-contained global fallback system-colour, dark-mode, and forced-colour aware. | `route-error-boundary.tsx`, `global-error.tsx`, route error tests | +| DQA-11 | P2 | Clinical Notes, Visual Evidence, and upload/index tab interfaces lacked complete roving keyboard behavior and reciprocal tab/panel relationships. | Added stable unique IDs, `aria-controls`/`aria-labelledby`, roving `tabIndex`, and Left/Right/Home/End auto-activation. Upload/indexing uses tab panels only with its visible mobile tablist and named regions when desktop renders every section. | `ClinicalDashboard.tsx`, `answer-result-surface.tsx`, `visual-evidence.tsx`, UI/DOM tests | +| DQA-12 | P2 | Universal search visually highlighted an item without exposing it through `aria-activedescendant`. | The combobox now points to the active option and tests verify the relationship through keyboard navigation. | `master-search-header.tsx`, `universal-search-command-surface.tsx`, `ui-universal-search.spec.ts` | +| DQA-13 | P2 | Differential filters and several selector groups behaved as toggles but lacked grouped/pressed semantics; the Tools result set retained orphaned tab-panel semantics after its controls became pressed filters. | Added labelled groups and stateful `aria-pressed` semantics, and exposed Tools results as a labelled group, with browser accessibility assertions. | `differentials-home.tsx`, `evidence-panels.tsx`, `applications-launcher-page.tsx`, `ui-accessibility.spec.ts`, `ui-tools.spec.ts` | +| DQA-14 | P2 | Therapy Compass silently swallowed data-load failure, rendered an empty `0`-record catalogue while loading, and still implied a fabricated `200+` catalogue count. | Added distinct loading and failure states, a Retry action, removed fabricated totals, and retained real successful/empty states. The final integration tests the routed workspace that replaced the legacy single-page wrapper. | `use-therapy-data.ts`, `workspace.tsx`, `home-screen.tsx`, `therapy-compass-data-recovery.dom.test.tsx` | +| DQA-15 | P2 | Direct-entry Forms “Back to forms” used browser history and could leave the application. | The control now deterministically navigates to `/forms?focus=1`; a DOM regression test verifies the destination. | `form-detail-page.tsx`, `forms-back-navigation.dom.test.tsx` | +| DQA-16 | P2 | Firefox/WebKit production projects unintentionally collected advisory `@mockup` tests; two browser helpers also retried hydration inconsistently. | Applied production matchers and `grepInvert` to all required projects and introduced one shared app-mode hydration retry helper. | `playwright.config.ts`, `playwright-app-mode.ts`, `ui-stress.spec.ts`, `ui-tools.spec.ts` | +| DQA-17 | P2 | Application/tool cards looked like direct links but prevented navigation to open an internal detail state, obscuring the real action. | Cards are now honest dialog buttons with `aria-haspopup="dialog"`; the dialog contains the actual destination link. | `applications-launcher-page.tsx`, `ui-smoke.spec.ts` | +| DQA-18 | P2 | Differential diagnosis streams keyed repeated cards only by visible title, producing React duplicate-key errors and unstable list identity. | Cards now use a stable href/index composite key; the route matrix listens for console errors. | `differential-stream-page.tsx`, `ui-route-coverage.spec.ts` | +| DQA-19 | P2 | The colour-reference skip link targeted `#main-content`, but the route had no matching focus target; app-shell targets also suppressed their focus outline. | Added canonical focusable main-content targets with an explicit visible focus outline and browser proof of both focus transfer and computed outline style. | `reference/colour-coding/page.tsx`, `ClinicalDashboard.tsx`, `global-search-shell.tsx`, `ui-route-coverage.spec.ts` | +| DQA-20 | P3 | Answer sheets overrode the semantic backdrop token and duplicate mobile CSS retained obsolete layout overrides. | Removed call-site backdrop overrides and redundant CSS so theme, forced-colour, and responsive behavior come from the shared tokens/layout rules. | `answer-result-surface.tsx`, `globals.css`, `ui-overlay-css-contract.test.ts` | +| DQA-21 | P2 | Route-handler redirects inherited the production server's `0.0.0.0` bind origin, so Chromium rejected Applications, Medications, and differential-presentation redirects with `ERR_ADDRESS_INVALID`. | Return same-origin relative `Location` headers, preserving allowed query state without leaking the server bind address into browser navigation. | `applications/route.ts`, `medications/route.ts`, `differentials/presentations/route.ts`, route tests | +| DQA-22 | P2 | Local no-auth mode reused the broad Favourites demo flag for uploads, so a development session with working private upload APIs incorrectly presented the upload workspace as read-only. | Added a dedicated upload-read-only resolver that retains explicit demo and unavailable-auth lockout while excluding local no-auth treatment; focused behavior and source contracts protect the boundary. | `client-env.ts`, `ClinicalDashboard.tsx`, `favourites-demo-boundary.test.ts`, `audit-navigation-auth-regressions.test.ts` | + +## Route and component interaction proof + +The new provider-free route matrix covers Therapy Compass, DSM home/compare/differentials, Specifier compare/map, differential diagnosis stream/detail, colour reference, Applications, Medications and differential-presentation redirects, and the invalid document-source alias at desktop and 390 px. It asserts visible primary content, no document-level horizontal overflow, a representative action, blocked external/API leakage, and route-specific behavior. The valid `/documents/source` case reached the expected redirect request but did not complete its final browser assertion, as recorded below. + +Additional focused browser proof covered: + +- Clinical Notes roving tabs and universal-search active descendant behavior. +- Favourites desktop workspace, item-specific evidence/notes, menu keyboard/copy/focus restoration, and native mobile link/menu separation. +- Applications detail-dialog semantics and real destination link. +- Tools mobile/desktop navigation and detail interactions. +- Differential comparison and the differential stream/detail console-error regression. +- Reduced motion, forced colours, 200% zoom, and axe WCAG A/AA in normal and forced-colour modes. + +The Services control test rendered the correct page and retained a screenshot showing the selected rail and working Clear action. Its assertion was incorrectly scoped to a nonexistent test ID; that test now uses the semantic `main` root. The final integration also preserves current main's functional checklist, confidence-detail, and selected-service comparison controls. Per the recovery instruction, the earlier audit test was not launched a third time after two failed attempts (the first was runner/port contention, the second exposed the selector defect). + +The valid `/documents/source` browser case issued the correct destination request but the cold document-detail compile (about 24 seconds plus source render) exceeded its 30-second browser poll. The exact local response contains `NEXT_REDIRECT;replace;/documents/11111111-1111-4111-8111-111111111111?page=2&chunk=safety+plan;307;`. The test allowance is now 60 seconds, but the same browser action was not retried a third time. + +## Verification results + +Passed: + +- `npm run build`: webpack production build compiled; TypeScript passed; 1,043/1,043 static pages generated; client-bundle secret scan passed. +- `npm run typecheck`. +- `npm run lint`. +- Runtime, GitHub Actions pin, sitemap, brand, strict type-scale, and strict icon-scale checks. +- Focused changed-surface Vitest: 9 files, 21/21 tests. +- Full jsdom project through the Windows worker-thread fallback: 10 files, 24/24 tests. +- Architecture boundary isolation: 6/6, including no runtime import cycles. +- Accessibility media/axe browser suite: 5/5. +- Focused route recovery: Specifier map 1/1; differential stream/detail 1/1; colour reference plus Applications, Medications, and invalid evidence alias 4/4. +- `git diff --check` before final documentation. + +Broad-gate limitations: + +- The canonical `npm run verify:cheap` reached the unit phase but Windows repeatedly failed to start Vitest fork workers. The worker-thread fallback exposed only the architecture scan exceeding the global 30-second timeout; that exact file passed 6/6 in isolation with diagnostic headroom. A later aggregate JSON run was terminated before writing its result. The changed-surface and jsdom suites are green, but the canonical aggregate unit command is not recorded as green. +- The first current-branch `npm run verify:ui` pass was 177/182. Each identified product/test issue was fixed and rerun narrowly; the full suite was not repeated after recovery to avoid repeating the large gate. The inherited prior design branch baseline remains 175/175. +- No OpenAI, Supabase, GitHub/GitLab, hosted CI, production-like, or paid-provider workflow was run. Provider variables were removed from local verification commands. + +## Residual decisions and risk + +- External design-target fidelity remains unprovable until an approved Figma file or screenshot set is supplied. +- Therapy favourite persistence and differential column editing remain intentionally unavailable. They now present disabled or read-only states instead of false actions; Services comparison/details are functional in the final current-main integration. +- Live authenticated data, provider failure behavior, and production clinical workflows remain outside this offline design audit and require explicit authorization before testing. +- No dependency, API contract, database schema, production data, or environment file was changed. + +## Audit execution state + +- Branch: `codex/design-audit-20260716` +- Isolated worktree: `C:\Dev\Apps\Database\worktrees\design-audit-20260716` +- Base HEAD: `47b58ddf95826231a6057b400e7f601c403a129d` +- The audit and verification finished before Git handoff; its reviewed diff is carried forward from this isolated worktree onto the final PR branch. +- No deployment or provider-backed call was made during the audit. + +## Final PR integration verification (2026-07-18) + +- Final branch/worktree: `codex/design-audit-final-pr-20260718` in `C:\Dev\Apps\Database\worktrees\design-audit-final-pr-20260718`. +- The current-main merge preserved the routed Therapy workspace, functional Services checklist/confidence/comparison controls, and dialog-first tool cards. Stale tests for the removed legacy wrappers/placeholders were reconciled without restoring inaccessible or false UI. +- The local PR plan passed runtime, changed-file formatting, full ESLint, TypeScript, 309 Vitest files / 2,797 tests, a webpack production build with 1,674 generated pages, client-bundle secret scanning, and 36 offline RAG golden fixtures across 21 suites. The build was rerun narrowly after an empty `NEXT_PUBLIC_DEMO_MODE` test-shell value was removed; no source change was required for that environment-only failure. +- The exact-branch Chromium run passed 230/237 tests on its first pass. Focused production recovery passed Therapy navigation and all three relative redirect journeys; Services and Tools then passed against the identity-verified provider-free branch server. The guest upload journey was proven in production through the Source Library and exact admin-only notice; its final strict alert locator was narrowed to exclude Next's empty route announcer. A later full sweep passed 236/237; the sole failure clicked the app-mode trigger before React attached its handler, so the test now uses the existing handler-readiness guard. Two focused rerun attempts were shared-lock blocked, making hosted exact-head UI the final confirmation. +- No OpenAI, Supabase, production-data, deployment, or live clinical workflow was invoked. Browser/API fixtures used the repository's offline environment and blocked external requests. diff --git a/playwright.config.ts b/playwright.config.ts index 0667d8acf..64f10400f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,16 +10,17 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // Prototype /mockups journeys live in their own advisory project so a red // mockup can never mask a production-journey regression (PT-05). The two // Tag-level filters keep production and prototype journeys disjoint even when -// they share a spec file. +// they share a spec file. Every required browser project uses the same +// production matcher and tag exclusion. const productionSpecPattern = - /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|pwa))\.spec\.ts/; + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|pwa|route-coverage))\.spec\.ts/; const mockupSpecPattern = /.*ui-(tools|tools-collapse|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|pwa))\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|pwa|route-coverage))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/src/app/applications/route.ts b/src/app/applications/route.ts index 0f19d2c8c..e934c3103 100644 --- a/src/app/applications/route.ts +++ b/src/app/applications/route.ts @@ -1,11 +1,12 @@ -import { type NextRequest, NextResponse } from "next/server"; +import { type NextRequest } from "next/server"; // "Tools" is the canonical name and /tools the canonical route (PT-11). A // route handler redirects the incoming legacy request before React renders it. export function GET(request: NextRequest) { - const destination = request.nextUrl.clone(); - destination.pathname = "/tools"; - return NextResponse.redirect(destination); + return new Response(null, { + status: 307, + headers: { Location: `/tools${request.nextUrl.search}` }, + }); } export const HEAD = GET; diff --git a/src/app/favourites/page.tsx b/src/app/favourites/page.tsx index 6048c35e0..eb3ff4715 100644 --- a/src/app/favourites/page.tsx +++ b/src/app/favourites/page.tsx @@ -1,4 +1,6 @@ import { FavouritesCommandLibraryPage } from "@/components/clinical-dashboard/favourites-command-library-page"; +import { resolveClientDemoMode } from "@/lib/client-env"; +import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; type FavouritesPageProps = { searchParams?: Promise<{ @@ -13,8 +15,13 @@ function firstSearchParam(value: string | string[] | undefined) { export default async function FavouritesPage({ searchParams }: FavouritesPageProps) { const params = searchParams ? await searchParams : {}; const query = firstSearchParam(params.q)?.trim() ?? ""; + const demoMode = resolveClientDemoMode({ + explicitDemoMode: isDemoMode(), + authUnavailableFallback: false, + localNoAuthMode: isLocalNoAuthMode(), + }); // No key={query} remount: query is a pure prop, and remounting on query // change wiped the set/type/view/sort selections when clearing a search. - return ; + return ; } diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index aed84dad6..157a1b45c 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -14,7 +14,7 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? const headingRef = useRef(null); useEffect(() => { console.error("Fatal error captured by global-error boundary:", error); - headingRef.current?.focus(); + headingRef.current?.focus({ preventScroll: true }); }, [error]); return ( @@ -27,6 +27,7 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? alignItems: "center", justifyContent: "center", padding: "1rem", + colorScheme: "light dark", backgroundColor: "Canvas", color: "CanvasText", fontFamily: @@ -68,11 +69,11 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? style={{ margin: "0 0 1.25rem", borderRadius: "0.5rem", - backgroundColor: "Canvas", + backgroundColor: "Field", padding: "0.5rem", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontSize: "0.75rem", - color: "CanvasText", + color: "FieldText", wordBreak: "break-all", }} > diff --git a/src/app/globals.css b/src/app/globals.css index d991a1ba1..be324e945 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1159,7 +1159,6 @@ summary::-webkit-details-marker { width: 1.1rem; } } - @media (prefers-reduced-motion: no-preference) { .mode-action-surface[data-placement="up"] { animation: mode-action-fold-up 170ms cubic-bezier(0.22, 1, 0.36, 1) both; diff --git a/src/app/medications/route.ts b/src/app/medications/route.ts index c7ad52d16..5ebeac9a8 100644 --- a/src/app/medications/route.ts +++ b/src/app/medications/route.ts @@ -1,7 +1,8 @@ -import { type NextRequest, NextResponse } from "next/server"; - -export function GET(request: NextRequest) { - return NextResponse.redirect(new URL("/?mode=prescribing", request.url)); +export function GET() { + return new Response(null, { + status: 307, + headers: { Location: "/?mode=prescribing" }, + }); } export const HEAD = GET; diff --git a/src/app/reference/colour-coding/page.tsx b/src/app/reference/colour-coding/page.tsx index cb248a1dc..d5e0a946e 100644 --- a/src/app/reference/colour-coding/page.tsx +++ b/src/app/reference/colour-coding/page.tsx @@ -31,7 +31,14 @@ const TONE_SAMPLE_LABEL: Record<(typeof SEMANTIC_TONES)[number], string> = { export default function ColourCodingReferencePage() { return ( -
+
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 5555299d5..f9012e2cd 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -23,10 +23,25 @@ import { WifiOff, Wrench, } from "lucide-react"; -import { type CSSProperties, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { extractSafetyFindings } from "@/lib/clinical-safety"; -import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/client-env"; +import { + isLocalNoAuthMode, + publicUploadsEnabled, + resolveClientDemoMode, + resolveUploadReadOnlyMode, +} from "@/lib/client-env"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { @@ -114,6 +129,23 @@ const FavouritesHub = dynamic( () => import("@/components/clinical-dashboard/favourites-hub").then((m) => m.FavouritesHub), { ssr: false }, ); + +const uploadDesktopMediaQuery = "(min-width: 1024px)"; + +function subscribeToUploadDesktopLayout(callback: () => void) { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {}; + const media = window.matchMedia(uploadDesktopMediaQuery); + media.addEventListener("change", callback); + return () => media.removeEventListener("change", callback); +} + +function getUploadDesktopLayoutSnapshot() { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(uploadDesktopMediaQuery).matches + ); +} const MedicationPrescribingWorkspace = dynamic( () => import("@/components/clinical-dashboard/medication-prescribing-workspace").then( @@ -652,6 +684,12 @@ export function ClinicalDashboard({ const [documentsDrawerMode, setDocumentsDrawerMode] = useState("library"); const [uploadDrawerOpen, setUploadDrawerOpen] = useState(false); const [uploadMobileTab, setUploadMobileTab] = useState("upload"); + const uploadUsesDesktopRegions = useSyncExternalStore( + subscribeToUploadDesktopLayout, + getUploadDesktopLayoutSnapshot, + () => false, + ); + const uploadTabRefs = useRef(new Map()); const [documentDrawerStatusFilter, setDocumentDrawerStatusFilter] = useState("indexed"); const [indexingMonitorFilter, setIndexingMonitorFilter] = useState("all"); const [recentQueries, setRecentQueries] = useState([]); @@ -772,7 +810,11 @@ export function ClinicalDashboard({ const browserAuthUnavailableDemoFallback = !auth.isConfigured && supabaseEnvStatus !== "ready"; const localNoAuthMode = isLocalNoAuthMode(); const explicitDemoMode = demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true"; - const clientDemoMode = explicitDemoMode || browserAuthUnavailableDemoFallback || localNoAuthMode; + const clientDemoMode = resolveClientDemoMode({ + explicitDemoMode, + authUnavailableFallback: browserAuthUnavailableDemoFallback, + localNoAuthMode, + }); const answerThreadOwnerId = auth.session?.user.id ?? (clientDemoMode ? demoRecentQueryOwnerId : null); const previousAnswerThreadOwnerIdRef = useRef(answerThreadOwnerId); useEffect(() => { @@ -824,8 +866,12 @@ export function ClinicalDashboard({ setAnswerThreadBootstrapped(true); }); }, [answerThreadOwnerId, authStatus]); - const uploadReadOnlyMode = - demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true" || browserAuthUnavailableDemoFallback; + // Local no-auth still has private upload APIs (`canUsePrivateApis`); do not lock the + // upload drawer just because `resolveClientDemoMode` treats no-auth as demo for favourites. + const uploadReadOnlyMode = resolveUploadReadOnlyMode({ + explicitDemoMode, + authUnavailableFallback: browserAuthUnavailableDemoFallback, + }); const localDevCanAttemptPrivateApis = process.env.NODE_ENV !== "production" && hasReadyPublicSearchSetup(setupChecks); const canUsePublicSearchApis = localProjectReady && hasReadyPublicSearchSetup(setupChecks); const canUseDegradedLocalSearchApis = @@ -3226,6 +3272,7 @@ export function ClinicalDashboard({ id: UploadIndexingTab; label: string; summary: string; + tabId: string; panelId: string; icon: typeof UploadCloud; }> = [ @@ -3233,6 +3280,7 @@ export function ClinicalDashboard({ id: "setup", label: "Setup", summary: `${setupReadyCount}/${setupCheckCount} ready`, + tabId: "dashboard-upload-tab-setup", panelId: "dashboard-setup-section", icon: ListChecks, }, @@ -3240,6 +3288,7 @@ export function ClinicalDashboard({ id: "upload", label: "Upload", summary: uploadReadOnlyMode || !canUploadDocuments ? "Locked" : "Ready", + tabId: "dashboard-upload-tab-upload", panelId: "dashboard-upload-section", icon: UploadCloud, }, @@ -3251,6 +3300,7 @@ export function ClinicalDashboard({ : failedUploadWork ? `${failedUploadWork} failed` : "Idle", + tabId: "dashboard-upload-tab-jobs", panelId: "dashboard-indexing-section", icon: RefreshCw, }, @@ -3258,10 +3308,31 @@ export function ClinicalDashboard({ id: "quality", label: "Quality", summary: qualityItems.length ? `${qualityItems.length} review` : "Clear", + tabId: "dashboard-upload-tab-quality", panelId: "dashboard-quality-section", icon: ShieldAlert, }, ]; + + function handleUploadTabKeyDown(event: ReactKeyboardEvent) { + const order = uploadTabs.map((tab) => tab.id); + const index = order.indexOf(uploadMobileTab); + const next = + event.key === "ArrowRight" + ? order[(index + 1) % order.length] + : event.key === "ArrowLeft" + ? order[(index - 1 + order.length) % order.length] + : event.key === "Home" + ? order[0] + : event.key === "End" + ? order[order.length - 1] + : null; + if (!next) return; + event.preventDefault(); + if (next !== uploadMobileTab) setUploadMobileTab(next); + uploadTabRefs.current.get(next)?.focus(); + } + const handleUploadQueued = () => { setUserStartedIngestion(true); setIndexingActive(true); @@ -3385,6 +3456,7 @@ export function ClinicalDashboard({
reserves its exact height as top padding (72px borderless // bar = 4rem content/padding + the max(0.5rem, safe-area) top inset — @@ -3758,14 +3830,12 @@ export function ClinicalDashboard({ ) : activeModeResultKind === "favourites" ? ( { setQuery(""); setModeSearchSubmitted(false); router.replace(appModeHomeHref("favourites", { focus: true, queryMode, scopeFilters })); }} - onAddFavourite={() => - setActionNotice({ tone: "success", message: "Favourite creation is ready to connect." }) - } desktopComposerSlotId={desktopHomeComposerSlotId} /> ) : activeModeResultKind === "documents" || activeModeResultKind === "services" ? ( @@ -3980,6 +4050,7 @@ export function ClinicalDashboard({
{uploadTabs.map((tab) => { @@ -3988,10 +4059,18 @@ export function ClinicalDashboard({ return ( @@ -4014,14 +4096,19 @@ export function ClinicalDashboard({
-

+

Developer setup status

@@ -4029,14 +4116,21 @@ export function ClinicalDashboard({
-

+

Clinical upload

-

+

Indexing progress

-

+

Ingestion quality console

-
+
{desktopFilters.map((filter) => { const active = filter.id === activeFilter || (filter.id === "all" && activeFilter === "more"); return (
-
+
{mobileFilters.map((filter) => { const active = filter.id === activeFilter || (filter.id === "all" && activeFilter === "saved"); return ( ); } @@ -452,14 +450,12 @@ function MobileToolRow({ onSelect: (id: string) => void; }) { return ( - { - event.preventDefault(); - onSelect(app.id); - }} + onClick={() => onSelect(app.id)} className={cn( "grid min-h-[5.25rem] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-[color:var(--surface-lux)] px-3 py-3 text-left shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)]", selected @@ -478,10 +474,10 @@ function MobileToolRow({ - {app.actionLabel} + Details - + ); } @@ -709,8 +705,8 @@ export function ApplicationsLauncherWorkspace({ ? selectedId : (filteredApps[0]?.id ?? selectedId); const selectedApp = appById(effectiveSelectedId); - // Label the panel by the selected tab's visible label (mobile-only tabs like - // "More" included) so assistive tech hears a region name matching the tab. + // Label the results by the selected filter's visible label (mobile-only filters + // like "More" included) so assistive tech hears which result set is active. const activeFilterLabel = desktopFilters.find((filter) => filter.id === activeFilter)?.label ?? mobileFilters.find((filter) => filter.id === activeFilter)?.label; @@ -800,12 +796,7 @@ export function ApplicationsLauncherWorkspace({
-
+
{filteredApps.length === 0 ? (

{copy.emptyTitle}

diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 752d816b3..6051ac0e0 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -286,7 +286,6 @@ function StagedAnswerResultSurfaceImpl({ closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-md" bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - desktopBackdropClassName="sm:bg-[color:var(--overlay-backdrop)]" returnFocusRef={clinicalNotesTriggerRef} portal > @@ -318,7 +317,6 @@ function StagedAnswerResultSurfaceImpl({ } contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-3xl" bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - desktopBackdropClassName="sm:bg-[color:var(--overlay-backdrop)]" returnFocusRef={evidenceTriggerRef} portal > @@ -363,7 +361,6 @@ function StagedAnswerResultSurfaceImpl({ closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg" bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - desktopBackdropClassName="sm:bg-[color:var(--overlay-backdrop)]" returnFocusRef={safetyTriggerRef} portal > diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index d12fdac01..47d3c984a 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { type RefObject, useId, useState } from "react"; +import { type KeyboardEvent as ReactKeyboardEvent, type RefObject, useId, useRef, useState } from "react"; import { Activity, CircleAlert, @@ -626,6 +626,7 @@ export function ClinicalNotesChecklistPanel({ const tabs = clinicalNotesAvailableTabs(detailSections); const defaultTab = tabs.find((tab) => tab.id === "actions")?.id ?? tabs[0]?.id ?? "actions"; const [requestedTab, setRequestedTab] = useState(defaultTab); + const tabRefs = useRef(new Map()); const tabBaseId = useId(); const tabButtonId = (id: ClinicalNotesTabId) => `${tabBaseId}-tab-${id}`; const notesPanelId = `${tabBaseId}-panel`; @@ -644,6 +645,25 @@ export function ClinicalNotesChecklistPanel({ const showTabStrip = tabs.length > 1; + function handleTabKeyDown(event: ReactKeyboardEvent) { + const order = tabs.map((tab) => tab.id); + const index = order.indexOf(activeTab); + const next = + event.key === "ArrowRight" + ? order[(index + 1) % order.length] + : event.key === "ArrowLeft" + ? order[(index - 1 + order.length) % order.length] + : event.key === "Home" + ? order[0] + : event.key === "End" + ? order[order.length - 1] + : null; + if (!next) return; + event.preventDefault(); + if (next !== activeTab) setRequestedTab(next); + tabRefs.current.get(next)?.focus(); + } + return (
{showTabStrip ? ( @@ -651,6 +671,7 @@ export function ClinicalNotesChecklistPanel({
{ + if (element) tabRefs.current.set(tab.id, element); + else tabRefs.current.delete(tab.id); + }} type="button" role="tab" id={tabButtonId(tab.id)} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 4b84f5c53..dcec32318 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -6,7 +6,6 @@ import { ChevronDown, ChevronsRight, Copy, - Edit3, ExternalLink, FileText, Folder, @@ -76,20 +75,9 @@ type FavouriteSet = { meta?: string; }; -type SourceRecord = { - title: string; - type: string; -}; - const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; -const sourceRecords: SourceRecord[] = [ - { title: "NICE CKS - Alcohol dependence", type: "Guideline" }, - { title: "BNF - Acamprosate", type: "BNF" }, - { title: "Medsafe - Acamprosate data sheet", type: "Datasheet" }, -]; - const typeStyles: Record = { Medication: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", @@ -150,6 +138,42 @@ function isSourceBacked(item: FavouriteItem): boolean { return Boolean(item.evidence && item.evidence !== "Run" && item.evidence !== "Saved query"); } +function favouriteCitationText(item: FavouriteItem): string { + const evidenceLine = isSourceBacked(item) ? `Evidence: ${item.evidence}` : item.description; + return `${item.title}\n${evidenceLine}\n${item.href}`; +} + +async function copyFavouriteCitation(item: FavouriteItem): Promise { + const text = favouriteCitationText(item); + + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to the local selection-based copy path. + } + } + + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + textarea.remove(); + previouslyFocused?.focus({ preventScroll: true }); + } +} + function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem { const type = item.type === "sources" && item.primaryAction === "Run" @@ -328,7 +352,7 @@ function ActiveFilterChips({ ); } -function ContinueStrip({ item, onSelect }: { item: FavouriteItem; onSelect: (id: string) => void }) { +function ContinueStrip({ item }: { item: FavouriteItem }) { const Icon = item.icon; return (
- +
("idle"); const buttonRef = useRef(null); const menuRef = useRef(null); + const menuId = `favourite-actions-${item.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; + const triggerId = `${menuId}-trigger`; useDismissableLayer({ enabled: open, @@ -393,15 +417,60 @@ function RowActionsMenu({ item }: { item: FavouriteItem }) { const actionLabel = item.action === "Copy" ? "Open" : item.action; + function focusMenuItem(position: "first" | "last") { + window.requestAnimationFrame(() => { + const items = Array.from( + menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') ?? [], + ); + const target = position === "first" ? items[0] : items.at(-1); + target?.focus({ preventScroll: true }); + }); + } + + function openMenu(position: "first" | "last" = "first") { + setOpen(true); + setCopyStatus("idle"); + focusMenuItem(position); + } + + function handleMenuKeyDown(event: React.KeyboardEvent) { + const items = Array.from(menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') ?? []); + const currentIndex = items.findIndex((candidate) => candidate === document.activeElement); + + if (event.key === "Tab") { + setOpen(false); + return; + } + + let nextIndex: number | null = null; + if (event.key === "ArrowDown") nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length; + if (event.key === "ArrowUp") + nextIndex = currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length; + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = items.length - 1; + + if (nextIndex !== null && items[nextIndex]) { + event.preventDefault(); + items[nextIndex].focus({ preventScroll: true }); + } + } + return (
) : null} + + {copyStatus === "copied" + ? `${item.title} citation copied` + : copyStatus === "failed" + ? "Unable to copy citation" + : ""} +
); } -function FavouriteMobileCard({ - item, - selected, - onSelect, -}: { - item: FavouriteItem; - selected: boolean; - onSelect: (id: string) => void; -}) { +function FavouriteMobileCard({ item }: { item: FavouriteItem }) { return ( -
- + + + {item.title} + + + {item.description} + + {item.type} @@ -713,6 +774,7 @@ function FavouritesTable({
{tableRows.map((item) => ( - + ))} {tableRows.length === 0 ? (
@@ -776,12 +833,13 @@ function FavouritesTable({ function ItemWorkspace({ item, onClose }: { item: FavouriteItem; onClose: () => void }) { const [activeTab, setActiveTab] = useState<"summary" | "evidence" | "notes">("summary"); + const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle"); const Icon = item.icon; const actionLabel = item.action === "Copy" ? "Open" : item.action; return (
); diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index 8b34efc70..93efa33d1 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -1,18 +1,7 @@ "use client"; import Link from "next/link"; -import { - ArrowUpDown, - ChevronDown, - Filter, - Folder, - FolderInput, - Heart, - Plus, - Search, - ShieldCheck, - X, -} from "lucide-react"; +import { ArrowUpDown, ChevronDown, Filter, Folder, Heart, Plus, Search, ShieldCheck, X } from "lucide-react"; import { useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { ModeHomeHero, ModeHomeVerificationFooter } from "@/components/mode-home-template"; @@ -40,20 +29,24 @@ function favouriteMatchesQuery(value: { title: string; meta?: string; set?: stri export function FavouritesHub({ query, onClearQuery, - onAddFavourite, desktopComposerSlotId, + demoMode, headingLevel = 2, }: { query: string; onClearQuery: () => void; - onAddFavourite: () => void; desktopComposerSlotId?: string; + demoMode: boolean; headingLevel?: 1 | 2; }) { const savedRegistryFavourites = useSavedRegistryFavourites(); - const allFavouriteItems = useMemo(() => [...favouriteItems, ...savedRegistryFavourites], [savedRegistryFavourites]); + const allFavouriteItems = useMemo( + () => [...(demoMode ? favouriteItems : []), ...savedRegistryFavourites], + [demoMode, savedRegistryFavourites], + ); const allFavouriteSets = useMemo(() => { - const savedSetTitles = new Set(favouriteSets.map((set) => set.title)); + const prototypeSets = demoMode ? favouriteSets : []; + const savedSetTitles = new Set(prototypeSets.map((set) => set.title)); const dynamicSets: FavouriteSet[] = Array.from(new Set(savedRegistryFavourites.map((item) => item.set))) .filter((title): title is string => Boolean(title) && !savedSetTitles.has(title)) .map((title) => ({ @@ -67,13 +60,13 @@ export function FavouritesHub({ keywords: title.toLowerCase(), })); return [ - ...favouriteSets.map((set) => ({ + ...prototypeSets.map((set) => ({ ...set, count: allFavouriteItems.filter((item) => item.set === set.title).length, })), ...dynamicSets, ].filter((set) => set.count > 0); - }, [allFavouriteItems, savedRegistryFavourites]); + }, [allFavouriteItems, demoMode, savedRegistryFavourites]); const [selectedTab, setSelectedTab] = useState("all"); const [tabMenuOpen, setTabMenuOpen] = useState(false); const [selectedSetId, setSelectedSetId] = useState(null); @@ -312,19 +305,37 @@ export function FavouritesHub({ ) : null}
- + + Additional sort options are coming soon. + + + Adding favourites from this screen is coming soon. +
@@ -392,7 +403,7 @@ export function FavouritesHub({ {showItems ? visibleItems.map((item) => ( - setSelectedTab("sets")} /> + setSelectedTab("sets")} /> )) : null} @@ -443,12 +454,19 @@ export function FavouritesHub({ + + Creating favourite sets is coming soon. + @@ -458,7 +476,7 @@ export function FavouritesHub({ ); } -function FavouriteItemRow({ item, onMoveToSet }: { item: FavouriteItem; onMoveToSet: () => void }) { +function FavouriteItemRow({ item, onBrowseSets }: { item: FavouriteItem; onBrowseSets: () => void }) { const Icon = item.icon; return (
@@ -479,21 +497,18 @@ function FavouriteItemRow({ item, onMoveToSet }: { item: FavouriteItem; onMoveTo {item.primaryAction} - - +
); } diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 33dbc82c5..331812ca7 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -43,7 +43,7 @@ import { visibleAppModeDefinitions, type AppModeId, } from "@/lib/app-modes"; -import { isLocalNoAuthMode } from "@/lib/client-env"; +import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; @@ -316,11 +316,12 @@ function GlobalStandaloneSearchShellClient({ // Recent queries are owner-scoped session state (2026-07-13 audit, finding 4): // the legacy unscoped localStorage value could resurface another account's // clinical queries on a shared workstation, so it is deleted, never read. - const recentQueriesOwnerId = - auth.session?.user.id ?? - (!auth.isConfigured || process.env.NEXT_PUBLIC_DEMO_MODE === "true" || isLocalNoAuthMode() - ? demoRecentQueryOwnerId - : null); + const clientDemoMode = resolveClientDemoMode({ + explicitDemoMode: process.env.NEXT_PUBLIC_DEMO_MODE === "true", + authUnavailableFallback: !auth.isConfigured, + localNoAuthMode: isLocalNoAuthMode(), + }); + const recentQueriesOwnerId = auth.session?.user.id ?? (clientDemoMode ? demoRecentQueryOwnerId : null); useEffect(() => { clearLegacyRecentQueries(); @@ -459,7 +460,11 @@ function GlobalStandaloneSearchShellClient({ if (!chromeVisible) { return (
-
+
{children}
@@ -515,6 +520,7 @@ function GlobalStandaloneSearchShellClient({
("up"); const [commandDropdownOpen, setCommandDropdownOpen] = useState(false); const [commandListboxId, setCommandListboxId] = useState(); + const [commandActiveItemId, setCommandActiveItemId] = useState(null); const [modeMenuOpen, setModeMenuOpen] = useState(false); const [usesScopeSheet, setUsesScopeSheet] = useState(false); const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); @@ -1267,6 +1270,7 @@ export function MasterSearchHeader({ /> ) : null} onCommandScopesChange?.(scopes)} onListboxIdReady={setCommandListboxId} + onActiveItemIdChange={setCommandActiveItemId} onFocusSearchInput={() => queryInputRef?.current?.focus()} >
text.includes(token))) continue; - const score = set.title.toLowerCase().includes(normalized) ? 100 : 8; - byKey.set(`set:${set.id}`, { - id: `set:${set.id}`, - title: set.title, - subtitle: `${set.count} saved ${set.count === 1 ? "item" : "items"} · ${set.meta}`, - href: `/favourites?q=${encodeURIComponent(set.title)}&run=1`, - standalone: true, - score, - }); + if (includePrototypeSets) { + for (const set of favouriteSets) { + const text = [set.title, set.meta, set.keywords].join(" ").toLowerCase(); + if (!tokens.every((token) => text.includes(token))) continue; + const score = set.title.toLowerCase().includes(normalized) ? 100 : 8; + byKey.set(`set:${set.id}`, { + id: `set:${set.id}`, + title: set.title, + subtitle: `${set.count} saved ${set.count === 1 ? "item" : "items"} · ${set.meta}`, + href: `/favourites?q=${encodeURIComponent(set.title)}&run=1`, + standalone: true, + score, + }); + } } return [...byKey.values()].sort((left, right) => right.score - left.score || left.title.localeCompare(right.title)); @@ -340,6 +346,7 @@ function CommandDropdown({ } export function UniversalSearchCommandSurface({ + demoMode, modeId, query, recentQueries, @@ -355,9 +362,11 @@ export function UniversalSearchCommandSurface({ onInputKeyDown, onFocusSearchInput, onListboxIdReady, + onActiveItemIdChange, placement = "inline", children, }: { + demoMode: boolean; modeId: AppModeId; query: string; recentQueries: string[]; @@ -373,6 +382,7 @@ export function UniversalSearchCommandSurface({ onInputKeyDown?: (event: ReactKeyboardEvent) => void; onFocusSearchInput?: () => void; onListboxIdReady?: (listboxId: string) => void; + onActiveItemIdChange?: (activeItemId: string | null) => void; placement?: CommandSurfacePlacement; children: ReactNode; }) { @@ -420,10 +430,13 @@ export function UniversalSearchCommandSurface({ contextMode: modeId, }); const savedRegistryFavourites = useSavedRegistryFavourites(); - const allFavouriteItems = useMemo(() => [...favouriteItems, ...savedRegistryFavourites], [savedRegistryFavourites]); + const allFavouriteItems = useMemo( + () => [...(demoMode ? favouriteItems : []), ...savedRegistryFavourites], + [demoMode, savedRegistryFavourites], + ); const favouriteMatches = useMemo( - () => rankLocalFavourites(allFavouriteItems, trimmedQuery), - [allFavouriteItems, trimmedQuery], + () => rankLocalFavourites(allFavouriteItems, trimmedQuery, demoMode), + [allFavouriteItems, demoMode, trimmedQuery], ); const savedHrefs = useMemo(() => new Set(allFavouriteItems.map((item) => item.href)), [allFavouriteItems]); @@ -929,6 +942,10 @@ export function UniversalSearchCommandSurface({ onListboxIdReady?.(listboxId); }, [listboxId, onListboxIdReady]); + useEffect(() => { + onActiveItemIdChange?.(dropdownOpen ? activeItemId : null); + }, [activeItemId, dropdownOpen, onActiveItemIdChange]); + useEffect(() => { if (!dropdownOpen) return; diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 8f5deb79b..c6c48f9c4 100644 --- a/src/components/clinical-dashboard/visual-evidence.tsx +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { type KeyboardEvent, useId, useRef, useState } from "react"; import { CircleAlert, BookOpen, @@ -378,6 +378,8 @@ function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[ const claimRows = claimRowsForEvidencePanel(rows, renderModel); const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; + const claimRowClassName = + "grid min-h-[76px] grid-cols-[auto_auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-[color:var(--border)] px-3 py-3 text-left last:border-b-0"; if (!claimRows.length) { return ; @@ -409,29 +411,52 @@ function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[
- {claimRows.map((row, index) => ( - - - - - {row.section} - - {row.detail || row.bestLinkedPassage || row.bestSourceLabel} + {claimRows.map((row, index) => { + const content = ( + <> + + + + {row.section} + + {row.detail || row.bestLinkedPassage || row.bestSourceLabel} + - -
); @@ -495,9 +520,40 @@ export function MobileEvidenceSheetContent({ const order = evidenceTabOrder(answer, renderModel); const [selectedTab, setSelectedTab] = useState(() => initialTab ?? null); const activeTab = selectedTab && order.includes(selectedTab) ? selectedTab : order[0]; - const panelIdFor = (tab: EvidenceTabName) => `mobile-evidence-panel-${tab.toLowerCase()}`; + const tabSetId = useId(); + const tabRefs = useRef>>({}); + const tabIdFor = (tab: EvidenceTabName) => `${tabSetId}-mobile-evidence-tab-${tab.toLowerCase()}`; + const panelIdFor = (tab: EvidenceTabName) => `${tabSetId}-mobile-evidence-panel-${tab.toLowerCase()}`; const [added, setAdded] = useState(false); const primarySourceHref = renderModel.primarySources[0]?.href; + + function handleTabKeyDown(event: KeyboardEvent, tab: EvidenceTabName) { + const currentIndex = order.indexOf(tab); + let nextIndex: number; + + switch (event.key) { + case "ArrowRight": + nextIndex = (currentIndex + 1) % order.length; + break; + case "ArrowLeft": + nextIndex = (currentIndex - 1 + order.length) % order.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = order.length - 1; + break; + default: + return; + } + + event.preventDefault(); + const nextTab = order[nextIndex]; + setSelectedTab(nextTab); + tabRefs.current[nextTab]?.focus(); + } + async function copyEvidence() { if (renderModel.quoteCards.length) { onCopyQuotes(); @@ -534,11 +590,16 @@ export function MobileEvidenceSheetContent({ key={tab} type="button" role="tab" + ref={(node) => { + tabRefs.current[tab] = node; + }} aria-selected={selected} aria-controls={panelIdFor(tab)} - id={`mobile-evidence-tab-${tab.toLowerCase()}`} + id={tabIdFor(tab)} + tabIndex={selected ? 0 : -1} data-testid={`mobile-evidence-tab-${tab.toLowerCase()}`} onClick={() => setSelectedTab(tab)} + onKeyDown={(event) => handleTabKeyDown(event, tab)} className={cn( "inline-flex min-h-11 items-center gap-1.5 rounded-md border px-3 text-xs font-semibold transition", selected @@ -563,7 +624,7 @@ export function MobileEvidenceSheetContent({ key={tab} id={panelIdFor(tab)} role="tabpanel" - aria-labelledby={`mobile-evidence-tab-${tab.toLowerCase()}`} + aria-labelledby={tabIdFor(tab)} data-testid={`mobile-evidence-panel-${tab.toLowerCase()}`} hidden={!selected} className="min-h-[220px]" diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx index 0bb0aa870..001b4f76a 100644 --- a/src/components/differentials/differential-presentation-workflow-page.tsx +++ b/src/components/differentials/differential-presentation-workflow-page.tsx @@ -192,7 +192,9 @@ function DesktopComparisonTable({
diff --git a/src/components/differentials/differential-stream-page.tsx b/src/components/differentials/differential-stream-page.tsx index 6d93e25c1..6914cab7d 100644 --- a/src/components/differentials/differential-stream-page.tsx +++ b/src/components/differentials/differential-stream-page.tsx @@ -68,8 +68,8 @@ export function DifferentialStreamPage({ stream, query = "" }: DifferentialStrea

{card.title}

{card.description}

    - {card.examples.map((example) => ( -
  • + {card.examples.map((example, index) => ( +
  • {example}
  • diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index e2f321327..3799bc1fc 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -494,10 +494,6 @@ export function FormDetailPage({ form }: { form: FormRecord }) { const relatedTags = useMemo(() => [...(form.tags ?? []), ...(form.catchments ?? [])].slice(0, 8), [form]); function goBack() { - if (window.history.length > 1) { - router.back(); - return; - } router.push(appModeHomeHref("forms", { focus: true })); } diff --git a/src/components/route-error-boundary.tsx b/src/components/route-error-boundary.tsx index 6d31de18e..c905644e9 100644 --- a/src/components/route-error-boundary.tsx +++ b/src/components/route-error-boundary.tsx @@ -41,7 +41,7 @@ export function RouteErrorBoundary({ const headingRef = useRef(null); useEffect(() => { console.error(logLabel, error); - headingRef.current?.focus(); + headingRef.current?.focus({ preventScroll: true }); }, [error, logLabel]); return ( @@ -64,7 +64,9 @@ export function RouteErrorBoundary({ {title} -

    {description}

    +

    + {description} +

    {error.digest && (
    diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 0459da34e..e664497bb 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -332,6 +332,7 @@ function RightRail({ type="button" onClick={clearSelectedServices} disabled={selected.length === 0} + title={selected.length === 0 ? "No selected services to clear" : "Clear selected services"} > Clear diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index 8b9491e17..eafee0256 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -229,7 +229,6 @@ export function TcProvider({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const { data, loading, error, retry } = useTherapyData(); const therapies = useMemo(() => data?.therapies ?? [], [data]); const pathways = useMemo(() => data?.pathways ?? [], [data]); diff --git a/src/components/therapy-compass/data/use-therapy-data.ts b/src/components/therapy-compass/data/use-therapy-data.ts index f04d4a702..1b83416d4 100644 --- a/src/components/therapy-compass/data/use-therapy-data.ts +++ b/src/components/therapy-compass/data/use-therapy-data.ts @@ -13,6 +13,11 @@ const BASE = "/therapy-compass-data"; // shared across every screen. let cache: Promise | null = null; +/** Test helper: drop the session-scoped dataset cache between cases. */ +export function clearTherapyDataCache() { + cache = null; +} + async function fetchJson(path: string): Promise { const res = await fetch(path); if (!res.ok) throw new Error(`Failed to load ${path}: ${res.status}`); diff --git a/src/components/therapy-compass/screens/brief-screen.tsx b/src/components/therapy-compass/screens/brief-screen.tsx index e3ac9a866..3b2e1b3a8 100644 --- a/src/components/therapy-compass/screens/brief-screen.tsx +++ b/src/components/therapy-compass/screens/brief-screen.tsx @@ -75,7 +75,7 @@ export function BriefScreen() { Fast scripts and steps drawn from each record’s delivery fields.

    -
    +
    -
    +
    {/* decision summary */}
    {/* tabs */} -
    +
    diff --git a/src/components/therapy-compass/screens/detail-screen.tsx b/src/components/therapy-compass/screens/detail-screen.tsx index 04a479bfa..33788ada5 100644 --- a/src/components/therapy-compass/screens/detail-screen.tsx +++ b/src/components/therapy-compass/screens/detail-screen.tsx @@ -96,7 +96,7 @@ export function DetailScreen() {
    {/* QUICK TILES */} -
    +
    {/* RIGHT RAIL */} -
    +
    At a glance diff --git a/src/components/therapy-compass/screens/home-screen.tsx b/src/components/therapy-compass/screens/home-screen.tsx index 066495cef..95b4b26a6 100644 --- a/src/components/therapy-compass/screens/home-screen.tsx +++ b/src/components/therapy-compass/screens/home-screen.tsx @@ -6,6 +6,7 @@ import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/co import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { useTcBindings } from "../bindings"; +import { LoadingState } from "../ui"; const SUGGESTIONS = [ "Anxiety in outpatient care", @@ -18,12 +19,22 @@ const SUGGESTIONS = [ export function HomeScreen() { const b = useTcBindings(); + // Avoid presenting an empty or fabricated catalogue while required data loads. + if (b.loading && b.therapies.length === 0) { + return ; + } + + const therapyCountCopy = + b.therapies.length === 0 + ? "Search source-grounded therapy records by problem, symptom, skill or population — or jump into a clinical pathway." + : `Search ${b.therapies.length} source-grounded therapy ${b.therapies.length === 1 ? "record" : "records"} by problem, symptom, skill or population — or jump into a clinical pathway.`; + return (
    -
    +
    -
    +