revert(frontend): kembalikan shell usable — hapus total eksperimen constellation (three/d3-force dihapus)
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { fitCamera, flyTo } from "./camera";
|
||||
|
||||
describe("flyTo", () => {
|
||||
const from = { x: 0, y: 0, z: 1 };
|
||||
const to = { x: 100, y: -50, z: 2 };
|
||||
|
||||
test("t=0 returns origin", () => {
|
||||
expect(flyTo(from, to, 0)).toEqual({ x: 0, y: 0, z: 1 });
|
||||
});
|
||||
|
||||
test("t=1 arrives exactly", () => {
|
||||
expect(flyTo(from, to, 1)).toEqual(to);
|
||||
});
|
||||
|
||||
test("monotonic progress", () => {
|
||||
let prev = -Infinity;
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
const s = flyTo(from, to, i / 20);
|
||||
expect(s.x).toBeGreaterThan(prev);
|
||||
prev = s.x;
|
||||
}
|
||||
});
|
||||
|
||||
test("clamps out-of-range t", () => {
|
||||
expect(flyTo(from, to, -3)).toEqual(from);
|
||||
expect(flyTo(from, to, 7)).toEqual(to);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitCamera", () => {
|
||||
test("empty scene centers", () => {
|
||||
const c = fitCamera([], 800, 600);
|
||||
expect(c.x).toBe(400);
|
||||
expect(c.y).toBe(300);
|
||||
});
|
||||
|
||||
test("zooms to fit nodes with padding", () => {
|
||||
const nodes = [
|
||||
{ x: 100, y: 100, r: 10 },
|
||||
{ x: 500, y: 400, r: 10 },
|
||||
];
|
||||
const c = fitCamera(nodes, 800, 600);
|
||||
expect(c.x).toBe(300);
|
||||
expect(c.y).toBe(250);
|
||||
expect(c.z).toBeGreaterThan(0);
|
||||
expect(c.z).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Camera math for constellation fly-to — pure, easing-based interpolation.
|
||||
*/
|
||||
export interface CameraState {
|
||||
/** Camera center in canvas space. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Zoom factor (1 = fit). */
|
||||
z: number;
|
||||
}
|
||||
|
||||
export type Easing = (t: number) => number;
|
||||
|
||||
/** Smooth ease-in-out cubic. Monotonic on [0,1]. */
|
||||
export const easeInOutCubic: Easing = (t) =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
|
||||
export function flyTo(
|
||||
from: CameraState,
|
||||
to: CameraState,
|
||||
t: number,
|
||||
ease: Easing = easeInOutCubic,
|
||||
): CameraState {
|
||||
const clamped = Math.max(0, Math.min(1, t));
|
||||
const k = ease(clamped);
|
||||
return {
|
||||
x: from.x + (to.x - from.x) * k,
|
||||
y: from.y + (to.y - from.y) * k,
|
||||
z: from.z + (to.z - from.z) * k,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fit-to-view camera so a scene always starts framed. */
|
||||
export function fitCamera(
|
||||
nodes: { x: number; y: number; r?: number }[],
|
||||
width: number,
|
||||
height: number,
|
||||
padding = 80,
|
||||
): CameraState {
|
||||
if (nodes.length === 0 || width <= 0 || height <= 0) {
|
||||
return { x: width / 2, y: height / 2, z: 1 };
|
||||
}
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const n of nodes) {
|
||||
const r = n.r ?? 8;
|
||||
minX = Math.min(minX, n.x - r);
|
||||
maxX = Math.max(maxX, n.x + r);
|
||||
minY = Math.min(minY, n.y - r);
|
||||
maxY = Math.max(maxY, n.y + r);
|
||||
}
|
||||
const w = Math.max(1, maxX - minX);
|
||||
const h = Math.max(1, maxY - minY);
|
||||
const z = Math.min(
|
||||
3,
|
||||
Math.max(
|
||||
0.35,
|
||||
Math.min((width - padding * 2) / w, (height - padding * 2) / h),
|
||||
),
|
||||
);
|
||||
return {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
z,
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Constellation graph model — pure data, no React/DOM.
|
||||
* Builders convert existing API payloads into star-graph structures.
|
||||
*/
|
||||
import type {
|
||||
ChannelCultureRow,
|
||||
DashboardChannel,
|
||||
DashboardStats,
|
||||
} from "@/lib/types";
|
||||
|
||||
export type NodeKind =
|
||||
| "guild"
|
||||
| "channel"
|
||||
| "message"
|
||||
| "flagged"
|
||||
| "speaker"
|
||||
| "media"
|
||||
| "term"
|
||||
| "metric";
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: NodeKind;
|
||||
/** Optional route this node navigates to (plain <a href>, trailingSlash). */
|
||||
href?: string;
|
||||
/** Relative magnitude used for radius/glow (0..1 normalized by caller). */
|
||||
value?: number;
|
||||
/** Extra scene-specific payload (e.g. flagged count, culture summary). */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
/** Edge thickness/pulse driver. Defaults to 0.5. */
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface ConstellationGraph {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
const clamp01 = (n: number, max: number): number =>
|
||||
max <= 0 ? 0.25 : Math.min(1, Math.max(0.05, n / max));
|
||||
|
||||
/** Dashboard scene: guild star at center, top channels orbiting it. */
|
||||
export function statsToGraph(stats: DashboardStats): ConstellationGraph {
|
||||
const top = stats.top_channels ?? [];
|
||||
const maxCount = top.reduce((m, c) => Math.max(m, c.message_count), 0);
|
||||
const nodes: GraphNode[] = [
|
||||
{
|
||||
id: "guild",
|
||||
label: "GMW",
|
||||
kind: "guild",
|
||||
value: 1,
|
||||
meta: {
|
||||
total_messages: stats.total_messages,
|
||||
total_flagged: stats.total_flagged,
|
||||
active_users_24h: stats.active_users_24h,
|
||||
},
|
||||
},
|
||||
];
|
||||
const edges: GraphEdge[] = [];
|
||||
for (const ch of top) {
|
||||
nodes.push({
|
||||
id: `channel:${ch.channel_id}`,
|
||||
label: ch.channel_name || ch.channel_id,
|
||||
kind: "channel",
|
||||
href: "/channels/",
|
||||
value: clamp01(ch.message_count, maxCount),
|
||||
meta: { message_count: ch.message_count },
|
||||
});
|
||||
edges.push({
|
||||
source: "guild",
|
||||
target: `channel:${ch.channel_id}`,
|
||||
weight: clamp01(ch.message_count, maxCount),
|
||||
});
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/** Channels scene: every channel is a star; size = traffic, color = flags. */
|
||||
export function channelsToGraph(
|
||||
channels: DashboardChannel[],
|
||||
): ConstellationGraph {
|
||||
const maxMsg = channels.reduce((m, c) => Math.max(m, c.total_messages), 0);
|
||||
const nodes: GraphNode[] = channels.map((c) => ({
|
||||
id: `channel:${c.channel_id}`,
|
||||
label: c.channel_name || c.channel_id,
|
||||
kind: "channel",
|
||||
value: clamp01(c.total_messages, maxMsg),
|
||||
meta: {
|
||||
flagged_count: c.flagged_count,
|
||||
culture_summary: c.culture_summary,
|
||||
},
|
||||
}));
|
||||
return { nodes, edges: [] };
|
||||
}
|
||||
|
||||
/** Channels scene variant fed by culture-knowledge rows. */
|
||||
export function culturesToGraph(rows: ChannelCultureRow[]): ConstellationGraph {
|
||||
const nodes: GraphNode[] = rows.map((r, i) => ({
|
||||
id: `channel:${r.channel_id}`,
|
||||
label: r.channel_name || r.channel_id,
|
||||
kind: "channel",
|
||||
value: r.culture_summary ? 0.4 + (i % 5) * 0.12 : 0.2,
|
||||
meta: {
|
||||
culture_summary: r.culture_summary,
|
||||
last_analyzed_at: r.last_analyzed_at,
|
||||
},
|
||||
}));
|
||||
return { nodes, edges: [] };
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { channelsToGraph, statsToGraph } from "./graph";
|
||||
import { computeLayout, radiusFor } from "./layout";
|
||||
|
||||
const statsFixture = {
|
||||
total_messages: 1200,
|
||||
total_users: 40,
|
||||
total_flagged: 30,
|
||||
total_clean: 1100,
|
||||
total_warned: 12,
|
||||
total_error: 2,
|
||||
total_voice_recordings: 8,
|
||||
total_profiles: 40,
|
||||
today_messages: 55,
|
||||
today_flagged: 3,
|
||||
active_users_24h: 18,
|
||||
top_channels: [
|
||||
{ channel_id: "c1", channel_name: "general", message_count: 400 },
|
||||
{ channel_id: "c2", channel_name: "random", message_count: 200 },
|
||||
],
|
||||
moderation_overview: { pending: 1, processing: 0, error: 0 },
|
||||
};
|
||||
|
||||
describe("statsToGraph", () => {
|
||||
test("guild center + channel nodes + edges", () => {
|
||||
const g = statsToGraph(statsFixture);
|
||||
expect(g.nodes.length).toBe(3);
|
||||
expect(g.nodes[0]?.id).toBe("guild");
|
||||
expect(g.edges.length).toBe(2);
|
||||
expect(g.edges[0]?.source).toBe("guild");
|
||||
});
|
||||
|
||||
test("bigger channel gets bigger value", () => {
|
||||
const g = statsToGraph(statsFixture);
|
||||
const c1 = g.nodes.find((n) => n.label === "general");
|
||||
const c2 = g.nodes.find((n) => n.label === "random");
|
||||
expect((c1?.value ?? 0) > (c2?.value ?? 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("empty top_channels still yields guild node", () => {
|
||||
const g = statsToGraph({ ...statsFixture, top_channels: [] });
|
||||
expect(g.nodes.length).toBe(1);
|
||||
expect(g.edges.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("channelsToGraph", () => {
|
||||
test("maps every channel", () => {
|
||||
const chans = [
|
||||
{
|
||||
channel_id: "a",
|
||||
channel_name: "alpha",
|
||||
total_messages: 10,
|
||||
flagged_count: 1,
|
||||
},
|
||||
{ channel_id: "b", total_messages: 5, flagged_count: 0 },
|
||||
];
|
||||
const g = channelsToGraph(chans);
|
||||
expect(g.nodes.length).toBe(2);
|
||||
expect(g.nodes[1]?.label).toBe("b"); // falls back to id when name null
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLayout", () => {
|
||||
const nodes = [
|
||||
{ id: "guild", kind: "guild", value: 1 },
|
||||
{ id: "channel:c1", kind: "channel", value: 0.8 },
|
||||
{ id: "channel:c2", kind: "channel", value: 0.4 },
|
||||
];
|
||||
const edges = [
|
||||
{ source: "guild", target: "channel:c1" },
|
||||
{ source: "guild", target: "channel:c2" },
|
||||
];
|
||||
|
||||
test("deterministic for same seed", () => {
|
||||
const a = computeLayout(nodes, edges, {
|
||||
width: 900,
|
||||
height: 700,
|
||||
seed: 42,
|
||||
});
|
||||
const b = computeLayout(nodes, edges, {
|
||||
width: 900,
|
||||
height: 700,
|
||||
seed: 42,
|
||||
});
|
||||
expect(a.map((n) => [n.id, Math.round(n.x), Math.round(n.y)])).toEqual(
|
||||
b.map((n) => [n.id, Math.round(n.x), Math.round(n.y)]),
|
||||
);
|
||||
});
|
||||
|
||||
test("all finite positions within canvas bounds", () => {
|
||||
const out = computeLayout(nodes, edges, {
|
||||
width: 900,
|
||||
height: 700,
|
||||
seed: 7,
|
||||
});
|
||||
for (const n of out) {
|
||||
expect(Number.isFinite(n.x)).toBe(true);
|
||||
expect(Number.isFinite(n.y)).toBe(true);
|
||||
expect(n.x).toBeGreaterThanOrEqual(-50);
|
||||
expect(n.x).toBeLessThanOrEqual(950);
|
||||
expect(n.y).toBeGreaterThanOrEqual(-50);
|
||||
expect(n.y).toBeLessThanOrEqual(750);
|
||||
}
|
||||
});
|
||||
|
||||
test("reduced motion returns static ring", () => {
|
||||
const out = computeLayout(nodes, edges, {
|
||||
width: 900,
|
||||
height: 700,
|
||||
reduced: true,
|
||||
});
|
||||
expect(out.length).toBe(3);
|
||||
expect(Number.isFinite(out[0]?.y ?? NaN)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("radiusFor", () => {
|
||||
test("guild larger than channel larger than message", () => {
|
||||
expect(radiusFor("guild", 1)).toBeGreaterThan(radiusFor("channel", 1));
|
||||
expect(radiusFor("channel", 1)).toBeGreaterThan(radiusFor("message", 1));
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Deterministic constellation layout — pure functions over graph data.
|
||||
* d3-force with a seeded PRNG so every render produces the same sky.
|
||||
* `prefers-reduced-motion` callers use the static radial fallback.
|
||||
*/
|
||||
import {
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceSimulation,
|
||||
type SimulationNodeDatum,
|
||||
} from "d3-force";
|
||||
|
||||
export interface LayoutNode extends SimulationNodeDatum {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Screen-space radius in px (already scaled by value + kind). */
|
||||
r: number;
|
||||
}
|
||||
|
||||
export interface LayoutOptions {
|
||||
/** Canvas width in CSS px. */
|
||||
width: number;
|
||||
height: number;
|
||||
seed?: number;
|
||||
/** Static radial ring layout (no simulation). */
|
||||
reduced?: boolean;
|
||||
}
|
||||
|
||||
/** Mulberry32 — small, fast, deterministic. */
|
||||
function mulberry32(seed: number): () => 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)) | 0;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function radiusFor(kind: string, value = 0.5): number {
|
||||
const base =
|
||||
kind === "guild"
|
||||
? 34
|
||||
: kind === "channel"
|
||||
? 16
|
||||
: kind === "flagged"
|
||||
? 11
|
||||
: 9;
|
||||
return base * (0.55 + 0.9 * Math.max(0, Math.min(1, value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute final positions. Same inputs (+seed) ⇒ identical output positions.
|
||||
*/
|
||||
export function computeLayout(
|
||||
nodesIn: { id: string; kind: string; value?: number }[],
|
||||
edges: { source: string; target: string }[],
|
||||
opts: LayoutOptions,
|
||||
): LayoutNode[] {
|
||||
const rand = mulberry32(opts.seed ?? 42);
|
||||
const cx = opts.width / 2;
|
||||
const cy = opts.height / 2;
|
||||
|
||||
if (opts.reduced || nodesIn.length === 0) {
|
||||
return nodesIn.map((n, i) => ({
|
||||
...n,
|
||||
x: cx + radiusFor(n.kind, n.value),
|
||||
y:
|
||||
cy +
|
||||
Math.sin((i / Math.max(1, nodesIn.length)) * Math.PI * 2) *
|
||||
Math.min(cx, cy) *
|
||||
0.62,
|
||||
r: radiusFor(n.kind, n.value),
|
||||
}));
|
||||
}
|
||||
|
||||
const simNodes = nodesIn.map((n) => ({
|
||||
id: n.id,
|
||||
kind: n.kind,
|
||||
value: n.value ?? 0.5,
|
||||
r: radiusFor(n.kind, n.value),
|
||||
x: cx + (rand() - 0.5) * opts.width * 0.8,
|
||||
y: cy + (rand() - 0.5) * opts.height * 0.8,
|
||||
}));
|
||||
|
||||
const sim = forceSimulation(simNodes)
|
||||
.force("charge", forceManyBody().strength(-420))
|
||||
.force(
|
||||
"link",
|
||||
forceLink(edges.map((e) => ({ ...e })))
|
||||
.id((d) => (d as { id: string }).id)
|
||||
.distance(120)
|
||||
.strength(0.6),
|
||||
)
|
||||
.force("center", forceCenter(cx, cy))
|
||||
.force(
|
||||
"collide",
|
||||
forceCollide((d) => (d as { r: number }).r + 14),
|
||||
)
|
||||
.stop();
|
||||
|
||||
// Fixed tick budget keeps the result deterministic across machines.
|
||||
for (let i = 0; i < 240; i++) sim.tick();
|
||||
sim.on("tick", null);
|
||||
|
||||
return simNodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: Number.isFinite(n.x) ? n.x : cx,
|
||||
y: Number.isFinite(n.y) ? n.y : cy,
|
||||
r: n.r,
|
||||
}));
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* 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