Skip to content

Universe — web parity with mobile (γ.1 follow-up)

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)

Missing on web — port from mobile

Three things only:

  1. “You” pinned anchor — a special node at canvas center, draggable, renders distinctively
  2. Orphan ringforceRadial pulls nodes with degree 0 onto a perimeter ring
  3. 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 via fx/fy after 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). forceRadial strength 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 is YOU_NODE_ID, treats it like an empty-space tap (clears focus, doesn’t navigate).

Web file layout

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 the forceRadial("orphanRing").
  • Nodes/links are passed as graphData prop. Adding YOU means including it in the graphData.nodes array.
  • Custom node rendering uses the nodeCanvasObject callback (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/fy on the node object before passing to graphData. 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

  1. 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.

  2. Inject the YOU node into graphData. When building graphData.nodes in the useMemo, push a synthetic node:

    { id: YOU_NODE_ID, label: "You", topic: "", color: YOU_COLOR, fx: 0, fy: 0 }

    The 0,0 is d3-force’s natural origin (which forceX/forceY then anchor; web doesn’t have explicit width/2, height/2 like mobile because react-force-graph centers automatically).

  3. Render the YOU spokes. Use linkCanvasObject or a custom paint pass — render lines from YOU’s current x,y to every memory at low alpha (0.06), behind the real edges. Skip when a node is hovered (focus mode owns the visual).

  4. Render the YOU node distinctively. In nodeCanvasObject, special-case node.id === YOU_NODE_ID to draw the ink-dark dot at YOU’s size. The existing nodeCanvasObject already renders custom shapes for hover state — extend it.

  5. Add forceRadial for orphans. In the d3Force configuration 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 orphanIds from edges (nodes with no incident edge, excluding YOU). Same logic as mobile’s data memo.

  6. Special-case YOU in onNodeClick. If node.id === YOU_NODE_ID, return early (no navigation, no focus shift).

  7. 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.

  8. Heading rename at apps/web/app/(app)/universe/page.tsx:95: <h1>Universe</h1><h1>My Universe</h1>. Don’t touch the nav link in apps/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:

  1. feat(web): add "You" anchor + orphan ring to Universe — the bulk of the work
  2. feat(web): rename Universe heading to "My Universe" — trivial, separate so it’s grep-able
  3. docs: 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