mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user