Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 |
8 changes: 5 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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% {
Expand All @@ -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 {
Expand Down
33 changes: 20 additions & 13 deletions src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ function AnswerActivityTrace({ density }: { density: AnswerProgressDensity }) {
<div
data-testid="answer-activity-trace"
data-density={density}
className={cn("answer-activity-trace w-full overflow-hidden", compact ? "h-5" : "h-10 sm:h-12")}
className={cn("answer-activity-trace relative w-full overflow-hidden", compact ? "h-5" : "h-10 sm:h-12")}
>
<svg
aria-hidden="true"
Expand All @@ -218,19 +218,26 @@ function AnswerActivityTrace({ density }: { density: AnswerProgressDensity }) {
vectorEffect="non-scaling-stroke"
className="text-[color:var(--clinical-accent)] opacity-25 forced-colors:text-[CanvasText] forced-colors:opacity-100"
/>
<path
data-slot="answer-activity-trace-sweep"
d={answerActivityPath}
pathLength="320"
fill="none"
stroke="currentColor"
strokeWidth={compact ? 1.75 : 2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="answer-activity-trace__sweep text-[color:var(--clinical-accent)] forced-colors:text-[Highlight]"
/>
</svg>
<span
aria-hidden="true"
data-slot="answer-activity-trace-sweep"
className="answer-activity-trace__sweep pointer-events-none absolute inset-0 block"
>
<svg focusable="false" viewBox="0 0 320 44" preserveAspectRatio="none" className="block size-full">
<path
d={answerActivityPath}
pathLength="320"
fill="none"
stroke="currentColor"
strokeWidth={compact ? 1.75 : 2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="text-[color:var(--clinical-accent)] forced-colors:text-[Highlight]"
/>
</svg>
</span>
</div>
);
}
Expand Down
10 changes: 10 additions & 0 deletions tests/answer-activity-trace-css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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(/<span[^>]*data-slot="answer-activity-trace-sweep"/s);
expect(answerStatusSource).not.toMatch(/<path[^>]*data-slot="answer-activity-trace-sweep"/s);
expect(globalsCss).toMatch(/\.answer-activity-trace__sweep\s*{[^}]*will-change:\s*opacity;/s);
});
});
29 changes: 17 additions & 12 deletions tests/answer-progress-ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading