From c81c05c14f78dfb7e335d2682c64e52a6e568bba Mon Sep 17 00:00:00 2001 From: Vorflux AI Date: Fri, 27 Mar 2026 05:16:48 +0000 Subject: [PATCH] Add visual enhancements: drop shadows, gradient fills, hover glow, forgotten X icon, version edge glow - Drop shadows on selected/hovered nodes (document + memory) for depth - Subtle gradient fill on document nodes - Improved dimming contrast for superseded memories (0.3 alpha + strikethrough) - Hover glow ring with dashed outline for interactivity feedback - X icon overlay on forgotten memory nodes (size > 14px) - Glow pass behind version edges for visual emphasis - lightenColor utility with cache + guard for non-6-digit hex - Use memData.isForgotten instead of fragile color comparison - Optimize version edge batch: skip filter, use first.isVersion flag - Add 10 unit tests for lightenColor (hex parsing, clamping, cache, edge cases) --- .../src/__tests__/renderer-utils.test.ts | 57 +++++++ packages/memory-graph/src/canvas/renderer.ts | 144 +++++++++++++++--- 2 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/renderer-utils.test.ts diff --git a/packages/memory-graph/src/__tests__/renderer-utils.test.ts b/packages/memory-graph/src/__tests__/renderer-utils.test.ts new file mode 100644 index 00000000..1b5e884f --- /dev/null +++ b/packages/memory-graph/src/__tests__/renderer-utils.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { lightenColor } from "../canvas/renderer" + +describe("lightenColor", () => { + test("lightens a dark hex color", () => { + // #1B1F24 lightened by 0.08 → each channel +20 (0.08*255≈20) + const result = lightenColor("#1B1F24", 0.08) + // R: 0x1B(27)+20=47=0x2f, G: 0x1F(31)+20=51=0x33, B: 0x24(36)+20=56=0x38 + expect(result).toBe("#2f3338") + }) + + test("clamps channels at 255", () => { + // #FFFFFF lightened by 0.1 → all channels clamped at 255 + const result = lightenColor("#ffffff", 0.1) + expect(result).toBe("#ffffff") + }) + + test("handles zero amount (no change)", () => { + const result = lightenColor("#1B1F24", 0) + expect(result).toBe("#1b1f24") + }) + + test("returns input unchanged for 3-digit hex", () => { + expect(lightenColor("#abc", 0.1)).toBe("#abc") + }) + + test("returns input unchanged for rgb() format", () => { + expect(lightenColor("rgb(27, 31, 36)", 0.1)).toBe("rgb(27, 31, 36)") + }) + + test("returns input unchanged for 8-digit hex with alpha", () => { + expect(lightenColor("#1B1F24FF", 0.1)).toBe("#1B1F24FF") + }) + + test("caches result for repeated calls", () => { + const first = lightenColor("#0D2034", 0.08) + const second = lightenColor("#0D2034", 0.08) + expect(first).toBe(second) + }) + + test("cache invalidates on different input", () => { + const a = lightenColor("#0D2034", 0.08) + const b = lightenColor("#1B1F24", 0.08) + expect(a).not.toBe(b) + }) + + test("cache invalidates on different amount", () => { + const a = lightenColor("#1B1F24", 0.05) + const b = lightenColor("#1B1F24", 0.1) + expect(a).not.toBe(b) + }) + + test("handles hex without # prefix", () => { + const result = lightenColor("1B1F24", 0.08) + expect(result).toBe("#2f3338") + }) +}) diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts index 15431431..6f61eb24 100644 --- a/packages/memory-graph/src/canvas/renderer.ts +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -17,6 +17,9 @@ export interface RenderState { // Module-level reusable batch map – cleared each frame instead of reallocating const edgeBatches = new Map() +// Cache for lightenColor results to avoid per-frame hex parsing +let _lightenCache: { input: string; amount: number; result: string } | null = null + export function renderFrame( ctx: CanvasRenderingContext2D, nodes: GraphNode[], @@ -247,6 +250,23 @@ function drawEdges( if (!first) continue const isDimmed = key.endsWith("|d") + // Draw glow pass behind version edges for visual emphasis + // Version edges share a distinct style key, so a batch is all-version or none + const isVersionBatch = first.isVersion + if (isVersionBatch && !isDimmed) { + ctx.save() + ctx.globalAlpha = 0.3 + ctx.strokeStyle = first.style.color + ctx.lineWidth = first.style.width + 4 + ctx.beginPath() + for (const e of batch) { + ctx.moveTo(e.startX, e.startY) + ctx.lineTo(e.endX, e.endY) + } + ctx.stroke() + ctx.restore() + } + ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1 ctx.strokeStyle = first.style.color ctx.lineWidth = first.style.width @@ -258,10 +278,9 @@ function drawEdges( } ctx.stroke() - const versionEdges = batch.filter((e) => e.isVersion) - if (versionEdges.length > 0) { + if (isVersionBatch) { ctx.fillStyle = first.style.color - for (const e of versionEdges) { + for (const e of batch) { drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize) } } @@ -377,8 +396,16 @@ function drawNodes( ) } - if (isSelected || isHighlighted) { - drawGlow(ctx, screen.x, screen.y, screenSize, node.type, colors) + if (isSelected || isHighlighted || isHovered) { + drawGlow( + ctx, + screen.x, + screen.y, + screenSize, + node.type, + colors, + isHovered && !isSelected, + ) } } @@ -437,7 +464,7 @@ function drawNodes( // Draw dimmed (superseded) memory dots at reduced opacity if (dimmedDots.length > 0) { - ctx.globalAlpha = dimAlpha * 0.35 + ctx.globalAlpha = dimAlpha * 0.3 ctx.fillStyle = colors.memFill ctx.beginPath() for (const d of dimmedDots) { @@ -485,18 +512,32 @@ function drawDocumentNode( const half = size * 0.5 const cornerR = 8 * (size / 50) - ctx.fillStyle = colors.docFill + // Drop shadow for selected/hovered nodes + if (isSelected || isHovered) { + ctx.save() + ctx.shadowColor = colors.accent + ctx.shadowBlur = isSelected ? 16 : 10 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + // Subtle gradient fill for document nodes + const grad = ctx.createLinearGradient(sx - half, sy - half, sx + half, sy + half) + grad.addColorStop(0, colors.docFill) + grad.addColorStop(1, lightenColor(colors.docFill, 0.08)) + ctx.fillStyle = grad + ctx.strokeStyle = - isSelected || isHighlighted - ? colors.accent - : isHovered - ? colors.accent - : colors.docStroke - ctx.lineWidth = isSelected || isHighlighted ? 2 : 1 + isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke + ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1 roundRect(ctx, sx - half, sy - half, size, size, cornerR) ctx.fill() ctx.stroke() + if (isSelected || isHovered) { + ctx.restore() + } + const innerSize = size * 0.72 const innerHalf = innerSize * 0.5 const innerR = 6 * (size / 50) @@ -523,12 +564,13 @@ function drawMemoryNode( ): void { const memData = node.data as MemoryNodeData const isSuperseded = memData.isLatest === false + const isForgotten = memData.isForgotten const radius = size * 0.5 - // Dim superseded (non-latest) memory nodes + // Dim superseded (non-latest) memory nodes with strikethrough effect if (isSuperseded && !isSelected && !isHovered) { const prevAlpha = ctx.globalAlpha - ctx.globalAlpha = prevAlpha * 0.35 + ctx.globalAlpha = prevAlpha * 0.3 ctx.fillStyle = colors.memFill drawHexagon(ctx, sx, sy, radius) ctx.fill() @@ -537,18 +579,59 @@ function drawMemoryNode( ctx.setLineDash([3, 3]) ctx.stroke() ctx.setLineDash([]) + + // Draw diagonal strikethrough for superseded nodes (visual clarity) + const strikeR = radius * 0.55 + ctx.beginPath() + ctx.moveTo(sx - strikeR, sy - strikeR) + ctx.lineTo(sx + strikeR, sy + strikeR) + ctx.strokeStyle = colors.textMuted + ctx.lineWidth = 1.5 + ctx.stroke() + ctx.globalAlpha = prevAlpha return } + // Drop shadow for selected/hovered memory nodes + if (isSelected || isHovered) { + ctx.save() + const shadowColor = isSelected ? colors.accent : colors.glowColor + ctx.shadowColor = shadowColor + ctx.shadowBlur = isSelected ? 18 : 12 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill drawHexagon(ctx, sx, sy, radius) ctx.fill() const borderColor = node.borderColor || colors.memStrokeDefault ctx.strokeStyle = isSelected ? colors.accent : borderColor - ctx.lineWidth = isHovered ? 2 : 1.5 + ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5 ctx.stroke() + + if (isSelected || isHovered) { + ctx.restore() + } + + // Draw X icon for forgotten nodes + if (isForgotten && size > 14) { + const iconR = radius * 0.3 + ctx.save() + ctx.strokeStyle = colors.memBorderForgotten + ctx.lineWidth = Math.max(1.5, size / 20) + ctx.lineCap = "round" + ctx.globalAlpha = 0.9 + ctx.beginPath() + ctx.moveTo(sx - iconR, sy - iconR) + ctx.lineTo(sx + iconR, sy + iconR) + ctx.moveTo(sx + iconR, sy - iconR) + ctx.lineTo(sx - iconR, sy + iconR) + ctx.stroke() + ctx.restore() + } } function drawGlow( @@ -558,19 +641,22 @@ function drawGlow( size: number, nodeType: "document" | "memory", colors: GraphThemeColors, + isHoverOnly = false, ): void { ctx.strokeStyle = colors.glowColor - ctx.lineWidth = 2 - ctx.setLineDash([3, 3]) - ctx.globalAlpha = 0.8 + ctx.lineWidth = isHoverOnly ? 1.5 : 2 + ctx.setLineDash(isHoverOnly ? [4, 4] : [3, 3]) + ctx.globalAlpha = isHoverOnly ? 0.5 : 0.8 + + const scale = isHoverOnly ? 1.1 : 1.15 if (nodeType === "document") { - const glowSize = size * 1.15 + const glowSize = size * scale const half = glowSize * 0.5 const r = 8 * (glowSize / 50) roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r) } else { - drawHexagon(ctx, sx, sy, size * 0.5 * 1.15) + drawHexagon(ctx, sx, sy, size * 0.5 * scale) } ctx.stroke() @@ -767,3 +853,19 @@ function drawDocOutline( ctx.lineTo(x + lw / 2, y + sp) ctx.stroke() } + +/** Lighten a 6-digit hex color by a fraction (0-1). Cached to avoid per-frame parsing. */ +export function lightenColor(hex: string, amount: number): string { + if (_lightenCache && _lightenCache.input === hex && _lightenCache.amount === amount) { + return _lightenCache.result + } + const h = hex.replace("#", "") + // Only handle standard 6-digit hex; return input unchanged for other formats + if (h.length !== 6) return hex + const r = Math.min(255, Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount)) + const g = Math.min(255, Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount)) + const b = Math.min(255, Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount)) + const result = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}` + _lightenCache = { input: hex, amount, result } + return result +}