diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b4ebad545..eaddabdf4 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1285,6 +1285,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | agent/document-topbar-actions (PR #1381) | 9da8ccfb19ff81b876a9bfff4e6b5870641e44d8 | PR #1381 CI triage | merged via squash auto-merge after main sync; all required checks green; no product code fix; no Bugbot/review threads | hosted CI pr-required pass; Production UI pass; CircleCI pass; lint; typecheck; document-viewer-shell.dom; Bugbot none | | 2026-07-29 | codex/chat-document-header-overlay-document-header-overlay-20260729 | 48ed6cc95f886837f4ddbb369fdbc611a0958f17 | document phone header overlay | No high-confidence findings; physical iPhone acceptance remains | verify:pr-local unit 4373 pass; build PASS; focused Playwright 2 pass; phone gate contended | | 2026-07-29 | codex/document-reader-condensed-view | 5678e878d4fe681d33bb58df5b5b3468a138a1c8 | pr-1380-ci-green-resync | hosted CI green on 7150899a (Static/Build/Unit/Advisory/Production UI/PR required/CircleCI); CodeRabbit density fallback + summary keys + search/plain compact tests landed; unresolved review threads none; resynced main after tip went BEHIND by 1 | hosted CI success on 7150899a; merge-tree clean; bugbot no P0/P1 | +| 2026-07-29 | claude/design-system-followups-1375 | 906c5a1cfc0d10c6788002825401481844366d2a | PR #1375 follow-ups: repoint five dead text-4xs classes onto the 10px floor plus an orphan guard, fix six hydration races at source (composer fill, mode menu, openGuide, differential submit, overlap geometry), document the intermediate-weight and leading idioms, ledger #108/#109 | PR #1391 opened; auto-merge off pending user review | verify:pr-local 426/426 files 4381 tests on lock-matched deps; 18/18 targeted Chromium under playwright 1.62.0; ui-overlap 14 passed x4 runs; contract 37 assertions | | 2026-07-29 | cursor/recent-pr-bugfixes-f30d | 2c424d9980acab6a2b982d52920c946de21b18d0 | pr-1374-merge-main-staleness | merged origin/main; merge-tree clean; GitHub DIRTY was staleness | merge-tree-clean; push | | 2026-07-29 | cursor/recent-pr-bugfixes-f30d | 1d2669858a3a53844938b521a7f6de572a60c712 | PR review | P1 found: mounted DocumentViewer signed URLs not cleared on auth change | git diff; review threads; actions list/logs; targeted test blocked (missing node_modules/vitest) | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 71db10c41de872fca6e400626704a549f145c66c | latency audit implementation (PR #1377) | SUPERSEDES the 78e2beb record: its 'scope-vs-ratelimit overlap with abort' description is stale and describes behaviour that was REVERTED. Codex review raised it P1 and it was correct — an AbortSignal cannot un-execute a statement Postgres already began, and resolveSearchScope only skips the database when there are no filters and no explicit ids (search-scope.ts:242,253), so a throttled caller kept spending DB capacity while collecting 429s. Shipped behaviour is rate-limit admission BEFORE scope evaluation, with request.signal threaded so a client disconnect still cancels scope's paginated queries, pinned by tests/answer-route-preamble.test.ts. Also retracted on this head: the L2-3 'recall is byte-identical' claim, since fetchDocumentTitleAliasRows applies .limit(12) with no ORDER BY. | verify:cheap exit 0 (423 files, 4278 passed/4 skipped); check:branch-review-ledger pass; focused preamble + server-timing suites pass | @@ -1300,3 +1301,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | cursor/page-anchored-search-composer-30ee | 7ff134ca7f614db527b8d142676640305533669d | branch-cleanup-deletion-pending | DELETION PENDING — content proven fully on main. Merge-base with main is 79d1c879 and tree(merge-base) equals tree(tip): git diff --name-only 79d1c879 7ff134ca reports 0 files, so the tip introduces nothing beyond a state already in main. Backs no open PR. Deletion blocked by HTTP 403; HEAD recorded here so the later branch-cleanup row can be keyed to it after the ref is gone. Supersedes the aggregate main-keyed row at 855aa291, which recorded no candidate HEADs. | local git only for the content proof: git merge-base, git diff --name-only (0 files), git diff --name-only origin/main... (0 files), full history after git fetch --unshallow. PROVIDER-BACKED evidence obtained (GitHub reads, authorised repo-scope): open-PR head cross-check via GitHub API against PRs #1374/#1377/#1384/#1385/#1386/#1387, and GitHub MCP capability inspection. PROVIDER-BACKED MUTATION ATTEMPTED AND REJECTED: git push origin --delete returned HTTP 403; no branch was deleted. No OpenAI/Supabase/hosted-CI calls. | | 2026-07-29 | cursor/pr-1379-babysit-ledger-9365 | be2de03f855cb7fdfccea4bb74d05eb4c9bf6c61 | branch-cleanup-deletion-pending | DELETION PENDING — content proven fully on main. Merge-base with main is b2740480 and tree(merge-base) equals tree(tip): git diff --name-only b2740480 be2de03f reports 0 files, so the tip introduces nothing beyond a state already in main. Backs no open PR. Deletion blocked by HTTP 403; HEAD recorded here so the later branch-cleanup row can be keyed to it after the ref is gone. Supersedes the aggregate main-keyed row at 855aa291, which recorded no candidate HEADs. | local git only for the content proof: git merge-base, git diff --name-only (0 files), git diff --name-only origin/main... (0 files), full history after git fetch --unshallow. PROVIDER-BACKED evidence obtained (GitHub reads, authorised repo-scope): open-PR head cross-check via GitHub API against PRs #1374/#1377/#1384/#1385/#1386/#1387, and GitHub MCP capability inspection. PROVIDER-BACKED MUTATION ATTEMPTED AND REJECTED: git push origin --delete returned HTTP 403; no branch was deleted. No OpenAI/Supabase/hosted-CI calls. | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 9e2ee65ca0bcce45a3cb6a0539e265ec8d961582 | PR #1377 latency findings — #098 stale offline-harness references | Codex P2 confirmed and fixed: the #098 row in docs/outstanding-issues.md still named test-cache-path.mjs and check-rag-fixtures.mjs as the offline fixtures for the round-trip counting harness. Neither exercises a RAG request (cache paths; fixture-manifest validation), so a harness built on them would count nothing. The audit doc carried the retraction at :358 but this row did not - the same local-retraction pattern flagged in two prior rounds. Now names eval-rag-offline.mjs, test-rag-offline.mjs, rag-offline-contract.mjs and the contract fixture, all verified present, with the correction recorded inline. Docs only. | prettier --check clean; docs:check-links 1363; docs:check-scripts 390; grep confirms no stale refs remain | +| 2026-07-29 | 1374 | c14edb9c6f0bdbbfb147752503e016f2543fd803 | PR #1374 review + merge | merged as 3704007c — DocumentViewer identity-bound state clear (P1) implemented and verified red without it; all 12 review threads resolved | verify:cheap exit 0 (429 files / 4403 tests); PR required success; Production UI success | diff --git a/docs/design-system.md b/docs/design-system.md index f6ec0fb02..636d60830 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -73,6 +73,18 @@ line-height/tracking — set `leading-*`/`tracking-*` at the call site): - **10px is the floor.** An 8px `text-4xs` step existed and is retired — indefensible at any density in a clinical product. Do not reintroduce a sub-10px step. +- **Leading** uses Tailwind's own steps plus two named additions for the cases they cannot + express: `leading-display` (1.05) for large display headings and `leading-prose` (1.6) for + the `max-w-[68ch]` body measure. Arbitrary `leading-[…]` is at zero in production and + `tests/design-token-contract.test.ts` keeps it there. **Never redefine `--leading-tight` / + `-snug` / `-normal` / `-relaxed`** — those are Tailwind theme names, and shadowing one + silently retunes every existing `leading-tight` / `leading-snug` call site. The same test + fails if they reappear in `:root` or `@theme`. +- **Intermediate font weights are deliberate, not drift.** Geist is a variable face, so + `520` / `540` / `560` / `580` / `640` / `650` / `680` interpolate rather than snapping, and + the band/panel treatments in `globals.css` and Therapy Compass use them on purpose. Do + **not** "normalise" them onto 600/700 — that was attempted on 2026-07-28 and reverted. Weight + is an expressive axis here; only flag a weight that is genuinely arbitrary and unexplained. - Arbitrary `text-[Npx]` is **banned**; `npm run check:type-scale` counts offenders. **Ratchet:** the count must never rise (baseline recorded in `docs/process-hardening.md`). When it reaches 0, wire `check:type-scale --strict` into diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index f38ecc4ae..07c77de0a 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -81,7 +81,7 @@ removed after current-main verification; it is not missing recommended work. | 33 | `#103` | A3 | Operator — Supabase schema | Same window as `#102` | 30–60 minutes | Confirm whether the wide `document_table_facts` trigram index from `20260714190000` exists live, then either mirror it into `schema.sql` (retained) or drop it via a forward migration (redundant). **Not the allowlist** — it suppresses live-vs-`schema.sql` findings only and cannot make the migration chain and the mirror agree. Stop: do not drop it without live scan evidence. | | 34 | `#105` | Optional | High — browser/UI verification | When the heavy-run lock is free | 20–40 minutes | Run `verify:ui` over the ten `LoadingPanel` fallbacks and confirm the Supabase `preconnect` reaches `` on a live page. Implementation already shipped; this row is the outstanding verification only. | - + ## Open items @@ -148,6 +148,7 @@ removed after current-main verification; it is not missing recommended work. | #107 | P2 | rec | Component state matrices are the largest untested surface | **Outcome:** loading / empty / error / disabled states on interactive components are covered by executing tests, not only by E2E happy paths. **Detail:** measured 2026-07-29 — production components (excluding mockups) sit at **38.2% lines / 22.8% branch** across 12,602 lines, with **83 of 208 files at zero executed lines**; there are 51 `.dom.test.tsx` files against 195 components. Playwright does visit these routes, so they are smoke-covered, but branch coverage is where the state matrix lives and smoke journeys rarely reach it. Worst by uncovered lines: `global-search-shell.tsx` (7%), `mode-action-popup.tsx` (21%), `answer-content.tsx` (27%), `document-search-results.tsx` (32%), `universal-search-command-surface.tsx` (39%), `master-search-header.tsx` (43%). A concrete first target with clinical meaning: `calculator-ui.tsx` now covers all exported scoring logic, but `seedCheckboxDefaults`, `toggleCheckboxAnswer` and `selectOptionAnswer` stay uncovered because they are module-private and only reachable through React event handlers — `seedCheckboxDefaults` is what makes an all-negative CAGE / SAD PERSONS screen read as a valid 0 rather than incomplete, so a regression there is a false-negative risk. **Next:** treat as a per-PR convention rather than a backfill push — `docs/testing.md` already prescribes the state matrix, so the gap is enforcement. Start with `global-search-shell.tsx`, which `docs/search-chrome-behaviour.md` treats as a contract surface. Keep additions in the jsdom tier (measured ~0.54s per file) instead of new Playwright journeys (~231 production journeys already run serially at `workers: 1` against a 45-minute CI budget). **Stop:** do not chase the coverage percentage by backfilling low-risk components; the re-ratcheted broad floor in `vitest.config.mts` holds the line. | session 2026-07-29 test-coverage analysis | 2026-07-29 | | #108 | P3 | task | Five verified-landed remote branches await deletion (blocked in-session) | **Outcome:** the five branches whose content is fully on `main` are gone. **Detail:** a full-history branch-cleanup review on 2026-07-29 verified these introduce an empty diff against `main` and back no open PR: `claude/clinical-kb-pwa-review-asi3wb` @ `df29f311b60cadf8e43bf51283a9d6f496b295e3`, `claude/dazzling-blackwell-f348d0` @ `c9bec8f9dce38cb647de9aa64ebf08bf7823a524`, `codex/document-reader-condensed-view` @ `b5cdbf301d517239ffe9ed941b9ebe809aea0bfd`, `cursor/page-anchored-search-composer-30ee` @ `7ff134ca7f614db527b8d142676640305533669d`, `cursor/pr-1379-babysit-ledger-9365` @ `be2de03f855cb7fdfccea4bb74d05eb4c9bf6c61`. **The HEADs are recorded because they are unrecoverable once the refs are deleted:** `hasCompletedCleanupReview` (`scripts/sweep-branch-ledger.mjs:83-93`) matches a completed row on branch name AND HEAD together, so without them no later operator could ever append the required `branch-cleanup` rows. Each candidate now also has its own `branch-cleanup-deletion-pending` ledger row keyed to its own HEAD. Deletion could not be performed: the session git proxy rejects ref deletion with **HTTP 403**, and the GitHub MCP toolset exposes no delete-branch capability. The remaining 87 were deliberately NOT cleared — their touched files still differ from `main`, which is the conservative direction. **Next — ORDER MATTERS:** append the completed `branch-cleanup` row for each branch FIRST, from a checkout that still has the objects, and only then delete the refs. `resolveHead` (`scripts/branch-review-ledger.mjs:155-167`) runs `git rev-parse --verify ^{commit}` and refuses to append a HEAD that is not a commit in the repository, so the reverse order is unexecutable once the refs are gone and their objects are pruned. The `n/a - ` escape hatch does not help here: `hasCompletedCleanupReview` only matches a 7-40 char hex HEAD, so an `n/a` row would leave the branch resurfacing in every future sweep. Delete the five from the GitHub UI or an interactive session once their rows are recorded (the existing row is `branch-cleanup-deletion-pending`, which by design does not count as complete). **Stop:** do not widen to the other 87 without per-branch content proof. | session 2026-07-29 branch cleanup; ledger `branch-cleanup-deletion-pending` @ 855aa291 | 2026-07-29 | | #109 | P2 | issue | Remote sessions clone shallow, silently invalidating all branch/merge analysis | **Outcome:** no session draws branch conclusions from a truncated history. **Detail:** on 2026-07-29 this repo's remote session had `git rev-parse --is-shallow-repository` = **true** with only **74** commits of `origin/main` (full history is 2829). Every merge-base, `--cherry-pick`, and ahead/behind number computed in that state was wrong: local `main` reported `ahead 52` and `refusing to merge unrelated histories` (it is actually 0 ahead with a shared base), and an all-branch sweep wrongly showed **90 of 91** branches as carrying unmerged work. Acting on that would have meant either deleting live branches or abandoning cleanup entirely. `git fetch --unshallow` corrected both. **Next:** make `is-shallow-repository` an explicit precondition check in `docs/branch-cleanup-guide.md` §Safety Rules and in `scripts/sweep-branch-ledger.mjs`, failing closed with the `--unshallow` remedy rather than silently reporting. **Stop:** never delete a branch, or report a branch as unmerged, from a shallow clone. | session 2026-07-29; `docs/branch-cleanup-guide.md`; `scripts/sweep-branch-ledger.mjs` | 2026-07-29 | +| #110 | P3 | task | Design-system project token manifest lags its stylesheet | **Outcome:** the claude.ai/design token panel matches the shipped stylesheet. **Detail:** PR #1375 pushed a recompiled `_ds_bundle.css` (Clinical Sky, `--e0`–`--e4`, 4px radius grid, `--tracking-eyebrow`/`--leading-display`/`--leading-prose`) plus the four changed guideline docs to project `08d6f126`, but `_ds_manifest.json` is converter-generated and still advertises `--text-4xs: 0.5rem`, the old `--radius-lg/xl/2xl` values, and `--tw-leading`/`--tw-tracking` entries scoped to the retired `.leading-[…]` / `.tracking-[0.08em]` utilities. Rendering is correct; only the token inventory lags. Hand-editing was rejected — `kind`/`scope`/`annotation` are converter heuristics and a wrong panel is worse than a stale one. **Next:** in a session with the `/design-sync` skill, `npm ci`, then `npm install --prefix .ds-sync --no-save --package-lock=false esbuild ts-morph @types/react @tailwindcss/cli geist`, read `.design-sync/NOTES.md`, and run `resync.mjs --remote` so bundle and manifest regenerate together. **Stop:** do not hand-author `_ds_manifest.json`; the converter is not a published npm package and ships with the skill. | PR #1375; `.design-sync/NOTES.md`; project `08d6f126` (`_ds_needs_recompile` marker present) | 2026-07-29 | ## Resolved / archive @@ -157,6 +158,7 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | ---- | ----- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | #087 | issue | `npm run check:knip` reported pre-existing dependency findings | NOT A DEFECT — false positive. The findings (unused `rimraf`/`tsx`/`@testing-library/dom`, unlisted `playwright-core`) only appeared because the worktree had no `node_modules` of its own and tooling resolved from the parent checkout. After `npm ci` in the same worktree, `npm run check:knip` exits 0 with no ignore-list change. Durable lesson: never act on a knip finding from a worktree that has not been installed. | 2026-07-28 | | #089 | issue | Branch-review-ledger hygiene deferred from PR #1275 | Closed by the 2026-07-28 hygiene pass. The MD056 cell-count and duplicate-row findings dispositioned as "belongs in a dedicated main ledger hygiene pass" on PR #1275 are fixed: 140 mojibake lines restored byte-exact from git history, 6 residual separators repaired, 46 exact duplicates removed, 21 wrong-width rows normalised, and 4 heading+bullet records converted to table rows — 1067 records, all six cells. Root cause closed too: `npm run ledger:lookup` / `ledger:append` (`scripts/branch-review-ledger.mjs`) replace hand-written rows, and `check:branch-review-ledger` now fails on mojibake, cell width, heading records, impossible dates, table gaps, and (from 2026-07-29) unresolvable HEADs and near-duplicates. | 2026-07-28 | +| #111 | issue | `ui-overlap` phone-inset case still flakes under load | RESOLVED 2026-07-29 (PR #1391). The inset measurement now retries inside `expect(async () => …).toPass({ timeout: 15_000 })` (`ui-overlap.spec.ts:159`) — the same retry-the-measurement shape PR #1375 applied to the eight overlap cases. The 2px symmetry tolerance and the assertions themselves are byte-for-byte unchanged, so a genuinely asymmetric header still fails once the retry budget is spent; only a transient mid-remount sample settles. Full spec green across 4 consecutive runs (14 passed each). Do not relax the tolerance if this recurs — the assertion is the point; make the measurement robust instead. Source: PR #1375 flake triage; session 2026-07-29 | | #012 | rec | Slim the lazy cross-mode differentials chunk | Precomputed a trimmed index (`src/data/cross-mode-differentials-index.json` via `scripts/build-cross-mode-differentials-index.mjs`) so the lazily-loaded cross-mode chunk imports a ~53 KB catalog instead of statically pulling the ~1.2 MB differentials snapshot (only that dynamic path reached it). A drift test plus `check:cross-mode-index` (in verify:cheap) lock the index to the live projection. | 2026-07-27 | | #029 | issue | Residual answer-quality fallback stubs | Closed after fixing each causal cluster independently. Active-community ED, community-home-visit, clozapine blood-threshold/typo, discharge source-gap recovery, and Best Practice Prescription now use narrowly validated, source-bound answers or auditable recovery; cited provider refusal prose can no longer masquerade as grounded, and terminal gaps retain no claim citations. The final 44-case gate reported 30/30 substantive grounded supported answers, 14/14 unsupported correct, zero review fallbacks, zero citation/numeric failures, and zero route-ceiling failures. Measurement still reports review fallback separately and denies targeting credit for echoed boilerplate. | 2026-07-27 | diff --git a/src/app/mockups/phone-inpage-navigation/page.tsx b/src/app/mockups/phone-inpage-navigation/page.tsx index 1e13fe63d..e3fdfa1ce 100644 --- a/src/app/mockups/phone-inpage-navigation/page.tsx +++ b/src/app/mockups/phone-inpage-navigation/page.tsx @@ -368,7 +368,7 @@ function PriorityDock() { setMore(false); }} aria-current={active === label ? "page" : undefined} - className={`flex min-h-[52px] flex-col items-center justify-center gap-1 rounded-xl text-4xs font-semibold ${active === label ? "bg-[#173b3e] text-[#6de1e4]" : "text-[#8f9997]"}`} + className={`flex min-h-[52px] flex-col items-center justify-center gap-1 rounded-xl text-3xs font-semibold ${active === label ? "bg-[#173b3e] text-[#6de1e4]" : "text-[#8f9997]"}`} > {label === "Why matched" ? "Matched" : label} @@ -379,7 +379,7 @@ function PriorityDock() { onClick={() => setMore((value) => !value)} aria-expanded={more} aria-current={overflowActive ? "page" : undefined} - className={`flex min-h-[52px] flex-col items-center justify-center gap-1 rounded-xl text-4xs font-semibold ${more || overflowActive ? "bg-[#173b3e] text-[#6de1e4]" : "text-[#8f9997]"}`} + className={`flex min-h-[52px] flex-col items-center justify-center gap-1 rounded-xl text-3xs font-semibold ${more || overflowActive ? "bg-[#173b3e] text-[#6de1e4]" : "text-[#8f9997]"}`} > More diff --git a/src/components/therapy-navigation-mockups/rail.tsx b/src/components/therapy-navigation-mockups/rail.tsx index b2e50d2a0..e2514ee5a 100644 --- a/src/components/therapy-navigation-mockups/rail.tsx +++ b/src/components/therapy-navigation-mockups/rail.tsx @@ -91,7 +91,7 @@ function RailRow({ {badge ? ( @@ -349,13 +349,13 @@ function PhoneTabBar({ activeId, sheetOpen, sheetId }: { activeId: string; sheet {tab.badge ? ( ) : null} - {tab.label} + {tab.label} ); })} diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts index 1c98d7bc7..4fa0c9dc3 100644 --- a/tests/design-token-contract.test.ts +++ b/tests/design-token-contract.test.ts @@ -261,6 +261,32 @@ describe("radius ladder", () => { }); describe("type scale floor", () => { + it("leaves no orphaned utility for the retired step", () => { + // Retiring a --text-* token silently kills its utility: Tailwind stops + // emitting the rule, every `text-4xs` class becomes a no-op, and the text + // falls back to inherited size with nothing failing. Two files carrying + // `text-4xs` landed from main while this port was in flight and were dead on + // arrival. Mockups are NOT exempt here — a dead class breaks them too. + const tracked = execFileSync("git", ["ls-files", "src"], { encoding: "utf8" }) + .split("\n") + .filter((file) => /\.(tsx?|css)$/.test(file)); + + const orphans = tracked.flatMap((file) => { + const source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); + return ( + source + .split(/\r?\n/) + .map((line, index) => ({ line, number: index + 1 })) + // Skip comment lines: the retirement is documented by name in a few places. + .filter(({ line }) => !/^\s*(\/\/|\/\*|\*)/.test(line)) + .filter(({ line }) => /\btext-4xs\b/.test(line)) + .map(({ number }) => `${file}:${number}`) + ); + }); + + expect(orphans, "text-4xs is retired — Tailwind emits no such rule").toEqual([]); + }); + it("has no sub-10px step", () => { // The 8px --text-4xs step is retired: indefensible at any density in a // clinical product. diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 148d99b14..796de5098 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -448,8 +448,17 @@ test.describe("Clinical KB accessibility coverage", () => { await mockDifferentialSearch(page); await gotoApp(page, "/differentials"); - await page.locator('input[placeholder="Ask or search a presentation"]:visible').first().fill("acute confusion"); - await page.locator('button[aria-label="Search differential presentations"]:visible').click(); + // Retry fill-then-enabled together: the server-rendered composer is visible + // before React controls it, and a fill landing in that gap is discarded by + // hydration, leaving the search button disabled and the click a no-op. + const presentationInput = page.locator('input[placeholder="Ask or search a presentation"]:visible').first(); + const differentialSubmit = page.locator('button[aria-label="Search differential presentations"]:visible'); + await expect(async () => { + await presentationInput.fill("acute confusion"); + await expect(presentationInput).toHaveValue("acute confusion"); + await expect(differentialSubmit).toBeEnabled({ timeout: 2_000 }); + }).toPass({ timeout: 30_000 }); + await differentialSubmit.click(); await expect(page.getByTestId("differentials-search-results")).toBeVisible(); const filterSelect = page.getByTestId("differential-result-type-select"); diff --git a/tests/ui-overlap.spec.ts b/tests/ui-overlap.spec.ts index 39c8ba5ab..941157a36 100644 --- a/tests/ui-overlap.spec.ts +++ b/tests/ui-overlap.spec.ts @@ -156,20 +156,28 @@ test.describe("Header element overlap coverage", () => { // Headless Chromium reports env(safe-area-inset-*) as 0, so this asserts // the --header-edge-pad (1rem) chrome inset — not notch asymmetry. - const menuBox = await menu.boundingBox(); - const newChatBox = await newChat.boundingBox(); - expect(menuBox, "menu control must have geometry").not.toBeNull(); - expect(newChatBox, "new-chat control must have geometry").not.toBeNull(); + // + // Sample the geometry inside a retry: a React remount can leave the header + // mid-layout, and a single sample then reads transient boxes. The + // assertions themselves are unchanged and still strict, so a genuinely + // asymmetric header fails once the retry budget is spent — only a + // transient one settles. + await expect(async () => { + const menuBox = await menu.boundingBox(); + const newChatBox = await newChat.boundingBox(); + expect(menuBox, "menu control must have geometry").not.toBeNull(); + expect(newChatBox, "new-chat control must have geometry").not.toBeNull(); - const leftInset = menuBox!.x; - const rightInset = viewport.width - (newChatBox!.x + newChatBox!.width); - // 1rem header pad (~16px) with 2px subpixel tolerance. - expect(leftInset, "left menu inset should be at least ~1rem").toBeGreaterThanOrEqual(14); - expect(rightInset, "right new-chat inset should be at least ~1rem").toBeGreaterThanOrEqual(14); - expect( - Math.abs(leftInset - rightInset), - `left/right insets should match (left=${leftInset}, right=${rightInset})`, - ).toBeLessThanOrEqual(2); + const leftInset = menuBox!.x; + const rightInset = viewport.width - (newChatBox!.x + newChatBox!.width); + // 1rem header pad (~16px) with 2px subpixel tolerance. + expect(leftInset, "left menu inset should be at least ~1rem").toBeGreaterThanOrEqual(14); + expect(rightInset, "right new-chat inset should be at least ~1rem").toBeGreaterThanOrEqual(14); + expect( + Math.abs(leftInset - rightInset), + `left/right insets should match (left=${leftInset}, right=${rightInset})`, + ).toBeLessThanOrEqual(2); + }).toPass({ timeout: 15_000 }); }); } diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 1461c2598..aedb59cea 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -833,21 +833,41 @@ async function openGuide(page: Page) { // Guide now lives inside Settings. If Settings is already open (e.g. after // closing Guide restores it), skip the reopen click that would hit the overlay. if (!(await settings.isVisible().catch(() => false))) { - if (viewport && viewport.width < 768) { - const menu = await openMobileClinicalGuideMenu(page); - await menu.getByRole("button", { name: "Settings", exact: true }).click(); - } else if (viewport && viewport.width < 1024) { - const rail = page.getByLabel("Clinical Guide collapsed sidebar"); - await expect(rail.getByRole("button", { name: "Settings", exact: true })).toBeVisible(); - await rail.getByRole("button", { name: "Settings", exact: true }).click(); - } else { - const sidebar = page.locator("#clinical-tools-sidebar"); - const settingsTrigger = (await sidebar.isVisible().catch(() => false)) - ? sidebar.getByRole("button", { name: "Settings", exact: true }) - : page.getByLabel("Clinical Guide collapsed sidebar").getByRole("button", { name: "Settings", exact: true }); - await expect(settingsTrigger).toBeVisible(); - await settingsTrigger.click(); - } + // A Settings trigger becomes visible before React attaches its handler, so a + // single click is silently swallowed and the dialog never opens. Retry the + // click together with the dialog it should produce, rather than asserting + // visibility once — the same shape used for the composer and mode menu. + await expect(async () => { + // Idempotent by construction. If the dialog opened just after the inner + // assertion's own deadline expired, `toPass` still schedules another + // attempt; without this the attempt clicks a trigger the modal is already + // covering (or reopens the phone menu on top of it) and the helper times + // out under exactly the load it exists to tolerate. + if (await settings.isVisible().catch(() => false)) return; + if (viewport && viewport.width < 768) { + // The swallowed click leaves the phone menu OPEN, so a retry that always + // reopens would toggle it shut and then fail to find Settings inside it. + // Reuse the open menu; only summon one when there is none. + const openMenu = page.getByRole("dialog", { name: "Clinical Guide" }); + const menu = (await openMenu.isVisible().catch(() => false)) + ? openMenu + : await openMobileClinicalGuideMenu(page); + await menu.getByRole("button", { name: "Settings", exact: true }).click(); + } else if (viewport && viewport.width < 1024) { + const rail = page.getByLabel("Clinical Guide collapsed sidebar"); + const railSettings = rail.getByRole("button", { name: "Settings", exact: true }); + await expect(railSettings).toBeVisible(); + await railSettings.click(); + } else { + const sidebar = page.locator("#clinical-tools-sidebar"); + const settingsTrigger = (await sidebar.isVisible().catch(() => false)) + ? sidebar.getByRole("button", { name: "Settings", exact: true }) + : page.getByLabel("Clinical Guide collapsed sidebar").getByRole("button", { name: "Settings", exact: true }); + await expect(settingsTrigger).toBeVisible(); + await settingsTrigger.click(); + } + await expect(settings).toBeVisible({ timeout: 3_000 }); + }).toPass({ timeout: uiAssertionTimeoutMs }); } await expect(settings).toBeVisible({ timeout: uiAssertionTimeoutMs }); @@ -1382,8 +1402,11 @@ test.describe("Clinical KB UI smoke coverage", () => { }); await gotoApp(page, "/"); - const questionInput = visibleQuestionInput(page); - await questionInput.fill("lithium monitoring"); + // Use the hydration-aware helper rather than a raw fill: the server-rendered + // composer is visible before React owns it, and a fill landing in that gap is + // discarded by hydration, leaving submit disabled with title "Enter a + // clinical question". + await fillVisibleQuestionInput(page, "lithium monitoring"); await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toBeEnabled(); await expect(page.getByTestId("answer-grounding-chip")).toHaveCount(0); expect(answerRequests).toEqual([]); @@ -1403,8 +1426,18 @@ test.describe("Clinical KB UI smoke coverage", () => { const appModeTrigger = page.getByRole("button", { name: "Mode Answer" }); const appModeMenu = page.getByRole("menu", { name: "Choose app mode" }); - await appModeTrigger.click(); - await expect(appModeMenu).toBeVisible(); + // Retry open-then-assert together: a click landing before React attaches the + // trigger's handler is swallowed silently, so asserting visibility once fails + // on an unhydrated first click rather than on a real regression. + await expect(async () => { + // The trigger TOGGLES, so this retry has to be idempotent. If the menu + // opened just after the inner assertion's deadline expired, a second + // unconditional click closes it again and the attempts oscillate — the + // retry would then fail a UI that is working. + if (await appModeMenu.isVisible().catch(() => false)) return; + await appModeTrigger.click(); + await expect(appModeMenu).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: uiAssertionTimeoutMs }); await page.mouse.click(640, 430); await expect(appModeMenu).toBeHidden(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 4dfe197c2..385f06cb4 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -80,8 +80,22 @@ function waitForDifferentialCatalogQuery(page: Page, query: string) { } async function submitDifferentialSearch(page: Page, query: string) { + const input = page.locator('input[placeholder="Ask or search a presentation"]:visible').first(); const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); - await expect(submit).toBeEnabled(); + + // Own the fill here rather than leaving it to callers. The server-rendered + // composer is visible before React controls it, so a fill landing in that gap + // is discarded by hydration and submit stays disabled ("Start a differential + // search"). Establish the live handler boundary, fill, then confirm the value + // actually stuck — retrying the whole sequence so a client remount between + // steps cannot strand a half-applied state. + await expect(async () => { + await waitForReactEventHandler(input, "onChange"); + await input.fill(query); + await expect(input).toHaveValue(query); + await expect(submit).toBeEnabled({ timeout: 2_000 }); + }).toPass({ timeout: 30_000 }); + await Promise.all([waitForDifferentialCatalogQuery(page, query), submit.click()]); } @@ -1512,7 +1526,6 @@ test.describe("Clinical KB tools launcher", () => { await gotoLauncher(page, "/differentials"); await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); - await page.locator('input[placeholder="Ask or search a presentation"]:visible').first().fill("acute confusion"); await submitDifferentialSearch(page, "acute confusion"); await expect.poll(() => searchRequests.length).toBeGreaterThan(0); @@ -1579,7 +1592,6 @@ test.describe("Clinical KB tools launcher", () => { }); await gotoLauncher(page, "/differentials"); - await page.locator('input[placeholder="Ask or search a presentation"]:visible').first().fill("acute confusion"); await submitDifferentialSearch(page, "acute confusion"); await expect(page.getByTestId("differentials-search-results")).toBeVisible(); @@ -1681,7 +1693,6 @@ test.describe("Clinical KB tools launcher", () => { }); await gotoLauncher(page, "/differentials"); - await page.locator('input[placeholder="Ask or search a presentation"]:visible').first().fill("acute confusion"); await submitDifferentialSearch(page, "acute confusion"); await expect(page.getByTestId("differentials-search-results")).toBeVisible();