feat(frontend): constellation stage + floating chrome replace classic shell

This commit is contained in:
asepharyana
2026-08-24 15:04:31 +07:00
parent 3d4236e8df
commit a9e09c38e9
7 changed files with 722 additions and 5 deletions
@@ -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),
};
}