From ad89fcb89159de2fce1e417d7815d8bc98ebcecb Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Thu, 2 Jul 2026 18:55:12 +0200 Subject: [PATCH 1/3] feat(motion): scaffold interactive motion kit (ADR 0013) Adds src/components/motion/ as the home for the site motion language: - three@0.185.1 + @react-three/fiber@9.6.1 (React 19 compatible; drei not needed) added as deps. - AmbientGradient and DotOrbit moved into the kit; ui/ambient-gradient.tsx and scroll-story/acts/dot-orbit.tsx become re-export shims so existing imports keep working. DotOrbit generalized with dots/size/ringRadius/ palette props (defaults reproduce the homepage ring exactly). - New Reveal primitive: motion whileInView fade+lift, <500ms, transform/ opacity only, plain visible div under reduced motion. - New flagship SpikeBurst: pointer-reactive 3D spike sphere (R3F), lazy chunk mounted via IntersectionObserver, frameloop paused off-screen and on hidden tabs, static pose under reduced motion, DPR capped 1.5, CSS fallback on WebGL failure. Calm-blue palette with sparse pink/yellow accents (no red/orange washes). - Prototype /motion-kit route (noindexed, not in nav) demoing all four pieces for evaluation. Tests: motion-kit.test.tsx unit coverage for the kit exports; Playwright e2e/motion-kit.spec.ts smoke + screenshot. Full suite 961 unit pass, lint clean, next build green, e2e 4 pass on a free port. --- docs/adr/0013-site-motion-language.md | 57 ++++ e2e/motion-kit.spec.ts | 66 ++++ package.json | 3 + pnpm-lock.yaml | 189 ++++++++++++ .../(main)/motion-kit/motion-kit-demo.tsx | 175 +++++++++++ src/app/[locale]/(main)/motion-kit/page.tsx | 25 ++ .../home/scroll-story/acts/dot-orbit.tsx | 175 +---------- src/components/motion/ambient-gradient.tsx | 291 ++++++++++++++++++ src/components/motion/dot-orbit.tsx | 200 ++++++++++++ src/components/motion/index.ts | 14 + src/components/motion/motion-kit.test.tsx | 162 ++++++++++ src/components/motion/reveal.tsx | 52 ++++ src/components/motion/spike-burst-scene.tsx | 225 ++++++++++++++ src/components/motion/spike-burst.tsx | 134 ++++++++ src/components/ui/ambient-gradient.tsx | 4 + 15 files changed, 1601 insertions(+), 171 deletions(-) create mode 100644 docs/adr/0013-site-motion-language.md create mode 100644 e2e/motion-kit.spec.ts create mode 100644 src/app/[locale]/(main)/motion-kit/motion-kit-demo.tsx create mode 100644 src/app/[locale]/(main)/motion-kit/page.tsx create mode 100644 src/components/motion/ambient-gradient.tsx create mode 100644 src/components/motion/dot-orbit.tsx create mode 100644 src/components/motion/index.ts create mode 100644 src/components/motion/motion-kit.test.tsx create mode 100644 src/components/motion/reveal.tsx create mode 100644 src/components/motion/spike-burst-scene.tsx create mode 100644 src/components/motion/spike-burst.tsx create mode 100644 src/components/ui/ambient-gradient.tsx diff --git a/docs/adr/0013-site-motion-language.md b/docs/adr/0013-site-motion-language.md new file mode 100644 index 00000000..6bf27169 --- /dev/null +++ b/docs/adr/0013-site-motion-language.md @@ -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. diff --git a/e2e/motion-kit.spec.ts b/e2e/motion-kit.spec.ts new file mode 100644 index 00000000..62c19e89 --- /dev/null +++ b/e2e/motion-kit.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test' + +/** + * Smoke coverage for the motion-kit prototype route (ADR 0013). The kit's + * behavioural contracts (gating, reduced motion, props) are unit-tested; + * here we only prove the page ships: sections render, the lazy R3F scene + * mounts a canvas (or its static fallback where headless WebGL is absent), + * and a full-page screenshot is attached for evaluation. + */ + +test.describe('motion kit prototype', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/motion-kit') + }) + + test('renders the kit evaluation sections', async ({ page }) => { + await expect( + page.getByRole('heading', { name: 'The motion kit' }) + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'AmbientGradient' }) + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'DotOrbit' }) + ).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reveal' })).toBeVisible() + }) + + test('mounts the flagship spike burst near the viewport', async ({ + page, + }) => { + const burst = page.getByTestId('spike-burst') + await expect(burst).toBeVisible() + // lazy chunk + WebGL init, or the CSS fallback when WebGL is unavailable + await expect( + burst.locator('canvas').or(page.getByTestId('spike-burst-fallback')) + ).toBeVisible({ timeout: 15000 }) + }) + + test('renders the canvas primitives (or their fallbacks)', async ({ + page, + }) => { + await expect(page.getByTestId('dot-orbit').first()).toBeVisible() + await expect( + page + .getByTestId('ambient-gradient') + .or(page.getByTestId('ambient-gradient-fallback')) + .first() + ).toBeVisible() + }) + + test('full-page screenshot for evaluation', async ({ page }, testInfo) => { + await expect(page.getByTestId('spike-burst')).toBeVisible() + // let the lazy scene settle before capturing + await page + .getByTestId('spike-burst') + .locator('canvas') + .or(page.getByTestId('spike-burst-fallback')) + .waitFor({ timeout: 15000 }) + const screenshot = await page.screenshot({ fullPage: true }) + await testInfo.attach('motion-kit', { + body: screenshot, + contentType: 'image/png', + }) + }) +}) diff --git a/package.json b/package.json index a5c94e31..95157c59 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-separator": "^1.1.10", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tabs": "^1.1.15", + "@react-three/fiber": "9.6.1", "@sentry/nextjs": "^10.58.0", "@stripe/react-stripe-js": "^6.6.0", "@stripe/stripe-js": "^9.8.0", @@ -57,6 +58,7 @@ "semver": "^7.8.4", "server-only": "^0.0.1", "tailwind-merge": "^3.6.0", + "three": "0.185.1", "zod": "^4.4.3" }, "devDependencies": { @@ -69,6 +71,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", + "@types/three": "0.185.0", "@vitejs/plugin-react": "^6.0.2", "eslint": "^10.5.0", "eslint-config-next": "16.2.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac6d283c..6b571969 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-three/fiber': + specifier: 9.6.1 + version: 9.6.1(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.185.1) '@sentry/nextjs': specifier: ^10.58.0 version: 10.58.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.105.0(esbuild@0.28.1)) @@ -126,6 +129,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + three: + specifier: 0.185.1 + version: 0.185.1 zod: specifier: ^4.4.3 version: 4.4.3 @@ -157,6 +163,9 @@ importers: '@types/semver': specifier: ^7.7.1 version: 7.7.1 + '@types/three': + specifier: 0.185.0 + version: 0.185.0 '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.0.2(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)) @@ -320,6 +329,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1410,6 +1422,31 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@react-three/fiber@9.6.1': + resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + '@reduxjs/toolkit@2.12.0': resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} peerDependencies: @@ -2067,6 +2104,9 @@ packages: '@types/react-dom': optional: true + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -2147,12 +2187,23 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.185.0': + resolution: {integrity: sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2165,6 +2216,9 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/eslint-plugin@8.61.1': resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2588,6 +2642,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.37: resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} engines: {node: '>=6.0.0'} @@ -2618,6 +2675,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -3104,6 +3164,9 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3274,6 +3337,9 @@ packages: icu-minify@4.13.0: resolution: {integrity: sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3453,6 +3519,11 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -3693,6 +3764,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -4138,6 +4212,15 @@ packages: '@types/react': optional: true + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -4388,6 +4471,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4449,6 +4537,9 @@ packages: engines: {node: '>=10'} hasBin: true + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4827,6 +4918,24 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4986,6 +5095,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -5896,6 +6007,26 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@react-three/fiber@9.6.1(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.185.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-use-measure: 2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.7) + three: 0.185.1 + use-sync-external-store: 1.6.0(react@19.2.7) + zustand: 5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': dependencies: '@standard-schema/spec': 1.1.0 @@ -6414,6 +6545,8 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -6496,12 +6629,27 @@ snapshots: dependencies: '@types/react': 19.2.17 + '@types/react-reconciler@0.28.9(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + '@types/react@19.2.17': dependencies: csstype: 3.2.3 '@types/semver@7.7.1': {} + '@types/stats.js@0.17.4': {} + + '@types/three@0.185.0': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/trusted-types@2.0.7': optional: true @@ -6511,6 +6659,8 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/webxr@0.5.24': {} + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6966,6 +7116,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.37: {} before-after-hook@4.0.0: {} @@ -6997,6 +7149,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -7619,6 +7776,8 @@ snapshots: fflate@0.4.8: {} + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -7808,6 +7967,8 @@ snapshots: dependencies: '@formatjs/icu-messageformat-parser': 3.5.11 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -7990,6 +8151,13 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + its-fine@2.0.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.17) + react: 19.2.7 + transitivePeerDependencies: + - '@types/react' + jest-worker@27.5.1: dependencies: '@types/node': 25.9.3 @@ -8308,6 +8476,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -8858,6 +9028,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + react-use-measure@2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + react@19.2.7: {} recharts@3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): @@ -9262,6 +9438,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + suspend-react@0.1.3(react@19.2.7): + dependencies: + react: 19.2.7 + symbol-tree@3.2.4: {} tailwind-merge@3.6.0: {} @@ -9287,6 +9467,8 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + three@0.185.1: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -9724,4 +9906,11 @@ snapshots: zod@4.4.3: {} + zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.17 + immer: 11.1.8 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + zwitch@2.0.4: {} diff --git a/src/app/[locale]/(main)/motion-kit/motion-kit-demo.tsx b/src/app/[locale]/(main)/motion-kit/motion-kit-demo.tsx new file mode 100644 index 00000000..0cac9761 --- /dev/null +++ b/src/app/[locale]/(main)/motion-kit/motion-kit-demo.tsx @@ -0,0 +1,175 @@ +'use client' + +import * as React from 'react' +import { + AmbientGradient, + DotOrbit, + Reveal, + SpikeBurst, +} from '@/components/motion' +import { Section } from '@/components/ui/section' +import { SectionHeading } from '@/components/ui/section-heading' +import { Badge } from '@/components/ui/badge' + +/** + * Evaluation page for the motion kit (ADR 0013). Each band shows one + * primitive with the notes a page author needs: what it is, when to reach + * for it, and the behaviours it guarantees (IO/visibility gating, reduced + * motion, DPR caps). + */ + +function KitNote({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +// blue-led recolor of the homepage ring, to show the palette prop +const BLUE_ORBIT_PALETTE = [ + 'rgba(91, 141, 239, 0.6)', + 'rgba(138, 210, 255, 0.65)', + 'rgba(43, 108, 176, 0.55)', + 'rgba(255, 158, 200, 0.55)', + 'rgba(255, 215, 106, 0.6)', +] + +const REVEAL_CARDS = [ + { + title: 'transform + opacity only', + body: 'Compositor-friendly: no layout shift, no paint storms. The card rises 16px and fades in.', + }, + { + title: 'under 500ms', + body: 'Interaction feedback is fast (motion rule 3): 450ms with a soft ease-out, once per element.', + }, + { + title: 'reduced-motion aware', + body: 'With prefers-reduced-motion the wrapper renders a plain div — visible immediately, nothing animates.', + }, + { + title: 'stagger via delay', + body: 'Siblings pass small delay steps (these cards use 90ms). Keep it a beat, not choreography.', + }, +] + +export function MotionKitDemo() { + return ( +
+ {/* ——— Flagship: SpikeBurst ——— */} +
+
+ Prototype — motion kit evaluation +

+ The motion kit +

+

+ ADR 0013 in one page: motion for scroll and springs,{' '} + three + R3F for component-scale 3D, and the two + custom canvas primitives. Move your mouse. +

+
+ +
+ +
+ + SpikeBurst — lazy R3F canvas (~300 spikes, two draw + calls) that mounts only near the viewport, pauses off-screen and on + hidden tabs, tilts toward the pointer over a slow idle spin, and + holds a static pose under reduced motion. DPR capped at 1.5. + +
+ + {/* ——— AmbientGradient ——— */} +
+ + +
+ +
+

+ Copy sits on the cool-white side of the diagonal band. Static + CSS fallback without WebGL; single frame under reduced motion. +

+
+
+
+
+ + {/* ——— DotOrbit ——— */} +
+ +
+ +
+
+ +
+
+ Homepage defaults (scaled) +
+
+
+ +
+
+ +
+
+ Denser, blue-led palette via props +
+
+
+
+
+ + {/* ——— Reveal ——— */} +
+ +
+ {REVEAL_CARDS.map((card, index) => ( + +
+

{card.title}

+

+ {card.body} +

+
+
+ ))} +
+ + Everything above honours prefers-reduced-motion — flip it in your OS + settings and reload: static gradient frame, static ring, static + burst, no reveals. + +
+
+ ) +} diff --git a/src/app/[locale]/(main)/motion-kit/page.tsx b/src/app/[locale]/(main)/motion-kit/page.tsx new file mode 100644 index 00000000..5a57635e --- /dev/null +++ b/src/app/[locale]/(main)/motion-kit/page.tsx @@ -0,0 +1,25 @@ +import { setRequestLocale } from 'next-intl/server' +import type { Metadata } from 'next' +import { MotionKitDemo } from './motion-kit-demo' + +/** + * Prototype route for evaluating the motion kit (ADR 0013) — not linked + * from navigation, noindexed, and intentionally English-only. Delete or + * fold into real pages once the kit pieces are adopted. + */ + +export const metadata: Metadata = { + title: 'Motion kit — prototype', + robots: { index: false, follow: false }, +} + +export default async function MotionKitPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + + return +} diff --git a/src/components/home/scroll-story/acts/dot-orbit.tsx b/src/components/home/scroll-story/acts/dot-orbit.tsx index a3934aea..5b1b1269 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 ( -