a11y(ui): pair animate-spin with motion-reduce:animate-none across 21 sites

The audit found that 21 of 128 `animate-spin` call sites lacked the
`motion-reduce:animate-none` modifier, so motion-sensitive users
still saw continuous rotation in those spinners. Sweep all remaining
sites (settings, admin, charts, dialogs, mood-list) and add a textual
guard test that scans `src/**` and fails if a future spinner is added
without the modifier.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:09:12 +02:00
1 parent 7e0f4afeeb
commit 97d39fc3d5
12 files changed
+88 -21

No files matched your search

@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.43 W11 — motion-reduce sweep guard.
*
* Every `animate-spin` site in component / app code must also carry
* `motion-reduce:animate-none` so that motion-sensitive users see a
* static icon instead of a continuous rotation. The audit found 21
* sites missing the modifier; this test prevents regressions.
*
* Scope: `src/**` excluding test files. The check is textual to avoid
* rendering every loading state — a CI-cheap guard that catches drift
* any time a new spinner is added without the modifier.
*/
const ROOT = join(process.cwd(), "src");
const EXCLUDE_DIRS = new Set(["__tests__", "node_modules"]);
function collectTsxFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (EXCLUDE_DIRS.has(entry)) continue;
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
collectTsxFiles(full, acc);
} else if (
entry.endsWith(".tsx") &&
!entry.endsWith(".test.tsx") &&
!entry.endsWith(".test.ts")
) {
acc.push(full);
}
}
return acc;
}
describe("motion-reduce coverage — every animate-spin pairs with motion-reduce:animate-none", () => {
const files = collectTsxFiles(ROOT);
it("collects component files to scan", () => {
expect(files.length).toBeGreaterThan(50);
});
it("every animate-spin site declares motion-reduce:animate-none", () => {
const violations: string[] = [];
for (const file of files) {
const src = readFileSync(file, "utf8");
const lines = src.split("\n");
lines.forEach((line, idx) => {
if (!line.includes("animate-spin")) return;
// Skip comments and string literals that aren't classNames.
if (line.trim().startsWith("//") || line.trim().startsWith("*"))
return;
if (!line.includes("motion-reduce:animate-none")) {
violations.push(`${file}:${idx + 1}: ${line.trim()}`);
}
});
}
expect(
violations,
`The following animate-spin sites are missing motion-reduce:animate-none. ` +
`Append the modifier so motion-sensitive users see a static icon:\n` +
violations.join("\n"),
).toEqual([]);
});
});
@@ -166,7 +166,7 @@ export function ApiTokenOverviewSection() {
<div className="mt-4">
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : !tokens?.length ? (
// v1.4.15 phase-C5: empty state explains where tokens come
+5 -5
View File
@@ -362,7 +362,7 @@ export function BackupsSection() {
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="mr-1 h-3.5 w-3.5" />
)}
@@ -418,7 +418,7 @@ export function BackupsSection() {
className="min-h-11"
>
{upload.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-1 h-3.5 w-3.5" />
)}
@@ -429,7 +429,7 @@ export function BackupsSection() {
{isLoading ? (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.section.backups.loading")}
</span>
@@ -459,7 +459,7 @@ export function BackupsSection() {
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="mr-1 h-3.5 w-3.5" />
)}
@@ -518,7 +518,7 @@ export function BackupsSection() {
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
)}
@@ -228,7 +228,7 @@ export function SystemStatusSection() {
data-testid="system-status-retry"
>
{isFetching ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="mr-1 h-3.5 w-3.5" />
)}
@@ -237,7 +237,7 @@ export function SystemStatusSection() {
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingStatus")}
</span>
+1 -1
View File
@@ -1286,7 +1286,7 @@ export function HealthChart({
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !chartData?.length ? null : (chartData?.length ?? 0) < 3 ? (
// v1.4.16 B1a — sparse-data placeholder. <3 daily points is too
@@ -483,7 +483,7 @@ export function DoctorReportDialog({
</Button>
<Button type="submit" disabled={submitting || !validation.ok}>
{submitting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("doctorReport.dialog.submit")}
</Button>
@@ -572,7 +572,7 @@ function SectionToggles({
{t("doctorReport.sections.title")}
</p>
{availabilityLoading && (
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin motion-reduce:animate-none" />
)}
</div>
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
@@ -232,7 +232,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
className="flex h-[220px] items-center justify-center"
data-slot="drug-level-chart-loading"
>
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !hasDoses ? (
<EmptyState />
+1 -1
View File
@@ -301,7 +301,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !data?.entries?.length ? (
// v1.4.15 phase-C5: replace bare-text empty rectangle with
+1 -1
View File
@@ -211,7 +211,7 @@ export function AboutSection() {
{isLoading || !version ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
+3 -3
View File
@@ -359,7 +359,7 @@ export function AccountSection() {
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
@@ -617,7 +617,7 @@ export function AccountSection() {
className="w-full shrink-0 sm:w-auto"
>
{tourRestarting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="mr-2 h-4 w-4" />
)}
@@ -708,7 +708,7 @@ export function AccountSection() {
<Button type="submit" variant="outline" disabled={passwordSaving}>
{passwordSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
+3 -3
View File
@@ -639,7 +639,7 @@ function CodexProviderForm({
disabled={disconnecting}
>
{disconnecting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-4 w-4" />
)}
@@ -682,7 +682,7 @@ function CodexProviderForm({
<div className="flex items-center gap-3 text-xs">
{devicePolling && (
<span className="text-muted-foreground inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin motion-reduce:animate-none" />
{t("settings.ai.deviceCodeWaiting")}
</span>
)}
@@ -703,7 +703,7 @@ function CodexProviderForm({
className="w-full sm:w-auto"
>
{devicePolling ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
+1 -1
View File
@@ -592,7 +592,7 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>