Status: open task. Mobile shipped 2026-05-06 in PR #13 (feature/mobile-universe, merged at 0f1478a). This doc is the handoff brief so a fresh session can pick it up without prior context.
What’s done already
Mobile has the new behaviors. Web partially has them — most ambient-feel work was done in an earlier slice. Don’t reimplement what’s already there.
Already on web (do NOT touch)
- Continuous d3-force drift via
d3AlphaTarget(apps/web/app/(app)/universe/graph.tsx:75) - Hover-focus dim-everything-else (apps/web/app/(app)/universe/graph.tsx:161-164)
- Drag a node — built into
react-force-graph-2d(re-heats sim, link force pulls neighbors) — already works - localStorage filter persistence (apps/web/app/(app)/universe/graph.tsx:135)
- Cluster labels at low zoom (web has them; mobile skips)
- 2D ↔ 3D mode toggle (web only)
- Settling indicator
Missing on web — port from mobile
Three things only:
- “You” pinned anchor — a special node at canvas center, draggable, renders distinctively
- Orphan ring —
forceRadialpulls nodes with degree 0 onto a perimeter ring - Heading rename — “Universe” → “My Universe”
That’s it. The “rebuild the world” work was mobile only because mobile was much further behind.
Reference implementation (mobile)
All in apps/mobile/components/universe-graph.tsx. Key landmarks:
- YOU constants — search for
YOU_NODE_ID,YOU_RADIUS,YOU_COLOR,YOU_EDGE_COLOR,YOU_EDGE_WIDTH. The “You” node is added to the data array, pinned viafx/fyafter pre-positioning, and rendered inline with other nodes plus a separate spoke pass. - Orphan ring — search for
ORPHAN_RING_RADIUS,ORPHAN_RING_STRENGTH,forceRadial. Orphan IDs computed from adjacency map (nodes with no edges).forceRadialstrength returns 0 for connected nodes so they’re unaffected. - Drag handler that re-pins YOU — search for
endNodeDrag. On release, if the dragged node is YOU, fx/fy are set back to canvas center; otherwise cleared. - Tap handler that no-ops on YOU — search for
handleTap. If the hit node isYOU_NODE_ID, treats it like an empty-space tap (clears focus, doesn’t navigate).
Web file layout
- apps/web/app/(app)/universe/graph.tsx — 716 lines. The 2D + 3D renderer, force config, hover focus, edge filter, settling indicator. All your work goes here except the heading.
- apps/web/app/(app)/universe/page.tsx — server component, fetches data + renders the heading. Heading is at line 95 (
<h1 className="text-2xl font-medium">Universe</h1>). - apps/web/app/(app)/universe/loading.tsx — skeleton; probably no changes needed.
Web specifics to be aware of
The web Universe uses react-force-graph-2d (and react-force-graph-3d) — wrappers around d3-force. You don’t construct the simulation directly. Instead:
- Forces are configured imperatively via
fg.d3Force("name")after the ref is ready. See the existing pattern at apps/web/app/(app)/universe/graph.tsx:319-341. That’s where you’ll add theforceRadial("orphanRing"). - Nodes/links are passed as
graphDataprop. Adding YOU means including it in thegraphData.nodesarray. - Custom node rendering uses the
nodeCanvasObjectcallback (already present for hover-rings — read it before adding YOU rendering). - Click navigation uses
onNodeClick— special-case YOU there to skip routing to a non-existent/memory/__you__. - Drag is built-in. To “pin” YOU at center, set
fx/fyon the node object before passing tographData. The simulation honors them.
3D mode caveat: react-force-graph-3d uses Three.js sprites instead of canvas drawing. Custom node rendering needs nodeThreeObject. Consider scoping this PR to 2D only and noting the 3D follow-up — 3D adds complexity disproportionate to the visual win.
Concrete tasks
-
Add the YOU constants near the existing TOPIC_PALETTE block. Mirror mobile’s values exactly so the “You” anchor reads the same on both platforms.
-
Inject the YOU node into
graphData. When buildinggraphData.nodesin theuseMemo, push a synthetic node:{ id: YOU_NODE_ID, label: "You", topic: "", color: YOU_COLOR, fx: 0, fy: 0 }The
0,0is d3-force’s natural origin (whichforceX/forceYthen anchor; web doesn’t have explicitwidth/2, height/2like mobile because react-force-graph centers automatically). -
Render the YOU spokes. Use
linkCanvasObjector a custom paint pass — render lines from YOU’s currentx,yto every memory at low alpha (0.06), behind the real edges. Skip when a node is hovered (focus mode owns the visual). -
Render the YOU node distinctively. In
nodeCanvasObject, special-casenode.id === YOU_NODE_IDto draw the ink-dark dot at YOU’s size. The existing nodeCanvasObject already renders custom shapes for hover state — extend it. -
Add
forceRadialfor orphans. In thed3Forceconfiguration effect (apps/web/app/(app)/universe/graph.tsx:319-341), add:fg.d3Force("orphanRing", forceRadial(ORPHAN_RING_RADIUS, 0, 0).strength((n) => orphanIds.has(n.id) ? ORPHAN_RING_STRENGTH : 0));Compute
orphanIdsfrom edges (nodes with no incident edge, excluding YOU). Same logic as mobile’s data memo. -
Special-case YOU in
onNodeClick. Ifnode.id === YOU_NODE_ID, return early (no navigation, no focus shift). -
Special-case YOU in hover focus. YOU should not become “focused” via hover — its 1-hop neighborhood is empty (no edges), which would wrongly dim everything. Skip the hover effect when YOU is hovered.
-
Heading rename at apps/web/app/(app)/universe/page.tsx:95:
<h1>Universe</h1>→<h1>My Universe</h1>. Don’t touch the nav link inapps/web/app/(app)/layout.tsx— that should stay “Universe” so links and muscle memory don’t break (same convention as mobile).
Constants to mirror from mobile
const YOU_NODE_ID = "__you__";const YOU_COLOR = "#1F1F1F";// YOU radius — mobile uses NODE_RADIUS_MAX (3px). Web's nodes are// larger; size YOU at the equivalent visual weight, probably 4–5 px// of node "value" for react-force-graph's nodeRelSize default.const YOU_RADIUS = 5;const YOU_EDGE_COLOR = "rgba(31,31,31,0.06)";const YOU_EDGE_WIDTH = 0.5;const ORPHAN_RING_RADIUS = 80;const ORPHAN_RING_STRENGTH = 0.5;The web’s coordinate system is centered at (0,0) by react-force-graph (no explicit cx/cy translation), so forceRadial(r, 0, 0) is correct — different from mobile where we use width/2, height/2.
Testing
Manual, in dev:
-
pnpm --filter web dev(port 3030 per project memory) → visit/universe - YOU dot visible at canvas center, ink-dark, distinguishable from topic palette
- Faint spokes visible from YOU to every memory
- Hover YOU → no focus dim (the rest of the graph stays normal)
- Click YOU → no navigation (currently we’d navigate to
/memory/__you__which 404s) - Drag YOU → moves with cursor; release returns to center
- Sparse data with orphan memories → orphans cluster around the ~80px ring
- Heading reads “My Universe” on the page; nav link still says “Universe”
- Existing behaviors unchanged: hover-focus dim still works on memory nodes; 2D/3D toggle still works (3D will lack YOU + ring per the scoping note); filter pill + persistence still works
Cross-platform parity check (per project memory):
- Same YOU color (
#1F1F1F) on both web and mobile - Same orphan ring concept (radius can differ in absolute pixels since web’s coordinate scale differs)
- Same heading text (“My Universe”)
Suggested commit / PR structure
One feature branch (feature/web-universe-parity), 2–3 commits:
feat(web): add "You" anchor + orphan ring to Universe— the bulk of the workfeat(web): rename Universe heading to "My Universe"— trivial, separate so it’s grep-abledocs: mark web Universe parity shipped— README + CHANGELOG entry under Unreleased
PR title: feat(web): Universe — "You" anchor, orphan ring, "My Universe" heading. Cite mobile PR #13 in the body for context.
Out of scope
- 3D mode parity (different rendering API, low ROI)
- New ambient-drift tuning on web (web’s existing values are already tuned and the “too jumpy” / “too still” iteration loop on mobile doesn’t translate; web users on desktop have a different expectation)
- Cluster labels on mobile (web has them; mobile skips because Skia text needs font loading — that’s a separate decision documented in the mobile component’s docblock)
Pointers worth opening before you start
- apps/mobile/components/universe-graph.tsx — mobile reference implementation, end to end
- apps/web/app/(app)/universe/graph.tsx — your editing surface
- apps/web/app/(app)/universe/page.tsx — heading rename
- docs/decisions/0012-topics-as-first-class-graph-nodes.md — context on what nodes/edges are conceptually
- d3-force docs for
forceRadial: https://d3js.org/d3-force/position#forceRadial - react-force-graph docs for the imperative
d3Force()accessor: https://github.com/vasturiano/react-force-graph