Skip to content

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter - #530

Draft
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal
Draft

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter#530
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 3D graph mode grows from bare spheres into the approved Focused Minimal design (spec: docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md)
  • matte spheres + always-visible SpriteText labels (roots bold), unexplored tier washed toward warm gray
  • hover-focus: 1-hop neighborhood lit, rest dimmed; sage halo on hovered + tutor-highlighted nodes
  • ⌖ recenter (zoomToFit) sharing the 2D control's title/testid (auto-hidden on tutor rail)
  • new dep three-spritetext, client-chunk only; 3D stays opt-in behind the existing toggle
  • camera auto-fit on engine settle (bbox-stabilization poll + warmupTicks under test/reduced-motion) — 3 review-gated fix rounds, root-caused in a live browser

Test plan

  • graph3dHelpers unit tests + rewritten KnowledgeGraph3D component tests
  • full frontend suite, lint, next build
  • flock'd E2E cycle: Chapter 1 journeys + oracles green
  • manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached)
  • fresh 3D screenshots reviewed on /tree + dashboard (visual-pass artifacts in .superpowers/sdd/, gitignored)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a focused visual treatment for the opt-in 3D knowledge graph, including matte nodes, halos, and always-visible labels.
    • Hovering over a node now highlights its immediate connections while dimming unrelated nodes and links.
    • Added a “Reset view” control to recenter and fit the graph.
    • Improved automatic camera fitting when graph data changes, with reduced-motion support.
  • Tests

    • Expanded coverage for rendering, highlighting, camera fitting, accessibility behavior, and visual styling.

AndresL230and others added 9 commits August 5, 2026 16:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght halo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… run warmup ticks under test/reduced-motion
Round 2 on d2e59a9's camera-framing fix, which was unit-correct but had no
runtime effect. Confirmed root cause via node_modules/3d-force-graph source
plus live frame-by-frame logging against a running dev server:
1. onEngineStop fires synchronously *before* tickFrame's "update node
positions" step writes that tick's x/y/z onto each node's Three.js
object (both run inside the same synchronous layoutTick() call, stop
callback first) — so fitting immediately (or even one rAF later) measures
a stale/near-empty bbox and ends up far too close once the real,
spread-out layout lands moments later. Fix: poll on requestAnimationFrame
until two consecutive getGraphBbox() reads agree (frame-capped safety
net) before calling zoomToFit.
2. Under cooldownTicks={0} (test/reduced-motion mode), warmupTicks was left
at its default of 0 too, so the 3D force simulation never actually ran —
nodes stayed at d3-force-3d's raw pre-simulation positions, an
overlapping cluster. KnowledgeGraph2D already solves this the same way
for its own reduced-motion path (`sim.alpha(1).tick(200).alpha(0).stop()`);
mirrored it here via `warmupTicks={reducedMotion || IS_TEST_MODE ? 200 : 0}`.
Verified visually against a dev server (:3010) proxying the held-up E2E
stack's backend: before, /tree in 3D was a tiny illegible clump; after, four
well-separated, readable node clusters filling most of the canvas, matching
the 2D layout's structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cel rAF on unmount
Round 3 re-review of the round-2 camera-framing fix (mechanism, determinism,
and tests independently verified) flagged one new Important issue and a
hygiene nit introduced by the async polling window itself:
1. The bbox-stabilization poll's closure never re-checked that it still
belonged to the current dataset. If nodes/edges changed while an old
poll was mid-flight (up to MAX_FRAMES/~1s wide), the stale closure kept
polling the live instance and could fire zoomToFit(400, 60) after —
clobbering — the fresh poll's correct fit for the new dataset. Fixed
with an epoch guard: pollEpochRef increments alongside didFitRef.current
= false in the [nodes, edges] reset effect; handleEngineStop captures
the epoch at poll start, and every scheduled frame callback bails
immediately if pollEpochRef.current no longer matches.
2. Hygiene: no cancelAnimationFrame cleanup tied the poll to component
lifetime, so an unmount mid-poll could leave up to ~60 dangling no-op
rAF callbacks. Now tracks the current rAF id in pollRafIdRef and cancels
it in an unmount-only effect.
TDD: wrote the new test first against the pre-fix code (stashed the
implementation), confirmed it failed exactly as described (stale poll
fires zoomToFit(400, 60) for the old dataset), then restored the
implementation and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99e248db-5120-4f64-8d7e-40dfec5218fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The opt-in 3D knowledge graph now uses custom spheres, halos, and labels. Shared helpers provide sizing, colors, themes, and adjacency. Hover interactions focus one-hop neighborhoods, links dim accordingly, and a reset control refits the camera.

Changes

Focused Minimal 3D graph

Layer / File(s)Summary
Styling helpers and rendering contract
docs/superpowers/..., frontend/src/components/graph/graph3dHelpers.ts, frontend/src/components/graph/graph3dHelpers.test.ts
Adds pure helpers for themes, colors, adjacency, node sizing, and label specifications with deterministic tests.
Custom node visuals
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/package.json, frontend/eslint-suppressions.json, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Renders matte spheres, focus halos, and SpriteText labels through custom Three.js objects and validates their properties.
Hover focus and link styling
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Tracks hovered nodes, dims non-neighbors and links, preserves persistent highlight halos, and supports string or resolved link endpoints.
Camera fitting and reset control
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx, frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
Adds ref-forwarded zoomToFit, stable-bounds auto-fit polling, stale-poll cancellation, warmup ticks, and the graph-zoom-reset button.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Pointer
participant KnowledgeGraph3D
participant VisualRegistry
participant ForceGraph3D
Pointer->>KnowledgeGraph3D: Hover a graph node
KnowledgeGraph3D->>VisualRegistry: Update node, label, and halo opacity
KnowledgeGraph3D->>ForceGraph3D: Recompute link color and width
ForceGraph3D-->>KnowledgeGraph3D: Render focused neighborhood
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 62.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main 3D graph changes: labels, hover focus, and recentering.
Description check✅ PassedThe description clearly explains the upgrade and includes comprehensive changes and testing details, despite using different section headings than the template.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3d-graph-focused-minimal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging987bfd7Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:09 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
frontend/src/components/graph/KnowledgeGraph3D.test.tsx (2)

553-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the frame-cap test deterministic and pin the cap value.

Line 557 uses Math.random() to produce a bbox that never repeats. The sibling test at Line 588 uses a monotonic counter for the same purpose, which is deterministic.

The test also asserts only that zoomToFit fired once. It does not assert how many frames elapsed. If MAX_FRAMES regressed from 60 to 2, this test would still pass. Assert the read count to pin the cap.

🧪 Proposed deterministic frame-cap test
- getGraphBboxMock.mockImplementation(() => ({ x: [0, Math.random()], y: [0, 0], z: [0, 0] }));+ let capCounter = 0;+ getGraphBboxMock.mockImplementation(() => ({ x: [0, ++capCounter], y: [0, 0], z: [0, 0] }));
 expect(zoomToFitSpy).toHaveBeenCalledTimes(1);
expect(zoomToFitSpy).toHaveBeenCalledWith(400, 60);
+ // Pins MAX_FRAMES: the poll reads the bbox once per frame up to the cap.+ expect(getGraphBboxMock).toHaveBeenCalledTimes(60);
expect(raf.queue.length).toBe(0); // no frame scheduled after the cap-triggered fit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 553 -
573, Make the frame-cap test deterministic by replacing Math.random() in
getGraphBboxMock with a monotonic counter so each bounding-box read differs
predictably. Track the number of bbox reads and assert it equals the configured
MAX_FRAMES value (60), while preserving the existing single fit and empty RAF
queue assertions.

171-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the unmount cancellation path.

captureAnimationFrames already spies on cancelAnimationFrame at Line 178, and the spy is exposed only through restore(). No test asserts that the unmount cleanup effect in KnowledgeGraph3D.tsx (Lines 202-206) cancels the pending frame. The PR lists that cleanup as a deliverable, so a regression that drops the effect would pass the current suite.

Return the cancelAnimationFrame spy from the helper, then assert it in a new test.

🧪 Proposed helper change and test
 const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
return {
queue,
+ caf,
it("cancels the in-flight bbox poll on unmount",()=>{getGraphBboxMock.mockReturnValue({x: [0,1],y: [0,1],z: [0,1]});constraf=captureAnimationFrames();const{ unmount }=render(<KnowledgeGraph3Dnodes={[makeNode()]}edges={[]}/>);act(()=>(lastProps!.onEngineStopas()=>void)());expect(raf.queue.length).toBe(1);unmount();expect(raf.caf).toHaveBeenCalledTimes(1);raf.restore();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 171 -
193, Update captureAnimationFrames to expose the cancelAnimationFrame spy, then
add a KnowledgeGraph3D unmount test that starts the bbox polling frame through
onEngineStop, unmounts the component, and asserts the cancellation spy was
called. Restore the animation-frame spies after the assertion.
frontend/src/components/graph/KnowledgeGraph3D.tsx (1)

303-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the link colors from the resolved theme instead of hardcoded RGB literals.

Lines 306, 311, and 312 hardcode rgba(138, 131, 114, ...) and rgba(138, 154, 91, ...). 138, 154, 91 is #8a9a5b, which is FALLBACK_THEME.accent. Nodes and halos use resolveGraphTheme(), which reads --accent and --ink-200 from CSS. If a deployment or an alternate color scheme overrides those variables, node colors follow the theme and link colors do not.

Convert theme.accent and theme.dim to RGB components once, then build the rgba strings from them.

🎨 Proposed theme-derived link colors
+ // Link colors follow the same resolved theme as nodes and halos.+ const linkRgb = React.useMemo(() => {+ const rgb = (hex: string) => {+ const c = new THREE.Color(hex);+ return `${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}`;+ };+ return { lit: rgb(theme.accent), dim: rgb(theme.dim) };+ }, [theme]);+
const linkColor = React.useCallback(
(l: object) => {
const link = l as FG3DLink;
- if (!hoverId) return `rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;+ if (!hoverId) return `rgba(${linkRgb.dim}, ${BASE_LINK_ALPHA})`;
const lit = linkEndId(link.source) === hoverId || linkEndId(link.target) === hoverId;
- // Lit links take the sage accent (rgb of `#8a9a5b`); dimmed links- // fade to near-invisible warm gray.+ // Lit links take the resolved accent; dimmed links fade to the+ // resolved dim token.
return lit
- ? `rgba(138, 154, 91, ${LIT_LINK_ALPHA})`- : `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;+ ? `rgba(${linkRgb.lit}, ${LIT_LINK_ALPHA})`+ : `rgba(${linkRgb.dim}, ${DIM_LINK_ALPHA})`;
},
- [hoverId],+ [hoverId, linkRgb],
);

If you accept this, update the literal expectations in frontend/src/components/graph/KnowledgeGraph3D.test.tsx at Lines 471, 478, 479, and 481-483 to build the same strings from FALLBACK_THEME.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 303 - 315,
Update the linkColor callback to use the resolved graph theme’s accent and dim
colors instead of hardcoded RGB values, converting theme.accent and theme.dim to
RGB components once and using them for the rgba strings. Preserve the existing
hover, lit, and dim alpha behavior, and update the corresponding
KnowledgeGraph3D.test.tsx expectations to construct the same values from
FALLBACK_THEME.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md`:
- Around line 937-947: Update the shell command around the E2E stack startup to
register an EXIT trap that runs make e2e-down before make e2e-up executes.
Preserve the existing test return-code collection and ensure teardown runs
exactly once, including when make e2e-up fails under set -e.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 243-285: Update nodeThreeObject to store the returned group
alongside sphereMat, halo, and label in visualsRef.current. Before replacing the
registry when the dataset changes and during component unmount, iterate the
existing entries and dispose both SphereGeometry instances, their materials, the
SpriteText canvas texture/material resources, and each group’s children; then
clear the registry before creating the new Map.
---
Nitpick comments:
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx`:
- Around line 553-573: Make the frame-cap test deterministic by replacing
Math.random() in getGraphBboxMock with a monotonic counter so each bounding-box
read differs predictably. Track the number of bbox reads and assert it equals
the configured MAX_FRAMES value (60), while preserving the existing single fit
and empty RAF queue assertions.
- Around line 171-193: Update captureAnimationFrames to expose the
cancelAnimationFrame spy, then add a KnowledgeGraph3D unmount test that starts
the bbox polling frame through onEngineStop, unmounts the component, and asserts
the cancellation spy was called. Restore the animation-frame spies after the
assertion.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 303-315: Update the linkColor callback to use the resolved graph
theme’s accent and dim colors instead of hardcoded RGB values, converting
theme.accent and theme.dim to RGB components once and using them for the rgba
strings. Preserve the existing hover, lit, and dim alpha behavior, and update
the corresponding KnowledgeGraph3D.test.tsx expectations to construct the same
values from FALLBACK_THEME.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d36f84-0079-4d42-a82d-ce69d01fc611

📥 Commits

Reviewing files that changed from the base of the PR and between 458ddb2 and c3ed797.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md
  • docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md
  • frontend/eslint-suppressions.json
  • frontend/package.json
  • frontend/src/components/graph/KnowledgeGraph3D.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/graph3dHelpers.test.ts
  • frontend/src/components/graph/graph3dHelpers.ts

Comment on lines +937 to +947
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Ensure teardown runs when make e2e-up fails.

set -e exits before make e2e-down if make e2e-up fails. This can leave a partially started E2E stack running. Register teardown with an EXIT trap before startup.

Proposed fix
 set -e
+ trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
- make e2e-down
exit $rc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
exit $rc
'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md` around lines
937 - 947, Update the shell command around the E2E stack startup to register an
EXIT trap that runs make e2e-down before make e2e-up executes. Preserve the
existing test return-code collection and ensure teardown runs exactly once,
including when make e2e-up fails under set -e.

Comment on lines +243 to +285
const nodeThreeObject = React.useCallback(
(raw: object) => {
const n = raw as FG3DNode;
const r = nodeRadius(n);
const color = baseNodeColor(n, theme);
const group = new THREE.Group();

const sphereMat = new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity: NODE_OPACITY,
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24), sphereMat));

// Focus halo: slightly larger translucent accent sphere, hidden
// until this node is hovered or is the persistent highlightId.
const halo = new THREE.Mesh(
new THREE.SphereGeometry(r * 1.4, 16, 16),
new THREE.MeshBasicMaterial({
color: theme.accent,
transparent: true,
opacity: 0.28,
depthWrite: false,
}),
);
halo.visible = n.id === hoverRef.current || n.id === highlightRef.current;
group.add(halo);

const spec = labelSpec(n);
const label = new SpriteText(n.name);
label.textHeight = spec.textHeight;
label.fontWeight = spec.fontWeight;
label.color = theme.ink;
label.fontFace = '"JetBrains Mono", monospace';
label.material.transparent = true;
label.position.set(0, -(r + spec.textHeight + 1.5), 0);
group.add(label);

visualsRef.current.set(n.id, { sphereMat, label, halo, baseColor: color });
return group;
},
[theme],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the repository already disposes Three.js resources anywhere, and whether SpriteText exposes a dispose path.set -euo pipefail
rg -n --type=ts --type=tsx -C3 '\.dispose\s*\(' frontend/src ||echo"no dispose() calls found in frontend/src"
fd -t d 'three-spritetext' frontend/node_modules --max-depth 3 --exec sh -c 'ls "$1"; sed -n "1,80p" "$1/dist/three-spritetext.d.ts" 2>/dev/null' _ {}

Repository: SaplingLearn/Sapling

Length of output: 340


🏁 Script executed:

#!/bin/bash0bashset -euo pipefail
printf'Tracked KnowledgeGraph files:\n'
fd -i 'KnowledgeGraph3D\.(ts|tsx)$'.||trueprintf'\nGit file list for likely front-end components:\n'
git ls-files | rg 'frontend/src/(components|.*Graph|.*three|package\.json)$|componentst'printf'\nSearch dispose across tracked source files:\n'
git ls-files | rg '\.(ts|tsx)$'| xargs -r rg -n -- '-- dispose\(' or true

Repository: SaplingLearn/Sapling

Length of output: 289


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'File line count:\n'
wc -l "$FILE"printf'\nRelevant section 160-300:\n'
sed -n '160,300p'"$FILE"| nl -ba -v160
printf'\nSearch for three-spritetext references and package manifests:\n'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo""$FILE"||true
rg -n "three-spritetext|react-three-fiber|`@react-three`" frontend -g 'package.json' -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock'||true

Repository: SaplingLearn/Sapling

Length of output: 298


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'Relevant section 160-300:\n'
sed -n '160,300p'"$FILE"| awk '{printf "%5d %s\n", NR+159, $0}'printf'\nImports and dependency references:\n'
sed -n '1,80p'"$FILE"| awk '{printf "%5d %s\n", NR, $0}'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo|useEffect|React""$FILE"||trueprintf'\nDependency files:\n'forfin package.json frontend/package.json frontend/package-lock.json frontend/pnpm-lock.yaml frontend/yarn.lock frontend/package.json;doif [ -f"$f" ];thenprintf'\n--- %s ---\n'"$f"
rg -n "three-spritetext|react-three-fiber|`@react-three`|three""$f"||truefidoneprintf'\nSearch disposed resource handling across tracked source files:\n'
git ls-files | xargs -r rg -n -- '-- dispose\s*\('| rg '\.(ts|tsx)$'||true

Repository: SaplingLearn/Sapling

Length of output: 13950


🌐 Web query:

three-spritetext v1.10.0 source canvas dispose texture code

💡 Result:

In three-spritetext v1.10.0, the process of disposing of the previous texture when updating the canvas is handled within the library's internal update logic. When the text content or styling changes, the library generates a new canvas and updates the sprite's material. The disposal logic is implemented as follows: // Inject canvas into sprite if (this.material.map) this.material.map.dispose; // gc previous texture var texture = this.material.map = new THREE.Texture(canvas); texture.minFilter = THREE.LinearFilter; texture.colorSpace = THREE.SRGBColorSpace; By calling this.material.map.dispose, the library ensures that the old texture is properly removed from GPU memory before assigning the new texture created from the updated canvas [1]. This practice is essential for preventing memory leaks when frequently updating sprite text [1][2].

Citations:


Dispose every node visual object when the dataset changes.

nodeThreeObject creates per-node Three.js resources: the returned group, two meshes with SphereGeometry, two materials, and a SpriteText with a canvas-backed Texture. Line 178 reassigns the registry to a new Map on a new dataset, so those previous objects become unreachable while their GPU buffers remain allocated.

Track the complete visual entry in visualsRef.current, including the group, then dispose the geometries, textures, and materials before replacing the registry and on component unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 243 - 285,
Update nodeThreeObject to store the returned group alongside sphereMat, halo,
and label in visualsRef.current. Before replacing the registry when the dataset
changes and during component unmount, iterate the existing entries and dispose
both SphereGeometry instances, their materials, the SpriteText canvas
texture/material resources, and each group’s children; then clear the registry
before creating the new Map.

…ion instant fit, warmupTicks pins
Three Important findings from the whole-branch final review, plus a doc
amendment, fixed in one wave:
1. Accent drift, one color one source: FALLBACK_THEME.accent was still the
retired sage (#8a9a5b) while globals.css's --accent is now the brighter
forest (#2d8f5c) — so the focus halo (theme.accent) rendered forest
while its own lit edges (hardcoded sage rgb) rendered sage. Corrected
FALLBACK_THEME.accent to #2d8f5c and added hexToRgbTriplet(), a small
pure helper next to resolveGraphTheme, so linkColor derives the lit-link
rgba from theme.accent instead of a second hardcoded copy that can
drift.
2. Reduced-motion camera animation: both zoomToFit call sites (the
engine-settle auto-fit and the manual recenter button) now gate their
duration — `reducedMotion || IS_TEST_MODE ? 0 : 400` — since these are
system-initiated camera flies and an animated 400ms fly is itself a
motion violation on the paths that already zero cooldownTicks to
eliminate motion. Padding unified to 60 across both call sites
(recenter button previously used 40).
3. warmupTicks was untested: pinned 200 in both the reduced-motion test
(KnowledgeGraph3D.test.tsx) and the test-mode determinism test
(KnowledgeGraph3D.testmode.test.tsx), and 0 in a new default-motion-path
test — plus new coverage for the reduced-motion/test-mode instant-fit
gating on the recenter button (the auto-fit path shares the identical
ternary, already exercised by the bbox-stabilization tests under
default motion).
Also amended docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md's
"Camera & motion" section to document both justified deviations (the
bbox-stabilization auto-fit poll + epoch guard, and warmupTicks=200 under
test/reduced-motion mirroring KnowledgeGraph2D's sim.tick(200) precedent),
and replaced the stale #8a9a5b halo color reference with "the app accent
token (--accent, currently #2d8f5c), resolved at mount".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — graph-3d Focused Minimal upgrade

This turns the 3D mode from bare library spheres into the Focused Minimal design: nodeThreeObject groups (matte MeshLambertMaterial sphere + hidden accent halo + always-visible three-spritetext label), a visualsRef registry so hover-focus mutates materials instead of rebuilding geometry, linkColor/linkWidth re-keyed on hover, a recenter button reusing the 2D control's title="Reset view" (which is what globals.css:609 keys on to hide it on the tutor rail), and a bbox-stabilisation rAF poll that drives a one-shot zoomToFit on the first engine settle. The pure-helper extraction into graph3dHelpers.ts is clean, the auto-fit poll's epoch guard and rAF-cancel-on-unmount are correct, and the SSR/testid/click-whitelist contracts are all preserved. Two things I checked and want to save you the time on: the library does own GPU disposal (react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate recursively disposes each group's geometries, materials and the sprite's material.map), and zoomToFit is null-safe on an empty graph — so CodeRabbit's disposal comment is not a real leak. What does need fixing is the hover path's interaction with three-forcegraph's prop-change semantics, plus the new lint suppressions and the merge state.

Findings

P1

[P1] Every hover transition destroys and rebuilds every link meshfrontend/src/components/graph/KnowledgeGraph3D.tsx:326-335

constlinkWidth=React.useCallback((l: object)=>{constlink=lasFG3DLink;constbase=0.4+(link.strength||0)*0.6;if(!hoverId)returnbase;constlit=linkEndId(link.source)===hoverId||linkEndId(link.target)===hoverId;returnlit ? base+0.6 : base;},[hoverId],);

hoverId is React state, so each hover enter/leave re-renders and hands react-force-graph-3d a new linkWidth function identity. react-kapsule pushes any identity-changed prop straight through (react-kapsule.mjs:30-33; linkWidth is a plain prop per react-force-graph-3d.mjs:126-129), and three-forcegraph treats linkWidth as an object-invalidating prop: three-forcegraph.mjs:1199-1201if (state._flushObjects || hasAnyPropChanged(['linkThreeObject','linkThreeObjectExtend','linkWidth'])) state.linkDataMapper.clear();. clear() is digest([]), which scene.remove()s and _deallocate()s (geometry.dispose + material.dispose) every link object and then recreates all of them. These aren't cheap lines either — useCylinder = !!widthAccessor(link) (:1220) is always true here, so each link is a CylinderGeometry mesh. On a few-hundred-edge graph that's a full teardown/rebuild of the link layer on every mouse enter and every mouse leave. This is exactly what docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md:138-142 promised not to do: "Hover mechanics — no per-hover geometry rebuilds … (links are cheap line materials)." Direction: give linkWidth a stable identity that reads hoverRef.current; linkColor's own change already triggers the digest, and the link onUpdateObj re-reads widthAccessor(link) each digest, so widths still update — just without the clear(). (Nothing visually breaks — three-forcegraph.mjs:1483 sets engineRunning = true at the end of every update(), so the next layoutTick repositions the new meshes — but it does mean onEngineStop also re-fires on every hover; the didFitRef guard absorbs that correctly.)

[P1] Two new react-hooks/refs suppressions added for new production codefrontend/eslint-suppressions.json:99-103

"src/components/graph/KnowledgeGraph3D.tsx": {
"react-hooks/refs": {
"count": 2
}
},

The Canopy Engineering Style Guide §7 states the ratchet explicitly — "don't fix the whole backlog to land a change, but never add to it" — and this PR's own plan repeats it at docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md:20: "eslint suppressions are a ratcheted baseline — new code must be clean." The two suppressed violations are real render-phase ref mutations: KnowledgeGraph3D.tsx:173highlightRef.current = highlightId; in the render body, and :179visualsRef.current = new Map(); inside the graphDatauseMemo. The useMemo one is the one with teeth — under React 19 concurrent rendering a render that starts and is then discarded still clears the registry, leaving the committed tree with an empty visualsRef and hover-focus a silent no-op. (The test-file suppressions — react-hooks/globals, react/display-name — are less load-bearing but also new.)

[P1] Branch is CONFLICTING with main, and main landed the #538 WebGL2 gate on the same three files

gh pr view 530 --json mergeable returns "mergeable":"CONFLICTING","mergeStateStatus":"DIRTY". Since the merge-base (ec34bf17), main has changed KnowledgeGraph.tsx (+201: WebGL2 capability probe, ErrorBoundary, graph-crash-fallback), added this header to KnowledgeGraph3D.tsx"#538: NEVER mount this component outside the KnowledgeGraph wrapper — three r163+ throws from the WebGLRenderer constructor when WebGL2 is unavailable" — and replaced the local next/dynamic mock in bothKnowledgeGraph3D.test.tsx and KnowledgeGraph3D.testmode.test.tsx with the shared @/test-utils/mockNextDynamic helper. This PR rewrites all three of those files from the pre-#538 base and still carries the old inline mock (KnowledgeGraph3D.test.tsx:98-114, KnowledgeGraph3D.testmode.test.tsx:36-52), so the conflict resolution has to re-apply main's work by hand rather than take either side wholesale. Worth rebasing before the draft flips to ready — main's shared helper does handle this PR's loader (which resolves to a bare function component, not a module) via typeof mod === "function" ? mod : (mod.default ?? null).

P2

[P2] nodeThreeObject doesn't apply the active focus state to the nodes it (re)buildsfrontend/src/components/graph/KnowledgeGraph3D.tsx:251-286

constsphereMat=newTHREE.MeshLambertMaterial({
color,transparent: true,opacity: NODE_OPACITY,});

The halo correctly reads the live refs (halo.visible = n.id === hoverRef.current || n.id === highlightRef.current), but the sphere is always built at full baseColor/NODE_OPACITY and the label at full opacity. Any nodes/edges identity change re-runs the graphData memo (clearing visualsRef at :179) and makes three-forcegraph rebuild every node object — node objects are keyed by identity and the memo mints fresh {...n} clones. If the pointer is still over a node at that moment (a tutor graph_update refreshing the Learn rail graph, a filter change on /tree), the hovered node keeps its halo but nothing dims: the focus effect renders half-applied until the pointer moves. The [applyFocus, highlightId] re-assert effect can't cover it because it runs at commit, before the library rebuilds. The component test doesn't catch this because it calls nodeThreeObject manually before driving onNodeHover (KnowledgeGraph3D.test.tsx:451-456), which is the opposite order.

[P2] A halo geometry + material is allocated for every node although at most two are ever visiblefrontend/src/components/graph/KnowledgeGraph3D.tsx:256-270

group.add(newTHREE.Mesh(newTHREE.SphereGeometry(r,24,24),sphereMat));// Focus halo: slightly larger translucent accent sphere, hidden// until this node is hovered or is the persistent highlightId.consthalo=newTHREE.Mesh(newTHREE.SphereGeometry(r*1.4,16,16),newTHREE.MeshBasicMaterial({

halo.visible is only ever true for the hovered node and highlightId, so an N-node graph carries N−2 unused SphereGeometry + MeshBasicMaterial pairs for the lifetime of the dataset, re-allocated in full on every refresh. A single shared unit-sphere geometry scaled per node, or lazy creation on first focus, avoids it. Separately, the main sphere went from the library's nodeResolution={16} (pre-patch) to SphereGeometry(r, 24, 24) — 2.25× the triangles per node — which the spec doesn't ask for.

[P2] Each label is rasterised five times at constructionfrontend/src/components/graph/KnowledgeGraph3D.tsx:273-279

constlabel=newSpriteText(n.name);label.textHeight=spec.textHeight;label.fontWeight=spec.fontWeight;label.color=theme.ink;label.fontFace='"JetBrains Mono", monospace';

Every three-spritetext setter re-runs _genCanvas() (measure text → resize canvas → repaint → new CanvasTexture). That's the constructor plus four setters = five full rasterisations and five texture allocations per node, paid again for the whole graph on every dataset refresh. SpriteText's constructor takes (text, textHeight, color), which folds three of the five. Related sizing note: _fontSize is fixed at 90 regardless of textHeight, so each label canvas is roughly 600×100 RGBA — at the spec's stated "few hundred nodes" ceiling that is tens of MB of texture memory. (No leak — _genCanvas disposes the previous map — just wasted work and VRAM.)

P3

[P3] The base and dim link colors are still hardcoded hex while the lit one derives from the themefrontend/src/components/graph/KnowledgeGraph3D.tsx:315,321

if(!hoverId)return`rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;
...
: `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;

138, 131, 114 is #8a8372 = --ink-400 (globals.css:47) — an existing token, not an unnamed value. graph3dHelpers.ts:33-36 says FALLBACK_THEME is "the ONE place that hex is allowed to be hardcoded", and the c65c075 pass fixed only the lit half. Adding a fourth GraphTheme field resolved from --ink-400 would close the loop and match the Canopy design-token rule ("Use the tokens; don't hardcode values"). Low stakes — the spec does sanction "today's rgba(138,131,114,…) family" — but the file's own comment now contradicts itself.

Test-plan checklist — per-item verdict

  1. graph3dHelpers unit tests + rewritten component tests — supported. graph3dHelpers.test.ts covers buildAdjacency, nodeVal/nodeRadius, mixHex, baseNodeColor, labelSpec, resolveGraphTheme; the component test covers node composition, hover dim/restore, halo persistence, linkColor, recenter (both durations), the bbox poll, the frame cap and the epoch guard. Two gaps: the newly exported hexToRgbTriplet has no unit test (only indirect coverage via the component test), and nothing asserts the unmount cancelAnimationFrame even though captureAnimationFrames already spies on it.
  2. full frontend suite, lint, next build — supported by CI (Frontend (lint + tsc + vitest) pass, Workers Builds: frontend-staging pass), with the caveat that lint only passes because of the new suppressions in the P1 finding above.
  3. flock'd E2E cycle: Chapter 1 journeys + oracles green — not verifiable from the PR; there is no E2E job among the checks on feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter #530. Taking it on trust.
  4. manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached) — the code claims hold up (the title="Reset view"globals.css:609 rail-hiding seam is real), but no screenshots are attached to the PR body or to any comment, so "attached" doesn't match the PR as it stands.
  5. fresh 3D screenshots reviewed on /tree + dashboard — artifacts are gitignored, so unverifiable from here. Note that the P1 hover finding wouldn't have shown up as a visual defect (it's allocation churn, not a broken frame), so this doesn't invalidate the pass.

What's good

  • The auto-fit story is genuinely well-engineered: the bbox-stabilisation poll, the MAX_FRAMES safety net, the pollEpochRef invalidation on mid-poll dataset change and the rAF cancel on unmount are all correct, and each one is pinned by a test that would fail if it regressed.
  • Reduced-motion is handled properly rather than performatively — zeroing the zoomToFit duration on both call sites because a system-initiated camera fly is itself motion, and the warmupTicks={200} reasoning mirrors KnowledgeGraph2D's sim.tick(200) precedent instead of inventing a new one.
  • graph3dHelpers.ts is the right shape: pure, three.js-free, unit-testable, and the "always return #rrggbb" color contract is documented where it will actually be read.
  • Every preserved contract really is preserved — sr-only list, all three testids, the onNodeClick id whitelist, and the dynamic(ssr:false) boundary all still hold, and the click-whitelist test now enumerates the full set of library-injected fields.

Verdict: solid design work with one real hover-path performance bug (P1), a ratchet violation (P1) and a rebase (P1) to clear before this leaves draft.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…nder-phase ref writes
Review round on #530.
P1 — linkWidth was a useCallback keyed on the hoverId state, so every
pointer enter AND leave handed react-force-graph-3d a new function
identity. three-forcegraph treats linkWidth as object-invalidating for
links (linkDataMapper.clear() -> digest([]) -> scene.remove() +
_deallocate() of every link mesh, all CylinderGeometry here since the
widths are never zero), so a hover was two full teardowns/rebuilds of
the whole link layer — the exact opposite of the spec's "no per-hover
geometry rebuilds". linkWidth now reads hoverRef with an empty dep
array; widths still follow the hover because linkColor's own identity
change triggers the digest and its onUpdateObj re-reads the width
accessor per link.
P1 — both react-hooks/refs violations were real render-phase ref
mutations. highlightId is now mirrored in an effect (the useRef
initializer covers first mount; kapsule's digest is debounce(fn, 1), so
the library cannot call nodeThreeObject before the commit's effects
have run). The visuals registry is no longer a ref cleared inside a
useMemo — a discarded concurrent render would still have cleared it and
left the committed tree with an empty registry and hover focus a silent
no-op. One memo now owns everything scoped to a dataset: graphData, the
registry, and the shared halo geometry/material.
P2 — nodeThreeObject applies the live focus state to nodes it builds,
so a dataset refresh under an active hover (tutor graph_update, /tree
filter) no longer renders half-focused until the pointer moves. Focus
styling has a single writer (applyVisualState) shared with the focus
pass.
P2 — one unit-sphere halo geometry + material per dataset, scaled per
node, instead of N pairs of which at most two are ever visible; main
sphere back to 16 segments (the library's nodeResolution default) from
24.
P2 — SpriteText labels use the (text, textHeight, color) constructor:
three of the five canvas rasterisations per label were setter-driven.
P3 — the resting and dimmed link rgb are derived from a new
GraphTheme.link resolved from --ink-400 instead of two hand-copied
rgba(138, 131, 114, …) literals, which contradicted FALLBACK_THEME's
"the ONE place that hex is allowed to be hardcoded".
Also re-applies #538's header warning about never mounting this
component outside the KnowledgeGraph wrapper.
…an gaps
Ports #538's work forward by hand so the eventual merge with main is a
no-op on these files: both 3D graph test files now use the shared
@/test-utils/mockNextDynamic helper (copied verbatim from main) instead
of a local inline dynamic mock. The helper renders the resolved
component with createElement rather than calling it as a function, so
the stub's hooks land in their own fiber, and it accepts this
component's loader, which resolves to a bare function component.
Removes every eslint-suppressions.json entry this branch had added, and
the pre-existing @typescript-eslint/no-explicit-any entry too, by
fixing the causes rather than baselining them:
- the react-force-graph-3d stub is a NAMED function inside forwardRef
(react/display-name) that records props in an effect instead of the
render body (react-hooks/globals — Testing Library flushes passive
effects before render/rerender/act return, so tests still read the
props synchronously);
- the capture is a spelled-out CapturedProps type, not
Record<string, any>.
Net effect vs the merge base: one entry removed, none added.
Test-plan gaps:
- the frame-cap test drove the bbox with Math.random() and asserted
only that zoomToFit fired once, so a MAX_FRAMES regression from 60 to
2 would have passed. Monotonic counter now, and the bbox read count is
pinned to the cap.
- captureAnimationFrames already spied on cancelAnimationFrame but
nothing asserted the unmount cleanup. The spy and the frame ids are
exposed and a test covers it.
- new test that linkWidth's identity is stable across a hover while
linkColor's is not, and that the same accessor reports the widened
value.
- new test that nodeThreeObject applies focus to nodes built AFTER
onNodeHover — the real order; the existing tests build first.
- direct unit tests for the newly exported hexToRgbTriplet.
Under `set -e` a failing `make e2e-up` exited the flock'd shell before
the trailing `make e2e-down` could run, leaving a half-started stack
holding the ports. Register `trap "make e2e-down || true" EXIT` first
and drop the explicit teardown.
…e graph
The only path that exercises the new highlightRef mirror effect is a
highlightId change while mounted — first mount is covered by the useRef
initializer, so mutation-testing showed the effect could be deleted
outright with all tests still green. This also pins the effect ORDER:
the mirror is declared before the re-assert effect, and moving it after
makes the re-assert read the stale highlight (verified: all three
mutations now fail).
Byte-identical to main in that region, so the shared-helper adoption
merges cleanly instead of conflicting on comment wording. The 'why an
await import inside a hoisted vi.mock factory' rationale already lives
in the helper's own docblock.
vi.mock calls are hoisted, so the order is behaviour-neutral — but this
block sat inside the exact hunk #538 rewrote on main (the next/dynamic
mock), which made an otherwise clean merge conflict on a pure addition.
Moved into untouched context above.
The plan's pre-review code sketches prescribe the exact five shapes the
review round removed (hover-keyed linkWidth, a visualsRef cleared inside
a useMemo, focus-blind nodeThreeObject, per-node halo pairs, hardcoded
link rgb). Left unmarked, the doc reads as authoritative and invites a
future reader to 'restore' them.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Every hover destroyed and rebuilt every link mesh.linkWidth was keyed on hoverId state, so each enter/leave handed the library a new function identity, and three-forcegraph treats linkWidth as object-invalidating → linkDataMapper.clear() → full teardown/rebuild of every CylinderGeometry link. That is exactly what the spec promised not to do. linkWidth now has a stable identity reading hoverRef.current; widths still track hover because linkColor already triggers the digest and onUpdateObj re-reads the accessor. Mutation-verified: restoring [hoverId] fails exactly one test.
  • Two new react-hooks/refs suppressions were added for new code, against the ratchet rule this PR's own plan restates. Both fixed at the source — the render-body ref write moved into an effect, and visualsRef replaced by a dataset-scoped epoch object so a discarded concurrent render cannot clobber the committed registry. The test-file suppressions were fixed properly too, not baselined. Suppressions went 29 files/193 → 28 files/187, deletion-only.
  • The branch was CONFLICTING with main (Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 landed the WebGL2 gate on the same three files). Ported forward by hand — no rebase: adopted @/test-utils/mockNextDynamic byte-identical to main, re-applied the Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 header, and shrank two more noise conflicts. git merge-tree --write-tree HEAD main now reports 0 conflicts in all three source files; the only residual is the generated eslint-suppressions.json baseline.

Minor / nits

nodeThreeObject applies the live focus state to nodes it rebuilds, so a mid-hover dataset refresh no longer renders focus half-applied · shared halo geometry instead of one per node, sphere segments back to 16 · SpriteText built via its constructor args, cutting five rasterisations per label · base/dim link colours derived from the theme rather than a hardcoded --ink-400 · e2e teardown trap in the plan doc · deterministic frame-cap test that pins MAX_FRAMES · unmount cancelAnimationFrame test · hexToRgbTriplet unit test.

Not done — CodeRabbit's disposal finding is not valid

react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate, which already disposes each group's geometries, materials and the sprite's material.map. Adding manual disposal would be redundant.

Verificationtsc clean · eslint 0 errors · 604 frontend tests pass · merge conflicts vs main: 3 source files → 0

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

…-minimal
# Conflicts:
#	frontend/eslint-suppressions.json
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter by AndresL230 · Pull Request #530 · SaplingLearn/Sapling · GitHub
Skip to content

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter - #530

Draft
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal
Draft

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter#530
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 3D graph mode grows from bare spheres into the approved Focused Minimal design (spec: docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md)
  • matte spheres + always-visible SpriteText labels (roots bold), unexplored tier washed toward warm gray
  • hover-focus: 1-hop neighborhood lit, rest dimmed; sage halo on hovered + tutor-highlighted nodes
  • ⌖ recenter (zoomToFit) sharing the 2D control's title/testid (auto-hidden on tutor rail)
  • new dep three-spritetext, client-chunk only; 3D stays opt-in behind the existing toggle
  • camera auto-fit on engine settle (bbox-stabilization poll + warmupTicks under test/reduced-motion) — 3 review-gated fix rounds, root-caused in a live browser

Test plan

  • graph3dHelpers unit tests + rewritten KnowledgeGraph3D component tests
  • full frontend suite, lint, next build
  • flock'd E2E cycle: Chapter 1 journeys + oracles green
  • manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached)
  • fresh 3D screenshots reviewed on /tree + dashboard (visual-pass artifacts in .superpowers/sdd/, gitignored)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a focused visual treatment for the opt-in 3D knowledge graph, including matte nodes, halos, and always-visible labels.
    • Hovering over a node now highlights its immediate connections while dimming unrelated nodes and links.
    • Added a “Reset view” control to recenter and fit the graph.
    • Improved automatic camera fitting when graph data changes, with reduced-motion support.
  • Tests

    • Expanded coverage for rendering, highlighting, camera fitting, accessibility behavior, and visual styling.

AndresL230and others added 9 commits August 5, 2026 16:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght halo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… run warmup ticks under test/reduced-motion
Round 2 on d2e59a9's camera-framing fix, which was unit-correct but had no
runtime effect. Confirmed root cause via node_modules/3d-force-graph source
plus live frame-by-frame logging against a running dev server:
1. onEngineStop fires synchronously *before* tickFrame's "update node
positions" step writes that tick's x/y/z onto each node's Three.js
object (both run inside the same synchronous layoutTick() call, stop
callback first) — so fitting immediately (or even one rAF later) measures
a stale/near-empty bbox and ends up far too close once the real,
spread-out layout lands moments later. Fix: poll on requestAnimationFrame
until two consecutive getGraphBbox() reads agree (frame-capped safety
net) before calling zoomToFit.
2. Under cooldownTicks={0} (test/reduced-motion mode), warmupTicks was left
at its default of 0 too, so the 3D force simulation never actually ran —
nodes stayed at d3-force-3d's raw pre-simulation positions, an
overlapping cluster. KnowledgeGraph2D already solves this the same way
for its own reduced-motion path (`sim.alpha(1).tick(200).alpha(0).stop()`);
mirrored it here via `warmupTicks={reducedMotion || IS_TEST_MODE ? 200 : 0}`.
Verified visually against a dev server (:3010) proxying the held-up E2E
stack's backend: before, /tree in 3D was a tiny illegible clump; after, four
well-separated, readable node clusters filling most of the canvas, matching
the 2D layout's structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cel rAF on unmount
Round 3 re-review of the round-2 camera-framing fix (mechanism, determinism,
and tests independently verified) flagged one new Important issue and a
hygiene nit introduced by the async polling window itself:
1. The bbox-stabilization poll's closure never re-checked that it still
belonged to the current dataset. If nodes/edges changed while an old
poll was mid-flight (up to MAX_FRAMES/~1s wide), the stale closure kept
polling the live instance and could fire zoomToFit(400, 60) after —
clobbering — the fresh poll's correct fit for the new dataset. Fixed
with an epoch guard: pollEpochRef increments alongside didFitRef.current
= false in the [nodes, edges] reset effect; handleEngineStop captures
the epoch at poll start, and every scheduled frame callback bails
immediately if pollEpochRef.current no longer matches.
2. Hygiene: no cancelAnimationFrame cleanup tied the poll to component
lifetime, so an unmount mid-poll could leave up to ~60 dangling no-op
rAF callbacks. Now tracks the current rAF id in pollRafIdRef and cancels
it in an unmount-only effect.
TDD: wrote the new test first against the pre-fix code (stashed the
implementation), confirmed it failed exactly as described (stale poll
fires zoomToFit(400, 60) for the old dataset), then restored the
implementation and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99e248db-5120-4f64-8d7e-40dfec5218fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The opt-in 3D knowledge graph now uses custom spheres, halos, and labels. Shared helpers provide sizing, colors, themes, and adjacency. Hover interactions focus one-hop neighborhoods, links dim accordingly, and a reset control refits the camera.

Changes

Focused Minimal 3D graph

Layer / File(s)Summary
Styling helpers and rendering contract
docs/superpowers/..., frontend/src/components/graph/graph3dHelpers.ts, frontend/src/components/graph/graph3dHelpers.test.ts
Adds pure helpers for themes, colors, adjacency, node sizing, and label specifications with deterministic tests.
Custom node visuals
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/package.json, frontend/eslint-suppressions.json, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Renders matte spheres, focus halos, and SpriteText labels through custom Three.js objects and validates their properties.
Hover focus and link styling
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Tracks hovered nodes, dims non-neighbors and links, preserves persistent highlight halos, and supports string or resolved link endpoints.
Camera fitting and reset control
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx, frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
Adds ref-forwarded zoomToFit, stable-bounds auto-fit polling, stale-poll cancellation, warmup ticks, and the graph-zoom-reset button.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Pointer
participant KnowledgeGraph3D
participant VisualRegistry
participant ForceGraph3D
Pointer->>KnowledgeGraph3D: Hover a graph node
KnowledgeGraph3D->>VisualRegistry: Update node, label, and halo opacity
KnowledgeGraph3D->>ForceGraph3D: Recompute link color and width
ForceGraph3D-->>KnowledgeGraph3D: Render focused neighborhood
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 62.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main 3D graph changes: labels, hover focus, and recentering.
Description check✅ PassedThe description clearly explains the upgrade and includes comprehensive changes and testing details, despite using different section headings than the template.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3d-graph-focused-minimal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging987bfd7Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:09 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
frontend/src/components/graph/KnowledgeGraph3D.test.tsx (2)

553-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the frame-cap test deterministic and pin the cap value.

Line 557 uses Math.random() to produce a bbox that never repeats. The sibling test at Line 588 uses a monotonic counter for the same purpose, which is deterministic.

The test also asserts only that zoomToFit fired once. It does not assert how many frames elapsed. If MAX_FRAMES regressed from 60 to 2, this test would still pass. Assert the read count to pin the cap.

🧪 Proposed deterministic frame-cap test
- getGraphBboxMock.mockImplementation(() => ({ x: [0, Math.random()], y: [0, 0], z: [0, 0] }));+ let capCounter = 0;+ getGraphBboxMock.mockImplementation(() => ({ x: [0, ++capCounter], y: [0, 0], z: [0, 0] }));
 expect(zoomToFitSpy).toHaveBeenCalledTimes(1);
expect(zoomToFitSpy).toHaveBeenCalledWith(400, 60);
+ // Pins MAX_FRAMES: the poll reads the bbox once per frame up to the cap.+ expect(getGraphBboxMock).toHaveBeenCalledTimes(60);
expect(raf.queue.length).toBe(0); // no frame scheduled after the cap-triggered fit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 553 -
573, Make the frame-cap test deterministic by replacing Math.random() in
getGraphBboxMock with a monotonic counter so each bounding-box read differs
predictably. Track the number of bbox reads and assert it equals the configured
MAX_FRAMES value (60), while preserving the existing single fit and empty RAF
queue assertions.

171-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the unmount cancellation path.

captureAnimationFrames already spies on cancelAnimationFrame at Line 178, and the spy is exposed only through restore(). No test asserts that the unmount cleanup effect in KnowledgeGraph3D.tsx (Lines 202-206) cancels the pending frame. The PR lists that cleanup as a deliverable, so a regression that drops the effect would pass the current suite.

Return the cancelAnimationFrame spy from the helper, then assert it in a new test.

🧪 Proposed helper change and test
 const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
return {
queue,
+ caf,
it("cancels the in-flight bbox poll on unmount",()=>{getGraphBboxMock.mockReturnValue({x: [0,1],y: [0,1],z: [0,1]});constraf=captureAnimationFrames();const{ unmount }=render(<KnowledgeGraph3Dnodes={[makeNode()]}edges={[]}/>);act(()=>(lastProps!.onEngineStopas()=>void)());expect(raf.queue.length).toBe(1);unmount();expect(raf.caf).toHaveBeenCalledTimes(1);raf.restore();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 171 -
193, Update captureAnimationFrames to expose the cancelAnimationFrame spy, then
add a KnowledgeGraph3D unmount test that starts the bbox polling frame through
onEngineStop, unmounts the component, and asserts the cancellation spy was
called. Restore the animation-frame spies after the assertion.
frontend/src/components/graph/KnowledgeGraph3D.tsx (1)

303-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the link colors from the resolved theme instead of hardcoded RGB literals.

Lines 306, 311, and 312 hardcode rgba(138, 131, 114, ...) and rgba(138, 154, 91, ...). 138, 154, 91 is #8a9a5b, which is FALLBACK_THEME.accent. Nodes and halos use resolveGraphTheme(), which reads --accent and --ink-200 from CSS. If a deployment or an alternate color scheme overrides those variables, node colors follow the theme and link colors do not.

Convert theme.accent and theme.dim to RGB components once, then build the rgba strings from them.

🎨 Proposed theme-derived link colors
+ // Link colors follow the same resolved theme as nodes and halos.+ const linkRgb = React.useMemo(() => {+ const rgb = (hex: string) => {+ const c = new THREE.Color(hex);+ return `${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}`;+ };+ return { lit: rgb(theme.accent), dim: rgb(theme.dim) };+ }, [theme]);+
const linkColor = React.useCallback(
(l: object) => {
const link = l as FG3DLink;
- if (!hoverId) return `rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;+ if (!hoverId) return `rgba(${linkRgb.dim}, ${BASE_LINK_ALPHA})`;
const lit = linkEndId(link.source) === hoverId || linkEndId(link.target) === hoverId;
- // Lit links take the sage accent (rgb of `#8a9a5b`); dimmed links- // fade to near-invisible warm gray.+ // Lit links take the resolved accent; dimmed links fade to the+ // resolved dim token.
return lit
- ? `rgba(138, 154, 91, ${LIT_LINK_ALPHA})`- : `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;+ ? `rgba(${linkRgb.lit}, ${LIT_LINK_ALPHA})`+ : `rgba(${linkRgb.dim}, ${DIM_LINK_ALPHA})`;
},
- [hoverId],+ [hoverId, linkRgb],
);

If you accept this, update the literal expectations in frontend/src/components/graph/KnowledgeGraph3D.test.tsx at Lines 471, 478, 479, and 481-483 to build the same strings from FALLBACK_THEME.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 303 - 315,
Update the linkColor callback to use the resolved graph theme’s accent and dim
colors instead of hardcoded RGB values, converting theme.accent and theme.dim to
RGB components once and using them for the rgba strings. Preserve the existing
hover, lit, and dim alpha behavior, and update the corresponding
KnowledgeGraph3D.test.tsx expectations to construct the same values from
FALLBACK_THEME.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md`:
- Around line 937-947: Update the shell command around the E2E stack startup to
register an EXIT trap that runs make e2e-down before make e2e-up executes.
Preserve the existing test return-code collection and ensure teardown runs
exactly once, including when make e2e-up fails under set -e.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 243-285: Update nodeThreeObject to store the returned group
alongside sphereMat, halo, and label in visualsRef.current. Before replacing the
registry when the dataset changes and during component unmount, iterate the
existing entries and dispose both SphereGeometry instances, their materials, the
SpriteText canvas texture/material resources, and each group’s children; then
clear the registry before creating the new Map.
---
Nitpick comments:
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx`:
- Around line 553-573: Make the frame-cap test deterministic by replacing
Math.random() in getGraphBboxMock with a monotonic counter so each bounding-box
read differs predictably. Track the number of bbox reads and assert it equals
the configured MAX_FRAMES value (60), while preserving the existing single fit
and empty RAF queue assertions.
- Around line 171-193: Update captureAnimationFrames to expose the
cancelAnimationFrame spy, then add a KnowledgeGraph3D unmount test that starts
the bbox polling frame through onEngineStop, unmounts the component, and asserts
the cancellation spy was called. Restore the animation-frame spies after the
assertion.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 303-315: Update the linkColor callback to use the resolved graph
theme’s accent and dim colors instead of hardcoded RGB values, converting
theme.accent and theme.dim to RGB components once and using them for the rgba
strings. Preserve the existing hover, lit, and dim alpha behavior, and update
the corresponding KnowledgeGraph3D.test.tsx expectations to construct the same
values from FALLBACK_THEME.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d36f84-0079-4d42-a82d-ce69d01fc611

📥 Commits

Reviewing files that changed from the base of the PR and between 458ddb2 and c3ed797.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md
  • docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md
  • frontend/eslint-suppressions.json
  • frontend/package.json
  • frontend/src/components/graph/KnowledgeGraph3D.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/graph3dHelpers.test.ts
  • frontend/src/components/graph/graph3dHelpers.ts

Comment on lines +937 to +947
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Ensure teardown runs when make e2e-up fails.

set -e exits before make e2e-down if make e2e-up fails. This can leave a partially started E2E stack running. Register teardown with an EXIT trap before startup.

Proposed fix
 set -e
+ trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
- make e2e-down
exit $rc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
exit $rc
'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md` around lines
937 - 947, Update the shell command around the E2E stack startup to register an
EXIT trap that runs make e2e-down before make e2e-up executes. Preserve the
existing test return-code collection and ensure teardown runs exactly once,
including when make e2e-up fails under set -e.

Comment on lines +243 to +285
const nodeThreeObject = React.useCallback(
(raw: object) => {
const n = raw as FG3DNode;
const r = nodeRadius(n);
const color = baseNodeColor(n, theme);
const group = new THREE.Group();

const sphereMat = new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity: NODE_OPACITY,
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24), sphereMat));

// Focus halo: slightly larger translucent accent sphere, hidden
// until this node is hovered or is the persistent highlightId.
const halo = new THREE.Mesh(
new THREE.SphereGeometry(r * 1.4, 16, 16),
new THREE.MeshBasicMaterial({
color: theme.accent,
transparent: true,
opacity: 0.28,
depthWrite: false,
}),
);
halo.visible = n.id === hoverRef.current || n.id === highlightRef.current;
group.add(halo);

const spec = labelSpec(n);
const label = new SpriteText(n.name);
label.textHeight = spec.textHeight;
label.fontWeight = spec.fontWeight;
label.color = theme.ink;
label.fontFace = '"JetBrains Mono", monospace';
label.material.transparent = true;
label.position.set(0, -(r + spec.textHeight + 1.5), 0);
group.add(label);

visualsRef.current.set(n.id, { sphereMat, label, halo, baseColor: color });
return group;
},
[theme],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the repository already disposes Three.js resources anywhere, and whether SpriteText exposes a dispose path.set -euo pipefail
rg -n --type=ts --type=tsx -C3 '\.dispose\s*\(' frontend/src ||echo"no dispose() calls found in frontend/src"
fd -t d 'three-spritetext' frontend/node_modules --max-depth 3 --exec sh -c 'ls "$1"; sed -n "1,80p" "$1/dist/three-spritetext.d.ts" 2>/dev/null' _ {}

Repository: SaplingLearn/Sapling

Length of output: 340


🏁 Script executed:

#!/bin/bash0bashset -euo pipefail
printf'Tracked KnowledgeGraph files:\n'
fd -i 'KnowledgeGraph3D\.(ts|tsx)$'.||trueprintf'\nGit file list for likely front-end components:\n'
git ls-files | rg 'frontend/src/(components|.*Graph|.*three|package\.json)$|componentst'printf'\nSearch dispose across tracked source files:\n'
git ls-files | rg '\.(ts|tsx)$'| xargs -r rg -n -- '-- dispose\(' or true

Repository: SaplingLearn/Sapling

Length of output: 289


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'File line count:\n'
wc -l "$FILE"printf'\nRelevant section 160-300:\n'
sed -n '160,300p'"$FILE"| nl -ba -v160
printf'\nSearch for three-spritetext references and package manifests:\n'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo""$FILE"||true
rg -n "three-spritetext|react-three-fiber|`@react-three`" frontend -g 'package.json' -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock'||true

Repository: SaplingLearn/Sapling

Length of output: 298


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'Relevant section 160-300:\n'
sed -n '160,300p'"$FILE"| awk '{printf "%5d %s\n", NR+159, $0}'printf'\nImports and dependency references:\n'
sed -n '1,80p'"$FILE"| awk '{printf "%5d %s\n", NR, $0}'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo|useEffect|React""$FILE"||trueprintf'\nDependency files:\n'forfin package.json frontend/package.json frontend/package-lock.json frontend/pnpm-lock.yaml frontend/yarn.lock frontend/package.json;doif [ -f"$f" ];thenprintf'\n--- %s ---\n'"$f"
rg -n "three-spritetext|react-three-fiber|`@react-three`|three""$f"||truefidoneprintf'\nSearch disposed resource handling across tracked source files:\n'
git ls-files | xargs -r rg -n -- '-- dispose\s*\('| rg '\.(ts|tsx)$'||true

Repository: SaplingLearn/Sapling

Length of output: 13950


🌐 Web query:

three-spritetext v1.10.0 source canvas dispose texture code

💡 Result:

In three-spritetext v1.10.0, the process of disposing of the previous texture when updating the canvas is handled within the library's internal update logic. When the text content or styling changes, the library generates a new canvas and updates the sprite's material. The disposal logic is implemented as follows: // Inject canvas into sprite if (this.material.map) this.material.map.dispose; // gc previous texture var texture = this.material.map = new THREE.Texture(canvas); texture.minFilter = THREE.LinearFilter; texture.colorSpace = THREE.SRGBColorSpace; By calling this.material.map.dispose, the library ensures that the old texture is properly removed from GPU memory before assigning the new texture created from the updated canvas [1]. This practice is essential for preventing memory leaks when frequently updating sprite text [1][2].

Citations:


Dispose every node visual object when the dataset changes.

nodeThreeObject creates per-node Three.js resources: the returned group, two meshes with SphereGeometry, two materials, and a SpriteText with a canvas-backed Texture. Line 178 reassigns the registry to a new Map on a new dataset, so those previous objects become unreachable while their GPU buffers remain allocated.

Track the complete visual entry in visualsRef.current, including the group, then dispose the geometries, textures, and materials before replacing the registry and on component unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 243 - 285,
Update nodeThreeObject to store the returned group alongside sphereMat, halo,
and label in visualsRef.current. Before replacing the registry when the dataset
changes and during component unmount, iterate the existing entries and dispose
both SphereGeometry instances, their materials, the SpriteText canvas
texture/material resources, and each group’s children; then clear the registry
before creating the new Map.

…ion instant fit, warmupTicks pins
Three Important findings from the whole-branch final review, plus a doc
amendment, fixed in one wave:
1. Accent drift, one color one source: FALLBACK_THEME.accent was still the
retired sage (#8a9a5b) while globals.css's --accent is now the brighter
forest (#2d8f5c) — so the focus halo (theme.accent) rendered forest
while its own lit edges (hardcoded sage rgb) rendered sage. Corrected
FALLBACK_THEME.accent to #2d8f5c and added hexToRgbTriplet(), a small
pure helper next to resolveGraphTheme, so linkColor derives the lit-link
rgba from theme.accent instead of a second hardcoded copy that can
drift.
2. Reduced-motion camera animation: both zoomToFit call sites (the
engine-settle auto-fit and the manual recenter button) now gate their
duration — `reducedMotion || IS_TEST_MODE ? 0 : 400` — since these are
system-initiated camera flies and an animated 400ms fly is itself a
motion violation on the paths that already zero cooldownTicks to
eliminate motion. Padding unified to 60 across both call sites
(recenter button previously used 40).
3. warmupTicks was untested: pinned 200 in both the reduced-motion test
(KnowledgeGraph3D.test.tsx) and the test-mode determinism test
(KnowledgeGraph3D.testmode.test.tsx), and 0 in a new default-motion-path
test — plus new coverage for the reduced-motion/test-mode instant-fit
gating on the recenter button (the auto-fit path shares the identical
ternary, already exercised by the bbox-stabilization tests under
default motion).
Also amended docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md's
"Camera & motion" section to document both justified deviations (the
bbox-stabilization auto-fit poll + epoch guard, and warmupTicks=200 under
test/reduced-motion mirroring KnowledgeGraph2D's sim.tick(200) precedent),
and replaced the stale #8a9a5b halo color reference with "the app accent
token (--accent, currently #2d8f5c), resolved at mount".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — graph-3d Focused Minimal upgrade

This turns the 3D mode from bare library spheres into the Focused Minimal design: nodeThreeObject groups (matte MeshLambertMaterial sphere + hidden accent halo + always-visible three-spritetext label), a visualsRef registry so hover-focus mutates materials instead of rebuilding geometry, linkColor/linkWidth re-keyed on hover, a recenter button reusing the 2D control's title="Reset view" (which is what globals.css:609 keys on to hide it on the tutor rail), and a bbox-stabilisation rAF poll that drives a one-shot zoomToFit on the first engine settle. The pure-helper extraction into graph3dHelpers.ts is clean, the auto-fit poll's epoch guard and rAF-cancel-on-unmount are correct, and the SSR/testid/click-whitelist contracts are all preserved. Two things I checked and want to save you the time on: the library does own GPU disposal (react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate recursively disposes each group's geometries, materials and the sprite's material.map), and zoomToFit is null-safe on an empty graph — so CodeRabbit's disposal comment is not a real leak. What does need fixing is the hover path's interaction with three-forcegraph's prop-change semantics, plus the new lint suppressions and the merge state.

Findings

P1

[P1] Every hover transition destroys and rebuilds every link meshfrontend/src/components/graph/KnowledgeGraph3D.tsx:326-335

constlinkWidth=React.useCallback((l: object)=>{constlink=lasFG3DLink;constbase=0.4+(link.strength||0)*0.6;if(!hoverId)returnbase;constlit=linkEndId(link.source)===hoverId||linkEndId(link.target)===hoverId;returnlit ? base+0.6 : base;},[hoverId],);

hoverId is React state, so each hover enter/leave re-renders and hands react-force-graph-3d a new linkWidth function identity. react-kapsule pushes any identity-changed prop straight through (react-kapsule.mjs:30-33; linkWidth is a plain prop per react-force-graph-3d.mjs:126-129), and three-forcegraph treats linkWidth as an object-invalidating prop: three-forcegraph.mjs:1199-1201if (state._flushObjects || hasAnyPropChanged(['linkThreeObject','linkThreeObjectExtend','linkWidth'])) state.linkDataMapper.clear();. clear() is digest([]), which scene.remove()s and _deallocate()s (geometry.dispose + material.dispose) every link object and then recreates all of them. These aren't cheap lines either — useCylinder = !!widthAccessor(link) (:1220) is always true here, so each link is a CylinderGeometry mesh. On a few-hundred-edge graph that's a full teardown/rebuild of the link layer on every mouse enter and every mouse leave. This is exactly what docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md:138-142 promised not to do: "Hover mechanics — no per-hover geometry rebuilds … (links are cheap line materials)." Direction: give linkWidth a stable identity that reads hoverRef.current; linkColor's own change already triggers the digest, and the link onUpdateObj re-reads widthAccessor(link) each digest, so widths still update — just without the clear(). (Nothing visually breaks — three-forcegraph.mjs:1483 sets engineRunning = true at the end of every update(), so the next layoutTick repositions the new meshes — but it does mean onEngineStop also re-fires on every hover; the didFitRef guard absorbs that correctly.)

[P1] Two new react-hooks/refs suppressions added for new production codefrontend/eslint-suppressions.json:99-103

"src/components/graph/KnowledgeGraph3D.tsx": {
"react-hooks/refs": {
"count": 2
}
},

The Canopy Engineering Style Guide §7 states the ratchet explicitly — "don't fix the whole backlog to land a change, but never add to it" — and this PR's own plan repeats it at docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md:20: "eslint suppressions are a ratcheted baseline — new code must be clean." The two suppressed violations are real render-phase ref mutations: KnowledgeGraph3D.tsx:173highlightRef.current = highlightId; in the render body, and :179visualsRef.current = new Map(); inside the graphDatauseMemo. The useMemo one is the one with teeth — under React 19 concurrent rendering a render that starts and is then discarded still clears the registry, leaving the committed tree with an empty visualsRef and hover-focus a silent no-op. (The test-file suppressions — react-hooks/globals, react/display-name — are less load-bearing but also new.)

[P1] Branch is CONFLICTING with main, and main landed the #538 WebGL2 gate on the same three files

gh pr view 530 --json mergeable returns "mergeable":"CONFLICTING","mergeStateStatus":"DIRTY". Since the merge-base (ec34bf17), main has changed KnowledgeGraph.tsx (+201: WebGL2 capability probe, ErrorBoundary, graph-crash-fallback), added this header to KnowledgeGraph3D.tsx"#538: NEVER mount this component outside the KnowledgeGraph wrapper — three r163+ throws from the WebGLRenderer constructor when WebGL2 is unavailable" — and replaced the local next/dynamic mock in bothKnowledgeGraph3D.test.tsx and KnowledgeGraph3D.testmode.test.tsx with the shared @/test-utils/mockNextDynamic helper. This PR rewrites all three of those files from the pre-#538 base and still carries the old inline mock (KnowledgeGraph3D.test.tsx:98-114, KnowledgeGraph3D.testmode.test.tsx:36-52), so the conflict resolution has to re-apply main's work by hand rather than take either side wholesale. Worth rebasing before the draft flips to ready — main's shared helper does handle this PR's loader (which resolves to a bare function component, not a module) via typeof mod === "function" ? mod : (mod.default ?? null).

P2

[P2] nodeThreeObject doesn't apply the active focus state to the nodes it (re)buildsfrontend/src/components/graph/KnowledgeGraph3D.tsx:251-286

constsphereMat=newTHREE.MeshLambertMaterial({
color,transparent: true,opacity: NODE_OPACITY,});

The halo correctly reads the live refs (halo.visible = n.id === hoverRef.current || n.id === highlightRef.current), but the sphere is always built at full baseColor/NODE_OPACITY and the label at full opacity. Any nodes/edges identity change re-runs the graphData memo (clearing visualsRef at :179) and makes three-forcegraph rebuild every node object — node objects are keyed by identity and the memo mints fresh {...n} clones. If the pointer is still over a node at that moment (a tutor graph_update refreshing the Learn rail graph, a filter change on /tree), the hovered node keeps its halo but nothing dims: the focus effect renders half-applied until the pointer moves. The [applyFocus, highlightId] re-assert effect can't cover it because it runs at commit, before the library rebuilds. The component test doesn't catch this because it calls nodeThreeObject manually before driving onNodeHover (KnowledgeGraph3D.test.tsx:451-456), which is the opposite order.

[P2] A halo geometry + material is allocated for every node although at most two are ever visiblefrontend/src/components/graph/KnowledgeGraph3D.tsx:256-270

group.add(newTHREE.Mesh(newTHREE.SphereGeometry(r,24,24),sphereMat));// Focus halo: slightly larger translucent accent sphere, hidden// until this node is hovered or is the persistent highlightId.consthalo=newTHREE.Mesh(newTHREE.SphereGeometry(r*1.4,16,16),newTHREE.MeshBasicMaterial({

halo.visible is only ever true for the hovered node and highlightId, so an N-node graph carries N−2 unused SphereGeometry + MeshBasicMaterial pairs for the lifetime of the dataset, re-allocated in full on every refresh. A single shared unit-sphere geometry scaled per node, or lazy creation on first focus, avoids it. Separately, the main sphere went from the library's nodeResolution={16} (pre-patch) to SphereGeometry(r, 24, 24) — 2.25× the triangles per node — which the spec doesn't ask for.

[P2] Each label is rasterised five times at constructionfrontend/src/components/graph/KnowledgeGraph3D.tsx:273-279

constlabel=newSpriteText(n.name);label.textHeight=spec.textHeight;label.fontWeight=spec.fontWeight;label.color=theme.ink;label.fontFace='"JetBrains Mono", monospace';

Every three-spritetext setter re-runs _genCanvas() (measure text → resize canvas → repaint → new CanvasTexture). That's the constructor plus four setters = five full rasterisations and five texture allocations per node, paid again for the whole graph on every dataset refresh. SpriteText's constructor takes (text, textHeight, color), which folds three of the five. Related sizing note: _fontSize is fixed at 90 regardless of textHeight, so each label canvas is roughly 600×100 RGBA — at the spec's stated "few hundred nodes" ceiling that is tens of MB of texture memory. (No leak — _genCanvas disposes the previous map — just wasted work and VRAM.)

P3

[P3] The base and dim link colors are still hardcoded hex while the lit one derives from the themefrontend/src/components/graph/KnowledgeGraph3D.tsx:315,321

if(!hoverId)return`rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;
...
: `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;

138, 131, 114 is #8a8372 = --ink-400 (globals.css:47) — an existing token, not an unnamed value. graph3dHelpers.ts:33-36 says FALLBACK_THEME is "the ONE place that hex is allowed to be hardcoded", and the c65c075 pass fixed only the lit half. Adding a fourth GraphTheme field resolved from --ink-400 would close the loop and match the Canopy design-token rule ("Use the tokens; don't hardcode values"). Low stakes — the spec does sanction "today's rgba(138,131,114,…) family" — but the file's own comment now contradicts itself.

Test-plan checklist — per-item verdict

  1. graph3dHelpers unit tests + rewritten component tests — supported. graph3dHelpers.test.ts covers buildAdjacency, nodeVal/nodeRadius, mixHex, baseNodeColor, labelSpec, resolveGraphTheme; the component test covers node composition, hover dim/restore, halo persistence, linkColor, recenter (both durations), the bbox poll, the frame cap and the epoch guard. Two gaps: the newly exported hexToRgbTriplet has no unit test (only indirect coverage via the component test), and nothing asserts the unmount cancelAnimationFrame even though captureAnimationFrames already spies on it.
  2. full frontend suite, lint, next build — supported by CI (Frontend (lint + tsc + vitest) pass, Workers Builds: frontend-staging pass), with the caveat that lint only passes because of the new suppressions in the P1 finding above.
  3. flock'd E2E cycle: Chapter 1 journeys + oracles green — not verifiable from the PR; there is no E2E job among the checks on feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter #530. Taking it on trust.
  4. manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached) — the code claims hold up (the title="Reset view"globals.css:609 rail-hiding seam is real), but no screenshots are attached to the PR body or to any comment, so "attached" doesn't match the PR as it stands.
  5. fresh 3D screenshots reviewed on /tree + dashboard — artifacts are gitignored, so unverifiable from here. Note that the P1 hover finding wouldn't have shown up as a visual defect (it's allocation churn, not a broken frame), so this doesn't invalidate the pass.

What's good

  • The auto-fit story is genuinely well-engineered: the bbox-stabilisation poll, the MAX_FRAMES safety net, the pollEpochRef invalidation on mid-poll dataset change and the rAF cancel on unmount are all correct, and each one is pinned by a test that would fail if it regressed.
  • Reduced-motion is handled properly rather than performatively — zeroing the zoomToFit duration on both call sites because a system-initiated camera fly is itself motion, and the warmupTicks={200} reasoning mirrors KnowledgeGraph2D's sim.tick(200) precedent instead of inventing a new one.
  • graph3dHelpers.ts is the right shape: pure, three.js-free, unit-testable, and the "always return #rrggbb" color contract is documented where it will actually be read.
  • Every preserved contract really is preserved — sr-only list, all three testids, the onNodeClick id whitelist, and the dynamic(ssr:false) boundary all still hold, and the click-whitelist test now enumerates the full set of library-injected fields.

Verdict: solid design work with one real hover-path performance bug (P1), a ratchet violation (P1) and a rebase (P1) to clear before this leaves draft.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…nder-phase ref writes
Review round on #530.
P1 — linkWidth was a useCallback keyed on the hoverId state, so every
pointer enter AND leave handed react-force-graph-3d a new function
identity. three-forcegraph treats linkWidth as object-invalidating for
links (linkDataMapper.clear() -> digest([]) -> scene.remove() +
_deallocate() of every link mesh, all CylinderGeometry here since the
widths are never zero), so a hover was two full teardowns/rebuilds of
the whole link layer — the exact opposite of the spec's "no per-hover
geometry rebuilds". linkWidth now reads hoverRef with an empty dep
array; widths still follow the hover because linkColor's own identity
change triggers the digest and its onUpdateObj re-reads the width
accessor per link.
P1 — both react-hooks/refs violations were real render-phase ref
mutations. highlightId is now mirrored in an effect (the useRef
initializer covers first mount; kapsule's digest is debounce(fn, 1), so
the library cannot call nodeThreeObject before the commit's effects
have run). The visuals registry is no longer a ref cleared inside a
useMemo — a discarded concurrent render would still have cleared it and
left the committed tree with an empty registry and hover focus a silent
no-op. One memo now owns everything scoped to a dataset: graphData, the
registry, and the shared halo geometry/material.
P2 — nodeThreeObject applies the live focus state to nodes it builds,
so a dataset refresh under an active hover (tutor graph_update, /tree
filter) no longer renders half-focused until the pointer moves. Focus
styling has a single writer (applyVisualState) shared with the focus
pass.
P2 — one unit-sphere halo geometry + material per dataset, scaled per
node, instead of N pairs of which at most two are ever visible; main
sphere back to 16 segments (the library's nodeResolution default) from
24.
P2 — SpriteText labels use the (text, textHeight, color) constructor:
three of the five canvas rasterisations per label were setter-driven.
P3 — the resting and dimmed link rgb are derived from a new
GraphTheme.link resolved from --ink-400 instead of two hand-copied
rgba(138, 131, 114, …) literals, which contradicted FALLBACK_THEME's
"the ONE place that hex is allowed to be hardcoded".
Also re-applies #538's header warning about never mounting this
component outside the KnowledgeGraph wrapper.
…an gaps
Ports #538's work forward by hand so the eventual merge with main is a
no-op on these files: both 3D graph test files now use the shared
@/test-utils/mockNextDynamic helper (copied verbatim from main) instead
of a local inline dynamic mock. The helper renders the resolved
component with createElement rather than calling it as a function, so
the stub's hooks land in their own fiber, and it accepts this
component's loader, which resolves to a bare function component.
Removes every eslint-suppressions.json entry this branch had added, and
the pre-existing @typescript-eslint/no-explicit-any entry too, by
fixing the causes rather than baselining them:
- the react-force-graph-3d stub is a NAMED function inside forwardRef
(react/display-name) that records props in an effect instead of the
render body (react-hooks/globals — Testing Library flushes passive
effects before render/rerender/act return, so tests still read the
props synchronously);
- the capture is a spelled-out CapturedProps type, not
Record<string, any>.
Net effect vs the merge base: one entry removed, none added.
Test-plan gaps:
- the frame-cap test drove the bbox with Math.random() and asserted
only that zoomToFit fired once, so a MAX_FRAMES regression from 60 to
2 would have passed. Monotonic counter now, and the bbox read count is
pinned to the cap.
- captureAnimationFrames already spied on cancelAnimationFrame but
nothing asserted the unmount cleanup. The spy and the frame ids are
exposed and a test covers it.
- new test that linkWidth's identity is stable across a hover while
linkColor's is not, and that the same accessor reports the widened
value.
- new test that nodeThreeObject applies focus to nodes built AFTER
onNodeHover — the real order; the existing tests build first.
- direct unit tests for the newly exported hexToRgbTriplet.
Under `set -e` a failing `make e2e-up` exited the flock'd shell before
the trailing `make e2e-down` could run, leaving a half-started stack
holding the ports. Register `trap "make e2e-down || true" EXIT` first
and drop the explicit teardown.
…e graph
The only path that exercises the new highlightRef mirror effect is a
highlightId change while mounted — first mount is covered by the useRef
initializer, so mutation-testing showed the effect could be deleted
outright with all tests still green. This also pins the effect ORDER:
the mirror is declared before the re-assert effect, and moving it after
makes the re-assert read the stale highlight (verified: all three
mutations now fail).
Byte-identical to main in that region, so the shared-helper adoption
merges cleanly instead of conflicting on comment wording. The 'why an
await import inside a hoisted vi.mock factory' rationale already lives
in the helper's own docblock.
vi.mock calls are hoisted, so the order is behaviour-neutral — but this
block sat inside the exact hunk #538 rewrote on main (the next/dynamic
mock), which made an otherwise clean merge conflict on a pure addition.
Moved into untouched context above.
The plan's pre-review code sketches prescribe the exact five shapes the
review round removed (hover-keyed linkWidth, a visualsRef cleared inside
a useMemo, focus-blind nodeThreeObject, per-node halo pairs, hardcoded
link rgb). Left unmarked, the doc reads as authoritative and invites a
future reader to 'restore' them.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Every hover destroyed and rebuilt every link mesh.linkWidth was keyed on hoverId state, so each enter/leave handed the library a new function identity, and three-forcegraph treats linkWidth as object-invalidating → linkDataMapper.clear() → full teardown/rebuild of every CylinderGeometry link. That is exactly what the spec promised not to do. linkWidth now has a stable identity reading hoverRef.current; widths still track hover because linkColor already triggers the digest and onUpdateObj re-reads the accessor. Mutation-verified: restoring [hoverId] fails exactly one test.
  • Two new react-hooks/refs suppressions were added for new code, against the ratchet rule this PR's own plan restates. Both fixed at the source — the render-body ref write moved into an effect, and visualsRef replaced by a dataset-scoped epoch object so a discarded concurrent render cannot clobber the committed registry. The test-file suppressions were fixed properly too, not baselined. Suppressions went 29 files/193 → 28 files/187, deletion-only.
  • The branch was CONFLICTING with main (Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 landed the WebGL2 gate on the same three files). Ported forward by hand — no rebase: adopted @/test-utils/mockNextDynamic byte-identical to main, re-applied the Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 header, and shrank two more noise conflicts. git merge-tree --write-tree HEAD main now reports 0 conflicts in all three source files; the only residual is the generated eslint-suppressions.json baseline.

Minor / nits

nodeThreeObject applies the live focus state to nodes it rebuilds, so a mid-hover dataset refresh no longer renders focus half-applied · shared halo geometry instead of one per node, sphere segments back to 16 · SpriteText built via its constructor args, cutting five rasterisations per label · base/dim link colours derived from the theme rather than a hardcoded --ink-400 · e2e teardown trap in the plan doc · deterministic frame-cap test that pins MAX_FRAMES · unmount cancelAnimationFrame test · hexToRgbTriplet unit test.

Not done — CodeRabbit's disposal finding is not valid

react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate, which already disposes each group's geometries, materials and the sprite's material.map. Adding manual disposal would be redundant.

Verificationtsc clean · eslint 0 errors · 604 frontend tests pass · merge conflicts vs main: 3 source files → 0

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

…-minimal
# Conflicts:
#	frontend/eslint-suppressions.json
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter by AndresL230 · Pull Request #530 · SaplingLearn/Sapling · GitHub
Skip to content

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter - #530

Draft
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal
Draft

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter#530
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 3D graph mode grows from bare spheres into the approved Focused Minimal design (spec: docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md)
  • matte spheres + always-visible SpriteText labels (roots bold), unexplored tier washed toward warm gray
  • hover-focus: 1-hop neighborhood lit, rest dimmed; sage halo on hovered + tutor-highlighted nodes
  • ⌖ recenter (zoomToFit) sharing the 2D control's title/testid (auto-hidden on tutor rail)
  • new dep three-spritetext, client-chunk only; 3D stays opt-in behind the existing toggle
  • camera auto-fit on engine settle (bbox-stabilization poll + warmupTicks under test/reduced-motion) — 3 review-gated fix rounds, root-caused in a live browser

Test plan

  • graph3dHelpers unit tests + rewritten KnowledgeGraph3D component tests
  • full frontend suite, lint, next build
  • flock'd E2E cycle: Chapter 1 journeys + oracles green
  • manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached)
  • fresh 3D screenshots reviewed on /tree + dashboard (visual-pass artifacts in .superpowers/sdd/, gitignored)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a focused visual treatment for the opt-in 3D knowledge graph, including matte nodes, halos, and always-visible labels.
    • Hovering over a node now highlights its immediate connections while dimming unrelated nodes and links.
    • Added a “Reset view” control to recenter and fit the graph.
    • Improved automatic camera fitting when graph data changes, with reduced-motion support.
  • Tests

    • Expanded coverage for rendering, highlighting, camera fitting, accessibility behavior, and visual styling.

AndresL230and others added 9 commits August 5, 2026 16:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght halo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… run warmup ticks under test/reduced-motion
Round 2 on d2e59a9's camera-framing fix, which was unit-correct but had no
runtime effect. Confirmed root cause via node_modules/3d-force-graph source
plus live frame-by-frame logging against a running dev server:
1. onEngineStop fires synchronously *before* tickFrame's "update node
positions" step writes that tick's x/y/z onto each node's Three.js
object (both run inside the same synchronous layoutTick() call, stop
callback first) — so fitting immediately (or even one rAF later) measures
a stale/near-empty bbox and ends up far too close once the real,
spread-out layout lands moments later. Fix: poll on requestAnimationFrame
until two consecutive getGraphBbox() reads agree (frame-capped safety
net) before calling zoomToFit.
2. Under cooldownTicks={0} (test/reduced-motion mode), warmupTicks was left
at its default of 0 too, so the 3D force simulation never actually ran —
nodes stayed at d3-force-3d's raw pre-simulation positions, an
overlapping cluster. KnowledgeGraph2D already solves this the same way
for its own reduced-motion path (`sim.alpha(1).tick(200).alpha(0).stop()`);
mirrored it here via `warmupTicks={reducedMotion || IS_TEST_MODE ? 200 : 0}`.
Verified visually against a dev server (:3010) proxying the held-up E2E
stack's backend: before, /tree in 3D was a tiny illegible clump; after, four
well-separated, readable node clusters filling most of the canvas, matching
the 2D layout's structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cel rAF on unmount
Round 3 re-review of the round-2 camera-framing fix (mechanism, determinism,
and tests independently verified) flagged one new Important issue and a
hygiene nit introduced by the async polling window itself:
1. The bbox-stabilization poll's closure never re-checked that it still
belonged to the current dataset. If nodes/edges changed while an old
poll was mid-flight (up to MAX_FRAMES/~1s wide), the stale closure kept
polling the live instance and could fire zoomToFit(400, 60) after —
clobbering — the fresh poll's correct fit for the new dataset. Fixed
with an epoch guard: pollEpochRef increments alongside didFitRef.current
= false in the [nodes, edges] reset effect; handleEngineStop captures
the epoch at poll start, and every scheduled frame callback bails
immediately if pollEpochRef.current no longer matches.
2. Hygiene: no cancelAnimationFrame cleanup tied the poll to component
lifetime, so an unmount mid-poll could leave up to ~60 dangling no-op
rAF callbacks. Now tracks the current rAF id in pollRafIdRef and cancels
it in an unmount-only effect.
TDD: wrote the new test first against the pre-fix code (stashed the
implementation), confirmed it failed exactly as described (stale poll
fires zoomToFit(400, 60) for the old dataset), then restored the
implementation and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99e248db-5120-4f64-8d7e-40dfec5218fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The opt-in 3D knowledge graph now uses custom spheres, halos, and labels. Shared helpers provide sizing, colors, themes, and adjacency. Hover interactions focus one-hop neighborhoods, links dim accordingly, and a reset control refits the camera.

Changes

Focused Minimal 3D graph

Layer / File(s)Summary
Styling helpers and rendering contract
docs/superpowers/..., frontend/src/components/graph/graph3dHelpers.ts, frontend/src/components/graph/graph3dHelpers.test.ts
Adds pure helpers for themes, colors, adjacency, node sizing, and label specifications with deterministic tests.
Custom node visuals
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/package.json, frontend/eslint-suppressions.json, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Renders matte spheres, focus halos, and SpriteText labels through custom Three.js objects and validates their properties.
Hover focus and link styling
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Tracks hovered nodes, dims non-neighbors and links, preserves persistent highlight halos, and supports string or resolved link endpoints.
Camera fitting and reset control
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx, frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
Adds ref-forwarded zoomToFit, stable-bounds auto-fit polling, stale-poll cancellation, warmup ticks, and the graph-zoom-reset button.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Pointer
participant KnowledgeGraph3D
participant VisualRegistry
participant ForceGraph3D
Pointer->>KnowledgeGraph3D: Hover a graph node
KnowledgeGraph3D->>VisualRegistry: Update node, label, and halo opacity
KnowledgeGraph3D->>ForceGraph3D: Recompute link color and width
ForceGraph3D-->>KnowledgeGraph3D: Render focused neighborhood
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 62.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main 3D graph changes: labels, hover focus, and recentering.
Description check✅ PassedThe description clearly explains the upgrade and includes comprehensive changes and testing details, despite using different section headings than the template.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3d-graph-focused-minimal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging987bfd7Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:09 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
frontend/src/components/graph/KnowledgeGraph3D.test.tsx (2)

553-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the frame-cap test deterministic and pin the cap value.

Line 557 uses Math.random() to produce a bbox that never repeats. The sibling test at Line 588 uses a monotonic counter for the same purpose, which is deterministic.

The test also asserts only that zoomToFit fired once. It does not assert how many frames elapsed. If MAX_FRAMES regressed from 60 to 2, this test would still pass. Assert the read count to pin the cap.

🧪 Proposed deterministic frame-cap test
- getGraphBboxMock.mockImplementation(() => ({ x: [0, Math.random()], y: [0, 0], z: [0, 0] }));+ let capCounter = 0;+ getGraphBboxMock.mockImplementation(() => ({ x: [0, ++capCounter], y: [0, 0], z: [0, 0] }));
 expect(zoomToFitSpy).toHaveBeenCalledTimes(1);
expect(zoomToFitSpy).toHaveBeenCalledWith(400, 60);
+ // Pins MAX_FRAMES: the poll reads the bbox once per frame up to the cap.+ expect(getGraphBboxMock).toHaveBeenCalledTimes(60);
expect(raf.queue.length).toBe(0); // no frame scheduled after the cap-triggered fit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 553 -
573, Make the frame-cap test deterministic by replacing Math.random() in
getGraphBboxMock with a monotonic counter so each bounding-box read differs
predictably. Track the number of bbox reads and assert it equals the configured
MAX_FRAMES value (60), while preserving the existing single fit and empty RAF
queue assertions.

171-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the unmount cancellation path.

captureAnimationFrames already spies on cancelAnimationFrame at Line 178, and the spy is exposed only through restore(). No test asserts that the unmount cleanup effect in KnowledgeGraph3D.tsx (Lines 202-206) cancels the pending frame. The PR lists that cleanup as a deliverable, so a regression that drops the effect would pass the current suite.

Return the cancelAnimationFrame spy from the helper, then assert it in a new test.

🧪 Proposed helper change and test
 const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
return {
queue,
+ caf,
it("cancels the in-flight bbox poll on unmount",()=>{getGraphBboxMock.mockReturnValue({x: [0,1],y: [0,1],z: [0,1]});constraf=captureAnimationFrames();const{ unmount }=render(<KnowledgeGraph3Dnodes={[makeNode()]}edges={[]}/>);act(()=>(lastProps!.onEngineStopas()=>void)());expect(raf.queue.length).toBe(1);unmount();expect(raf.caf).toHaveBeenCalledTimes(1);raf.restore();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 171 -
193, Update captureAnimationFrames to expose the cancelAnimationFrame spy, then
add a KnowledgeGraph3D unmount test that starts the bbox polling frame through
onEngineStop, unmounts the component, and asserts the cancellation spy was
called. Restore the animation-frame spies after the assertion.
frontend/src/components/graph/KnowledgeGraph3D.tsx (1)

303-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the link colors from the resolved theme instead of hardcoded RGB literals.

Lines 306, 311, and 312 hardcode rgba(138, 131, 114, ...) and rgba(138, 154, 91, ...). 138, 154, 91 is #8a9a5b, which is FALLBACK_THEME.accent. Nodes and halos use resolveGraphTheme(), which reads --accent and --ink-200 from CSS. If a deployment or an alternate color scheme overrides those variables, node colors follow the theme and link colors do not.

Convert theme.accent and theme.dim to RGB components once, then build the rgba strings from them.

🎨 Proposed theme-derived link colors
+ // Link colors follow the same resolved theme as nodes and halos.+ const linkRgb = React.useMemo(() => {+ const rgb = (hex: string) => {+ const c = new THREE.Color(hex);+ return `${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}`;+ };+ return { lit: rgb(theme.accent), dim: rgb(theme.dim) };+ }, [theme]);+
const linkColor = React.useCallback(
(l: object) => {
const link = l as FG3DLink;
- if (!hoverId) return `rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;+ if (!hoverId) return `rgba(${linkRgb.dim}, ${BASE_LINK_ALPHA})`;
const lit = linkEndId(link.source) === hoverId || linkEndId(link.target) === hoverId;
- // Lit links take the sage accent (rgb of `#8a9a5b`); dimmed links- // fade to near-invisible warm gray.+ // Lit links take the resolved accent; dimmed links fade to the+ // resolved dim token.
return lit
- ? `rgba(138, 154, 91, ${LIT_LINK_ALPHA})`- : `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;+ ? `rgba(${linkRgb.lit}, ${LIT_LINK_ALPHA})`+ : `rgba(${linkRgb.dim}, ${DIM_LINK_ALPHA})`;
},
- [hoverId],+ [hoverId, linkRgb],
);

If you accept this, update the literal expectations in frontend/src/components/graph/KnowledgeGraph3D.test.tsx at Lines 471, 478, 479, and 481-483 to build the same strings from FALLBACK_THEME.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 303 - 315,
Update the linkColor callback to use the resolved graph theme’s accent and dim
colors instead of hardcoded RGB values, converting theme.accent and theme.dim to
RGB components once and using them for the rgba strings. Preserve the existing
hover, lit, and dim alpha behavior, and update the corresponding
KnowledgeGraph3D.test.tsx expectations to construct the same values from
FALLBACK_THEME.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md`:
- Around line 937-947: Update the shell command around the E2E stack startup to
register an EXIT trap that runs make e2e-down before make e2e-up executes.
Preserve the existing test return-code collection and ensure teardown runs
exactly once, including when make e2e-up fails under set -e.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 243-285: Update nodeThreeObject to store the returned group
alongside sphereMat, halo, and label in visualsRef.current. Before replacing the
registry when the dataset changes and during component unmount, iterate the
existing entries and dispose both SphereGeometry instances, their materials, the
SpriteText canvas texture/material resources, and each group’s children; then
clear the registry before creating the new Map.
---
Nitpick comments:
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx`:
- Around line 553-573: Make the frame-cap test deterministic by replacing
Math.random() in getGraphBboxMock with a monotonic counter so each bounding-box
read differs predictably. Track the number of bbox reads and assert it equals
the configured MAX_FRAMES value (60), while preserving the existing single fit
and empty RAF queue assertions.
- Around line 171-193: Update captureAnimationFrames to expose the
cancelAnimationFrame spy, then add a KnowledgeGraph3D unmount test that starts
the bbox polling frame through onEngineStop, unmounts the component, and asserts
the cancellation spy was called. Restore the animation-frame spies after the
assertion.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 303-315: Update the linkColor callback to use the resolved graph
theme’s accent and dim colors instead of hardcoded RGB values, converting
theme.accent and theme.dim to RGB components once and using them for the rgba
strings. Preserve the existing hover, lit, and dim alpha behavior, and update
the corresponding KnowledgeGraph3D.test.tsx expectations to construct the same
values from FALLBACK_THEME.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d36f84-0079-4d42-a82d-ce69d01fc611

📥 Commits

Reviewing files that changed from the base of the PR and between 458ddb2 and c3ed797.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md
  • docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md
  • frontend/eslint-suppressions.json
  • frontend/package.json
  • frontend/src/components/graph/KnowledgeGraph3D.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/graph3dHelpers.test.ts
  • frontend/src/components/graph/graph3dHelpers.ts

Comment on lines +937 to +947
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Ensure teardown runs when make e2e-up fails.

set -e exits before make e2e-down if make e2e-up fails. This can leave a partially started E2E stack running. Register teardown with an EXIT trap before startup.

Proposed fix
 set -e
+ trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
- make e2e-down
exit $rc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
exit $rc
'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md` around lines
937 - 947, Update the shell command around the E2E stack startup to register an
EXIT trap that runs make e2e-down before make e2e-up executes. Preserve the
existing test return-code collection and ensure teardown runs exactly once,
including when make e2e-up fails under set -e.

Comment on lines +243 to +285
const nodeThreeObject = React.useCallback(
(raw: object) => {
const n = raw as FG3DNode;
const r = nodeRadius(n);
const color = baseNodeColor(n, theme);
const group = new THREE.Group();

const sphereMat = new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity: NODE_OPACITY,
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24), sphereMat));

// Focus halo: slightly larger translucent accent sphere, hidden
// until this node is hovered or is the persistent highlightId.
const halo = new THREE.Mesh(
new THREE.SphereGeometry(r * 1.4, 16, 16),
new THREE.MeshBasicMaterial({
color: theme.accent,
transparent: true,
opacity: 0.28,
depthWrite: false,
}),
);
halo.visible = n.id === hoverRef.current || n.id === highlightRef.current;
group.add(halo);

const spec = labelSpec(n);
const label = new SpriteText(n.name);
label.textHeight = spec.textHeight;
label.fontWeight = spec.fontWeight;
label.color = theme.ink;
label.fontFace = '"JetBrains Mono", monospace';
label.material.transparent = true;
label.position.set(0, -(r + spec.textHeight + 1.5), 0);
group.add(label);

visualsRef.current.set(n.id, { sphereMat, label, halo, baseColor: color });
return group;
},
[theme],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the repository already disposes Three.js resources anywhere, and whether SpriteText exposes a dispose path.set -euo pipefail
rg -n --type=ts --type=tsx -C3 '\.dispose\s*\(' frontend/src ||echo"no dispose() calls found in frontend/src"
fd -t d 'three-spritetext' frontend/node_modules --max-depth 3 --exec sh -c 'ls "$1"; sed -n "1,80p" "$1/dist/three-spritetext.d.ts" 2>/dev/null' _ {}

Repository: SaplingLearn/Sapling

Length of output: 340


🏁 Script executed:

#!/bin/bash0bashset -euo pipefail
printf'Tracked KnowledgeGraph files:\n'
fd -i 'KnowledgeGraph3D\.(ts|tsx)$'.||trueprintf'\nGit file list for likely front-end components:\n'
git ls-files | rg 'frontend/src/(components|.*Graph|.*three|package\.json)$|componentst'printf'\nSearch dispose across tracked source files:\n'
git ls-files | rg '\.(ts|tsx)$'| xargs -r rg -n -- '-- dispose\(' or true

Repository: SaplingLearn/Sapling

Length of output: 289


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'File line count:\n'
wc -l "$FILE"printf'\nRelevant section 160-300:\n'
sed -n '160,300p'"$FILE"| nl -ba -v160
printf'\nSearch for three-spritetext references and package manifests:\n'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo""$FILE"||true
rg -n "three-spritetext|react-three-fiber|`@react-three`" frontend -g 'package.json' -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock'||true

Repository: SaplingLearn/Sapling

Length of output: 298


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'Relevant section 160-300:\n'
sed -n '160,300p'"$FILE"| awk '{printf "%5d %s\n", NR+159, $0}'printf'\nImports and dependency references:\n'
sed -n '1,80p'"$FILE"| awk '{printf "%5d %s\n", NR, $0}'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo|useEffect|React""$FILE"||trueprintf'\nDependency files:\n'forfin package.json frontend/package.json frontend/package-lock.json frontend/pnpm-lock.yaml frontend/yarn.lock frontend/package.json;doif [ -f"$f" ];thenprintf'\n--- %s ---\n'"$f"
rg -n "three-spritetext|react-three-fiber|`@react-three`|three""$f"||truefidoneprintf'\nSearch disposed resource handling across tracked source files:\n'
git ls-files | xargs -r rg -n -- '-- dispose\s*\('| rg '\.(ts|tsx)$'||true

Repository: SaplingLearn/Sapling

Length of output: 13950


🌐 Web query:

three-spritetext v1.10.0 source canvas dispose texture code

💡 Result:

In three-spritetext v1.10.0, the process of disposing of the previous texture when updating the canvas is handled within the library's internal update logic. When the text content or styling changes, the library generates a new canvas and updates the sprite's material. The disposal logic is implemented as follows: // Inject canvas into sprite if (this.material.map) this.material.map.dispose; // gc previous texture var texture = this.material.map = new THREE.Texture(canvas); texture.minFilter = THREE.LinearFilter; texture.colorSpace = THREE.SRGBColorSpace; By calling this.material.map.dispose, the library ensures that the old texture is properly removed from GPU memory before assigning the new texture created from the updated canvas [1]. This practice is essential for preventing memory leaks when frequently updating sprite text [1][2].

Citations:


Dispose every node visual object when the dataset changes.

nodeThreeObject creates per-node Three.js resources: the returned group, two meshes with SphereGeometry, two materials, and a SpriteText with a canvas-backed Texture. Line 178 reassigns the registry to a new Map on a new dataset, so those previous objects become unreachable while their GPU buffers remain allocated.

Track the complete visual entry in visualsRef.current, including the group, then dispose the geometries, textures, and materials before replacing the registry and on component unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 243 - 285,
Update nodeThreeObject to store the returned group alongside sphereMat, halo,
and label in visualsRef.current. Before replacing the registry when the dataset
changes and during component unmount, iterate the existing entries and dispose
both SphereGeometry instances, their materials, the SpriteText canvas
texture/material resources, and each group’s children; then clear the registry
before creating the new Map.

…ion instant fit, warmupTicks pins
Three Important findings from the whole-branch final review, plus a doc
amendment, fixed in one wave:
1. Accent drift, one color one source: FALLBACK_THEME.accent was still the
retired sage (#8a9a5b) while globals.css's --accent is now the brighter
forest (#2d8f5c) — so the focus halo (theme.accent) rendered forest
while its own lit edges (hardcoded sage rgb) rendered sage. Corrected
FALLBACK_THEME.accent to #2d8f5c and added hexToRgbTriplet(), a small
pure helper next to resolveGraphTheme, so linkColor derives the lit-link
rgba from theme.accent instead of a second hardcoded copy that can
drift.
2. Reduced-motion camera animation: both zoomToFit call sites (the
engine-settle auto-fit and the manual recenter button) now gate their
duration — `reducedMotion || IS_TEST_MODE ? 0 : 400` — since these are
system-initiated camera flies and an animated 400ms fly is itself a
motion violation on the paths that already zero cooldownTicks to
eliminate motion. Padding unified to 60 across both call sites
(recenter button previously used 40).
3. warmupTicks was untested: pinned 200 in both the reduced-motion test
(KnowledgeGraph3D.test.tsx) and the test-mode determinism test
(KnowledgeGraph3D.testmode.test.tsx), and 0 in a new default-motion-path
test — plus new coverage for the reduced-motion/test-mode instant-fit
gating on the recenter button (the auto-fit path shares the identical
ternary, already exercised by the bbox-stabilization tests under
default motion).
Also amended docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md's
"Camera & motion" section to document both justified deviations (the
bbox-stabilization auto-fit poll + epoch guard, and warmupTicks=200 under
test/reduced-motion mirroring KnowledgeGraph2D's sim.tick(200) precedent),
and replaced the stale #8a9a5b halo color reference with "the app accent
token (--accent, currently #2d8f5c), resolved at mount".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — graph-3d Focused Minimal upgrade

This turns the 3D mode from bare library spheres into the Focused Minimal design: nodeThreeObject groups (matte MeshLambertMaterial sphere + hidden accent halo + always-visible three-spritetext label), a visualsRef registry so hover-focus mutates materials instead of rebuilding geometry, linkColor/linkWidth re-keyed on hover, a recenter button reusing the 2D control's title="Reset view" (which is what globals.css:609 keys on to hide it on the tutor rail), and a bbox-stabilisation rAF poll that drives a one-shot zoomToFit on the first engine settle. The pure-helper extraction into graph3dHelpers.ts is clean, the auto-fit poll's epoch guard and rAF-cancel-on-unmount are correct, and the SSR/testid/click-whitelist contracts are all preserved. Two things I checked and want to save you the time on: the library does own GPU disposal (react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate recursively disposes each group's geometries, materials and the sprite's material.map), and zoomToFit is null-safe on an empty graph — so CodeRabbit's disposal comment is not a real leak. What does need fixing is the hover path's interaction with three-forcegraph's prop-change semantics, plus the new lint suppressions and the merge state.

Findings

P1

[P1] Every hover transition destroys and rebuilds every link meshfrontend/src/components/graph/KnowledgeGraph3D.tsx:326-335

constlinkWidth=React.useCallback((l: object)=>{constlink=lasFG3DLink;constbase=0.4+(link.strength||0)*0.6;if(!hoverId)returnbase;constlit=linkEndId(link.source)===hoverId||linkEndId(link.target)===hoverId;returnlit ? base+0.6 : base;},[hoverId],);

hoverId is React state, so each hover enter/leave re-renders and hands react-force-graph-3d a new linkWidth function identity. react-kapsule pushes any identity-changed prop straight through (react-kapsule.mjs:30-33; linkWidth is a plain prop per react-force-graph-3d.mjs:126-129), and three-forcegraph treats linkWidth as an object-invalidating prop: three-forcegraph.mjs:1199-1201if (state._flushObjects || hasAnyPropChanged(['linkThreeObject','linkThreeObjectExtend','linkWidth'])) state.linkDataMapper.clear();. clear() is digest([]), which scene.remove()s and _deallocate()s (geometry.dispose + material.dispose) every link object and then recreates all of them. These aren't cheap lines either — useCylinder = !!widthAccessor(link) (:1220) is always true here, so each link is a CylinderGeometry mesh. On a few-hundred-edge graph that's a full teardown/rebuild of the link layer on every mouse enter and every mouse leave. This is exactly what docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md:138-142 promised not to do: "Hover mechanics — no per-hover geometry rebuilds … (links are cheap line materials)." Direction: give linkWidth a stable identity that reads hoverRef.current; linkColor's own change already triggers the digest, and the link onUpdateObj re-reads widthAccessor(link) each digest, so widths still update — just without the clear(). (Nothing visually breaks — three-forcegraph.mjs:1483 sets engineRunning = true at the end of every update(), so the next layoutTick repositions the new meshes — but it does mean onEngineStop also re-fires on every hover; the didFitRef guard absorbs that correctly.)

[P1] Two new react-hooks/refs suppressions added for new production codefrontend/eslint-suppressions.json:99-103

"src/components/graph/KnowledgeGraph3D.tsx": {
"react-hooks/refs": {
"count": 2
}
},

The Canopy Engineering Style Guide §7 states the ratchet explicitly — "don't fix the whole backlog to land a change, but never add to it" — and this PR's own plan repeats it at docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md:20: "eslint suppressions are a ratcheted baseline — new code must be clean." The two suppressed violations are real render-phase ref mutations: KnowledgeGraph3D.tsx:173highlightRef.current = highlightId; in the render body, and :179visualsRef.current = new Map(); inside the graphDatauseMemo. The useMemo one is the one with teeth — under React 19 concurrent rendering a render that starts and is then discarded still clears the registry, leaving the committed tree with an empty visualsRef and hover-focus a silent no-op. (The test-file suppressions — react-hooks/globals, react/display-name — are less load-bearing but also new.)

[P1] Branch is CONFLICTING with main, and main landed the #538 WebGL2 gate on the same three files

gh pr view 530 --json mergeable returns "mergeable":"CONFLICTING","mergeStateStatus":"DIRTY". Since the merge-base (ec34bf17), main has changed KnowledgeGraph.tsx (+201: WebGL2 capability probe, ErrorBoundary, graph-crash-fallback), added this header to KnowledgeGraph3D.tsx"#538: NEVER mount this component outside the KnowledgeGraph wrapper — three r163+ throws from the WebGLRenderer constructor when WebGL2 is unavailable" — and replaced the local next/dynamic mock in bothKnowledgeGraph3D.test.tsx and KnowledgeGraph3D.testmode.test.tsx with the shared @/test-utils/mockNextDynamic helper. This PR rewrites all three of those files from the pre-#538 base and still carries the old inline mock (KnowledgeGraph3D.test.tsx:98-114, KnowledgeGraph3D.testmode.test.tsx:36-52), so the conflict resolution has to re-apply main's work by hand rather than take either side wholesale. Worth rebasing before the draft flips to ready — main's shared helper does handle this PR's loader (which resolves to a bare function component, not a module) via typeof mod === "function" ? mod : (mod.default ?? null).

P2

[P2] nodeThreeObject doesn't apply the active focus state to the nodes it (re)buildsfrontend/src/components/graph/KnowledgeGraph3D.tsx:251-286

constsphereMat=newTHREE.MeshLambertMaterial({
color,transparent: true,opacity: NODE_OPACITY,});

The halo correctly reads the live refs (halo.visible = n.id === hoverRef.current || n.id === highlightRef.current), but the sphere is always built at full baseColor/NODE_OPACITY and the label at full opacity. Any nodes/edges identity change re-runs the graphData memo (clearing visualsRef at :179) and makes three-forcegraph rebuild every node object — node objects are keyed by identity and the memo mints fresh {...n} clones. If the pointer is still over a node at that moment (a tutor graph_update refreshing the Learn rail graph, a filter change on /tree), the hovered node keeps its halo but nothing dims: the focus effect renders half-applied until the pointer moves. The [applyFocus, highlightId] re-assert effect can't cover it because it runs at commit, before the library rebuilds. The component test doesn't catch this because it calls nodeThreeObject manually before driving onNodeHover (KnowledgeGraph3D.test.tsx:451-456), which is the opposite order.

[P2] A halo geometry + material is allocated for every node although at most two are ever visiblefrontend/src/components/graph/KnowledgeGraph3D.tsx:256-270

group.add(newTHREE.Mesh(newTHREE.SphereGeometry(r,24,24),sphereMat));// Focus halo: slightly larger translucent accent sphere, hidden// until this node is hovered or is the persistent highlightId.consthalo=newTHREE.Mesh(newTHREE.SphereGeometry(r*1.4,16,16),newTHREE.MeshBasicMaterial({

halo.visible is only ever true for the hovered node and highlightId, so an N-node graph carries N−2 unused SphereGeometry + MeshBasicMaterial pairs for the lifetime of the dataset, re-allocated in full on every refresh. A single shared unit-sphere geometry scaled per node, or lazy creation on first focus, avoids it. Separately, the main sphere went from the library's nodeResolution={16} (pre-patch) to SphereGeometry(r, 24, 24) — 2.25× the triangles per node — which the spec doesn't ask for.

[P2] Each label is rasterised five times at constructionfrontend/src/components/graph/KnowledgeGraph3D.tsx:273-279

constlabel=newSpriteText(n.name);label.textHeight=spec.textHeight;label.fontWeight=spec.fontWeight;label.color=theme.ink;label.fontFace='"JetBrains Mono", monospace';

Every three-spritetext setter re-runs _genCanvas() (measure text → resize canvas → repaint → new CanvasTexture). That's the constructor plus four setters = five full rasterisations and five texture allocations per node, paid again for the whole graph on every dataset refresh. SpriteText's constructor takes (text, textHeight, color), which folds three of the five. Related sizing note: _fontSize is fixed at 90 regardless of textHeight, so each label canvas is roughly 600×100 RGBA — at the spec's stated "few hundred nodes" ceiling that is tens of MB of texture memory. (No leak — _genCanvas disposes the previous map — just wasted work and VRAM.)

P3

[P3] The base and dim link colors are still hardcoded hex while the lit one derives from the themefrontend/src/components/graph/KnowledgeGraph3D.tsx:315,321

if(!hoverId)return`rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;
...
: `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;

138, 131, 114 is #8a8372 = --ink-400 (globals.css:47) — an existing token, not an unnamed value. graph3dHelpers.ts:33-36 says FALLBACK_THEME is "the ONE place that hex is allowed to be hardcoded", and the c65c075 pass fixed only the lit half. Adding a fourth GraphTheme field resolved from --ink-400 would close the loop and match the Canopy design-token rule ("Use the tokens; don't hardcode values"). Low stakes — the spec does sanction "today's rgba(138,131,114,…) family" — but the file's own comment now contradicts itself.

Test-plan checklist — per-item verdict

  1. graph3dHelpers unit tests + rewritten component tests — supported. graph3dHelpers.test.ts covers buildAdjacency, nodeVal/nodeRadius, mixHex, baseNodeColor, labelSpec, resolveGraphTheme; the component test covers node composition, hover dim/restore, halo persistence, linkColor, recenter (both durations), the bbox poll, the frame cap and the epoch guard. Two gaps: the newly exported hexToRgbTriplet has no unit test (only indirect coverage via the component test), and nothing asserts the unmount cancelAnimationFrame even though captureAnimationFrames already spies on it.
  2. full frontend suite, lint, next build — supported by CI (Frontend (lint + tsc + vitest) pass, Workers Builds: frontend-staging pass), with the caveat that lint only passes because of the new suppressions in the P1 finding above.
  3. flock'd E2E cycle: Chapter 1 journeys + oracles green — not verifiable from the PR; there is no E2E job among the checks on feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter #530. Taking it on trust.
  4. manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached) — the code claims hold up (the title="Reset view"globals.css:609 rail-hiding seam is real), but no screenshots are attached to the PR body or to any comment, so "attached" doesn't match the PR as it stands.
  5. fresh 3D screenshots reviewed on /tree + dashboard — artifacts are gitignored, so unverifiable from here. Note that the P1 hover finding wouldn't have shown up as a visual defect (it's allocation churn, not a broken frame), so this doesn't invalidate the pass.

What's good

  • The auto-fit story is genuinely well-engineered: the bbox-stabilisation poll, the MAX_FRAMES safety net, the pollEpochRef invalidation on mid-poll dataset change and the rAF cancel on unmount are all correct, and each one is pinned by a test that would fail if it regressed.
  • Reduced-motion is handled properly rather than performatively — zeroing the zoomToFit duration on both call sites because a system-initiated camera fly is itself motion, and the warmupTicks={200} reasoning mirrors KnowledgeGraph2D's sim.tick(200) precedent instead of inventing a new one.
  • graph3dHelpers.ts is the right shape: pure, three.js-free, unit-testable, and the "always return #rrggbb" color contract is documented where it will actually be read.
  • Every preserved contract really is preserved — sr-only list, all three testids, the onNodeClick id whitelist, and the dynamic(ssr:false) boundary all still hold, and the click-whitelist test now enumerates the full set of library-injected fields.

Verdict: solid design work with one real hover-path performance bug (P1), a ratchet violation (P1) and a rebase (P1) to clear before this leaves draft.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…nder-phase ref writes
Review round on #530.
P1 — linkWidth was a useCallback keyed on the hoverId state, so every
pointer enter AND leave handed react-force-graph-3d a new function
identity. three-forcegraph treats linkWidth as object-invalidating for
links (linkDataMapper.clear() -> digest([]) -> scene.remove() +
_deallocate() of every link mesh, all CylinderGeometry here since the
widths are never zero), so a hover was two full teardowns/rebuilds of
the whole link layer — the exact opposite of the spec's "no per-hover
geometry rebuilds". linkWidth now reads hoverRef with an empty dep
array; widths still follow the hover because linkColor's own identity
change triggers the digest and its onUpdateObj re-reads the width
accessor per link.
P1 — both react-hooks/refs violations were real render-phase ref
mutations. highlightId is now mirrored in an effect (the useRef
initializer covers first mount; kapsule's digest is debounce(fn, 1), so
the library cannot call nodeThreeObject before the commit's effects
have run). The visuals registry is no longer a ref cleared inside a
useMemo — a discarded concurrent render would still have cleared it and
left the committed tree with an empty registry and hover focus a silent
no-op. One memo now owns everything scoped to a dataset: graphData, the
registry, and the shared halo geometry/material.
P2 — nodeThreeObject applies the live focus state to nodes it builds,
so a dataset refresh under an active hover (tutor graph_update, /tree
filter) no longer renders half-focused until the pointer moves. Focus
styling has a single writer (applyVisualState) shared with the focus
pass.
P2 — one unit-sphere halo geometry + material per dataset, scaled per
node, instead of N pairs of which at most two are ever visible; main
sphere back to 16 segments (the library's nodeResolution default) from
24.
P2 — SpriteText labels use the (text, textHeight, color) constructor:
three of the five canvas rasterisations per label were setter-driven.
P3 — the resting and dimmed link rgb are derived from a new
GraphTheme.link resolved from --ink-400 instead of two hand-copied
rgba(138, 131, 114, …) literals, which contradicted FALLBACK_THEME's
"the ONE place that hex is allowed to be hardcoded".
Also re-applies #538's header warning about never mounting this
component outside the KnowledgeGraph wrapper.
…an gaps
Ports #538's work forward by hand so the eventual merge with main is a
no-op on these files: both 3D graph test files now use the shared
@/test-utils/mockNextDynamic helper (copied verbatim from main) instead
of a local inline dynamic mock. The helper renders the resolved
component with createElement rather than calling it as a function, so
the stub's hooks land in their own fiber, and it accepts this
component's loader, which resolves to a bare function component.
Removes every eslint-suppressions.json entry this branch had added, and
the pre-existing @typescript-eslint/no-explicit-any entry too, by
fixing the causes rather than baselining them:
- the react-force-graph-3d stub is a NAMED function inside forwardRef
(react/display-name) that records props in an effect instead of the
render body (react-hooks/globals — Testing Library flushes passive
effects before render/rerender/act return, so tests still read the
props synchronously);
- the capture is a spelled-out CapturedProps type, not
Record<string, any>.
Net effect vs the merge base: one entry removed, none added.
Test-plan gaps:
- the frame-cap test drove the bbox with Math.random() and asserted
only that zoomToFit fired once, so a MAX_FRAMES regression from 60 to
2 would have passed. Monotonic counter now, and the bbox read count is
pinned to the cap.
- captureAnimationFrames already spied on cancelAnimationFrame but
nothing asserted the unmount cleanup. The spy and the frame ids are
exposed and a test covers it.
- new test that linkWidth's identity is stable across a hover while
linkColor's is not, and that the same accessor reports the widened
value.
- new test that nodeThreeObject applies focus to nodes built AFTER
onNodeHover — the real order; the existing tests build first.
- direct unit tests for the newly exported hexToRgbTriplet.
Under `set -e` a failing `make e2e-up` exited the flock'd shell before
the trailing `make e2e-down` could run, leaving a half-started stack
holding the ports. Register `trap "make e2e-down || true" EXIT` first
and drop the explicit teardown.
…e graph
The only path that exercises the new highlightRef mirror effect is a
highlightId change while mounted — first mount is covered by the useRef
initializer, so mutation-testing showed the effect could be deleted
outright with all tests still green. This also pins the effect ORDER:
the mirror is declared before the re-assert effect, and moving it after
makes the re-assert read the stale highlight (verified: all three
mutations now fail).
Byte-identical to main in that region, so the shared-helper adoption
merges cleanly instead of conflicting on comment wording. The 'why an
await import inside a hoisted vi.mock factory' rationale already lives
in the helper's own docblock.
vi.mock calls are hoisted, so the order is behaviour-neutral — but this
block sat inside the exact hunk #538 rewrote on main (the next/dynamic
mock), which made an otherwise clean merge conflict on a pure addition.
Moved into untouched context above.
The plan's pre-review code sketches prescribe the exact five shapes the
review round removed (hover-keyed linkWidth, a visualsRef cleared inside
a useMemo, focus-blind nodeThreeObject, per-node halo pairs, hardcoded
link rgb). Left unmarked, the doc reads as authoritative and invites a
future reader to 'restore' them.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Every hover destroyed and rebuilt every link mesh.linkWidth was keyed on hoverId state, so each enter/leave handed the library a new function identity, and three-forcegraph treats linkWidth as object-invalidating → linkDataMapper.clear() → full teardown/rebuild of every CylinderGeometry link. That is exactly what the spec promised not to do. linkWidth now has a stable identity reading hoverRef.current; widths still track hover because linkColor already triggers the digest and onUpdateObj re-reads the accessor. Mutation-verified: restoring [hoverId] fails exactly one test.
  • Two new react-hooks/refs suppressions were added for new code, against the ratchet rule this PR's own plan restates. Both fixed at the source — the render-body ref write moved into an effect, and visualsRef replaced by a dataset-scoped epoch object so a discarded concurrent render cannot clobber the committed registry. The test-file suppressions were fixed properly too, not baselined. Suppressions went 29 files/193 → 28 files/187, deletion-only.
  • The branch was CONFLICTING with main (Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 landed the WebGL2 gate on the same three files). Ported forward by hand — no rebase: adopted @/test-utils/mockNextDynamic byte-identical to main, re-applied the Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 header, and shrank two more noise conflicts. git merge-tree --write-tree HEAD main now reports 0 conflicts in all three source files; the only residual is the generated eslint-suppressions.json baseline.

Minor / nits

nodeThreeObject applies the live focus state to nodes it rebuilds, so a mid-hover dataset refresh no longer renders focus half-applied · shared halo geometry instead of one per node, sphere segments back to 16 · SpriteText built via its constructor args, cutting five rasterisations per label · base/dim link colours derived from the theme rather than a hardcoded --ink-400 · e2e teardown trap in the plan doc · deterministic frame-cap test that pins MAX_FRAMES · unmount cancelAnimationFrame test · hexToRgbTriplet unit test.

Not done — CodeRabbit's disposal finding is not valid

react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate, which already disposes each group's geometries, materials and the sprite's material.map. Adding manual disposal would be redundant.

Verificationtsc clean · eslint 0 errors · 604 frontend tests pass · merge conflicts vs main: 3 source files → 0

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

…-minimal
# Conflicts:
#	frontend/eslint-suppressions.json
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter by AndresL230 · Pull Request #530 · SaplingLearn/Sapling · GitHub
Skip to content

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter - #530

Draft
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal
Draft

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter#530
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 3D graph mode grows from bare spheres into the approved Focused Minimal design (spec: docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md)
  • matte spheres + always-visible SpriteText labels (roots bold), unexplored tier washed toward warm gray
  • hover-focus: 1-hop neighborhood lit, rest dimmed; sage halo on hovered + tutor-highlighted nodes
  • ⌖ recenter (zoomToFit) sharing the 2D control's title/testid (auto-hidden on tutor rail)
  • new dep three-spritetext, client-chunk only; 3D stays opt-in behind the existing toggle
  • camera auto-fit on engine settle (bbox-stabilization poll + warmupTicks under test/reduced-motion) — 3 review-gated fix rounds, root-caused in a live browser

Test plan

  • graph3dHelpers unit tests + rewritten KnowledgeGraph3D component tests
  • full frontend suite, lint, next build
  • flock'd E2E cycle: Chapter 1 journeys + oracles green
  • manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached)
  • fresh 3D screenshots reviewed on /tree + dashboard (visual-pass artifacts in .superpowers/sdd/, gitignored)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a focused visual treatment for the opt-in 3D knowledge graph, including matte nodes, halos, and always-visible labels.
    • Hovering over a node now highlights its immediate connections while dimming unrelated nodes and links.
    • Added a “Reset view” control to recenter and fit the graph.
    • Improved automatic camera fitting when graph data changes, with reduced-motion support.
  • Tests

    • Expanded coverage for rendering, highlighting, camera fitting, accessibility behavior, and visual styling.

AndresL230and others added 9 commits August 5, 2026 16:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght halo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… run warmup ticks under test/reduced-motion
Round 2 on d2e59a9's camera-framing fix, which was unit-correct but had no
runtime effect. Confirmed root cause via node_modules/3d-force-graph source
plus live frame-by-frame logging against a running dev server:
1. onEngineStop fires synchronously *before* tickFrame's "update node
positions" step writes that tick's x/y/z onto each node's Three.js
object (both run inside the same synchronous layoutTick() call, stop
callback first) — so fitting immediately (or even one rAF later) measures
a stale/near-empty bbox and ends up far too close once the real,
spread-out layout lands moments later. Fix: poll on requestAnimationFrame
until two consecutive getGraphBbox() reads agree (frame-capped safety
net) before calling zoomToFit.
2. Under cooldownTicks={0} (test/reduced-motion mode), warmupTicks was left
at its default of 0 too, so the 3D force simulation never actually ran —
nodes stayed at d3-force-3d's raw pre-simulation positions, an
overlapping cluster. KnowledgeGraph2D already solves this the same way
for its own reduced-motion path (`sim.alpha(1).tick(200).alpha(0).stop()`);
mirrored it here via `warmupTicks={reducedMotion || IS_TEST_MODE ? 200 : 0}`.
Verified visually against a dev server (:3010) proxying the held-up E2E
stack's backend: before, /tree in 3D was a tiny illegible clump; after, four
well-separated, readable node clusters filling most of the canvas, matching
the 2D layout's structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cel rAF on unmount
Round 3 re-review of the round-2 camera-framing fix (mechanism, determinism,
and tests independently verified) flagged one new Important issue and a
hygiene nit introduced by the async polling window itself:
1. The bbox-stabilization poll's closure never re-checked that it still
belonged to the current dataset. If nodes/edges changed while an old
poll was mid-flight (up to MAX_FRAMES/~1s wide), the stale closure kept
polling the live instance and could fire zoomToFit(400, 60) after —
clobbering — the fresh poll's correct fit for the new dataset. Fixed
with an epoch guard: pollEpochRef increments alongside didFitRef.current
= false in the [nodes, edges] reset effect; handleEngineStop captures
the epoch at poll start, and every scheduled frame callback bails
immediately if pollEpochRef.current no longer matches.
2. Hygiene: no cancelAnimationFrame cleanup tied the poll to component
lifetime, so an unmount mid-poll could leave up to ~60 dangling no-op
rAF callbacks. Now tracks the current rAF id in pollRafIdRef and cancels
it in an unmount-only effect.
TDD: wrote the new test first against the pre-fix code (stashed the
implementation), confirmed it failed exactly as described (stale poll
fires zoomToFit(400, 60) for the old dataset), then restored the
implementation and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99e248db-5120-4f64-8d7e-40dfec5218fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The opt-in 3D knowledge graph now uses custom spheres, halos, and labels. Shared helpers provide sizing, colors, themes, and adjacency. Hover interactions focus one-hop neighborhoods, links dim accordingly, and a reset control refits the camera.

Changes

Focused Minimal 3D graph

Layer / File(s)Summary
Styling helpers and rendering contract
docs/superpowers/..., frontend/src/components/graph/graph3dHelpers.ts, frontend/src/components/graph/graph3dHelpers.test.ts
Adds pure helpers for themes, colors, adjacency, node sizing, and label specifications with deterministic tests.
Custom node visuals
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/package.json, frontend/eslint-suppressions.json, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Renders matte spheres, focus halos, and SpriteText labels through custom Three.js objects and validates their properties.
Hover focus and link styling
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Tracks hovered nodes, dims non-neighbors and links, preserves persistent highlight halos, and supports string or resolved link endpoints.
Camera fitting and reset control
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx, frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
Adds ref-forwarded zoomToFit, stable-bounds auto-fit polling, stale-poll cancellation, warmup ticks, and the graph-zoom-reset button.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Pointer
participant KnowledgeGraph3D
participant VisualRegistry
participant ForceGraph3D
Pointer->>KnowledgeGraph3D: Hover a graph node
KnowledgeGraph3D->>VisualRegistry: Update node, label, and halo opacity
KnowledgeGraph3D->>ForceGraph3D: Recompute link color and width
ForceGraph3D-->>KnowledgeGraph3D: Render focused neighborhood
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 62.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main 3D graph changes: labels, hover focus, and recentering.
Description check✅ PassedThe description clearly explains the upgrade and includes comprehensive changes and testing details, despite using different section headings than the template.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3d-graph-focused-minimal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging987bfd7Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:09 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
frontend/src/components/graph/KnowledgeGraph3D.test.tsx (2)

553-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the frame-cap test deterministic and pin the cap value.

Line 557 uses Math.random() to produce a bbox that never repeats. The sibling test at Line 588 uses a monotonic counter for the same purpose, which is deterministic.

The test also asserts only that zoomToFit fired once. It does not assert how many frames elapsed. If MAX_FRAMES regressed from 60 to 2, this test would still pass. Assert the read count to pin the cap.

🧪 Proposed deterministic frame-cap test
- getGraphBboxMock.mockImplementation(() => ({ x: [0, Math.random()], y: [0, 0], z: [0, 0] }));+ let capCounter = 0;+ getGraphBboxMock.mockImplementation(() => ({ x: [0, ++capCounter], y: [0, 0], z: [0, 0] }));
 expect(zoomToFitSpy).toHaveBeenCalledTimes(1);
expect(zoomToFitSpy).toHaveBeenCalledWith(400, 60);
+ // Pins MAX_FRAMES: the poll reads the bbox once per frame up to the cap.+ expect(getGraphBboxMock).toHaveBeenCalledTimes(60);
expect(raf.queue.length).toBe(0); // no frame scheduled after the cap-triggered fit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 553 -
573, Make the frame-cap test deterministic by replacing Math.random() in
getGraphBboxMock with a monotonic counter so each bounding-box read differs
predictably. Track the number of bbox reads and assert it equals the configured
MAX_FRAMES value (60), while preserving the existing single fit and empty RAF
queue assertions.

171-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the unmount cancellation path.

captureAnimationFrames already spies on cancelAnimationFrame at Line 178, and the spy is exposed only through restore(). No test asserts that the unmount cleanup effect in KnowledgeGraph3D.tsx (Lines 202-206) cancels the pending frame. The PR lists that cleanup as a deliverable, so a regression that drops the effect would pass the current suite.

Return the cancelAnimationFrame spy from the helper, then assert it in a new test.

🧪 Proposed helper change and test
 const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
return {
queue,
+ caf,
it("cancels the in-flight bbox poll on unmount",()=>{getGraphBboxMock.mockReturnValue({x: [0,1],y: [0,1],z: [0,1]});constraf=captureAnimationFrames();const{ unmount }=render(<KnowledgeGraph3Dnodes={[makeNode()]}edges={[]}/>);act(()=>(lastProps!.onEngineStopas()=>void)());expect(raf.queue.length).toBe(1);unmount();expect(raf.caf).toHaveBeenCalledTimes(1);raf.restore();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 171 -
193, Update captureAnimationFrames to expose the cancelAnimationFrame spy, then
add a KnowledgeGraph3D unmount test that starts the bbox polling frame through
onEngineStop, unmounts the component, and asserts the cancellation spy was
called. Restore the animation-frame spies after the assertion.
frontend/src/components/graph/KnowledgeGraph3D.tsx (1)

303-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the link colors from the resolved theme instead of hardcoded RGB literals.

Lines 306, 311, and 312 hardcode rgba(138, 131, 114, ...) and rgba(138, 154, 91, ...). 138, 154, 91 is #8a9a5b, which is FALLBACK_THEME.accent. Nodes and halos use resolveGraphTheme(), which reads --accent and --ink-200 from CSS. If a deployment or an alternate color scheme overrides those variables, node colors follow the theme and link colors do not.

Convert theme.accent and theme.dim to RGB components once, then build the rgba strings from them.

🎨 Proposed theme-derived link colors
+ // Link colors follow the same resolved theme as nodes and halos.+ const linkRgb = React.useMemo(() => {+ const rgb = (hex: string) => {+ const c = new THREE.Color(hex);+ return `${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}`;+ };+ return { lit: rgb(theme.accent), dim: rgb(theme.dim) };+ }, [theme]);+
const linkColor = React.useCallback(
(l: object) => {
const link = l as FG3DLink;
- if (!hoverId) return `rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;+ if (!hoverId) return `rgba(${linkRgb.dim}, ${BASE_LINK_ALPHA})`;
const lit = linkEndId(link.source) === hoverId || linkEndId(link.target) === hoverId;
- // Lit links take the sage accent (rgb of `#8a9a5b`); dimmed links- // fade to near-invisible warm gray.+ // Lit links take the resolved accent; dimmed links fade to the+ // resolved dim token.
return lit
- ? `rgba(138, 154, 91, ${LIT_LINK_ALPHA})`- : `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;+ ? `rgba(${linkRgb.lit}, ${LIT_LINK_ALPHA})`+ : `rgba(${linkRgb.dim}, ${DIM_LINK_ALPHA})`;
},
- [hoverId],+ [hoverId, linkRgb],
);

If you accept this, update the literal expectations in frontend/src/components/graph/KnowledgeGraph3D.test.tsx at Lines 471, 478, 479, and 481-483 to build the same strings from FALLBACK_THEME.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 303 - 315,
Update the linkColor callback to use the resolved graph theme’s accent and dim
colors instead of hardcoded RGB values, converting theme.accent and theme.dim to
RGB components once and using them for the rgba strings. Preserve the existing
hover, lit, and dim alpha behavior, and update the corresponding
KnowledgeGraph3D.test.tsx expectations to construct the same values from
FALLBACK_THEME.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md`:
- Around line 937-947: Update the shell command around the E2E stack startup to
register an EXIT trap that runs make e2e-down before make e2e-up executes.
Preserve the existing test return-code collection and ensure teardown runs
exactly once, including when make e2e-up fails under set -e.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 243-285: Update nodeThreeObject to store the returned group
alongside sphereMat, halo, and label in visualsRef.current. Before replacing the
registry when the dataset changes and during component unmount, iterate the
existing entries and dispose both SphereGeometry instances, their materials, the
SpriteText canvas texture/material resources, and each group’s children; then
clear the registry before creating the new Map.
---
Nitpick comments:
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx`:
- Around line 553-573: Make the frame-cap test deterministic by replacing
Math.random() in getGraphBboxMock with a monotonic counter so each bounding-box
read differs predictably. Track the number of bbox reads and assert it equals
the configured MAX_FRAMES value (60), while preserving the existing single fit
and empty RAF queue assertions.
- Around line 171-193: Update captureAnimationFrames to expose the
cancelAnimationFrame spy, then add a KnowledgeGraph3D unmount test that starts
the bbox polling frame through onEngineStop, unmounts the component, and asserts
the cancellation spy was called. Restore the animation-frame spies after the
assertion.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 303-315: Update the linkColor callback to use the resolved graph
theme’s accent and dim colors instead of hardcoded RGB values, converting
theme.accent and theme.dim to RGB components once and using them for the rgba
strings. Preserve the existing hover, lit, and dim alpha behavior, and update
the corresponding KnowledgeGraph3D.test.tsx expectations to construct the same
values from FALLBACK_THEME.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d36f84-0079-4d42-a82d-ce69d01fc611

📥 Commits

Reviewing files that changed from the base of the PR and between 458ddb2 and c3ed797.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md
  • docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md
  • frontend/eslint-suppressions.json
  • frontend/package.json
  • frontend/src/components/graph/KnowledgeGraph3D.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/graph3dHelpers.test.ts
  • frontend/src/components/graph/graph3dHelpers.ts

Comment on lines +937 to +947
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Ensure teardown runs when make e2e-up fails.

set -e exits before make e2e-down if make e2e-up fails. This can leave a partially started E2E stack running. Register teardown with an EXIT trap before startup.

Proposed fix
 set -e
+ trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
- make e2e-down
exit $rc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
exit $rc
'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md` around lines
937 - 947, Update the shell command around the E2E stack startup to register an
EXIT trap that runs make e2e-down before make e2e-up executes. Preserve the
existing test return-code collection and ensure teardown runs exactly once,
including when make e2e-up fails under set -e.

Comment on lines +243 to +285
const nodeThreeObject = React.useCallback(
(raw: object) => {
const n = raw as FG3DNode;
const r = nodeRadius(n);
const color = baseNodeColor(n, theme);
const group = new THREE.Group();

const sphereMat = new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity: NODE_OPACITY,
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24), sphereMat));

// Focus halo: slightly larger translucent accent sphere, hidden
// until this node is hovered or is the persistent highlightId.
const halo = new THREE.Mesh(
new THREE.SphereGeometry(r * 1.4, 16, 16),
new THREE.MeshBasicMaterial({
color: theme.accent,
transparent: true,
opacity: 0.28,
depthWrite: false,
}),
);
halo.visible = n.id === hoverRef.current || n.id === highlightRef.current;
group.add(halo);

const spec = labelSpec(n);
const label = new SpriteText(n.name);
label.textHeight = spec.textHeight;
label.fontWeight = spec.fontWeight;
label.color = theme.ink;
label.fontFace = '"JetBrains Mono", monospace';
label.material.transparent = true;
label.position.set(0, -(r + spec.textHeight + 1.5), 0);
group.add(label);

visualsRef.current.set(n.id, { sphereMat, label, halo, baseColor: color });
return group;
},
[theme],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the repository already disposes Three.js resources anywhere, and whether SpriteText exposes a dispose path.set -euo pipefail
rg -n --type=ts --type=tsx -C3 '\.dispose\s*\(' frontend/src ||echo"no dispose() calls found in frontend/src"
fd -t d 'three-spritetext' frontend/node_modules --max-depth 3 --exec sh -c 'ls "$1"; sed -n "1,80p" "$1/dist/three-spritetext.d.ts" 2>/dev/null' _ {}

Repository: SaplingLearn/Sapling

Length of output: 340


🏁 Script executed:

#!/bin/bash0bashset -euo pipefail
printf'Tracked KnowledgeGraph files:\n'
fd -i 'KnowledgeGraph3D\.(ts|tsx)$'.||trueprintf'\nGit file list for likely front-end components:\n'
git ls-files | rg 'frontend/src/(components|.*Graph|.*three|package\.json)$|componentst'printf'\nSearch dispose across tracked source files:\n'
git ls-files | rg '\.(ts|tsx)$'| xargs -r rg -n -- '-- dispose\(' or true

Repository: SaplingLearn/Sapling

Length of output: 289


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'File line count:\n'
wc -l "$FILE"printf'\nRelevant section 160-300:\n'
sed -n '160,300p'"$FILE"| nl -ba -v160
printf'\nSearch for three-spritetext references and package manifests:\n'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo""$FILE"||true
rg -n "three-spritetext|react-three-fiber|`@react-three`" frontend -g 'package.json' -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock'||true

Repository: SaplingLearn/Sapling

Length of output: 298


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'Relevant section 160-300:\n'
sed -n '160,300p'"$FILE"| awk '{printf "%5d %s\n", NR+159, $0}'printf'\nImports and dependency references:\n'
sed -n '1,80p'"$FILE"| awk '{printf "%5d %s\n", NR, $0}'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo|useEffect|React""$FILE"||trueprintf'\nDependency files:\n'forfin package.json frontend/package.json frontend/package-lock.json frontend/pnpm-lock.yaml frontend/yarn.lock frontend/package.json;doif [ -f"$f" ];thenprintf'\n--- %s ---\n'"$f"
rg -n "three-spritetext|react-three-fiber|`@react-three`|three""$f"||truefidoneprintf'\nSearch disposed resource handling across tracked source files:\n'
git ls-files | xargs -r rg -n -- '-- dispose\s*\('| rg '\.(ts|tsx)$'||true

Repository: SaplingLearn/Sapling

Length of output: 13950


🌐 Web query:

three-spritetext v1.10.0 source canvas dispose texture code

💡 Result:

In three-spritetext v1.10.0, the process of disposing of the previous texture when updating the canvas is handled within the library's internal update logic. When the text content or styling changes, the library generates a new canvas and updates the sprite's material. The disposal logic is implemented as follows: // Inject canvas into sprite if (this.material.map) this.material.map.dispose; // gc previous texture var texture = this.material.map = new THREE.Texture(canvas); texture.minFilter = THREE.LinearFilter; texture.colorSpace = THREE.SRGBColorSpace; By calling this.material.map.dispose, the library ensures that the old texture is properly removed from GPU memory before assigning the new texture created from the updated canvas [1]. This practice is essential for preventing memory leaks when frequently updating sprite text [1][2].

Citations:


Dispose every node visual object when the dataset changes.

nodeThreeObject creates per-node Three.js resources: the returned group, two meshes with SphereGeometry, two materials, and a SpriteText with a canvas-backed Texture. Line 178 reassigns the registry to a new Map on a new dataset, so those previous objects become unreachable while their GPU buffers remain allocated.

Track the complete visual entry in visualsRef.current, including the group, then dispose the geometries, textures, and materials before replacing the registry and on component unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 243 - 285,
Update nodeThreeObject to store the returned group alongside sphereMat, halo,
and label in visualsRef.current. Before replacing the registry when the dataset
changes and during component unmount, iterate the existing entries and dispose
both SphereGeometry instances, their materials, the SpriteText canvas
texture/material resources, and each group’s children; then clear the registry
before creating the new Map.

…ion instant fit, warmupTicks pins
Three Important findings from the whole-branch final review, plus a doc
amendment, fixed in one wave:
1. Accent drift, one color one source: FALLBACK_THEME.accent was still the
retired sage (#8a9a5b) while globals.css's --accent is now the brighter
forest (#2d8f5c) — so the focus halo (theme.accent) rendered forest
while its own lit edges (hardcoded sage rgb) rendered sage. Corrected
FALLBACK_THEME.accent to #2d8f5c and added hexToRgbTriplet(), a small
pure helper next to resolveGraphTheme, so linkColor derives the lit-link
rgba from theme.accent instead of a second hardcoded copy that can
drift.
2. Reduced-motion camera animation: both zoomToFit call sites (the
engine-settle auto-fit and the manual recenter button) now gate their
duration — `reducedMotion || IS_TEST_MODE ? 0 : 400` — since these are
system-initiated camera flies and an animated 400ms fly is itself a
motion violation on the paths that already zero cooldownTicks to
eliminate motion. Padding unified to 60 across both call sites
(recenter button previously used 40).
3. warmupTicks was untested: pinned 200 in both the reduced-motion test
(KnowledgeGraph3D.test.tsx) and the test-mode determinism test
(KnowledgeGraph3D.testmode.test.tsx), and 0 in a new default-motion-path
test — plus new coverage for the reduced-motion/test-mode instant-fit
gating on the recenter button (the auto-fit path shares the identical
ternary, already exercised by the bbox-stabilization tests under
default motion).
Also amended docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md's
"Camera & motion" section to document both justified deviations (the
bbox-stabilization auto-fit poll + epoch guard, and warmupTicks=200 under
test/reduced-motion mirroring KnowledgeGraph2D's sim.tick(200) precedent),
and replaced the stale #8a9a5b halo color reference with "the app accent
token (--accent, currently #2d8f5c), resolved at mount".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — graph-3d Focused Minimal upgrade

This turns the 3D mode from bare library spheres into the Focused Minimal design: nodeThreeObject groups (matte MeshLambertMaterial sphere + hidden accent halo + always-visible three-spritetext label), a visualsRef registry so hover-focus mutates materials instead of rebuilding geometry, linkColor/linkWidth re-keyed on hover, a recenter button reusing the 2D control's title="Reset view" (which is what globals.css:609 keys on to hide it on the tutor rail), and a bbox-stabilisation rAF poll that drives a one-shot zoomToFit on the first engine settle. The pure-helper extraction into graph3dHelpers.ts is clean, the auto-fit poll's epoch guard and rAF-cancel-on-unmount are correct, and the SSR/testid/click-whitelist contracts are all preserved. Two things I checked and want to save you the time on: the library does own GPU disposal (react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate recursively disposes each group's geometries, materials and the sprite's material.map), and zoomToFit is null-safe on an empty graph — so CodeRabbit's disposal comment is not a real leak. What does need fixing is the hover path's interaction with three-forcegraph's prop-change semantics, plus the new lint suppressions and the merge state.

Findings

P1

[P1] Every hover transition destroys and rebuilds every link meshfrontend/src/components/graph/KnowledgeGraph3D.tsx:326-335

constlinkWidth=React.useCallback((l: object)=>{constlink=lasFG3DLink;constbase=0.4+(link.strength||0)*0.6;if(!hoverId)returnbase;constlit=linkEndId(link.source)===hoverId||linkEndId(link.target)===hoverId;returnlit ? base+0.6 : base;},[hoverId],);

hoverId is React state, so each hover enter/leave re-renders and hands react-force-graph-3d a new linkWidth function identity. react-kapsule pushes any identity-changed prop straight through (react-kapsule.mjs:30-33; linkWidth is a plain prop per react-force-graph-3d.mjs:126-129), and three-forcegraph treats linkWidth as an object-invalidating prop: three-forcegraph.mjs:1199-1201if (state._flushObjects || hasAnyPropChanged(['linkThreeObject','linkThreeObjectExtend','linkWidth'])) state.linkDataMapper.clear();. clear() is digest([]), which scene.remove()s and _deallocate()s (geometry.dispose + material.dispose) every link object and then recreates all of them. These aren't cheap lines either — useCylinder = !!widthAccessor(link) (:1220) is always true here, so each link is a CylinderGeometry mesh. On a few-hundred-edge graph that's a full teardown/rebuild of the link layer on every mouse enter and every mouse leave. This is exactly what docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md:138-142 promised not to do: "Hover mechanics — no per-hover geometry rebuilds … (links are cheap line materials)." Direction: give linkWidth a stable identity that reads hoverRef.current; linkColor's own change already triggers the digest, and the link onUpdateObj re-reads widthAccessor(link) each digest, so widths still update — just without the clear(). (Nothing visually breaks — three-forcegraph.mjs:1483 sets engineRunning = true at the end of every update(), so the next layoutTick repositions the new meshes — but it does mean onEngineStop also re-fires on every hover; the didFitRef guard absorbs that correctly.)

[P1] Two new react-hooks/refs suppressions added for new production codefrontend/eslint-suppressions.json:99-103

"src/components/graph/KnowledgeGraph3D.tsx": {
"react-hooks/refs": {
"count": 2
}
},

The Canopy Engineering Style Guide §7 states the ratchet explicitly — "don't fix the whole backlog to land a change, but never add to it" — and this PR's own plan repeats it at docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md:20: "eslint suppressions are a ratcheted baseline — new code must be clean." The two suppressed violations are real render-phase ref mutations: KnowledgeGraph3D.tsx:173highlightRef.current = highlightId; in the render body, and :179visualsRef.current = new Map(); inside the graphDatauseMemo. The useMemo one is the one with teeth — under React 19 concurrent rendering a render that starts and is then discarded still clears the registry, leaving the committed tree with an empty visualsRef and hover-focus a silent no-op. (The test-file suppressions — react-hooks/globals, react/display-name — are less load-bearing but also new.)

[P1] Branch is CONFLICTING with main, and main landed the #538 WebGL2 gate on the same three files

gh pr view 530 --json mergeable returns "mergeable":"CONFLICTING","mergeStateStatus":"DIRTY". Since the merge-base (ec34bf17), main has changed KnowledgeGraph.tsx (+201: WebGL2 capability probe, ErrorBoundary, graph-crash-fallback), added this header to KnowledgeGraph3D.tsx"#538: NEVER mount this component outside the KnowledgeGraph wrapper — three r163+ throws from the WebGLRenderer constructor when WebGL2 is unavailable" — and replaced the local next/dynamic mock in bothKnowledgeGraph3D.test.tsx and KnowledgeGraph3D.testmode.test.tsx with the shared @/test-utils/mockNextDynamic helper. This PR rewrites all three of those files from the pre-#538 base and still carries the old inline mock (KnowledgeGraph3D.test.tsx:98-114, KnowledgeGraph3D.testmode.test.tsx:36-52), so the conflict resolution has to re-apply main's work by hand rather than take either side wholesale. Worth rebasing before the draft flips to ready — main's shared helper does handle this PR's loader (which resolves to a bare function component, not a module) via typeof mod === "function" ? mod : (mod.default ?? null).

P2

[P2] nodeThreeObject doesn't apply the active focus state to the nodes it (re)buildsfrontend/src/components/graph/KnowledgeGraph3D.tsx:251-286

constsphereMat=newTHREE.MeshLambertMaterial({
color,transparent: true,opacity: NODE_OPACITY,});

The halo correctly reads the live refs (halo.visible = n.id === hoverRef.current || n.id === highlightRef.current), but the sphere is always built at full baseColor/NODE_OPACITY and the label at full opacity. Any nodes/edges identity change re-runs the graphData memo (clearing visualsRef at :179) and makes three-forcegraph rebuild every node object — node objects are keyed by identity and the memo mints fresh {...n} clones. If the pointer is still over a node at that moment (a tutor graph_update refreshing the Learn rail graph, a filter change on /tree), the hovered node keeps its halo but nothing dims: the focus effect renders half-applied until the pointer moves. The [applyFocus, highlightId] re-assert effect can't cover it because it runs at commit, before the library rebuilds. The component test doesn't catch this because it calls nodeThreeObject manually before driving onNodeHover (KnowledgeGraph3D.test.tsx:451-456), which is the opposite order.

[P2] A halo geometry + material is allocated for every node although at most two are ever visiblefrontend/src/components/graph/KnowledgeGraph3D.tsx:256-270

group.add(newTHREE.Mesh(newTHREE.SphereGeometry(r,24,24),sphereMat));// Focus halo: slightly larger translucent accent sphere, hidden// until this node is hovered or is the persistent highlightId.consthalo=newTHREE.Mesh(newTHREE.SphereGeometry(r*1.4,16,16),newTHREE.MeshBasicMaterial({

halo.visible is only ever true for the hovered node and highlightId, so an N-node graph carries N−2 unused SphereGeometry + MeshBasicMaterial pairs for the lifetime of the dataset, re-allocated in full on every refresh. A single shared unit-sphere geometry scaled per node, or lazy creation on first focus, avoids it. Separately, the main sphere went from the library's nodeResolution={16} (pre-patch) to SphereGeometry(r, 24, 24) — 2.25× the triangles per node — which the spec doesn't ask for.

[P2] Each label is rasterised five times at constructionfrontend/src/components/graph/KnowledgeGraph3D.tsx:273-279

constlabel=newSpriteText(n.name);label.textHeight=spec.textHeight;label.fontWeight=spec.fontWeight;label.color=theme.ink;label.fontFace='"JetBrains Mono", monospace';

Every three-spritetext setter re-runs _genCanvas() (measure text → resize canvas → repaint → new CanvasTexture). That's the constructor plus four setters = five full rasterisations and five texture allocations per node, paid again for the whole graph on every dataset refresh. SpriteText's constructor takes (text, textHeight, color), which folds three of the five. Related sizing note: _fontSize is fixed at 90 regardless of textHeight, so each label canvas is roughly 600×100 RGBA — at the spec's stated "few hundred nodes" ceiling that is tens of MB of texture memory. (No leak — _genCanvas disposes the previous map — just wasted work and VRAM.)

P3

[P3] The base and dim link colors are still hardcoded hex while the lit one derives from the themefrontend/src/components/graph/KnowledgeGraph3D.tsx:315,321

if(!hoverId)return`rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;
...
: `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;

138, 131, 114 is #8a8372 = --ink-400 (globals.css:47) — an existing token, not an unnamed value. graph3dHelpers.ts:33-36 says FALLBACK_THEME is "the ONE place that hex is allowed to be hardcoded", and the c65c075 pass fixed only the lit half. Adding a fourth GraphTheme field resolved from --ink-400 would close the loop and match the Canopy design-token rule ("Use the tokens; don't hardcode values"). Low stakes — the spec does sanction "today's rgba(138,131,114,…) family" — but the file's own comment now contradicts itself.

Test-plan checklist — per-item verdict

  1. graph3dHelpers unit tests + rewritten component tests — supported. graph3dHelpers.test.ts covers buildAdjacency, nodeVal/nodeRadius, mixHex, baseNodeColor, labelSpec, resolveGraphTheme; the component test covers node composition, hover dim/restore, halo persistence, linkColor, recenter (both durations), the bbox poll, the frame cap and the epoch guard. Two gaps: the newly exported hexToRgbTriplet has no unit test (only indirect coverage via the component test), and nothing asserts the unmount cancelAnimationFrame even though captureAnimationFrames already spies on it.
  2. full frontend suite, lint, next build — supported by CI (Frontend (lint + tsc + vitest) pass, Workers Builds: frontend-staging pass), with the caveat that lint only passes because of the new suppressions in the P1 finding above.
  3. flock'd E2E cycle: Chapter 1 journeys + oracles green — not verifiable from the PR; there is no E2E job among the checks on feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter #530. Taking it on trust.
  4. manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached) — the code claims hold up (the title="Reset view"globals.css:609 rail-hiding seam is real), but no screenshots are attached to the PR body or to any comment, so "attached" doesn't match the PR as it stands.
  5. fresh 3D screenshots reviewed on /tree + dashboard — artifacts are gitignored, so unverifiable from here. Note that the P1 hover finding wouldn't have shown up as a visual defect (it's allocation churn, not a broken frame), so this doesn't invalidate the pass.

What's good

  • The auto-fit story is genuinely well-engineered: the bbox-stabilisation poll, the MAX_FRAMES safety net, the pollEpochRef invalidation on mid-poll dataset change and the rAF cancel on unmount are all correct, and each one is pinned by a test that would fail if it regressed.
  • Reduced-motion is handled properly rather than performatively — zeroing the zoomToFit duration on both call sites because a system-initiated camera fly is itself motion, and the warmupTicks={200} reasoning mirrors KnowledgeGraph2D's sim.tick(200) precedent instead of inventing a new one.
  • graph3dHelpers.ts is the right shape: pure, three.js-free, unit-testable, and the "always return #rrggbb" color contract is documented where it will actually be read.
  • Every preserved contract really is preserved — sr-only list, all three testids, the onNodeClick id whitelist, and the dynamic(ssr:false) boundary all still hold, and the click-whitelist test now enumerates the full set of library-injected fields.

Verdict: solid design work with one real hover-path performance bug (P1), a ratchet violation (P1) and a rebase (P1) to clear before this leaves draft.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…nder-phase ref writes
Review round on #530.
P1 — linkWidth was a useCallback keyed on the hoverId state, so every
pointer enter AND leave handed react-force-graph-3d a new function
identity. three-forcegraph treats linkWidth as object-invalidating for
links (linkDataMapper.clear() -> digest([]) -> scene.remove() +
_deallocate() of every link mesh, all CylinderGeometry here since the
widths are never zero), so a hover was two full teardowns/rebuilds of
the whole link layer — the exact opposite of the spec's "no per-hover
geometry rebuilds". linkWidth now reads hoverRef with an empty dep
array; widths still follow the hover because linkColor's own identity
change triggers the digest and its onUpdateObj re-reads the width
accessor per link.
P1 — both react-hooks/refs violations were real render-phase ref
mutations. highlightId is now mirrored in an effect (the useRef
initializer covers first mount; kapsule's digest is debounce(fn, 1), so
the library cannot call nodeThreeObject before the commit's effects
have run). The visuals registry is no longer a ref cleared inside a
useMemo — a discarded concurrent render would still have cleared it and
left the committed tree with an empty registry and hover focus a silent
no-op. One memo now owns everything scoped to a dataset: graphData, the
registry, and the shared halo geometry/material.
P2 — nodeThreeObject applies the live focus state to nodes it builds,
so a dataset refresh under an active hover (tutor graph_update, /tree
filter) no longer renders half-focused until the pointer moves. Focus
styling has a single writer (applyVisualState) shared with the focus
pass.
P2 — one unit-sphere halo geometry + material per dataset, scaled per
node, instead of N pairs of which at most two are ever visible; main
sphere back to 16 segments (the library's nodeResolution default) from
24.
P2 — SpriteText labels use the (text, textHeight, color) constructor:
three of the five canvas rasterisations per label were setter-driven.
P3 — the resting and dimmed link rgb are derived from a new
GraphTheme.link resolved from --ink-400 instead of two hand-copied
rgba(138, 131, 114, …) literals, which contradicted FALLBACK_THEME's
"the ONE place that hex is allowed to be hardcoded".
Also re-applies #538's header warning about never mounting this
component outside the KnowledgeGraph wrapper.
…an gaps
Ports #538's work forward by hand so the eventual merge with main is a
no-op on these files: both 3D graph test files now use the shared
@/test-utils/mockNextDynamic helper (copied verbatim from main) instead
of a local inline dynamic mock. The helper renders the resolved
component with createElement rather than calling it as a function, so
the stub's hooks land in their own fiber, and it accepts this
component's loader, which resolves to a bare function component.
Removes every eslint-suppressions.json entry this branch had added, and
the pre-existing @typescript-eslint/no-explicit-any entry too, by
fixing the causes rather than baselining them:
- the react-force-graph-3d stub is a NAMED function inside forwardRef
(react/display-name) that records props in an effect instead of the
render body (react-hooks/globals — Testing Library flushes passive
effects before render/rerender/act return, so tests still read the
props synchronously);
- the capture is a spelled-out CapturedProps type, not
Record<string, any>.
Net effect vs the merge base: one entry removed, none added.
Test-plan gaps:
- the frame-cap test drove the bbox with Math.random() and asserted
only that zoomToFit fired once, so a MAX_FRAMES regression from 60 to
2 would have passed. Monotonic counter now, and the bbox read count is
pinned to the cap.
- captureAnimationFrames already spied on cancelAnimationFrame but
nothing asserted the unmount cleanup. The spy and the frame ids are
exposed and a test covers it.
- new test that linkWidth's identity is stable across a hover while
linkColor's is not, and that the same accessor reports the widened
value.
- new test that nodeThreeObject applies focus to nodes built AFTER
onNodeHover — the real order; the existing tests build first.
- direct unit tests for the newly exported hexToRgbTriplet.
Under `set -e` a failing `make e2e-up` exited the flock'd shell before
the trailing `make e2e-down` could run, leaving a half-started stack
holding the ports. Register `trap "make e2e-down || true" EXIT` first
and drop the explicit teardown.
…e graph
The only path that exercises the new highlightRef mirror effect is a
highlightId change while mounted — first mount is covered by the useRef
initializer, so mutation-testing showed the effect could be deleted
outright with all tests still green. This also pins the effect ORDER:
the mirror is declared before the re-assert effect, and moving it after
makes the re-assert read the stale highlight (verified: all three
mutations now fail).
Byte-identical to main in that region, so the shared-helper adoption
merges cleanly instead of conflicting on comment wording. The 'why an
await import inside a hoisted vi.mock factory' rationale already lives
in the helper's own docblock.
vi.mock calls are hoisted, so the order is behaviour-neutral — but this
block sat inside the exact hunk #538 rewrote on main (the next/dynamic
mock), which made an otherwise clean merge conflict on a pure addition.
Moved into untouched context above.
The plan's pre-review code sketches prescribe the exact five shapes the
review round removed (hover-keyed linkWidth, a visualsRef cleared inside
a useMemo, focus-blind nodeThreeObject, per-node halo pairs, hardcoded
link rgb). Left unmarked, the doc reads as authoritative and invites a
future reader to 'restore' them.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Every hover destroyed and rebuilt every link mesh.linkWidth was keyed on hoverId state, so each enter/leave handed the library a new function identity, and three-forcegraph treats linkWidth as object-invalidating → linkDataMapper.clear() → full teardown/rebuild of every CylinderGeometry link. That is exactly what the spec promised not to do. linkWidth now has a stable identity reading hoverRef.current; widths still track hover because linkColor already triggers the digest and onUpdateObj re-reads the accessor. Mutation-verified: restoring [hoverId] fails exactly one test.
  • Two new react-hooks/refs suppressions were added for new code, against the ratchet rule this PR's own plan restates. Both fixed at the source — the render-body ref write moved into an effect, and visualsRef replaced by a dataset-scoped epoch object so a discarded concurrent render cannot clobber the committed registry. The test-file suppressions were fixed properly too, not baselined. Suppressions went 29 files/193 → 28 files/187, deletion-only.
  • The branch was CONFLICTING with main (Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 landed the WebGL2 gate on the same three files). Ported forward by hand — no rebase: adopted @/test-utils/mockNextDynamic byte-identical to main, re-applied the Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 header, and shrank two more noise conflicts. git merge-tree --write-tree HEAD main now reports 0 conflicts in all three source files; the only residual is the generated eslint-suppressions.json baseline.

Minor / nits

nodeThreeObject applies the live focus state to nodes it rebuilds, so a mid-hover dataset refresh no longer renders focus half-applied · shared halo geometry instead of one per node, sphere segments back to 16 · SpriteText built via its constructor args, cutting five rasterisations per label · base/dim link colours derived from the theme rather than a hardcoded --ink-400 · e2e teardown trap in the plan doc · deterministic frame-cap test that pins MAX_FRAMES · unmount cancelAnimationFrame test · hexToRgbTriplet unit test.

Not done — CodeRabbit's disposal finding is not valid

react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate, which already disposes each group's geometries, materials and the sprite's material.map. Adding manual disposal would be redundant.

Verificationtsc clean · eslint 0 errors · 604 frontend tests pass · merge conflicts vs main: 3 source files → 0

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

…-minimal
# Conflicts:
#	frontend/eslint-suppressions.json
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter by AndresL230 · Pull Request #530 · SaplingLearn/Sapling · GitHub
Skip to content

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter - #530

Draft
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal
Draft

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter#530
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 3D graph mode grows from bare spheres into the approved Focused Minimal design (spec: docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md)
  • matte spheres + always-visible SpriteText labels (roots bold), unexplored tier washed toward warm gray
  • hover-focus: 1-hop neighborhood lit, rest dimmed; sage halo on hovered + tutor-highlighted nodes
  • ⌖ recenter (zoomToFit) sharing the 2D control's title/testid (auto-hidden on tutor rail)
  • new dep three-spritetext, client-chunk only; 3D stays opt-in behind the existing toggle
  • camera auto-fit on engine settle (bbox-stabilization poll + warmupTicks under test/reduced-motion) — 3 review-gated fix rounds, root-caused in a live browser

Test plan

  • graph3dHelpers unit tests + rewritten KnowledgeGraph3D component tests
  • full frontend suite, lint, next build
  • flock'd E2E cycle: Chapter 1 journeys + oracles green
  • manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached)
  • fresh 3D screenshots reviewed on /tree + dashboard (visual-pass artifacts in .superpowers/sdd/, gitignored)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a focused visual treatment for the opt-in 3D knowledge graph, including matte nodes, halos, and always-visible labels.
    • Hovering over a node now highlights its immediate connections while dimming unrelated nodes and links.
    • Added a “Reset view” control to recenter and fit the graph.
    • Improved automatic camera fitting when graph data changes, with reduced-motion support.
  • Tests

    • Expanded coverage for rendering, highlighting, camera fitting, accessibility behavior, and visual styling.

AndresL230and others added 9 commits August 5, 2026 16:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght halo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… run warmup ticks under test/reduced-motion
Round 2 on d2e59a9's camera-framing fix, which was unit-correct but had no
runtime effect. Confirmed root cause via node_modules/3d-force-graph source
plus live frame-by-frame logging against a running dev server:
1. onEngineStop fires synchronously *before* tickFrame's "update node
positions" step writes that tick's x/y/z onto each node's Three.js
object (both run inside the same synchronous layoutTick() call, stop
callback first) — so fitting immediately (or even one rAF later) measures
a stale/near-empty bbox and ends up far too close once the real,
spread-out layout lands moments later. Fix: poll on requestAnimationFrame
until two consecutive getGraphBbox() reads agree (frame-capped safety
net) before calling zoomToFit.
2. Under cooldownTicks={0} (test/reduced-motion mode), warmupTicks was left
at its default of 0 too, so the 3D force simulation never actually ran —
nodes stayed at d3-force-3d's raw pre-simulation positions, an
overlapping cluster. KnowledgeGraph2D already solves this the same way
for its own reduced-motion path (`sim.alpha(1).tick(200).alpha(0).stop()`);
mirrored it here via `warmupTicks={reducedMotion || IS_TEST_MODE ? 200 : 0}`.
Verified visually against a dev server (:3010) proxying the held-up E2E
stack's backend: before, /tree in 3D was a tiny illegible clump; after, four
well-separated, readable node clusters filling most of the canvas, matching
the 2D layout's structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cel rAF on unmount
Round 3 re-review of the round-2 camera-framing fix (mechanism, determinism,
and tests independently verified) flagged one new Important issue and a
hygiene nit introduced by the async polling window itself:
1. The bbox-stabilization poll's closure never re-checked that it still
belonged to the current dataset. If nodes/edges changed while an old
poll was mid-flight (up to MAX_FRAMES/~1s wide), the stale closure kept
polling the live instance and could fire zoomToFit(400, 60) after —
clobbering — the fresh poll's correct fit for the new dataset. Fixed
with an epoch guard: pollEpochRef increments alongside didFitRef.current
= false in the [nodes, edges] reset effect; handleEngineStop captures
the epoch at poll start, and every scheduled frame callback bails
immediately if pollEpochRef.current no longer matches.
2. Hygiene: no cancelAnimationFrame cleanup tied the poll to component
lifetime, so an unmount mid-poll could leave up to ~60 dangling no-op
rAF callbacks. Now tracks the current rAF id in pollRafIdRef and cancels
it in an unmount-only effect.
TDD: wrote the new test first against the pre-fix code (stashed the
implementation), confirmed it failed exactly as described (stale poll
fires zoomToFit(400, 60) for the old dataset), then restored the
implementation and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99e248db-5120-4f64-8d7e-40dfec5218fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The opt-in 3D knowledge graph now uses custom spheres, halos, and labels. Shared helpers provide sizing, colors, themes, and adjacency. Hover interactions focus one-hop neighborhoods, links dim accordingly, and a reset control refits the camera.

Changes

Focused Minimal 3D graph

Layer / File(s)Summary
Styling helpers and rendering contract
docs/superpowers/..., frontend/src/components/graph/graph3dHelpers.ts, frontend/src/components/graph/graph3dHelpers.test.ts
Adds pure helpers for themes, colors, adjacency, node sizing, and label specifications with deterministic tests.
Custom node visuals
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/package.json, frontend/eslint-suppressions.json, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Renders matte spheres, focus halos, and SpriteText labels through custom Three.js objects and validates their properties.
Hover focus and link styling
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Tracks hovered nodes, dims non-neighbors and links, preserves persistent highlight halos, and supports string or resolved link endpoints.
Camera fitting and reset control
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx, frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
Adds ref-forwarded zoomToFit, stable-bounds auto-fit polling, stale-poll cancellation, warmup ticks, and the graph-zoom-reset button.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Pointer
participant KnowledgeGraph3D
participant VisualRegistry
participant ForceGraph3D
Pointer->>KnowledgeGraph3D: Hover a graph node
KnowledgeGraph3D->>VisualRegistry: Update node, label, and halo opacity
KnowledgeGraph3D->>ForceGraph3D: Recompute link color and width
ForceGraph3D-->>KnowledgeGraph3D: Render focused neighborhood
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 62.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main 3D graph changes: labels, hover focus, and recentering.
Description check✅ PassedThe description clearly explains the upgrade and includes comprehensive changes and testing details, despite using different section headings than the template.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3d-graph-focused-minimal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging987bfd7Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:09 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
frontend/src/components/graph/KnowledgeGraph3D.test.tsx (2)

553-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the frame-cap test deterministic and pin the cap value.

Line 557 uses Math.random() to produce a bbox that never repeats. The sibling test at Line 588 uses a monotonic counter for the same purpose, which is deterministic.

The test also asserts only that zoomToFit fired once. It does not assert how many frames elapsed. If MAX_FRAMES regressed from 60 to 2, this test would still pass. Assert the read count to pin the cap.

🧪 Proposed deterministic frame-cap test
- getGraphBboxMock.mockImplementation(() => ({ x: [0, Math.random()], y: [0, 0], z: [0, 0] }));+ let capCounter = 0;+ getGraphBboxMock.mockImplementation(() => ({ x: [0, ++capCounter], y: [0, 0], z: [0, 0] }));
 expect(zoomToFitSpy).toHaveBeenCalledTimes(1);
expect(zoomToFitSpy).toHaveBeenCalledWith(400, 60);
+ // Pins MAX_FRAMES: the poll reads the bbox once per frame up to the cap.+ expect(getGraphBboxMock).toHaveBeenCalledTimes(60);
expect(raf.queue.length).toBe(0); // no frame scheduled after the cap-triggered fit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 553 -
573, Make the frame-cap test deterministic by replacing Math.random() in
getGraphBboxMock with a monotonic counter so each bounding-box read differs
predictably. Track the number of bbox reads and assert it equals the configured
MAX_FRAMES value (60), while preserving the existing single fit and empty RAF
queue assertions.

171-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the unmount cancellation path.

captureAnimationFrames already spies on cancelAnimationFrame at Line 178, and the spy is exposed only through restore(). No test asserts that the unmount cleanup effect in KnowledgeGraph3D.tsx (Lines 202-206) cancels the pending frame. The PR lists that cleanup as a deliverable, so a regression that drops the effect would pass the current suite.

Return the cancelAnimationFrame spy from the helper, then assert it in a new test.

🧪 Proposed helper change and test
 const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
return {
queue,
+ caf,
it("cancels the in-flight bbox poll on unmount",()=>{getGraphBboxMock.mockReturnValue({x: [0,1],y: [0,1],z: [0,1]});constraf=captureAnimationFrames();const{ unmount }=render(<KnowledgeGraph3Dnodes={[makeNode()]}edges={[]}/>);act(()=>(lastProps!.onEngineStopas()=>void)());expect(raf.queue.length).toBe(1);unmount();expect(raf.caf).toHaveBeenCalledTimes(1);raf.restore();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 171 -
193, Update captureAnimationFrames to expose the cancelAnimationFrame spy, then
add a KnowledgeGraph3D unmount test that starts the bbox polling frame through
onEngineStop, unmounts the component, and asserts the cancellation spy was
called. Restore the animation-frame spies after the assertion.
frontend/src/components/graph/KnowledgeGraph3D.tsx (1)

303-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the link colors from the resolved theme instead of hardcoded RGB literals.

Lines 306, 311, and 312 hardcode rgba(138, 131, 114, ...) and rgba(138, 154, 91, ...). 138, 154, 91 is #8a9a5b, which is FALLBACK_THEME.accent. Nodes and halos use resolveGraphTheme(), which reads --accent and --ink-200 from CSS. If a deployment or an alternate color scheme overrides those variables, node colors follow the theme and link colors do not.

Convert theme.accent and theme.dim to RGB components once, then build the rgba strings from them.

🎨 Proposed theme-derived link colors
+ // Link colors follow the same resolved theme as nodes and halos.+ const linkRgb = React.useMemo(() => {+ const rgb = (hex: string) => {+ const c = new THREE.Color(hex);+ return `${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}`;+ };+ return { lit: rgb(theme.accent), dim: rgb(theme.dim) };+ }, [theme]);+
const linkColor = React.useCallback(
(l: object) => {
const link = l as FG3DLink;
- if (!hoverId) return `rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;+ if (!hoverId) return `rgba(${linkRgb.dim}, ${BASE_LINK_ALPHA})`;
const lit = linkEndId(link.source) === hoverId || linkEndId(link.target) === hoverId;
- // Lit links take the sage accent (rgb of `#8a9a5b`); dimmed links- // fade to near-invisible warm gray.+ // Lit links take the resolved accent; dimmed links fade to the+ // resolved dim token.
return lit
- ? `rgba(138, 154, 91, ${LIT_LINK_ALPHA})`- : `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;+ ? `rgba(${linkRgb.lit}, ${LIT_LINK_ALPHA})`+ : `rgba(${linkRgb.dim}, ${DIM_LINK_ALPHA})`;
},
- [hoverId],+ [hoverId, linkRgb],
);

If you accept this, update the literal expectations in frontend/src/components/graph/KnowledgeGraph3D.test.tsx at Lines 471, 478, 479, and 481-483 to build the same strings from FALLBACK_THEME.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 303 - 315,
Update the linkColor callback to use the resolved graph theme’s accent and dim
colors instead of hardcoded RGB values, converting theme.accent and theme.dim to
RGB components once and using them for the rgba strings. Preserve the existing
hover, lit, and dim alpha behavior, and update the corresponding
KnowledgeGraph3D.test.tsx expectations to construct the same values from
FALLBACK_THEME.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md`:
- Around line 937-947: Update the shell command around the E2E stack startup to
register an EXIT trap that runs make e2e-down before make e2e-up executes.
Preserve the existing test return-code collection and ensure teardown runs
exactly once, including when make e2e-up fails under set -e.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 243-285: Update nodeThreeObject to store the returned group
alongside sphereMat, halo, and label in visualsRef.current. Before replacing the
registry when the dataset changes and during component unmount, iterate the
existing entries and dispose both SphereGeometry instances, their materials, the
SpriteText canvas texture/material resources, and each group’s children; then
clear the registry before creating the new Map.
---
Nitpick comments:
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx`:
- Around line 553-573: Make the frame-cap test deterministic by replacing
Math.random() in getGraphBboxMock with a monotonic counter so each bounding-box
read differs predictably. Track the number of bbox reads and assert it equals
the configured MAX_FRAMES value (60), while preserving the existing single fit
and empty RAF queue assertions.
- Around line 171-193: Update captureAnimationFrames to expose the
cancelAnimationFrame spy, then add a KnowledgeGraph3D unmount test that starts
the bbox polling frame through onEngineStop, unmounts the component, and asserts
the cancellation spy was called. Restore the animation-frame spies after the
assertion.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 303-315: Update the linkColor callback to use the resolved graph
theme’s accent and dim colors instead of hardcoded RGB values, converting
theme.accent and theme.dim to RGB components once and using them for the rgba
strings. Preserve the existing hover, lit, and dim alpha behavior, and update
the corresponding KnowledgeGraph3D.test.tsx expectations to construct the same
values from FALLBACK_THEME.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d36f84-0079-4d42-a82d-ce69d01fc611

📥 Commits

Reviewing files that changed from the base of the PR and between 458ddb2 and c3ed797.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md
  • docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md
  • frontend/eslint-suppressions.json
  • frontend/package.json
  • frontend/src/components/graph/KnowledgeGraph3D.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/graph3dHelpers.test.ts
  • frontend/src/components/graph/graph3dHelpers.ts

Comment on lines +937 to +947
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Ensure teardown runs when make e2e-up fails.

set -e exits before make e2e-down if make e2e-up fails. This can leave a partially started E2E stack running. Register teardown with an EXIT trap before startup.

Proposed fix
 set -e
+ trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
- make e2e-down
exit $rc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
exit $rc
'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md` around lines
937 - 947, Update the shell command around the E2E stack startup to register an
EXIT trap that runs make e2e-down before make e2e-up executes. Preserve the
existing test return-code collection and ensure teardown runs exactly once,
including when make e2e-up fails under set -e.

Comment on lines +243 to +285
const nodeThreeObject = React.useCallback(
(raw: object) => {
const n = raw as FG3DNode;
const r = nodeRadius(n);
const color = baseNodeColor(n, theme);
const group = new THREE.Group();

const sphereMat = new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity: NODE_OPACITY,
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24), sphereMat));

// Focus halo: slightly larger translucent accent sphere, hidden
// until this node is hovered or is the persistent highlightId.
const halo = new THREE.Mesh(
new THREE.SphereGeometry(r * 1.4, 16, 16),
new THREE.MeshBasicMaterial({
color: theme.accent,
transparent: true,
opacity: 0.28,
depthWrite: false,
}),
);
halo.visible = n.id === hoverRef.current || n.id === highlightRef.current;
group.add(halo);

const spec = labelSpec(n);
const label = new SpriteText(n.name);
label.textHeight = spec.textHeight;
label.fontWeight = spec.fontWeight;
label.color = theme.ink;
label.fontFace = '"JetBrains Mono", monospace';
label.material.transparent = true;
label.position.set(0, -(r + spec.textHeight + 1.5), 0);
group.add(label);

visualsRef.current.set(n.id, { sphereMat, label, halo, baseColor: color });
return group;
},
[theme],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the repository already disposes Three.js resources anywhere, and whether SpriteText exposes a dispose path.set -euo pipefail
rg -n --type=ts --type=tsx -C3 '\.dispose\s*\(' frontend/src ||echo"no dispose() calls found in frontend/src"
fd -t d 'three-spritetext' frontend/node_modules --max-depth 3 --exec sh -c 'ls "$1"; sed -n "1,80p" "$1/dist/three-spritetext.d.ts" 2>/dev/null' _ {}

Repository: SaplingLearn/Sapling

Length of output: 340


🏁 Script executed:

#!/bin/bash0bashset -euo pipefail
printf'Tracked KnowledgeGraph files:\n'
fd -i 'KnowledgeGraph3D\.(ts|tsx)$'.||trueprintf'\nGit file list for likely front-end components:\n'
git ls-files | rg 'frontend/src/(components|.*Graph|.*three|package\.json)$|componentst'printf'\nSearch dispose across tracked source files:\n'
git ls-files | rg '\.(ts|tsx)$'| xargs -r rg -n -- '-- dispose\(' or true

Repository: SaplingLearn/Sapling

Length of output: 289


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'File line count:\n'
wc -l "$FILE"printf'\nRelevant section 160-300:\n'
sed -n '160,300p'"$FILE"| nl -ba -v160
printf'\nSearch for three-spritetext references and package manifests:\n'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo""$FILE"||true
rg -n "three-spritetext|react-three-fiber|`@react-three`" frontend -g 'package.json' -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock'||true

Repository: SaplingLearn/Sapling

Length of output: 298


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'Relevant section 160-300:\n'
sed -n '160,300p'"$FILE"| awk '{printf "%5d %s\n", NR+159, $0}'printf'\nImports and dependency references:\n'
sed -n '1,80p'"$FILE"| awk '{printf "%5d %s\n", NR, $0}'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo|useEffect|React""$FILE"||trueprintf'\nDependency files:\n'forfin package.json frontend/package.json frontend/package-lock.json frontend/pnpm-lock.yaml frontend/yarn.lock frontend/package.json;doif [ -f"$f" ];thenprintf'\n--- %s ---\n'"$f"
rg -n "three-spritetext|react-three-fiber|`@react-three`|three""$f"||truefidoneprintf'\nSearch disposed resource handling across tracked source files:\n'
git ls-files | xargs -r rg -n -- '-- dispose\s*\('| rg '\.(ts|tsx)$'||true

Repository: SaplingLearn/Sapling

Length of output: 13950


🌐 Web query:

three-spritetext v1.10.0 source canvas dispose texture code

💡 Result:

In three-spritetext v1.10.0, the process of disposing of the previous texture when updating the canvas is handled within the library's internal update logic. When the text content or styling changes, the library generates a new canvas and updates the sprite's material. The disposal logic is implemented as follows: // Inject canvas into sprite if (this.material.map) this.material.map.dispose; // gc previous texture var texture = this.material.map = new THREE.Texture(canvas); texture.minFilter = THREE.LinearFilter; texture.colorSpace = THREE.SRGBColorSpace; By calling this.material.map.dispose, the library ensures that the old texture is properly removed from GPU memory before assigning the new texture created from the updated canvas [1]. This practice is essential for preventing memory leaks when frequently updating sprite text [1][2].

Citations:


Dispose every node visual object when the dataset changes.

nodeThreeObject creates per-node Three.js resources: the returned group, two meshes with SphereGeometry, two materials, and a SpriteText with a canvas-backed Texture. Line 178 reassigns the registry to a new Map on a new dataset, so those previous objects become unreachable while their GPU buffers remain allocated.

Track the complete visual entry in visualsRef.current, including the group, then dispose the geometries, textures, and materials before replacing the registry and on component unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 243 - 285,
Update nodeThreeObject to store the returned group alongside sphereMat, halo,
and label in visualsRef.current. Before replacing the registry when the dataset
changes and during component unmount, iterate the existing entries and dispose
both SphereGeometry instances, their materials, the SpriteText canvas
texture/material resources, and each group’s children; then clear the registry
before creating the new Map.

…ion instant fit, warmupTicks pins
Three Important findings from the whole-branch final review, plus a doc
amendment, fixed in one wave:
1. Accent drift, one color one source: FALLBACK_THEME.accent was still the
retired sage (#8a9a5b) while globals.css's --accent is now the brighter
forest (#2d8f5c) — so the focus halo (theme.accent) rendered forest
while its own lit edges (hardcoded sage rgb) rendered sage. Corrected
FALLBACK_THEME.accent to #2d8f5c and added hexToRgbTriplet(), a small
pure helper next to resolveGraphTheme, so linkColor derives the lit-link
rgba from theme.accent instead of a second hardcoded copy that can
drift.
2. Reduced-motion camera animation: both zoomToFit call sites (the
engine-settle auto-fit and the manual recenter button) now gate their
duration — `reducedMotion || IS_TEST_MODE ? 0 : 400` — since these are
system-initiated camera flies and an animated 400ms fly is itself a
motion violation on the paths that already zero cooldownTicks to
eliminate motion. Padding unified to 60 across both call sites
(recenter button previously used 40).
3. warmupTicks was untested: pinned 200 in both the reduced-motion test
(KnowledgeGraph3D.test.tsx) and the test-mode determinism test
(KnowledgeGraph3D.testmode.test.tsx), and 0 in a new default-motion-path
test — plus new coverage for the reduced-motion/test-mode instant-fit
gating on the recenter button (the auto-fit path shares the identical
ternary, already exercised by the bbox-stabilization tests under
default motion).
Also amended docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md's
"Camera & motion" section to document both justified deviations (the
bbox-stabilization auto-fit poll + epoch guard, and warmupTicks=200 under
test/reduced-motion mirroring KnowledgeGraph2D's sim.tick(200) precedent),
and replaced the stale #8a9a5b halo color reference with "the app accent
token (--accent, currently #2d8f5c), resolved at mount".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — graph-3d Focused Minimal upgrade

This turns the 3D mode from bare library spheres into the Focused Minimal design: nodeThreeObject groups (matte MeshLambertMaterial sphere + hidden accent halo + always-visible three-spritetext label), a visualsRef registry so hover-focus mutates materials instead of rebuilding geometry, linkColor/linkWidth re-keyed on hover, a recenter button reusing the 2D control's title="Reset view" (which is what globals.css:609 keys on to hide it on the tutor rail), and a bbox-stabilisation rAF poll that drives a one-shot zoomToFit on the first engine settle. The pure-helper extraction into graph3dHelpers.ts is clean, the auto-fit poll's epoch guard and rAF-cancel-on-unmount are correct, and the SSR/testid/click-whitelist contracts are all preserved. Two things I checked and want to save you the time on: the library does own GPU disposal (react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate recursively disposes each group's geometries, materials and the sprite's material.map), and zoomToFit is null-safe on an empty graph — so CodeRabbit's disposal comment is not a real leak. What does need fixing is the hover path's interaction with three-forcegraph's prop-change semantics, plus the new lint suppressions and the merge state.

Findings

P1

[P1] Every hover transition destroys and rebuilds every link meshfrontend/src/components/graph/KnowledgeGraph3D.tsx:326-335

constlinkWidth=React.useCallback((l: object)=>{constlink=lasFG3DLink;constbase=0.4+(link.strength||0)*0.6;if(!hoverId)returnbase;constlit=linkEndId(link.source)===hoverId||linkEndId(link.target)===hoverId;returnlit ? base+0.6 : base;},[hoverId],);

hoverId is React state, so each hover enter/leave re-renders and hands react-force-graph-3d a new linkWidth function identity. react-kapsule pushes any identity-changed prop straight through (react-kapsule.mjs:30-33; linkWidth is a plain prop per react-force-graph-3d.mjs:126-129), and three-forcegraph treats linkWidth as an object-invalidating prop: three-forcegraph.mjs:1199-1201if (state._flushObjects || hasAnyPropChanged(['linkThreeObject','linkThreeObjectExtend','linkWidth'])) state.linkDataMapper.clear();. clear() is digest([]), which scene.remove()s and _deallocate()s (geometry.dispose + material.dispose) every link object and then recreates all of them. These aren't cheap lines either — useCylinder = !!widthAccessor(link) (:1220) is always true here, so each link is a CylinderGeometry mesh. On a few-hundred-edge graph that's a full teardown/rebuild of the link layer on every mouse enter and every mouse leave. This is exactly what docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md:138-142 promised not to do: "Hover mechanics — no per-hover geometry rebuilds … (links are cheap line materials)." Direction: give linkWidth a stable identity that reads hoverRef.current; linkColor's own change already triggers the digest, and the link onUpdateObj re-reads widthAccessor(link) each digest, so widths still update — just without the clear(). (Nothing visually breaks — three-forcegraph.mjs:1483 sets engineRunning = true at the end of every update(), so the next layoutTick repositions the new meshes — but it does mean onEngineStop also re-fires on every hover; the didFitRef guard absorbs that correctly.)

[P1] Two new react-hooks/refs suppressions added for new production codefrontend/eslint-suppressions.json:99-103

"src/components/graph/KnowledgeGraph3D.tsx": {
"react-hooks/refs": {
"count": 2
}
},

The Canopy Engineering Style Guide §7 states the ratchet explicitly — "don't fix the whole backlog to land a change, but never add to it" — and this PR's own plan repeats it at docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md:20: "eslint suppressions are a ratcheted baseline — new code must be clean." The two suppressed violations are real render-phase ref mutations: KnowledgeGraph3D.tsx:173highlightRef.current = highlightId; in the render body, and :179visualsRef.current = new Map(); inside the graphDatauseMemo. The useMemo one is the one with teeth — under React 19 concurrent rendering a render that starts and is then discarded still clears the registry, leaving the committed tree with an empty visualsRef and hover-focus a silent no-op. (The test-file suppressions — react-hooks/globals, react/display-name — are less load-bearing but also new.)

[P1] Branch is CONFLICTING with main, and main landed the #538 WebGL2 gate on the same three files

gh pr view 530 --json mergeable returns "mergeable":"CONFLICTING","mergeStateStatus":"DIRTY". Since the merge-base (ec34bf17), main has changed KnowledgeGraph.tsx (+201: WebGL2 capability probe, ErrorBoundary, graph-crash-fallback), added this header to KnowledgeGraph3D.tsx"#538: NEVER mount this component outside the KnowledgeGraph wrapper — three r163+ throws from the WebGLRenderer constructor when WebGL2 is unavailable" — and replaced the local next/dynamic mock in bothKnowledgeGraph3D.test.tsx and KnowledgeGraph3D.testmode.test.tsx with the shared @/test-utils/mockNextDynamic helper. This PR rewrites all three of those files from the pre-#538 base and still carries the old inline mock (KnowledgeGraph3D.test.tsx:98-114, KnowledgeGraph3D.testmode.test.tsx:36-52), so the conflict resolution has to re-apply main's work by hand rather than take either side wholesale. Worth rebasing before the draft flips to ready — main's shared helper does handle this PR's loader (which resolves to a bare function component, not a module) via typeof mod === "function" ? mod : (mod.default ?? null).

P2

[P2] nodeThreeObject doesn't apply the active focus state to the nodes it (re)buildsfrontend/src/components/graph/KnowledgeGraph3D.tsx:251-286

constsphereMat=newTHREE.MeshLambertMaterial({
color,transparent: true,opacity: NODE_OPACITY,});

The halo correctly reads the live refs (halo.visible = n.id === hoverRef.current || n.id === highlightRef.current), but the sphere is always built at full baseColor/NODE_OPACITY and the label at full opacity. Any nodes/edges identity change re-runs the graphData memo (clearing visualsRef at :179) and makes three-forcegraph rebuild every node object — node objects are keyed by identity and the memo mints fresh {...n} clones. If the pointer is still over a node at that moment (a tutor graph_update refreshing the Learn rail graph, a filter change on /tree), the hovered node keeps its halo but nothing dims: the focus effect renders half-applied until the pointer moves. The [applyFocus, highlightId] re-assert effect can't cover it because it runs at commit, before the library rebuilds. The component test doesn't catch this because it calls nodeThreeObject manually before driving onNodeHover (KnowledgeGraph3D.test.tsx:451-456), which is the opposite order.

[P2] A halo geometry + material is allocated for every node although at most two are ever visiblefrontend/src/components/graph/KnowledgeGraph3D.tsx:256-270

group.add(newTHREE.Mesh(newTHREE.SphereGeometry(r,24,24),sphereMat));// Focus halo: slightly larger translucent accent sphere, hidden// until this node is hovered or is the persistent highlightId.consthalo=newTHREE.Mesh(newTHREE.SphereGeometry(r*1.4,16,16),newTHREE.MeshBasicMaterial({

halo.visible is only ever true for the hovered node and highlightId, so an N-node graph carries N−2 unused SphereGeometry + MeshBasicMaterial pairs for the lifetime of the dataset, re-allocated in full on every refresh. A single shared unit-sphere geometry scaled per node, or lazy creation on first focus, avoids it. Separately, the main sphere went from the library's nodeResolution={16} (pre-patch) to SphereGeometry(r, 24, 24) — 2.25× the triangles per node — which the spec doesn't ask for.

[P2] Each label is rasterised five times at constructionfrontend/src/components/graph/KnowledgeGraph3D.tsx:273-279

constlabel=newSpriteText(n.name);label.textHeight=spec.textHeight;label.fontWeight=spec.fontWeight;label.color=theme.ink;label.fontFace='"JetBrains Mono", monospace';

Every three-spritetext setter re-runs _genCanvas() (measure text → resize canvas → repaint → new CanvasTexture). That's the constructor plus four setters = five full rasterisations and five texture allocations per node, paid again for the whole graph on every dataset refresh. SpriteText's constructor takes (text, textHeight, color), which folds three of the five. Related sizing note: _fontSize is fixed at 90 regardless of textHeight, so each label canvas is roughly 600×100 RGBA — at the spec's stated "few hundred nodes" ceiling that is tens of MB of texture memory. (No leak — _genCanvas disposes the previous map — just wasted work and VRAM.)

P3

[P3] The base and dim link colors are still hardcoded hex while the lit one derives from the themefrontend/src/components/graph/KnowledgeGraph3D.tsx:315,321

if(!hoverId)return`rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;
...
: `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;

138, 131, 114 is #8a8372 = --ink-400 (globals.css:47) — an existing token, not an unnamed value. graph3dHelpers.ts:33-36 says FALLBACK_THEME is "the ONE place that hex is allowed to be hardcoded", and the c65c075 pass fixed only the lit half. Adding a fourth GraphTheme field resolved from --ink-400 would close the loop and match the Canopy design-token rule ("Use the tokens; don't hardcode values"). Low stakes — the spec does sanction "today's rgba(138,131,114,…) family" — but the file's own comment now contradicts itself.

Test-plan checklist — per-item verdict

  1. graph3dHelpers unit tests + rewritten component tests — supported. graph3dHelpers.test.ts covers buildAdjacency, nodeVal/nodeRadius, mixHex, baseNodeColor, labelSpec, resolveGraphTheme; the component test covers node composition, hover dim/restore, halo persistence, linkColor, recenter (both durations), the bbox poll, the frame cap and the epoch guard. Two gaps: the newly exported hexToRgbTriplet has no unit test (only indirect coverage via the component test), and nothing asserts the unmount cancelAnimationFrame even though captureAnimationFrames already spies on it.
  2. full frontend suite, lint, next build — supported by CI (Frontend (lint + tsc + vitest) pass, Workers Builds: frontend-staging pass), with the caveat that lint only passes because of the new suppressions in the P1 finding above.
  3. flock'd E2E cycle: Chapter 1 journeys + oracles green — not verifiable from the PR; there is no E2E job among the checks on feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter #530. Taking it on trust.
  4. manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached) — the code claims hold up (the title="Reset view"globals.css:609 rail-hiding seam is real), but no screenshots are attached to the PR body or to any comment, so "attached" doesn't match the PR as it stands.
  5. fresh 3D screenshots reviewed on /tree + dashboard — artifacts are gitignored, so unverifiable from here. Note that the P1 hover finding wouldn't have shown up as a visual defect (it's allocation churn, not a broken frame), so this doesn't invalidate the pass.

What's good

  • The auto-fit story is genuinely well-engineered: the bbox-stabilisation poll, the MAX_FRAMES safety net, the pollEpochRef invalidation on mid-poll dataset change and the rAF cancel on unmount are all correct, and each one is pinned by a test that would fail if it regressed.
  • Reduced-motion is handled properly rather than performatively — zeroing the zoomToFit duration on both call sites because a system-initiated camera fly is itself motion, and the warmupTicks={200} reasoning mirrors KnowledgeGraph2D's sim.tick(200) precedent instead of inventing a new one.
  • graph3dHelpers.ts is the right shape: pure, three.js-free, unit-testable, and the "always return #rrggbb" color contract is documented where it will actually be read.
  • Every preserved contract really is preserved — sr-only list, all three testids, the onNodeClick id whitelist, and the dynamic(ssr:false) boundary all still hold, and the click-whitelist test now enumerates the full set of library-injected fields.

Verdict: solid design work with one real hover-path performance bug (P1), a ratchet violation (P1) and a rebase (P1) to clear before this leaves draft.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…nder-phase ref writes
Review round on #530.
P1 — linkWidth was a useCallback keyed on the hoverId state, so every
pointer enter AND leave handed react-force-graph-3d a new function
identity. three-forcegraph treats linkWidth as object-invalidating for
links (linkDataMapper.clear() -> digest([]) -> scene.remove() +
_deallocate() of every link mesh, all CylinderGeometry here since the
widths are never zero), so a hover was two full teardowns/rebuilds of
the whole link layer — the exact opposite of the spec's "no per-hover
geometry rebuilds". linkWidth now reads hoverRef with an empty dep
array; widths still follow the hover because linkColor's own identity
change triggers the digest and its onUpdateObj re-reads the width
accessor per link.
P1 — both react-hooks/refs violations were real render-phase ref
mutations. highlightId is now mirrored in an effect (the useRef
initializer covers first mount; kapsule's digest is debounce(fn, 1), so
the library cannot call nodeThreeObject before the commit's effects
have run). The visuals registry is no longer a ref cleared inside a
useMemo — a discarded concurrent render would still have cleared it and
left the committed tree with an empty registry and hover focus a silent
no-op. One memo now owns everything scoped to a dataset: graphData, the
registry, and the shared halo geometry/material.
P2 — nodeThreeObject applies the live focus state to nodes it builds,
so a dataset refresh under an active hover (tutor graph_update, /tree
filter) no longer renders half-focused until the pointer moves. Focus
styling has a single writer (applyVisualState) shared with the focus
pass.
P2 — one unit-sphere halo geometry + material per dataset, scaled per
node, instead of N pairs of which at most two are ever visible; main
sphere back to 16 segments (the library's nodeResolution default) from
24.
P2 — SpriteText labels use the (text, textHeight, color) constructor:
three of the five canvas rasterisations per label were setter-driven.
P3 — the resting and dimmed link rgb are derived from a new
GraphTheme.link resolved from --ink-400 instead of two hand-copied
rgba(138, 131, 114, …) literals, which contradicted FALLBACK_THEME's
"the ONE place that hex is allowed to be hardcoded".
Also re-applies #538's header warning about never mounting this
component outside the KnowledgeGraph wrapper.
…an gaps
Ports #538's work forward by hand so the eventual merge with main is a
no-op on these files: both 3D graph test files now use the shared
@/test-utils/mockNextDynamic helper (copied verbatim from main) instead
of a local inline dynamic mock. The helper renders the resolved
component with createElement rather than calling it as a function, so
the stub's hooks land in their own fiber, and it accepts this
component's loader, which resolves to a bare function component.
Removes every eslint-suppressions.json entry this branch had added, and
the pre-existing @typescript-eslint/no-explicit-any entry too, by
fixing the causes rather than baselining them:
- the react-force-graph-3d stub is a NAMED function inside forwardRef
(react/display-name) that records props in an effect instead of the
render body (react-hooks/globals — Testing Library flushes passive
effects before render/rerender/act return, so tests still read the
props synchronously);
- the capture is a spelled-out CapturedProps type, not
Record<string, any>.
Net effect vs the merge base: one entry removed, none added.
Test-plan gaps:
- the frame-cap test drove the bbox with Math.random() and asserted
only that zoomToFit fired once, so a MAX_FRAMES regression from 60 to
2 would have passed. Monotonic counter now, and the bbox read count is
pinned to the cap.
- captureAnimationFrames already spied on cancelAnimationFrame but
nothing asserted the unmount cleanup. The spy and the frame ids are
exposed and a test covers it.
- new test that linkWidth's identity is stable across a hover while
linkColor's is not, and that the same accessor reports the widened
value.
- new test that nodeThreeObject applies focus to nodes built AFTER
onNodeHover — the real order; the existing tests build first.
- direct unit tests for the newly exported hexToRgbTriplet.
Under `set -e` a failing `make e2e-up` exited the flock'd shell before
the trailing `make e2e-down` could run, leaving a half-started stack
holding the ports. Register `trap "make e2e-down || true" EXIT` first
and drop the explicit teardown.
…e graph
The only path that exercises the new highlightRef mirror effect is a
highlightId change while mounted — first mount is covered by the useRef
initializer, so mutation-testing showed the effect could be deleted
outright with all tests still green. This also pins the effect ORDER:
the mirror is declared before the re-assert effect, and moving it after
makes the re-assert read the stale highlight (verified: all three
mutations now fail).
Byte-identical to main in that region, so the shared-helper adoption
merges cleanly instead of conflicting on comment wording. The 'why an
await import inside a hoisted vi.mock factory' rationale already lives
in the helper's own docblock.
vi.mock calls are hoisted, so the order is behaviour-neutral — but this
block sat inside the exact hunk #538 rewrote on main (the next/dynamic
mock), which made an otherwise clean merge conflict on a pure addition.
Moved into untouched context above.
The plan's pre-review code sketches prescribe the exact five shapes the
review round removed (hover-keyed linkWidth, a visualsRef cleared inside
a useMemo, focus-blind nodeThreeObject, per-node halo pairs, hardcoded
link rgb). Left unmarked, the doc reads as authoritative and invites a
future reader to 'restore' them.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Every hover destroyed and rebuilt every link mesh.linkWidth was keyed on hoverId state, so each enter/leave handed the library a new function identity, and three-forcegraph treats linkWidth as object-invalidating → linkDataMapper.clear() → full teardown/rebuild of every CylinderGeometry link. That is exactly what the spec promised not to do. linkWidth now has a stable identity reading hoverRef.current; widths still track hover because linkColor already triggers the digest and onUpdateObj re-reads the accessor. Mutation-verified: restoring [hoverId] fails exactly one test.
  • Two new react-hooks/refs suppressions were added for new code, against the ratchet rule this PR's own plan restates. Both fixed at the source — the render-body ref write moved into an effect, and visualsRef replaced by a dataset-scoped epoch object so a discarded concurrent render cannot clobber the committed registry. The test-file suppressions were fixed properly too, not baselined. Suppressions went 29 files/193 → 28 files/187, deletion-only.
  • The branch was CONFLICTING with main (Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 landed the WebGL2 gate on the same three files). Ported forward by hand — no rebase: adopted @/test-utils/mockNextDynamic byte-identical to main, re-applied the Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 header, and shrank two more noise conflicts. git merge-tree --write-tree HEAD main now reports 0 conflicts in all three source files; the only residual is the generated eslint-suppressions.json baseline.

Minor / nits

nodeThreeObject applies the live focus state to nodes it rebuilds, so a mid-hover dataset refresh no longer renders focus half-applied · shared halo geometry instead of one per node, sphere segments back to 16 · SpriteText built via its constructor args, cutting five rasterisations per label · base/dim link colours derived from the theme rather than a hardcoded --ink-400 · e2e teardown trap in the plan doc · deterministic frame-cap test that pins MAX_FRAMES · unmount cancelAnimationFrame test · hexToRgbTriplet unit test.

Not done — CodeRabbit's disposal finding is not valid

react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate, which already disposes each group's geometries, materials and the sprite's material.map. Adding manual disposal would be redundant.

Verificationtsc clean · eslint 0 errors · 604 frontend tests pass · merge conflicts vs main: 3 source files → 0

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

…-minimal
# Conflicts:
#	frontend/eslint-suppressions.json
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter by AndresL230 · Pull Request #530 · SaplingLearn/Sapling · GitHub
Skip to content

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter - #530

Draft
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal
Draft

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter#530
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 3D graph mode grows from bare spheres into the approved Focused Minimal design (spec: docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md)
  • matte spheres + always-visible SpriteText labels (roots bold), unexplored tier washed toward warm gray
  • hover-focus: 1-hop neighborhood lit, rest dimmed; sage halo on hovered + tutor-highlighted nodes
  • ⌖ recenter (zoomToFit) sharing the 2D control's title/testid (auto-hidden on tutor rail)
  • new dep three-spritetext, client-chunk only; 3D stays opt-in behind the existing toggle
  • camera auto-fit on engine settle (bbox-stabilization poll + warmupTicks under test/reduced-motion) — 3 review-gated fix rounds, root-caused in a live browser

Test plan

  • graph3dHelpers unit tests + rewritten KnowledgeGraph3D component tests
  • full frontend suite, lint, next build
  • flock'd E2E cycle: Chapter 1 journeys + oracles green
  • manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached)
  • fresh 3D screenshots reviewed on /tree + dashboard (visual-pass artifacts in .superpowers/sdd/, gitignored)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a focused visual treatment for the opt-in 3D knowledge graph, including matte nodes, halos, and always-visible labels.
    • Hovering over a node now highlights its immediate connections while dimming unrelated nodes and links.
    • Added a “Reset view” control to recenter and fit the graph.
    • Improved automatic camera fitting when graph data changes, with reduced-motion support.
  • Tests

    • Expanded coverage for rendering, highlighting, camera fitting, accessibility behavior, and visual styling.

AndresL230and others added 9 commits August 5, 2026 16:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght halo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… run warmup ticks under test/reduced-motion
Round 2 on d2e59a9's camera-framing fix, which was unit-correct but had no
runtime effect. Confirmed root cause via node_modules/3d-force-graph source
plus live frame-by-frame logging against a running dev server:
1. onEngineStop fires synchronously *before* tickFrame's "update node
positions" step writes that tick's x/y/z onto each node's Three.js
object (both run inside the same synchronous layoutTick() call, stop
callback first) — so fitting immediately (or even one rAF later) measures
a stale/near-empty bbox and ends up far too close once the real,
spread-out layout lands moments later. Fix: poll on requestAnimationFrame
until two consecutive getGraphBbox() reads agree (frame-capped safety
net) before calling zoomToFit.
2. Under cooldownTicks={0} (test/reduced-motion mode), warmupTicks was left
at its default of 0 too, so the 3D force simulation never actually ran —
nodes stayed at d3-force-3d's raw pre-simulation positions, an
overlapping cluster. KnowledgeGraph2D already solves this the same way
for its own reduced-motion path (`sim.alpha(1).tick(200).alpha(0).stop()`);
mirrored it here via `warmupTicks={reducedMotion || IS_TEST_MODE ? 200 : 0}`.
Verified visually against a dev server (:3010) proxying the held-up E2E
stack's backend: before, /tree in 3D was a tiny illegible clump; after, four
well-separated, readable node clusters filling most of the canvas, matching
the 2D layout's structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cel rAF on unmount
Round 3 re-review of the round-2 camera-framing fix (mechanism, determinism,
and tests independently verified) flagged one new Important issue and a
hygiene nit introduced by the async polling window itself:
1. The bbox-stabilization poll's closure never re-checked that it still
belonged to the current dataset. If nodes/edges changed while an old
poll was mid-flight (up to MAX_FRAMES/~1s wide), the stale closure kept
polling the live instance and could fire zoomToFit(400, 60) after —
clobbering — the fresh poll's correct fit for the new dataset. Fixed
with an epoch guard: pollEpochRef increments alongside didFitRef.current
= false in the [nodes, edges] reset effect; handleEngineStop captures
the epoch at poll start, and every scheduled frame callback bails
immediately if pollEpochRef.current no longer matches.
2. Hygiene: no cancelAnimationFrame cleanup tied the poll to component
lifetime, so an unmount mid-poll could leave up to ~60 dangling no-op
rAF callbacks. Now tracks the current rAF id in pollRafIdRef and cancels
it in an unmount-only effect.
TDD: wrote the new test first against the pre-fix code (stashed the
implementation), confirmed it failed exactly as described (stale poll
fires zoomToFit(400, 60) for the old dataset), then restored the
implementation and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99e248db-5120-4f64-8d7e-40dfec5218fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The opt-in 3D knowledge graph now uses custom spheres, halos, and labels. Shared helpers provide sizing, colors, themes, and adjacency. Hover interactions focus one-hop neighborhoods, links dim accordingly, and a reset control refits the camera.

Changes

Focused Minimal 3D graph

Layer / File(s)Summary
Styling helpers and rendering contract
docs/superpowers/..., frontend/src/components/graph/graph3dHelpers.ts, frontend/src/components/graph/graph3dHelpers.test.ts
Adds pure helpers for themes, colors, adjacency, node sizing, and label specifications with deterministic tests.
Custom node visuals
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/package.json, frontend/eslint-suppressions.json, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Renders matte spheres, focus halos, and SpriteText labels through custom Three.js objects and validates their properties.
Hover focus and link styling
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Tracks hovered nodes, dims non-neighbors and links, preserves persistent highlight halos, and supports string or resolved link endpoints.
Camera fitting and reset control
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx, frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
Adds ref-forwarded zoomToFit, stable-bounds auto-fit polling, stale-poll cancellation, warmup ticks, and the graph-zoom-reset button.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Pointer
participant KnowledgeGraph3D
participant VisualRegistry
participant ForceGraph3D
Pointer->>KnowledgeGraph3D: Hover a graph node
KnowledgeGraph3D->>VisualRegistry: Update node, label, and halo opacity
KnowledgeGraph3D->>ForceGraph3D: Recompute link color and width
ForceGraph3D-->>KnowledgeGraph3D: Render focused neighborhood
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 62.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main 3D graph changes: labels, hover focus, and recentering.
Description check✅ PassedThe description clearly explains the upgrade and includes comprehensive changes and testing details, despite using different section headings than the template.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3d-graph-focused-minimal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging987bfd7Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:09 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
frontend/src/components/graph/KnowledgeGraph3D.test.tsx (2)

553-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the frame-cap test deterministic and pin the cap value.

Line 557 uses Math.random() to produce a bbox that never repeats. The sibling test at Line 588 uses a monotonic counter for the same purpose, which is deterministic.

The test also asserts only that zoomToFit fired once. It does not assert how many frames elapsed. If MAX_FRAMES regressed from 60 to 2, this test would still pass. Assert the read count to pin the cap.

🧪 Proposed deterministic frame-cap test
- getGraphBboxMock.mockImplementation(() => ({ x: [0, Math.random()], y: [0, 0], z: [0, 0] }));+ let capCounter = 0;+ getGraphBboxMock.mockImplementation(() => ({ x: [0, ++capCounter], y: [0, 0], z: [0, 0] }));
 expect(zoomToFitSpy).toHaveBeenCalledTimes(1);
expect(zoomToFitSpy).toHaveBeenCalledWith(400, 60);
+ // Pins MAX_FRAMES: the poll reads the bbox once per frame up to the cap.+ expect(getGraphBboxMock).toHaveBeenCalledTimes(60);
expect(raf.queue.length).toBe(0); // no frame scheduled after the cap-triggered fit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 553 -
573, Make the frame-cap test deterministic by replacing Math.random() in
getGraphBboxMock with a monotonic counter so each bounding-box read differs
predictably. Track the number of bbox reads and assert it equals the configured
MAX_FRAMES value (60), while preserving the existing single fit and empty RAF
queue assertions.

171-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the unmount cancellation path.

captureAnimationFrames already spies on cancelAnimationFrame at Line 178, and the spy is exposed only through restore(). No test asserts that the unmount cleanup effect in KnowledgeGraph3D.tsx (Lines 202-206) cancels the pending frame. The PR lists that cleanup as a deliverable, so a regression that drops the effect would pass the current suite.

Return the cancelAnimationFrame spy from the helper, then assert it in a new test.

🧪 Proposed helper change and test
 const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
return {
queue,
+ caf,
it("cancels the in-flight bbox poll on unmount",()=>{getGraphBboxMock.mockReturnValue({x: [0,1],y: [0,1],z: [0,1]});constraf=captureAnimationFrames();const{ unmount }=render(<KnowledgeGraph3Dnodes={[makeNode()]}edges={[]}/>);act(()=>(lastProps!.onEngineStopas()=>void)());expect(raf.queue.length).toBe(1);unmount();expect(raf.caf).toHaveBeenCalledTimes(1);raf.restore();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 171 -
193, Update captureAnimationFrames to expose the cancelAnimationFrame spy, then
add a KnowledgeGraph3D unmount test that starts the bbox polling frame through
onEngineStop, unmounts the component, and asserts the cancellation spy was
called. Restore the animation-frame spies after the assertion.
frontend/src/components/graph/KnowledgeGraph3D.tsx (1)

303-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the link colors from the resolved theme instead of hardcoded RGB literals.

Lines 306, 311, and 312 hardcode rgba(138, 131, 114, ...) and rgba(138, 154, 91, ...). 138, 154, 91 is #8a9a5b, which is FALLBACK_THEME.accent. Nodes and halos use resolveGraphTheme(), which reads --accent and --ink-200 from CSS. If a deployment or an alternate color scheme overrides those variables, node colors follow the theme and link colors do not.

Convert theme.accent and theme.dim to RGB components once, then build the rgba strings from them.

🎨 Proposed theme-derived link colors
+ // Link colors follow the same resolved theme as nodes and halos.+ const linkRgb = React.useMemo(() => {+ const rgb = (hex: string) => {+ const c = new THREE.Color(hex);+ return `${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}`;+ };+ return { lit: rgb(theme.accent), dim: rgb(theme.dim) };+ }, [theme]);+
const linkColor = React.useCallback(
(l: object) => {
const link = l as FG3DLink;
- if (!hoverId) return `rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;+ if (!hoverId) return `rgba(${linkRgb.dim}, ${BASE_LINK_ALPHA})`;
const lit = linkEndId(link.source) === hoverId || linkEndId(link.target) === hoverId;
- // Lit links take the sage accent (rgb of `#8a9a5b`); dimmed links- // fade to near-invisible warm gray.+ // Lit links take the resolved accent; dimmed links fade to the+ // resolved dim token.
return lit
- ? `rgba(138, 154, 91, ${LIT_LINK_ALPHA})`- : `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;+ ? `rgba(${linkRgb.lit}, ${LIT_LINK_ALPHA})`+ : `rgba(${linkRgb.dim}, ${DIM_LINK_ALPHA})`;
},
- [hoverId],+ [hoverId, linkRgb],
);

If you accept this, update the literal expectations in frontend/src/components/graph/KnowledgeGraph3D.test.tsx at Lines 471, 478, 479, and 481-483 to build the same strings from FALLBACK_THEME.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 303 - 315,
Update the linkColor callback to use the resolved graph theme’s accent and dim
colors instead of hardcoded RGB values, converting theme.accent and theme.dim to
RGB components once and using them for the rgba strings. Preserve the existing
hover, lit, and dim alpha behavior, and update the corresponding
KnowledgeGraph3D.test.tsx expectations to construct the same values from
FALLBACK_THEME.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md`:
- Around line 937-947: Update the shell command around the E2E stack startup to
register an EXIT trap that runs make e2e-down before make e2e-up executes.
Preserve the existing test return-code collection and ensure teardown runs
exactly once, including when make e2e-up fails under set -e.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 243-285: Update nodeThreeObject to store the returned group
alongside sphereMat, halo, and label in visualsRef.current. Before replacing the
registry when the dataset changes and during component unmount, iterate the
existing entries and dispose both SphereGeometry instances, their materials, the
SpriteText canvas texture/material resources, and each group’s children; then
clear the registry before creating the new Map.
---
Nitpick comments:
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx`:
- Around line 553-573: Make the frame-cap test deterministic by replacing
Math.random() in getGraphBboxMock with a monotonic counter so each bounding-box
read differs predictably. Track the number of bbox reads and assert it equals
the configured MAX_FRAMES value (60), while preserving the existing single fit
and empty RAF queue assertions.
- Around line 171-193: Update captureAnimationFrames to expose the
cancelAnimationFrame spy, then add a KnowledgeGraph3D unmount test that starts
the bbox polling frame through onEngineStop, unmounts the component, and asserts
the cancellation spy was called. Restore the animation-frame spies after the
assertion.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 303-315: Update the linkColor callback to use the resolved graph
theme’s accent and dim colors instead of hardcoded RGB values, converting
theme.accent and theme.dim to RGB components once and using them for the rgba
strings. Preserve the existing hover, lit, and dim alpha behavior, and update
the corresponding KnowledgeGraph3D.test.tsx expectations to construct the same
values from FALLBACK_THEME.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d36f84-0079-4d42-a82d-ce69d01fc611

📥 Commits

Reviewing files that changed from the base of the PR and between 458ddb2 and c3ed797.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md
  • docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md
  • frontend/eslint-suppressions.json
  • frontend/package.json
  • frontend/src/components/graph/KnowledgeGraph3D.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/graph3dHelpers.test.ts
  • frontend/src/components/graph/graph3dHelpers.ts

Comment on lines +937 to +947
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Ensure teardown runs when make e2e-up fails.

set -e exits before make e2e-down if make e2e-up fails. This can leave a partially started E2E stack running. Register teardown with an EXIT trap before startup.

Proposed fix
 set -e
+ trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
- make e2e-down
exit $rc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
exit $rc
'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md` around lines
937 - 947, Update the shell command around the E2E stack startup to register an
EXIT trap that runs make e2e-down before make e2e-up executes. Preserve the
existing test return-code collection and ensure teardown runs exactly once,
including when make e2e-up fails under set -e.

Comment on lines +243 to +285
const nodeThreeObject = React.useCallback(
(raw: object) => {
const n = raw as FG3DNode;
const r = nodeRadius(n);
const color = baseNodeColor(n, theme);
const group = new THREE.Group();

const sphereMat = new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity: NODE_OPACITY,
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24), sphereMat));

// Focus halo: slightly larger translucent accent sphere, hidden
// until this node is hovered or is the persistent highlightId.
const halo = new THREE.Mesh(
new THREE.SphereGeometry(r * 1.4, 16, 16),
new THREE.MeshBasicMaterial({
color: theme.accent,
transparent: true,
opacity: 0.28,
depthWrite: false,
}),
);
halo.visible = n.id === hoverRef.current || n.id === highlightRef.current;
group.add(halo);

const spec = labelSpec(n);
const label = new SpriteText(n.name);
label.textHeight = spec.textHeight;
label.fontWeight = spec.fontWeight;
label.color = theme.ink;
label.fontFace = '"JetBrains Mono", monospace';
label.material.transparent = true;
label.position.set(0, -(r + spec.textHeight + 1.5), 0);
group.add(label);

visualsRef.current.set(n.id, { sphereMat, label, halo, baseColor: color });
return group;
},
[theme],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the repository already disposes Three.js resources anywhere, and whether SpriteText exposes a dispose path.set -euo pipefail
rg -n --type=ts --type=tsx -C3 '\.dispose\s*\(' frontend/src ||echo"no dispose() calls found in frontend/src"
fd -t d 'three-spritetext' frontend/node_modules --max-depth 3 --exec sh -c 'ls "$1"; sed -n "1,80p" "$1/dist/three-spritetext.d.ts" 2>/dev/null' _ {}

Repository: SaplingLearn/Sapling

Length of output: 340


🏁 Script executed:

#!/bin/bash0bashset -euo pipefail
printf'Tracked KnowledgeGraph files:\n'
fd -i 'KnowledgeGraph3D\.(ts|tsx)$'.||trueprintf'\nGit file list for likely front-end components:\n'
git ls-files | rg 'frontend/src/(components|.*Graph|.*three|package\.json)$|componentst'printf'\nSearch dispose across tracked source files:\n'
git ls-files | rg '\.(ts|tsx)$'| xargs -r rg -n -- '-- dispose\(' or true

Repository: SaplingLearn/Sapling

Length of output: 289


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'File line count:\n'
wc -l "$FILE"printf'\nRelevant section 160-300:\n'
sed -n '160,300p'"$FILE"| nl -ba -v160
printf'\nSearch for three-spritetext references and package manifests:\n'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo""$FILE"||true
rg -n "three-spritetext|react-three-fiber|`@react-three`" frontend -g 'package.json' -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock'||true

Repository: SaplingLearn/Sapling

Length of output: 298


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'Relevant section 160-300:\n'
sed -n '160,300p'"$FILE"| awk '{printf "%5d %s\n", NR+159, $0}'printf'\nImports and dependency references:\n'
sed -n '1,80p'"$FILE"| awk '{printf "%5d %s\n", NR, $0}'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo|useEffect|React""$FILE"||trueprintf'\nDependency files:\n'forfin package.json frontend/package.json frontend/package-lock.json frontend/pnpm-lock.yaml frontend/yarn.lock frontend/package.json;doif [ -f"$f" ];thenprintf'\n--- %s ---\n'"$f"
rg -n "three-spritetext|react-three-fiber|`@react-three`|three""$f"||truefidoneprintf'\nSearch disposed resource handling across tracked source files:\n'
git ls-files | xargs -r rg -n -- '-- dispose\s*\('| rg '\.(ts|tsx)$'||true

Repository: SaplingLearn/Sapling

Length of output: 13950


🌐 Web query:

three-spritetext v1.10.0 source canvas dispose texture code

💡 Result:

In three-spritetext v1.10.0, the process of disposing of the previous texture when updating the canvas is handled within the library's internal update logic. When the text content or styling changes, the library generates a new canvas and updates the sprite's material. The disposal logic is implemented as follows: // Inject canvas into sprite if (this.material.map) this.material.map.dispose; // gc previous texture var texture = this.material.map = new THREE.Texture(canvas); texture.minFilter = THREE.LinearFilter; texture.colorSpace = THREE.SRGBColorSpace; By calling this.material.map.dispose, the library ensures that the old texture is properly removed from GPU memory before assigning the new texture created from the updated canvas [1]. This practice is essential for preventing memory leaks when frequently updating sprite text [1][2].

Citations:


Dispose every node visual object when the dataset changes.

nodeThreeObject creates per-node Three.js resources: the returned group, two meshes with SphereGeometry, two materials, and a SpriteText with a canvas-backed Texture. Line 178 reassigns the registry to a new Map on a new dataset, so those previous objects become unreachable while their GPU buffers remain allocated.

Track the complete visual entry in visualsRef.current, including the group, then dispose the geometries, textures, and materials before replacing the registry and on component unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 243 - 285,
Update nodeThreeObject to store the returned group alongside sphereMat, halo,
and label in visualsRef.current. Before replacing the registry when the dataset
changes and during component unmount, iterate the existing entries and dispose
both SphereGeometry instances, their materials, the SpriteText canvas
texture/material resources, and each group’s children; then clear the registry
before creating the new Map.

…ion instant fit, warmupTicks pins
Three Important findings from the whole-branch final review, plus a doc
amendment, fixed in one wave:
1. Accent drift, one color one source: FALLBACK_THEME.accent was still the
retired sage (#8a9a5b) while globals.css's --accent is now the brighter
forest (#2d8f5c) — so the focus halo (theme.accent) rendered forest
while its own lit edges (hardcoded sage rgb) rendered sage. Corrected
FALLBACK_THEME.accent to #2d8f5c and added hexToRgbTriplet(), a small
pure helper next to resolveGraphTheme, so linkColor derives the lit-link
rgba from theme.accent instead of a second hardcoded copy that can
drift.
2. Reduced-motion camera animation: both zoomToFit call sites (the
engine-settle auto-fit and the manual recenter button) now gate their
duration — `reducedMotion || IS_TEST_MODE ? 0 : 400` — since these are
system-initiated camera flies and an animated 400ms fly is itself a
motion violation on the paths that already zero cooldownTicks to
eliminate motion. Padding unified to 60 across both call sites
(recenter button previously used 40).
3. warmupTicks was untested: pinned 200 in both the reduced-motion test
(KnowledgeGraph3D.test.tsx) and the test-mode determinism test
(KnowledgeGraph3D.testmode.test.tsx), and 0 in a new default-motion-path
test — plus new coverage for the reduced-motion/test-mode instant-fit
gating on the recenter button (the auto-fit path shares the identical
ternary, already exercised by the bbox-stabilization tests under
default motion).
Also amended docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md's
"Camera & motion" section to document both justified deviations (the
bbox-stabilization auto-fit poll + epoch guard, and warmupTicks=200 under
test/reduced-motion mirroring KnowledgeGraph2D's sim.tick(200) precedent),
and replaced the stale #8a9a5b halo color reference with "the app accent
token (--accent, currently #2d8f5c), resolved at mount".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — graph-3d Focused Minimal upgrade

This turns the 3D mode from bare library spheres into the Focused Minimal design: nodeThreeObject groups (matte MeshLambertMaterial sphere + hidden accent halo + always-visible three-spritetext label), a visualsRef registry so hover-focus mutates materials instead of rebuilding geometry, linkColor/linkWidth re-keyed on hover, a recenter button reusing the 2D control's title="Reset view" (which is what globals.css:609 keys on to hide it on the tutor rail), and a bbox-stabilisation rAF poll that drives a one-shot zoomToFit on the first engine settle. The pure-helper extraction into graph3dHelpers.ts is clean, the auto-fit poll's epoch guard and rAF-cancel-on-unmount are correct, and the SSR/testid/click-whitelist contracts are all preserved. Two things I checked and want to save you the time on: the library does own GPU disposal (react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate recursively disposes each group's geometries, materials and the sprite's material.map), and zoomToFit is null-safe on an empty graph — so CodeRabbit's disposal comment is not a real leak. What does need fixing is the hover path's interaction with three-forcegraph's prop-change semantics, plus the new lint suppressions and the merge state.

Findings

P1

[P1] Every hover transition destroys and rebuilds every link meshfrontend/src/components/graph/KnowledgeGraph3D.tsx:326-335

constlinkWidth=React.useCallback((l: object)=>{constlink=lasFG3DLink;constbase=0.4+(link.strength||0)*0.6;if(!hoverId)returnbase;constlit=linkEndId(link.source)===hoverId||linkEndId(link.target)===hoverId;returnlit ? base+0.6 : base;},[hoverId],);

hoverId is React state, so each hover enter/leave re-renders and hands react-force-graph-3d a new linkWidth function identity. react-kapsule pushes any identity-changed prop straight through (react-kapsule.mjs:30-33; linkWidth is a plain prop per react-force-graph-3d.mjs:126-129), and three-forcegraph treats linkWidth as an object-invalidating prop: three-forcegraph.mjs:1199-1201if (state._flushObjects || hasAnyPropChanged(['linkThreeObject','linkThreeObjectExtend','linkWidth'])) state.linkDataMapper.clear();. clear() is digest([]), which scene.remove()s and _deallocate()s (geometry.dispose + material.dispose) every link object and then recreates all of them. These aren't cheap lines either — useCylinder = !!widthAccessor(link) (:1220) is always true here, so each link is a CylinderGeometry mesh. On a few-hundred-edge graph that's a full teardown/rebuild of the link layer on every mouse enter and every mouse leave. This is exactly what docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md:138-142 promised not to do: "Hover mechanics — no per-hover geometry rebuilds … (links are cheap line materials)." Direction: give linkWidth a stable identity that reads hoverRef.current; linkColor's own change already triggers the digest, and the link onUpdateObj re-reads widthAccessor(link) each digest, so widths still update — just without the clear(). (Nothing visually breaks — three-forcegraph.mjs:1483 sets engineRunning = true at the end of every update(), so the next layoutTick repositions the new meshes — but it does mean onEngineStop also re-fires on every hover; the didFitRef guard absorbs that correctly.)

[P1] Two new react-hooks/refs suppressions added for new production codefrontend/eslint-suppressions.json:99-103

"src/components/graph/KnowledgeGraph3D.tsx": {
"react-hooks/refs": {
"count": 2
}
},

The Canopy Engineering Style Guide §7 states the ratchet explicitly — "don't fix the whole backlog to land a change, but never add to it" — and this PR's own plan repeats it at docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md:20: "eslint suppressions are a ratcheted baseline — new code must be clean." The two suppressed violations are real render-phase ref mutations: KnowledgeGraph3D.tsx:173highlightRef.current = highlightId; in the render body, and :179visualsRef.current = new Map(); inside the graphDatauseMemo. The useMemo one is the one with teeth — under React 19 concurrent rendering a render that starts and is then discarded still clears the registry, leaving the committed tree with an empty visualsRef and hover-focus a silent no-op. (The test-file suppressions — react-hooks/globals, react/display-name — are less load-bearing but also new.)

[P1] Branch is CONFLICTING with main, and main landed the #538 WebGL2 gate on the same three files

gh pr view 530 --json mergeable returns "mergeable":"CONFLICTING","mergeStateStatus":"DIRTY". Since the merge-base (ec34bf17), main has changed KnowledgeGraph.tsx (+201: WebGL2 capability probe, ErrorBoundary, graph-crash-fallback), added this header to KnowledgeGraph3D.tsx"#538: NEVER mount this component outside the KnowledgeGraph wrapper — three r163+ throws from the WebGLRenderer constructor when WebGL2 is unavailable" — and replaced the local next/dynamic mock in bothKnowledgeGraph3D.test.tsx and KnowledgeGraph3D.testmode.test.tsx with the shared @/test-utils/mockNextDynamic helper. This PR rewrites all three of those files from the pre-#538 base and still carries the old inline mock (KnowledgeGraph3D.test.tsx:98-114, KnowledgeGraph3D.testmode.test.tsx:36-52), so the conflict resolution has to re-apply main's work by hand rather than take either side wholesale. Worth rebasing before the draft flips to ready — main's shared helper does handle this PR's loader (which resolves to a bare function component, not a module) via typeof mod === "function" ? mod : (mod.default ?? null).

P2

[P2] nodeThreeObject doesn't apply the active focus state to the nodes it (re)buildsfrontend/src/components/graph/KnowledgeGraph3D.tsx:251-286

constsphereMat=newTHREE.MeshLambertMaterial({
color,transparent: true,opacity: NODE_OPACITY,});

The halo correctly reads the live refs (halo.visible = n.id === hoverRef.current || n.id === highlightRef.current), but the sphere is always built at full baseColor/NODE_OPACITY and the label at full opacity. Any nodes/edges identity change re-runs the graphData memo (clearing visualsRef at :179) and makes three-forcegraph rebuild every node object — node objects are keyed by identity and the memo mints fresh {...n} clones. If the pointer is still over a node at that moment (a tutor graph_update refreshing the Learn rail graph, a filter change on /tree), the hovered node keeps its halo but nothing dims: the focus effect renders half-applied until the pointer moves. The [applyFocus, highlightId] re-assert effect can't cover it because it runs at commit, before the library rebuilds. The component test doesn't catch this because it calls nodeThreeObject manually before driving onNodeHover (KnowledgeGraph3D.test.tsx:451-456), which is the opposite order.

[P2] A halo geometry + material is allocated for every node although at most two are ever visiblefrontend/src/components/graph/KnowledgeGraph3D.tsx:256-270

group.add(newTHREE.Mesh(newTHREE.SphereGeometry(r,24,24),sphereMat));// Focus halo: slightly larger translucent accent sphere, hidden// until this node is hovered or is the persistent highlightId.consthalo=newTHREE.Mesh(newTHREE.SphereGeometry(r*1.4,16,16),newTHREE.MeshBasicMaterial({

halo.visible is only ever true for the hovered node and highlightId, so an N-node graph carries N−2 unused SphereGeometry + MeshBasicMaterial pairs for the lifetime of the dataset, re-allocated in full on every refresh. A single shared unit-sphere geometry scaled per node, or lazy creation on first focus, avoids it. Separately, the main sphere went from the library's nodeResolution={16} (pre-patch) to SphereGeometry(r, 24, 24) — 2.25× the triangles per node — which the spec doesn't ask for.

[P2] Each label is rasterised five times at constructionfrontend/src/components/graph/KnowledgeGraph3D.tsx:273-279

constlabel=newSpriteText(n.name);label.textHeight=spec.textHeight;label.fontWeight=spec.fontWeight;label.color=theme.ink;label.fontFace='"JetBrains Mono", monospace';

Every three-spritetext setter re-runs _genCanvas() (measure text → resize canvas → repaint → new CanvasTexture). That's the constructor plus four setters = five full rasterisations and five texture allocations per node, paid again for the whole graph on every dataset refresh. SpriteText's constructor takes (text, textHeight, color), which folds three of the five. Related sizing note: _fontSize is fixed at 90 regardless of textHeight, so each label canvas is roughly 600×100 RGBA — at the spec's stated "few hundred nodes" ceiling that is tens of MB of texture memory. (No leak — _genCanvas disposes the previous map — just wasted work and VRAM.)

P3

[P3] The base and dim link colors are still hardcoded hex while the lit one derives from the themefrontend/src/components/graph/KnowledgeGraph3D.tsx:315,321

if(!hoverId)return`rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;
...
: `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;

138, 131, 114 is #8a8372 = --ink-400 (globals.css:47) — an existing token, not an unnamed value. graph3dHelpers.ts:33-36 says FALLBACK_THEME is "the ONE place that hex is allowed to be hardcoded", and the c65c075 pass fixed only the lit half. Adding a fourth GraphTheme field resolved from --ink-400 would close the loop and match the Canopy design-token rule ("Use the tokens; don't hardcode values"). Low stakes — the spec does sanction "today's rgba(138,131,114,…) family" — but the file's own comment now contradicts itself.

Test-plan checklist — per-item verdict

  1. graph3dHelpers unit tests + rewritten component tests — supported. graph3dHelpers.test.ts covers buildAdjacency, nodeVal/nodeRadius, mixHex, baseNodeColor, labelSpec, resolveGraphTheme; the component test covers node composition, hover dim/restore, halo persistence, linkColor, recenter (both durations), the bbox poll, the frame cap and the epoch guard. Two gaps: the newly exported hexToRgbTriplet has no unit test (only indirect coverage via the component test), and nothing asserts the unmount cancelAnimationFrame even though captureAnimationFrames already spies on it.
  2. full frontend suite, lint, next build — supported by CI (Frontend (lint + tsc + vitest) pass, Workers Builds: frontend-staging pass), with the caveat that lint only passes because of the new suppressions in the P1 finding above.
  3. flock'd E2E cycle: Chapter 1 journeys + oracles green — not verifiable from the PR; there is no E2E job among the checks on feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter #530. Taking it on trust.
  4. manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached) — the code claims hold up (the title="Reset view"globals.css:609 rail-hiding seam is real), but no screenshots are attached to the PR body or to any comment, so "attached" doesn't match the PR as it stands.
  5. fresh 3D screenshots reviewed on /tree + dashboard — artifacts are gitignored, so unverifiable from here. Note that the P1 hover finding wouldn't have shown up as a visual defect (it's allocation churn, not a broken frame), so this doesn't invalidate the pass.

What's good

  • The auto-fit story is genuinely well-engineered: the bbox-stabilisation poll, the MAX_FRAMES safety net, the pollEpochRef invalidation on mid-poll dataset change and the rAF cancel on unmount are all correct, and each one is pinned by a test that would fail if it regressed.
  • Reduced-motion is handled properly rather than performatively — zeroing the zoomToFit duration on both call sites because a system-initiated camera fly is itself motion, and the warmupTicks={200} reasoning mirrors KnowledgeGraph2D's sim.tick(200) precedent instead of inventing a new one.
  • graph3dHelpers.ts is the right shape: pure, three.js-free, unit-testable, and the "always return #rrggbb" color contract is documented where it will actually be read.
  • Every preserved contract really is preserved — sr-only list, all three testids, the onNodeClick id whitelist, and the dynamic(ssr:false) boundary all still hold, and the click-whitelist test now enumerates the full set of library-injected fields.

Verdict: solid design work with one real hover-path performance bug (P1), a ratchet violation (P1) and a rebase (P1) to clear before this leaves draft.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…nder-phase ref writes
Review round on #530.
P1 — linkWidth was a useCallback keyed on the hoverId state, so every
pointer enter AND leave handed react-force-graph-3d a new function
identity. three-forcegraph treats linkWidth as object-invalidating for
links (linkDataMapper.clear() -> digest([]) -> scene.remove() +
_deallocate() of every link mesh, all CylinderGeometry here since the
widths are never zero), so a hover was two full teardowns/rebuilds of
the whole link layer — the exact opposite of the spec's "no per-hover
geometry rebuilds". linkWidth now reads hoverRef with an empty dep
array; widths still follow the hover because linkColor's own identity
change triggers the digest and its onUpdateObj re-reads the width
accessor per link.
P1 — both react-hooks/refs violations were real render-phase ref
mutations. highlightId is now mirrored in an effect (the useRef
initializer covers first mount; kapsule's digest is debounce(fn, 1), so
the library cannot call nodeThreeObject before the commit's effects
have run). The visuals registry is no longer a ref cleared inside a
useMemo — a discarded concurrent render would still have cleared it and
left the committed tree with an empty registry and hover focus a silent
no-op. One memo now owns everything scoped to a dataset: graphData, the
registry, and the shared halo geometry/material.
P2 — nodeThreeObject applies the live focus state to nodes it builds,
so a dataset refresh under an active hover (tutor graph_update, /tree
filter) no longer renders half-focused until the pointer moves. Focus
styling has a single writer (applyVisualState) shared with the focus
pass.
P2 — one unit-sphere halo geometry + material per dataset, scaled per
node, instead of N pairs of which at most two are ever visible; main
sphere back to 16 segments (the library's nodeResolution default) from
24.
P2 — SpriteText labels use the (text, textHeight, color) constructor:
three of the five canvas rasterisations per label were setter-driven.
P3 — the resting and dimmed link rgb are derived from a new
GraphTheme.link resolved from --ink-400 instead of two hand-copied
rgba(138, 131, 114, …) literals, which contradicted FALLBACK_THEME's
"the ONE place that hex is allowed to be hardcoded".
Also re-applies #538's header warning about never mounting this
component outside the KnowledgeGraph wrapper.
…an gaps
Ports #538's work forward by hand so the eventual merge with main is a
no-op on these files: both 3D graph test files now use the shared
@/test-utils/mockNextDynamic helper (copied verbatim from main) instead
of a local inline dynamic mock. The helper renders the resolved
component with createElement rather than calling it as a function, so
the stub's hooks land in their own fiber, and it accepts this
component's loader, which resolves to a bare function component.
Removes every eslint-suppressions.json entry this branch had added, and
the pre-existing @typescript-eslint/no-explicit-any entry too, by
fixing the causes rather than baselining them:
- the react-force-graph-3d stub is a NAMED function inside forwardRef
(react/display-name) that records props in an effect instead of the
render body (react-hooks/globals — Testing Library flushes passive
effects before render/rerender/act return, so tests still read the
props synchronously);
- the capture is a spelled-out CapturedProps type, not
Record<string, any>.
Net effect vs the merge base: one entry removed, none added.
Test-plan gaps:
- the frame-cap test drove the bbox with Math.random() and asserted
only that zoomToFit fired once, so a MAX_FRAMES regression from 60 to
2 would have passed. Monotonic counter now, and the bbox read count is
pinned to the cap.
- captureAnimationFrames already spied on cancelAnimationFrame but
nothing asserted the unmount cleanup. The spy and the frame ids are
exposed and a test covers it.
- new test that linkWidth's identity is stable across a hover while
linkColor's is not, and that the same accessor reports the widened
value.
- new test that nodeThreeObject applies focus to nodes built AFTER
onNodeHover — the real order; the existing tests build first.
- direct unit tests for the newly exported hexToRgbTriplet.
Under `set -e` a failing `make e2e-up` exited the flock'd shell before
the trailing `make e2e-down` could run, leaving a half-started stack
holding the ports. Register `trap "make e2e-down || true" EXIT` first
and drop the explicit teardown.
…e graph
The only path that exercises the new highlightRef mirror effect is a
highlightId change while mounted — first mount is covered by the useRef
initializer, so mutation-testing showed the effect could be deleted
outright with all tests still green. This also pins the effect ORDER:
the mirror is declared before the re-assert effect, and moving it after
makes the re-assert read the stale highlight (verified: all three
mutations now fail).
Byte-identical to main in that region, so the shared-helper adoption
merges cleanly instead of conflicting on comment wording. The 'why an
await import inside a hoisted vi.mock factory' rationale already lives
in the helper's own docblock.
vi.mock calls are hoisted, so the order is behaviour-neutral — but this
block sat inside the exact hunk #538 rewrote on main (the next/dynamic
mock), which made an otherwise clean merge conflict on a pure addition.
Moved into untouched context above.
The plan's pre-review code sketches prescribe the exact five shapes the
review round removed (hover-keyed linkWidth, a visualsRef cleared inside
a useMemo, focus-blind nodeThreeObject, per-node halo pairs, hardcoded
link rgb). Left unmarked, the doc reads as authoritative and invites a
future reader to 'restore' them.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Every hover destroyed and rebuilt every link mesh.linkWidth was keyed on hoverId state, so each enter/leave handed the library a new function identity, and three-forcegraph treats linkWidth as object-invalidating → linkDataMapper.clear() → full teardown/rebuild of every CylinderGeometry link. That is exactly what the spec promised not to do. linkWidth now has a stable identity reading hoverRef.current; widths still track hover because linkColor already triggers the digest and onUpdateObj re-reads the accessor. Mutation-verified: restoring [hoverId] fails exactly one test.
  • Two new react-hooks/refs suppressions were added for new code, against the ratchet rule this PR's own plan restates. Both fixed at the source — the render-body ref write moved into an effect, and visualsRef replaced by a dataset-scoped epoch object so a discarded concurrent render cannot clobber the committed registry. The test-file suppressions were fixed properly too, not baselined. Suppressions went 29 files/193 → 28 files/187, deletion-only.
  • The branch was CONFLICTING with main (Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 landed the WebGL2 gate on the same three files). Ported forward by hand — no rebase: adopted @/test-utils/mockNextDynamic byte-identical to main, re-applied the Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 header, and shrank two more noise conflicts. git merge-tree --write-tree HEAD main now reports 0 conflicts in all three source files; the only residual is the generated eslint-suppressions.json baseline.

Minor / nits

nodeThreeObject applies the live focus state to nodes it rebuilds, so a mid-hover dataset refresh no longer renders focus half-applied · shared halo geometry instead of one per node, sphere segments back to 16 · SpriteText built via its constructor args, cutting five rasterisations per label · base/dim link colours derived from the theme rather than a hardcoded --ink-400 · e2e teardown trap in the plan doc · deterministic frame-cap test that pins MAX_FRAMES · unmount cancelAnimationFrame test · hexToRgbTriplet unit test.

Not done — CodeRabbit's disposal finding is not valid

react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate, which already disposes each group's geometries, materials and the sprite's material.map. Adding manual disposal would be redundant.

Verificationtsc clean · eslint 0 errors · 604 frontend tests pass · merge conflicts vs main: 3 source files → 0

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

…-minimal
# Conflicts:
#	frontend/eslint-suppressions.json
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter by AndresL230 · Pull Request #530 · SaplingLearn/Sapling · GitHub
Skip to content

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter - #530

Draft
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal
Draft

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter#530
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 3D graph mode grows from bare spheres into the approved Focused Minimal design (spec: docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md)
  • matte spheres + always-visible SpriteText labels (roots bold), unexplored tier washed toward warm gray
  • hover-focus: 1-hop neighborhood lit, rest dimmed; sage halo on hovered + tutor-highlighted nodes
  • ⌖ recenter (zoomToFit) sharing the 2D control's title/testid (auto-hidden on tutor rail)
  • new dep three-spritetext, client-chunk only; 3D stays opt-in behind the existing toggle
  • camera auto-fit on engine settle (bbox-stabilization poll + warmupTicks under test/reduced-motion) — 3 review-gated fix rounds, root-caused in a live browser

Test plan

  • graph3dHelpers unit tests + rewritten KnowledgeGraph3D component tests
  • full frontend suite, lint, next build
  • flock'd E2E cycle: Chapter 1 journeys + oracles green
  • manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached)
  • fresh 3D screenshots reviewed on /tree + dashboard (visual-pass artifacts in .superpowers/sdd/, gitignored)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a focused visual treatment for the opt-in 3D knowledge graph, including matte nodes, halos, and always-visible labels.
    • Hovering over a node now highlights its immediate connections while dimming unrelated nodes and links.
    • Added a “Reset view” control to recenter and fit the graph.
    • Improved automatic camera fitting when graph data changes, with reduced-motion support.
  • Tests

    • Expanded coverage for rendering, highlighting, camera fitting, accessibility behavior, and visual styling.

AndresL230and others added 9 commits August 5, 2026 16:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght halo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… run warmup ticks under test/reduced-motion
Round 2 on d2e59a9's camera-framing fix, which was unit-correct but had no
runtime effect. Confirmed root cause via node_modules/3d-force-graph source
plus live frame-by-frame logging against a running dev server:
1. onEngineStop fires synchronously *before* tickFrame's "update node
positions" step writes that tick's x/y/z onto each node's Three.js
object (both run inside the same synchronous layoutTick() call, stop
callback first) — so fitting immediately (or even one rAF later) measures
a stale/near-empty bbox and ends up far too close once the real,
spread-out layout lands moments later. Fix: poll on requestAnimationFrame
until two consecutive getGraphBbox() reads agree (frame-capped safety
net) before calling zoomToFit.
2. Under cooldownTicks={0} (test/reduced-motion mode), warmupTicks was left
at its default of 0 too, so the 3D force simulation never actually ran —
nodes stayed at d3-force-3d's raw pre-simulation positions, an
overlapping cluster. KnowledgeGraph2D already solves this the same way
for its own reduced-motion path (`sim.alpha(1).tick(200).alpha(0).stop()`);
mirrored it here via `warmupTicks={reducedMotion || IS_TEST_MODE ? 200 : 0}`.
Verified visually against a dev server (:3010) proxying the held-up E2E
stack's backend: before, /tree in 3D was a tiny illegible clump; after, four
well-separated, readable node clusters filling most of the canvas, matching
the 2D layout's structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cel rAF on unmount
Round 3 re-review of the round-2 camera-framing fix (mechanism, determinism,
and tests independently verified) flagged one new Important issue and a
hygiene nit introduced by the async polling window itself:
1. The bbox-stabilization poll's closure never re-checked that it still
belonged to the current dataset. If nodes/edges changed while an old
poll was mid-flight (up to MAX_FRAMES/~1s wide), the stale closure kept
polling the live instance and could fire zoomToFit(400, 60) after —
clobbering — the fresh poll's correct fit for the new dataset. Fixed
with an epoch guard: pollEpochRef increments alongside didFitRef.current
= false in the [nodes, edges] reset effect; handleEngineStop captures
the epoch at poll start, and every scheduled frame callback bails
immediately if pollEpochRef.current no longer matches.
2. Hygiene: no cancelAnimationFrame cleanup tied the poll to component
lifetime, so an unmount mid-poll could leave up to ~60 dangling no-op
rAF callbacks. Now tracks the current rAF id in pollRafIdRef and cancels
it in an unmount-only effect.
TDD: wrote the new test first against the pre-fix code (stashed the
implementation), confirmed it failed exactly as described (stale poll
fires zoomToFit(400, 60) for the old dataset), then restored the
implementation and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99e248db-5120-4f64-8d7e-40dfec5218fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The opt-in 3D knowledge graph now uses custom spheres, halos, and labels. Shared helpers provide sizing, colors, themes, and adjacency. Hover interactions focus one-hop neighborhoods, links dim accordingly, and a reset control refits the camera.

Changes

Focused Minimal 3D graph

Layer / File(s)Summary
Styling helpers and rendering contract
docs/superpowers/..., frontend/src/components/graph/graph3dHelpers.ts, frontend/src/components/graph/graph3dHelpers.test.ts
Adds pure helpers for themes, colors, adjacency, node sizing, and label specifications with deterministic tests.
Custom node visuals
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/package.json, frontend/eslint-suppressions.json, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Renders matte spheres, focus halos, and SpriteText labels through custom Three.js objects and validates their properties.
Hover focus and link styling
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Tracks hovered nodes, dims non-neighbors and links, preserves persistent highlight halos, and supports string or resolved link endpoints.
Camera fitting and reset control
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx, frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
Adds ref-forwarded zoomToFit, stable-bounds auto-fit polling, stale-poll cancellation, warmup ticks, and the graph-zoom-reset button.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Pointer
participant KnowledgeGraph3D
participant VisualRegistry
participant ForceGraph3D
Pointer->>KnowledgeGraph3D: Hover a graph node
KnowledgeGraph3D->>VisualRegistry: Update node, label, and halo opacity
KnowledgeGraph3D->>ForceGraph3D: Recompute link color and width
ForceGraph3D-->>KnowledgeGraph3D: Render focused neighborhood
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 62.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main 3D graph changes: labels, hover focus, and recentering.
Description check✅ PassedThe description clearly explains the upgrade and includes comprehensive changes and testing details, despite using different section headings than the template.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3d-graph-focused-minimal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging987bfd7Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:09 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
frontend/src/components/graph/KnowledgeGraph3D.test.tsx (2)

553-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the frame-cap test deterministic and pin the cap value.

Line 557 uses Math.random() to produce a bbox that never repeats. The sibling test at Line 588 uses a monotonic counter for the same purpose, which is deterministic.

The test also asserts only that zoomToFit fired once. It does not assert how many frames elapsed. If MAX_FRAMES regressed from 60 to 2, this test would still pass. Assert the read count to pin the cap.

🧪 Proposed deterministic frame-cap test
- getGraphBboxMock.mockImplementation(() => ({ x: [0, Math.random()], y: [0, 0], z: [0, 0] }));+ let capCounter = 0;+ getGraphBboxMock.mockImplementation(() => ({ x: [0, ++capCounter], y: [0, 0], z: [0, 0] }));
 expect(zoomToFitSpy).toHaveBeenCalledTimes(1);
expect(zoomToFitSpy).toHaveBeenCalledWith(400, 60);
+ // Pins MAX_FRAMES: the poll reads the bbox once per frame up to the cap.+ expect(getGraphBboxMock).toHaveBeenCalledTimes(60);
expect(raf.queue.length).toBe(0); // no frame scheduled after the cap-triggered fit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 553 -
573, Make the frame-cap test deterministic by replacing Math.random() in
getGraphBboxMock with a monotonic counter so each bounding-box read differs
predictably. Track the number of bbox reads and assert it equals the configured
MAX_FRAMES value (60), while preserving the existing single fit and empty RAF
queue assertions.

171-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the unmount cancellation path.

captureAnimationFrames already spies on cancelAnimationFrame at Line 178, and the spy is exposed only through restore(). No test asserts that the unmount cleanup effect in KnowledgeGraph3D.tsx (Lines 202-206) cancels the pending frame. The PR lists that cleanup as a deliverable, so a regression that drops the effect would pass the current suite.

Return the cancelAnimationFrame spy from the helper, then assert it in a new test.

🧪 Proposed helper change and test
 const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
return {
queue,
+ caf,
it("cancels the in-flight bbox poll on unmount",()=>{getGraphBboxMock.mockReturnValue({x: [0,1],y: [0,1],z: [0,1]});constraf=captureAnimationFrames();const{ unmount }=render(<KnowledgeGraph3Dnodes={[makeNode()]}edges={[]}/>);act(()=>(lastProps!.onEngineStopas()=>void)());expect(raf.queue.length).toBe(1);unmount();expect(raf.caf).toHaveBeenCalledTimes(1);raf.restore();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 171 -
193, Update captureAnimationFrames to expose the cancelAnimationFrame spy, then
add a KnowledgeGraph3D unmount test that starts the bbox polling frame through
onEngineStop, unmounts the component, and asserts the cancellation spy was
called. Restore the animation-frame spies after the assertion.
frontend/src/components/graph/KnowledgeGraph3D.tsx (1)

303-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the link colors from the resolved theme instead of hardcoded RGB literals.

Lines 306, 311, and 312 hardcode rgba(138, 131, 114, ...) and rgba(138, 154, 91, ...). 138, 154, 91 is #8a9a5b, which is FALLBACK_THEME.accent. Nodes and halos use resolveGraphTheme(), which reads --accent and --ink-200 from CSS. If a deployment or an alternate color scheme overrides those variables, node colors follow the theme and link colors do not.

Convert theme.accent and theme.dim to RGB components once, then build the rgba strings from them.

🎨 Proposed theme-derived link colors
+ // Link colors follow the same resolved theme as nodes and halos.+ const linkRgb = React.useMemo(() => {+ const rgb = (hex: string) => {+ const c = new THREE.Color(hex);+ return `${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}`;+ };+ return { lit: rgb(theme.accent), dim: rgb(theme.dim) };+ }, [theme]);+
const linkColor = React.useCallback(
(l: object) => {
const link = l as FG3DLink;
- if (!hoverId) return `rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;+ if (!hoverId) return `rgba(${linkRgb.dim}, ${BASE_LINK_ALPHA})`;
const lit = linkEndId(link.source) === hoverId || linkEndId(link.target) === hoverId;
- // Lit links take the sage accent (rgb of `#8a9a5b`); dimmed links- // fade to near-invisible warm gray.+ // Lit links take the resolved accent; dimmed links fade to the+ // resolved dim token.
return lit
- ? `rgba(138, 154, 91, ${LIT_LINK_ALPHA})`- : `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;+ ? `rgba(${linkRgb.lit}, ${LIT_LINK_ALPHA})`+ : `rgba(${linkRgb.dim}, ${DIM_LINK_ALPHA})`;
},
- [hoverId],+ [hoverId, linkRgb],
);

If you accept this, update the literal expectations in frontend/src/components/graph/KnowledgeGraph3D.test.tsx at Lines 471, 478, 479, and 481-483 to build the same strings from FALLBACK_THEME.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 303 - 315,
Update the linkColor callback to use the resolved graph theme’s accent and dim
colors instead of hardcoded RGB values, converting theme.accent and theme.dim to
RGB components once and using them for the rgba strings. Preserve the existing
hover, lit, and dim alpha behavior, and update the corresponding
KnowledgeGraph3D.test.tsx expectations to construct the same values from
FALLBACK_THEME.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md`:
- Around line 937-947: Update the shell command around the E2E stack startup to
register an EXIT trap that runs make e2e-down before make e2e-up executes.
Preserve the existing test return-code collection and ensure teardown runs
exactly once, including when make e2e-up fails under set -e.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 243-285: Update nodeThreeObject to store the returned group
alongside sphereMat, halo, and label in visualsRef.current. Before replacing the
registry when the dataset changes and during component unmount, iterate the
existing entries and dispose both SphereGeometry instances, their materials, the
SpriteText canvas texture/material resources, and each group’s children; then
clear the registry before creating the new Map.
---
Nitpick comments:
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx`:
- Around line 553-573: Make the frame-cap test deterministic by replacing
Math.random() in getGraphBboxMock with a monotonic counter so each bounding-box
read differs predictably. Track the number of bbox reads and assert it equals
the configured MAX_FRAMES value (60), while preserving the existing single fit
and empty RAF queue assertions.
- Around line 171-193: Update captureAnimationFrames to expose the
cancelAnimationFrame spy, then add a KnowledgeGraph3D unmount test that starts
the bbox polling frame through onEngineStop, unmounts the component, and asserts
the cancellation spy was called. Restore the animation-frame spies after the
assertion.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 303-315: Update the linkColor callback to use the resolved graph
theme’s accent and dim colors instead of hardcoded RGB values, converting
theme.accent and theme.dim to RGB components once and using them for the rgba
strings. Preserve the existing hover, lit, and dim alpha behavior, and update
the corresponding KnowledgeGraph3D.test.tsx expectations to construct the same
values from FALLBACK_THEME.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d36f84-0079-4d42-a82d-ce69d01fc611

📥 Commits

Reviewing files that changed from the base of the PR and between 458ddb2 and c3ed797.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md
  • docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md
  • frontend/eslint-suppressions.json
  • frontend/package.json
  • frontend/src/components/graph/KnowledgeGraph3D.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/graph3dHelpers.test.ts
  • frontend/src/components/graph/graph3dHelpers.ts

Comment on lines +937 to +947
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Ensure teardown runs when make e2e-up fails.

set -e exits before make e2e-down if make e2e-up fails. This can leave a partially started E2E stack running. Register teardown with an EXIT trap before startup.

Proposed fix
 set -e
+ trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
- make e2e-down
exit $rc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
exit $rc
'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md` around lines
937 - 947, Update the shell command around the E2E stack startup to register an
EXIT trap that runs make e2e-down before make e2e-up executes. Preserve the
existing test return-code collection and ensure teardown runs exactly once,
including when make e2e-up fails under set -e.

Comment on lines +243 to +285
const nodeThreeObject = React.useCallback(
(raw: object) => {
const n = raw as FG3DNode;
const r = nodeRadius(n);
const color = baseNodeColor(n, theme);
const group = new THREE.Group();

const sphereMat = new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity: NODE_OPACITY,
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24), sphereMat));

// Focus halo: slightly larger translucent accent sphere, hidden
// until this node is hovered or is the persistent highlightId.
const halo = new THREE.Mesh(
new THREE.SphereGeometry(r * 1.4, 16, 16),
new THREE.MeshBasicMaterial({
color: theme.accent,
transparent: true,
opacity: 0.28,
depthWrite: false,
}),
);
halo.visible = n.id === hoverRef.current || n.id === highlightRef.current;
group.add(halo);

const spec = labelSpec(n);
const label = new SpriteText(n.name);
label.textHeight = spec.textHeight;
label.fontWeight = spec.fontWeight;
label.color = theme.ink;
label.fontFace = '"JetBrains Mono", monospace';
label.material.transparent = true;
label.position.set(0, -(r + spec.textHeight + 1.5), 0);
group.add(label);

visualsRef.current.set(n.id, { sphereMat, label, halo, baseColor: color });
return group;
},
[theme],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the repository already disposes Three.js resources anywhere, and whether SpriteText exposes a dispose path.set -euo pipefail
rg -n --type=ts --type=tsx -C3 '\.dispose\s*\(' frontend/src ||echo"no dispose() calls found in frontend/src"
fd -t d 'three-spritetext' frontend/node_modules --max-depth 3 --exec sh -c 'ls "$1"; sed -n "1,80p" "$1/dist/three-spritetext.d.ts" 2>/dev/null' _ {}

Repository: SaplingLearn/Sapling

Length of output: 340


🏁 Script executed:

#!/bin/bash0bashset -euo pipefail
printf'Tracked KnowledgeGraph files:\n'
fd -i 'KnowledgeGraph3D\.(ts|tsx)$'.||trueprintf'\nGit file list for likely front-end components:\n'
git ls-files | rg 'frontend/src/(components|.*Graph|.*three|package\.json)$|componentst'printf'\nSearch dispose across tracked source files:\n'
git ls-files | rg '\.(ts|tsx)$'| xargs -r rg -n -- '-- dispose\(' or true

Repository: SaplingLearn/Sapling

Length of output: 289


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'File line count:\n'
wc -l "$FILE"printf'\nRelevant section 160-300:\n'
sed -n '160,300p'"$FILE"| nl -ba -v160
printf'\nSearch for three-spritetext references and package manifests:\n'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo""$FILE"||true
rg -n "three-spritetext|react-three-fiber|`@react-three`" frontend -g 'package.json' -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock'||true

Repository: SaplingLearn/Sapling

Length of output: 298


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'Relevant section 160-300:\n'
sed -n '160,300p'"$FILE"| awk '{printf "%5d %s\n", NR+159, $0}'printf'\nImports and dependency references:\n'
sed -n '1,80p'"$FILE"| awk '{printf "%5d %s\n", NR, $0}'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo|useEffect|React""$FILE"||trueprintf'\nDependency files:\n'forfin package.json frontend/package.json frontend/package-lock.json frontend/pnpm-lock.yaml frontend/yarn.lock frontend/package.json;doif [ -f"$f" ];thenprintf'\n--- %s ---\n'"$f"
rg -n "three-spritetext|react-three-fiber|`@react-three`|three""$f"||truefidoneprintf'\nSearch disposed resource handling across tracked source files:\n'
git ls-files | xargs -r rg -n -- '-- dispose\s*\('| rg '\.(ts|tsx)$'||true

Repository: SaplingLearn/Sapling

Length of output: 13950


🌐 Web query:

three-spritetext v1.10.0 source canvas dispose texture code

💡 Result:

In three-spritetext v1.10.0, the process of disposing of the previous texture when updating the canvas is handled within the library's internal update logic. When the text content or styling changes, the library generates a new canvas and updates the sprite's material. The disposal logic is implemented as follows: // Inject canvas into sprite if (this.material.map) this.material.map.dispose; // gc previous texture var texture = this.material.map = new THREE.Texture(canvas); texture.minFilter = THREE.LinearFilter; texture.colorSpace = THREE.SRGBColorSpace; By calling this.material.map.dispose, the library ensures that the old texture is properly removed from GPU memory before assigning the new texture created from the updated canvas [1]. This practice is essential for preventing memory leaks when frequently updating sprite text [1][2].

Citations:


Dispose every node visual object when the dataset changes.

nodeThreeObject creates per-node Three.js resources: the returned group, two meshes with SphereGeometry, two materials, and a SpriteText with a canvas-backed Texture. Line 178 reassigns the registry to a new Map on a new dataset, so those previous objects become unreachable while their GPU buffers remain allocated.

Track the complete visual entry in visualsRef.current, including the group, then dispose the geometries, textures, and materials before replacing the registry and on component unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 243 - 285,
Update nodeThreeObject to store the returned group alongside sphereMat, halo,
and label in visualsRef.current. Before replacing the registry when the dataset
changes and during component unmount, iterate the existing entries and dispose
both SphereGeometry instances, their materials, the SpriteText canvas
texture/material resources, and each group’s children; then clear the registry
before creating the new Map.

…ion instant fit, warmupTicks pins
Three Important findings from the whole-branch final review, plus a doc
amendment, fixed in one wave:
1. Accent drift, one color one source: FALLBACK_THEME.accent was still the
retired sage (#8a9a5b) while globals.css's --accent is now the brighter
forest (#2d8f5c) — so the focus halo (theme.accent) rendered forest
while its own lit edges (hardcoded sage rgb) rendered sage. Corrected
FALLBACK_THEME.accent to #2d8f5c and added hexToRgbTriplet(), a small
pure helper next to resolveGraphTheme, so linkColor derives the lit-link
rgba from theme.accent instead of a second hardcoded copy that can
drift.
2. Reduced-motion camera animation: both zoomToFit call sites (the
engine-settle auto-fit and the manual recenter button) now gate their
duration — `reducedMotion || IS_TEST_MODE ? 0 : 400` — since these are
system-initiated camera flies and an animated 400ms fly is itself a
motion violation on the paths that already zero cooldownTicks to
eliminate motion. Padding unified to 60 across both call sites
(recenter button previously used 40).
3. warmupTicks was untested: pinned 200 in both the reduced-motion test
(KnowledgeGraph3D.test.tsx) and the test-mode determinism test
(KnowledgeGraph3D.testmode.test.tsx), and 0 in a new default-motion-path
test — plus new coverage for the reduced-motion/test-mode instant-fit
gating on the recenter button (the auto-fit path shares the identical
ternary, already exercised by the bbox-stabilization tests under
default motion).
Also amended docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md's
"Camera & motion" section to document both justified deviations (the
bbox-stabilization auto-fit poll + epoch guard, and warmupTicks=200 under
test/reduced-motion mirroring KnowledgeGraph2D's sim.tick(200) precedent),
and replaced the stale #8a9a5b halo color reference with "the app accent
token (--accent, currently #2d8f5c), resolved at mount".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — graph-3d Focused Minimal upgrade

This turns the 3D mode from bare library spheres into the Focused Minimal design: nodeThreeObject groups (matte MeshLambertMaterial sphere + hidden accent halo + always-visible three-spritetext label), a visualsRef registry so hover-focus mutates materials instead of rebuilding geometry, linkColor/linkWidth re-keyed on hover, a recenter button reusing the 2D control's title="Reset view" (which is what globals.css:609 keys on to hide it on the tutor rail), and a bbox-stabilisation rAF poll that drives a one-shot zoomToFit on the first engine settle. The pure-helper extraction into graph3dHelpers.ts is clean, the auto-fit poll's epoch guard and rAF-cancel-on-unmount are correct, and the SSR/testid/click-whitelist contracts are all preserved. Two things I checked and want to save you the time on: the library does own GPU disposal (react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate recursively disposes each group's geometries, materials and the sprite's material.map), and zoomToFit is null-safe on an empty graph — so CodeRabbit's disposal comment is not a real leak. What does need fixing is the hover path's interaction with three-forcegraph's prop-change semantics, plus the new lint suppressions and the merge state.

Findings

P1

[P1] Every hover transition destroys and rebuilds every link meshfrontend/src/components/graph/KnowledgeGraph3D.tsx:326-335

constlinkWidth=React.useCallback((l: object)=>{constlink=lasFG3DLink;constbase=0.4+(link.strength||0)*0.6;if(!hoverId)returnbase;constlit=linkEndId(link.source)===hoverId||linkEndId(link.target)===hoverId;returnlit ? base+0.6 : base;},[hoverId],);

hoverId is React state, so each hover enter/leave re-renders and hands react-force-graph-3d a new linkWidth function identity. react-kapsule pushes any identity-changed prop straight through (react-kapsule.mjs:30-33; linkWidth is a plain prop per react-force-graph-3d.mjs:126-129), and three-forcegraph treats linkWidth as an object-invalidating prop: three-forcegraph.mjs:1199-1201if (state._flushObjects || hasAnyPropChanged(['linkThreeObject','linkThreeObjectExtend','linkWidth'])) state.linkDataMapper.clear();. clear() is digest([]), which scene.remove()s and _deallocate()s (geometry.dispose + material.dispose) every link object and then recreates all of them. These aren't cheap lines either — useCylinder = !!widthAccessor(link) (:1220) is always true here, so each link is a CylinderGeometry mesh. On a few-hundred-edge graph that's a full teardown/rebuild of the link layer on every mouse enter and every mouse leave. This is exactly what docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md:138-142 promised not to do: "Hover mechanics — no per-hover geometry rebuilds … (links are cheap line materials)." Direction: give linkWidth a stable identity that reads hoverRef.current; linkColor's own change already triggers the digest, and the link onUpdateObj re-reads widthAccessor(link) each digest, so widths still update — just without the clear(). (Nothing visually breaks — three-forcegraph.mjs:1483 sets engineRunning = true at the end of every update(), so the next layoutTick repositions the new meshes — but it does mean onEngineStop also re-fires on every hover; the didFitRef guard absorbs that correctly.)

[P1] Two new react-hooks/refs suppressions added for new production codefrontend/eslint-suppressions.json:99-103

"src/components/graph/KnowledgeGraph3D.tsx": {
"react-hooks/refs": {
"count": 2
}
},

The Canopy Engineering Style Guide §7 states the ratchet explicitly — "don't fix the whole backlog to land a change, but never add to it" — and this PR's own plan repeats it at docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md:20: "eslint suppressions are a ratcheted baseline — new code must be clean." The two suppressed violations are real render-phase ref mutations: KnowledgeGraph3D.tsx:173highlightRef.current = highlightId; in the render body, and :179visualsRef.current = new Map(); inside the graphDatauseMemo. The useMemo one is the one with teeth — under React 19 concurrent rendering a render that starts and is then discarded still clears the registry, leaving the committed tree with an empty visualsRef and hover-focus a silent no-op. (The test-file suppressions — react-hooks/globals, react/display-name — are less load-bearing but also new.)

[P1] Branch is CONFLICTING with main, and main landed the #538 WebGL2 gate on the same three files

gh pr view 530 --json mergeable returns "mergeable":"CONFLICTING","mergeStateStatus":"DIRTY". Since the merge-base (ec34bf17), main has changed KnowledgeGraph.tsx (+201: WebGL2 capability probe, ErrorBoundary, graph-crash-fallback), added this header to KnowledgeGraph3D.tsx"#538: NEVER mount this component outside the KnowledgeGraph wrapper — three r163+ throws from the WebGLRenderer constructor when WebGL2 is unavailable" — and replaced the local next/dynamic mock in bothKnowledgeGraph3D.test.tsx and KnowledgeGraph3D.testmode.test.tsx with the shared @/test-utils/mockNextDynamic helper. This PR rewrites all three of those files from the pre-#538 base and still carries the old inline mock (KnowledgeGraph3D.test.tsx:98-114, KnowledgeGraph3D.testmode.test.tsx:36-52), so the conflict resolution has to re-apply main's work by hand rather than take either side wholesale. Worth rebasing before the draft flips to ready — main's shared helper does handle this PR's loader (which resolves to a bare function component, not a module) via typeof mod === "function" ? mod : (mod.default ?? null).

P2

[P2] nodeThreeObject doesn't apply the active focus state to the nodes it (re)buildsfrontend/src/components/graph/KnowledgeGraph3D.tsx:251-286

constsphereMat=newTHREE.MeshLambertMaterial({
color,transparent: true,opacity: NODE_OPACITY,});

The halo correctly reads the live refs (halo.visible = n.id === hoverRef.current || n.id === highlightRef.current), but the sphere is always built at full baseColor/NODE_OPACITY and the label at full opacity. Any nodes/edges identity change re-runs the graphData memo (clearing visualsRef at :179) and makes three-forcegraph rebuild every node object — node objects are keyed by identity and the memo mints fresh {...n} clones. If the pointer is still over a node at that moment (a tutor graph_update refreshing the Learn rail graph, a filter change on /tree), the hovered node keeps its halo but nothing dims: the focus effect renders half-applied until the pointer moves. The [applyFocus, highlightId] re-assert effect can't cover it because it runs at commit, before the library rebuilds. The component test doesn't catch this because it calls nodeThreeObject manually before driving onNodeHover (KnowledgeGraph3D.test.tsx:451-456), which is the opposite order.

[P2] A halo geometry + material is allocated for every node although at most two are ever visiblefrontend/src/components/graph/KnowledgeGraph3D.tsx:256-270

group.add(newTHREE.Mesh(newTHREE.SphereGeometry(r,24,24),sphereMat));// Focus halo: slightly larger translucent accent sphere, hidden// until this node is hovered or is the persistent highlightId.consthalo=newTHREE.Mesh(newTHREE.SphereGeometry(r*1.4,16,16),newTHREE.MeshBasicMaterial({

halo.visible is only ever true for the hovered node and highlightId, so an N-node graph carries N−2 unused SphereGeometry + MeshBasicMaterial pairs for the lifetime of the dataset, re-allocated in full on every refresh. A single shared unit-sphere geometry scaled per node, or lazy creation on first focus, avoids it. Separately, the main sphere went from the library's nodeResolution={16} (pre-patch) to SphereGeometry(r, 24, 24) — 2.25× the triangles per node — which the spec doesn't ask for.

[P2] Each label is rasterised five times at constructionfrontend/src/components/graph/KnowledgeGraph3D.tsx:273-279

constlabel=newSpriteText(n.name);label.textHeight=spec.textHeight;label.fontWeight=spec.fontWeight;label.color=theme.ink;label.fontFace='"JetBrains Mono", monospace';

Every three-spritetext setter re-runs _genCanvas() (measure text → resize canvas → repaint → new CanvasTexture). That's the constructor plus four setters = five full rasterisations and five texture allocations per node, paid again for the whole graph on every dataset refresh. SpriteText's constructor takes (text, textHeight, color), which folds three of the five. Related sizing note: _fontSize is fixed at 90 regardless of textHeight, so each label canvas is roughly 600×100 RGBA — at the spec's stated "few hundred nodes" ceiling that is tens of MB of texture memory. (No leak — _genCanvas disposes the previous map — just wasted work and VRAM.)

P3

[P3] The base and dim link colors are still hardcoded hex while the lit one derives from the themefrontend/src/components/graph/KnowledgeGraph3D.tsx:315,321

if(!hoverId)return`rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;
...
: `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;

138, 131, 114 is #8a8372 = --ink-400 (globals.css:47) — an existing token, not an unnamed value. graph3dHelpers.ts:33-36 says FALLBACK_THEME is "the ONE place that hex is allowed to be hardcoded", and the c65c075 pass fixed only the lit half. Adding a fourth GraphTheme field resolved from --ink-400 would close the loop and match the Canopy design-token rule ("Use the tokens; don't hardcode values"). Low stakes — the spec does sanction "today's rgba(138,131,114,…) family" — but the file's own comment now contradicts itself.

Test-plan checklist — per-item verdict

  1. graph3dHelpers unit tests + rewritten component tests — supported. graph3dHelpers.test.ts covers buildAdjacency, nodeVal/nodeRadius, mixHex, baseNodeColor, labelSpec, resolveGraphTheme; the component test covers node composition, hover dim/restore, halo persistence, linkColor, recenter (both durations), the bbox poll, the frame cap and the epoch guard. Two gaps: the newly exported hexToRgbTriplet has no unit test (only indirect coverage via the component test), and nothing asserts the unmount cancelAnimationFrame even though captureAnimationFrames already spies on it.
  2. full frontend suite, lint, next build — supported by CI (Frontend (lint + tsc + vitest) pass, Workers Builds: frontend-staging pass), with the caveat that lint only passes because of the new suppressions in the P1 finding above.
  3. flock'd E2E cycle: Chapter 1 journeys + oracles green — not verifiable from the PR; there is no E2E job among the checks on feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter #530. Taking it on trust.
  4. manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached) — the code claims hold up (the title="Reset view"globals.css:609 rail-hiding seam is real), but no screenshots are attached to the PR body or to any comment, so "attached" doesn't match the PR as it stands.
  5. fresh 3D screenshots reviewed on /tree + dashboard — artifacts are gitignored, so unverifiable from here. Note that the P1 hover finding wouldn't have shown up as a visual defect (it's allocation churn, not a broken frame), so this doesn't invalidate the pass.

What's good

  • The auto-fit story is genuinely well-engineered: the bbox-stabilisation poll, the MAX_FRAMES safety net, the pollEpochRef invalidation on mid-poll dataset change and the rAF cancel on unmount are all correct, and each one is pinned by a test that would fail if it regressed.
  • Reduced-motion is handled properly rather than performatively — zeroing the zoomToFit duration on both call sites because a system-initiated camera fly is itself motion, and the warmupTicks={200} reasoning mirrors KnowledgeGraph2D's sim.tick(200) precedent instead of inventing a new one.
  • graph3dHelpers.ts is the right shape: pure, three.js-free, unit-testable, and the "always return #rrggbb" color contract is documented where it will actually be read.
  • Every preserved contract really is preserved — sr-only list, all three testids, the onNodeClick id whitelist, and the dynamic(ssr:false) boundary all still hold, and the click-whitelist test now enumerates the full set of library-injected fields.

Verdict: solid design work with one real hover-path performance bug (P1), a ratchet violation (P1) and a rebase (P1) to clear before this leaves draft.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…nder-phase ref writes
Review round on #530.
P1 — linkWidth was a useCallback keyed on the hoverId state, so every
pointer enter AND leave handed react-force-graph-3d a new function
identity. three-forcegraph treats linkWidth as object-invalidating for
links (linkDataMapper.clear() -> digest([]) -> scene.remove() +
_deallocate() of every link mesh, all CylinderGeometry here since the
widths are never zero), so a hover was two full teardowns/rebuilds of
the whole link layer — the exact opposite of the spec's "no per-hover
geometry rebuilds". linkWidth now reads hoverRef with an empty dep
array; widths still follow the hover because linkColor's own identity
change triggers the digest and its onUpdateObj re-reads the width
accessor per link.
P1 — both react-hooks/refs violations were real render-phase ref
mutations. highlightId is now mirrored in an effect (the useRef
initializer covers first mount; kapsule's digest is debounce(fn, 1), so
the library cannot call nodeThreeObject before the commit's effects
have run). The visuals registry is no longer a ref cleared inside a
useMemo — a discarded concurrent render would still have cleared it and
left the committed tree with an empty registry and hover focus a silent
no-op. One memo now owns everything scoped to a dataset: graphData, the
registry, and the shared halo geometry/material.
P2 — nodeThreeObject applies the live focus state to nodes it builds,
so a dataset refresh under an active hover (tutor graph_update, /tree
filter) no longer renders half-focused until the pointer moves. Focus
styling has a single writer (applyVisualState) shared with the focus
pass.
P2 — one unit-sphere halo geometry + material per dataset, scaled per
node, instead of N pairs of which at most two are ever visible; main
sphere back to 16 segments (the library's nodeResolution default) from
24.
P2 — SpriteText labels use the (text, textHeight, color) constructor:
three of the five canvas rasterisations per label were setter-driven.
P3 — the resting and dimmed link rgb are derived from a new
GraphTheme.link resolved from --ink-400 instead of two hand-copied
rgba(138, 131, 114, …) literals, which contradicted FALLBACK_THEME's
"the ONE place that hex is allowed to be hardcoded".
Also re-applies #538's header warning about never mounting this
component outside the KnowledgeGraph wrapper.
…an gaps
Ports #538's work forward by hand so the eventual merge with main is a
no-op on these files: both 3D graph test files now use the shared
@/test-utils/mockNextDynamic helper (copied verbatim from main) instead
of a local inline dynamic mock. The helper renders the resolved
component with createElement rather than calling it as a function, so
the stub's hooks land in their own fiber, and it accepts this
component's loader, which resolves to a bare function component.
Removes every eslint-suppressions.json entry this branch had added, and
the pre-existing @typescript-eslint/no-explicit-any entry too, by
fixing the causes rather than baselining them:
- the react-force-graph-3d stub is a NAMED function inside forwardRef
(react/display-name) that records props in an effect instead of the
render body (react-hooks/globals — Testing Library flushes passive
effects before render/rerender/act return, so tests still read the
props synchronously);
- the capture is a spelled-out CapturedProps type, not
Record<string, any>.
Net effect vs the merge base: one entry removed, none added.
Test-plan gaps:
- the frame-cap test drove the bbox with Math.random() and asserted
only that zoomToFit fired once, so a MAX_FRAMES regression from 60 to
2 would have passed. Monotonic counter now, and the bbox read count is
pinned to the cap.
- captureAnimationFrames already spied on cancelAnimationFrame but
nothing asserted the unmount cleanup. The spy and the frame ids are
exposed and a test covers it.
- new test that linkWidth's identity is stable across a hover while
linkColor's is not, and that the same accessor reports the widened
value.
- new test that nodeThreeObject applies focus to nodes built AFTER
onNodeHover — the real order; the existing tests build first.
- direct unit tests for the newly exported hexToRgbTriplet.
Under `set -e` a failing `make e2e-up` exited the flock'd shell before
the trailing `make e2e-down` could run, leaving a half-started stack
holding the ports. Register `trap "make e2e-down || true" EXIT` first
and drop the explicit teardown.
…e graph
The only path that exercises the new highlightRef mirror effect is a
highlightId change while mounted — first mount is covered by the useRef
initializer, so mutation-testing showed the effect could be deleted
outright with all tests still green. This also pins the effect ORDER:
the mirror is declared before the re-assert effect, and moving it after
makes the re-assert read the stale highlight (verified: all three
mutations now fail).
Byte-identical to main in that region, so the shared-helper adoption
merges cleanly instead of conflicting on comment wording. The 'why an
await import inside a hoisted vi.mock factory' rationale already lives
in the helper's own docblock.
vi.mock calls are hoisted, so the order is behaviour-neutral — but this
block sat inside the exact hunk #538 rewrote on main (the next/dynamic
mock), which made an otherwise clean merge conflict on a pure addition.
Moved into untouched context above.
The plan's pre-review code sketches prescribe the exact five shapes the
review round removed (hover-keyed linkWidth, a visualsRef cleared inside
a useMemo, focus-blind nodeThreeObject, per-node halo pairs, hardcoded
link rgb). Left unmarked, the doc reads as authoritative and invites a
future reader to 'restore' them.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Every hover destroyed and rebuilt every link mesh.linkWidth was keyed on hoverId state, so each enter/leave handed the library a new function identity, and three-forcegraph treats linkWidth as object-invalidating → linkDataMapper.clear() → full teardown/rebuild of every CylinderGeometry link. That is exactly what the spec promised not to do. linkWidth now has a stable identity reading hoverRef.current; widths still track hover because linkColor already triggers the digest and onUpdateObj re-reads the accessor. Mutation-verified: restoring [hoverId] fails exactly one test.
  • Two new react-hooks/refs suppressions were added for new code, against the ratchet rule this PR's own plan restates. Both fixed at the source — the render-body ref write moved into an effect, and visualsRef replaced by a dataset-scoped epoch object so a discarded concurrent render cannot clobber the committed registry. The test-file suppressions were fixed properly too, not baselined. Suppressions went 29 files/193 → 28 files/187, deletion-only.
  • The branch was CONFLICTING with main (Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 landed the WebGL2 gate on the same three files). Ported forward by hand — no rebase: adopted @/test-utils/mockNextDynamic byte-identical to main, re-applied the Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 header, and shrank two more noise conflicts. git merge-tree --write-tree HEAD main now reports 0 conflicts in all three source files; the only residual is the generated eslint-suppressions.json baseline.

Minor / nits

nodeThreeObject applies the live focus state to nodes it rebuilds, so a mid-hover dataset refresh no longer renders focus half-applied · shared halo geometry instead of one per node, sphere segments back to 16 · SpriteText built via its constructor args, cutting five rasterisations per label · base/dim link colours derived from the theme rather than a hardcoded --ink-400 · e2e teardown trap in the plan doc · deterministic frame-cap test that pins MAX_FRAMES · unmount cancelAnimationFrame test · hexToRgbTriplet unit test.

Not done — CodeRabbit's disposal finding is not valid

react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate, which already disposes each group's geometries, materials and the sprite's material.map. Adding manual disposal would be redundant.

Verificationtsc clean · eslint 0 errors · 604 frontend tests pass · merge conflicts vs main: 3 source files → 0

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

…-minimal
# Conflicts:
#	frontend/eslint-suppressions.json
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter by AndresL230 · Pull Request #530 · SaplingLearn/Sapling · GitHub
Skip to content

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter - #530

Draft
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal
Draft

feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter#530
AndresL230 wants to merge 18 commits into
mainfrom
feat/3d-graph-focused-minimal

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 3D graph mode grows from bare spheres into the approved Focused Minimal design (spec: docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md)
  • matte spheres + always-visible SpriteText labels (roots bold), unexplored tier washed toward warm gray
  • hover-focus: 1-hop neighborhood lit, rest dimmed; sage halo on hovered + tutor-highlighted nodes
  • ⌖ recenter (zoomToFit) sharing the 2D control's title/testid (auto-hidden on tutor rail)
  • new dep three-spritetext, client-chunk only; 3D stays opt-in behind the existing toggle
  • camera auto-fit on engine settle (bbox-stabilization poll + warmupTicks under test/reduced-motion) — 3 review-gated fix rounds, root-caused in a live browser

Test plan

  • graph3dHelpers unit tests + rewritten KnowledgeGraph3D component tests
  • full frontend suite, lint, next build
  • flock'd E2E cycle: Chapter 1 journeys + oracles green
  • manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached)
  • fresh 3D screenshots reviewed on /tree + dashboard (visual-pass artifacts in .superpowers/sdd/, gitignored)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a focused visual treatment for the opt-in 3D knowledge graph, including matte nodes, halos, and always-visible labels.
    • Hovering over a node now highlights its immediate connections while dimming unrelated nodes and links.
    • Added a “Reset view” control to recenter and fit the graph.
    • Improved automatic camera fitting when graph data changes, with reduced-motion support.
  • Tests

    • Expanded coverage for rendering, highlighting, camera fitting, accessibility behavior, and visual styling.

AndresL230and others added 9 commits August 5, 2026 16:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pgrade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght halo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… run warmup ticks under test/reduced-motion
Round 2 on d2e59a9's camera-framing fix, which was unit-correct but had no
runtime effect. Confirmed root cause via node_modules/3d-force-graph source
plus live frame-by-frame logging against a running dev server:
1. onEngineStop fires synchronously *before* tickFrame's "update node
positions" step writes that tick's x/y/z onto each node's Three.js
object (both run inside the same synchronous layoutTick() call, stop
callback first) — so fitting immediately (or even one rAF later) measures
a stale/near-empty bbox and ends up far too close once the real,
spread-out layout lands moments later. Fix: poll on requestAnimationFrame
until two consecutive getGraphBbox() reads agree (frame-capped safety
net) before calling zoomToFit.
2. Under cooldownTicks={0} (test/reduced-motion mode), warmupTicks was left
at its default of 0 too, so the 3D force simulation never actually ran —
nodes stayed at d3-force-3d's raw pre-simulation positions, an
overlapping cluster. KnowledgeGraph2D already solves this the same way
for its own reduced-motion path (`sim.alpha(1).tick(200).alpha(0).stop()`);
mirrored it here via `warmupTicks={reducedMotion || IS_TEST_MODE ? 200 : 0}`.
Verified visually against a dev server (:3010) proxying the held-up E2E
stack's backend: before, /tree in 3D was a tiny illegible clump; after, four
well-separated, readable node clusters filling most of the canvas, matching
the 2D layout's structure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cel rAF on unmount
Round 3 re-review of the round-2 camera-framing fix (mechanism, determinism,
and tests independently verified) flagged one new Important issue and a
hygiene nit introduced by the async polling window itself:
1. The bbox-stabilization poll's closure never re-checked that it still
belonged to the current dataset. If nodes/edges changed while an old
poll was mid-flight (up to MAX_FRAMES/~1s wide), the stale closure kept
polling the live instance and could fire zoomToFit(400, 60) after —
clobbering — the fresh poll's correct fit for the new dataset. Fixed
with an epoch guard: pollEpochRef increments alongside didFitRef.current
= false in the [nodes, edges] reset effect; handleEngineStop captures
the epoch at poll start, and every scheduled frame callback bails
immediately if pollEpochRef.current no longer matches.
2. Hygiene: no cancelAnimationFrame cleanup tied the poll to component
lifetime, so an unmount mid-poll could leave up to ~60 dangling no-op
rAF callbacks. Now tracks the current rAF id in pollRafIdRef and cancels
it in an unmount-only effect.
TDD: wrote the new test first against the pre-fix code (stashed the
implementation), confirmed it failed exactly as described (stale poll
fires zoomToFit(400, 60) for the old dataset), then restored the
implementation and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99e248db-5120-4f64-8d7e-40dfec5218fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The opt-in 3D knowledge graph now uses custom spheres, halos, and labels. Shared helpers provide sizing, colors, themes, and adjacency. Hover interactions focus one-hop neighborhoods, links dim accordingly, and a reset control refits the camera.

Changes

Focused Minimal 3D graph

Layer / File(s)Summary
Styling helpers and rendering contract
docs/superpowers/..., frontend/src/components/graph/graph3dHelpers.ts, frontend/src/components/graph/graph3dHelpers.test.ts
Adds pure helpers for themes, colors, adjacency, node sizing, and label specifications with deterministic tests.
Custom node visuals
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/package.json, frontend/eslint-suppressions.json, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Renders matte spheres, focus halos, and SpriteText labels through custom Three.js objects and validates their properties.
Hover focus and link styling
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx
Tracks hovered nodes, dims non-neighbors and links, preserves persistent highlight halos, and supports string or resolved link endpoints.
Camera fitting and reset control
frontend/src/components/graph/KnowledgeGraph3D.tsx, frontend/src/components/graph/KnowledgeGraph3D.test.tsx, frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
Adds ref-forwarded zoomToFit, stable-bounds auto-fit polling, stale-poll cancellation, warmup ticks, and the graph-zoom-reset button.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Pointer
participant KnowledgeGraph3D
participant VisualRegistry
participant ForceGraph3D
Pointer->>KnowledgeGraph3D: Hover a graph node
KnowledgeGraph3D->>VisualRegistry: Update node, label, and halo opacity
KnowledgeGraph3D->>ForceGraph3D: Recompute link color and width
ForceGraph3D-->>KnowledgeGraph3D: Render focused neighborhood
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 62.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main 3D graph changes: labels, hover focus, and recentering.
Description check✅ PassedThe description clearly explains the upgrade and includes comprehensive changes and testing details, despite using different section headings than the template.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3d-graph-focused-minimal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging987bfd7Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:09 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
frontend/src/components/graph/KnowledgeGraph3D.test.tsx (2)

553-573: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the frame-cap test deterministic and pin the cap value.

Line 557 uses Math.random() to produce a bbox that never repeats. The sibling test at Line 588 uses a monotonic counter for the same purpose, which is deterministic.

The test also asserts only that zoomToFit fired once. It does not assert how many frames elapsed. If MAX_FRAMES regressed from 60 to 2, this test would still pass. Assert the read count to pin the cap.

🧪 Proposed deterministic frame-cap test
- getGraphBboxMock.mockImplementation(() => ({ x: [0, Math.random()], y: [0, 0], z: [0, 0] }));+ let capCounter = 0;+ getGraphBboxMock.mockImplementation(() => ({ x: [0, ++capCounter], y: [0, 0], z: [0, 0] }));
 expect(zoomToFitSpy).toHaveBeenCalledTimes(1);
expect(zoomToFitSpy).toHaveBeenCalledWith(400, 60);
+ // Pins MAX_FRAMES: the poll reads the bbox once per frame up to the cap.+ expect(getGraphBboxMock).toHaveBeenCalledTimes(60);
expect(raf.queue.length).toBe(0); // no frame scheduled after the cap-triggered fit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 553 -
573, Make the frame-cap test deterministic by replacing Math.random() in
getGraphBboxMock with a monotonic counter so each bounding-box read differs
predictably. Track the number of bbox reads and assert it equals the configured
MAX_FRAMES value (60), while preserving the existing single fit and empty RAF
queue assertions.

171-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the unmount cancellation path.

captureAnimationFrames already spies on cancelAnimationFrame at Line 178, and the spy is exposed only through restore(). No test asserts that the unmount cleanup effect in KnowledgeGraph3D.tsx (Lines 202-206) cancels the pending frame. The PR lists that cleanup as a deliverable, so a regression that drops the effect would pass the current suite.

Return the cancelAnimationFrame spy from the helper, then assert it in a new test.

🧪 Proposed helper change and test
 const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
return {
queue,
+ caf,
it("cancels the in-flight bbox poll on unmount",()=>{getGraphBboxMock.mockReturnValue({x: [0,1],y: [0,1],z: [0,1]});constraf=captureAnimationFrames();const{ unmount }=render(<KnowledgeGraph3Dnodes={[makeNode()]}edges={[]}/>);act(()=>(lastProps!.onEngineStopas()=>void)());expect(raf.queue.length).toBe(1);unmount();expect(raf.caf).toHaveBeenCalledTimes(1);raf.restore();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx` around lines 171 -
193, Update captureAnimationFrames to expose the cancelAnimationFrame spy, then
add a KnowledgeGraph3D unmount test that starts the bbox polling frame through
onEngineStop, unmounts the component, and asserts the cancellation spy was
called. Restore the animation-frame spies after the assertion.
frontend/src/components/graph/KnowledgeGraph3D.tsx (1)

303-315: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the link colors from the resolved theme instead of hardcoded RGB literals.

Lines 306, 311, and 312 hardcode rgba(138, 131, 114, ...) and rgba(138, 154, 91, ...). 138, 154, 91 is #8a9a5b, which is FALLBACK_THEME.accent. Nodes and halos use resolveGraphTheme(), which reads --accent and --ink-200 from CSS. If a deployment or an alternate color scheme overrides those variables, node colors follow the theme and link colors do not.

Convert theme.accent and theme.dim to RGB components once, then build the rgba strings from them.

🎨 Proposed theme-derived link colors
+ // Link colors follow the same resolved theme as nodes and halos.+ const linkRgb = React.useMemo(() => {+ const rgb = (hex: string) => {+ const c = new THREE.Color(hex);+ return `${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}`;+ };+ return { lit: rgb(theme.accent), dim: rgb(theme.dim) };+ }, [theme]);+
const linkColor = React.useCallback(
(l: object) => {
const link = l as FG3DLink;
- if (!hoverId) return `rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;+ if (!hoverId) return `rgba(${linkRgb.dim}, ${BASE_LINK_ALPHA})`;
const lit = linkEndId(link.source) === hoverId || linkEndId(link.target) === hoverId;
- // Lit links take the sage accent (rgb of `#8a9a5b`); dimmed links- // fade to near-invisible warm gray.+ // Lit links take the resolved accent; dimmed links fade to the+ // resolved dim token.
return lit
- ? `rgba(138, 154, 91, ${LIT_LINK_ALPHA})`- : `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;+ ? `rgba(${linkRgb.lit}, ${LIT_LINK_ALPHA})`+ : `rgba(${linkRgb.dim}, ${DIM_LINK_ALPHA})`;
},
- [hoverId],+ [hoverId, linkRgb],
);

If you accept this, update the literal expectations in frontend/src/components/graph/KnowledgeGraph3D.test.tsx at Lines 471, 478, 479, and 481-483 to build the same strings from FALLBACK_THEME.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 303 - 315,
Update the linkColor callback to use the resolved graph theme’s accent and dim
colors instead of hardcoded RGB values, converting theme.accent and theme.dim to
RGB components once and using them for the rgba strings. Preserve the existing
hover, lit, and dim alpha behavior, and update the corresponding
KnowledgeGraph3D.test.tsx expectations to construct the same values from
FALLBACK_THEME.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md`:
- Around line 937-947: Update the shell command around the E2E stack startup to
register an EXIT trap that runs make e2e-down before make e2e-up executes.
Preserve the existing test return-code collection and ensure teardown runs
exactly once, including when make e2e-up fails under set -e.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 243-285: Update nodeThreeObject to store the returned group
alongside sphereMat, halo, and label in visualsRef.current. Before replacing the
registry when the dataset changes and during component unmount, iterate the
existing entries and dispose both SphereGeometry instances, their materials, the
SpriteText canvas texture/material resources, and each group’s children; then
clear the registry before creating the new Map.
---
Nitpick comments:
In `@frontend/src/components/graph/KnowledgeGraph3D.test.tsx`:
- Around line 553-573: Make the frame-cap test deterministic by replacing
Math.random() in getGraphBboxMock with a monotonic counter so each bounding-box
read differs predictably. Track the number of bbox reads and assert it equals
the configured MAX_FRAMES value (60), while preserving the existing single fit
and empty RAF queue assertions.
- Around line 171-193: Update captureAnimationFrames to expose the
cancelAnimationFrame spy, then add a KnowledgeGraph3D unmount test that starts
the bbox polling frame through onEngineStop, unmounts the component, and asserts
the cancellation spy was called. Restore the animation-frame spies after the
assertion.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx`:
- Around line 303-315: Update the linkColor callback to use the resolved graph
theme’s accent and dim colors instead of hardcoded RGB values, converting
theme.accent and theme.dim to RGB components once and using them for the rgba
strings. Preserve the existing hover, lit, and dim alpha behavior, and update
the corresponding KnowledgeGraph3D.test.tsx expectations to construct the same
values from FALLBACK_THEME.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d36f84-0079-4d42-a82d-ce69d01fc611

📥 Commits

Reviewing files that changed from the base of the PR and between 458ddb2 and c3ed797.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md
  • docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md
  • frontend/eslint-suppressions.json
  • frontend/package.json
  • frontend/src/components/graph/KnowledgeGraph3D.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/graph3dHelpers.test.ts
  • frontend/src/components/graph/graph3dHelpers.ts

Comment on lines +937 to +947
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Ensure teardown runs when make e2e-up fails.

set -e exits before make e2e-down if make e2e-up fails. This can leave a partially started E2E stack running. Register teardown with an EXIT trap before startup.

Proposed fix
 set -e
+ trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<value from e2e.yml>
export SAPLING_FUNCTION_HANDLERS=<value from e2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
- make e2e-down
exit $rc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
make e2e-down
exit $rc
'
flock /tmp/claude-1000/sapling-e2e-stack.lock bash -c '
set -e
trap "make e2e-down || true" EXIT
export SAPLING_MODEL_MODE=<valuefrome2e.yml>
export SAPLING_FUNCTION_HANDLERS=<valuefrome2e.yml>
make e2e-up
rc=0
(cd frontend && npx playwright test) || rc=$?
(cd backend && venv/bin/python -m e2e_oracles) || rc=$?
exit $rc
'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md` around lines
937 - 947, Update the shell command around the E2E stack startup to register an
EXIT trap that runs make e2e-down before make e2e-up executes. Preserve the
existing test return-code collection and ensure teardown runs exactly once,
including when make e2e-up fails under set -e.

Comment on lines +243 to +285
const nodeThreeObject = React.useCallback(
(raw: object) => {
const n = raw as FG3DNode;
const r = nodeRadius(n);
const color = baseNodeColor(n, theme);
const group = new THREE.Group();

const sphereMat = new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity: NODE_OPACITY,
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24), sphereMat));

// Focus halo: slightly larger translucent accent sphere, hidden
// until this node is hovered or is the persistent highlightId.
const halo = new THREE.Mesh(
new THREE.SphereGeometry(r * 1.4, 16, 16),
new THREE.MeshBasicMaterial({
color: theme.accent,
transparent: true,
opacity: 0.28,
depthWrite: false,
}),
);
halo.visible = n.id === hoverRef.current || n.id === highlightRef.current;
group.add(halo);

const spec = labelSpec(n);
const label = new SpriteText(n.name);
label.textHeight = spec.textHeight;
label.fontWeight = spec.fontWeight;
label.color = theme.ink;
label.fontFace = '"JetBrains Mono", monospace';
label.material.transparent = true;
label.position.set(0, -(r + spec.textHeight + 1.5), 0);
group.add(label);

visualsRef.current.set(n.id, { sphereMat, label, halo, baseColor: color });
return group;
},
[theme],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether the repository already disposes Three.js resources anywhere, and whether SpriteText exposes a dispose path.set -euo pipefail
rg -n --type=ts --type=tsx -C3 '\.dispose\s*\(' frontend/src ||echo"no dispose() calls found in frontend/src"
fd -t d 'three-spritetext' frontend/node_modules --max-depth 3 --exec sh -c 'ls "$1"; sed -n "1,80p" "$1/dist/three-spritetext.d.ts" 2>/dev/null' _ {}

Repository: SaplingLearn/Sapling

Length of output: 340


🏁 Script executed:

#!/bin/bash0bashset -euo pipefail
printf'Tracked KnowledgeGraph files:\n'
fd -i 'KnowledgeGraph3D\.(ts|tsx)$'.||trueprintf'\nGit file list for likely front-end components:\n'
git ls-files | rg 'frontend/src/(components|.*Graph|.*three|package\.json)$|componentst'printf'\nSearch dispose across tracked source files:\n'
git ls-files | rg '\.(ts|tsx)$'| xargs -r rg -n -- '-- dispose\(' or true

Repository: SaplingLearn/Sapling

Length of output: 289


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'File line count:\n'
wc -l "$FILE"printf'\nRelevant section 160-300:\n'
sed -n '160,300p'"$FILE"| nl -ba -v160
printf'\nSearch for three-spritetext references and package manifests:\n'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo""$FILE"||true
rg -n "three-spritetext|react-three-fiber|`@react-three`" frontend -g 'package.json' -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock'||true

Repository: SaplingLearn/Sapling

Length of output: 298


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="frontend/src/components/graph/KnowledgeGraph3D.tsx"printf'Relevant section 160-300:\n'
sed -n '160,300p'"$FILE"| awk '{printf "%5d %s\n", NR+159, $0}'printf'\nImports and dependency references:\n'
sed -n '1,80p'"$FILE"| awk '{printf "%5d %s\n", NR, $0}'
rg -n "three-spritetext|SpriteText|FR3D|FG3DNode|visualsRef|nodeThreeObject|useFrame|useThree|useMemo|useEffect|React""$FILE"||trueprintf'\nDependency files:\n'forfin package.json frontend/package.json frontend/package-lock.json frontend/pnpm-lock.yaml frontend/yarn.lock frontend/package.json;doif [ -f"$f" ];thenprintf'\n--- %s ---\n'"$f"
rg -n "three-spritetext|react-three-fiber|`@react-three`|three""$f"||truefidoneprintf'\nSearch disposed resource handling across tracked source files:\n'
git ls-files | xargs -r rg -n -- '-- dispose\s*\('| rg '\.(ts|tsx)$'||true

Repository: SaplingLearn/Sapling

Length of output: 13950


🌐 Web query:

three-spritetext v1.10.0 source canvas dispose texture code

💡 Result:

In three-spritetext v1.10.0, the process of disposing of the previous texture when updating the canvas is handled within the library's internal update logic. When the text content or styling changes, the library generates a new canvas and updates the sprite's material. The disposal logic is implemented as follows: // Inject canvas into sprite if (this.material.map) this.material.map.dispose; // gc previous texture var texture = this.material.map = new THREE.Texture(canvas); texture.minFilter = THREE.LinearFilter; texture.colorSpace = THREE.SRGBColorSpace; By calling this.material.map.dispose, the library ensures that the old texture is properly removed from GPU memory before assigning the new texture created from the updated canvas [1]. This practice is essential for preventing memory leaks when frequently updating sprite text [1][2].

Citations:


Dispose every node visual object when the dataset changes.

nodeThreeObject creates per-node Three.js resources: the returned group, two meshes with SphereGeometry, two materials, and a SpriteText with a canvas-backed Texture. Line 178 reassigns the registry to a new Map on a new dataset, so those previous objects become unreachable while their GPU buffers remain allocated.

Track the complete visual entry in visualsRef.current, including the group, then dispose the geometries, textures, and materials before replacing the registry and on component unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/graph/KnowledgeGraph3D.tsx` around lines 243 - 285,
Update nodeThreeObject to store the returned group alongside sphereMat, halo,
and label in visualsRef.current. Before replacing the registry when the dataset
changes and during component unmount, iterate the existing entries and dispose
both SphereGeometry instances, their materials, the SpriteText canvas
texture/material resources, and each group’s children; then clear the registry
before creating the new Map.

…ion instant fit, warmupTicks pins
Three Important findings from the whole-branch final review, plus a doc
amendment, fixed in one wave:
1. Accent drift, one color one source: FALLBACK_THEME.accent was still the
retired sage (#8a9a5b) while globals.css's --accent is now the brighter
forest (#2d8f5c) — so the focus halo (theme.accent) rendered forest
while its own lit edges (hardcoded sage rgb) rendered sage. Corrected
FALLBACK_THEME.accent to #2d8f5c and added hexToRgbTriplet(), a small
pure helper next to resolveGraphTheme, so linkColor derives the lit-link
rgba from theme.accent instead of a second hardcoded copy that can
drift.
2. Reduced-motion camera animation: both zoomToFit call sites (the
engine-settle auto-fit and the manual recenter button) now gate their
duration — `reducedMotion || IS_TEST_MODE ? 0 : 400` — since these are
system-initiated camera flies and an animated 400ms fly is itself a
motion violation on the paths that already zero cooldownTicks to
eliminate motion. Padding unified to 60 across both call sites
(recenter button previously used 40).
3. warmupTicks was untested: pinned 200 in both the reduced-motion test
(KnowledgeGraph3D.test.tsx) and the test-mode determinism test
(KnowledgeGraph3D.testmode.test.tsx), and 0 in a new default-motion-path
test — plus new coverage for the reduced-motion/test-mode instant-fit
gating on the recenter button (the auto-fit path shares the identical
ternary, already exercised by the bbox-stabilization tests under
default motion).
Also amended docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md's
"Camera & motion" section to document both justified deviations (the
bbox-stabilization auto-fit poll + epoch guard, and warmupTicks=200 under
test/reduced-motion mirroring KnowledgeGraph2D's sim.tick(200) precedent),
and replaced the stale #8a9a5b halo color reference with "the app accent
token (--accent, currently #2d8f5c), resolved at mount".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — graph-3d Focused Minimal upgrade

This turns the 3D mode from bare library spheres into the Focused Minimal design: nodeThreeObject groups (matte MeshLambertMaterial sphere + hidden accent halo + always-visible three-spritetext label), a visualsRef registry so hover-focus mutates materials instead of rebuilding geometry, linkColor/linkWidth re-keyed on hover, a recenter button reusing the 2D control's title="Reset view" (which is what globals.css:609 keys on to hide it on the tutor rail), and a bbox-stabilisation rAF poll that drives a one-shot zoomToFit on the first engine settle. The pure-helper extraction into graph3dHelpers.ts is clean, the auto-fit poll's epoch guard and rAF-cancel-on-unmount are correct, and the SSR/testid/click-whitelist contracts are all preserved. Two things I checked and want to save you the time on: the library does own GPU disposal (react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate recursively disposes each group's geometries, materials and the sprite's material.map), and zoomToFit is null-safe on an empty graph — so CodeRabbit's disposal comment is not a real leak. What does need fixing is the hover path's interaction with three-forcegraph's prop-change semantics, plus the new lint suppressions and the merge state.

Findings

P1

[P1] Every hover transition destroys and rebuilds every link meshfrontend/src/components/graph/KnowledgeGraph3D.tsx:326-335

constlinkWidth=React.useCallback((l: object)=>{constlink=lasFG3DLink;constbase=0.4+(link.strength||0)*0.6;if(!hoverId)returnbase;constlit=linkEndId(link.source)===hoverId||linkEndId(link.target)===hoverId;returnlit ? base+0.6 : base;},[hoverId],);

hoverId is React state, so each hover enter/leave re-renders and hands react-force-graph-3d a new linkWidth function identity. react-kapsule pushes any identity-changed prop straight through (react-kapsule.mjs:30-33; linkWidth is a plain prop per react-force-graph-3d.mjs:126-129), and three-forcegraph treats linkWidth as an object-invalidating prop: three-forcegraph.mjs:1199-1201if (state._flushObjects || hasAnyPropChanged(['linkThreeObject','linkThreeObjectExtend','linkWidth'])) state.linkDataMapper.clear();. clear() is digest([]), which scene.remove()s and _deallocate()s (geometry.dispose + material.dispose) every link object and then recreates all of them. These aren't cheap lines either — useCylinder = !!widthAccessor(link) (:1220) is always true here, so each link is a CylinderGeometry mesh. On a few-hundred-edge graph that's a full teardown/rebuild of the link layer on every mouse enter and every mouse leave. This is exactly what docs/superpowers/specs/2026-08-05-3d-graph-focused-minimal-design.md:138-142 promised not to do: "Hover mechanics — no per-hover geometry rebuilds … (links are cheap line materials)." Direction: give linkWidth a stable identity that reads hoverRef.current; linkColor's own change already triggers the digest, and the link onUpdateObj re-reads widthAccessor(link) each digest, so widths still update — just without the clear(). (Nothing visually breaks — three-forcegraph.mjs:1483 sets engineRunning = true at the end of every update(), so the next layoutTick repositions the new meshes — but it does mean onEngineStop also re-fires on every hover; the didFitRef guard absorbs that correctly.)

[P1] Two new react-hooks/refs suppressions added for new production codefrontend/eslint-suppressions.json:99-103

"src/components/graph/KnowledgeGraph3D.tsx": {
"react-hooks/refs": {
"count": 2
}
},

The Canopy Engineering Style Guide §7 states the ratchet explicitly — "don't fix the whole backlog to land a change, but never add to it" — and this PR's own plan repeats it at docs/superpowers/plans/2026-08-05-3d-graph-focused-minimal.md:20: "eslint suppressions are a ratcheted baseline — new code must be clean." The two suppressed violations are real render-phase ref mutations: KnowledgeGraph3D.tsx:173highlightRef.current = highlightId; in the render body, and :179visualsRef.current = new Map(); inside the graphDatauseMemo. The useMemo one is the one with teeth — under React 19 concurrent rendering a render that starts and is then discarded still clears the registry, leaving the committed tree with an empty visualsRef and hover-focus a silent no-op. (The test-file suppressions — react-hooks/globals, react/display-name — are less load-bearing but also new.)

[P1] Branch is CONFLICTING with main, and main landed the #538 WebGL2 gate on the same three files

gh pr view 530 --json mergeable returns "mergeable":"CONFLICTING","mergeStateStatus":"DIRTY". Since the merge-base (ec34bf17), main has changed KnowledgeGraph.tsx (+201: WebGL2 capability probe, ErrorBoundary, graph-crash-fallback), added this header to KnowledgeGraph3D.tsx"#538: NEVER mount this component outside the KnowledgeGraph wrapper — three r163+ throws from the WebGLRenderer constructor when WebGL2 is unavailable" — and replaced the local next/dynamic mock in bothKnowledgeGraph3D.test.tsx and KnowledgeGraph3D.testmode.test.tsx with the shared @/test-utils/mockNextDynamic helper. This PR rewrites all three of those files from the pre-#538 base and still carries the old inline mock (KnowledgeGraph3D.test.tsx:98-114, KnowledgeGraph3D.testmode.test.tsx:36-52), so the conflict resolution has to re-apply main's work by hand rather than take either side wholesale. Worth rebasing before the draft flips to ready — main's shared helper does handle this PR's loader (which resolves to a bare function component, not a module) via typeof mod === "function" ? mod : (mod.default ?? null).

P2

[P2] nodeThreeObject doesn't apply the active focus state to the nodes it (re)buildsfrontend/src/components/graph/KnowledgeGraph3D.tsx:251-286

constsphereMat=newTHREE.MeshLambertMaterial({
color,transparent: true,opacity: NODE_OPACITY,});

The halo correctly reads the live refs (halo.visible = n.id === hoverRef.current || n.id === highlightRef.current), but the sphere is always built at full baseColor/NODE_OPACITY and the label at full opacity. Any nodes/edges identity change re-runs the graphData memo (clearing visualsRef at :179) and makes three-forcegraph rebuild every node object — node objects are keyed by identity and the memo mints fresh {...n} clones. If the pointer is still over a node at that moment (a tutor graph_update refreshing the Learn rail graph, a filter change on /tree), the hovered node keeps its halo but nothing dims: the focus effect renders half-applied until the pointer moves. The [applyFocus, highlightId] re-assert effect can't cover it because it runs at commit, before the library rebuilds. The component test doesn't catch this because it calls nodeThreeObject manually before driving onNodeHover (KnowledgeGraph3D.test.tsx:451-456), which is the opposite order.

[P2] A halo geometry + material is allocated for every node although at most two are ever visiblefrontend/src/components/graph/KnowledgeGraph3D.tsx:256-270

group.add(newTHREE.Mesh(newTHREE.SphereGeometry(r,24,24),sphereMat));// Focus halo: slightly larger translucent accent sphere, hidden// until this node is hovered or is the persistent highlightId.consthalo=newTHREE.Mesh(newTHREE.SphereGeometry(r*1.4,16,16),newTHREE.MeshBasicMaterial({

halo.visible is only ever true for the hovered node and highlightId, so an N-node graph carries N−2 unused SphereGeometry + MeshBasicMaterial pairs for the lifetime of the dataset, re-allocated in full on every refresh. A single shared unit-sphere geometry scaled per node, or lazy creation on first focus, avoids it. Separately, the main sphere went from the library's nodeResolution={16} (pre-patch) to SphereGeometry(r, 24, 24) — 2.25× the triangles per node — which the spec doesn't ask for.

[P2] Each label is rasterised five times at constructionfrontend/src/components/graph/KnowledgeGraph3D.tsx:273-279

constlabel=newSpriteText(n.name);label.textHeight=spec.textHeight;label.fontWeight=spec.fontWeight;label.color=theme.ink;label.fontFace='"JetBrains Mono", monospace';

Every three-spritetext setter re-runs _genCanvas() (measure text → resize canvas → repaint → new CanvasTexture). That's the constructor plus four setters = five full rasterisations and five texture allocations per node, paid again for the whole graph on every dataset refresh. SpriteText's constructor takes (text, textHeight, color), which folds three of the five. Related sizing note: _fontSize is fixed at 90 regardless of textHeight, so each label canvas is roughly 600×100 RGBA — at the spec's stated "few hundred nodes" ceiling that is tens of MB of texture memory. (No leak — _genCanvas disposes the previous map — just wasted work and VRAM.)

P3

[P3] The base and dim link colors are still hardcoded hex while the lit one derives from the themefrontend/src/components/graph/KnowledgeGraph3D.tsx:315,321

if(!hoverId)return`rgba(138, 131, 114, ${BASE_LINK_ALPHA})`;
...
: `rgba(138, 131, 114, ${DIM_LINK_ALPHA})`;

138, 131, 114 is #8a8372 = --ink-400 (globals.css:47) — an existing token, not an unnamed value. graph3dHelpers.ts:33-36 says FALLBACK_THEME is "the ONE place that hex is allowed to be hardcoded", and the c65c075 pass fixed only the lit half. Adding a fourth GraphTheme field resolved from --ink-400 would close the loop and match the Canopy design-token rule ("Use the tokens; don't hardcode values"). Low stakes — the spec does sanction "today's rgba(138,131,114,…) family" — but the file's own comment now contradicts itself.

Test-plan checklist — per-item verdict

  1. graph3dHelpers unit tests + rewritten component tests — supported. graph3dHelpers.test.ts covers buildAdjacency, nodeVal/nodeRadius, mixHex, baseNodeColor, labelSpec, resolveGraphTheme; the component test covers node composition, hover dim/restore, halo persistence, linkColor, recenter (both durations), the bbox poll, the frame cap and the epoch guard. Two gaps: the newly exported hexToRgbTriplet has no unit test (only indirect coverage via the component test), and nothing asserts the unmount cancelAnimationFrame even though captureAnimationFrames already spies on it.
  2. full frontend suite, lint, next build — supported by CI (Frontend (lint + tsc + vitest) pass, Workers Builds: frontend-staging pass), with the caveat that lint only passes because of the new suppressions in the P1 finding above.
  3. flock'd E2E cycle: Chapter 1 journeys + oracles green — not verifiable from the PR; there is no E2E job among the checks on feat(graph-3d): Focused Minimal visual upgrade — labels, hover focus, recenter #530. Taking it on trust.
  4. manual visual pass on /tree, dashboard, tutor sidebar (screenshots attached) — the code claims hold up (the title="Reset view"globals.css:609 rail-hiding seam is real), but no screenshots are attached to the PR body or to any comment, so "attached" doesn't match the PR as it stands.
  5. fresh 3D screenshots reviewed on /tree + dashboard — artifacts are gitignored, so unverifiable from here. Note that the P1 hover finding wouldn't have shown up as a visual defect (it's allocation churn, not a broken frame), so this doesn't invalidate the pass.

What's good

  • The auto-fit story is genuinely well-engineered: the bbox-stabilisation poll, the MAX_FRAMES safety net, the pollEpochRef invalidation on mid-poll dataset change and the rAF cancel on unmount are all correct, and each one is pinned by a test that would fail if it regressed.
  • Reduced-motion is handled properly rather than performatively — zeroing the zoomToFit duration on both call sites because a system-initiated camera fly is itself motion, and the warmupTicks={200} reasoning mirrors KnowledgeGraph2D's sim.tick(200) precedent instead of inventing a new one.
  • graph3dHelpers.ts is the right shape: pure, three.js-free, unit-testable, and the "always return #rrggbb" color contract is documented where it will actually be read.
  • Every preserved contract really is preserved — sr-only list, all three testids, the onNodeClick id whitelist, and the dynamic(ssr:false) boundary all still hold, and the click-whitelist test now enumerates the full set of library-injected fields.

Verdict: solid design work with one real hover-path performance bug (P1), a ratchet violation (P1) and a rebase (P1) to clear before this leaves draft.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…nder-phase ref writes
Review round on #530.
P1 — linkWidth was a useCallback keyed on the hoverId state, so every
pointer enter AND leave handed react-force-graph-3d a new function
identity. three-forcegraph treats linkWidth as object-invalidating for
links (linkDataMapper.clear() -> digest([]) -> scene.remove() +
_deallocate() of every link mesh, all CylinderGeometry here since the
widths are never zero), so a hover was two full teardowns/rebuilds of
the whole link layer — the exact opposite of the spec's "no per-hover
geometry rebuilds". linkWidth now reads hoverRef with an empty dep
array; widths still follow the hover because linkColor's own identity
change triggers the digest and its onUpdateObj re-reads the width
accessor per link.
P1 — both react-hooks/refs violations were real render-phase ref
mutations. highlightId is now mirrored in an effect (the useRef
initializer covers first mount; kapsule's digest is debounce(fn, 1), so
the library cannot call nodeThreeObject before the commit's effects
have run). The visuals registry is no longer a ref cleared inside a
useMemo — a discarded concurrent render would still have cleared it and
left the committed tree with an empty registry and hover focus a silent
no-op. One memo now owns everything scoped to a dataset: graphData, the
registry, and the shared halo geometry/material.
P2 — nodeThreeObject applies the live focus state to nodes it builds,
so a dataset refresh under an active hover (tutor graph_update, /tree
filter) no longer renders half-focused until the pointer moves. Focus
styling has a single writer (applyVisualState) shared with the focus
pass.
P2 — one unit-sphere halo geometry + material per dataset, scaled per
node, instead of N pairs of which at most two are ever visible; main
sphere back to 16 segments (the library's nodeResolution default) from
24.
P2 — SpriteText labels use the (text, textHeight, color) constructor:
three of the five canvas rasterisations per label were setter-driven.
P3 — the resting and dimmed link rgb are derived from a new
GraphTheme.link resolved from --ink-400 instead of two hand-copied
rgba(138, 131, 114, …) literals, which contradicted FALLBACK_THEME's
"the ONE place that hex is allowed to be hardcoded".
Also re-applies #538's header warning about never mounting this
component outside the KnowledgeGraph wrapper.
…an gaps
Ports #538's work forward by hand so the eventual merge with main is a
no-op on these files: both 3D graph test files now use the shared
@/test-utils/mockNextDynamic helper (copied verbatim from main) instead
of a local inline dynamic mock. The helper renders the resolved
component with createElement rather than calling it as a function, so
the stub's hooks land in their own fiber, and it accepts this
component's loader, which resolves to a bare function component.
Removes every eslint-suppressions.json entry this branch had added, and
the pre-existing @typescript-eslint/no-explicit-any entry too, by
fixing the causes rather than baselining them:
- the react-force-graph-3d stub is a NAMED function inside forwardRef
(react/display-name) that records props in an effect instead of the
render body (react-hooks/globals — Testing Library flushes passive
effects before render/rerender/act return, so tests still read the
props synchronously);
- the capture is a spelled-out CapturedProps type, not
Record<string, any>.
Net effect vs the merge base: one entry removed, none added.
Test-plan gaps:
- the frame-cap test drove the bbox with Math.random() and asserted
only that zoomToFit fired once, so a MAX_FRAMES regression from 60 to
2 would have passed. Monotonic counter now, and the bbox read count is
pinned to the cap.
- captureAnimationFrames already spied on cancelAnimationFrame but
nothing asserted the unmount cleanup. The spy and the frame ids are
exposed and a test covers it.
- new test that linkWidth's identity is stable across a hover while
linkColor's is not, and that the same accessor reports the widened
value.
- new test that nodeThreeObject applies focus to nodes built AFTER
onNodeHover — the real order; the existing tests build first.
- direct unit tests for the newly exported hexToRgbTriplet.
Under `set -e` a failing `make e2e-up` exited the flock'd shell before
the trailing `make e2e-down` could run, leaving a half-started stack
holding the ports. Register `trap "make e2e-down || true" EXIT` first
and drop the explicit teardown.
…e graph
The only path that exercises the new highlightRef mirror effect is a
highlightId change while mounted — first mount is covered by the useRef
initializer, so mutation-testing showed the effect could be deleted
outright with all tests still green. This also pins the effect ORDER:
the mirror is declared before the re-assert effect, and moving it after
makes the re-assert read the stale highlight (verified: all three
mutations now fail).
Byte-identical to main in that region, so the shared-helper adoption
merges cleanly instead of conflicting on comment wording. The 'why an
await import inside a hoisted vi.mock factory' rationale already lives
in the helper's own docblock.
vi.mock calls are hoisted, so the order is behaviour-neutral — but this
block sat inside the exact hunk #538 rewrote on main (the next/dynamic
mock), which made an otherwise clean merge conflict on a pure addition.
Moved into untouched context above.
The plan's pre-review code sketches prescribe the exact five shapes the
review round removed (hover-keyed linkWidth, a visualsRef cleared inside
a useMemo, focus-blind nodeThreeObject, per-node halo pairs, hardcoded
link rgb). Left unmarked, the doc reads as authoritative and invites a
future reader to 'restore' them.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Every hover destroyed and rebuilt every link mesh.linkWidth was keyed on hoverId state, so each enter/leave handed the library a new function identity, and three-forcegraph treats linkWidth as object-invalidating → linkDataMapper.clear() → full teardown/rebuild of every CylinderGeometry link. That is exactly what the spec promised not to do. linkWidth now has a stable identity reading hoverRef.current; widths still track hover because linkColor already triggers the digest and onUpdateObj re-reads the accessor. Mutation-verified: restoring [hoverId] fails exactly one test.
  • Two new react-hooks/refs suppressions were added for new code, against the ratchet rule this PR's own plan restates. Both fixed at the source — the render-body ref write moved into an effect, and visualsRef replaced by a dataset-scoped epoch object so a discarded concurrent render cannot clobber the committed registry. The test-file suppressions were fixed properly too, not baselined. Suppressions went 29 files/193 → 28 files/187, deletion-only.
  • The branch was CONFLICTING with main (Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 landed the WebGL2 gate on the same three files). Ported forward by hand — no rebase: adopted @/test-utils/mockNextDynamic byte-identical to main, re-applied the Dashboard crashes to the root error fallback on browsers without WebGL when 3D graph mode is persisted #538 header, and shrank two more noise conflicts. git merge-tree --write-tree HEAD main now reports 0 conflicts in all three source files; the only residual is the generated eslint-suppressions.json baseline.

Minor / nits

nodeThreeObject applies the live focus state to nodes it rebuilds, so a mid-hover dataset refresh no longer renders focus half-applied · shared halo geometry instead of one per node, sphere segments back to 16 · SpriteText built via its constructor args, cutting five rasterisations per label · base/dim link colours derived from the theme rather than a hardcoded --ink-400 · e2e teardown trap in the plan doc · deterministic frame-cap test that pins MAX_FRAMES · unmount cancelAnimationFrame test · hexToRgbTriplet unit test.

Not done — CodeRabbit's disposal finding is not valid

react-kapsule calls _destructor on unmount → graphData({nodes:[],links:[]})_deallocate, which already disposes each group's geometries, materials and the sprite's material.map. Adding manual disposal would be redundant.

Verificationtsc clean · eslint 0 errors · 604 frontend tests pass · merge conflicts vs main: 3 source files → 0

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

…-minimal
# Conflicts:
#	frontend/eslint-suppressions.json
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez