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 { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||||
import { CommandPalette } from "@/components/command/command-palette";
|
import { CommandPalette } from "@/components/command/command-palette";
|
||||||
import { AppFrame } from "@/components/shell";
|
import { ConstellationFrame } from "@/components/shell";
|
||||||
import { WsProvider } from "@/lib/ws/context";
|
import { WsProvider } from "@/lib/ws/context";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
@@ -10,7 +10,7 @@ export default function DashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<AmbientProvider>
|
<AmbientProvider>
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<AppFrame>{children}</AppFrame>
|
<ConstellationFrame>{children}</ConstellationFrame>
|
||||||
<Chatbot />
|
<Chatbot />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
</WsProvider>
|
</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 { AppFrame } from "./ambient-app";
|
||||||
export { MobileNav } from "./mobile-nav";
|
export { ConstellationFrame } from "./constellation-frame";
|
||||||
export { NavRail } from "./nav-rail";
|
|
||||||
export { ConnectionStatus } from "./status-dot";
|
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