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
62 changes: 62 additions & 0 deletions docs/adr/0014-site-motion-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 0014 — 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/motion/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 `motion` (scroll,
springs, gestures, SVG pathLength) plus the custom canvas/WebGL
primitives (DotOrbit, AmbientGradient) for the signature surfaces. A
`three` + `@react-three/fiber` 3D centerpiece (pointer-reactive spike
burst) was prototyped and **rejected** (owner, 2026-07-02): visually
strong, but not worth carrying a 3D library for a site with no other 3D
need. Do not reintroduce a 3D dependency for decorative motion. 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. The owner's
preferred DotOrbit accent variant: dense blue-led
(`dots={190} ringRadius={120} size={340}` with the blue/pink/yellow
palette from the kit evaluation).
175 changes: 4 additions & 171 deletions src/components/home/scroll-story/acts/dot-orbit.tsx
Original file line number Diff line number Diff line change
@@ -1,171 +1,4 @@
'use client'

import * as React from 'react'

/**
* Pointer-reactive dot ring (Stripe-style): ~130 dots drift slowly around a
* circle; dots near the cursor are gently attracted to it and ease back when
* it leaves. Canvas + rAF, paused when off-screen (IntersectionObserver) or
* when the tab is hidden; skipped entirely under prefers-reduced-motion
* (static ring is drawn once instead).
*/

const DOTS = 130
const SIZE = 520
const RING_RADIUS = 190
const ATTRACT_RADIUS = 130
const ATTRACT_STRENGTH = 0.22
const RETURN_EASE = 0.06

type Dot = {
angle: number
radius: number
speed: number
size: number
hue: 'slate' | 'red' | 'purple'
x: number
y: number
}

function makeDots(): Dot[] {
const hues: Dot['hue'][] = ['slate', 'slate', 'slate', 'red', 'purple']
return Array.from({ length: DOTS }, (_, i) => {
const angle = (i / DOTS) * Math.PI * 2 + Math.sin(i * 7.3) * 0.12
const radius = RING_RADIUS + Math.sin(i * 3.7) * 26 + Math.cos(i * 1.9) * 12
return {
angle,
radius,
speed: 0.0009 + (Math.abs(Math.sin(i * 5.1)) * 0.0012),
size: 1.1 + Math.abs(Math.sin(i * 2.3)) * 1.5,
hue: hues[i % hues.length],
x: 0,
y: 0,
}
})
}

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

export function DotOrbit({ className }: { className?: string }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null)

React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const context = canvas.getContext('2d')
if (!context) return

const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = SIZE * dpr
canvas.height = SIZE * dpr
context.scale(dpr, dpr)

const dots = makeDots()
const center = SIZE / 2
const pointer = { x: -9999, y: -9999 }
const reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches

function draw(animate: boolean) {
if (!context) return
context.clearRect(0, 0, SIZE, SIZE)
for (const dot of dots) {
if (animate) dot.angle += dot.speed
const targetX = center + Math.cos(dot.angle) * dot.radius
const targetY = center + Math.sin(dot.angle) * dot.radius * 0.86

if (dot.x === 0 && dot.y === 0) {
dot.x = targetX
dot.y = targetY
}

const dx = pointer.x - dot.x
const dy = pointer.y - dot.y
const dist = Math.hypot(dx, dy)
if (animate && dist < ATTRACT_RADIUS) {
const pull = (1 - dist / ATTRACT_RADIUS) * ATTRACT_STRENGTH
dot.x += dx * pull
dot.y += dy * pull
} else {
dot.x += (targetX - dot.x) * RETURN_EASE
dot.y += (targetY - dot.y) * RETURN_EASE
}

context.beginPath()
context.arc(dot.x, dot.y, dot.size, 0, Math.PI * 2)
context.fillStyle = COLORS[dot.hue]
context.fill()
}
}

if (reducedMotion) {
draw(false)
return
}

let frame = 0
let running = false
const tick = () => {
draw(true)
frame = requestAnimationFrame(tick)
}
const start = () => {
if (!running) {
running = true
frame = requestAnimationFrame(tick)
}
}
const stop = () => {
running = false
cancelAnimationFrame(frame)
}

const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && !document.hidden) start()
else stop()
})
io.observe(canvas)

const onVisibility = () => {
if (document.hidden) stop()
else start()
}
document.addEventListener('visibilitychange', onVisibility)

const onPointerMove = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect()
// rect is post-transform; map back into the canvas's logical square
pointer.x = ((event.clientX - rect.left) / rect.width) * SIZE
pointer.y = ((event.clientY - rect.top) / rect.height) * SIZE
}
const onPointerLeave = () => {
pointer.x = -9999
pointer.y = -9999
}
window.addEventListener('pointermove', onPointerMove, { passive: true })
window.addEventListener('pointerleave', onPointerLeave)

return () => {
stop()
io.disconnect()
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerleave', onPointerLeave)
}
}, [])

return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={className}
style={{ width: SIZE, height: SIZE }}
data-testid="dot-orbit"
/>
)
}
// Compatibility shim: DotOrbit moved to the motion kit (ADR 0014) and grew
// props for dot count, size and palette — the defaults reproduce this
// homepage ring exactly. New code should import from '@/components/motion'.
export { DotOrbit } from '@/components/motion/dot-orbit'
Loading
Loading