From 6ed53f661f4d4a927617eeb2941f4e3c8c528f5f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:42:42 +0800 Subject: [PATCH 1/8] test(ui): fix hydration races at the source; document the weight idiom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chromium suite failed a different random subset on each contended run — always the same shape: a control that never enables, a menu that never opens. The repo already had the fix documented on `fillVisibleQuestionInput`: "Production HTML can be visible before React owns the controlled input. Filling during that gap is immediately overwritten by hydration and leaves the submit button disabled." The flaky sites bypassed that helper and filled raw. - ui-smoke "offline browser gate": raw fill -> fillVisibleQuestionInput. - ui-smoke "desktop mode options": retry open-then-assert together, since a click landing before React attaches the trigger handler is swallowed. - ui-tools submitDifferentialSearch: the helper now owns the fill and confirms the value stuck before submitting, so all three callers get hydration safety; their redundant raw fills are removed. - ui-accessibility differential filter: retry fill-then-enabled inline (that file has no React-internals helper). Previously flaky cases now pass 13/13 across three consecutive runs. Also record in docs/design-system.md that intermediate font weights (520/540/560/580/640/650/680) are deliberate on a variable face rather than drift — normalising Therapy Compass onto 600/700 was attempted on 2026-07-28 and reverted — plus the leading vocabulary and the trap that redefining Tailwind's --leading-tight/-snug silently retunes every existing call site. Co-Authored-By: Claude Opus 5 (cherry picked from commit 73879c63980bda09adbb9b5743c96cfefcb9a9f9) --- docs/design-system.md | 12 ++++++++++++ tests/ui-accessibility.spec.ts | 13 +++++++++++-- tests/ui-smoke.spec.ts | 16 ++++++++++++---- tests/ui-tools.spec.ts | 19 +++++++++++++++---- 4 files changed, 50 insertions(+), 10 deletions(-) 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/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-smoke.spec.ts b/tests/ui-smoke.spec.ts index 5991ade69..9c567a2bd 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1376,8 +1376,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([]); @@ -1397,8 +1400,13 @@ 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 () => { + 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 d4a644e27..f3b6bca9e 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(); From b03f48cc3d552e714347bd55b6112af7d4bea0c7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:48:03 +0800 Subject: [PATCH 2/8] test(ui): retry the Settings open in openGuide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth instance of the same hydration race: the Settings trigger becomes visible before React attaches its handler, so the click is swallowed and "Account & app" never opens — `guide opens and dismisses at tablet` then fails on `expect(settings).toBeVisible()` after 30s. Wrap all three viewport branches in one retry of click-plus-resulting-dialog, matching the shape used for the composer, the mode menu and the header measurement. A swallowed first click retries; a dialog that never opens still fails. Passes 3/3 across three consecutive runs. Co-Authored-By: Claude Opus 5 (cherry picked from commit ba5893a095b2ec2a73f52c68481ad3dfccda1975) --- tests/ui-smoke.spec.ts | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 9c567a2bd..901ae0d7f 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -827,21 +827,29 @@ 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 () => { + 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"); + 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 }); From 6078e691b86c01e3f09fb1392e76ebb5d63a1eac Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:07:34 +0800 Subject: [PATCH 3/8] fix(ui): repoint orphaned text-4xs onto the 10px floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retiring --text-4xs killed its utility: Tailwind emits no `text-4xs` rule, so every remaining class is a no-op and that text silently falls back to inherited size. Two files carrying it landed from main while this port was in flight (#1273 phone in-page navigation mockups, #1362 therapy navigation mockups), so they were dead on arrival — five classes, nothing failing. Repoint all five to text-3xs, the 10px floor, and guard it: the design-token contract now fails if any tracked file under src/ references the retired class outside a comment. Mockups are deliberately NOT exempt from this one — a dead utility breaks a mockup exactly as it breaks production. Co-Authored-By: Claude Opus 5 (cherry picked from commit 314ca51fb5f36fd71a0722aab85365155bdddece) --- .../mockups/phone-inpage-navigation/page.tsx | 4 +-- .../therapy-navigation-mockups/rail.tsx | 6 ++--- tests/design-token-contract.test.ts | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) 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. From 718227dbbf5aa970eb8ee7e33e01f31abc0eddc3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:38:25 +0800 Subject: [PATCH 4/8] test(ui): retry phone header inset geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as the width cases: the geometry was sampled once, so a React remount mid-layout produced transient boxes. Assertions are unchanged and still strict — a genuinely asymmetric header fails once the retry budget is spent. Co-Authored-By: Claude Opus 5 (cherry picked from commit 996fccdc88b7482589a8df91307be97412f0c1f0) --- tests/ui-overlap.spec.ts | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) 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 }); }); } From 906c5a1cfc0d10c6788002825401481844366d2a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:40:25 +0800 Subject: [PATCH 5/8] issues: capture #108 design-system manifest re-sync and #109 ui-overlap phone-inset flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-allocated from #096/#097 then #098/#099 — both pairs were taken by other work while this branch was in flight. The issues:next-id marker has no concurrency protection, so agents working the same hour collide on it. Co-Authored-By: Claude Opus 5 --- docs/outstanding-issues.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index d72da481b..db0011307 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -84,7 +84,7 @@ removed after current-main verification; it is not missing recommended work. | 36 | `#101` | A3 | Specialist — retrieval/ranking | Only after `#098` sizes the win | 1–2 days plus canary | Outcome: independent retrieval stages stop running serially. Gate: live eval-canary pair, explicit approval, ~$1–2. Verification: 36/36 golden, document/content recall 1.0, zero per-case rr regressions. Stop on any regression and revert in a single commit. Resolved `#075` and `#083` are the precedents for why this is gated rather than free. | | 37 | `#104` | A3 | Standard — ingestion worker | Any ingestion-touching session | 30–60 minutes | Outcome: ingestion reads each extracted image once instead of up to three times (`worker/main.ts:997`). Gate: `verify:cheap`. Verification: targeted worker test plus one ingestion smoke run. Throughput only — no clinician-facing latency, so do not prioritise it above anything above. Stop if the read is load-bearing for OCR retry semantics. | - + ## Open items @@ -149,6 +149,8 @@ removed after current-main verification; it is not missing recommended work. | #105 | P3 | task | `#017`-exempt client latency wins | **Outcome:** zero-payload client latency fixes are not trapped behind the `#017` measurement gate. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number. **Done 2026-07-28:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. **Next:** verify with `verify:ui` once the heavy-run lock is free. Sidebar dialogs intentionally excluded (they mount on open). | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-28 | | #106 | P2 | rec | Ingestion worker and indexing agent are verified by grepping their own source | **Outcome:** the ingestion worker and indexing agent are verified by executing code, not by asserting on their own source text. **Detail:** measured 2026-07-29 via `npm run test:coverage` — `worker/main.ts` (2,015 lines) and `supabase/functions/indexing-v3-agent/index.ts` (1,966 lines) each report **0% executed lines**; no test imports either module. Both are covered only by `readFileSync` + `toContain` assertions in `worker-safe-logging.test.ts`, `worker-visual-capture.test.ts` and `document-metadata-merge.test.ts`, which pass whenever a string is present and break on harmless refactors; `document-metadata-merge.test.ts` additionally reimplements the SQL deep-merge in TypeScript and tests the reimplementation rather than the worker. Area totals: `worker/` 18.6% lines, `supabase/functions/` 4.5%. **Next:** continue the extraction pattern that already works here — `indexing-v3-agent/behavior.ts` (167 lines, 96%) and `ingestion-worker/auth.ts` (30 lines, 90%) — pulling the highest-risk decision points out of `worker/main.ts` (job claim/retry, generation commit, failure classification) into importable modules with executing tests, retiring the matching source-text assertion as each lands. Roughly cost-neutral: each extracted test replaces a grep assertion. **Stop:** do not try to make the 2,000-line entrypoint importable in one pass; extract incrementally and keep each step green. | session 2026-07-29 test-coverage analysis | 2026-07-29 | | #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 | 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 | +| #109 | P3 | issue | `ui-overlap` phone-inset case still flakes under load | **Outcome:** `ui-overlap.spec.ts` is stable at any machine load. **Detail:** PR #1375 fixed the eight `header controls do not overlap at px` cases, which were failing at a different arbitrary width per contended run because the header measurement was not covered by the same retry `gotoHome` already uses (a React remount left a 0×0 rect, so `count` was 0). `header menu and new-chat insets stay symmetric on phone` (`ui-overlap.spec.ts:147`) failed once in three consecutive runs and was left alone to avoid widening that PR's scope. **Next:** reproduce under load, then apply the same retry-the-measurement shape if the cause is again a transient remount rather than real asymmetry. **Stop:** do not relax the symmetry tolerance — the assertion is the point; make the measurement robust instead. | PR #1375 flake triage; session 2026-07-29 | 2026-07-29 | ## Resolved / archive From 7536ca80d3fd5b87662313acaa3768a4b876c7dd Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:01:57 +0800 Subject: [PATCH 6/8] docs(ledger): record PR #1391 design-system follow-ups Co-Authored-By: Claude Opus 5 --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b318d051d..0ea4c3bdf 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1267,3 +1267,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 0922d7f56624ef84be8abcb2bbc89205027cf9a6 | PR #1383 babysit | BLOCKER CLEARED: merged origin/main; renumbered coverage follow-ups #098/#099 -> #106/#107 (main claimed #098-#105). Before: CONFLICTING/DIRTY, 4 behind; CI green on prior tip; 0 review threads; 0 Bugbot findings. After: mergeable expected; verify:cheap 424 files/4371 passed; test:coverage exit 0; format:changed + check:rag:fixtures pass. | verify:cheap PASS (424 files, 4371 passed \| 4 skipped); test:coverage PASS (no threshold errors); format:changed PASS; check:rag:fixtures PASS (36 golden); Bugbot: no findings; no provider-backed checks | | 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 6f476b5f741627cb622af57d1b4665e3989789ca | PR #1383 babysit | CLOSEOUT at tip after ledger bookkeeping commit. Merge conflict cleared; coverage follow-ups live as #106/#107; local gates green; awaiting hosted CI on tip. | same as prior tip 0922d7f5 plus ledger append only; no product code change | | 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 | From 8d8cb1e6bb0094ce15c4ffada68117ba815a7ee0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 17:21:56 +0000 Subject: [PATCH 7/8] docs(ledger): record the PR #1374 review and merge Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FvU8z73P6TXUXoYBqN5K1P --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 042450fee..71d37e47f 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1295,3 +1295,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 | From ff4ddfabb8d8594da0c09e04dc37e9286745140b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 17:51:07 +0000 Subject: [PATCH 8/8] fix(tests): make the two hydration retries idempotent, archive #111 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all correct. `openGuide` and the mode-menu retry both re-ran an action that had already succeeded. `toPass` schedules another attempt whenever the inner assertion's own deadline expires, which can happen a moment AFTER the click landed — so under the contention these retries exist to tolerate: - `openGuide` clicked a Settings trigger the modal was already covering, or reopened the phone menu on top of it, and timed out. - the mode-menu retry clicked a TOGGLE a second time, closing a menu that had just opened, and could oscillate until the budget ran out — failing a UI that was working. Both now return early when the thing they were about to produce already exists. The mobile branch needed a second guard: a swallowed Settings click leaves the phone menu OPEN, so `openMobileClinicalGuideMenu` on the retry would toggle it shut and then fail to find Settings inside it. It now reuses an open menu and only summons one when there is none. Without this the <768px branch could not recover on retry at all, which is the branch the PR claims to fix. Also moves #111 from Open items to Resolved / archive. The row recorded the ui-overlap fix as done and simultaneously sat in the queue describing the test as still flaking, so an operator reading the file as a work queue would have scheduled the investigation again. The repo's own conventions say a resolved item is archived, not left open with a done note. verify:cheap exit 0 — Test Files 429 passed (429), Tests 4404 passed | 4 skipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FvU8z73P6TXUXoYBqN5K1P --- docs/outstanding-issues.md | 2 +- tests/ui-smoke.spec.ts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 59f4dc5cc..07c77de0a 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -149,7 +149,6 @@ removed after current-main verification; it is not missing recommended work. | #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 | -| #111 | P3 | issue | `ui-overlap` phone-inset case still flakes under load | **Outcome:** `ui-overlap.spec.ts` is stable at any machine load. **Detail:** PR #1375 fixed the eight `header controls do not overlap at px` cases, which were failing at a different arbitrary width per contended run because the header measurement was not covered by the same retry `gotoHome` already uses (a React remount left a 0×0 rect, so `count` was 0). `header menu and new-chat insets stay symmetric on phone` (`ui-overlap.spec.ts:147`) failed once in three consecutive runs and was left alone to avoid widening that PR's scope. **Done 2026-07-29 (PR #1391):** the same retry-the-measurement shape was applied — the two `boundingBox()` samples and the inset assertions now run inside `expect(async () => …).toPass({ timeout: 15_000 })` (`ui-overlap.spec.ts:159`). The symmetry tolerance is unchanged at 2px and the assertions are byte-for-byte the same, 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). **Stop:** do not relax the symmetry tolerance — the assertion is the point; make the measurement robust instead. | PR #1375 flake triage; session 2026-07-29 | 2026-07-29 | ## Resolved / archive @@ -159,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/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 220c5564c..aedb59cea 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -838,8 +838,20 @@ async function openGuide(page: Page) { // 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) { - const menu = await openMobileClinicalGuideMenu(page); + // 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"); @@ -1418,6 +1430,11 @@ test.describe("Clinical KB UI smoke coverage", () => { // 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 });