diff --git a/docs/adr/0014-site-motion-language.md b/docs/adr/0014-site-motion-language.md new file mode 100644 index 00000000..f678d156 --- /dev/null +++ b/docs/adr/0014-site-motion-language.md @@ -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). diff --git a/src/components/home/scroll-story/acts/dot-orbit.tsx b/src/components/home/scroll-story/acts/dot-orbit.tsx index a3934aea..fe86d235 100644 --- a/src/components/home/scroll-story/acts/dot-orbit.tsx +++ b/src/components/home/scroll-story/acts/dot-orbit.tsx @@ -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 = { - 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(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 ( -