feat(frontend): constellation stage + floating chrome replace classic shell
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||
import { CommandPalette } from "@/components/command/command-palette";
|
||||
import { AppFrame } from "@/components/shell";
|
||||
import { ConstellationFrame } from "@/components/shell";
|
||||
import { WsProvider } from "@/lib/ws/context";
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -10,7 +10,7 @@ export default function DashboardLayout({
|
||||
return (
|
||||
<AmbientProvider>
|
||||
<WsProvider>
|
||||
<AppFrame>{children}</AppFrame>
|
||||
<ConstellationFrame>{children}</ConstellationFrame>
|
||||
<Chatbot />
|
||||
<CommandPalette />
|
||||
</WsProvider>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ConstellationFrame — replaces the classic AppFrame chrome.
|
||||
* The stage canvas sits fixed behind everything; page content is an
|
||||
* overlay layer (absolute, no top bar / nav rail / scroll shell).
|
||||
* MiniPlayer + Chatbot + CommandPalette keep mounting at the layout level.
|
||||
*/
|
||||
import { usePathname } from "next/navigation";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { ConstellationStage } from "@/components/shell/constellation-stage";
|
||||
import { FloatingChrome } from "@/components/shell/floating-chrome";
|
||||
import { resolveScene, type SceneSeed } from "@/components/shell/route-scenes";
|
||||
|
||||
export interface ConstellationFrameProps {
|
||||
children: ReactNode;
|
||||
/** Typed SSR seed consumed by the active scene's graph builder. */
|
||||
sceneSeed?: SceneSeed;
|
||||
}
|
||||
|
||||
export function ConstellationFrame({
|
||||
children,
|
||||
sceneSeed,
|
||||
}: ConstellationFrameProps) {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const scene = useMemo(() => resolveScene(pathname), [pathname]);
|
||||
const graph = useMemo(
|
||||
() => (scene ? scene.build(sceneSeed ?? {}) : { nodes: [], edges: [] }),
|
||||
[scene, sceneSeed],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-dvh w-full overflow-hidden">
|
||||
<ConstellationStage
|
||||
graph={graph}
|
||||
onNodeClick={(id) => {
|
||||
if (id.startsWith("channel:")) window.location.assign("/channels/");
|
||||
}}
|
||||
/>
|
||||
<FloatingChrome />
|
||||
{/* Overlay content region — scenes place floating panels inside. */}
|
||||
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
|
||||
<div className="pointer-events-auto min-h-full">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ConstellationStage — full-bleed interactive star-field renderer.
|
||||
* Renders the route's graph as glowing nodes + hairline edges over a
|
||||
* three.js orthographic view. Pan = drag, zoom = wheel/pinch, click on
|
||||
* an href-bearing node navigates (plain location.assign — router.push
|
||||
* can no-op under standalone+trailingSlash builds).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { type CameraState, fitCamera } from "@/lib/constellation/camera";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import { computeLayout, type LayoutNode } from "@/lib/constellation/layout";
|
||||
import { readPalette, type StagePalette } from "@/lib/constellation/palette";
|
||||
|
||||
export interface ConstellationStageProps {
|
||||
graph: ConstellationGraph;
|
||||
seed?: number;
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
const Z_MIN = 0.35;
|
||||
const Z_MAX = 3;
|
||||
|
||||
export function ConstellationStage({
|
||||
graph,
|
||||
seed = 42,
|
||||
onNodeClick,
|
||||
}: ConstellationStageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const labelsRef = useRef<HTMLDivElement | null>(null);
|
||||
const camRef = useRef<CameraState>({ x: 0, y: 0, z: 1 });
|
||||
const layoutRef = useRef<LayoutNode[]>([]);
|
||||
const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||
|
||||
// Viewport size + reduced-motion preference.
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const sync = () => setReduced(mq.matches);
|
||||
sync();
|
||||
mq.addEventListener("change", sync);
|
||||
const measure = () =>
|
||||
setSize({ w: window.innerWidth, h: window.innerHeight });
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
mq.removeEventListener("change", sync);
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Deterministic layout whenever graph or viewport changes.
|
||||
useEffect(() => {
|
||||
if (size.w < 50 || size.h < 50) return;
|
||||
const layout = computeLayout(graph.nodes, graph.edges, {
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
seed,
|
||||
reduced,
|
||||
});
|
||||
layoutRef.current = layout;
|
||||
camRef.current = fitCamera(layout, size.w, size.h);
|
||||
}, [graph, size.w, size.h, seed, reduced]);
|
||||
|
||||
// Build + run the renderer.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || size.w < 50 || size.h < 50 || layoutRef.current.length === 0)
|
||||
return;
|
||||
|
||||
let palette: StagePalette = readPalette();
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(size.w, size.h, false);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -200, 200);
|
||||
|
||||
const group = new THREE.Group();
|
||||
scene.add(group);
|
||||
const disposables: { dispose: () => void }[] = [];
|
||||
const track = <T extends { dispose: () => void }>(d: T): T => {
|
||||
disposables.push(d);
|
||||
return d;
|
||||
};
|
||||
|
||||
// Edges first (under nodes).
|
||||
const byId = new Map(layoutRef.current.map((n) => [n.id, n]));
|
||||
const edgeMat = track(
|
||||
new THREE.LineBasicMaterial({
|
||||
color: palette.inkFaint,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
}),
|
||||
);
|
||||
for (const e of graph.edges) {
|
||||
const a = byId.get(e.source);
|
||||
const b = byId.get(e.target);
|
||||
if (!a || !b) continue;
|
||||
const geo = track(
|
||||
new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(a.x, a.y, 0),
|
||||
new THREE.Vector3(b.x, b.y, 0),
|
||||
]),
|
||||
);
|
||||
group.add(new THREE.Line(geo, edgeMat));
|
||||
}
|
||||
|
||||
// Nodes: glow halo + core disc.
|
||||
interface NodeVisual {
|
||||
id: string;
|
||||
halo: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
|
||||
core: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
|
||||
r: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
const visuals: NodeVisual[] = [];
|
||||
for (const n of layoutRef.current) {
|
||||
const meta = nodeByIdRef.current.get(n.id);
|
||||
const kind = meta?.kind ?? "message";
|
||||
const flagged =
|
||||
Number(meta?.meta?.flagged_count ?? 0) > 0 && kind === "channel";
|
||||
const color =
|
||||
kind === "guild"
|
||||
? palette.signal
|
||||
: flagged
|
||||
? palette.vermilion
|
||||
: palette.ink;
|
||||
const haloGeo = track(new THREE.CircleGeometry(n.r * 2.4, 32));
|
||||
const haloMat = track(
|
||||
new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: kind === "guild" ? 0.14 : 0.08,
|
||||
}),
|
||||
);
|
||||
const coreGeo = track(new THREE.CircleGeometry(n.r, 32));
|
||||
const coreMat = track(
|
||||
new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
}),
|
||||
);
|
||||
const halo = new THREE.Mesh(haloGeo, haloMat);
|
||||
halo.position.set(n.x, n.y, -1);
|
||||
const core = new THREE.Mesh(coreGeo, coreMat);
|
||||
core.position.set(n.x, n.y, 0);
|
||||
group.add(halo, core);
|
||||
visuals.push({ id: n.id, halo, core, r: n.r, x: n.x, y: n.y });
|
||||
}
|
||||
|
||||
const applyCamera = () => {
|
||||
const { x, y, z } = camRef.current;
|
||||
const hw = size.w / (2 * z);
|
||||
const hh = size.h / (2 * z);
|
||||
camera.left = -hw;
|
||||
camera.right = hw;
|
||||
camera.top = hh;
|
||||
camera.bottom = -hh;
|
||||
camera.position.set(x, y, 100);
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
// --- interaction state ---
|
||||
type PointerMode =
|
||||
| { t: "idle" }
|
||||
| { t: "drag"; sx: number; sy: number; cx: number; cy: number };
|
||||
let mode: PointerMode = { t: "idle" };
|
||||
let raf = 0;
|
||||
let disposed = false;
|
||||
|
||||
const pointerToWorld = (clientX: number, clientY: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const { x, y, z } = camRef.current;
|
||||
return {
|
||||
wx: x + (clientX - rect.left - size.w / 2) / z,
|
||||
wy: y - (clientY - rect.top - size.h / 2) / z,
|
||||
};
|
||||
};
|
||||
|
||||
const pickNode = (clientX: number, clientY: number): NodeVisual | null => {
|
||||
const { wx, wy } = pointerToWorld(clientX, clientY);
|
||||
let best: NodeVisual | null = null;
|
||||
let bestD = Infinity;
|
||||
for (const v of visuals) {
|
||||
const d = Math.hypot(wx - v.x, wy - v.y);
|
||||
if (d <= v.r + 6 && d < bestD) {
|
||||
best = v;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const onPointerDown = (ev: PointerEvent) => {
|
||||
mode = {
|
||||
t: "drag",
|
||||
sx: ev.clientX,
|
||||
sy: ev.clientY,
|
||||
cx: camRef.current.x,
|
||||
cy: camRef.current.y,
|
||||
};
|
||||
canvas.style.cursor = "grabbing";
|
||||
};
|
||||
const onPointerMove = (ev: PointerEvent) => {
|
||||
if (mode.t === "drag") {
|
||||
const z = camRef.current.z;
|
||||
camRef.current.x = mode.cx - (ev.clientX - mode.sx) / z;
|
||||
camRef.current.y = mode.cy + (ev.clientY - mode.sy) / z;
|
||||
return;
|
||||
}
|
||||
const hit = pickNode(ev.clientX, ev.clientY);
|
||||
setHovered(hit ? hit.id : null);
|
||||
canvas.style.cursor = hit ? "pointer" : "grab";
|
||||
};
|
||||
const onPointerUp = (ev: PointerEvent) => {
|
||||
const wasDrag =
|
||||
mode.t === "drag" &&
|
||||
Math.hypot(ev.clientX - mode.sx, ev.clientY - mode.sy) > 4;
|
||||
mode = { t: "idle" };
|
||||
canvas.style.cursor = "grab";
|
||||
if (wasDrag) return;
|
||||
const hit = pickNode(ev.clientX, ev.clientY);
|
||||
if (!hit) return;
|
||||
const meta = nodeByIdRef.current.get(hit.id);
|
||||
onNodeClick?.(hit.id);
|
||||
if (meta?.href) window.location.assign(meta.href);
|
||||
};
|
||||
const onWheel = (ev: WheelEvent) => {
|
||||
ev.preventDefault();
|
||||
const old = camRef.current.z;
|
||||
const next = Math.max(
|
||||
Z_MIN,
|
||||
Math.min(Z_MAX, old * Math.exp(-ev.deltaY * 0.0012)),
|
||||
);
|
||||
camRef.current.z = next;
|
||||
};
|
||||
|
||||
canvas.style.cursor = "grab";
|
||||
canvas.addEventListener("pointerdown", onPointerDown);
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
// Labels (HTML, imperative transforms — cheap for ≤16 nodes).
|
||||
const labelHost = labelsRef.current;
|
||||
const labelEls = new Map<string, HTMLSpanElement>();
|
||||
const labeled = [...layoutRef.current]
|
||||
.sort((a, b) => b.r - a.r)
|
||||
.slice(0, 14);
|
||||
if (labelHost) {
|
||||
for (const n of labeled) {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = nodeByIdRef.current.get(n.id)?.label ?? n.id;
|
||||
el.dataset.nodeLabel = n.id;
|
||||
el.className =
|
||||
"pointer-events-none absolute whitespace-nowrap font-mono text-[11px] tracking-wide text-[var(--color-ink-soft)] transition-colors";
|
||||
labelHost.appendChild(el);
|
||||
labelEls.set(n.id, el);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme sync — tokens flip with the .light class.
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
palette = readPalette();
|
||||
for (const v of visuals) {
|
||||
const meta = nodeByIdRef.current.get(v.id);
|
||||
const flagged =
|
||||
Number(meta?.meta?.flagged_count ?? 0) > 0 &&
|
||||
(meta?.kind ?? "") === "channel";
|
||||
const c =
|
||||
meta?.kind === "guild"
|
||||
? palette.signal
|
||||
: flagged
|
||||
? palette.vermilion
|
||||
: palette.ink;
|
||||
v.core.material.color.setHex(c);
|
||||
v.halo.material.color.setHex(c);
|
||||
}
|
||||
edgeMat.color.setHex(palette.inkFaint);
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
let hidden = document.hidden;
|
||||
const onVis = () => {
|
||||
hidden = document.hidden;
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
|
||||
const start = performance.now();
|
||||
const frame = (now: number) => {
|
||||
if (disposed) return;
|
||||
raf = requestAnimationFrame(frame);
|
||||
if (hidden) return;
|
||||
applyCamera();
|
||||
|
||||
// Gentle breathing glow (visual only — hit positions stay baked).
|
||||
const t = (now - start) / 1000;
|
||||
let i = 0;
|
||||
for (const v of visuals) {
|
||||
if (!reduced) {
|
||||
const pulse = 0.5 + 0.5 * Math.sin(t * 1.4 + i * 0.7);
|
||||
v.halo.material.opacity =
|
||||
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
|
||||
}
|
||||
const hov = v.id === hovered;
|
||||
v.core.scale.setScalar(hov ? 1.25 : 1);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
|
||||
if (labelHost) {
|
||||
const { x, y, z } = camRef.current;
|
||||
for (const [id, el] of labelEls) {
|
||||
const v = visuals.find((s) => s.id === id);
|
||||
if (!v) continue;
|
||||
const sx = (v.x - x) * z + size.w / 2;
|
||||
const sy = -(v.y - y) * z + size.h / 2 + v.r + 14;
|
||||
el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`;
|
||||
el.style.color =
|
||||
id === hovered ? "var(--color-signal)" : "var(--color-ink-soft)";
|
||||
}
|
||||
}
|
||||
};
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(raf);
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
canvas.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
canvas.removeEventListener("wheel", onWheel);
|
||||
themeObserver.disconnect();
|
||||
for (const el of labelEls.values()) el.remove();
|
||||
for (const d of disposables) d.dispose();
|
||||
scene.clear();
|
||||
renderer.dispose();
|
||||
};
|
||||
}, [graph, size.w, size.h, reduced, hovered, onNodeClick]);
|
||||
|
||||
const emptyGraph = graph.nodes.length === 0;
|
||||
const hint = useMemo(
|
||||
() => (emptyGraph ? "Menunggu data scene…" : null),
|
||||
[emptyGraph],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (layoutRef.current.length > 0 && size.w > 0) {
|
||||
camRef.current = fitCamera(layoutRef.current, size.w, size.h);
|
||||
}
|
||||
}, [size.w, size.h]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 -z-10 h-full w-full touch-none select-none"
|
||||
/>
|
||||
<div ref={labelsRef} aria-hidden="true" className="fixed inset-0 -z-10" />
|
||||
{hint ? (
|
||||
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
className="absolute bottom-24 right-5 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/60 px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] backdrop-blur-sm hover:text-[var(--color-ink)] md:bottom-20"
|
||||
onClick={handleReset}
|
||||
type="button"
|
||||
>
|
||||
reset view
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FloatingChrome — the only persistent UI over the constellation.
|
||||
* No top bar, no nav rail: brand + live link (top-left), theme toggle
|
||||
* and palette hint (top-right), route switcher (bottom-center).
|
||||
* All internal navigation uses plain <a href> — trailingSlash builds.
|
||||
*/
|
||||
import { Menu, Moon, Sun, X } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { ConnectionStatus } from "./status-dot";
|
||||
|
||||
function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) {
|
||||
return <span className="inline-block size-8" aria-hidden="true" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
className="inline-flex size-8 items-center justify-center rounded-full border border-[var(--color-hairline)] text-[var(--color-ink-soft)] backdrop-blur-sm transition-colors hover:border-[var(--color-signal)] hover:text-[var(--color-signal)]"
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<Moon className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Sun className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingChrome() {
|
||||
const pathname = usePathname();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Brand + live link */}
|
||||
<div className="pointer-events-auto absolute left-5 top-5 z-20 flex items-center gap-3">
|
||||
<span className="font-display text-lg font-semibold tracking-tight text-[var(--color-ink)]">
|
||||
GMW
|
||||
</span>
|
||||
<span className="hidden font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-ink-faint)] sm:inline">
|
||||
constellation
|
||||
</span>
|
||||
<ConnectionStatus compact />
|
||||
</div>
|
||||
|
||||
{/* Theme + palette hint */}
|
||||
<div className="pointer-events-auto absolute right-5 top-5 z-20 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="hidden rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-faint)] backdrop-blur-sm transition-colors hover:text-[var(--color-ink)] md:block"
|
||||
onClick={() =>
|
||||
window.dispatchEvent(new Event("command-palette:open"))
|
||||
}
|
||||
>
|
||||
⌘K
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Route switcher */}
|
||||
<nav
|
||||
aria-label="Routes"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-5 z-20 hidden justify-center gap-1 px-4 md:flex"
|
||||
>
|
||||
<div className="pointer-events-auto flex max-w-full items-center gap-1 overflow-x-auto rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/55 px-2 py-1.5 backdrop-blur-md">
|
||||
{navItems.map((item) => {
|
||||
const active =
|
||||
pathname === item.matchPrefix ||
|
||||
pathname.startsWith(item.matchPrefix);
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
data-active={active || undefined}
|
||||
className={`rounded-full px-3 py-1 font-mono text-xs whitespace-nowrap transition-colors ${
|
||||
active
|
||||
? "bg-[var(--color-signal-glow)] text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile: sheet menu instead of a bottom tab bar */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={menuOpen ? "Close menu" : "Open menu"}
|
||||
aria-expanded={menuOpen}
|
||||
className="pointer-events-auto absolute bottom-5 right-5 z-30 inline-flex size-10 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 text-[var(--color-ink)] backdrop-blur-md md:hidden"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
>
|
||||
{menuOpen ? (
|
||||
<X className="size-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Menu className="size-5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<nav
|
||||
aria-label="Routes mobile"
|
||||
className="pointer-events-auto absolute inset-x-4 bottom-20 z-20 flex flex-col gap-1 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/90 p-2 backdrop-blur-xl md:hidden"
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
const active =
|
||||
pathname === item.matchPrefix ||
|
||||
pathname.startsWith(item.matchPrefix);
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`rounded-xl px-4 py-3 font-mono text-sm ${
|
||||
active
|
||||
? "bg-[var(--color-signal-glow)] text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
export { AppFrame } from "./ambient-app";
|
||||
export { MobileNav } from "./mobile-nav";
|
||||
export { NavRail } from "./nav-rail";
|
||||
export { ConstellationFrame } from "./constellation-frame";
|
||||
export { ConnectionStatus } from "./status-dot";
|
||||
export { TopBar } from "./topbar";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { cssColorToHexInt, oklchToHexInt } from "./palette";
|
||||
|
||||
describe("oklchToHexInt", () => {
|
||||
test("white", () => {
|
||||
expect(oklchToHexInt(1, 0, 0)).toBe(0xffffff);
|
||||
});
|
||||
test("black", () => {
|
||||
expect(oklchToHexInt(0, 0, 0)).toBe(0x000000);
|
||||
});
|
||||
test("green-ish signal stays recognizable", () => {
|
||||
const hex = oklchToHexInt(0.86, 0.19, 128);
|
||||
expect(hex).not.toBeNull();
|
||||
if (hex === null) throw new Error("hex is null");
|
||||
const r = (hex >> 16) & 0xff;
|
||||
const g = (hex >> 8) & 0xff;
|
||||
expect(g).toBeGreaterThan(r);
|
||||
});
|
||||
test("invalid input returns null", () => {
|
||||
expect(oklchToHexInt(Number.NaN, 0, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cssColorToHexInt", () => {
|
||||
test("parses oklch()", () => {
|
||||
expect(cssColorToHexInt("oklch(1 0 0)")).toBe(0xffffff);
|
||||
});
|
||||
test("parses rgb()", () => {
|
||||
expect(cssColorToHexInt("rgb(255, 0, 0)")).toBe(0xff0000);
|
||||
});
|
||||
test("parses hex", () => {
|
||||
expect(cssColorToHexInt("#ff8000")).toBe(0xff8000);
|
||||
});
|
||||
test("garbage returns null", () => {
|
||||
expect(cssColorToHexInt("nonsense")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Color helpers for the constellation stage.
|
||||
* Reads CSS custom properties (oklch strings) and converts them to
|
||||
* THREE-friendly hex integers. Pure math + DOM reader separated.
|
||||
*/
|
||||
|
||||
/** oklch(L C H) → sRGB hex integer (#rrggbb). Returns null on invalid input. */
|
||||
export function oklchToHexInt(l: number, c: number, h: number): number | null {
|
||||
if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) {
|
||||
return null;
|
||||
}
|
||||
// oklch → oklab
|
||||
const hr = (h * Math.PI) / 180;
|
||||
const a = c * Math.cos(hr);
|
||||
const b = c * Math.sin(hr);
|
||||
|
||||
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = l - 0.0894841775 * a - 1.291485548 * b;
|
||||
|
||||
const L = l_ * l_ * l_;
|
||||
const M = m_ * m_ * m_;
|
||||
const S = s_ * s_ * s_;
|
||||
|
||||
let r = 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S;
|
||||
let g = -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S;
|
||||
let bb = -0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S;
|
||||
|
||||
r = gamma(r);
|
||||
g = gamma(g);
|
||||
bb = gamma(bb);
|
||||
|
||||
if ([r, g, bb].some((v) => !Number.isFinite(v))) return null;
|
||||
|
||||
const to255 = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255)));
|
||||
return (to255(r) << 16) | (to255(g) << 8) | to255(bb);
|
||||
}
|
||||
|
||||
function gamma(v: number): number {
|
||||
const abs = Math.abs(v);
|
||||
if (abs <= 0.0031308) return 12.92 * v;
|
||||
return (Math.sign(v) || 1) * (1.055 * abs ** (1 / 2.4) - 0.055);
|
||||
}
|
||||
|
||||
/** Parse "oklch(0.86 0.19 128)" (or legacy "rgb(...)") into hex int. */
|
||||
export function cssColorToHexInt(color: string): number | null {
|
||||
const oklch = color.match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.-]+)/i);
|
||||
if (oklch) {
|
||||
const lStr = oklch[1] ?? "";
|
||||
const l = lStr.endsWith("%")
|
||||
? (Number.parseFloat(lStr) || 0) / 100
|
||||
: Number.parseFloat(lStr);
|
||||
return oklchToHexInt(
|
||||
l,
|
||||
Number.parseFloat(oklch[2] ?? "0"),
|
||||
Number.parseFloat(oklch[3] ?? "0"),
|
||||
);
|
||||
}
|
||||
const rgb = color.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
|
||||
if (rgb) {
|
||||
return (
|
||||
(Math.round(Number(rgb[1])) << 16) |
|
||||
(Math.round(Number(rgb[2])) << 8) |
|
||||
Math.round(Number(rgb[3]))
|
||||
);
|
||||
}
|
||||
const hex = color.match(/^#([0-9a-f]{6})$/i);
|
||||
if (hex?.[1]) return Number.parseInt(hex[1], 16);
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface StagePalette {
|
||||
signal: number;
|
||||
vermilion: number;
|
||||
amber: number;
|
||||
ink: number;
|
||||
inkSoft: number;
|
||||
inkFaint: number;
|
||||
}
|
||||
|
||||
const FALLBACK_DARK: StagePalette = {
|
||||
signal: 0x7dd87a,
|
||||
vermilion: 0xe05642,
|
||||
amber: 0xd9a441,
|
||||
ink: 0xf2ede2,
|
||||
inkSoft: 0xa89f90,
|
||||
inkFaint: 0x807767,
|
||||
};
|
||||
|
||||
/** Read theme tokens off :root computed style; fall back to dark set. */
|
||||
export function readPalette(): StagePalette {
|
||||
if (typeof window === "undefined") return FALLBACK_DARK;
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const pick = (name: string, fb: number): number =>
|
||||
cssColorToHexInt(cs.getPropertyValue(name).trim()) ?? fb;
|
||||
return {
|
||||
signal: pick("--color-signal", FALLBACK_DARK.signal),
|
||||
vermilion: pick("--color-vermilion", FALLBACK_DARK.vermilion),
|
||||
amber: pick("--color-amber", FALLBACK_DARK.amber),
|
||||
ink: pick("--color-ink", FALLBACK_DARK.ink),
|
||||
inkSoft: pick("--color-ink-soft", FALLBACK_DARK.inkSoft),
|
||||
inkFaint: pick("--color-ink-faint", FALLBACK_DARK.inkFaint),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user