From 708992c9e09b733cee473b7551134379df2cdd64 Mon Sep 17 00:00:00 2001 From: Tony Ko Date: Thu, 16 Jul 2026 13:55:36 -0400 Subject: [PATCH 1/2] feat: update font colors --- src/components/atoms/intro.tsx | 6 +++--- src/components/molecules/record.tsx | 2 +- src/components/molecules/testimonial.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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/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 ? ( From 4ad6fc3f1b5e1cb6e1fe4cc50237d8ce60395ec8 Mon Sep 17 00:00:00 2001 From: Tony Ko Date: Thu, 16 Jul 2026 14:36:34 -0400 Subject: [PATCH 2/2] fix(motion): honour reduced motion without stranding content Reveal branched on useReducedMotion at render, so the server (which cannot read the preference) emitted framer's hidden state as inline styles while the client rendered a bare div. React does not patch up inline-style mismatches, leaving every Reveal stuck at opacity 0: all four routes rendered blank below the hero under reduced motion. Reveal now always animates and only swaps its variant, since the animation is the one thing that clears the SSR start state. The same rule applied elsewhere: keep the fade, drop the travel. - reveal/motion: revealItemReduced snaps the rise and de-blur, fades opacity; both variants share one hidden state so the markup hydrates identically - marquee: a paused drift inside overflow-hidden put every entry past the edge out of reach, so the row becomes a plain scroller and drops its seam duplicate - reticle: the outer logo marks fade in place instead of travelling their height - drawer: vaul animates the panel from its own injected stylesheet, beyond the reach of motion-reduce utilities, so the slide keyframes swap for a fade --- docs/02-prd/03-solution.md | 7 ++++-- src/components/atoms/marquee.tsx | 15 ++++++++---- src/components/atoms/reticle.tsx | 6 +++-- src/components/atoms/reveal.tsx | 16 ++++--------- src/constants/motion.ts | 41 +++++++++++++++++++++++++++----- src/themes/theme.css | 17 +++++++++++++ 6 files changed, 77 insertions(+), 25 deletions(-) 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/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/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; + } +}