Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1ee3fb1
fix(a11y): stop OS Reduce Motion from freezing and hiding answer prog…
claude Aug 17, 2026
1c03944
docs(ledger): record phone/PWA animation defect review
claude Aug 17, 2026
e9e501d
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
f754340
docs(issues): capture phone motion verification follow-ups
claude Aug 17, 2026
050d0c1
Merge remote-tracking branch 'origin/claude/phone-pwa-animation-bug-s…
claude Aug 17, 2026
5476073
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
fed037e
Merge remote-tracking branch 'origin/claude/phone-pwa-animation-bug-s…
claude Aug 17, 2026
9886081
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
e463eaf
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
926ea91
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
88e1bfd
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
ceac0fc
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
e1d1d3a
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
edcf25e
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
b8ad8ca
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
425f2aa
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
6396cb5
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
16d7cb0
Merge branch 'main' into claude/phone-pwa-animation-bug-suooj4
BigSimmo Aug 17, 2026
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-17 | claude/phone-pwa-animation-bug-suooj4 | 1ee3fb13f52190632fc85fdea2833c19ccaa3e62 | phone/PWA answer-progress animation defect: reduced-motion suppression, tri-state Motion preference, travelling ECG sweep | Fixed: OS Reduce Motion was freezing all animation and setting the ECG trace to opacity:0; suppression now leaves a visible static trace, motion:full opts back in, and the pulse became a transform-driven scrolling strip | verify:pr-local exit 0 (640 files/6832 tests); verify:cheap exit 0; ui-phone-motion 3 passed and answer-progress-ui-smoke 4 passed on Chromium 1194 (diagnostic, browser-revision drift #255); check:production-readiness exit 0 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "5cc39bc6-4cf4-4a96-9ce7-a0ee46020b90",
"createdOn": "2026-08-17",
"action": "add",
"payload": {
"pri": "P2",
"type": "task",
"summary": "No mobile-WebKit or display-mode:standalone Playwright project exists; phone coverage is a narrow viewport on desktop engines",
"detail": "playwright.config.ts defines chromium, chromium-mockups, firefox and webkit, and the webkit project uses devices[\"Desktop Safari\"]. Every phone assertion in the suite is therefore a narrow viewport on a desktop engine, and nothing exercises display-mode: standalone at all — despite globals.css:3570-3606 carrying the only phone-AND-standalone-exclusive CSS in the repo (bounded overflow:hidden shell, -webkit-overflow-scrolling:touch scrollport). PR #2046 added per-test devices[\"iPhone 14\"] emulation in tests/ui-phone-motion.spec.ts as a cheap partial, but that is still Playwright WebKit, not the iOS engine. Next: decide between a dedicated mobile-WebKit project (CI cost) and per-test emulation as the standing pattern, and add standalone display-mode coverage for the phone shell rules. Relates to #280 (physical iPhone acceptance debt).",
"source": "PR #2046 phone/PWA answer-progress animation defect, 2026-08-17",
"issueUlid": "01M085V50M71NT23RWPENDRH8T"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "c68dae81-559c-45ad-af70-f1b0334a49c3",
"createdOn": "2026-08-17",
"action": "add",
"payload": {
"pri": "P3",
"type": "task",
"summary": "Physical iPhone acceptance owed for the answer-progress motion fix (Safari + installed PWA, Motion=Full)",
"detail": "PR #2046 fixed the reported defect (OS Reduce Motion froze every animation and set the ECG trace to opacity:0) and added a Motion preference whose \"full\" value opts back in over the OS setting. All executed browser evidence ran on Chromium 1194 in a Cloud container — the repo's own verify:ui gate could not run because check:playwright-browser-revision reports the known #255 drift (expects 1234). Playwright WebKit is not the iOS engine either. Acceptance: on the physical iPhone, in Safari and as the installed PWA, with Settings > Motion set to Full, confirm the ECG strip visibly travels and the current-step spinner rotates; with Motion left on System, confirm the trace stays visible and static rather than blank. Failure to confirm means the defect class is unclosed, which is exactly how #1974/#1989/#1995 were each declared fixed. Relates to #255, #280.",
"source": "PR #2046 phone/PWA answer-progress animation defect, 2026-08-17",
"issueUlid": "01M085VNVZS4K1GA5Y6P6B49D6"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "e790f80b-efb3-4684-8195-77ff872ad014",
"createdOn": "2026-08-17",
"action": "add",
"payload": {
"pri": "P3",
"type": "rec",
"summary": "Three spring easing tokens in globals.css are dead: zero var() references and zero utility usage",
"detail": "--spring-tight, --spring-bouncy and --spring-gentle (src/app/globals.css:222-224) are declared in the @theme block but have no var() consumer in any stylesheet and no generated-utility consumer in src/. Tailwind v4.3.3 tree-shakes unused theme variables, so they never reach the compiled CSS — they are source noise, not shipped weight. Found while confirming (during PR #2046) that --animate-answer-ecg survives that same tree-shaking because it IS referenced via var() from the project's own CSS; --ease-spring is the working precedent for that pattern. Next: delete the three tokens, or wire them to the motion surfaces they were intended for.",
"source": "PR #2046 phone/PWA answer-progress animation defect, 2026-08-17",
"issueUlid": "01M085W28G43SSS0NTZ0VN3D1P"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "e7f18a92-046d-4fb7-98be-d6aca938657d",
"createdOn": "2026-08-17",
"action": "add",
"payload": {
"pri": "P2",
"type": "issue",
"summary": "Playwright runs the whole suite with reducedMotion:\"reduce\", so no gate reflects the default user configuration",
"detail": "playwright.config.ts:61 sets contextOptions: { reducedMotion: \"reduce\" } suite-wide, and every motion assertion has to opt out per-test via page.emulateMedia({ reducedMotion: \"no-preference\" }). That inversion is why three consecutive PRs (#1974, #1989, #1995) shipped green while a physical iPhone with OS Reduce Motion on showed a frozen, blank answer-progress panel: the suite never exercised the reported configuration. PR #2046 added tests/ui-phone-motion.spec.ts to cover that one surface, but the suite-wide default remains inverted for every other motion behaviour. Next: decide whether the suite default should be no-preference with reduce opted into per-test (the safer direction), or keep the current default and add a contract test that fails when a motion assertion has no explicit emulateMedia call.",
"source": "PR #2046 phone/PWA answer-progress animation defect, 2026-08-17",
"issueUlid": "01M085TT0575JA0P5H0386V0JB"
}
}
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
// `tests/playwright-project-isolation.test.ts` asserts every such file on disk is
// matched here.
const productionSpecPattern =
/.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/;
/.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/;
const mockupSpecPattern =
/.*ui-(document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory)\.spec\.ts/;
const mockupTag = /@mockup/;

export default defineConfig({
testDir: "./tests",
testMatch:
/.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|document-canvas|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/,
/.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|document-canvas|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/,
timeout: 60_000,
retries: 0,
// Fail the run if a stray `test.only` is committed: otherwise it silently
Expand Down
5 changes: 4 additions & 1 deletion scripts/playwright-pr-shards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { childProcessExitCode } from "./child-process-result.mjs";

/** Same matcher as playwright.config.ts `productionSpecPattern` (keep in sync). */
export const productionSpecFilePattern =
/^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/;
/^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/;

/**
* One source of truth for shard membership and its latest hosted timing sample.
Expand All @@ -44,6 +44,9 @@ export const prUiSpecProfiles = Object.freeze([
// Added after the timing sample; place it on the lightest measured shard and
// replace this zero with hosted evidence at the next timing refresh.
{ file: "tests/dsm-ui-smoke.spec.ts", shard: 2, fullSeconds: 0, criticalSeconds: 0 },
// Added after the timing sample. Measured locally at ~4.8s for 3 tests; replace
// with hosted evidence at the next timing refresh.
{ file: "tests/ui-phone-motion.spec.ts", shard: 2, fullSeconds: 5.0, criticalSeconds: 0 },

{ file: "tests/ui-tools.spec.ts", shard: 3, fullSeconds: 110.5, criticalSeconds: 3.1 },
{ file: "tests/ui-chrome-scroll.spec.ts", shard: 3, fullSeconds: 60.7, criticalSeconds: 0 },
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/account/preferences/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const runtime = "nodejs";
const preferencesSchema = z
.object({
density: z.enum(["comfortable", "compact", "spacious"]),
motion: z.enum(["system", "reduced"]),
motion: z.enum(["system", "reduced", "full"]),
jurisdiction: z.enum(["wa", "nsw", "vic", "qld", "sa", "tas", "act", "nt", "national"]),
population: z.enum(["adults", "older-adults", "adolescents", "all"]),
answerStyle: z.enum(["conservative", "balanced", "comprehensive"]),
Expand Down
4 changes: 3 additions & 1 deletion src/app/ckb-v2-tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@
--ease-chrome-reveal: cubic-bezier(0.22, 1, 0.36, 1);
}

/* `:not([data-motion="full"])` keeps this in step with the in-app Motion
preference, which can opt back in to animation over the OS setting. */
@media (prefers-reduced-motion: reduce) {
.ckb-v2.ckb-v2 {
html:not([data-motion="full"]) .ckb-v2.ckb-v2 {
--duration-instant: 0ms;
--duration-fast: 0ms;
--duration-quick: 0ms;
Expand Down
127 changes: 102 additions & 25 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,46 @@

@custom-variant dark (&:where(.dark, .dark *));

/*
* Motion variants are redefined so the in-app Motion preference is authoritative
* in BOTH directions, without touching the ~164 `motion-reduce:`/`motion-safe:`
* call sites.
*
* data-motion absent -> "system": follow prefers-reduced-motion (unchanged)
* data-motion="reduced" -> explicit opt-OUT: suppress regardless of the OS
* data-motion="full" -> explicit opt-IN: animate even when the OS asks to reduce
*
* The opt-in exists because iOS "Reduce Motion" is commonly switched on to stop
* app-launch zoom rather than out of vestibular sensitivity, and it was silently
* freezing this app's answer-progress feedback on a physical iPhone (both Safari
* and the installed PWA) with no in-app way to get it back.
*/
@custom-variant motion-reduce {
@media (prefers-reduced-motion: reduce) {
html:not([data-motion="full"]) & {
@slot;
}
}

html[data-motion="reduced"] & {
@slot;
}
}

@custom-variant motion-safe {
@media (prefers-reduced-motion: no-preference) {
html:not([data-motion="reduced"]) & {
@slot;
}
}

@media (prefers-reduced-motion: reduce) {
html[data-motion="full"] & {
@slot;
}
}
}

/*
* Design tokens that generate Tailwind utilities.
* Colour ramps and per-theme values live in :root / .dark below and are
Expand Down Expand Up @@ -231,8 +271,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-pulse 1.8s ease-in-out infinite;
--animate-answer-ecg-compact: answer-ecg-pulse 1.6s ease-in-out infinite;
--animate-answer-ecg: answer-ecg-scroll 3.2s linear infinite;
--animate-answer-ecg-compact: answer-ecg-scroll 2.6s linear infinite;
}

/* Theme tokens */
Expand Down Expand Up @@ -1754,6 +1794,18 @@ summary::-webkit-details-marker {
width: var(--spacing-icon-lg);
}
}
/* Mirrors the `motion-safe` variant: animate when the OS allows it, and also when
the in-app Motion preference explicitly opts in over an OS reduce request. */
@media (prefers-reduced-motion: reduce) {
html[data-motion="full"] .mode-action-surface[data-placement="up"] {
animation: mode-action-fold-up 170ms var(--ease-out-soft) both;
}

html[data-motion="full"] .mode-action-surface[data-placement="down"] {
animation: mode-action-fold-down 170ms var(--ease-out-soft) both;
}
}

@media (prefers-reduced-motion: no-preference) {
.mode-action-surface[data-placement="up"] {
animation: mode-action-fold-up 170ms var(--ease-out-soft) both;
Expand Down Expand Up @@ -2952,7 +3004,7 @@ summary::-webkit-details-marker {
}

@media (prefers-reduced-motion: reduce) {
.pwa-notice-card {
html:not([data-motion="full"]) .pwa-notice-card {
animation: none;
}
}
Expand Down Expand Up @@ -3425,17 +3477,24 @@ td,
}
}

/* 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% {
opacity: 0.2;
/* Scroll the bright ECG strip like a cardiac monitor.
*
* The strip is two identical copies of the trace side by side inside a 200%-wide
* HTML span, so translating it by exactly -50% lands copy 2 where copy 1 started
* and the loop has no seam (the path starts at `M0 24` and ends at the same y).
*
* `transform` is the only animated property here on purpose. Mobile WebKit can
* report animations on SVG path properties as running without repainting them,
* especially in an installed PWA, and an earlier whole-line opacity pulse was
* technically running but too subtle to read as motion on a phone hairline.
* Translating a plain HTML layer is the same compositor-thread recipe that
* `.animate-skeleton-shimmer::after` already uses reliably in this app. */
@keyframes answer-ecg-scroll {
from {
transform: translate3d(0, 0, 0);
}
50% {
opacity: 1;
to {
transform: translate3d(-50%, 0, 0);
}
}

Expand All @@ -3452,9 +3511,19 @@ td,
* those unlayered classes, check the class body first; the class wins.
*/
@layer components {
/* `isolation` + `translateZ(0)` mirror .animate-skeleton-shimmer: they give the
scrolling strip its own compositor layer so WebKit repaints it. The mask is
static (never animated) and only softens the two clipped edges. */
.answer-activity-trace {
isolation: isolate;
transform: translateZ(0);
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
}

.answer-activity-trace__sweep {
animation: var(--animate-answer-ecg);
will-change: opacity;
will-change: transform;
}

.answer-activity-trace[data-density="compact"] .answer-activity-trace__sweep {
Expand Down Expand Up @@ -3790,16 +3859,21 @@ td,
}
}

/* Suppressing motion must not delete the status indicator. `opacity: 0` here used
to hide the bright trace outright, so anyone with Reduce Motion on saw a dead
panel while an answer was generating. At translateX(0) the first copy of the
strip fills the box exactly, so simply stopping the animation leaves a correct,
clearly visible static ECG. */
@media (prefers-reduced-motion: reduce) {
.answer-activity-trace__sweep {
html:not([data-motion="full"]) .answer-activity-trace__sweep {
animation: none;
opacity: 0;
opacity: 0.55;
}
}

html[data-motion="reduced"] .answer-activity-trace__sweep {
animation: none;
opacity: 0;
opacity: 0.55;
}

/* IMP-04: Compositor-thread sweep used by .animate-skeleton-shimmer::after. */
Expand Down Expand Up @@ -3830,8 +3904,8 @@ html[data-motion="reduced"] .answer-activity-trace__sweep {
}

@media (prefers-reduced-motion: reduce) {
.animate-skeleton-shimmer::after,
.stagger-item {
html:not([data-motion="full"]) .animate-skeleton-shimmer::after,
html:not([data-motion="full"]) .stagger-item {
animation: none !important;
}
}
Expand All @@ -3841,19 +3915,22 @@ html[data-motion="reduced"] .stagger-item {
animation: none !important;
}

/* User preference and alternate output modes */
/* User preference and alternate output modes.
`html:not([data-motion="full"])` is what lets the in-app Motion preference opt
back in; without it this universal rule froze every animation on the page —
including the answer-progress spinner — for anyone with OS Reduce Motion on. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
html:not([data-motion="full"]) *,
html:not([data-motion="full"]) *::before,
html:not([data-motion="full"]) *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}

.source-capsule-hit:hover .source-capsule-face,
.source-capsule-hit[aria-expanded="true"]:hover .source-capsule-face {
html:not([data-motion="full"]) .source-capsule-hit:hover .source-capsule-face,
html:not([data-motion="full"]) .source-capsule-hit[aria-expanded="true"]:hover .source-capsule-face {
transform: none !important;
}

Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export default async function RootLayout({
// read it), which reads as a hydration mismatch on this attribute.
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `${THEME_BOOTSTRAP_SCRIPT}(function(){try{var p=JSON.parse(localStorage.getItem("clinical-kb-preferences")||"{}");if(p&&typeof p==="object"){if(p.density==="compact"||p.density==="spacious"){document.documentElement.setAttribute("data-density",p.density);}if(p.motion==="reduced"){document.documentElement.setAttribute("data-motion","reduced");}}}catch(e){/* storage blocked or stored preferences JSON corrupt - the default density/motion apply */}})();`,
__html: `${THEME_BOOTSTRAP_SCRIPT}(function(){try{var p=JSON.parse(localStorage.getItem("clinical-kb-preferences")||"{}");if(p&&typeof p==="object"){if(p.density==="compact"||p.density==="spacious"){document.documentElement.setAttribute("data-density",p.density);}if(p.motion==="reduced"||p.motion==="full"){document.documentElement.setAttribute("data-motion",p.motion);}}}catch(e){/* storage blocked or stored preferences JSON corrupt - the default density/motion apply */}})();`,
}}
/>
<a
Expand Down
Loading
Loading