diff --git a/docs/branch-review-records/417c3d2bbb8346fc650dc4bed9db149912fc13abb7aaf5fc9d7e6f75a3db0fa1.record.md b/docs/branch-review-records/417c3d2bbb8346fc650dc4bed9db149912fc13abb7aaf5fc9d7e6f75a3db0fa1.record.md new file mode 100644 index 0000000000..a09aba63e7 --- /dev/null +++ b/docs/branch-review-records/417c3d2bbb8346fc650dc4bed9db149912fc13abb7aaf5fc9d7e6f75a3db0fa1.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/fix-ecg-animation-on-mobile-devices | d4662baa81ba738b2278f624f213db416fde95a5 | review-and-fix | Merged latest main, resolved the CSS-test conflict, and fixed the WebKit late-notice test race | Focused Vitest 2/2; design-system contract; changed-file ESLint; typecheck; Prettier; branch-review, outstanding-issues, ledger-discipline and final-merge audit. Browser/provider gates not run locally. | diff --git a/src/app/globals.css b/src/app/globals.css index 2f704cc2f3..20630183fe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -231,8 +231,8 @@ --animate-dialog-rise: dialog-rise 220ms cubic-bezier(0.22, 1, 0.36, 1) both; --animate-action-tray-in: action-tray-in var(--swipe-duration, 160ms) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-shimmer: shimmer 1.4s linear infinite; - --animate-answer-ecg: answer-ecg-sweep 1.8s linear infinite; - --animate-answer-ecg-compact: answer-ecg-sweep 1.6s linear infinite; + --animate-answer-ecg: answer-ecg-pulse 1.8s ease-in-out infinite; + --animate-answer-ecg-compact: answer-ecg-pulse 1.6s ease-in-out infinite; } /* Theme tokens */ @@ -3419,14 +3419,16 @@ td, } } -/* 320 → 0 is equivalent to 0 → -320 modulo the dash period, without - WebKit's unreliable negative SVG dash-offset interpolation. */ -@keyframes answer-ecg-sweep { - from { - stroke-dashoffset: 320; +/* Animate a compositable CSS property rather than SVG stroke-dashoffset. + Mobile WebKit can report the dash animation as running without repainting it, + especially in an installed PWA. Opacity produces a visible ECG pulse in both. */ +@keyframes answer-ecg-pulse { + 0%, + 100% { + opacity: 0.2; } - to { - stroke-dashoffset: 0; + 50% { + opacity: 1; } } diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index 6f7ef60bb1..f0c1641d95 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -227,7 +227,6 @@ function AnswerActivityTrace({ density }: { density: AnswerProgressDensity }) { strokeWidth={compact ? 1.75 : 2} strokeLinecap="round" strokeLinejoin="round" - strokeDasharray="36 284" vectorEffect="non-scaling-stroke" className="answer-activity-trace__sweep text-[color:var(--clinical-accent)] forced-colors:text-[Highlight]" /> diff --git a/tests/answer-activity-trace-css.test.ts b/tests/answer-activity-trace-css.test.ts index 47105e9d0f..8d0de75a3a 100644 --- a/tests/answer-activity-trace-css.test.ts +++ b/tests/answer-activity-trace-css.test.ts @@ -28,11 +28,11 @@ describe("answer activity trace CSS", () => { expect(globalsCss).not.toMatch(/\.answer-activity-trace\s*{[^}]*contain:\s*paint;/s); }); - it("cycles through the positive dash-offset equivalent for WebKit", () => { - const sweep = keyframes("answer-ecg-sweep"); + it("pulses with opacity instead of WebKit-unreliable SVG dash offsets", () => { + const pulse = keyframes("answer-ecg-pulse"); - expect(sweep).toMatch(/from\s*{\s*stroke-dashoffset:\s*320;/); - expect(sweep).toMatch(/to\s*{\s*stroke-dashoffset:\s*0;/); - expect(sweep).not.toMatch(/stroke-dashoffset:\s*-/); + expect(pulse).toMatch(/opacity:\s*0\.2;/); + expect(pulse).toMatch(/opacity:\s*1;/); + expect(pulse).not.toMatch(/stroke-dashoffset/); }); }); diff --git a/tests/answer-progress-ui-smoke.spec.ts b/tests/answer-progress-ui-smoke.spec.ts index 01b7121bf5..eade42ba9e 100644 --- a/tests/answer-progress-ui-smoke.spec.ts +++ b/tests/answer-progress-ui-smoke.spec.ts @@ -42,6 +42,15 @@ async function fillHydratedAnswerQuestion(page: Page, value: string) { return submit; } +async function dismissBlockingPwaNotice(page: Page) { + const dismiss = page.getByRole("button", { name: /Dismiss (?:offline notice|update notice|install)/ }).first(); + const noticeAppeared = await dismiss + .waitFor({ state: "visible", timeout: 2_000 }) + .then(() => true) + .catch(() => false); + if (noticeAppeared) await dismiss.click(); +} + async function mockDashboardApis(page: Page) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()); @@ -435,6 +444,7 @@ test("follow-up answer generation stays compact above the previous answer", asyn await mockDashboardApis(page); await installSuccessfulThenHoldingAnswerStreams(page); await page.goto("/?mode=answer", { waitUntil: "domcontentloaded" }); + await dismissBlockingPwaNotice(page); const submit = await fillHydratedAnswerQuestion(page, "Lithium dosing"); await submit.click(); @@ -466,7 +476,7 @@ test("follow-up answer generation stays compact above the previous answer", asyn timingFunction: style.animationTimingFunction, }; }), - ).toEqual({ duration: "1.6s", iterationCount: "infinite", timingFunction: "linear" }); + ).toEqual({ duration: "1.6s", iterationCount: "infinite", timingFunction: "ease-in-out" }); const stop = progress.getByRole("button", { name: "Stop generating answer" }); expect((await stop.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(48); @@ -528,6 +538,7 @@ test("answer progress keeps focus, reduced-motion, and forced-colour behavior in await mockDashboardApis(page); await installHoldingAnswerStream(page); await page.goto("/?mode=answer", { waitUntil: "domcontentloaded" }); + await dismissBlockingPwaNotice(page); const submit = await fillHydratedAnswerQuestion(page, "Lithium dosing"); await submit.click(); @@ -566,11 +577,23 @@ test("answer progress keeps focus, reduced-motion, and forced-colour behavior in }; }), ).toEqual({ - name: "answer-ecg-sweep", + name: "answer-ecg-pulse", duration: "1.8s", iterationCount: "infinite", - timingFunction: "linear", + timingFunction: "ease-in-out", }); + expect( + await activityTraceSweep.evaluate(async (trace) => { + const animation = trace.getAnimations()[0]; + animation.pause(); + animation.currentTime = 0; + await new Promise(requestAnimationFrame); + const restingOpacity = getComputedStyle(trace).opacity; + animation.currentTime = 900; + await new Promise(requestAnimationFrame); + return { restingOpacity, peakOpacity: getComputedStyle(trace).opacity }; + }), + ).toEqual({ restingOpacity: "0.2", peakOpacity: "1" }); await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" }); await expect(currentStage.locator('[data-slot="answer-progress-stage-marker"]')).toBeVisible();