supermemory/packages/memory-graph/src/hooks/use-graph-theme.ts
sohamd22 cfa6c7cb17 fix(memory-graph): distinguish document links from derives relations (#1701)
Document-to-memory links and actual `derives` relations were both emitted as `derives`, so they shared the same color and legend entry.

This separates structural document links into a `document` edge type, adds a dedicated theme color with `--graph-edge-document` support, and updates force-layout and level-of-detail handling to preserve existing structural behavior. The package and MCP widget legends/themes now distinguish document links from derived-memory relations.

Adds regression coverage for edge classification and validates the package plus its MCP consumer.

<!-- capy-badge:start -->
<a href="https://capy.ai/thread/jam_01M36F4MPFXZCA8J14T53YY029"><picture><source media="(prefers-color-scheme: dark)" srcset="https://capy.ai/badge/accent-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://capy.ai/badge/accent-light.svg"><img alt="Open in Capy" src="https://capy.ai/badge/accent-light.svg"></picture></a>
<!-- capy-badge:end -->
2026-09-25 22:00:12 +00:00

126 lines
4.1 KiB
TypeScript

import { useEffect, useMemo, useState } from "react"
import type { GraphThemeColors } from "../types"
import { DEFAULT_COLORS } from "../constants"
function readCssVar(name: string, fallback: string): string {
if (typeof document === "undefined") return fallback
const val = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
return val || fallback
}
function resolveColors(): GraphThemeColors {
return {
bg: readCssVar("--graph-bg", DEFAULT_COLORS.bg),
dotColor: readCssVar("--graph-dot", "") || undefined,
docFill: readCssVar("--graph-doc-fill", DEFAULT_COLORS.docFill),
docStroke: readCssVar("--graph-doc-stroke", DEFAULT_COLORS.docStroke),
docInnerFill: readCssVar("--graph-doc-inner", DEFAULT_COLORS.docInnerFill),
memFill: readCssVar("--graph-mem-fill", DEFAULT_COLORS.memFill),
memFillHover: readCssVar(
"--graph-mem-fill-hover",
DEFAULT_COLORS.memFillHover,
),
memStrokeDefault: readCssVar(
"--graph-mem-stroke",
DEFAULT_COLORS.memStrokeDefault,
),
accent: readCssVar("--graph-accent", DEFAULT_COLORS.accent),
textPrimary: readCssVar("--graph-text-primary", DEFAULT_COLORS.textPrimary),
textSecondary: readCssVar(
"--graph-text-secondary",
DEFAULT_COLORS.textSecondary,
),
textMuted: readCssVar("--graph-text-muted", DEFAULT_COLORS.textMuted),
edgeDocument: readCssVar(
"--graph-edge-document",
readCssVar("--graph-text-muted", DEFAULT_COLORS.edgeDocument),
),
edgeDerives: readCssVar("--graph-edge-derives", DEFAULT_COLORS.edgeDerives),
edgeUpdates: readCssVar("--graph-edge-updates", DEFAULT_COLORS.edgeUpdates),
edgeExtends: readCssVar("--graph-edge-extends", DEFAULT_COLORS.edgeExtends),
memBorderForgotten: readCssVar(
"--graph-mem-border-forgotten",
DEFAULT_COLORS.memBorderForgotten,
),
memBorderExpiring: readCssVar(
"--graph-mem-border-expiring",
DEFAULT_COLORS.memBorderExpiring,
),
memBorderRecent: readCssVar(
"--graph-mem-border-recent",
DEFAULT_COLORS.memBorderRecent,
),
glowColor: readCssVar("--graph-glow", DEFAULT_COLORS.glowColor),
iconColor: readCssVar("--graph-icon", DEFAULT_COLORS.iconColor),
popoverBg: readCssVar("--graph-popover-bg", DEFAULT_COLORS.popoverBg),
popoverBorder: readCssVar(
"--graph-popover-border",
DEFAULT_COLORS.popoverBorder,
),
popoverTextPrimary: readCssVar(
"--graph-popover-text-primary",
DEFAULT_COLORS.popoverTextPrimary,
),
popoverTextSecondary: readCssVar(
"--graph-popover-text-secondary",
DEFAULT_COLORS.popoverTextSecondary,
),
popoverTextMuted: readCssVar(
"--graph-popover-text-muted",
DEFAULT_COLORS.popoverTextMuted,
),
controlBg: readCssVar("--graph-control-bg", DEFAULT_COLORS.controlBg),
controlBorder: readCssVar(
"--graph-control-border",
DEFAULT_COLORS.controlBorder,
),
}
}
export function useGraphTheme(
overrides?: Partial<GraphThemeColors>,
): GraphThemeColors {
const [colors, setColors] = useState<GraphThemeColors>(() => resolveColors())
useEffect(() => {
const update = () => setColors(resolveColors())
// Re-read on theme class change
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === "attributes" && m.attributeName === "class") {
update()
}
}
})
observer.observe(document.documentElement, { attributes: true })
// Also listen for media query changes (system theme)
const mq = window.matchMedia("(prefers-color-scheme: dark)")
mq.addEventListener("change", update)
return () => {
observer.disconnect()
mq.removeEventListener("change", update)
}
}, [])
// Serialize overrides to a stable string key so useMemo only recomputes
// when the actual override values change, not on every render.
const overrideKey = overrides
? Object.entries(overrides)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${v}`)
.join(",")
: ""
const merged = useMemo(
() => (overrides ? { ...colors, ...overrides } : colors),
// biome-ignore lint/correctness/useExhaustiveDependencies: overrideKey tracks overrides by value
[colors, overrideKey],
)
return merged
}