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.
From INSPIRATION.md, row 10:
| 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 |
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).
x = 2 cos n, y = 2 sin n, z = 2 cos n) at opacity 0 to 0.87, 550 ms easeInQuart (shots/d01-arrival-banner.png: a pale ghost ball behind the first headline word). The source also writes delay: 300, but inside the spring's config; react-spring (chunk 600) reads delay only from the spring's props, so no delay is applied (see VERIFY.md).radial 31, spacing 1 for 175 ticks. Because the shell (radius 31) is wider than the camera's distance (30), the ball bursts outward past the viewer and settles as a scattered field around the headline (shots/d02-intro-sheet.png, shots/d03-rest.png). At 60 fps that is about 2.9 s.radial 10, spacing 1 for 100 ticks pulls everything into the ball, still grouped by hue (shots/d10-orb-forming.png to shots/d11-orb.png), and the label "Explore our portfolio by color" fades in at the bottom left (Neutral 15/21 px, rgb(106,108,110), fixed, left 37.5 px, bottom 40 px, z-index 9).+0.01 sin(i + t) in x and +0.02 sin(i + t) in y (about 0.15 and 0.3 units after the easing, period about 6.3 s, offset per disc). The whole group turns around the vertical axis by 0.0015 rad per frame (about 5° a second at 60 fps, a full turn in about 70 s), slowed to 0.0005 while a disc is hovered. The pointer's height tilts the group around the horizontal axis through a smoothed accumulator, rotation.x = 0.008 × (x + 50 y) at steady state, so moving the pointer from top to bottom swings the ball by about ±0.4 rad (±23°). That spin plus tilt is the "rotation" a visitor feels.enabled: false, enablePan: false, autoRotate: false. Captured: an 840 px horizontal drag and a 610 px vertical drag with the button held leave the cloud where idle drift alone would (shots/d20-d25-drag-wheel-sheet.png, frames a to d; the drags ran in the start phase, 10 to 15 s after load, before the orb formed, which does not change the result since the controls are disabled in every state).deltaY / 250 to a running sum, and the camera eases to z = 30 + clamp(sum, -10, 20) (factor 0.02 per frame), so zoom runs from z 20 to z 50. The sum itself is not clamped: after a long wheel one way, wheeling back does nothing until the excess is cancelled. Captured in the orb state (shots/verify/verify-zoom-a-orb.png to verify-zoom-d-wheel-down-7500-20s.png): one -7500 px wheel event zooms in (disc pixels cover 40% of the frame, against 23% at rest); a following +5000 px event leaves the sum at -10, so the camera keeps closing in (51%); a further +7500 px zooms out to a ball about 60% of its rest width (9%). The earlier pair shots/d24-wheel-out-10s.png and shots/d25-wheel-in-10s.png (one +5000 px and one -7500 px event, each followed by 10 s) was taken in the start phase, and at 3 fps the camera covers under half of each move in 10 s, so d25 is back near the default z 30, not a view from z 20. The page never scrolls (scrollY 0 and body overflow: hidden before and after the wheel events in the verify capture).onPointerDown runs the same handler as hover; a click never navigates.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).
forceRadial (shell), forceCollide (radius plus spacing) and a pull toward one anchor per group, then freeze it.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.Gotchas.
shots/d11-orb.png). Either go opaque or sort.shots/d02-intro-01.png).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).
radial 20, spacing 1.5, camera eases back to z 35; a cursor ring follows the pointer: a 60 × 60 px div (pointer-events: none) with an SVG circle, r 18, stroke 6, in the foreground colour, scaling in from 0 (seen in the DOM, not in a frame).x = sin(i/2 - 0.5) × 25, so about -12, 0 and +12 units, y 0), scaled 3.2× (4× on touch); the camera steps back to z 40, 50 and 60 as picks accumulate, and the shell re-spaces (radial 25/30/35, spacing 1/0.5/0.25). The label counts down beside an icon of filled circles: "Hover 2 more colors", "Hover 1 more color" ("Touch …" on a phone); after the third pick the text gives way to a 2.5 s countdown ring. On the phone the label read-outs (sources/capture-measurements.json, tap0 to tap8) show the no-pick label through the fourth tap and no label text from the fifth tap on (the three-pick countdown ring has no text); the "Touch 2 more colors" and "Touch 1 more color" labels were never caught, so when the three picks registered is not established.shots/d12-cluster-sheet.png, frame 00 shows the colour-view label, frames 01 to 05 the orb label again).sources/cloud-source-excerpts.txt, "Colour matching in a Web Worker").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.
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).
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) |
| 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 |
__NEXT_DATA__, headers)/_next/static/chunks/: webpack-ade1cb0b1434887f.js, framework-0c70fb2ef59568fa.js, main-97b1af3c27ac5753.js, pages/_app-bac1a869e134c078.js, 487-fcd5e2fa3ca8af36.js, 396-f7f185a0095fd825.js, 769-d44d76e47ac5833b.js, 692-ff517e40a91bd6db.js, pages/index-32f8177892fabe39.js, 020d8314-9e90170b4660584f.js (three), 600-ec8a3c39732ab4c6.js (fiber), 46.e27fa7bbd4668db9.js (drei), 705.64c101786dbad2d5.js (the cloud), 629.2f5e6234c63fc6be.js (the worker); /_next/static/160u6NXhVUQxeSouTstU_/_buildManifest.js; /_next/static/css/b39dbd97004bc4b5.csssteps/01-desktop-rest-probe-idle.json, steps/02-desktop-orb-explore-by-color.json, steps/03-phone-orb-explore-by-color.json, steps/04-desktop-drag-wheel.json (run on fsit2 through design/tools/capture-remote.sh)sources/cloud-source-excerpts.txt, sources/page-data-summary.json, sources/capture-measurements.jsonsteps/verify-01-desktop-orb-wheel-stack.json (one run; PNGs and its meta.json in shots/verify/) and one more read of 705.64c101786dbad2d5.js; see VERIFY.mddesign/inspiration/INSPIRATION.md (row 10), design/inspiration/cleverfranke-dot-cloud.md, design/inspiration/bar-stage-drawers.md, design/inspiration/components-to-copy.md, design/inspiration/cleverfranke/desktop-top.png and mobile-top.png (the earlier renders show the "start" field, not the orb)reducedMotion: "reduce" context option (capture.cjs has no switch for it yet) would confirm.easings), zustand (v4 from the deprecation warning) and framer-motion; the source maps that would name them were not fetched./va/script.js is most likely Vercel Web Analytics behind a rewrite; not opened.view-transition; 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, and nothing in the captures shows a view transition.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.
README.md and component.json and listed every stack, mechanism, timing and measurement claim.sources/capture-measurements.json (the agent's condensed evals, css steps, probes and burst times for runs 01 to 03; run 04's evals were not kept), sources/cloud-source-excerpts.txt, sources/page-data-summary.json and the four steps files. Arithmetic was redone: sphere triangle counts, spin rate, tilt steady state, bob amplitude, pixels per unit, camera easing at the measured frame rates.705.64c101786dbad2d5.js (the cloud, 33,934 bytes) once more from the Mac with a desktop User-Agent, to check the claims the excerpts do not quote (point-plane segments, shader, spring chain, shrink springs, labels, timers, hover handler, pick row, cursor ring, sheet direction). The copy lived in the session scratchpad and is deleted._app, index, chunks 46, 396 and 600), found in the session scratchpad outside the project, to settle points the excerpts left open (React version, chunk map, react-spring's delay handling and frame caps, the headline preset).d01-arrival-banner, d02-intro-sheet, d04-idle-sheet, d10-orb-forming, d11-orb, d12-cluster-sheet, d12-cluster-00-crops, d14-match-sheet, d20-d25-drag-wheel-sheet, d24-wheel-out-10s, d25-wheel-in-10s, m01-m06-states-sheet, m06-picks, m07-match-sheet, m07-match-00, m07-match-03, m07-match-04, m07-match-05, m08-outro-sheet, m09-after-visit, and the four fresh verify/verify-zoom-* shots.design/tools/capture-remote.sh: steps/verify-01-desktop-orb-wheel-stack.json, results in shots/verify/ (meta.json and four PNGs). 21 steps, 0 step errors, 0 page errors, 145 s. Requests: one load of / and one HEAD of / from inside the page for the response headers. It waited 74 s for the orb, then sent single wheel events of -7500, +5000 and +7500 px with 20 s between them, the pointer never moved (no hover, no tilt). Disc coverage in the four PNGs was measured as the share of pixels whose channel spread exceeds 40 (every second pixel).Stack
window.next.version is "12.3.1" at runtime; x-powered-by: Next.js; __NEXT_DATA__ buildId 160u6NXhVUQxeSouTstU_, page "/", gsp true.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).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.<style id="stitches"> present (676,466 characters); the only stylesheet is css/b39dbd97004bc4b5.css; tokens $foreground, @bp3 in chunk 705.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).window.__THREE__ is "143" in all three runs that read it.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.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.new Worker(n.tu(new URL(n.p+n.u(629),n.b))); its answer sets the palette and the state "loading"./fonts/NeutralStd-Regular.woff2 and -Medium.woff2; document.fonts "Neutral 400" and "Neutral 500 900".#rcc-decline-button was found and clicked in every run; "Reject non-essential" and "Accept all" in d01-arrival-banner.png./va/script.js in the probe script lists.__NEXT_DATA__; Lottie 0, video 0, custom cursor 0, backdrop filter 0; scroll-behavior: smooth; body overflow: hidden.Dot cloud
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.__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.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).high-performance, no preserveDrawingBuffer.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)..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.meshBasicMaterial transparent; one Instances with limit equal to the disc count.onPointerOver and onPointerDown call the same handler; in the orb state that handler only switches to the colour view; no disc navigates.enabled:!1, autoRotate:!1, enablePan:!1; the d20 to d23 frames agree.scrollY stayed 0 and body overflow hidden.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.h2 with filter: drop-shadow(rgba(0, 0, 0, 0.12) 1px 1px 0px) (probe).d11-orb.png and verify-zoom-a-orb.png.d02-intro-sheet.png is filled by a few huge discs.matchMedia anywhere in chunks 705, 600 or 46.Explore by color
1px solid, padding 11.25 px, 15 px type, z-index 9, 119.7 × 33.0 px; display: none at 1440 px (@bp3).L&&d in the handler); m01-m06-states-sheet.png frame 5 shows the close box and the touch label.strokeDashoffset) fills during the dwell; DOM: 60 × 60 div, circle r 18, stroke 6, nearly scaled out when read.[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.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.Match and reveal
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.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.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.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.@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.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)
version:"18.3.1",rendererPackageName:"react-dom" in framework-0c70fb2ef59568fa.js.546 to static/chunks/020d8314-9e90170b4660584f.js and 600 to 600-ec8a3c39732ab4c6.js; chunk 46 carries OrbitControls (minPolarAngle, enableDamping, dampingFactor).storageKey "theme"; react-cookie-consent defaults (containerClasses:"CookieConsent", byCookieValue) in _app; Vimeo player code in chunk 396.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.Each item was wrong or overstated; the text was corrected in place where named.
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.shots.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.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.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.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.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.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).not_confirmed[0].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./va/script.js, colour fidelity, exact versions of fiber, drei, react-spring, zustand and framer-motion: as the README lists them.tmp/, no hidden files, only PNG, JSON, Markdown and text files.shots/d02-intro-sheet.png at 1.81 MB; nothing over 2 MB. Text excerpts are small (cloud-source-excerpts.txt 12.8 KB, capture-measurements.json 18.2 KB).meta.json files, and run 04's evals (the scrollY read-outs) are missing from them; the verify run's meta.json (9.8 KB) is kept in shots/verify/ and covers that point.cf-dl, cf-py) are still in that scratchpad, outside the project; they were read, not changed, and are left to their owner.