Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/adr/0013-site-motion-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 0013 — Site motion language and the ambient gradient

Date: 2026-07-02
Status: accepted

## Context

The scroll-story homepage (ADR 0012) established that motion is central to
the site's character. The owner's direction, after evaluating dark vs light
prototypes: light pages, one continuous animated brand gradient tying
sections together, and — borrowing Stripe's philosophy, not their pixels —
"restraint, but nearly every element has movement."

## Decision

**Motion rules (site-wide):**

1. Animate only `transform` and `opacity` in loops; canvas/WebGL for the two
signature elements (ambient gradient, dot orbit) with rAF gated by
IntersectionObserver and `visibilitychange`.
2. Everything honours `prefers-reduced-motion` (static frame or no
animation; the homepage swaps to `StoryStatic`).
3. Ambient loops are slow (≥9s periods); interaction feedback is fast
(<500ms). Nothing autoplays sound, blocks input, or shifts layout.
4. Backgrounds may drift; **pinned foreground objects hold still**.
5. Every section-level animation must illustrate the copy's point, not
decorate it (hardware carousel = "anything works"; arc pulses = sync;
synchronized Charge buttons = "same store, same data").

**The ambient gradient** (`src/components/ui/ambient-gradient.tsx`) is an
original ~180-line WebGL fragment shader: domain-warped value noise through
the backdrop palette (blue-led with pink/yellow splashes — bright but calm; warm red/orange washes ruled out, brand red lives only in small accents) with a digital character: terraced contour lines and a bright filament through the noise field, not soft cloud inside a
bottom-left→top-right diagonal band that fades to warm white where copy
sits, with slow upward drift (echoing the homepage's coffee steam). It is
deliberately *not* Stripe's minigl or any recreation of it — inspired-by,
clean-room. Static CSS-gradient fallback on missing WebGL or context loss;
single-frame render under reduced motion; DPR capped at 1.5.

It is a reusable primitive: pricing/pro/downloads heroes adopt it as the
follow-up to this ADR.

## Consequences

- One GPU surface per page for the signature background; everything else is
compositor-only CSS. Old-hardware cost stays bounded.
- The homepage's per-act drifting patterns were removed in favour of
foreground act animations (rule 5).
- A `Reveal` scroll-into-view primitive (IntersectionObserver + <500ms
transform/opacity) is the next building block for non-homepage pages.
- **Interactive kit direction:** the site standardises on two libraries and
pushes them hard — `motion` (scroll, springs, gestures, SVG pathLength)
and `three` + `@react-three/fiber` (lazy-loaded, component-scale 3D
centerpieces such as a pointer-reactive spike burst). Custom canvas/WebGL
(DotOrbit, AmbientGradient) stays for cases smaller than a three scene.
Kit lives in `src/components/motion/`; every piece is IO/visibility gated
and reduced-motion aware. First deployments: downloads "how it fits
together" hub pulses, about-us timeline draw-on-scroll.
4 changes: 2 additions & 2 deletions src/components/home/scroll-story/acts/dot-orbit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as React from 'react'
* (static ring is drawn once instead).
*/

const DOTS = 130
const DOTS = 170
const SIZE = 520
const RING_RADIUS = 190
const ATTRACT_RADIUS = 130
Expand Down Expand Up @@ -46,7 +46,7 @@ function makeDots(): Dot[] {

const COLORS: Record<Dot['hue'], string> = {
slate: 'rgba(100, 116, 139, 0.55)',
red: 'rgba(205, 32, 31, 0.5)',
red: 'rgba(43, 108, 176, 0.55)',
purple: 'rgba(127, 84, 179, 0.55)',
}

Expand Down
14 changes: 12 additions & 2 deletions src/components/home/scroll-story/devices/laptop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@ import { cn } from '@/lib/utils'
import { PosScreen } from './pos-screen'
import styles from '../story.module.css'

export function DeviceLaptop({ className }: { className?: string }) {
export function DeviceLaptop({
className,
animateCharge = true,
}: {
className?: string
animateCharge?: boolean
}) {
return (
<div aria-hidden="true" className={cn('w-[420px]', className)}>
<div className={cn('h-[250px] rounded-t-xl p-2.5 pb-1', styles.laptopLid)}>
<PosScreen variant="laptop" register="Register 2" />
<PosScreen
variant="laptop"
register="Register 2"
animateCharge={animateCharge}
/>
</div>
<div className={cn('-mx-6 h-3.5 rounded-b-2xl', styles.laptopBase)}>
<div className="mx-auto h-1.5 w-24 rounded-b-md bg-slate-800" />
Expand Down
10 changes: 8 additions & 2 deletions src/components/home/scroll-story/devices/phone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { cn } from '@/lib/utils'
import { PosScreen } from './pos-screen'
import styles from '../story.module.css'

export function DevicePhone({ className }: { className?: string }) {
export function DevicePhone({
className,
animateCharge = true,
}: {
className?: string
animateCharge?: boolean
}) {
return (
<div
aria-hidden="true"
Expand All @@ -12,7 +18,7 @@ export function DevicePhone({ className }: { className?: string }) {
className
)}
>
<PosScreen variant="phone" />
<PosScreen variant="phone" animateCharge={animateCharge} />
</div>
)
}
33 changes: 29 additions & 4 deletions src/components/home/scroll-story/devices/pos-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ function LandscapePos({
w,
h,
register,
animateCharge,
}: {
w: number
h: number
register: string
animateCharge: boolean
}) {
const rail = 24
const bar = 20
Expand Down Expand Up @@ -230,7 +232,18 @@ function LandscapePos({
<text x={w - 8} y={h - 31} textAnchor="end" fontSize="6" fontWeight="700" fill={ink.text}>
$69.00
</text>
<rect x={cartX + 8} y={h - 25} width={cartW - 16} height={16} rx={4} fill={ink.red} />
<rect x={cartX + 8} y={h - 25} width={cartW - 16} height={16} rx={4} fill={ink.red}>
{/* same store, same data: every device's Charge button breathes on
the same clock (document timeline), a quiet sync cue */}
{animateCharge ? (
<animate
attributeName="opacity"
values="1;0.72;1"
dur="2.4s"
repeatCount="indefinite"
/>
) : null}
</rect>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<text
x={cartX + cartW / 2}
y={h - 14}
Expand All @@ -245,7 +258,7 @@ function LandscapePos({
)
}

function PhonePos() {
function PhonePos({ animateCharge }: { animateCharge: boolean }) {
const w = 112
const h = 242
const cols = 2
Expand Down Expand Up @@ -312,7 +325,16 @@ function PhonePos() {
})}

{/* charge bar + bottom nav */}
<rect x={6} y={h - navH - 20} width={w - 12} height={15} rx={4} fill={ink.red} />
<rect x={6} y={h - navH - 20} width={w - 12} height={15} rx={4} fill={ink.red}>
{animateCharge ? (
<animate
attributeName="opacity"
values="1;0.72;1"
dur="2.4s"
repeatCount="indefinite"
/>
) : null}
</rect>
<text x={w / 2} y={h - navH - 9.5} textAnchor="middle" fontSize="6.5" fontWeight="700" fill="#fff">
Charge $69
</text>
Expand All @@ -338,10 +360,12 @@ export function PosScreen({
variant = 'tablet',
register = 'Register 1',
className,
animateCharge = true,
}: {
variant?: 'tablet' | 'laptop' | 'phone'
register?: string
className?: string
animateCharge?: boolean
}) {
return (
<div
Expand All @@ -352,12 +376,13 @@ export function PosScreen({
)}
>
{variant === 'phone' ? (
<PhonePos />
<PhonePos animateCharge={animateCharge} />
) : (
<LandscapePos
w={variant === 'tablet' ? 436 : 400}
h={variant === 'tablet' ? 294 : 236}
register={register}
animateCharge={animateCharge}
/>
)}
</div>
Expand Down
10 changes: 8 additions & 2 deletions src/components/home/scroll-story/devices/tablet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import styles from '../story.module.css'
* shot through to the cloud act. The bezel is a CSS stand-in for the future
* hyper-real render; the screen is always the live DOM PosScreen.
*/
export function DeviceTablet({ className }: { className?: string }) {
export function DeviceTablet({
className,
animateCharge = true,
}: {
className?: string
animateCharge?: boolean
}) {
return (
<div
aria-hidden="true"
Expand All @@ -17,7 +23,7 @@ export function DeviceTablet({ className }: { className?: string }) {
className
)}
>
<PosScreen variant="tablet" />
<PosScreen variant="tablet" animateCharge={animateCharge} />
</div>
)
}
9 changes: 9 additions & 0 deletions src/components/home/scroll-story/scroll-story.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ vi.mock('motion/react', async () => {

afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
motionMock.progressHandlers.length = 0
})

function stubMatchMedia({ reducedMotion }: { reducedMotion: boolean }) {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
vi.stubGlobal(
'matchMedia',
vi.fn((query: string) => ({
Expand Down Expand Up @@ -151,6 +153,13 @@ describe('ScrollStory', () => {
expect(screen.getAllByTestId('story-static')).toHaveLength(2)
})

it('omits Charge pulse animations under prefers-reduced-motion', () => {
stubMatchMedia({ reducedMotion: true })
render(<ScrollStory />)

expect(document.querySelector('animate')).not.toBeInTheDocument()
})

it('disables every scroll-story animation under prefers-reduced-motion', () => {
const style = document.createElement('style')
style.textContent = readFileSync(
Expand Down
38 changes: 13 additions & 25 deletions src/components/home/scroll-story/scroll-story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type MotionValue,
} from 'motion/react'
import { cn } from '@/lib/utils'
import { AmbientGradient } from '@/components/ui/ambient-gradient'
import { CounterProps } from './acts/counter-props'
import { CloudSync } from './acts/cloud-sync'
import { CyclingDevice } from './acts/cycling-device'
Expand Down Expand Up @@ -68,26 +69,24 @@ function usePrefersReducedMotion() {
* in the DOM, switched by CSS, so SSR needs no viewport knowledge.
*/
export function ScrollStory() {
const reducedMotion = usePrefersReducedMotion()

return (
<>
<div className="hidden md:block">
<PinnedStory />
{reducedMotion ? (
<StoryStatic animateDevices={false} />
) : (
<PinnedStoryScroller />
)}
</div>
<div className="md:hidden">
<StoryStatic />
<StoryStatic animateDevices={!reducedMotion} />
</div>
</>
)
}

function PinnedStory() {
const reducedMotion = usePrefersReducedMotion()
if (reducedMotion) {
return <StoryStatic />
}
return <PinnedStoryScroller />
}

function PinnedStoryScroller() {
const tone = 'onLight' as const
const scrollerRef = React.useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -178,21 +177,10 @@ function PinnedStoryScroller() {
)}
style={{ opacity: bgSlateOpacity }}
>
{/* the one continuous background: a slow-breathing brand gradient
that ties acts 2-4 together (per-act patterns removed — the
acts now illustrate their point with foreground animation) */}
<div
className={cn(
'absolute -inset-x-[10%] -inset-y-[20%]',
styles.ribbonWrap
)}
>
<div className={cn('absolute left-[8%] top-[-18%] h-[70%] w-[55%] rounded-full', styles.ribbonBlob1)} />
<div className={cn('absolute right-[-6%] top-[6%] h-[75%] w-[60%] rounded-full', styles.ribbonBlob2)} />
<div className={cn('absolute bottom-[-22%] left-[34%] h-[60%] w-[46%] rounded-full', styles.ribbonBlob3)} />
<div className={cn('absolute bottom-[-8%] left-[-8%] h-[52%] w-[38%] rounded-full', styles.ribbonBlob4)} />
</div>
<div className={cn('absolute inset-0', styles.ribbonMask)} />
{/* the one continuous background tying acts 2-4 together: our own
shader gradient (Stripe-inspired in spirit, original in code —
see ui/ambient-gradient.tsx and ADR 0013) */}
<AmbientGradient className="absolute inset-0" />
</motion.div>

{/* act 1 counter dressing */}
Expand Down
17 changes: 12 additions & 5 deletions src/components/home/scroll-story/story-static.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ function StageStrip({
)
}

export function StoryStatic() {
export function StoryStatic({
animateDevices = true,
}: {
animateDevices?: boolean
}) {
return (
<div data-testid="story-static">
<Section tone="none" spacing="hero" className={cn('overflow-hidden', styles.woodCounterLight)}>
Expand All @@ -56,7 +60,7 @@ export function StoryStatic() {
<CopyAct1 headingLevel={1} tone="onLight" />
</div>
<StageStrip className="h-[240px] sm:h-[300px]">
<DeviceTablet />
<DeviceTablet animateCharge={animateDevices} />
</StageStrip>
</Section>

Expand All @@ -65,9 +69,12 @@ export function StoryStatic() {
<CopyAct2 tone="onLight" />
</div>
<StageStrip className="h-[220px] sm:h-[280px]">
<DevicePhone />
<DeviceTablet className="h-[240px] w-[348px]" />
<DeviceLaptop className="w-[340px]" />
<DevicePhone animateCharge={animateDevices} />
<DeviceTablet
className="h-[240px] w-[348px]"
animateCharge={animateDevices}
/>
<DeviceLaptop className="w-[340px]" animateCharge={animateDevices} />
</StageStrip>
</Section>

Expand Down
Loading
Loading