2026-05-27 12:05:28 +02:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ParticleField — GPGPU particle simulation backing the marketing hero.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Lean Three.js, no @react-three/fiber. Renders a 256×256 (or 128×128
|
|
|
|
|
|
* on lower-end devices) float texture of positions, ping-ponged each
|
|
|
|
|
|
* frame through a sim shader, then drawn as gl_Points with an
|
|
|
|
|
|
* anti-aliased disc SDF and additive blending.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Callers MUST gate this behind the capability checks in `index.tsx` —
|
|
|
|
|
|
* this component assumes WebGL2 + float-render-target support exists
|
|
|
|
|
|
* and will throw if they don't.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
|
|
import * as THREE from 'three';
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
initFragment,
|
|
|
|
|
|
renderFragment,
|
|
|
|
|
|
renderVertex,
|
|
|
|
|
|
simFragment,
|
|
|
|
|
|
simVertex,
|
|
|
|
|
|
} from './shaders';
|
|
|
|
|
|
|
|
|
|
|
|
export interface ParticleFieldProps {
|
|
|
|
|
|
/** Sqrt of particle count. 256 → 65,536 particles, 128 → 16,384. */
|
|
|
|
|
|
textureSize: 128 | 256;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Global multiplier on drift + ring-push velocity. 1.0 default; 0.5
|
|
|
|
|
|
* for reduced-motion users. Pointer position is NOT scaled — the ring
|
|
|
|
|
|
* still tracks the cursor at full fidelity, only the ambient motion
|
|
|
|
|
|
* and the gradient-push are damped.
|
|
|
|
|
|
*/
|
|
|
|
|
|
motionScale?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldProps) {
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Renderer ---------------------------------------------------
|
|
|
|
|
|
// alpha:true so the hero gradient/border behind the canvas shows
|
|
|
|
|
|
// through where particles are sparse. premultipliedAlpha pairs with
|
|
|
|
|
|
// the premultiplied output of the render fragment shader.
|
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
|
canvas.style.display = 'block';
|
|
|
|
|
|
canvas.style.width = '100%';
|
|
|
|
|
|
canvas.style.height = '100%';
|
|
|
|
|
|
container.appendChild(canvas);
|
|
|
|
|
|
|
|
|
|
|
|
const renderer = new THREE.WebGLRenderer({
|
|
|
|
|
|
canvas,
|
|
|
|
|
|
antialias: false,
|
|
|
|
|
|
alpha: true,
|
|
|
|
|
|
premultipliedAlpha: true,
|
|
|
|
|
|
powerPreference: 'high-performance',
|
|
|
|
|
|
});
|
|
|
|
|
|
renderer.setClearColor(0x000000, 0);
|
|
|
|
|
|
|
|
|
|
|
|
// Clamp DPR — going above 2 on a 65k-particle field burns laptop GPUs
|
|
|
|
|
|
// with negligible visual gain.
|
|
|
|
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
|
|
|
|
renderer.setPixelRatio(dpr);
|
|
|
|
|
|
|
|
|
|
|
|
const initialRect = container.getBoundingClientRect();
|
|
|
|
|
|
renderer.setSize(Math.max(1, initialRect.width), Math.max(1, initialRect.height), false);
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Float-texture support check --------------------------------
|
|
|
|
|
|
// EXT_color_buffer_float is required to render INTO a float target
|
|
|
|
|
|
// on WebGL2. Without it, ping-pong won't work — bail out and let the
|
|
|
|
|
|
// wrapper fall back to the static gradient.
|
|
|
|
|
|
const gl = renderer.getContext() as WebGL2RenderingContext;
|
|
|
|
|
|
const floatExt = gl.getExtension('EXT_color_buffer_float');
|
|
|
|
|
|
if (!floatExt) {
|
|
|
|
|
|
// Tear down what we built and signal failure via the canvas
|
|
|
|
|
|
// remaining empty. The wrapper checks this synchronously before
|
|
|
|
|
|
// we even mount, so this is a belt-and-braces guard.
|
|
|
|
|
|
canvas.remove();
|
|
|
|
|
|
renderer.dispose();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Scenes & camera --------------------------------------------
|
|
|
|
|
|
// Two scenes: one for the simulation pass (fullscreen quad), one
|
|
|
|
|
|
// for the actual particle render. Both use the same OrthographicCamera
|
|
|
|
|
|
// because everything is already in clip space.
|
|
|
|
|
|
const simScene = new THREE.Scene();
|
|
|
|
|
|
const renderScene = new THREE.Scene();
|
|
|
|
|
|
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Ping-pong render targets ------------------------------------
|
|
|
|
|
|
const rtParams: THREE.RenderTargetOptions = {
|
|
|
|
|
|
minFilter: THREE.NearestFilter,
|
|
|
|
|
|
magFilter: THREE.NearestFilter,
|
|
|
|
|
|
format: THREE.RGBAFormat,
|
|
|
|
|
|
type: THREE.FloatType,
|
|
|
|
|
|
depthBuffer: false,
|
|
|
|
|
|
stencilBuffer: false,
|
|
|
|
|
|
generateMipmaps: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let rtA = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams);
|
|
|
|
|
|
let rtB = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams);
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Init pass: seed both targets with the starting field -------
|
|
|
|
|
|
const initMaterial = new THREE.ShaderMaterial({
|
|
|
|
|
|
vertexShader: simVertex,
|
|
|
|
|
|
fragmentShader: initFragment,
|
|
|
|
|
|
});
|
|
|
|
|
|
const fsQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), initMaterial);
|
|
|
|
|
|
simScene.add(fsQuad);
|
|
|
|
|
|
|
|
|
|
|
|
renderer.setRenderTarget(rtA);
|
|
|
|
|
|
renderer.render(simScene, camera);
|
|
|
|
|
|
renderer.setRenderTarget(rtB);
|
|
|
|
|
|
renderer.render(simScene, camera);
|
|
|
|
|
|
renderer.setRenderTarget(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Swap in the actual sim material on the same quad.
|
|
|
|
|
|
const simUniforms = {
|
|
|
|
|
|
uPrev: { value: rtA.texture },
|
|
|
|
|
|
uTime: { value: 0 },
|
|
|
|
|
|
uDelta: { value: 1 / 60 },
|
|
|
|
|
|
uRingPos: { value: new THREE.Vector2(0, 0) },
|
|
|
|
|
|
uRingRadius: { value: 0.22 },
|
|
|
|
|
|
uRingWidth: { value: 0.05 },
|
|
|
|
|
|
uRingActive: { value: 0 },
|
|
|
|
|
|
uMotionScale: { value: motionScale },
|
|
|
|
|
|
};
|
|
|
|
|
|
const simMaterial = new THREE.ShaderMaterial({
|
|
|
|
|
|
vertexShader: simVertex,
|
|
|
|
|
|
fragmentShader: simFragment,
|
|
|
|
|
|
uniforms: simUniforms,
|
|
|
|
|
|
});
|
|
|
|
|
|
fsQuad.material = simMaterial;
|
|
|
|
|
|
initMaterial.dispose();
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Particle geometry: one vertex per texel --------------------
|
|
|
|
|
|
const count = textureSize * textureSize;
|
|
|
|
|
|
const indexUvs = new Float32Array(count * 2);
|
|
|
|
|
|
const positionsAttr = new Float32Array(count * 3); // unused but required
|
|
|
|
|
|
for (let y = 0; y < textureSize; y++) {
|
|
|
|
|
|
for (let x = 0; x < textureSize; x++) {
|
|
|
|
|
|
const i = y * textureSize + x;
|
|
|
|
|
|
// Sample texels at their centers, not corners.
|
|
|
|
|
|
indexUvs[i * 2 + 0] = (x + 0.5) / textureSize;
|
|
|
|
|
|
indexUvs[i * 2 + 1] = (y + 0.5) / textureSize;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const particleGeo = new THREE.BufferGeometry();
|
|
|
|
|
|
particleGeo.setAttribute('position', new THREE.BufferAttribute(positionsAttr, 3));
|
|
|
|
|
|
particleGeo.setAttribute('aIndexUv', new THREE.BufferAttribute(indexUvs, 2));
|
|
|
|
|
|
// Tell Three.js never to frustum-cull this — positions live in
|
|
|
|
|
|
// the texture, not the bounding box of the buffer geometry.
|
|
|
|
|
|
particleGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 10);
|
|
|
|
|
|
|
|
|
|
|
|
// Brand colors — read from --color-accent (#6366f1) and
|
|
|
|
|
|
// --color-success (#22c55e). Kept as constants here rather than
|
|
|
|
|
|
// reading from CSS variables: those resolve to oklch in modern
|
|
|
|
|
|
// Tailwind builds, which needs parsing. Hardcode the hex values
|
|
|
|
|
|
// the design system already commits to.
|
|
|
|
|
|
const colorCalm = new THREE.Color('#6366f1');
|
|
|
|
|
|
const colorHot = new THREE.Color('#22c55e');
|
|
|
|
|
|
|
|
|
|
|
|
const renderUniforms = {
|
|
|
|
|
|
uPositions: { value: rtB.texture },
|
feat(video): v10 hero video with mute toggle — voice + bg music
Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.
## Video composition (remotion/)
Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:
- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
excerpt, an oauth_callback.ts snippet, an .env file with a yellow
squiggle warning ("in git history since v0.3.1"), and a live-ticking
502 retry toast. Tangle now reads as a developer's desktop right
before they give up, not as four icons drifting.
- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
the parse beat: three sequential highlights on the prompt text
(MCP server / searches / Notion workspace), three chips below the
input (intent / tool / secret → vault), three-stat summary panel
(tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
global, on the voice line "the prompt path and the secret path
never cross") a mini two-rail diagram with an explicit X-marker
ring lands, visualising the architectural promise the moment it's
spoken.
- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
env-var injection beats; added the lock-snap flash at frame 66,
pinned the vault at full opacity throughout, and added a dashed
vault → container connector so the secret's provenance is visible.
The "what the AI sees" panel is now 680 px wide with an eye icon,
four corner viewfinder brackets around the prompt text, and three
explicit denied lines (no secrets / no environment variables / no
tokens).
- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
container caption, <60s countdown.
- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
chips that collided with the card and with the tokens-only badge
are replaced by a clean vertical chip column at x=760: read-only
filesystem · dropped capabilities · no new privileges · 512 MB
memory cap · 0.5 CPU limit · ✓ your token only (last in green).
A vault graphic now sits below the server card with a dashed arrow
up into its env slot so the architecture story is complete in one
frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
the curious. Handshake stages simplified to your client → verified
→ scoped token. Final settlement arrow in success-green curves
from the scoped-token pill back into the card.
- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
with 36 px gaps. The "templates carry code, not credentials"
sub-caption was pulled (felt on-the-nose; the detached lock and
empty NOTION_API_KEY=? slot carry the story visually).
- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
versions had a fake "1,200+ developers building" fork counter
(pulled — solo-founder, hadn't earned). Replaced with a two-lane
architecture diagram that visualises "no paths cross" literally:
top lane prompt → AI → code, bottom lane vault → encrypted →
env, both converging at the server box on the right. v10
refinements: all seven boxes visible from frame 0 (no late
server arrival), a parallel glow tour walks across both lanes
simultaneously, a dashed vertical divider with a "no shared
node" chip pinned in the middle, and the closing line "One
sentence in. Live server out." slides down from above and lands
centred while the diagram fades to 0.12 opacity behind it —
no overlap.
- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
loop seam.
The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.
Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.
## Web integration (apps/web/components/hero-video.tsx)
New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:31:10 +02:00
|
|
|
|
// Bigger dots + higher base alpha = more volumetric "calm field"
|
|
|
|
|
|
// read at the load-in (was 1.8 / 0.42 — read as too thin, looked
|
|
|
|
|
|
// stuttery because individual particles were hard to track between
|
|
|
|
|
|
// frames). With these values the field has a denser cumulative
|
|
|
|
|
|
// glow without any change to the simulation itself.
|
|
|
|
|
|
uPointSize: { value: textureSize === 256 ? 2.8 : 3.6 },
|
2026-05-27 12:05:28 +02:00
|
|
|
|
uDpr: { value: dpr },
|
|
|
|
|
|
uColorCalm: { value: colorCalm },
|
|
|
|
|
|
uColorHot: { value: colorHot },
|
feat(video): v10 hero video with mute toggle — voice + bg music
Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.
## Video composition (remotion/)
Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:
- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
excerpt, an oauth_callback.ts snippet, an .env file with a yellow
squiggle warning ("in git history since v0.3.1"), and a live-ticking
502 retry toast. Tangle now reads as a developer's desktop right
before they give up, not as four icons drifting.
- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
the parse beat: three sequential highlights on the prompt text
(MCP server / searches / Notion workspace), three chips below the
input (intent / tool / secret → vault), three-stat summary panel
(tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
global, on the voice line "the prompt path and the secret path
never cross") a mini two-rail diagram with an explicit X-marker
ring lands, visualising the architectural promise the moment it's
spoken.
- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
env-var injection beats; added the lock-snap flash at frame 66,
pinned the vault at full opacity throughout, and added a dashed
vault → container connector so the secret's provenance is visible.
The "what the AI sees" panel is now 680 px wide with an eye icon,
four corner viewfinder brackets around the prompt text, and three
explicit denied lines (no secrets / no environment variables / no
tokens).
- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
container caption, <60s countdown.
- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
chips that collided with the card and with the tokens-only badge
are replaced by a clean vertical chip column at x=760: read-only
filesystem · dropped capabilities · no new privileges · 512 MB
memory cap · 0.5 CPU limit · ✓ your token only (last in green).
A vault graphic now sits below the server card with a dashed arrow
up into its env slot so the architecture story is complete in one
frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
the curious. Handshake stages simplified to your client → verified
→ scoped token. Final settlement arrow in success-green curves
from the scoped-token pill back into the card.
- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
with 36 px gaps. The "templates carry code, not credentials"
sub-caption was pulled (felt on-the-nose; the detached lock and
empty NOTION_API_KEY=? slot carry the story visually).
- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
versions had a fake "1,200+ developers building" fork counter
(pulled — solo-founder, hadn't earned). Replaced with a two-lane
architecture diagram that visualises "no paths cross" literally:
top lane prompt → AI → code, bottom lane vault → encrypted →
env, both converging at the server box on the right. v10
refinements: all seven boxes visible from frame 0 (no late
server arrival), a parallel glow tour walks across both lanes
simultaneously, a dashed vertical divider with a "no shared
node" chip pinned in the middle, and the closing line "One
sentence in. Live server out." slides down from above and lands
centred while the diagram fades to 0.12 opacity behind it —
no overlap.
- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
loop seam.
The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.
Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.
## Web integration (apps/web/components/hero-video.tsx)
New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:31:10 +02:00
|
|
|
|
uBaseAlpha: { value: 0.6 },
|
2026-05-27 12:05:28 +02:00
|
|
|
|
};
|
|
|
|
|
|
const particleMat = new THREE.ShaderMaterial({
|
|
|
|
|
|
vertexShader: renderVertex,
|
|
|
|
|
|
fragmentShader: renderFragment,
|
|
|
|
|
|
uniforms: renderUniforms,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
depthTest: false,
|
|
|
|
|
|
depthWrite: false,
|
|
|
|
|
|
blending: THREE.AdditiveBlending,
|
|
|
|
|
|
});
|
|
|
|
|
|
const particles = new THREE.Points(particleGeo, particleMat);
|
|
|
|
|
|
renderScene.add(particles);
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Pointer tracking ------------------------------------------
|
|
|
|
|
|
// Raw target (last pointer event), smoothed via EMA into the
|
|
|
|
|
|
// uniform each frame so the ring tracks fluidly even if events
|
|
|
|
|
|
// are sparse (touch / pen / throttled mouse).
|
|
|
|
|
|
const target = new THREE.Vector2(0, 0);
|
|
|
|
|
|
const smoothed = new THREE.Vector2(0, 0);
|
|
|
|
|
|
let hasPointer = false;
|
|
|
|
|
|
|
|
|
|
|
|
const updatePointerFromClient = (clientX: number, clientY: number) => {
|
|
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
|
|
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
|
|
|
|
// Flip Y so up is positive — matches clip space.
|
|
|
|
|
|
const y = -(((clientY - rect.top) / rect.height) * 2 - 1);
|
|
|
|
|
|
target.set(x, y);
|
|
|
|
|
|
hasPointer = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onPointerMove = (e: PointerEvent) => {
|
|
|
|
|
|
updatePointerFromClient(e.clientX, e.clientY);
|
|
|
|
|
|
};
|
|
|
|
|
|
const onPointerLeave = () => {
|
|
|
|
|
|
hasPointer = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Listen on window so the ring tracks even when the cursor is over
|
|
|
|
|
|
// the codeblocks/CTAs that sit above the canvas. The container is
|
|
|
|
|
|
// pointer-events:none-friendly because we read clientX/clientY.
|
|
|
|
|
|
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
|
|
|
|
|
container.addEventListener('pointerleave', onPointerLeave, { passive: true });
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Resize handling -------------------------------------------
|
|
|
|
|
|
const onResize = () => {
|
|
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
|
|
if (rect.width < 1 || rect.height < 1) return;
|
|
|
|
|
|
renderer.setSize(rect.width, rect.height, false);
|
|
|
|
|
|
};
|
|
|
|
|
|
const ro = new ResizeObserver(onResize);
|
|
|
|
|
|
ro.observe(container);
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Animation loop --------------------------------------------
|
|
|
|
|
|
const clock = new THREE.Clock();
|
|
|
|
|
|
let raf = 0;
|
|
|
|
|
|
let running = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Defer the first frame to idle to keep LCP clean — the hero text
|
|
|
|
|
|
// is the LCP element and must paint before we start eating GPU.
|
|
|
|
|
|
const startLoop = () => {
|
|
|
|
|
|
const tick = () => {
|
|
|
|
|
|
if (!running) return;
|
|
|
|
|
|
raf = requestAnimationFrame(tick);
|
|
|
|
|
|
|
|
|
|
|
|
const delta = Math.min(clock.getDelta(), 1 / 30); // tab-switch guard
|
|
|
|
|
|
const t = clock.elapsedTime;
|
|
|
|
|
|
|
|
|
|
|
|
// Smooth the pointer position (EMA, alpha=0.15).
|
|
|
|
|
|
smoothed.x = smoothed.x * 0.85 + target.x * 0.15;
|
|
|
|
|
|
smoothed.y = smoothed.y * 0.85 + target.y * 0.15;
|
|
|
|
|
|
|
feat(video): v10 hero video with mute toggle — voice + bg music
Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.
## Video composition (remotion/)
Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:
- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
excerpt, an oauth_callback.ts snippet, an .env file with a yellow
squiggle warning ("in git history since v0.3.1"), and a live-ticking
502 retry toast. Tangle now reads as a developer's desktop right
before they give up, not as four icons drifting.
- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
the parse beat: three sequential highlights on the prompt text
(MCP server / searches / Notion workspace), three chips below the
input (intent / tool / secret → vault), three-stat summary panel
(tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
global, on the voice line "the prompt path and the secret path
never cross") a mini two-rail diagram with an explicit X-marker
ring lands, visualising the architectural promise the moment it's
spoken.
- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
env-var injection beats; added the lock-snap flash at frame 66,
pinned the vault at full opacity throughout, and added a dashed
vault → container connector so the secret's provenance is visible.
The "what the AI sees" panel is now 680 px wide with an eye icon,
four corner viewfinder brackets around the prompt text, and three
explicit denied lines (no secrets / no environment variables / no
tokens).
- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
container caption, <60s countdown.
- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
chips that collided with the card and with the tokens-only badge
are replaced by a clean vertical chip column at x=760: read-only
filesystem · dropped capabilities · no new privileges · 512 MB
memory cap · 0.5 CPU limit · ✓ your token only (last in green).
A vault graphic now sits below the server card with a dashed arrow
up into its env slot so the architecture story is complete in one
frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
the curious. Handshake stages simplified to your client → verified
→ scoped token. Final settlement arrow in success-green curves
from the scoped-token pill back into the card.
- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
with 36 px gaps. The "templates carry code, not credentials"
sub-caption was pulled (felt on-the-nose; the detached lock and
empty NOTION_API_KEY=? slot carry the story visually).
- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
versions had a fake "1,200+ developers building" fork counter
(pulled — solo-founder, hadn't earned). Replaced with a two-lane
architecture diagram that visualises "no paths cross" literally:
top lane prompt → AI → code, bottom lane vault → encrypted →
env, both converging at the server box on the right. v10
refinements: all seven boxes visible from frame 0 (no late
server arrival), a parallel glow tour walks across both lanes
simultaneously, a dashed vertical divider with a "no shared
node" chip pinned in the middle, and the closing line "One
sentence in. Live server out." slides down from above and lands
centred while the diagram fades to 0.12 opacity behind it —
no overlap.
- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
loop seam.
The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.
Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.
## Web integration (apps/web/components/hero-video.tsx)
New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:31:10 +02:00
|
|
|
|
// Fade ring in/out when the pointer enters/leaves.
|
2026-05-27 12:05:28 +02:00
|
|
|
|
const targetActive = hasPointer ? 1 : 0;
|
feat(video): v10 hero video with mute toggle — voice + bg music
Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.
## Video composition (remotion/)
Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:
- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
excerpt, an oauth_callback.ts snippet, an .env file with a yellow
squiggle warning ("in git history since v0.3.1"), and a live-ticking
502 retry toast. Tangle now reads as a developer's desktop right
before they give up, not as four icons drifting.
- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
the parse beat: three sequential highlights on the prompt text
(MCP server / searches / Notion workspace), three chips below the
input (intent / tool / secret → vault), three-stat summary panel
(tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
global, on the voice line "the prompt path and the secret path
never cross") a mini two-rail diagram with an explicit X-marker
ring lands, visualising the architectural promise the moment it's
spoken.
- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
env-var injection beats; added the lock-snap flash at frame 66,
pinned the vault at full opacity throughout, and added a dashed
vault → container connector so the secret's provenance is visible.
The "what the AI sees" panel is now 680 px wide with an eye icon,
four corner viewfinder brackets around the prompt text, and three
explicit denied lines (no secrets / no environment variables / no
tokens).
- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
container caption, <60s countdown.
- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
chips that collided with the card and with the tokens-only badge
are replaced by a clean vertical chip column at x=760: read-only
filesystem · dropped capabilities · no new privileges · 512 MB
memory cap · 0.5 CPU limit · ✓ your token only (last in green).
A vault graphic now sits below the server card with a dashed arrow
up into its env slot so the architecture story is complete in one
frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
the curious. Handshake stages simplified to your client → verified
→ scoped token. Final settlement arrow in success-green curves
from the scoped-token pill back into the card.
- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
with 36 px gaps. The "templates carry code, not credentials"
sub-caption was pulled (felt on-the-nose; the detached lock and
empty NOTION_API_KEY=? slot carry the story visually).
- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
versions had a fake "1,200+ developers building" fork counter
(pulled — solo-founder, hadn't earned). Replaced with a two-lane
architecture diagram that visualises "no paths cross" literally:
top lane prompt → AI → code, bottom lane vault → encrypted →
env, both converging at the server box on the right. v10
refinements: all seven boxes visible from frame 0 (no late
server arrival), a parallel glow tour walks across both lanes
simultaneously, a dashed vertical divider with a "no shared
node" chip pinned in the middle, and the closing line "One
sentence in. Live server out." slides down from above and lands
centred while the diagram fades to 0.12 opacity behind it —
no overlap.
- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
loop seam.
The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.
Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.
## Web integration (apps/web/components/hero-video.tsx)
New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:31:10 +02:00
|
|
|
|
simUniforms.uRingActive.value =
|
|
|
|
|
|
simUniforms.uRingActive.value * 0.92 + targetActive * 0.08;
|
2026-05-27 12:05:28 +02:00
|
|
|
|
|
|
|
|
|
|
simUniforms.uTime.value = t;
|
|
|
|
|
|
simUniforms.uDelta.value = delta;
|
|
|
|
|
|
simUniforms.uRingPos.value.copy(smoothed);
|
|
|
|
|
|
|
|
|
|
|
|
// Sim pass: read rtA, write rtB.
|
|
|
|
|
|
simUniforms.uPrev.value = rtA.texture;
|
|
|
|
|
|
renderer.setRenderTarget(rtB);
|
|
|
|
|
|
renderer.render(simScene, camera);
|
|
|
|
|
|
renderer.setRenderTarget(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Render pass: draw particles sampling rtB.
|
|
|
|
|
|
renderUniforms.uPositions.value = rtB.texture;
|
|
|
|
|
|
renderer.render(renderScene, camera);
|
|
|
|
|
|
|
|
|
|
|
|
// Swap.
|
|
|
|
|
|
const tmp = rtA;
|
|
|
|
|
|
rtA = rtB;
|
|
|
|
|
|
rtB = tmp;
|
|
|
|
|
|
};
|
|
|
|
|
|
tick();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let idleHandle: number | null = null;
|
|
|
|
|
|
const w = window as Window & {
|
|
|
|
|
|
requestIdleCallback?: (cb: IdleRequestCallback) => number;
|
|
|
|
|
|
cancelIdleCallback?: (h: number) => void;
|
|
|
|
|
|
};
|
|
|
|
|
|
if (typeof w.requestIdleCallback === 'function') {
|
|
|
|
|
|
idleHandle = w.requestIdleCallback(() => startLoop());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
idleHandle = window.setTimeout(startLoop, 80);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Cleanup ----------------------------------------------------
|
|
|
|
|
|
// Three.js leaks GPU memory aggressively if we skip any of this.
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
running = false;
|
|
|
|
|
|
if (raf) cancelAnimationFrame(raf);
|
|
|
|
|
|
if (idleHandle !== null) {
|
|
|
|
|
|
if (typeof w.cancelIdleCallback === 'function') {
|
|
|
|
|
|
w.cancelIdleCallback(idleHandle);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
window.clearTimeout(idleHandle);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
window.removeEventListener('pointermove', onPointerMove);
|
|
|
|
|
|
container.removeEventListener('pointerleave', onPointerLeave);
|
|
|
|
|
|
ro.disconnect();
|
|
|
|
|
|
|
|
|
|
|
|
particleGeo.dispose();
|
|
|
|
|
|
particleMat.dispose();
|
|
|
|
|
|
simMaterial.dispose();
|
|
|
|
|
|
(fsQuad.geometry as THREE.BufferGeometry).dispose();
|
|
|
|
|
|
rtA.dispose();
|
|
|
|
|
|
rtB.dispose();
|
|
|
|
|
|
renderer.dispose();
|
|
|
|
|
|
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [textureSize, motionScale]);
|
|
|
|
|
|
|
|
|
|
|
|
// The container is the surface that receives pointer events.
|
|
|
|
|
|
// Visually transparent — the canvas it owns paints the field.
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className="absolute inset-0 size-full"
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|