Clever°Franke teardown

2026-10-21 · read https://www.cleverfranke.com/ (homepage HTML, its __NEXT_DATA__, 15 JavaScript files and the one stylesheet, each fetched once) and drove it headless on fsit2 through four steps files; the matched case led to https://www.cleverfranke.com/project/wellcome-design-system · serves inspiration row 10 (the dot cloud), with a bearing on row 7 (stage and drawer) and row 13 (which idle stage).

Three corrections to ../../cleverfranke-dot-cloud.md, all from source and confirmed in captures where noted: a disc is one palette colour of one case, not one case (282 discs for 86 cases); dragging does not rotate anything (the orbit controls are mounted disabled; the turning is an automatic spin plus a tilt that follows the pointer); and a disc never opens a case by itself (you pick three colours and the site finds the case whose palette matches best).

Checked by a verifier on 2026-10-22: VERIFY.md lists what held, what was corrected in place here and in component.json, and what stays unconfirmed.

1. What the owner liked

From INSPIRATION.md, row 10:

2. The stack

Layer Finding Evidence
Framework Next.js 12.3.1, pages router, statically generated page (gsp: true), React 18.3.1 x-powered-by: Next.js header; "12.3.1" in main-97b1af3c27ac5753.js; "18.3.1" in framework-0c70fb2ef59568fa.js; __NEXT_DATA__ with buildId 160u6NXhVUQxeSouTstU_, page "/", gsp true
Hosting and CDN Vercel edge cache headers server: Vercel, x-vercel-cache: HIT, x-vercel-id pdx1::fra1::…
CMS Sanity (project bgm3v1ft, dataset production), studio at cms.cleverfranke.com og:image on cdn.sanity.io/images/bgm3v1ft/production/…; image refs image-<hash>-2560x1440-jpg in the page JSON; @sanity/image-url style builder in chunk 487 and in the cloud (.width(640).quality(50)); CSP frame-ancestors 'self' https://cms.cleverfranke.com http://localhost:3333 (Sanity Studio's dev port)
Styling Stitches (CSS in JS) with tokens and eight breakpoints, bp1 30em to bp8 120em (bp3 = 52.5em = 840 px) <style id="stitches"> in the HTML; class names c-hzpbim, c-ZEmqm…; tokens $foreground, @bp3 in the chunks; the only stylesheet b39dbd97004bc4b5.css is 658 bytes
Theme next-themes, light and dark, follows the system by default; the ◐ button at the bottom centre toggles it storageKey "theme", attribute "class", defaultTheme "system" in _app; setTheme/resolvedTheme in chunk 769; the cloud reads resolvedTheme to paint the scene white or black
DOM animation framer-motion, with MotionConfig reducedMotion="user" around the app motion.div/AnimatePresence usage across chunks; (0,o.jsx)(ct,{reducedMotion:"user",… in _app
3D three.js r143 through @react-three/fiber v8 and drei window.__THREE__ = "143" at runtime; REVISION … i="143" in chunk 020d8314-9e90170b4660584f.js (607 KB); rendererPackageName:"@react-three/fiber" and reconciler build 18.0.0-fc46dba67-20220329 in chunk 600; OrbitControls in chunk 46; Instances/Instance API (limit, per-instance color, scale, userData) in chunk 705
3D loading the cloud is a next/dynamic component with ssr: false, pulling four lazy chunks (546 three, 600 fiber, 46 drei, 705 the cloud) after hydration index chunk: Promise.all([t.e(546),t.e(600),t.e(46),t.e(705)]).then(…e.CaseExperience), ssr:!1
Layout physics d3-force-3d (three dimensions) with a cluster force (d3-force-cluster API) numDimensions(3), force("cluster", …centers(…).strength(.1).centerInertia(.1)), force("r", forceRadial), force("collide", …iterations(2)), force("center") in chunk 705
3D springs react-spring v9 (useSpring, useChain, useSpringRef, easings) M.useSpring, M.useChain, M.easings.easeInExpo in chunk 705
State zustand store shared by the canvas and the DOM labels console warning on every run: "[DEPRECATED] Default export is deprecated. Instead use import { create } from 'zustand'"; store fields currentState, exploreState, activeIndexes, hoverIndex, activePalette, showContent in the index chunk
Worker colour matching runs in a Web Worker new Worker(… n.u(629) …) in chunk 705; 629.2f5e6234c63fc6be.js (2.6 KB)
Fonts Neutral, regular and medium, self-hosted and preloaded <link rel=preload href="/fonts/NeutralStd-Regular.woff2"> and -Medium.woff2; document.fonts: "Neutral 400", "Neutral 500 900"
Video Vimeo player for "Watch our story" (video 877940803) chunk 396 (Vimeo code); home.videoId in the page JSON
Cookie banner react-cookie-consent defaults containerClasses:"CookieConsent", ids #rcc-confirm-button ("Accept all") and #rcc-decline-button ("Reject non-essential") in _app
Analytics Google Tag Manager GTM-W2XNWDJ, GA4 G-6MZF471GT9, Universal Analytics UA-3211909-3, Google Ads AW-991534915, plus a first-party /va/script.js probe script list after "Reject non-essential" was clicked; ids in _app and the HTML
Not used no GSAP, no Lenis or other smooth scroller, no Lottie, no video element on the page, no custom cursor CSS, no backdrop filters probe: globals only __NEXT_DATA__; lottieEls 0, videoEls 0, customCursorEls 0, backdropFilterEls 0; html { scroll-behavior: smooth }, body overflow: hidden

3. Component playbooks

3.1 The dot cloud (the orb)

What it is in pixels. A full-viewport WebGL2 canvas behind the page: 1440 × 900 CSS pixels on the desktop capture (backing store 1440 × 900 at DPR 1), 390 × 844 on the phone (backing store 780 × 1688 at DPR 2). White scene (black in the dark theme). 282 flat discs, which are really spheres in a flat unlit colour at 0.87 opacity, seen through a wide perspective camera, so the ones near the edges stretch into ellipses pointing away from the centre (shots/d03-rest.png, shots/d10-orb-forming.png). At rest they form a ball about 800 to 900 px across (outer discs span about 930 px in shots/d11-orb.png and 770 px in shots/verify/verify-zoom-a-orb.png), a little left of the viewport centre (centre near x 650), sorted by hue: yellows on top, greens to the left, blues and teals along the bottom, reds and oranges to the right (shots/d11-orb.png; on the phone shots/m03-orb.png, where the ball overfills the width). The headline "A data design and technology consultancy" sits over it in Neutral; discs pass behind the type; the headline (an h2; the h1 is the logo) carries filter: drop-shadow(rgba(0,0,0,.12) 1px 1px 0) to stay legible.

Where the discs come from. props.pageProps.homepage.experienceItems holds 35 experiences with 86 case items; every case item lists one to five colours with a ratio (its share of the case image). The cloud flattens all of them: one disc per colour, 282 in all (sources/page-data-summary.json). Size comes from the ratio: radius 2.5 × clamp(ratio, 0.2, 0.85) world units with a pointer (0.5 to 2.125), 2 × clamp(ratio, 0.5, 0.85) on touch (1.0 to 1.7, so the small ones are twice as big on a phone). Each colour is assigned to the nearest of ten hues by RGB distance (red, orange, yellow, lime, green, mint, cyan, blue, violet, purple); blue takes 103 of the 282 discs. The hue decides the cluster, the cluster decides where on the ball the disc lives. At the orb's depth (camera 30 units away, 75° vertical field of view) one unit is about 19.6 px on the desktop, so a disc is roughly 10 to 42 px in radius before perspective.

DOM structure.

<!-- absolute, top 0, left 0, height 100vh, min-height -webkit-fill-available, width 100%, z-index -1 -->
<div class="c-…">
  <div style="position: relative; width: 100%; height: 100%; overflow: hidden; touch-action: none;">
    <div style="width: 100%; height: 100%;"><canvas …></canvas></div>
  </div>
</div>
<!-- the headline and nav sit above in a container with pointer-events: none, so the pointer reaches the canvas through the type -->
<div class="c-hzpbim c-hzpbim-gacCWS-showContent-true" style="opacity: 1; transform: none;">
  <span class="c-ZEmqm …">Explore our portfolio by color</span>
</div>

Captured from the page (sources/capture-measurements.json, keys wrap and orbLabel). The canvas computes to display: block; 1440px × 900px; the R3F wrapper has touch-action: none. On mount the component sets document.body.style.overflow = "hidden" and restores visible on unmount, which is why the homepage is one screen.

Rendering. Measured by wrapping the WebGL draw calls for three seconds (steps/01-desktop-rest-probe-idle.json, eval canvas): exactly one drawElementsInstanced call per frame, 282 instances, 11,904 indices per instance. That index count is three.js's SphereGeometry(1, 64, 32) (3,968 triangles), matching sphereGeometry args={[1, 2*segments, segments]} with segments: 32 in the source, so each frame draws about 1.1 million triangles for discs that read as flat circles. Context attributes: WebGL2, antialias: true, alpha: true, powerPreference: "high-performance", preserveDrawingBuffer: false. The material is meshBasicMaterial transparent (no lights), with instances drawn in index order and not depth sorted. R3F's default DPR range [1, 2] applies (phone measured at 2); on a 2× laptop that would be a 2880 × 1800 backing store (not measured on a laptop).

Behaviour (all from chunk 705, quoted in sources/cloud-source-excerpts.txt).

Phone. Same 282 instances (measured), bigger minimum radius, and a portrait frame that cuts the ball at the sides, which is why it looks like fewer, larger discs. The nav becomes a vertical list over the cloud; a bordered "Explore by color" button sits at the bottom right (3.2).

Accessibility and reduced motion. The discs exist only in WebGL: no DOM nodes, no focus, no keyboard path, and no text alternative was seen in the wrapper markup (the canvas element's own attributes were not captured). The cases stay reachable through Work. framer-motion honours "reduce motion" for the DOM labels and sheets (MotionConfig reducedMotion="user"), but none of the three 3D chunks (705, 600, 46) calls matchMedia, and the cloud's motion is driven by useFrame, so by the source the spin, bob, burst and tilt keep running under reduced motion (not confirmed in a capture, see section 7).

Rebuild for ETS27 (library-agnostic recipe).

  1. Data: one node per speaker (or per session or theme), colour from a palette that means something (track, day, kind of company), size from one honest number or uniform. Keep the record id on the node so a click can open the drawer directly.
  2. Layout: precompute positions on a sphere grouped by colour (a Fibonacci sphere with each group given an arc of longitudes is enough), or run d3-force-3d with forceRadial (shell), forceCollide (radius plus spacing) and a pull toward one anchor per group, then freeze it.
  3. Render: one instanced mesh with per-instance colour and an unlit material. three.js InstancedMesh (or R3F with drei Instances) is the easy path; OGL or regl if the bundle must stay small. Use a camera with a wide field of view (about 75°) to get C°F's ellipses at the edges for free.
  4. Motion: slow spin (about 5° a second), a gentle bob per node, a tilt that follows the pointer; scale every step by frame time so it runs the same on 60 and 120 Hz screens.
  5. Interaction: raycast on pointer move; hover grows the node 1.5× and writes the speaker's name and session into the bar (row 14, "the bar speaks for the canvas"); click opens the speaker drawer (row 7). Add real drag-to-rotate with damping (drei OrbitControls or a small trackball) and pinch or wheel zoom clamped like C°F's (z 20 to 50), since the owner asked for both and C°F has neither drag nor pinch.
  6. "By track" as C°F's colour view: re-target the group anchors so the ball loosens and the colours separate, with the label in the bar; on the phone a button enters it, taps act at once.
  7. Reduced motion: no spin, no bob, no burst; a static ball and cross-fades.
  8. Performance: one draw call as C°F does, but a far lighter disc: a 16 × 8 sphere (224 triangles) cuts the 1.1 million triangles per frame about 18 times, and a 32-segment camera-facing circle (32 triangles) over 100 times; cap DPR at 2 (or 1.5 on phones); render on demand when nothing moves.

Gotchas.

3.2 Explore by color (the colour view and picking three colours)

What it is. The real "Explore by color": the ball loosens into colour groups and the visitor picks three colours; the site then finds the case whose palette is closest. On the desktop it is entered by hovering any disc; the bottom-left label becomes a three-circle outline icon plus "Hover over circles to explore our portfolio by color" in the foreground colour (shots/d12-cluster-00-crops.png). On the phone it is a button: an <a> with "Explore by color", position: fixed; right: 20px; bottom: 16px; border: 1px solid; padding: 11.25px, Neutral 15 px, z-index 9, measured at 120 × 33 px, hidden from 840 px up (display: none on the desktop capture). Tapping it hides the headline and the nav, puts a boxed close button at the top right and shows "Touch circles to explore our portfolio by color" (shots/m05-explore-tapped.png).

States and timings (source, 60 fps).

Captured. On the phone, the full sequence ran: the button, taps, enlarged picks (large pink, blue and teal discs; frame 03 shows four large discs, so which three were picked is not certain from the frames, and they stay spread over the screen rather than lined up at the centre, since by the source the per-frame fly-in covers only about a third of the way at 4 fps during the 2.5 s countdown), then "matching colors to portfolio image" with a rotating arrow icon while every other disc shrinks away and, last, the enlarged discs vanish too (shots/m07-match-sheet.png, frames 00 to 05). On the desktop the hover switched the view, but none of eight dwell attempts registered a pick in the headless run (section 7).

Rebuild for ETS27. Keep the idea, change the input. Three picks to reach one speaker is a game, not a finder; for ETS27 the colour view is best as "by track" or "by day", and a single tap or click opens the drawer. If a playful finder is wanted, the matching is ten lines of code (nearest palette colour, mean distance) and can match a visitor's three interests to a session the same way. The dwell ring is a nice reactive cursor for row 9; keep it on the desktop, skip it on touch.

Gotchas. The 6 s reset loop collapses the colour view if the visitor hesitates. The dwell only completes if the pointer stays put, which fights the slow spin. There is no keyboard path at all.

3.3 The match and the case reveal

What it is. When the worker answers (state "loading"), the non-picked discs shrink by 5% of their size per frame; a 2 s easeInExpo spring then shrinks the three picked discs and draws them to the centre (its config also holds delay: 1000, which react-spring ignores, see VERIFY.md), and when that spring ends the state becomes "matched", the cloud unmounts and the matched case's image appears as a field of points on the white stage: a PlaneGeometry with the image's aspect, subdivided into round(width / 13 × 3.5) segments each way (about 388 × 388 on a 1440 px screen, 105 × 105 on a phone), drawn as points with a custom shader (casePlaneMaterial) that samples the image (640 px wide, quality 50, from Sanity), pushes each point in depth by its luminance and stirs it with 3D Perlin noise. In the phone capture the image spans about 85% of the width (the code sizes it from a constant named SCREEN_WIDTH_PERCENTAGE, 90, which it reads as an angle, and the camera distance). Three chained springs settle it: 2.5 s easeOutQuint, 3 s easeOutCirc, 2.5 s easeOutCirc; the pointer tilts it by up to 0.1 rad. A black sheet arrives with "Show meta data ^", a bordered "Visit this case →" and "Back to all C°F colors" beside a countdown ring; on the phone it slides up from the bottom (0.7 s easeInOut, shots/m07-match-07.png); on the desktop, by the source, it slides in from the left. Left alone, by the source, the case goes away after 18 s and the cloud returns.

Outro. "Visit this case" starts a bouncy spring (mass 4.2, tension 316, friction 12) that recolours every point to the case's dominant colour and swells it, while the scene background tweens from white to that colour (1.5 s easeInExpo; the 200 ms delay written inside the spring config is ignored); when the tween rests the router pushes /project/<slug>. Captured on the phone: the sheet leaves, the image turns into a teal silhouette, the background floods teal, and the Wellcome case page loads with its hero and white header (shots/m08-outro-sheet.png, shots/m09-after-visit.png, final URL /project/wellcome-design-system).

Rebuild for ETS27. This is the strongest piece for the speakers. The speaker's photo (or the marker sketch of row 8) can assemble from points the same way when the drawer opens, and the colour flood is a ready-made transition from the stage to a full speaker page. Recipe: three.js Points on a subdivided plane with a texture lookup in the vertex shader, depth from luminance, a noise offset whose amplitude springs down from 1 to 0.2; the drawer itself in DOM (framer-motion, Motion One or GSAP). Keep the point count near 10 to 40 thousand on phones.

Gotchas. The reveal waits on a texture load; the 18 s auto-return is shown only as a countdown ring beside "Back to all C°F colors", with no text saying the case will close; the route change after the flood is a plain client navigation, so the case page simply replaces the stage (no shared element).

4. Flow and transitions

Headless frame rate matters for reading the bursts: SwiftShader on fsit2 drew 3.0 fps on the desktop and 4.3 fps on the phone, and every 3D motion here is counted in frames, so the bursts show the right order but stretched time (the 2.9 s "start" phase took about 60 s headless). Time-based steps stretched too: the headline word reveal (about 1.3 s by the source) was still rising 12 to 15 s after load in shots/d02-intro-sheet.png, the picks' 2 s shrink took 8 to 12 s on the phone (shots/m07-match-sheet.png, frames 00 to 05), and the 1.5 s background flood had not finished 5 s after the click (shots/m08-outro-sheet.png). Both libraries cap how far one frame may advance an animation (framer-motion's frame loop at 40 ms, Math.min(t - timestamp, 40) in _app; react-spring's rafz at 64 ms, Math.min(64, …) in chunk 600), so at 3 to 4 fps they run several times slower. Only the setTimeout steps (2.5 s confirm, 6 s reset, 18 s return) and the worker keep wall-clock time. The durations below are from the source at 60 fps.

Transition What moves, in order Duration Shots
Arrival discs fade in as a pale ball (0 to 0.87 opacity); the headline words rise from overflow-hidden masks, each from y: 100%, opacity 0; the nav items follow one by one fade 550 ms (the 300 ms delay in its config is ignored); words 800 ms each with 100 ms stagger on cubic-bezier(.46,.03,.52,.96) (long copy: 550 ms, 30 ms, cubic-bezier(.4,0,.2,1)) shots/d01-arrival-banner.png, shots/m01-arrival-banner.png
Burst ("start") the ball explodes outward past the camera and settles as a scattered field 175 ticks, about 2.9 s shots/d02-intro-sheet.png, shots/m02-start.png, shots/d03-rest.png
Start to orb the field contracts into a ball grouped by hue; the label fades and rises in (y 20 to 0) 100 ticks, about 1.7 s, plus the per-frame easing shots/d10-orb-forming.png, shots/d11-orb.png, shots/m03-orb.png
Idle slow spin, bob, pointer tilt continuous; a full turn about 70 s the orb turning in shots/d14-match-sheet.png (8 frames over 32 s); shots/d04-idle-sheet.png is the start field before the orb formed (about 48 frames, under a second of 60 fps motion, across 16 s of headless time)
Orb to colour view hover (desktop) or the button (phone, hides headline and nav, close button appears); the ball loosens, groups separate, camera backs off to z 35, label swaps label 0.3 s; layout per frame shots/d12-cluster-sheet.png, shots/d12-cluster-00-crops.png, shots/m05-explore-tapped.png, shots/m06-picks.png
Picks to match picked discs fly to a row of three, scaled 3.2×; after the third, 2.5 s wait, worker, "matching colors to portfolio image", others shrink 5% per frame, then the picks shrink into the centre 2.5 s countdown, then 2 s easeInExpo for the picks (the 1 s delay in its config is ignored) shots/m07-match-sheet.png (frames 00 to 05)
Match reveal case image assembles from points; black sheet slides up (phone) or in from the left (desktop) points 2.5 + 3 + 2.5 s; sheet 0.7 s easeInOut; auto return after 18 s shots/m07-match-07.png
Outro to case page sheet leaves; points recolour to the case's dominant colour and swell; background floods that colour; client route to /project/<slug> spring (mass 4.2, tension 316, friction 12); background 1.5 s easeInExpo (the 200 ms delay in its config is ignored) shots/m08-outro-sheet.png, shots/m09-after-visit.png
Zoom wheel moves the camera between z 20 and z 50 eases per frame shots/verify/verify-zoom-a-orb.png to verify-zoom-d-wheel-down-7500-20s.png (orb); shots/d20-d25-drag-wheel-sheet.png (e, f: start phase, see 3.1)

5. Shots

File What it shows Device
shots/d01-arrival-banner.png arrival: cookie banner, pale ball fading in, first headline word desktop 1440 × 900
shots/d02-intro-00.png to -11.png, shots/d02-intro-sheet.png the burst and the headline and nav reveals, no input desktop
shots/d03-rest.png the scattered "start" field, edge discs as ellipses desktop
shots/d04-idle-00.png to -07.png, shots/d04-idle-sheet.png 16 s with no input during the start phase (about 48 frames headless; the orb has not formed and the label is not up yet) desktop
shots/d10-orb-forming.png the field contracting, label fading in desktop
shots/d11-orb.png the orb at rest, sorted by hue, "Explore our portfolio by color" desktop
shots/d12-cluster-00.png to -05.png, shots/d12-cluster-sheet.png after a hover sweep: colour-view label in frame 00, back to the orb label from frame 01 (the 6 s reset) desktop
shots/d12-cluster-00-crops.png crops of frame 00: the area under the pointer, and the label with its three-circle icon desktop
shots/d13-picks.png after eight dwell attempts, no pick registered desktop
shots/d14-match-00.png to -07.png, shots/d14-match-sheet.png no match on desktop: the orb keeps turning desktop
shots/d15-outro-00.png to -05.png, shots/d15-outro-sheet.png, shots/d16-after-visit.png "Visit this case" absent on desktop, page unchanged desktop
shots/d20-drag-a-before.png, shots/d21-drag-b-idle-2500ms.png idle pair 2.5 s apart (baseline drift) desktop
shots/d22-drag-c-after-horizontal-drag.png, shots/d23-drag-d-after-vertical-drag.png after an 840 px and a 610 px drag with the button down: no rotation desktop
shots/d24-wheel-out-10s.png, shots/d25-wheel-in-10s.png, shots/d20-d25-drag-wheel-sheet.png start phase: 10 s after one +5000 px wheel event (camera farther out), 10 s after one -7500 px event (camera back near z 30, not z 20; see 3.1) desktop
shots/verify/verify-zoom-a-orb.png, -b-wheel-up-7500-20s.png, -c-wheel-down-5000-20s.png, -d-wheel-down-7500-20s.png, shots/verify/meta.json verifier, orb state: at rest; 20 s after -7500 px (zoomed in); 20 s after +5000 px (still closing in, since the wheel sum is unclamped); 20 s after +7500 px (zoomed out) desktop
shots/m01-arrival-banner.png, shots/m02-start.png phone arrival with banner; the burst phone 390 × 844 @2x
shots/m03-orb.png, shots/m04-orb-later.png phone orb, "Explore by color" button bottom right phone
shots/m05-explore-tapped.png, shots/m06-picks.png, shots/m01-m06-states-sheet.png colour view after the button: headline and nav gone, close button, touch label; after the taps phone
shots/m07-match-00.png to -07.png, shots/m07-match-sheet.png three picks, matching, discs shrinking, case image as points, the black sheet phone
shots/m08-outro-00.png to -04.png, shots/m08-outro-sheet.png outro: teal flood of points and background phone
shots/m09-after-visit.png the Wellcome case page reached through the cloud phone

6. Sources

7. Not confirmed


Verification

Clever°Franke teardown: verification

Verifier pass on 2026-10-22 UTC (the evening of 2026-10-21 local). The aim was to refute the teardown's claims, not to restate them. Result: 50 claims held, 13 statements were wrong or overstated and are corrected in place in README.md and component.json, and 7 points stay unconfirmed. The stack and the source-read mechanisms are solid; the errors sit in how the headless captures were read (the wheel pair, the idle burst, the "real time" assumption), in one mechanism attribution (which discs the 2 s shrink acts on) and in three spring delays that the library ignores.

1. How it was checked

2. What held

Stack

  1. Next.js 12.3.1, pages router, static page: window.next.version is "12.3.1" at runtime; x-powered-by: Next.js; __NEXT_DATA__ buildId 160u6NXhVUQxeSouTstU_, page "/", gsp true.
  2. Vercel: server: Vercel, x-vercel-cache: HIT, x-vercel-id iad1::fra1::… from fsit2 (the first point of presence depends on where the request comes from; the agent saw pdx1).
  3. Sanity: og:image goes through /_next/image to cdn.sanity.io/images/bgm3v1ft/production/…-2560x…; CSP frame-ancestors 'self' https://cms.cleverfranke.com http://localhost:3333;; .width(640).quality(50).url() on the case image in chunk 705.
  4. Stitches: <style id="stitches"> present (676,466 characters); the only stylesheet is css/b39dbd97004bc4b5.css; tokens $foreground, @bp3 in chunk 705.
  5. Theme: html class "light" with no stored theme (system default, light scheme in the capture); attribute:"class", defaultTheme:"system" in the _app excerpt; the scene background follows it with a 300 ms linear spring (Pe in chunk 705).
  6. three.js r143: window.__THREE__ is "143" in all three runs that read it.
  7. Lazy 3D chunks: resource timing in the verify run lists 020d8314-9e90170b4660584f.js, 600-ec8a3c39732ab4c6.js, 46.e27fa7bbd4668db9.js, 705.64c101786dbad2d5.js and the worker 629.2f5e6234c63fc6be.js on the homepage. The probe's script list lacks 46 and 705, which fits webpack removing its script tags once a lazy chunk has loaded.
  8. d3-force-3d with a cluster force: numDimensions(3), force("cluster", …centers(…).strength(.1).centerInertia(.1)), forceRadial, collide with iterations(2), force("center"); tick counts via function O(e){return 1-Math.pow(.001,1/e)}, so O(175) settles in 175 ticks.
  9. zustand: the same deprecation warning appears in the verify run's console.
  10. Worker: new Worker(n.tu(new URL(n.p+n.u(629),n.b))); its answer sets the palette and the state "loading".
  11. Fonts: preloads /fonts/NeutralStd-Regular.woff2 and -Medium.woff2; document.fonts "Neutral 400" and "Neutral 500 900".
  12. Cookie banner: #rcc-decline-button was found and clicked in every run; "Reject non-essential" and "Accept all" in d01-arrival-banner.png.
  13. Analytics: GTM-W2XNWDJ, G-6MZF471GT9, UA-3211909-3, AW-991534915 and /va/script.js in the probe script lists.
  14. Not used: probe globals only __NEXT_DATA__; Lottie 0, video 0, custom cursor 0, backdrop filter 0; scroll-behavior: smooth; body overflow: hidden.

Dot cloud

  1. One drawElementsInstanced per frame, 282 instances, 11,904 indices, on desktop and phone. SphereGeometry(1, 64, 32) has 2 × 64 × 31 = 3,968 triangles (11,904 indices), so 1.12 million triangles per frame.
  2. Data: recomputed in the page from __NEXT_DATA__ with the chunk's nearest-colour rule: 35 experiences, 86 case items (all with an image), 282 colours, 1 to 5 per case, hue counts 44, 38, 19, 5, 10, 19, 14, 103, 20, 10; identical to sources/page-data-summary.json.
  3. Radius 2.5 × clamp(ratio, .2, .85), touch 2 × clamp(ratio, .5, .85); cluster = index of the nearest of the ten palette hues by squared RGB distance (chunk 705).
  4. Canvas: WebGL2, 1440 × 900 at DPR 1; 780 × 1688 backing for 390 × 844 at DPR 2; antialias, alpha, high-performance, no preserveDrawingBuffer.
  5. Wrapper: absolute, 100vh, min-height: -webkit-fill-available, z-index: -1 (source); the R3F div has touch-action: none (measured); body overflow set to hidden on mount and back to visible on unmount (source).
  6. Forces and camera per state: start radial 31, spacing 1, 175 ticks; orb radial 10, spacing 1, 100 ticks; colour view radial 20/25/30/35 with spacing 1.5/1/0.5/0.25 for 0 to 3 picks; loading radial 20, spacing 5; camera z 30 (start and orb) and 35/40/50/60 in the colour view.
  7. Per-frame motion: easing factors .07/.06/.05; bob .01 and .02 × sin(i + t) only in explore (steady offsets about 0.14 and 0.33 units, period 2π s); spin .0015 rad per frame (5.2° a second at 60 fps, a turn in about 70 s), .0005 while a disc is hovered; tilt 0.008 × (x + 50 y) at steady state, from a window mousemove hook normalised to -1..1, so about ±0.4 rad.
  8. Opacity 0.87 set on the material; meshBasicMaterial transparent; one Instances with limit equal to the disc count.
  9. Hover grows 1.5× over 500 ms linear and shrinks over 150 ms; onPointerOver and onPointerDown call the same handler; in the orb state that handler only switches to the colour view; no disc navigates.
  10. Drag: OrbitControls mounted with enabled:!1, autoRotate:!1, enablePan:!1; the d20 to d23 frames agree.
  11. Wheel dolly: confirmed in the orb state by the verify run (disc coverage 23% at rest, 40% after -7500 px, 9% after the final +7500 px); scrollY stayed 0 and body overflow hidden.
  12. Label: "Explore our portfolio by color", Neutral 15/21 px, rgb(106, 108, 110) (#6a6c6e in source), left 37.5 px, bottom 40 px (@bp3), z-index 9; it enters with opacity 0 and y 20.
  13. Headline h2 with filter: drop-shadow(rgba(0, 0, 0, 0.12) 1px 1px 0px) (probe).
  14. Hue layout: yellows on top, greens left, blues and teals along the bottom, reds and oranges right, in d11-orb.png and verify-zoom-a-orb.png.
  15. The burst reaches the lens: frame 01 of d02-intro-sheet.png is filled by a few huge discs.
  16. The start phase took about a minute headless: run 02's eval at about 61 s after load already shows the orb label; 175 ticks at 3 fps is 58 s.
  17. Frame rates: 9 frames in 3 s on the desktop (3.0 fps), 13 in 3 s on the phone (4.3 fps).
  18. No matchMedia anywhere in chunks 705, 600 or 46.

Explore by color

  1. Phone button: fixed, right 20 px, bottom 16 px, 1px solid, padding 11.25 px, 15 px type, z-index 9, 119.7 × 33.0 px; display: none at 1440 px (@bp3).
  2. The button switches to the colour view and hides the content; the close button sits fixed at top 15, right 15; on touch, taps on discs are ignored while the content is shown (L&&d in the handler); m01-m06-states-sheet.png frame 5 shows the close box and the touch label.
  3. Labels in chunk 705: "Hover over" or "Touch" + " circles to explore our portfolio by color" in the foreground colour; "Hover" or "Touch" + " N more color(s)"; a 2.5 s countdown ring (radius 14) at three picks; "matching colors to portfolio image" beside an arrow turning once per 2.5 s.
  4. Dwell: an 800 ms linear spring picks the hovered disc, 150 ms back; a rotating ring shows in the colour view before the first pick and a progress ring (strokeDashoffset) fills during the dwell; DOM: 60 × 60 div, circle r 18, stroke 6, nearly scaled out when read.
  5. Touch picks at once; picks fly to [sin(i/2 - .5) × 25, 0, 0] (the list is reversed, so the first pick goes to +12), scaled 3.2× (4× on touch), easing .035 per frame.
  6. Timers: 2.5 s confirm then the worker; with fewer than three picks a 6 s timer clears them and the effect sets the orb again when none are left (the loop behind d12-cluster-sheet.png: frame 00 shows the colour-view label, frames 01 to 05 the orb label); 18 s after "matched" the cloud returns.
  7. Matching: for each picked colour the nearest palette colour by RGB distance, averaged, smallest mean wins (chunk 629 excerpt).

Match and reveal

  1. Points plane: segments: Math.round(width / segmentDivider * 3.5) with divider 13 (388 at 1440, 105 at 390); planeGeometry [1, h/w, segments, segments]; casePlaneMaterial transparent, depthWrite: false.
  2. Shader: luma dot(rgb, (0.299, 0.587, 0.114)), "Classic Perlin 3D Noise by Stefan Gustavson", pos.z += luma * (uLumaZ + cnoise(noisePos) * uNoiseAmplitude) + offsetZ * 0.5.
  3. Size from SCREEN_WIDTH_PERCENTAGE * Math.PI / 180 (90 read as an angle), the camera distance and both aspect ratios; the phone image spans about 84% of the width in m07-match-sheet.png frame 07.
  4. Chain: 2.5 s easeOutQuint, 3 s easeOutCirc, 2.5 s easeOutCirc with useChain; noise amplitude 0 to 1, then 0.5, then 0.2; pointer tilt 0.1 rad.
  5. Sheet: from the left at @bp3 and up (x: -100%), from the bottom below it (y: 100%), 0.7 s easeInOut; on the phone in frame 07 with "Show meta data", "Visit this case" and "Back to all C°F colors" beside its countdown.
  6. Outro: spring mass 4.2, tension 316, friction 12 drives the outro colour, depth and point size; the colour is the palette entry with the largest ratio; background tween 1.5 s easeInExpo; router.push("/project/" + slug) on rest. Captured: teal silhouette, pale teal flood, the Wellcome page with a white logo over the hero, final URL /project/wellcome-design-system.

Read in the agent's downloaded bundles (no new requests)

  1. React 18.3.1: version:"18.3.1",rendererPackageName:"react-dom" in framework-0c70fb2ef59568fa.js.
  2. Chunk map and drei: the webpack runtime maps 546 to static/chunks/020d8314-9e90170b4660584f.js and 600 to 600-ec8a3c39732ab4c6.js; chunk 46 carries OrbitControls (minPolarAngle, enableDamping, dampingFactor).
  3. next-themes with the default storageKey "theme"; react-cookie-consent defaults (containerClasses:"CookieConsent", byCookieValue) in _app; Vimeo player code in chunk 396.
  4. Headline preset: the word component picks isLongCopy ? 550 ms/30 ms : 800 ms/100 ms with isLongCopy: w > 10, so the six-word headline uses 800 ms words with a 100 ms stagger, as the README says.
  5. useChain without time steps runs the three point-plane springs one after another (promise chain in chunk 600).

3. Corrections, verifier, 2026-10-22

Each item was wrong or overstated; the text was corrected in place where named.

  1. The wheel sum is not clamped. Said: each wheel event adds deltaY / 250 "to an offset clamped to [-10, 20]". True: the running sum is unclamped and only the applied offset is clamped (e+=t.deltaY/250, u.current=clamp(e,-10,20)). Fresh check: after -7500 px, a +5000 px event left the camera still closing in (coverage 40% to 51%, verify-zoom-c). Corrected in README 3.1 and the cloud's mechanism.
  2. The wheel capture did not show what it was said to show. Said: "wheel down for 10 s" and "wheel up brings discs right up to the lens". True: one +5000 px and one -7500 px event, each followed by 10 s, both in the start phase; by the source's 0.02 easing per frame at the measured 3 fps, the camera reaches about z 39 in d24 and is back near z 30 in d25, so d25's huge disc is the start shell passing the default camera, not a z 20 view. Replaced by the orb-state verify shots in README 3.1, sections 4 and 5, and the cloud's shots.
  3. Time-based animation did not keep real time headless. Said: react-spring and framer-motion steps "keep their real lengths". The cause is in the bundles: framer-motion's frame loop clamps each frame to 40 ms (Math.max(Math.min(t-s.w.timestamp,40),1) in _app) and react-spring's rafz to 64 ms (r.flush(e?Math.min(64,d-e):16.667) in chunk 600), so at 3 to 4 fps they run roughly 3.5 to 8 times slow. True: the headline word reveal (about 1.3 s by source, plus a base delay) was still rising 12 to 15 s after load (d02-intro-sheet), the picks' 2 s shrink took 8 to 12 s (m07-match frames 00 to 05: matching began before frame 00 at 1.5 s, the large discs were still there at frame 04 at 9.5 s and gone at frame 05 at 11.6 s), and the 1.5 s background flood was unfinished 5 s after the click (m08-outro-sheet). Corrected in README section 4's opening paragraph and the not_confirmed list.
  4. The 2 s easeInExpo shrink acts on the picks, not on the other discs. Said: "the non-picked discs shrink to nothing (2 s easeInExpo after a 1 s delay)". True: in "loading" the other discs shrink 5% per frame; the spring multiplies the three picks' scale and position by its progress, pulling them into the centre, and its end sets "matched", which unmounts the cloud. The frames agree: non-picks are dots by frame 04 while the large discs remain, and those are gone by frame 05. Corrected in README 3.3, the section 4 table, the explore component's timings and transitions[4].layers.
  5. The idle burst is not the orb. Said: d04-idle-sheet.png shows the idle orb, "three seconds of real motion across 16 s". True: it ran within the first minute after load, still in the start phase (the label read-out right after it is empty), and 16 s at 3 fps is about 48 frames, under a second of 60 fps motion. d14-match-sheet.png shows the orb turning. Corrected in the section 4 Idle row and the section 5 row.
  6. Lighter discs: about 18 times, not twenty or more. A 16 × 8 sphere has 2 × 16 × 7 = 224 triangles, 3,968 / 224 = 17.7; a 32-segment circle (32 triangles) is 124 times lighter. Corrected in README 3.1 rebuild step 8.
  7. The phone picks were not lined up at the centre. Said: "three picks (pink, blue, teal enlarged at the centre)". True: the enlarged discs stay spread over the screen, and frame 03 shows four large discs (blue, pink, two teal), so which three were picked is not certain from the frames. Corrected in README 3.2.
  8. The phone label read-outs do not show when the picks registered. Said: the labels "went empty from the fifth tap on, which fits three picks already made by then". True: tap0 to tap3 show the no-pick label and tap4 to tap8 show no text; the "Touch 2 more colors" and "Touch 1 more color" labels never appear, so the read-outs do not fit one pick per tap. Corrected (and the typo fixed) in README 3.2.
  9. Ball size and position. Said: "about 900 px across, centred on the viewport". Measured: outer discs span about 930 px in d11-orb.png and 770 px in verify-zoom-a-orb.png, and the ball's centre sits near x 645 to 660, 60 to 75 px left of centre. Corrected in README 3.1.
  10. The "Back" link is labelled. Said: "the countdown hides the 'Back' link's purpose behind an icon". True: the link reads "Back to all C°F colors"; what is shown only as an icon is the 18 s auto-return. Corrected in README 3.3 gotchas.
  11. The spring delays are not applied. Said: fade "after a 300 ms delay", shrink "after a 1 s delay", background "after 200 ms". Each delay sits inside the spring's config; the bundled react-spring (chunk 600) takes the delay only from the spring's props (p=o(n.delay||0,t) in its scheduler), and none of its ten delay references reads config.delay. Corrected in README 3.1, 3.3 and the section 4 rows, the three components' timings, and transitions durations (Picks to match 13500 to 12500 ms, Outro 1700 to 1500 ms).
  12. The dwell explanation was vague. The not-confirmed entry now gives the likely causes: at 3 fps react-spring's 800 ms dwell (64 ms per frame) needs about 4 s of hold, and from the orb the first pointer-over only switches views. Rewritten in README section 7 and not_confirmed[0].
  13. The drags ran before the orb formed. The drag frames (10 to 15 s after load) are of the start phase; the result stands because the controls are disabled in every state, which README 3.1 now says.

4. Not confirmed

  1. The desktop dwell pick and the desktop matched view with its left-hand sheet; a real GPU, or holds of 5 s or more right after the switch to the colour view, would settle it.
  2. Which three discs were the phone picks, and when they registered (the tap read-outs never caught a "Touch N more" label).
  3. Two CSS rules mentioning view-transition, counted by the probe in two runs: they are not in the homepage HTML, its stylesheet or the 15 chunks read, so their source (a prefetched route chunk or a third-party script) is unknown; nothing in the captures shows a view transition.
  4. Field of view 75° (R3F's default camera): still not read at runtime.
  5. Reduced motion in a capture, the dark theme, "Show meta data", /va/script.js, colour fidelity, exact versions of fiber, drei, react-spring, zustand and framer-motion: as the README lists them.
  6. The cursor rings' look: in the DOM and the source, not in a frame.
  7. Real-time pace on a display: neither the frame-counted motion nor the springs were measured at 60 fps.

5. Folder tidiness