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>
|
|||
|
|
);
|
|||
|
|
}
|