From e63fae564be970322ceb58248a5f1171db9579d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:28:34 +0200 Subject: [PATCH] test(insights): pin the reduced-motion guard for .animate-insight-in The 400 ms `.animate-insight-in` entrance keyframe is applied across five insight surfaces and is collapsed to `animation: none` once, centrally, in a `@media (prefers-reduced-motion: reduce)` block in globals.css. Extend the motion-reduce coverage test to assert that central guard so a future edit cannot silently drop it and replay the entrance motion for motion-sensitive users. --- .../motion-reduce-spin-coverage.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/components/__tests__/motion-reduce-spin-coverage.test.ts b/src/components/__tests__/motion-reduce-spin-coverage.test.ts index c9a730571..225dda2e2 100644 --- a/src/components/__tests__/motion-reduce-spin-coverage.test.ts +++ b/src/components/__tests__/motion-reduce-spin-coverage.test.ts @@ -65,3 +65,56 @@ describe("motion-reduce coverage — every animate-spin pairs with motion-reduce ).toEqual([]); }); }); + +/** + * v1.11.3 — `.animate-insight-in` reduced-motion guard. + * + * Unlike `animate-spin` (a Tailwind utility paired with an inline + * `motion-reduce:animate-none` per site), `.animate-insight-in` is a custom + * keyframe utility defined in `globals.css` and applied across five insight + * surfaces (recommendations-grid, recommendation-card, insight-status-card, + * hero-strip, arztbericht-hero-card). Guarding it at each call site would be + * fragile, so the guard lives once in `globals.css`: a + * `@media (prefers-reduced-motion: reduce)` block collapses the animation to + * `none`, covering every consumer at once. This test pins that central guard + * so a future edit cannot drop it and silently re-introduce a 400 ms motion + * for motion-sensitive users. + */ +describe("motion-reduce coverage — animate-insight-in collapses under prefers-reduced-motion", () => { + const GLOBALS = join(process.cwd(), "src", "app", "globals.css"); + const css = readFileSync(GLOBALS, "utf8"); + + it("globals.css defines the .animate-insight-in utility", () => { + expect(css).toMatch(/\.animate-insight-in\s*\{[^}]*animation:/); + }); + + it("a prefers-reduced-motion block collapses .animate-insight-in to no motion", () => { + // Locate the reduced-motion media query, then assert that within its + // body `.animate-insight-in` is reset to `animation: none`. The check + // is anchored on the media query so a stray `.animate-insight-in { + // animation: none }` outside a reduced-motion context cannot satisfy it. + const mediaStart = css.search( + /@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)/, + ); + expect( + mediaStart, + "Expected a `@media (prefers-reduced-motion: reduce)` block in globals.css.", + ).toBeGreaterThanOrEqual(0); + + // Scan from the media query to the end of the file — every motion the app + // defers under reduced motion lives in such a block, and `.animate-insight-in` + // must be one of them. + const fromMedia = css.slice(mediaStart); + const guarded = /\.animate-insight-in\s*\{\s*animation:\s*none/.test( + fromMedia, + ); + + expect( + guarded, + "Expected `.animate-insight-in { animation: none }` inside a " + + "`@media (prefers-reduced-motion: reduce)` block in globals.css. " + + "Without it the 400 ms entrance keyframe plays for motion-sensitive " + + "users on every insight card.", + ).toBe(true); + }); +});