diff --git a/src/components/__tests__/motion-reduce-spin-coverage.test.ts b/src/components/__tests__/motion-reduce-spin-coverage.test.ts new file mode 100644 index 000000000..c9a730571 --- /dev/null +++ b/src/components/__tests__/motion-reduce-spin-coverage.test.ts @@ -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([]); + }); +}); diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index fc5faf58f..f42166114 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -166,7 +166,7 @@ export function ApiTokenOverviewSection() {
{isLoading ? (
- +
) : !tokens?.length ? ( // v1.4.15 phase-C5: empty state explains where tokens come diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 13a4c913e..b9a7d9b6d 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -362,7 +362,7 @@ export function BackupsSection() { className="min-h-11" > {runBackup.isPending ? ( - + ) : ( )} @@ -418,7 +418,7 @@ export function BackupsSection() { className="min-h-11" > {upload.isPending ? ( - + ) : ( )} @@ -429,7 +429,7 @@ export function BackupsSection() { {isLoading ? (
- + {t("admin.section.backups.loading")} @@ -459,7 +459,7 @@ export function BackupsSection() { className="min-h-11" > {runBackup.isPending ? ( - + ) : ( )} @@ -518,7 +518,7 @@ export function BackupsSection() { className="min-h-11" > {downloadingId === row.id ? ( - + ) : ( )} diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 506ee5305..549ae3afa 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -228,7 +228,7 @@ export function SystemStatusSection() { data-testid="system-status-retry" > {isFetching ? ( - + ) : ( )} @@ -237,7 +237,7 @@ export function SystemStatusSection() {
) : (
- + {t("admin.loadingStatus")} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 514b20911..059351ebc 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1286,7 +1286,7 @@ export function HealthChart({ {isLoading ? (
- +
) : !chartData?.length ? null : (chartData?.length ?? 0) < 3 ? ( // v1.4.16 B1a — sparse-data placeholder. <3 daily points is too diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index 75416295d..4a3bc1566 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -483,7 +483,7 @@ export function DoctorReportDialog({ @@ -572,7 +572,7 @@ function SectionToggles({ {t("doctorReport.sections.title")}

{availabilityLoading && ( - + )}
    diff --git a/src/components/medications/DrugLevelChart.tsx b/src/components/medications/DrugLevelChart.tsx index f5435c7dc..5b1e44bbe 100644 --- a/src/components/medications/DrugLevelChart.tsx +++ b/src/components/medications/DrugLevelChart.tsx @@ -232,7 +232,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { className="flex h-[220px] items-center justify-center" data-slot="drug-level-chart-loading" > - +
) : !hasDoses ? ( diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 42bae8831..5bbbafdf6 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -301,7 +301,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { {isLoading ? (
- +
) : !data?.entries?.length ? ( // v1.4.15 phase-C5: replace bare-text empty rectangle with diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 5f4860240..16d1a1810 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -211,7 +211,7 @@ export function AboutSection() { {isLoading || !version ? (
- + {t("common.loading")} diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 9f8712f78..ba846da25 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -359,7 +359,7 @@ export function AccountSection() { if (isLoading) { return (
- +
); } @@ -617,7 +617,7 @@ export function AccountSection() { className="w-full shrink-0 sm:w-auto" > {tourRestarting ? ( - + ) : ( )} @@ -708,7 +708,7 @@ export function AccountSection() {