All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s
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>
733 lines
23 KiB
TypeScript
733 lines
23 KiB
TypeScript
import type { ReactNode } from 'react';
|
||
import { interpolate } from 'remotion';
|
||
import { C } from '../lib/colors';
|
||
import { springIn, softSpring, clampLerp } from '../lib/easings';
|
||
|
||
// FloatingChaos overlay — see definition at the bottom of this file. Adds
|
||
// real plumbing-pain to the tangle phase: code-snippet cards, an env file
|
||
// with a leaked-secret squiggle, and a 502 retry toast. Without it the
|
||
// "tangle" reads as four icons drifting; with it the tangle reads as a
|
||
// developer's actual desktop right before they give up. Fades out during
|
||
// the resolve phase so the clean row at the end isn't competing for eye.
|
||
|
||
// Phase 1 (frames 0–360 global → localFrame 0..360): the "problem" scene.
|
||
//
|
||
// Four floating mini-icons (hosting / OAuth / Docker / secrets) drift into the
|
||
// frame from corners and slowly tangle together with dotted lines forming
|
||
// a mess. A small "the plumbing" label fades in. In the last ~1.5s the mess
|
||
// RESOLVES: icons snap into a clean horizontal row and a fifth icon (the
|
||
// "prompt cursor") slides in from the right — foreshadowing PromptScene.
|
||
//
|
||
// Typography monospace, indigo + fg-muted. No flashy gradients.
|
||
|
||
interface Icon {
|
||
id: string;
|
||
label: string;
|
||
start: { x: number; y: number };
|
||
tangle: { x: number; y: number };
|
||
resolved: { x: number; y: number };
|
||
draw: (color: string) => ReactNode;
|
||
}
|
||
|
||
// Resolved row centered at y=540, evenly spaced.
|
||
const ROW_Y = 540;
|
||
const ROW_CENTER_X = 1920 / 2;
|
||
const RESOLVED_SPACING = 230;
|
||
const RESOLVED_OFFSETS = [-1.5, -0.5, 0.5, 1.5];
|
||
|
||
const ICONS: Icon[] = [
|
||
{
|
||
id: 'hosting',
|
||
label: 'hosting',
|
||
start: { x: 360, y: 220 },
|
||
tangle: { x: 760, y: 420 },
|
||
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[0] * RESOLVED_SPACING, y: ROW_Y },
|
||
draw: (color) => <ServerStackIcon color={color} />,
|
||
},
|
||
{
|
||
id: 'oauth',
|
||
label: 'OAuth',
|
||
start: { x: 1560, y: 240 },
|
||
tangle: { x: 1100, y: 460 },
|
||
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[1] * RESOLVED_SPACING, y: ROW_Y },
|
||
draw: (color) => <KeyIcon color={color} />,
|
||
},
|
||
{
|
||
id: 'docker',
|
||
label: 'containers',
|
||
start: { x: 380, y: 820 },
|
||
tangle: { x: 840, y: 620 },
|
||
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[2] * RESOLVED_SPACING, y: ROW_Y },
|
||
draw: (color) => <ContainerIcon color={color} />,
|
||
},
|
||
{
|
||
id: 'secrets',
|
||
label: 'secrets',
|
||
start: { x: 1540, y: 820 },
|
||
tangle: { x: 1080, y: 660 },
|
||
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[3] * RESOLVED_SPACING, y: ROW_Y },
|
||
draw: (color) => <LockBigIcon color={color} />,
|
||
},
|
||
];
|
||
|
||
const ICON_SIZE = 96;
|
||
|
||
// Resolve transition timings (in local frames):
|
||
const RESOLVE_START = 300; // 10s
|
||
const RESOLVE_END = 345; // 11.5s — clean row landed
|
||
const HANDOFF_START = 330; // prompt-cursor icon slides in from the right
|
||
const HANDOFF_END = 358;
|
||
|
||
export function HookScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||
// Icons enter staggered over the first ~36 frames.
|
||
const iconEntries = ICONS.map((_, i) => springIn(localFrame, fps, 6 + i * 8));
|
||
|
||
// Tangle phase: drift from start position toward tangle position between
|
||
// localFrame 60 and 240.
|
||
const tangleProgress = clampLerp(localFrame, 60, 240);
|
||
|
||
// Resolve phase: from tangled centroid to clean row.
|
||
const resolveProgress = clampLerp(localFrame, RESOLVE_START, RESOLVE_END);
|
||
|
||
// Sub-label "the plumbing nobody talks about" fades in at ~30, fades
|
||
// out at the resolve.
|
||
const labelIn = clampLerp(localFrame, 30, 60);
|
||
const labelOut = 1 - clampLerp(localFrame, RESOLVE_START - 10, RESOLVE_START + 14);
|
||
const labelOpacity = Math.min(labelIn, labelOut);
|
||
|
||
// Resolved-row sub-label: monospace, fades in as row settles.
|
||
const rowLabelIn = clampLerp(localFrame, RESOLVE_END - 6, RESOLVE_END + 14);
|
||
|
||
// Handoff cursor icon — slides in from the right edge.
|
||
const handoffProgress = clampLerp(localFrame, HANDOFF_START, HANDOFF_END);
|
||
const handoffX = interpolate(handoffProgress, [0, 1], [1920 + 60, ROW_CENTER_X + 2.5 * RESOLVED_SPACING]);
|
||
|
||
// Tangle SVG opacity: full while tangled, fades out as resolve happens.
|
||
const tangleOpacity = Math.min(
|
||
clampLerp(localFrame, 30, 90),
|
||
1 - clampLerp(localFrame, RESOLVE_START - 6, RESOLVE_START + 18),
|
||
);
|
||
|
||
return (
|
||
<div style={{ position: 'absolute', inset: 0 }}>
|
||
{/* Top label */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: 140,
|
||
left: 0,
|
||
right: 0,
|
||
textAlign: 'center',
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 18,
|
||
letterSpacing: 6,
|
||
textTransform: 'uppercase',
|
||
color: C.fgSubtle,
|
||
opacity: labelOpacity,
|
||
}}
|
||
>
|
||
the plumbing nobody talks about
|
||
</div>
|
||
|
||
{/* Resolved-row label, appears when icons land in clean row */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: ROW_Y - 110,
|
||
left: 0,
|
||
right: 0,
|
||
textAlign: 'center',
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 16,
|
||
letterSpacing: 5,
|
||
textTransform: 'uppercase',
|
||
color: C.fgSubtle,
|
||
opacity: rowLabelIn,
|
||
transform: `translateY(${interpolate(rowLabelIn, [0, 1], [6, 0])}px)`,
|
||
}}
|
||
>
|
||
what if one prompt handled all of it
|
||
</div>
|
||
|
||
{/* Floating chaos — code-snippet cards + 502 toast + env warning.
|
||
Sits behind the icons, behind the tangle lines. Their entrance
|
||
is staggered across frames 80–200 so the tangle grows in
|
||
visible complexity rather than just sitting at peak. */}
|
||
<FloatingChaos localFrame={localFrame} />
|
||
|
||
{/* Tangle SVG (dotted lines between icons) */}
|
||
<TangleLines
|
||
icons={ICONS}
|
||
tangleProgress={tangleProgress}
|
||
opacity={tangleOpacity}
|
||
/>
|
||
|
||
{/* Icons themselves */}
|
||
{ICONS.map((icon, i) => {
|
||
const entry = iconEntries[i];
|
||
// Position interpolation: start → tangle → resolved.
|
||
const sx = icon.start.x;
|
||
const sy = icon.start.y;
|
||
const tx = icon.tangle.x;
|
||
const ty = icon.tangle.y;
|
||
const rx = icon.resolved.x;
|
||
const ry = icon.resolved.y;
|
||
|
||
// Three-stage lerp.
|
||
let x: number;
|
||
let y: number;
|
||
if (resolveProgress > 0) {
|
||
x = interpolate(resolveProgress, [0, 1], [tx, rx]);
|
||
y = interpolate(resolveProgress, [0, 1], [ty, ry]);
|
||
} else {
|
||
x = interpolate(tangleProgress, [0, 1], [sx, tx]);
|
||
y = interpolate(tangleProgress, [0, 1], [sy, ty]);
|
||
}
|
||
|
||
// Subtle floating drift while tangled.
|
||
const driftPhase = (localFrame + i * 12) / 30;
|
||
const driftX = tangleProgress > 0.4 && resolveProgress < 0.1
|
||
? Math.sin(driftPhase * Math.PI) * 8
|
||
: 0;
|
||
const driftY = tangleProgress > 0.4 && resolveProgress < 0.1
|
||
? Math.cos(driftPhase * Math.PI * 0.8) * 8
|
||
: 0;
|
||
|
||
const scale = interpolate(entry, [0, 1], [0.6, 1]);
|
||
const opacity = entry;
|
||
|
||
// Icon color: muted during tangle, then accent on resolve.
|
||
const iconColor =
|
||
resolveProgress > 0.4 ? C.accent : C.fgMuted;
|
||
|
||
return (
|
||
<div
|
||
key={icon.id}
|
||
style={{
|
||
position: 'absolute',
|
||
left: x + driftX - ICON_SIZE / 2,
|
||
top: y + driftY - ICON_SIZE / 2,
|
||
width: ICON_SIZE,
|
||
height: ICON_SIZE,
|
||
opacity,
|
||
transform: `scale(${scale})`,
|
||
transformOrigin: 'center center',
|
||
transition: 'none',
|
||
}}
|
||
>
|
||
<IconCard color={iconColor}>
|
||
{icon.draw(iconColor)}
|
||
</IconCard>
|
||
{/* Label below */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: ICON_SIZE + 8,
|
||
left: 0,
|
||
right: 0,
|
||
textAlign: 'center',
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 12,
|
||
letterSpacing: 2,
|
||
textTransform: 'uppercase',
|
||
color: resolveProgress > 0.5 ? C.fgMuted : C.fgSubtle,
|
||
}}
|
||
>
|
||
{icon.label}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Hand-off cursor icon — slides in from the right at the end */}
|
||
{handoffProgress > 0.02 && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: handoffX - ICON_SIZE / 2,
|
||
top: ROW_Y - ICON_SIZE / 2,
|
||
width: ICON_SIZE,
|
||
height: ICON_SIZE,
|
||
opacity: handoffProgress,
|
||
}}
|
||
>
|
||
<IconCard color={C.accent} glow>
|
||
<PromptCursorIcon color={C.accent} />
|
||
</IconCard>
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: ICON_SIZE + 8,
|
||
left: 0,
|
||
right: 0,
|
||
textAlign: 'center',
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 12,
|
||
letterSpacing: 2,
|
||
textTransform: 'uppercase',
|
||
color: C.accent,
|
||
}}
|
||
>
|
||
one prompt
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IconCard({ children, color, glow }: { children: React.ReactNode; color: string; glow?: boolean }) {
|
||
return (
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
borderRadius: 18,
|
||
backgroundColor: C.bgElevated,
|
||
border: `1.5px solid ${color === C.accent ? C.accent : C.borderStrong}`,
|
||
boxShadow: glow
|
||
? `0 0 0 4px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.55)`
|
||
: `0 10px 30px rgba(0,0,0,0.5)`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TangleLines({
|
||
icons,
|
||
tangleProgress,
|
||
opacity,
|
||
}: {
|
||
icons: Icon[];
|
||
tangleProgress: number;
|
||
opacity: number;
|
||
}) {
|
||
if (opacity < 0.01) return null;
|
||
// Draw dotted lines between every pair of icons at their tangle positions.
|
||
// Reveal via strokeDashoffset as tangleProgress grows.
|
||
const pairs: Array<[Icon, Icon]> = [];
|
||
for (let i = 0; i < icons.length; i++) {
|
||
for (let j = i + 1; j < icons.length; j++) {
|
||
pairs.push([icons[i], icons[j]]);
|
||
}
|
||
}
|
||
return (
|
||
<svg
|
||
width={1920}
|
||
height={1080}
|
||
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity }}
|
||
>
|
||
{pairs.map(([a, b], i) => {
|
||
// Interpolate endpoints between start and tangle positions.
|
||
const ax = interpolate(tangleProgress, [0, 1], [a.start.x, a.tangle.x]);
|
||
const ay = interpolate(tangleProgress, [0, 1], [a.start.y, a.tangle.y]);
|
||
const bx = interpolate(tangleProgress, [0, 1], [b.start.x, b.tangle.x]);
|
||
const by = interpolate(tangleProgress, [0, 1], [b.start.y, b.tangle.y]);
|
||
const len = Math.hypot(bx - ax, by - ay);
|
||
const reveal = clampLerp(tangleProgress, 0.2 + i * 0.05, 0.6 + i * 0.05);
|
||
return (
|
||
<line
|
||
key={i}
|
||
x1={ax}
|
||
y1={ay}
|
||
x2={bx}
|
||
y2={by}
|
||
stroke={C.fgSubtle}
|
||
strokeWidth={1.5}
|
||
strokeDasharray="4 7"
|
||
strokeDashoffset={(1 - reveal) * len}
|
||
opacity={0.55}
|
||
/>
|
||
);
|
||
})}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// ---- Icons ----
|
||
|
||
function ServerStackIcon({ color }: { color: string }) {
|
||
return (
|
||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||
<rect x="8" y="10" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||
<rect x="8" y="22" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||
<rect x="8" y="34" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||
<circle cx="12" cy="14.5" r="1.2" fill={color} />
|
||
<circle cx="12" cy="26.5" r="1.2" fill={color} />
|
||
<circle cx="12" cy="38.5" r="1.2" fill={color} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function KeyIcon({ color }: { color: string }) {
|
||
return (
|
||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||
<circle cx="17" cy="24" r="9" stroke={color} strokeWidth={2.2} fill="rgba(99,102,241,0.08)" />
|
||
<circle cx="17" cy="24" r="3" fill={color} />
|
||
<path d="M 26 24 L 42 24 M 38 24 L 38 31 M 34 24 L 34 29" stroke={color} strokeWidth={2.2} strokeLinecap="round" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function ContainerIcon({ color }: { color: string }) {
|
||
return (
|
||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||
<rect x="6" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||
<rect x="17" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||
<rect x="28" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||
<rect x="17" y="11" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||
<path d="M 4 36 Q 24 42 44 36" stroke={color} strokeWidth={2} fill="none" strokeLinecap="round" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function LockBigIcon({ color }: { color: string }) {
|
||
return (
|
||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||
<path d="M 15 22 L 15 15 A 9 9 0 0 1 33 15 L 33 22" stroke={color} strokeWidth={2.2} fill="none" />
|
||
<rect x="11" y="22" width="26" height="18" rx="3" stroke={color} strokeWidth={2.2} fill="rgba(99,102,241,0.10)" />
|
||
<circle cx="24" cy="30" r="2.2" fill={color} />
|
||
<rect x="23" y="31" width="2" height="5" fill={color} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function PromptCursorIcon({ color }: { color: string }) {
|
||
return (
|
||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||
<rect x="8" y="18" width="32" height="14" rx="3" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.10)" />
|
||
<text x="13" y="29" fill={color} fontSize="11" fontFamily="ui-monospace, SF Mono, Menlo, monospace" letterSpacing="1">›</text>
|
||
<rect x="20" y="22" width="2" height="6" fill={color} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// =============== FloatingChaos ====================================
|
||
// Renders four small "fragment" cards during the tangle phase. Each one
|
||
// makes the chaos concrete instead of abstract: real config snippets,
|
||
// a real-looking error toast, a real env file with a security squiggle.
|
||
// Together they sell the "tagelange Bastelei" reality the rest of the
|
||
// scene only gestures at.
|
||
//
|
||
// Timing:
|
||
// Each card has a personal `appearAt` frame, spring-entrances with a
|
||
// slight rotation. During the hold (frames 240–270) they jitter ±2px
|
||
// so the layout doesn't feel frozen. From frame 280 they collapse —
|
||
// scale to 0.85, translate inward toward the icon row, fade out, so
|
||
// the resolve reads as "the chaos folds into the clean answer."
|
||
//
|
||
// Layout: four corners of the canvas, each card sized < 380px so they
|
||
// frame the centre tangle without crowding the icons.
|
||
|
||
interface ChaosCard {
|
||
id: string;
|
||
appearAt: number;
|
||
x: number;
|
||
y: number;
|
||
rotate: number; // degrees
|
||
render: () => ReactNode;
|
||
}
|
||
|
||
const CHAOS_HOLD_START = 240;
|
||
const CHAOS_RESOLVE_START = 280;
|
||
const CHAOS_RESOLVE_END = 315;
|
||
|
||
function FloatingChaos({ localFrame }: { localFrame: number }) {
|
||
// Collapse alpha + collapse-toward-centre during resolve.
|
||
const collapse = clampLerp(localFrame, CHAOS_RESOLVE_START, CHAOS_RESOLVE_END);
|
||
if (collapse >= 0.99) return null;
|
||
|
||
const cards: ChaosCard[] = [
|
||
{
|
||
id: 'docker',
|
||
appearAt: 70,
|
||
x: 130,
|
||
y: 360,
|
||
rotate: -3.5,
|
||
render: () => <ConfigCard />,
|
||
},
|
||
{
|
||
id: 'oauth',
|
||
appearAt: 100,
|
||
x: 1450,
|
||
y: 220,
|
||
rotate: 2.4,
|
||
render: () => <OAuthCodeCard />,
|
||
},
|
||
{
|
||
id: 'env',
|
||
appearAt: 135,
|
||
x: 1330,
|
||
y: 740,
|
||
rotate: -2.6,
|
||
render: () => <EnvLeakCard />,
|
||
},
|
||
{
|
||
id: 'error',
|
||
appearAt: 175,
|
||
x: 170,
|
||
y: 750,
|
||
rotate: 3.2,
|
||
render: () => <ErrorToastCard localFrame={localFrame} />,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
pointerEvents: 'none',
|
||
opacity: 1 - collapse,
|
||
}}
|
||
>
|
||
{cards.map((card) => {
|
||
// Entrance: scale + opacity ramp over 14 frames.
|
||
const entrance = clampLerp(localFrame, card.appearAt, card.appearAt + 14);
|
||
if (entrance < 0.01) return null;
|
||
const entryScale = interpolate(entrance, [0, 1], [0.85, 1]);
|
||
const entryY = interpolate(entrance, [0, 1], [14, 0]);
|
||
|
||
// Hold jitter — keeps the cards alive during the centre tangle
|
||
// hold without making them feel busy. Different phase per card.
|
||
const jitterPhase = (localFrame + card.appearAt * 1.7) / 22;
|
||
const jitterX = Math.sin(jitterPhase * Math.PI) * 1.6;
|
||
const jitterY = Math.cos(jitterPhase * Math.PI * 0.7) * 1.4;
|
||
|
||
// Resolve: cards collapse inward toward canvas centre (960, 540)
|
||
// and scale down. By the time the icons settle into their row,
|
||
// the chaos has folded away.
|
||
const collapseScale = interpolate(collapse, [0, 1], [1, 0.7]);
|
||
const collapseDx = interpolate(collapse, [0, 1], [0, (960 - card.x) * 0.35]);
|
||
const collapseDy = interpolate(collapse, [0, 1], [0, (540 - card.y) * 0.35]);
|
||
|
||
return (
|
||
<div
|
||
key={card.id}
|
||
style={{
|
||
position: 'absolute',
|
||
left: card.x,
|
||
top: card.y,
|
||
opacity: entrance * (1 - collapse * 0.4),
|
||
transform: `translate(${jitterX + collapseDx}px, ${jitterY + collapseDy + entryY}px) rotate(${card.rotate}deg) scale(${entryScale * collapseScale})`,
|
||
transformOrigin: 'center center',
|
||
filter: collapse > 0.4 ? `blur(${collapse * 1.5}px)` : 'none',
|
||
}}
|
||
>
|
||
{card.render()}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- Chaos card variants ----
|
||
|
||
function CardFrame({
|
||
children,
|
||
width,
|
||
label,
|
||
intent = 'neutral',
|
||
}: {
|
||
children: ReactNode;
|
||
width: number;
|
||
label?: string;
|
||
intent?: 'neutral' | 'warn' | 'error';
|
||
}) {
|
||
const borderColor =
|
||
intent === 'error' ? '#dc2626' : intent === 'warn' ? '#f59e0b' : C.borderStrong;
|
||
return (
|
||
<div
|
||
style={{
|
||
width,
|
||
backgroundColor: C.bgElevated,
|
||
border: `1px solid ${borderColor}`,
|
||
borderRadius: 10,
|
||
boxShadow: '0 16px 36px rgba(0,0,0,0.55)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{label && (
|
||
<div
|
||
style={{
|
||
padding: '8px 14px',
|
||
borderBottom: `1px solid ${C.border}`,
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 10,
|
||
letterSpacing: 2,
|
||
textTransform: 'uppercase',
|
||
color: intent === 'error' ? '#dc2626' : intent === 'warn' ? '#f59e0b' : C.fgSubtle,
|
||
}}
|
||
>
|
||
{label}
|
||
</div>
|
||
)}
|
||
<div style={{ padding: '12px 14px' }}>{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ConfigCard() {
|
||
return (
|
||
<CardFrame width={340} label="docker-compose.yml">
|
||
<pre
|
||
style={{
|
||
margin: 0,
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 11.5,
|
||
lineHeight: 1.5,
|
||
color: C.fgMuted,
|
||
letterSpacing: 0.1,
|
||
}}
|
||
>
|
||
{`services:
|
||
nginx:
|
||
image: nginx:alpine
|
||
ports: ["443:443"]
|
||
volumes:
|
||
- ./certs:/etc/ssl
|
||
- ./conf.d:/etc/nginx`}
|
||
</pre>
|
||
</CardFrame>
|
||
);
|
||
}
|
||
|
||
function OAuthCodeCard() {
|
||
return (
|
||
<CardFrame width={360} label="oauth_callback.ts">
|
||
<pre
|
||
style={{
|
||
margin: 0,
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 11.5,
|
||
lineHeight: 1.5,
|
||
color: C.fgMuted,
|
||
letterSpacing: 0.1,
|
||
}}
|
||
>
|
||
{`app.use(passport.authenticate(
|
||
'oauth2', {
|
||
clientID: process.env.CID,
|
||
clientSecret: process.env.CS,
|
||
callbackURL: '/cb',
|
||
}
|
||
));`}
|
||
</pre>
|
||
</CardFrame>
|
||
);
|
||
}
|
||
|
||
function EnvLeakCard() {
|
||
// The squiggle hints at "this got committed to git by accident" —
|
||
// a recognisable plumbing failure mode.
|
||
return (
|
||
<CardFrame width={320} label="// .env" intent="warn">
|
||
<div
|
||
style={{
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 12,
|
||
lineHeight: 1.55,
|
||
color: C.fgMuted,
|
||
}}
|
||
>
|
||
<div>STRIPE_KEY=sk_live_•••</div>
|
||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||
NOTION_API_KEY=secret_3a8f
|
||
<svg
|
||
width="170"
|
||
height="6"
|
||
viewBox="0 0 170 6"
|
||
style={{ position: 'absolute', left: 0, bottom: -3, pointerEvents: 'none' }}
|
||
>
|
||
<path
|
||
d="M 0 3 Q 4 0 8 3 T 16 3 T 24 3 T 32 3 T 40 3 T 48 3 T 56 3 T 64 3 T 72 3 T 80 3 T 88 3 T 96 3 T 104 3 T 112 3 T 120 3 T 128 3 T 136 3 T 144 3 T 152 3 T 160 3 T 168 3"
|
||
stroke="#f59e0b"
|
||
strokeWidth="1.2"
|
||
fill="none"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<div style={{ color: '#f59e0b', fontSize: 10.5, marginTop: 8, letterSpacing: 0.4 }}>
|
||
⚠ in git history since v0.3.1
|
||
</div>
|
||
</div>
|
||
</CardFrame>
|
||
);
|
||
}
|
||
|
||
function ErrorToastCard({ localFrame }: { localFrame: number }) {
|
||
// The retry counter ticks while the card is visible — keeps the
|
||
// "still broken" feeling alive across the hold without changing the
|
||
// overall composition.
|
||
const elapsedSecs = Math.floor((localFrame - 175) / 30);
|
||
const retries = Math.max(1, Math.min(8, Math.floor(elapsedSecs / 1) + 1));
|
||
|
||
return (
|
||
<CardFrame width={320} intent="error">
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 22,
|
||
height: 22,
|
||
borderRadius: 11,
|
||
backgroundColor: 'rgba(220,38,38,0.16)',
|
||
border: '1.5px solid #dc2626',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
color: '#dc2626',
|
||
fontSize: 14,
|
||
fontWeight: 700,
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
!
|
||
</div>
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 12.5,
|
||
color: '#dc2626',
|
||
fontWeight: 600,
|
||
letterSpacing: 0.2,
|
||
}}
|
||
>
|
||
502 Bad Gateway
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||
fontSize: 11,
|
||
color: C.fgMuted,
|
||
marginTop: 4,
|
||
letterSpacing: 0.2,
|
||
}}
|
||
>
|
||
upstream timed out · retrying ({retries}/8)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardFrame>
|
||
);
|
||
}
|