BRIDGE_Modul-S_prod / src /modules /ModuleShell.tsx
BRIDGE Platform Agent
feat(platform): BRIDGE Modules hub with /modules/e and /modules/s routed workspaces
1955a0f
Raw History Blame Contribute Delete
5.49 kB
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Outlet } from "react-router-dom";
import { AnimatePresence, motion } from "motion/react";
import { ThemeConfig, ThemeContext } from "../context/ThemeContext";
import GeometricBackground from "../components/GeometricBackground";
import BackgroundElements from "../components/BackgroundElements";
import TopBar from "../components/TopBar";
import PlatformSidebar from "./PlatformSidebar";
import ModuleLaunchAnimation, { hashModuleId } from "./ModuleLaunchAnimation";
import { useLaunch } from "./LaunchContext";
import { MODULES, ModuleId } from "./registry";
/**
* Nested theme provider — overrides the platform default (mint) with the
* active module palette for the whole module subtree.
*/
function ModuleThemeProvider({ config, children }: { config: ThemeConfig; children: React.ReactNode }) {
const value = useMemo(() => ({ theme: config.id, config }), [config]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
interface ModuleShellProps {
module: ModuleId;
}
/**
* Route shell for /modules/e and /modules/s.
*
* Launch guard: on every mount (menu launch, hub launch, direct URL) the
* module's real dashboard primitives random-reassemble in slow motion before
* the workspace is revealed. Leaving via the menu plays the reverse-assembly
* exit. Internal navigation never replays the animation (shell stays mounted).
*/
export default function ModuleShell({ module }: ModuleShellProps) {
const def = MODULES[module];
const { seq, exiting, finishExit } = useLaunch();
const [launchDone, setLaunchDone] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const seedRef = useRef<number>(0);
if (seedRef.current === 0) {
seedRef.current = Date.now() ^ hashModuleId(module) ^ ((seq + 1) * 0x9e3779b9);
}
// A fresh mount is a fresh launch — clear any stale exit flag from the
// previous module.
useEffect(() => {
if (exiting) finishExit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isExiting = exiting === module;
return (
<ModuleThemeProvider config={def.theme}>
<div
data-module={module}
data-testid={`module-shell-${module}`}
className={module === "s" ? "bg-module-s min-h-full" : ""}
>
<div className="flex h-screen bg-transparent text-zinc-100 overflow-hidden relative">
<GeometricBackground />
<BackgroundElements />
{/* Mobile overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/60 z-30 md:hidden backdrop-blur-sm"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Sidebar with mobile slide transition */}
<div
className={`fixed inset-y-0 left-0 z-40 transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<PlatformSidebar module={module} onClose={() => setIsSidebarOpen(false)} />
</div>
<div className="flex flex-col flex-1 min-w-0 overflow-hidden w-full">
<TopBar module={module} onMenuClick={() => setIsSidebarOpen(true)} />
<main className="flex-1 overflow-y-auto bg-transparent relative z-10 w-full p-2 md:p-0">
{/* Launch guard: the animation covers the workspace; pages load
(and prefetch their data) behind the opaque overlay. */}
<div className="relative min-h-full">
{!isExiting && (
<React.Suspense fallback={<div className="p-6" />}>
<Outlet />
</React.Suspense>
)}
<AnimatePresence>
{!launchDone && !isExiting && (
<motion.div
key="launch-overlay"
className={module === "s" ? "absolute inset-0 z-30 bg-module-s" : "absolute inset-0 z-30 bg-[#05070a]"}
exit={{ opacity: 0, transition: { duration: 0.45, ease: "easeOut" } }}
>
<ModuleLaunchAnimation
module={module}
mode="enter"
seed={seedRef.current}
onDone={() => setLaunchDone(true)}
accent={def.theme.accent}
accentSoft={def.theme.accentText}
/>
</motion.div>
)}
{isExiting && (
<motion.div
key="exit-overlay"
className={module === "s" ? "absolute inset-0 z-30 bg-module-s" : "absolute inset-0 z-30 bg-[#05070a]"}
>
<ModuleLaunchAnimation
module={module}
mode="exit"
seed={Date.now() ^ hashModuleId(module)}
onDone={() => finishExit()}
accent={def.theme.accent}
accentSoft={def.theme.accentText}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</main>
</div>
</div>
</div>
</ModuleThemeProvider>
);
}