From f0b0ec06b1d58d5c318997333765722ff39bb874 Mon Sep 17 00:00:00 2001 From: haroon0x Date: Sun, 5 Jul 2026 09:02:19 +0000 Subject: [PATCH] feat(memory-graph): configurable labels + popover z-index Document-facing copy and hover popover stacking were hardcoded, forcing downstream apps to patch dist/ output when product vocabulary (sources vs documents) or app-shell layering differ. - add prop (MemoryGraphLabels): legend group, edge copy, popover type fallback, ID label, nav actions, memory/loading count templates; defaults preserve existing strings exactly - add prop; default stays 100 - export DEFAULT_LABELS, DEFAULT_HOVER_POPOVER_Z_INDEX + new types --- .../src/__tests__/labels-layering.test.ts | 154 ++++++++++++++++++ .../src/__tests__/node-hover-popover.test.tsx | 4 +- .../src/__tests__/popover-render.e2e.test.tsx | 101 ++++++++++++ .../memory-graph/src/components/legend.tsx | 18 +- .../src/components/loading-indicator.tsx | 135 ++++++++------- .../src/components/memory-graph.tsx | 18 +- .../src/components/node-hover-popover.tsx | 28 +++- packages/memory-graph/src/constants.ts | 20 ++- packages/memory-graph/src/index.tsx | 11 +- packages/memory-graph/src/types.ts | 25 +++ 10 files changed, 436 insertions(+), 78 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/labels-layering.test.ts create mode 100644 packages/memory-graph/src/__tests__/popover-render.e2e.test.tsx diff --git a/packages/memory-graph/src/__tests__/labels-layering.test.ts b/packages/memory-graph/src/__tests__/labels-layering.test.ts new file mode 100644 index 000000000..07338a386 --- /dev/null +++ b/packages/memory-graph/src/__tests__/labels-layering.test.ts @@ -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") + 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") + }) +}) diff --git a/packages/memory-graph/src/__tests__/node-hover-popover.test.tsx b/packages/memory-graph/src/__tests__/node-hover-popover.test.tsx index 4631c02b7..e843bf94e 100644 --- a/packages/memory-graph/src/__tests__/node-hover-popover.test.tsx +++ b/packages/memory-graph/src/__tests__/node-hover-popover.test.tsx @@ -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)", () => { diff --git a/packages/memory-graph/src/__tests__/popover-render.e2e.test.tsx b/packages/memory-graph/src/__tests__/popover-render.e2e.test.tsx new file mode 100644 index 000000000..95ab18ef4 --- /dev/null +++ b/packages/memory-graph/src/__tests__/popover-render.e2e.test.tsx @@ -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( + , + ) +} + +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") + }) +}) diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index 32c520af8..e9be3a228 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -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} />
- Document source + + {labels.documentSourceEdge} +
{derivesCount}
- Document to memory + {labels.documentToMemoryEdge}
diff --git a/packages/memory-graph/src/components/loading-indicator.tsx b/packages/memory-graph/src/components/loading-indicator.tsx index 37ddc82ee..8d550eb98 100644 --- a/packages/memory-graph/src/components/loading-indicator.tsx +++ b/packages/memory-graph/src/components/loading-indicator.tsx @@ -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 + LoadingIndicatorProps & { + colors?: GraphThemeColors + labels?: ResolvedMemoryGraphLabels + } +>( + ({ + isLoading, + isLoadingMore, + totalLoaded, + colors, + labels = DEFAULT_LABELS, + }) => { + if (!isLoading && !isLoadingMore) return null - injectSpinStyle() + 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)", - } + 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 flexStyle: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 8, - } + const flexStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + } - const spinnerStyle: React.CSSProperties = { - width: 16, - height: 16, - animation: "mg-spin 1s linear infinite", - color: colors?.accent ?? "#3B73B8", - flexShrink: 0, - } + 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", - } + const textStyle: React.CSSProperties = { + fontSize: 14, + color: colors?.textSecondary ?? "#e2e8f0", + } - return ( -
-
- - - {isLoading - ? "Loading memory graph..." - : `Loading more documents... (${totalLoaded})`} - + return ( +
+
+ + + {isLoading + ? "Loading memory graph..." + : labels.loadingMoreDocuments(totalLoaded)} + +
-
- ) -}) + ) + }, +) LoadingIndicator.displayName = "LoadingIndicator" diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 048e1a1c3..9d0c2ef65 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -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( + () => ({ ...DEFAULT_LABELS, ...labelOverrides }), + [labelOverrides], + ) + const hoverPopoverZIndex = + layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX const resolvedColors = useGraphTheme(colorOverrides) const colors = useMemo( () => @@ -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({ @@ -783,7 +794,9 @@ export function MemoryGraph({ void onNavigatePrev?: () => void onNavigateUp?: () => void @@ -310,6 +314,8 @@ export const NodeHoverPopover = memo( containerBounds, versionChain, colors, + labels = DEFAULT_LABELS, + zIndex = DEFAULT_HOVER_POPOVER_Z_INDEX, onNavigateNext, onNavigatePrev, onNavigateUp, @@ -419,7 +425,7 @@ export const NodeHoverPopover = memo( left: 0, right: 0, bottom: 0, - zIndex: 100, + zIndex, } const svgStyle: React.CSSProperties = { @@ -605,7 +611,7 @@ export const NodeHoverPopover = memo( color: colors.popoverTextSecondary, }} > - {docData?.type || "document"} + {docData?.type || labels.documentTypeFallback} ( color: colors.popoverTextSecondary, }} > - {docData?.memories?.length ?? 0} memories + {labels.memoryCount(docData?.memories?.length ?? 0)} )} @@ -623,7 +629,11 @@ export const NodeHoverPopover = memo( {isMemory ? ( ) : ( - + )}
@@ -633,7 +643,7 @@ export const NodeHoverPopover = memo( } - label="View document" + label={labels.viewDocument} onClick={() => onOpenDocument(documentId)} /> )} @@ -641,7 +651,7 @@ export const NodeHoverPopover = memo( )} @@ -649,20 +659,20 @@ export const NodeHoverPopover = memo( )} diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts index fcfe36508..8520b0453 100644 --- a/packages/memory-graph/src/constants.ts +++ b/packages/memory-graph/src/constants.ts @@ -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", diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index 1d17766a3..edeeb31cc 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -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, diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index b446a0232..f871b3dab 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -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 + +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 {