diff --git a/components/CherryBlossom.accessibility.test.tsx b/components/CherryBlossom.accessibility.test.tsx
index 79164cb71..79247521e 100644
--- a/components/CherryBlossom.accessibility.test.tsx
+++ b/components/CherryBlossom.accessibility.test.tsx
@@ -5,6 +5,7 @@ import CherryBlossom from './CherryBlossom';
import type React from 'react';
vi.mock('framer-motion', () => ({
+ useReducedMotion: vi.fn(() => false),
motion: {
div: ({
children,
diff --git a/components/CherryBlossom.empty-fallback.test.tsx b/components/CherryBlossom.empty-fallback.test.tsx
index 964ba0647..a96e7eb1e 100644
--- a/components/CherryBlossom.empty-fallback.test.tsx
+++ b/components/CherryBlossom.empty-fallback.test.tsx
@@ -5,6 +5,7 @@ import CherryBlossom from './CherryBlossom';
// Mock framer-motion to avoid animation DOM complexity
vi.mock('framer-motion', () => {
return {
+ useReducedMotion: vi.fn(() => false),
motion: {
div: ({ children }: { children?: React.ReactNode }) =>
{children}
,
},
diff --git a/components/CherryBlossom.error-resilience.test.tsx b/components/CherryBlossom.error-resilience.test.tsx
index f9978be98..029d4663e 100644
--- a/components/CherryBlossom.error-resilience.test.tsx
+++ b/components/CherryBlossom.error-resilience.test.tsx
@@ -11,6 +11,7 @@ const motionRuntime = vi.hoisted(() => ({
}));
vi.mock('framer-motion', () => ({
+ useReducedMotion: vi.fn(() => false),
motion: {
div: ({ children, ...props }: React.HTMLAttributes) => {
if (motionRuntime.shouldThrow) {
diff --git a/components/CherryBlossom.mock-integrations.test.tsx b/components/CherryBlossom.mock-integrations.test.tsx
index 691f53d85..a3b19eb80 100644
--- a/components/CherryBlossom.mock-integrations.test.tsx
+++ b/components/CherryBlossom.mock-integrations.test.tsx
@@ -6,6 +6,7 @@ import CherryBlossom from './CherryBlossom';
// 1. Mock standard asynchronous imports and databases using stubs
// We mock framer-motion to execute synchronously to avoid async animation timeouts
vi.mock('framer-motion', () => ({
+ useReducedMotion: vi.fn(() => false),
motion: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
div: ({ children, className, ...props }: any) => (
diff --git a/components/CherryBlossom.mouse-interactivity.test.tsx b/components/CherryBlossom.mouse-interactivity.test.tsx
index 1e76cfad5..27d03d088 100644
--- a/components/CherryBlossom.mouse-interactivity.test.tsx
+++ b/components/CherryBlossom.mouse-interactivity.test.tsx
@@ -6,6 +6,7 @@ import '@testing-library/jest-dom';
// Mock framer-motion to keep the tests simple and stable
vi.mock('framer-motion', () => ({
+ useReducedMotion: vi.fn(() => false),
motion: {
div: ({
children,
diff --git a/components/CherryBlossom.responsive-breakpoints.test.tsx b/components/CherryBlossom.responsive-breakpoints.test.tsx
index dd3bbb455..ce858e4a8 100644
--- a/components/CherryBlossom.responsive-breakpoints.test.tsx
+++ b/components/CherryBlossom.responsive-breakpoints.test.tsx
@@ -6,6 +6,7 @@ import '@testing-library/jest-dom';
// Mock framer-motion to inspect the props passed to the animated elements
vi.mock('framer-motion', () => ({
+ useReducedMotion: vi.fn(() => false),
motion: {
div: ({
children,
diff --git a/components/CherryBlossom.test.tsx b/components/CherryBlossom.test.tsx
index 320bdf387..67c1b9354 100644
--- a/components/CherryBlossom.test.tsx
+++ b/components/CherryBlossom.test.tsx
@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import CherryBlossom from './CherryBlossom';
+import { useReducedMotion } from 'framer-motion';
import type React from 'react';
import '@testing-library/jest-dom';
+
// Mock framer-motion
vi.mock('framer-motion', () => ({
motion: {
@@ -15,11 +17,13 @@ vi.mock('framer-motion', () => ({
),
},
+ useReducedMotion: vi.fn(() => false), // default: motion is allowed
}));
describe('CherryBlossom', () => {
beforeEach(() => {
vi.restoreAllMocks();
+ vi.mocked(useReducedMotion).mockReturnValue(false);
});
it('renders without crashing after mount', async () => {
@@ -68,3 +72,30 @@ describe('CherryBlossom', () => {
expect(() => unmount()).not.toThrow();
});
});
+
+describe('CherryBlossom - Reduced Motion Accessibility', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.mocked(useReducedMotion).mockReturnValue(false);
+ });
+
+ it('renders nothing when the user prefers reduced motion', async () => {
+ vi.mocked(useReducedMotion).mockReturnValue(true);
+
+ const { container } = render();
+
+ await waitFor(() => {
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ it('still renders the falling petals when reduced motion is not requested', async () => {
+ vi.mocked(useReducedMotion).mockReturnValue(false);
+
+ const { container } = render();
+
+ await waitFor(() => {
+ expect(container.querySelector('.fixed.inset-0')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/components/CherryBlossom.theme-contrast.test.tsx b/components/CherryBlossom.theme-contrast.test.tsx
index 861261618..492a5281f 100644
--- a/components/CherryBlossom.theme-contrast.test.tsx
+++ b/components/CherryBlossom.theme-contrast.test.tsx
@@ -6,6 +6,7 @@ import '@testing-library/jest-dom';
// Mock framer-motion to inspect the props passed to the animated elements
vi.mock('framer-motion', () => ({
+ useReducedMotion: vi.fn(() => false),
motion: {
div: ({
children,
diff --git a/components/CherryBlossom.tsx b/components/CherryBlossom.tsx
index 2626b67f9..ac17f5b19 100644
--- a/components/CherryBlossom.tsx
+++ b/components/CherryBlossom.tsx
@@ -1,6 +1,6 @@
'use client';
-import { motion } from 'framer-motion';
+import { motion, useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
const generatePetals = (count: number) => {
@@ -32,6 +32,7 @@ export interface Petal {
export default function CherryBlossom() {
const [petals] = useState(() => generatePetals(25));
const [mounted, setMounted] = useState(false);
+ const prefersReducedMotion = useReducedMotion();
// SSR hydration guard: the petal animations use framer-motion values derived
// from Math.random() at component initialisation time (via useState initialiser).
@@ -43,6 +44,9 @@ export default function CherryBlossom() {
}, []);
if (!mounted) return null;
+ if (prefersReducedMotion) {
+ return null;
+ }
return (
diff --git a/lib/validations.ts b/lib/validations.ts
index b1f6205ff..9e2b3abaa 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -299,14 +299,12 @@ const baseStreakParamsSchema = z.object({
grace: z
.string()
.optional()
- .refine(
- (val) => {
- if (val === undefined || val === '') return true;
- return /^\d+$/.test(val) && Number(val) >= 0 && Number(val) <= 7;
- },
- { message: 'grace must be an integer between 0 and 7' }
- )
- .transform((val) => (val === undefined || val === '' ? 1 : Number(val)))
+ .transform((val) => {
+ if (val === undefined || val === '') return 1;
+ const n = Number(val);
+ if (isNaN(n) || !Number.isInteger(n)) return 1;
+ return Math.min(7, Math.max(0, n));
+ })
.default(1),
mode: z.enum(['commits', 'loc']).catch('commits').default('commits'),