diff --git a/docs/branch-review-records/887618b97bf3256125def037cd1b5e87659aaa9b1439015c6354eb96e19d0c46.record.md b/docs/branch-review-records/887618b97bf3256125def037cd1b5e87659aaa9b1439015c6354eb96e19d0c46.record.md new file mode 100644 index 0000000000..3b89e21920 --- /dev/null +++ b/docs/branch-review-records/887618b97bf3256125def037cd1b5e87659aaa9b1439015c6354eb96e19d0c46.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/search-phone-animation-20260815 | ed29ba4b3effa6ad58f4a6ecc240b30a6a93fdd2 | Fresh phone browser and standalone-PWA loading animation defect hunt after physical-device failure report | Fixed: moved the ECG opacity pulse from an SVG path to a normal HTML compositor layer and added WebKit raster-difference coverage | CSS contract 3/3; focused production WebKit 2/2; design-system contract passed | diff --git a/src/app/globals.css b/src/app/globals.css index 20630183fe..f16a7f9e24 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3419,9 +3419,10 @@ td, } } -/* 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. */ +/* Animate compositable opacity on the HTML overlay that contains the bright ECG + path. Mobile WebKit can report animations on SVG path properties as running + without repainting them, especially in an installed PWA. Keeping animation on + a regular HTML layer makes the pulse visible without JavaScript or layout work. */ @keyframes answer-ecg-pulse { 0%, 100% { @@ -3447,6 +3448,7 @@ td, @layer components { .answer-activity-trace__sweep { animation: var(--animate-answer-ecg); + will-change: opacity; } .answer-activity-trace[data-density="compact"] .answer-activity-trace__sweep { diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index f0c1641d95..d6e207ec42 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -197,7 +197,7 @@ function AnswerActivityTrace({ density }: { density: AnswerProgressDensity }) {
- +
); } diff --git a/tests/answer-activity-trace-css.test.ts b/tests/answer-activity-trace-css.test.ts index 8d0de75a3a..a64f704996 100644 --- a/tests/answer-activity-trace-css.test.ts +++ b/tests/answer-activity-trace-css.test.ts @@ -3,6 +3,10 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const answerStatusSource = readFileSync( + new URL("../src/components/clinical-dashboard/answer-status.tsx", import.meta.url), + "utf8", +); function keyframes(name: string) { const start = globalsCss.indexOf(`@keyframes ${name}`); @@ -35,4 +39,10 @@ describe("answer activity trace CSS", () => { expect(pulse).toMatch(/opacity:\s*1;/); expect(pulse).not.toMatch(/stroke-dashoffset/); }); + + it("hosts the animation on a regular HTML compositor layer instead of an SVG path", () => { + expect(answerStatusSource).toMatch(/]*data-slot="answer-activity-trace-sweep"/s); + expect(answerStatusSource).not.toMatch(/]*data-slot="answer-activity-trace-sweep"/s); + expect(globalsCss).toMatch(/\.answer-activity-trace__sweep\s*{[^}]*will-change:\s*opacity;/s); + }); }); diff --git a/tests/answer-progress-ui-smoke.spec.ts b/tests/answer-progress-ui-smoke.spec.ts index eade42ba9e..12e4accf9d 100644 --- a/tests/answer-progress-ui-smoke.spec.ts +++ b/tests/answer-progress-ui-smoke.spec.ts @@ -582,18 +582,23 @@ test("answer progress keeps focus, reduced-motion, and forced-colour behavior in iterationCount: "infinite", 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" }); + const restingOpacity = await activityTraceSweep.evaluate(async (trace) => { + const animation = trace.getAnimations()[0]; + animation.pause(); + animation.currentTime = 0; + await new Promise(requestAnimationFrame); + return getComputedStyle(trace).opacity; + }); + const restingPixels = await activityTraceSweep.screenshot(); + const peakOpacity = await activityTraceSweep.evaluate(async (trace) => { + const animation = trace.getAnimations()[0]; + animation.currentTime = 900; + await new Promise(requestAnimationFrame); + return getComputedStyle(trace).opacity; + }); + const peakPixels = await activityTraceSweep.screenshot(); + expect({ restingOpacity, peakOpacity }).toEqual({ restingOpacity: "0.2", peakOpacity: "1" }); + expect(restingPixels.equals(peakPixels), "the WebKit raster must visibly change across the pulse").toBe(false); await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" }); await expect(currentStage.locator('[data-slot="answer-progress-stage-marker"]')).toBeVisible();