feat(memory-graph): configurable labels + popover z-index (#1201)

This commit is contained in:
Haroon 2026-07-11 07:45:17 +05:30 committed by GitHub
parent 9487a928f6
commit d77b6eb6d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 437 additions and 79 deletions

View file

@ -0,0 +1,154 @@
/**
* Tests for configurable labels and hover popover layering.
*
* Follows the same pattern as node-hover-popover.test.tsx: source-code
* assertions plus pure-logic tests on the exported defaults and merge
* behaviour, since hook-bearing components can't be mounted in this
* workspace's vitest environment.
*/
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { describe, expect, it } from "vitest"
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
import type { MemoryGraphLabels, ResolvedMemoryGraphLabels } from "../types"
const popoverSrc = readFileSync(
resolve(__dirname, "../components/node-hover-popover.tsx"),
"utf-8",
)
const legendSrc = readFileSync(
resolve(__dirname, "../components/legend.tsx"),
"utf-8",
)
const graphSrc = readFileSync(
resolve(__dirname, "../components/memory-graph.tsx"),
"utf-8",
)
const indexSrc = readFileSync(resolve(__dirname, "../index.tsx"), "utf-8")
const loadingSrc = readFileSync(
resolve(__dirname, "../components/loading-indicator.tsx"),
"utf-8",
)
describe("DEFAULT_LABELS — preserves original copy", () => {
it("matches every previously hardcoded string", () => {
expect(DEFAULT_LABELS.documentGroup).toBe("Documents")
expect(DEFAULT_LABELS.documentTypeFallback).toBe("document")
expect(DEFAULT_LABELS.documentSourceEdge).toBe("Document source")
expect(DEFAULT_LABELS.documentToMemoryEdge).toBe("Document to memory")
expect(DEFAULT_LABELS.documentIdLabel).toBe("Document")
expect(DEFAULT_LABELS.viewDocument).toBe("View document")
expect(DEFAULT_LABELS.goToDocument).toBe("Go to document")
expect(DEFAULT_LABELS.nextDocument).toBe("Next document")
expect(DEFAULT_LABELS.previousDocument).toBe("Prev document")
expect(DEFAULT_LABELS.showMemory).toBe("Go to memory")
})
it("memoryCount formats counts like the original template", () => {
expect(DEFAULT_LABELS.memoryCount(0)).toBe("0 memories")
expect(DEFAULT_LABELS.memoryCount(5)).toBe("5 memories")
})
it("loadingMoreDocuments formats counts like the original template", () => {
expect(DEFAULT_LABELS.loadingMoreDocuments(12)).toBe(
"Loading more documents... (12)",
)
})
})
describe("label merge — partial overrides win, defaults fill the rest", () => {
it("spread merge overrides only the provided keys", () => {
const overrides: MemoryGraphLabels = {
documentGroup: "Sources",
viewDocument: "View source",
}
const resolved: ResolvedMemoryGraphLabels = {
...DEFAULT_LABELS,
...overrides,
}
expect(resolved.documentGroup).toBe("Sources")
expect(resolved.viewDocument).toBe("View source")
expect(resolved.nextDocument).toBe("Next document")
expect(resolved.memoryCount(3)).toBe("3 memories")
})
it("custom memoryCount function replaces the default", () => {
const resolved: ResolvedMemoryGraphLabels = {
...DEFAULT_LABELS,
memoryCount: (count) => `${count} facts`,
}
expect(resolved.memoryCount(2)).toBe("2 facts")
})
})
describe("hover popover layering", () => {
it("default z-index stays at the previous hardcoded value", () => {
expect(DEFAULT_HOVER_POPOVER_Z_INDEX).toBe(100)
})
it("popover overlay uses the zIndex prop, not a literal", () => {
expect(popoverSrc).not.toContain("zIndex: 100")
const overlayIdx = popoverSrc.indexOf("const overlayStyle")
const overlayEnd = popoverSrc.indexOf("}", overlayIdx)
expect(popoverSrc.slice(overlayIdx, overlayEnd)).toContain("zIndex,")
})
it("popover defaults zIndex to DEFAULT_HOVER_POPOVER_Z_INDEX", () => {
expect(popoverSrc).toContain("zIndex = DEFAULT_HOVER_POPOVER_Z_INDEX")
})
it("MemoryGraph resolves layering.hoverPopoverZIndex with fallback", () => {
expect(graphSrc).toContain(
"layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX",
)
expect(graphSrc).toContain("zIndex={hoverPopoverZIndex}")
})
})
describe("no user-facing document strings remain hardcoded", () => {
it("popover uses labels for all document-facing copy", () => {
expect(popoverSrc).not.toContain('"View document"')
expect(popoverSrc).not.toContain('"Go to document"')
expect(popoverSrc).not.toContain('"Next document"')
expect(popoverSrc).not.toContain('"Prev document"')
expect(popoverSrc).not.toContain('label="Document"')
expect(popoverSrc).not.toContain('|| "document"')
expect(popoverSrc).toContain("labels.documentTypeFallback")
expect(popoverSrc).toContain("labels.memoryCount(")
expect(popoverSrc).toContain("labels.documentIdLabel")
expect(popoverSrc).toContain("labels.viewDocument")
expect(popoverSrc).toContain("labels.goToDocument")
expect(popoverSrc).toContain("labels.showMemory")
expect(popoverSrc).toContain("labels.nextDocument")
expect(popoverSrc).toContain("labels.previousDocument")
})
it("legend uses labels for group and edge copy", () => {
expect(legendSrc).not.toContain('label="Documents"')
expect(legendSrc).not.toContain("Document source</span>")
expect(legendSrc).not.toContain("Document to memory")
expect(legendSrc).toContain("labels.documentGroup")
expect(legendSrc).toContain("labels.documentSourceEdge")
expect(legendSrc).toContain("labels.documentToMemoryEdge")
})
it("loading indicator uses labels for the loading-more copy", () => {
expect(loadingSrc).not.toContain("Loading more documents")
expect(loadingSrc).toContain("labels.loadingMoreDocuments(totalLoaded)")
})
it("MemoryGraph merges label overrides and passes them down", () => {
expect(graphSrc).toContain("{ ...DEFAULT_LABELS, ...labelOverrides }")
expect(graphSrc).toContain("labels={resolvedLabels}")
})
})
describe("public API exports", () => {
it("index exports the new constants and types", () => {
expect(indexSrc).toContain("DEFAULT_LABELS")
expect(indexSrc).toContain("DEFAULT_HOVER_POPOVER_Z_INDEX")
expect(indexSrc).toContain("MemoryGraphLabels")
expect(indexSrc).toContain("MemoryGraphLayering")
})
})

View file

@ -209,8 +209,8 @@ describe("'View document' button — render guard in JSX", () => {
expect(src).toContain("onClick={() => onOpenDocument(documentId)}")
})
it("button label text is 'View document'", () => {
expect(src).toContain('label="View document"')
it("button label comes from configurable labels", () => {
expect(src).toContain("label={labels.viewDocument}")
})
it("button icon is the EyeIcon component (not a string shortcut key)", () => {

View file

@ -0,0 +1,101 @@
/**
* Mounted-render verification for configurable labels and popover layering.
*/
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
afterEach(cleanup)
import { NodeHoverPopover } from "../components/node-hover-popover"
import { DEFAULT_COLORS, DEFAULT_LABELS } from "../constants"
import type { GraphNode, ResolvedMemoryGraphLabels } from "../types"
const documentNode: GraphNode = {
id: "doc-1",
type: "document",
x: 0,
y: 0,
data: {
id: "doc-1",
title: "Doc title",
summary: "Doc summary",
type: "",
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
memories: [],
},
size: 40,
borderColor: "#fff",
isHovered: false,
isDragging: false,
}
function renderPopover(
labels?: ResolvedMemoryGraphLabels,
zIndex?: number,
onOpenDocument?: (id: string) => void,
) {
return render(
<NodeHoverPopover
colors={DEFAULT_COLORS}
labels={labels}
node={documentNode}
nodeRadius={20}
screenX={100}
screenY={100}
zIndex={zIndex}
onOpenDocument={onOpenDocument}
/>,
)
}
describe("NodeHoverPopover mounted render", () => {
it("renders default document copy when no labels are passed", () => {
const { container, getByText } = renderPopover(
undefined,
undefined,
() => {},
)
getByText("View document")
getByText("Go to memory")
getByText("Next document")
getByText("Prev document")
getByText("document")
getByText("Document")
getByText("0 memories")
const overlay = container.firstChild as HTMLElement
expect(overlay.style.zIndex).toBe("100")
})
it("renders custom source-facing copy and custom z-index", () => {
const labels: ResolvedMemoryGraphLabels = {
...DEFAULT_LABELS,
documentGroup: "Sources",
documentTypeFallback: "source",
documentIdLabel: "Source",
viewDocument: "View source",
goToDocument: "Go to source",
nextDocument: "Next source",
previousDocument: "Prev source",
showMemory: "Show memory",
memoryCount: (count) => `${count} facts`,
}
const { container, getByText, queryByText } = renderPopover(
labels,
30,
() => {},
)
getByText("View source")
getByText("Show memory")
getByText("Next source")
getByText("Prev source")
getByText("source")
getByText("Source")
getByText("0 facts")
expect(queryByText("View document")).toBeNull()
const overlay = container.firstChild as HTMLElement
expect(overlay.style.zIndex).toBe("30")
})
})

View file

@ -1,11 +1,18 @@
import { memo, useState } from "react"
import type { GraphEdge, GraphNode, GraphThemeColors } from "../types"
import { DEFAULT_LABELS } from "../constants"
import type {
GraphEdge,
GraphNode,
GraphThemeColors,
ResolvedMemoryGraphLabels,
} from "../types"
interface LegendProps {
nodes?: GraphNode[]
edges?: GraphEdge[]
isLoading?: boolean
colors: GraphThemeColors
labels?: ResolvedMemoryGraphLabels
hoveredNode?: string | null
compact?: boolean
maxHeight?: number
@ -291,6 +298,7 @@ export const Legend = memo(function Legend({
edges = [],
isLoading: _isLoading = false,
colors,
labels = DEFAULT_LABELS,
hoveredNode,
compact = false,
maxHeight,
@ -462,7 +470,7 @@ export const Legend = memo(function Legend({
}}
/>
}
label="Documents"
label={labels.documentGroup}
colors={colors}
/>
<StatRow
@ -523,12 +531,14 @@ export const Legend = memo(function Legend({
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDerives} />
<span style={edgeLabelStyle}>Document source</span>
<span style={edgeLabelStyle}>
{labels.documentSourceEdge}
</span>
</div>
<span style={countStyle}>{derivesCount}</span>
</div>
<div style={edgeDescriptionStyle}>
Document to memory
{labels.documentToMemoryEdge}
</div>
</div>
<div>

View file

@ -1,5 +1,10 @@
import { memo } from "react"
import type { GraphThemeColors, LoadingIndicatorProps } from "../types"
import { DEFAULT_LABELS } from "../constants"
import type {
GraphThemeColors,
LoadingIndicatorProps,
ResolvedMemoryGraphLabels,
} from "../types"
const spinKeyframes = `
@keyframes mg-spin {
@ -18,70 +23,82 @@ function injectSpinStyle() {
}
export const LoadingIndicator = memo<
LoadingIndicatorProps & { colors?: GraphThemeColors }
>(({ isLoading, isLoadingMore, totalLoaded, colors }) => {
if (!isLoading && !isLoadingMore) return null
injectSpinStyle()
const containerStyle: React.CSSProperties = {
position: "absolute",
zIndex: 30,
overflow: "hidden",
top: 16,
left: 16,
borderRadius: 12,
border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`,
backgroundColor: colors?.controlBg ?? "#1a1f29",
paddingLeft: 16,
paddingRight: 16,
paddingTop: 12,
paddingBottom: 12,
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
LoadingIndicatorProps & {
colors?: GraphThemeColors
labels?: ResolvedMemoryGraphLabels
}
>(
({
isLoading,
isLoadingMore,
totalLoaded,
colors,
labels = DEFAULT_LABELS,
}) => {
if (!isLoading && !isLoadingMore) return null
const flexStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
}
injectSpinStyle()
const spinnerStyle: React.CSSProperties = {
width: 16,
height: 16,
animation: "mg-spin 1s linear infinite",
color: colors?.accent ?? "#3B73B8",
flexShrink: 0,
}
const containerStyle: React.CSSProperties = {
position: "absolute",
zIndex: 30,
overflow: "hidden",
top: 16,
left: 16,
borderRadius: 12,
border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`,
backgroundColor: colors?.controlBg ?? "#1a1f29",
paddingLeft: 16,
paddingRight: 16,
paddingTop: 12,
paddingBottom: 12,
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
}
const textStyle: React.CSSProperties = {
fontSize: 14,
color: colors?.textSecondary ?? "#e2e8f0",
}
const flexStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
}
return (
<div style={containerStyle}>
<div style={flexStyle}>
<svg
aria-hidden="true"
fill="none"
role="img"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
style={spinnerStyle}
>
<title>Loading</title>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
<span style={textStyle}>
{isLoading
? "Loading memory graph..."
: `Loading more documents... (${totalLoaded})`}
</span>
const spinnerStyle: React.CSSProperties = {
width: 16,
height: 16,
animation: "mg-spin 1s linear infinite",
color: colors?.accent ?? "#3B73B8",
flexShrink: 0,
}
const textStyle: React.CSSProperties = {
fontSize: 14,
color: colors?.textSecondary ?? "#e2e8f0",
}
return (
<div style={containerStyle}>
<div style={flexStyle}>
<svg
aria-hidden="true"
fill="none"
role="img"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
style={spinnerStyle}
>
<title>Loading</title>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
<span style={textStyle}>
{isLoading
? "Loading memory graph..."
: labels.loadingMoreDocuments(totalLoaded)}
</span>
</div>
</div>
</div>
)
})
)
},
)
LoadingIndicator.displayName = "LoadingIndicator"

View file

@ -3,6 +3,7 @@ import {
DENSE_GRAPH_STATIC_THRESHOLD,
ForceSimulation,
} from "../canvas/simulation"
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
import { VersionChainIndex } from "../canvas/version-chain"
import type { ViewportState } from "../canvas/viewport"
import { useGraphData } from "../hooks/use-graph-data"
@ -11,6 +12,7 @@ import type {
GraphApiDocument,
GraphThemeColors,
MemoryGraphProps,
ResolvedMemoryGraphLabels,
} from "../types"
import { GraphCanvas } from "./graph-canvas"
import { Legend } from "./legend"
@ -37,7 +39,15 @@ export function MemoryGraph({
colors: colorOverrides,
totalCount,
onOpenDocument,
labels: labelOverrides,
layering,
}: MemoryGraphProps) {
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
[labelOverrides],
)
const hoverPopoverZIndex =
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
const resolvedColors = useGraphTheme(colorOverrides)
const colors = useMemo<GraphThemeColors>(
() =>
@ -397,8 +407,8 @@ export function MemoryGraph({
}, [containerSize.width, graphFitHeight])
// Wrap onOpenDocument to dismiss the popover before opening the modal.
// Without this, the popover (z-index: 100) stays mounted on top of the
// document modal (z-50), obscuring it and intercepting clicks.
// Without this, the popover overlay stays mounted on top of the
// document modal, obscuring it and intercepting clicks.
const handleOpenDocument = useCallback(
(documentId: string) => {
setSelectedNode(null)
@ -748,6 +758,7 @@ export function MemoryGraph({
<LoadingIndicator
isLoading={isLoading}
isLoadingMore={isLoadingMore}
labels={resolvedLabels}
totalLoaded={totalCount ?? documents.length}
colors={colors}
/>
@ -783,7 +794,9 @@ export function MemoryGraph({
<NodeHoverPopover
colors={colors}
containerBounds={containerBounds ?? undefined}
labels={resolvedLabels}
node={activeNodeData}
zIndex={hoverPopoverZIndex}
nodeRadius={activePopoverPosition.nodeRadius}
onNavigateDown={navigateDown}
onNavigateNext={navigateNext}
@ -812,6 +825,7 @@ export function MemoryGraph({
<Legend
colors={colors}
edges={edges}
labels={resolvedLabels}
hoveredNode={hoveredNode}
compact={isCompactViewport}
isLoading={isLoading}

View file

@ -1,10 +1,12 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ChainEntry } from "../canvas/version-chain"
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
import type {
DocumentNodeData,
GraphNode,
GraphThemeColors,
MemoryNodeData,
ResolvedMemoryGraphLabels,
} from "../types"
export interface NodeHoverPopoverProps {
@ -15,6 +17,8 @@ export interface NodeHoverPopoverProps {
containerBounds?: DOMRect
versionChain?: ChainEntry[] | null
colors: GraphThemeColors
labels?: ResolvedMemoryGraphLabels
zIndex?: number
onNavigateNext?: () => void
onNavigatePrev?: () => void
onNavigateUp?: () => void
@ -310,6 +314,8 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
containerBounds,
versionChain,
colors,
labels = DEFAULT_LABELS,
zIndex = DEFAULT_HOVER_POPOVER_Z_INDEX,
onNavigateNext,
onNavigatePrev,
onNavigateUp,
@ -419,7 +425,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
left: 0,
right: 0,
bottom: 0,
zIndex: 100,
zIndex,
}
const svgStyle: React.CSSProperties = {
@ -605,7 +611,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
color: colors.popoverTextSecondary,
}}
>
{docData?.type || "document"}
{docData?.type || labels.documentTypeFallback}
</span>
<span
style={{
@ -613,7 +619,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
color: colors.popoverTextSecondary,
}}
>
{docData?.memories?.length ?? 0} memories
{labels.memoryCount(docData?.memories?.length ?? 0)}
</span>
</>
)}
@ -623,7 +629,11 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
{isMemory ? (
<CopyableId colors={colors} label="Memory" value={node.id} />
) : (
<CopyableId colors={colors} label="Document" value={node.id} />
<CopyableId
colors={colors}
label={labels.documentIdLabel}
value={node.id}
/>
)}
</div>
</div>
@ -633,7 +643,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<NavButton
colors={colors}
icon={<EyeIcon color={colors.popoverTextMuted} />}
label="View document"
label={labels.viewDocument}
onClick={() => onOpenDocument(documentId)}
/>
)}
@ -641,7 +651,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<NavButton
colors={colors}
icon="↑"
label={hasChain ? "Older version" : "Go to document"}
label={hasChain ? "Older version" : labels.goToDocument}
onClick={onNavigateUp}
/>
)}
@ -649,20 +659,20 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<NavButton
colors={colors}
icon="↓"
label={isMemory ? "Newer version" : "Go to memory"}
label={isMemory ? "Newer version" : labels.showMemory}
onClick={onNavigateDown}
/>
)}
<NavButton
colors={colors}
icon="→"
label={isMemory ? "Next memory" : "Next document"}
label={isMemory ? "Next memory" : labels.nextDocument}
onClick={onNavigateNext}
/>
<NavButton
colors={colors}
icon="←"
label={isMemory ? "Prev memory" : "Prev document"}
label={isMemory ? "Prev memory" : labels.previousDocument}
onClick={onNavigatePrev}
/>
</div>

View file

@ -1,4 +1,22 @@
import type { GraphThemeColors } from "./types"
import type { GraphThemeColors, ResolvedMemoryGraphLabels } from "./types"
export const DEFAULT_LABELS: ResolvedMemoryGraphLabels = {
documentGroup: "Documents",
documentTypeFallback: "document",
documentSourceEdge: "Document source",
documentToMemoryEdge: "Document to memory",
memoryCount: (count: number) => `${count} memories`,
documentIdLabel: "Document",
viewDocument: "View document",
goToDocument: "Go to document",
nextDocument: "Next document",
previousDocument: "Prev document",
showMemory: "Go to memory",
loadingMoreDocuments: (count: number) =>
`Loading more documents... (${count})`,
}
export const DEFAULT_HOVER_POPOVER_Z_INDEX = 100
export const MEMORY_BORDER_KEYS = {
forgotten: "memBorderForgotten",

View file

@ -13,11 +13,20 @@ export { SpatialIndex } from "./canvas/hit-test"
export { VersionChainIndex } from "./canvas/version-chain"
// Constants
export { DEFAULT_COLORS, FORCE_CONFIG, GRAPH_SETTINGS } from "./constants"
export {
DEFAULT_COLORS,
DEFAULT_HOVER_POPOVER_Z_INDEX,
DEFAULT_LABELS,
FORCE_CONFIG,
GRAPH_SETTINGS,
} from "./constants"
// Types
export type {
MemoryGraphProps,
MemoryGraphLabels,
MemoryGraphLayering,
ResolvedMemoryGraphLabels,
GraphNode,
GraphEdge,
GraphThemeColors,

View file

@ -132,6 +132,27 @@ export interface GraphThemeColors {
controlBorder: string
}
export interface MemoryGraphLabels {
documentGroup?: string
documentTypeFallback?: string
documentSourceEdge?: string
documentToMemoryEdge?: string
memoryCount?: (count: number) => string
documentIdLabel?: string
viewDocument?: string
goToDocument?: string
nextDocument?: string
previousDocument?: string
showMemory?: string
loadingMoreDocuments?: (count: number) => string
}
export type ResolvedMemoryGraphLabels = Required<MemoryGraphLabels>
export interface MemoryGraphLayering {
hoverPopoverZIndex?: number
}
export interface GraphCanvasProps {
nodes: GraphNode[]
edges: GraphEdge[]
@ -195,6 +216,10 @@ export interface MemoryGraphProps {
totalCount?: number
/** Callback when user wants to view full document content */
onOpenDocument?: (documentId: string) => void
/** Custom user-facing labels (partial) - merged with defaults */
labels?: MemoryGraphLabels
/** Overlay layering controls */
layering?: MemoryGraphLayering
}
export interface ChainEntry {