BRIDGE_Modul-S_prod / src /modules /ModuleLaunchAnimation.tsx
BRIDGE Platform Agent
feat(platform): BRIDGE Modules hub with /modules/e and /modules/s routed workspaces
1955a0f
Raw History Blame Contribute Delete
15.8 kB
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { Activity, ArrowUpRight, Mail, Megaphone, ShieldCheck, TrendingUp, Users } from "lucide-react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import StatCard from "../components/StatCard";
import { Skeleton } from "../components/Skeleton";
import SStatCard from "./s/components/StatCard";
import SGlassCard from "./s/components/GlassCard";
import { cn } from "../utils/cn";
import { ModuleId } from "./registry";
/* ------------------------------------------------------------------ */
/* Deterministic seeded PRNG (mulberry32) — re-seeded on every launch. */
/* ------------------------------------------------------------------ */
export function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function hashModuleId(id: string): number {
let h = 2166136261;
for (let i = 0; i < id.length; i++) {
h ^= id.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/* Slow-motion reassembly curve mandated by the brief. */
export const ASSEMBLY_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
interface PieceTransform {
x: number;
y: number;
rotate: number;
scale: number;
delay: number;
duration: number;
}
interface PieceSpec {
key: string;
span: string; // grid column span classes
from: PieceTransform;
}
const MIN_DISTANCE = 240;
const MAX_DISTANCE = 720;
const MIN_STAGGER_BASE = 40;
const MAX_STAGGER_JITTER = 50; // stagger = i*55 + rand(40..90) per brief
const MIN_DURATION = 2.4;
const MAX_DURATION = 3.2;
export function buildPieceSpecs(rand: () => number, count: number): PieceSpec[] {
const spans = [
"sm:col-span-3 sm:row-span-1 col-span-6",
"sm:col-span-3 sm:row-span-1 col-span-6",
"sm:col-span-3 sm:row-span-1 col-span-6",
"sm:col-span-3 sm:row-span-1 col-span-6",
"sm:col-span-4 sm:row-span-2 col-span-6",
"sm:col-span-2 sm:row-span-2 col-span-6",
"col-span-6",
"sm:col-span-2 col-span-6",
"sm:col-span-2 col-span-6",
"sm:col-span-2 col-span-6",
"col-span-6",
];
return Array.from({ length: count }, (_, i) => {
const angle = rand() * Math.PI * 2;
const distance = MIN_DISTANCE + rand() * (MAX_DISTANCE - MIN_DISTANCE);
const direction = i % 2 === 0 ? 1 : -1; // pieces fly in from both sides of the canvas
return {
key: `piece-${i}`,
span: spans[i % spans.length],
from: {
x: Math.cos(angle) * distance * direction,
y: Math.sin(angle) * distance,
rotate: (8 + rand() * 37) * (rand() > 0.5 ? 1 : -1),
scale: 0.55 + rand() * 0.8,
delay: i * 55 + MIN_STAGGER_BASE + rand() * MAX_STAGGER_JITTER,
duration: MIN_DURATION + rand() * (MAX_DURATION - MIN_DURATION),
},
};
});
}
/* ------------------------------------------------------------------ */
/* Real dashboard primitives rendered by the assembly grid */
/* ------------------------------------------------------------------ */
const CHART_DATA = [
{ d: "Mon", v: 3200, b: 2100 },
{ d: "Tue", v: 4100, b: 2600 },
{ d: "Wed", v: 3800, b: 2400 },
{ d: "Thu", v: 5200, b: 3100 },
{ d: "Fri", v: 4800, b: 2900 },
{ d: "Sat", v: 6100, b: 3600 },
{ d: "Sun", v: 7400, b: 4200 },
];
const LIST_ROWS = [
{ email: "ava.reyes@northwind.io", badge: "DELIVERED" as const, tone: "text-emerald-300 bg-emerald-500/10 border-emerald-500/20" },
{ email: "marcus.chen@brightlab.co", badge: "OPENED" as const, tone: "text-sky-300 bg-sky-500/10 border-sky-500/20" },
{ email: "lena.hoffmann@vanta.dev", badge: "REPLIED" as const, tone: "text-violet-300 bg-violet-500/10 border-violet-500/20" },
{ email: "j.torres@quanticloud.ai", badge: "ACTIVE" as const, tone: "text-emerald-300 bg-emerald-500/10 border-emerald-500/20" },
{ email: "priya.nair@helioshift.com", badge: "QUEUED" as const, tone: "text-zinc-300 bg-white/5 border-white/10" },
];
interface AnimationProps {
module: ModuleId;
mode: "enter" | "exit";
/** Re-seed source: Date.now() ^ hashModuleId(module) ^ launchSeq */
seed: number;
onDone: () => void;
/** Accent colors from the active module theme (engineLabel uses it too). */
accent: string;
accentSoft: string;
}
export default function ModuleLaunchAnimation({ module, mode, seed, onDone, accent, accentSoft }: AnimationProps) {
const prefersReducedMotion = useReducedMotion();
const [play, setPlay] = useState(false);
const [settled, setSettled] = useState<Set<number>>(new Set());
const doneRef = useRef(false);
const watchdogRef = useRef<number | null>(null);
const specs = useMemo(
() => buildPieceSpecs(mulberry32(seed), 11),
[seed]
);
// Double-rAF: mount pieces in their hidden state first, then start the
// timeline — guarantees the first paint of the overlay is cheap (no jank).
useEffect(() => {
if (prefersReducedMotion) {
setPlay(true);
setSettled(new Set(specs.map((_, i) => i)));
return;
}
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setPlay(true));
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, [prefersReducedMotion, specs]);
const finish = useCallback(() => {
if (doneRef.current) return;
doneRef.current = true;
onDone();
}, [onDone]);
// Watchdog is the single source of truth for completion (deterministic:
// longest stagger + longest duration + margin). onAnimationComplete only
// drives will-change cleanup.
useEffect(() => {
if (!play) return;
const maxTime = Math.max(...specs.map((s) => s.from.delay + s.from.duration * 1000));
watchdogRef.current = window.setTimeout(
finish,
prefersReducedMotion ? 0 : (mode === "enter" ? maxTime + 120 : maxTime) + 80
);
return () => {
if (watchdogRef.current) window.clearTimeout(watchdogRef.current);
};
}, [play, specs, finish, mode, prefersReducedMotion]);
const markSettled = useCallback((i: number) => {
setSettled((prev) => {
if (prev.has(i)) return prev;
const next = new Set(prev);
next.add(i);
return next;
});
}, []);
const isEnter = mode === "enter";
const StatCardVariant = module === "s" ? SStatCard : StatCard;
const Frame = module === "s" ? SGlassCard : React.Fragment;
const frameProps = module === "s" ? { className: "p-4" } : {};
return (
<div
className="absolute inset-0 z-30 flex items-center justify-center overflow-hidden p-4 md:p-8 pointer-events-none"
aria-hidden="true"
data-testid="module-launch-animation"
>
<div className="grid grid-cols-6 auto-rows-min gap-3 md:gap-4 w-full max-w-5xl">
{specs.map((spec, i) => {
const initial =
isEnter && !prefersReducedMotion
? { x: spec.from.x, y: spec.from.y, rotate: spec.from.rotate, scale: spec.from.scale, opacity: 0 }
: isEnter
? { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }
: { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 };
const animate = isEnter
? { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }
: prefersReducedMotion
? { x: 0, y: 0, opacity: 0 }
: {
x: spec.from.x,
y: spec.from.y,
rotate: spec.from.rotate,
scale: spec.from.scale,
opacity: 0,
};
return (
<motion.div
key={`${spec.key}-${seed}`}
className={cn("module-launch-piece min-w-0", spec.span, settled.has(i) && "module-launch-settled")}
initial={initial}
animate={play ? animate : initial}
transition={
prefersReducedMotion
? { duration: 0 }
: {
delay: (isEnter ? spec.from.delay : i * 24) / 1000,
duration: isEnter ? spec.from.duration : 0.85,
ease: ASSEMBLY_EASE,
}
}
onAnimationComplete={() => {
if (play) markSettled(i);
}}
style={{ transformOrigin: "center center" }}
>
<Piece
index={i}
module={module}
accent={accent}
accentSoft={accentSoft}
StatCardVariant={StatCardVariant}
Frame={Frame}
frameProps={frameProps}
/>
</motion.div>
);
})}
</div>
{/* Engine strip — reassembles last, identifies the module being launched */}
<motion.div
className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 px-3 py-1.5 rounded-full border border-white/10 bg-black/50 backdrop-blur-md"
initial={{ opacity: 0, y: 14 }}
animate={play ? { opacity: 1, y: 0 } : { opacity: 0, y: 14 }}
transition={prefersReducedMotion ? { duration: 0 } : { delay: 0.35, duration: 0.7, ease: ASSEMBLY_EASE }}
>
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: accent, boxShadow: `0 0 10px ${accent}` }} />
<span className="text-[10px] font-mono uppercase tracking-[0.2em] text-zinc-400">
{isEnter ? "Assembling" : "Releasing"} {module === "s" ? "ModuleS" : "ModuleE"} workspace
</span>
<span className="text-[10px] font-mono" style={{ color: accentSoft }}>
{Math.round(MIN_DURATION * 10) / 10}–{MAX_DURATION}s
</span>
</motion.div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Piece renderer — every slot is a REAL platform component */
/* ------------------------------------------------------------------ */
interface PieceProps {
index: number;
module: ModuleId;
accent: string;
accentSoft: string;
StatCardVariant: typeof StatCard;
Frame: React.ComponentType<{ children?: React.ReactNode; className?: string }> | typeof React.Fragment;
frameProps: Record<string, unknown>;
}
function Piece({ index, module, accent, accentSoft, StatCardVariant, Frame, frameProps }: PieceProps) {
switch (index) {
case 0:
return (
<StatCardVariant
title="Total Leads"
value="12,847"
icon={Users}
iconColor="indigo"
change={{ value: "+12.4%", isPositive: true, period: "vs last week" }}
/>
);
case 1:
return (
<StatCardVariant
title="Active Campaigns"
value="24"
icon={Megaphone}
iconColor="emerald"
change={{ value: "3 launching", isPositive: null }}
/>
);
case 2:
return (
<StatCardVariant
title="Reply Rate"
value="8.2%"
icon={Mail}
iconColor="purple"
change={{ value: "+0.9%", isPositive: true, period: "7-day avg" }}
/>
);
case 3:
return (
<StatCardVariant
title="Deliverability"
value="98.2%"
icon={ShieldCheck}
iconColor="teal"
subtitle="Reputation shield active"
progress={98}
/>
);
case 4:
return (
<Frame {...frameProps}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<TrendingUp className="w-3.5 h-3.5 shrink-0" style={{ color: accent }} />
<span className="text-[11px] font-mono uppercase tracking-wider text-zinc-400 truncate">Sequence telemetry</span>
</div>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded border" style={{ color: accentSoft, borderColor: accentSoft + "55", backgroundColor: accentSoft + "14" }}>
LIVE
</span>
</div>
<div className="h-28 -ml-2">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={CHART_DATA} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
<defs>
<linearGradient id={`launch-grad-${module}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={accent} stopOpacity={0.45} />
<stop offset="100%" stopColor={accent} stopOpacity={0.02} />
</linearGradient>
</defs>
<XAxis dataKey="d" hide />
<YAxis hide />
<Tooltip cursor={{ stroke: accent + "55" }} contentStyle={{ background: "#0c0d10", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, fontSize: 10 }} />
<Area type="monotone" dataKey="b" stroke="transparent" fill={`url(#launch-grad-${module})`} isAnimationActive={false} />
<Area type="monotone" dataKey="v" stroke={accent} strokeWidth={1.5} fill="transparent" isAnimationActive={false} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</Frame>
);
case 5:
return (
<Frame {...frameProps}>
<div className="flex items-center gap-2 mb-2">
<Activity className="w-3.5 h-3.5 shrink-0" style={{ color: accent }} />
<span className="text-[11px] font-mono uppercase tracking-wider text-zinc-400 truncate">Live feed</span>
</div>
<div className="space-y-2">
{LIST_ROWS.map((row) => (
<div key={row.email} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: accent }} />
<span className="text-[11px] text-zinc-300 truncate">{row.email}</span>
</div>
<span className={cn("text-[8px] font-bold tracking-wider px-1.5 py-0.5 rounded border shrink-0", row.tone)}>{row.badge}</span>
</div>
))}
</div>
</Frame>
);
case 6:
return (
<div className="flex items-center gap-2 flex-wrap">
{["SEQUENCES", "WARMUP", "UNIBOX"].map((tag) => (
<span key={tag} className="text-[9px] font-bold tracking-[0.18em] px-2 py-1 rounded-md border border-white/10 bg-white/5 text-zinc-300">
{tag}
</span>
))}
<span className="text-[9px] font-bold tracking-[0.18em] px-2 py-1 rounded-md border" style={{ color: accentSoft, borderColor: accentSoft + "55", backgroundColor: accentSoft + "14" }}>
DELIVERABILITY
</span>
</div>
);
case 7:
return (
<StatCardVariant title="Open Rate" value="64.7%" icon={Mail} iconColor="blue" change={{ value: "+2.1%", isPositive: true }} />
);
case 8:
return <StatCardVariant title="Bounce Rate" value="0.4%" icon={ArrowUpRight} iconColor="rose" change={{ value: "-0.2%", isPositive: true }} />;
case 9:
return (
<StatCardVariant title="Mailboxes Rotating" value="38" icon={Users} iconColor="amber" subtitle="Multi-mailbox rotation on" />
);
default:
return (
<div className="space-y-2.5">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
);
}
}