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.
This commit is contained in:
@@ -13,11 +13,10 @@ import { gotoPage, openApp, openFirstVideo } from "./helpers";
|
|||||||
// Chromium only (playwright.config.ts leaves it out of the other projects).
|
// Chromium only (playwright.config.ts leaves it out of the other projects).
|
||||||
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
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<void> {
|
async function settled(page: Page): Promise<void> {
|
||||||
await expect(
|
await expect(page.locator('[role="status"] .animate-spin, [data-skeleton]')).toHaveCount(0);
|
||||||
page.locator('[role="status"] .animate-spin, [aria-hidden="true"].animate-pulse'),
|
|
||||||
).toHaveCount(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function violations(page: Page): Promise<string[]> {
|
async function violations(page: Page): Promise<string[]> {
|
||||||
|
|||||||
@@ -515,7 +515,9 @@ export default function Channels() {
|
|||||||
id: "backfill",
|
id: "backfill",
|
||||||
label: t("channels.backfillEverything"),
|
label: t("channels.backfillEverything"),
|
||||||
title: t("channels.backfillEverythingHint"),
|
title: t("channels.backfillEverythingHint"),
|
||||||
icon: <History className={`h-4 w-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />,
|
icon: (
|
||||||
|
<History className={`h-4 w-4 ${deepAll.isPending ? "motion-safe:animate-pulse" : ""}`} />
|
||||||
|
),
|
||||||
onSelect: () => deepAll.mutate(),
|
onSelect: () => deepAll.mutate(),
|
||||||
disabled: deepAll.isPending,
|
disabled: deepAll.isPending,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ export default function DownloadButton({
|
|||||||
>
|
>
|
||||||
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
|
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
|
||||||
<Download
|
<Download
|
||||||
className={clsx((downloaded || inQueue) && "text-accent", inQueue && "animate-pulse")}
|
className={clsx(
|
||||||
|
(downloaded || inQueue) && "text-accent",
|
||||||
|
inQueue && "motion-safe:animate-pulse",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{open && (
|
{open && (
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ function Progress({ job }: { job: QueueJob }) {
|
|||||||
style={{ width: `${job.progress}%` }}
|
style={{ width: `${job.progress}%` }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="block h-full w-full animate-pulse bg-accent/60" />
|
<span className="block h-full w-full motion-safe:animate-pulse bg-accent/60" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{bytes && <span className="w-8 shrink-0 text-right tabular-nums">{job.progress}%</span>}
|
{bytes && <span className="w-8 shrink-0 text-right tabular-nums">{job.progress}%</span>}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Interrupted is a ring, not a fill: the job did not fail, but its work did not finish either — set
|
// 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.
|
// apart from a skipped run (the same colour, filled) and from a failure.
|
||||||
const DOT_CLASS = {
|
const DOT_CLASS = {
|
||||||
running: "bg-accent animate-pulse",
|
running: "bg-accent motion-safe:animate-pulse",
|
||||||
ok: "bg-success",
|
ok: "bg-success",
|
||||||
error: "bg-danger",
|
error: "bg-danger",
|
||||||
skipped: "bg-warn",
|
skipped: "bg-warn",
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Indeterminate: total unknown, so a slim moving sliver instead of a fill.
|
// Indeterminate: total unknown, so a slim moving sliver instead of a fill.
|
||||||
<div className="h-full w-1/3 rounded-full bg-accent animate-pulse" />
|
<div className="h-full w-1/3 rounded-full bg-accent motion-safe:animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -259,7 +259,9 @@ export default function SetupWizard() {
|
|||||||
onClick={sendTest}
|
onClick={sendTest}
|
||||||
disabled={testState === "sending" || !testTo.trim()}
|
disabled={testState === "sending" || !testTo.trim()}
|
||||||
>
|
>
|
||||||
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
|
<Send
|
||||||
|
className={`w-4 h-4 ${testState === "sending" ? "motion-safe:animate-pulse" : ""}`}
|
||||||
|
/>
|
||||||
{t("setup.smtp.test")}
|
{t("setup.smtp.test")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function Skeleton({ className, inline = false }: { className?: string; inline?:
|
|||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
data-skeleton=""
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-lg bg-fg/8 animate-pulse motion-reduce:animate-none",
|
"rounded-lg bg-fg/8 animate-pulse motion-reduce:animate-none",
|
||||||
inline && "inline-block align-middle",
|
inline && "inline-block align-middle",
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export function usePlaylistActions() {
|
|||||||
id: "push",
|
id: "push",
|
||||||
label: linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube"),
|
label: linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube"),
|
||||||
icon: pushing.has(pl.id) ? (
|
icon: pushing.has(pl.id) ? (
|
||||||
<Upload className="w-4 h-4 animate-pulse" />
|
<Upload className="w-4 h-4 motion-safe:animate-pulse" />
|
||||||
) : linked ? (
|
) : linked ? (
|
||||||
<YouTubeIcon className="w-4 h-4" />
|
<YouTubeIcon className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
// docs/DESIGN.md (Motion): `prefers-reduced-motion` is respected. A pulse that loops for as long as
|
||||||
|
// something lasts — a running job's dot, a queued download, a skeleton bone — is the motion that rule
|
||||||
|
// is about, and seven of them pulsed regardless. Each is `motion-safe:animate-pulse`, or pairs the
|
||||||
|
// pulse with `motion-reduce:animate-none` on the same line.
|
||||||
|
function sources(): Array<[string, string]> {
|
||||||
|
const walk = (u: URL, prefix: string): Array<[string, string]> =>
|
||||||
|
readdirSync(u, { withFileTypes: true }).flatMap((e) => {
|
||||||
|
if (e.isDirectory()) return walk(new URL(`${e.name}/`, u), `${prefix}${e.name}/`);
|
||||||
|
if (!/\.tsx?$/.test(e.name) || e.name.includes(".test.")) return [];
|
||||||
|
return [
|
||||||
|
[`${prefix}${e.name}`, readFileSync(new URL(e.name, u), "utf-8")] as [string, string],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return walk(new URL("../", import.meta.url), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("every looping pulse stops for a reader who asked for less motion", () => {
|
||||||
|
const unguarded = sources().flatMap(([name, src]) =>
|
||||||
|
src
|
||||||
|
.split("\n")
|
||||||
|
.map((line, i) => [line, i + 1] as const)
|
||||||
|
.filter(
|
||||||
|
([line]) =>
|
||||||
|
/(?<!motion-safe:)\banimate-pulse\b/.test(line) &&
|
||||||
|
!line.includes("motion-reduce:animate-none"),
|
||||||
|
)
|
||||||
|
.map(([, n]) => `${name}:${n}`),
|
||||||
|
);
|
||||||
|
expect(unguarded).toEqual([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user