diff --git a/docs/02-prd/03-solution.md b/docs/02-prd/03-solution.md index 15cc1b9..da88bbb 100644 --- a/docs/02-prd/03-solution.md +++ b/docs/02-prd/03-solution.md @@ -174,8 +174,11 @@ Motion is a grammar, not garnish: ## Edge cases - **Reduced motion**: the boot sequence is absent (§Boot sequence); canvas - instruments render their static state, loops replaced by posters. Every state - readable with zero motion. + instruments render their static state, loops replaced by posters; the marquee rows + become plain scrollers, since a stopped drift would strand every entry past the edge + out of reach. Reveals and the reticle fade in place rather than travelling: they still + animate, because the server cannot know the preference and the animation is what clears + the rendered start state. Every state readable with zero motion. - **Mobile**: the instrument panel recomposes (edge-density becomes top/bottom strips); grab-reel becomes native touch scroll; hover-revealed metadata must have a visible-by-default equivalent. diff --git a/src/components/atoms/intro.tsx b/src/components/atoms/intro.tsx index 52f6488..e0d829c 100644 --- a/src/components/atoms/intro.tsx +++ b/src/components/atoms/intro.tsx @@ -3,10 +3,10 @@ import { Decode } from '@/components/atoms/decode' import { cn, cva, type VariantProps } from '@/utils/theme' const styles = { - root: cva('relative flex max-w-100 flex-col items-center justify-center gap-4 text-center'), - subtitle: cva('text-muted-foreground text-e4 font-expressive uppercase'), + root: cva('relative flex max-w-100 flex-col items-center justify-center gap-2 text-center'), + subtitle: cva('text-foreground text-e4 font-expressive mb-2 uppercase'), title: cva('text-h1 font-heading whitespace-pre-line uppercase'), - desc: cva('text-muted-foreground text-sm whitespace-pre-line'), + desc: cva('text-foreground text-sm whitespace-pre-line'), cta: cva('flex flex-col items-center gap-2') } diff --git a/src/components/atoms/marquee.tsx b/src/components/atoms/marquee.tsx index 4a5c18e..f59e5a9 100644 --- a/src/components/atoms/marquee.tsx +++ b/src/components/atoms/marquee.tsx @@ -2,8 +2,13 @@ import { forwardRef, type HTMLAttributes } from 'react' import { cn, cva, type VariantProps } from '@/utils/theme' const styles = { - root: cva('w-full overflow-hidden'), - track: cva(['animate-marquee flex w-max', 'hover:paused', 'motion-reduce:paused'], { + // the drift is the only thing bringing offscreen entries into view, so under reduced + // motion the row hands that job to the reader as a plain scroller + root: cva([ + 'w-full overflow-hidden', + 'motion-reduce:overflow-x-auto motion-reduce:overscroll-x-contain' + ]), + track: cva(['animate-marquee flex w-max', 'hover:paused', 'motion-reduce:animate-none'], { variants: { direction: { left: '', @@ -14,7 +19,9 @@ const styles = { direction: 'left' } }), - group: cva('flex shrink-0 items-stretch gap-8 pr-8') + group: cva('flex shrink-0 items-stretch gap-8 pr-8'), + // the duplicate exists only to hide the loop's seam; scrolling it would just repeat + duplicate: cva('motion-reduce:hidden') } type MarqueeRef = HTMLDivElement @@ -29,7 +36,7 @@ const Marquee = forwardRef((props, ref) => {
{children}
-
+
{children}
diff --git a/src/components/atoms/reticle.tsx b/src/components/atoms/reticle.tsx index 0e6a7c3..af20ce9 100644 --- a/src/components/atoms/reticle.tsx +++ b/src/components/atoms/reticle.tsx @@ -17,10 +17,12 @@ const styles = { ], { variants: { + // the outer marks boot in from off-centre, so under reduced motion they fade in + // place like the middle one rather than travelling their own height position: { - 1: 'animate-boot-down', + 1: 'animate-boot-down motion-reduce:animate-fade-in', 2: 'animate-fade-in', - 3: 'animate-boot-up' + 3: 'animate-boot-up motion-reduce:animate-fade-in' } } } diff --git a/src/components/atoms/reveal.tsx b/src/components/atoms/reveal.tsx index a743c01..ff2e797 100644 --- a/src/components/atoms/reveal.tsx +++ b/src/components/atoms/reveal.tsx @@ -3,7 +3,7 @@ import { forwardRef, type ReactNode } from 'react' import { motion, useReducedMotion } from 'motion/react' import { Slot } from 'radix-ui' -import { motionReveal, revealItem } from '@/constants/motion' +import { motionReveal, revealItem, revealItemReduced } from '@/constants/motion' import { cn, cva } from '@/utils/theme' const styles = { @@ -26,7 +26,9 @@ type RevealProps = { /** * Fades and lifts its content in the first time it scrolls into view. Each Reveal - * triggers on its own intersection. + * triggers on its own intersection. Under reduced motion the content fades without + * travelling; it always animates rather than rendering statically, because the server + * cannot know the preference and only the animation clears the SSR-rendered start state. */ const Reveal = forwardRef((props, ref) => { // props @@ -40,20 +42,12 @@ const Reveal = forwardRef((props, ref) => { const viewport = { once: true, amount: rail ? motionReveal.railAmount : amount } // jsx - if (reduced) { - const Static = asChild ? Slot.Root : 'div' - return ( - - {children} - - ) - } return ( diff --git a/src/components/molecules/record.tsx b/src/components/molecules/record.tsx index 5af782a..1ddf3a3 100644 --- a/src/components/molecules/record.tsx +++ b/src/components/molecules/record.tsx @@ -21,7 +21,7 @@ const styles = { name: cva('text-md'), span: cva('text-e4 font-expressive text-muted-foreground uppercase'), location: cva('text-e4 font-expressive text-muted-foreground uppercase'), - desc: cva('text-muted-foreground text-sm whitespace-pre-line'), + desc: cva('text-foreground text-sm whitespace-pre-line'), skills: cva('flex flex-wrap gap-2') } diff --git a/src/components/molecules/testimonial.tsx b/src/components/molecules/testimonial.tsx index 3f31e1c..dae9bc1 100644 --- a/src/components/molecules/testimonial.tsx +++ b/src/components/molecules/testimonial.tsx @@ -9,7 +9,7 @@ const styles = { root: cva('flex flex-col'), quoteCell: cva('grow border-b p-4'), - quote: cva('text-muted-foreground line-clamp-9 text-sm whitespace-pre-line italic'), + quote: cva('text-foreground line-clamp-9 text-sm whitespace-pre-line'), footerCell: cva('flex items-center gap-4 px-4 py-4'), photo: cva('size-12 rounded-sm object-cover'), @@ -46,7 +46,7 @@ const Testimonial = forwardRef((props, ref) => return (
-
“{basic.desc}”
+
{basic.desc}
{photo ? ( diff --git a/src/constants/motion.ts b/src/constants/motion.ts index 0848856..63293c2 100644 --- a/src/constants/motion.ts +++ b/src/constants/motion.ts @@ -27,13 +27,21 @@ const motionReveal = { railAmount: 0.01 } as const +/** + * The resting state a `Reveal` starts from, shared by both variants below. It is rendered + * on the server, where the reduced-motion preference is unknowable, so both variants must + * open from it: a client that hydrated a different `hidden` state would mismatch, and React + * does not patch up inline-style mismatches, stranding the element at `opacity: 0`. + */ +const revealHidden = { + opacity: 0, + y: motionReveal.distance, + filter: `blur(${motionReveal.blur}px)` +} as const + /** The `Reveal` variant: the fade + rise + de-blur an element runs when it enters view. */ const revealItem: Variants = { - hidden: { - opacity: 0, - y: motionReveal.distance, - filter: `blur(${motionReveal.blur}px)` - }, + hidden: revealHidden, shown: { opacity: 1, y: 0, @@ -45,4 +53,25 @@ const revealItem: Variants = { } } -export { motionDurations, motionEasings, motionReveal, revealItem } +/** + * The reduced-motion `Reveal` variant: the rise and de-blur snap to their resting values + * while the opacity still fades, so the element arrives without travelling. Opacity alone + * carries no vestibular cost, and the element must still animate to `shown`: it is the only + * thing that clears the server-rendered `hidden` style. + */ +const revealItemReduced: Variants = { + hidden: revealHidden, + shown: { + opacity: 1, + y: 0, + filter: 'blur(0px)', + transition: { + duration: motionDurations.base, + ease: motionEasings.out, + y: { duration: 0 }, + filter: { duration: 0 } + } + } +} + +export { motionDurations, motionEasings, motionReveal, revealItem, revealItemReduced } diff --git a/src/themes/theme.css b/src/themes/theme.css index e96c7bd..c50a37b 100644 --- a/src/themes/theme.css +++ b/src/themes/theme.css @@ -351,3 +351,20 @@ opacity: 0; } } + +/* reduced motion */ + +/* Vaul drives the drawer panel from its own injected stylesheet, so `motion-reduce:` + utilities cannot reach it. Swap its slide keyframes for a fade: the panel still + announces its arrival, without travelling the height of the viewport. */ +@media (prefers-reduced-motion: reduce) { + [data-vaul-drawer][data-state='open'] { + animation-name: fade-in !important; + transition: none !important; + } + + [data-vaul-drawer][data-state='closed'] { + animation-name: fade-out !important; + transition: none !important; + } +}