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();