From b842e4e871785d4bda5acaf6a74a2fb9e98e5add Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 18 Oct 2026 21:40:52 +0200 Subject: [PATCH] fix(ui): a looping pulse stops for reduced motion; the e2e waits for skeleton bones, not for any pulse - Seven looping pulses (a running job's dot, the scheduler's progress bar, a queued download, the download tray, a playlist upload, a channel's full-history action, the setup wizard's test mail) ran regardless of prefers-reduced-motion, which docs/DESIGN.md promises to respect: each is motion-safe now, held by a contract test. - The a11y suite's "settled" wait took every aria-hidden pulse for a loading skeleton, so the Scheduler legend's running dot, which pulses for good, kept it waiting until it timed out. A skeleton bone now says it is one (data-skeleton) and the wait looks for that. --- frontend/e2e/a11y.spec.ts | 7 ++-- frontend/src/components/Channels.tsx | 4 ++- frontend/src/components/DownloadButton.tsx | 5 ++- frontend/src/components/DownloadTray.tsx | 2 +- frontend/src/components/RunStatusDot.tsx | 2 +- frontend/src/components/Scheduler.tsx | 2 +- frontend/src/components/SetupWizard.tsx | 4 ++- frontend/src/components/ui/Skeleton.tsx | 1 + .../src/components/usePlaylistActions.tsx | 2 +- .../src/lib/reducedMotion.contract.test.ts | 34 +++++++++++++++++++ 10 files changed, 52 insertions(+), 11 deletions(-) create mode 100644 frontend/src/lib/reducedMotion.contract.test.ts diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts index d62b31e1..42830509 100644 --- a/frontend/e2e/a11y.spec.ts +++ b/frontend/e2e/a11y.spec.ts @@ -13,11 +13,10 @@ import { gotoPage, openApp, openFirstVideo } from "./helpers"; // Chromium only (playwright.config.ts leaves it out of the other projects). const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]; -/** Wait until nothing is loading: no QueryState spinner and no skeleton bone left. */ +/** Wait until nothing is loading: no QueryState spinner and no skeleton bone left. A bone says it is + * one (`data-skeleton`): a pulse alone is not loading — a running job's dot pulses as long as it runs. */ async function settled(page: Page): Promise { - await expect( - page.locator('[role="status"] .animate-spin, [aria-hidden="true"].animate-pulse'), - ).toHaveCount(0); + await expect(page.locator('[role="status"] .animate-spin, [data-skeleton]')).toHaveCount(0); } async function violations(page: Page): Promise { diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 125709fe..fc1e6c85 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -515,7 +515,9 @@ export default function Channels() { id: "backfill", label: t("channels.backfillEverything"), title: t("channels.backfillEverythingHint"), - icon: , + icon: ( + + ), onSelect: () => deepAll.mutate(), disabled: deepAll.isPending, }, diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index e12c2f84..f68d5f38 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -54,7 +54,10 @@ export default function DownloadButton({ > {/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */} {open && ( diff --git a/frontend/src/components/DownloadTray.tsx b/frontend/src/components/DownloadTray.tsx index 9937cfca..bee55acc 100644 --- a/frontend/src/components/DownloadTray.tsx +++ b/frontend/src/components/DownloadTray.tsx @@ -98,7 +98,7 @@ function Progress({ job }: { job: QueueJob }) { style={{ width: `${job.progress}%` }} /> ) : ( - + )} {bytes && {job.progress}%} diff --git a/frontend/src/components/RunStatusDot.tsx b/frontend/src/components/RunStatusDot.tsx index cb3ba7f5..a1657c38 100644 --- a/frontend/src/components/RunStatusDot.tsx +++ b/frontend/src/components/RunStatusDot.tsx @@ -4,7 +4,7 @@ // Interrupted is a ring, not a fill: the job did not fail, but its work did not finish either — set // apart from a skipped run (the same colour, filled) and from a failure. const DOT_CLASS = { - running: "bg-accent animate-pulse", + running: "bg-accent motion-safe:animate-pulse", ok: "bg-success", error: "bg-danger", skipped: "bg-warn", diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 1b1cd714..e4d501cf 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -238,7 +238,7 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) { /> ) : ( // Indeterminate: total unknown, so a slim moving sliver instead of a fill. -
+
)}
diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx index 6989b7f2..03f01ba8 100644 --- a/frontend/src/components/SetupWizard.tsx +++ b/frontend/src/components/SetupWizard.tsx @@ -259,7 +259,9 @@ export default function SetupWizard() { onClick={sendTest} disabled={testState === "sending" || !testTo.trim()} > - + {t("setup.smtp.test")} diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx index 5c1576ff..115fef2c 100644 --- a/frontend/src/components/ui/Skeleton.tsx +++ b/frontend/src/components/ui/Skeleton.tsx @@ -13,6 +13,7 @@ function Skeleton({ className, inline = false }: { className?: string; inline?: return (