diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ .docusaurus/ .vercel .env*.local .worktrees/ +next-env.d.ts diff --git a/packages/docs/next-env.d.ts b/packages/docs/next-env.d.ts deleted file mode 100644 --- a/packages/docs/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/docs/src/app/global.css b/packages/docs/src/app/global.css --- a/packages/docs/src/app/global.css +++ b/packages/docs/src/app/global.css @@ -5,6 +5,7 @@ @import "@happyview/design-system/globals.css"; @import "@happyview/design-system/vaporwave.css"; @source "../../node_modules/fumadocs-ui/dist/**/*.js"; +@source "../../node_modules/fumadocs-ui/css/**/*.css"; /* --- Layout --- */ diff --git a/packages/docs/src/app/layout.tsx b/packages/docs/src/app/layout.tsx --- a/packages/docs/src/app/layout.tsx +++ b/packages/docs/src/app/layout.tsx @@ -3,6 +3,7 @@ import { RootProvider } from 'fumadocs-ui/provider/next'; import { DocsLayout } from 'fumadocs-ui/layouts/docs'; import { source } from '@/lib/source'; import { fontVariables } from '@/lib/fonts'; +import { ReducedMotionProvider } from '@/lib/reduced-motion'; import { SidebarFooter } from '@/components/sidebar-footer'; import type { ReactNode } from 'react'; @@ -26,7 +27,8 @@ return ( - + {children} - + + diff --git a/packages/docs/src/components/sidebar-footer.tsx b/packages/docs/src/components/sidebar-footer.tsx --- a/packages/docs/src/components/sidebar-footer.tsx +++ b/packages/docs/src/components/sidebar-footer.tsx @@ -2,12 +2,45 @@ 'use client'; import { SidebarCollapseTrigger } from 'fumadocs-ui/components/sidebar/base'; import { ThemeSwitch } from 'fumadocs-ui/layouts/shared/slots/theme-switch'; -import { SidebarIcon } from 'lucide-react'; +import { SidebarIcon, Zap, ZapOff } from 'lucide-react'; +import { useReducedMotion } from '@/lib/reduced-motion'; export function SidebarFooter() { + const { reducedMotion, override, setOverride } = useReducedMotion(); + + function cycleMotion() { + if (override === null) { + setOverride(!reducedMotion); + } else { + setOverride(null); + } + } + return (
+
diff --git a/packages/docs/src/components/vaporwave-grid.tsx b/packages/docs/src/components/vaporwave-grid.tsx --- a/packages/docs/src/components/vaporwave-grid.tsx +++ b/packages/docs/src/components/vaporwave-grid.tsx @@ -1,18 +1,12 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; +import { useReducedMotion } from '@/lib/reduced-motion'; export function VaporwaveGrid() { const canvasRef = useRef(null); - const [reducedMotion, setReducedMotion] = useState(false); - - useEffect(() => { - const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); - setReducedMotion(mq.matches); - const onChange = () => setReducedMotion(mq.matches); - mq.addEventListener('change', onChange); - return () => mq.removeEventListener('change', onChange); - }, []); + const logoRef = useRef(null); + const { reducedMotion } = useReducedMotion(); useEffect(() => { const canvas = canvasRef.current; @@ -21,8 +15,11 @@ const ctx = canvas.getContext('2d'); if (!ctx) return; - const logo = new Image(); - logo.src = '/img/face.webp'; + if (!logoRef.current) { + logoRef.current = new Image(); + logoRef.current.src = '/img/face.webp'; + } + const logo = logoRef.current; let animationId: number; let w = 0; @@ -39,8 +36,6 @@ ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); } resize(); - const observer = new ResizeObserver(resize); - observer.observe(canvas); function render(opts: { elapsed: number; @@ -219,16 +214,22 @@ ctx!.fillRect(0, horizonY - 4, w, 16); } if (reducedMotion) { - function drawStatic() { - if (logo.complete && logo.naturalWidth > 0) { - render({ elapsed: 0, faceReveal: 1, scrollOffset: 0, glitch: false }); - } else { - logo.addEventListener('load', drawStatic, { once: true }); - } - } + const drawStatic = () => render({ elapsed: 0, faceReveal: 1, scrollOffset: 0, glitch: false }); + const observer = new ResizeObserver(() => { resize(); drawStatic(); }); + observer.observe(canvas); drawStatic(); + if (!logo.complete || logo.naturalWidth === 0) { + logo.addEventListener('load', drawStatic, { once: true }); + return () => { + logo.removeEventListener('load', drawStatic); + observer.disconnect(); + }; + } return () => observer.disconnect(); } + + const observer = new ResizeObserver(resize); + observer.observe(canvas); function draw(time: number) { const elapsed = time / 1000; diff --git a/packages/docs/src/lib/reduced-motion.tsx b/packages/docs/src/lib/reduced-motion.tsx new file mode 100644 --- /dev/null +++ b/packages/docs/src/lib/reduced-motion.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; + +type ReducedMotionContextValue = { + reducedMotion: boolean; + override: boolean | null; + setOverride: (value: boolean | null) => void; +}; + +const ReducedMotionContext = createContext({ + reducedMotion: false, + override: null, + setOverride: () => {}, +}); + +const STORAGE_KEY = 'happyview-reduced-motion'; + +export function ReducedMotionProvider({ children }: { children: ReactNode }) { + const [systemPreference, setSystemPreference] = useState(false); + const [override, setOverrideState] = useState(null); + + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + setSystemPreference(mq.matches); + const onChange = () => setSystemPreference(mq.matches); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }, []); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored !== null) setOverrideState(stored === 'true'); + }, []); + + function setOverride(value: boolean | null) { + setOverrideState(value); + if (value === null) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, String(value)); + } + } + + const reducedMotion = override ?? systemPreference; + + return ( + + {children} + + ); +} + +export function useReducedMotion() { + return useContext(ReducedMotionContext); +}