feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) (#702)

* feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734)

Clicking a node now enters focus mode instead of navigating away:
the camera flies to the node (800ms, standard distance-ratio pattern
with at-origin guard), its neighborhood (any shared edge type) keeps
full color while everything else dims to low-alpha, adjacent links
brighten and the rest fade hard. A DetailCard slides in from the
right: collection dot + ref + title, status pill (terminal styling),
child count, relative updated-at, plus priority/assignee fetched
lazily via the items API (stale-select token), and the "Open item"
button carrying the old click-through navigation.

Deselect via background click, Escape (only when a selection is
active), or automatically when the workspace/show-completed payload
changes. Selection sets stay plain non-reactive lets per CONVE-1688;
accessor re-evaluation is explicit via graph.refresh().

Parent: PLAN-1730.

* fix(web): focus-mode link adjacency vs mutated endpoints per Codex review (round 1)

The force layout mutates link source/target from ref strings into node
objects after ingest, so linkColor's adjacency check against
selectedRef silently failed once the simulation ran. Preserve the raw
refs as sourceRef/targetRef at mapping time and compare those.
This commit is contained in:
xarmian
2026-06-05 18:57:07 -04:00
committed by GitHub
parent db3917f6d2
commit 1c3db435a3
2 changed files with 463 additions and 12 deletions
@@ -12,7 +12,8 @@
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { titleStore } from '$lib/stores/title.svelte';
import type { NodeObject, LinkObject } from '3d-force-graph';
import type { GraphResponse } from '$lib/types';
import type { GraphResponse, Item } from '$lib/types';
import DetailCard from './DetailCard.svelte';
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
@@ -44,6 +45,27 @@
// Latches once the renderer is constructed; the data-sync effect waits on it.
let rendererReady = $state(false);
// ── Focus / selection state (PLAN-1730 / TASK-1734) ──────────────────────────
// The dim-everything-else highlight is driven by two plain `let` Sets that the
// renderer accessor closures read. Per CONVE-1688 these stay non-reactive — they
// are mutated imperatively in the click handler, never tracked by an $effect.
// Re-evaluation is triggered explicitly by calling `graph.refresh()` after each
// change (3d-force-graph README: `refresh()` "Redraws all the nodes/links",
// re-running every color/opacity accessor).
let selectedRef: string | null = null;
let neighborRefs = new Set<string>();
// The selected node, surfaced to the detail-card template. A separate $state from
// the plain Sets above: this one is READ in markup, so it must be reactive — but
// no $effect both reads and writes it (it's only written from event handlers).
let selectedNode = $state<GraphNode3D | null>(null);
// Richer item detail, fetched lazily on select (priority / assignee live here).
let selectedItem = $state<Item | null>(null);
let selectedItemLoading = $state(false);
// Stale-select token — same shape as reqSeq; gates which in-flight item fetch
// commits so a fast re-select can't be clobbered by an older response.
let selectSeq = 0;
// Node-count / edge-count readout for the toolbar.
const nodeCount = $derived(graphData?.nodes.length ?? 0);
const edgeCount = $derived(graphData?.edges.length ?? 0);
@@ -118,11 +140,15 @@
.nodeRelSize(4)
// Subtree-weighted node size: parents/plans with children read bigger.
.nodeVal((n: NodeObject) => 1 + (asNode(n).child_count ?? 0) * 2)
.nodeColor((n: NodeObject) => colorForCollection(asNode(n).collection))
// Selection-aware color: in focus mode, anything outside the selected
// node's neighborhood is dimmed to a low-alpha version of its collection
// color (the accessor reads the plain `let` Sets above).
.nodeColor((n: NodeObject) => nodeColor(asNode(n)))
.nodeLabel((n: NodeObject) => `${escapeHtml(asNode(n).ref)}${escapeHtml(asNode(n).name)}`)
.nodeOpacity(0.95)
// 'blocks' edges read red with a directional arrow; structural links
// (parent/implements) brighter than soft links (wiki-link/related).
// In focus mode, adjacent links brighten and the rest fade out.
.linkColor((l: LinkObject<NodeObject>) => linkColor(asLink(l)))
.linkOpacity(0.5)
.linkWidth((l: LinkObject<NodeObject>) => (asLink(l).type === 'blocks' ? 1.5 : 0.5))
@@ -130,13 +156,9 @@
asLink(l).type === 'blocks' ? 3 : 0
)
.linkDirectionalArrowRelPos(1)
.onNodeClick((n: NodeObject) => {
const node = asNode(n);
// Item pages live at [collection]/[slug]; the server's ResolveItem
// resolves a PREFIX-NUMBER ref in the slug param (same path the
// insights "What shipped" links use), so the ref works directly.
void goto(`/${username}/${wsSlug}/${node.collection}/${node.ref}`);
});
.onNodeClick((n: NodeObject) => selectNode(asNode(n)))
// Click empty space → exit focus mode (camera is left where it is).
.onBackgroundClick(() => deselect());
graph = instance;
rendererReady = true;
@@ -161,9 +183,30 @@
}
}
// ── Selection-aware accessors ────────────────────────────────────────────────
// All three read the plain `let` selection Sets directly (CONVE-1688: no $state
// in the imperative path). `graph.refresh()` re-runs them after each change.
// Node color: collection hex normally; dimmed (low-alpha) when a selection is
// active and this node isn't in the neighborhood.
function nodeColor(n: GraphNode3D): string {
const base = colorForCollection(n.collection);
if (selectedRef === null) return base;
return neighborRefs.has(n.ref) ? base : hexToRgba(base, 0.15);
}
// 'blocks' → red-ish; structural (parent/implements/supersedes/split-from) →
// bright slate; soft (wiki-link/related) → dim slate. Alpha carries emphasis.
// In focus mode: links touching the selected node brighten; the rest fade hard.
function linkColor(l: GraphLink3D): string {
if (selectedRef !== null) {
// Compare against the preserved raw refs — the force layout mutates
// source/target into node objects after ingest.
const adjacent = l.sourceRef === selectedRef || l.targetRef === selectedRef;
if (!adjacent) return 'rgba(148, 163, 184, 0.06)';
if (l.type === 'blocks') return 'rgba(244, 63, 94, 0.95)';
return 'rgba(148, 163, 184, 0.95)';
}
if (l.type === 'blocks') return 'rgba(244, 63, 94, 0.85)';
if (l.type === 'parent' || l.type === 'implements' || l.type === 'supersedes' || l.type === 'split-from') {
return 'rgba(148, 163, 184, 0.85)';
@@ -171,12 +214,119 @@
return 'rgba(148, 163, 184, 0.35)';
}
// Hex (#rrggbb) → rgba() with the given alpha. The dim treatment for out-of-
// neighborhood nodes; mixing toward transparent reads as receding into the
// dark backdrop without losing the collection hue entirely.
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// ── Selection / focus mode ───────────────────────────────────────────────────
// After the linked-list edges resolve to node objects the renderer mutates
// `source`/`target` from refs into the node instances; but our GraphLink3D still
// carries the original ref strings via the raw payload. We compute neighborhoods
// from `graphData.edges` (the source of truth, untouched by the renderer).
function computeNeighbors(ref: string): Set<string> {
const set = new Set<string>([ref]);
const edges = graphData?.edges ?? [];
for (const e of edges) {
if (e.source === ref) set.add(e.target);
else if (e.target === ref) set.add(e.source);
}
return set;
}
function selectNode(node: GraphNode3D) {
// Plain-`let` selection state (consulted by the accessors).
selectedRef = node.ref;
neighborRefs = computeNeighbors(node.ref);
// Reactive copy for the detail card.
selectedNode = node;
// Camera fly-to: position the camera a comfortable distance out along the
// node's position vector, looking at the node. Standard 3d-force-graph
// pattern; guard the at-origin case where the vector has zero length.
const dist = 60;
const hyp = Math.hypot(node.x ?? 0, node.y ?? 0, node.z ?? 0);
const ratio = hyp > 0 ? 1 + dist / hyp : 1;
graph?.cameraPosition(
{ x: (node.x ?? 0) * ratio, y: (node.y ?? 0) * ratio, z: (node.z ?? 0) * ratio },
{ x: node.x ?? 0, y: node.y ?? 0, z: node.z ?? 0 },
800
);
// Re-run every node/link accessor so the dim/highlight takes effect.
graph?.refresh();
// Fetch richer detail (priority / assignee) for the card. Stale-gated so a
// rapid re-select can't be overwritten by an older response.
void loadSelectedItem(node.ref);
}
async function loadSelectedItem(ref: string) {
const seq = ++selectSeq;
selectedItem = null;
selectedItemLoading = true;
try {
// Refs resolve server-side (same path the node click used to navigate to).
const item = await api.items.get(wsSlug, ref);
if (seq !== selectSeq) return;
selectedItem = item;
} catch {
// Card degrades gracefully — it just won't show priority/assignee.
if (seq !== selectSeq) return;
selectedItem = null;
} finally {
if (seq === selectSeq) selectedItemLoading = false;
}
}
// Clear focus mode: un-dim everything, close the card. Does NOT move the camera
// back (kept simple per TASK-1734). Bumps selectSeq so any in-flight item fetch
// is discarded.
function deselect() {
if (selectedRef === null) return;
selectedRef = null;
neighborRefs = new Set<string>();
selectedNode = null;
selectedItem = null;
selectedItemLoading = false;
selectSeq++;
graph?.refresh();
}
// Open the selected item's page — this is where the old direct-click navigation
// moved to. Item pages live at [collection]/[slug]; the server's ResolveItem
// resolves a PREFIX-NUMBER ref in the slug param, so the ref works directly.
function openSelected() {
if (!selectedNode) return;
void goto(`/${username}/${wsSlug}/${selectedNode.collection}/${selectedNode.ref}`);
}
// Escape exits focus mode — but only when a node is selected, so it doesn't
// swallow the key from other handlers (spirit of CONVE-639).
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedRef !== null) {
e.preventDefault();
deselect();
}
}
// ── Data load + sync ─────────────────────────────────────────────────────────
// Fetch whenever the workspace or the "show completed" toggle changes, with a
// request token so a stale response can't clobber a newer one.
$effect(() => {
const slug = wsSlug;
const withTerminal = showCompleted;
// Either trigger (workspace switch or show-completed toggle) yields a fresh
// payload in which the selected node may no longer exist — clear focus mode
// so the dim/highlight + detail card don't reference a vanished node. This
// effect reads graphData-independent state only via deselect() (which never
// reads graphData), so it stays CONVE-1688-clean.
deselect();
if (slug !== graphWsSlug) {
graphWsSlug = slug;
// Drop the previous workspace's graph so it doesn't linger under the new
@@ -202,7 +352,17 @@
graph.graphData({
nodes: data ? data.nodes.map((n) => ({ ...n, id: n.ref, name: n.title })) : [],
links: data
? data.edges.map((e) => ({ source: e.source, target: e.target, type: e.type }))
? // sourceRef/targetRef preserve the raw ref strings: after ingest the
// force layout mutates source/target into node OBJECTS, so any
// accessor comparing endpoints against a ref (linkColor's focus-mode
// adjacency check) must read these instead (Codex PR #702 round 1).
data.edges.map((e) => ({
source: e.source,
target: e.target,
sourceRef: e.source,
targetRef: e.target,
type: e.type
}))
: []
});
});
@@ -245,14 +405,25 @@
is_terminal: boolean;
child_count: number;
updated_at: string;
// Position coords the renderer assigns as the force simulation runs. Present
// on any node that's been laid out (always true by the time it's clicked).
x?: number;
y?: number;
z?: number;
}
interface GraphLink3D {
source: string;
target: string;
// source/target start as ref strings but are mutated into node objects by
// the force layout after ingest — compare sourceRef/targetRef instead.
source: string | NodeObject;
target: string | NodeObject;
sourceRef: string;
targetRef: string;
type: string;
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="graph-page">
<!-- Controls overlay (top-left) -->
<div class="toolbar">
@@ -270,6 +441,18 @@
<!-- The renderer mounts here; it owns its own canvas. -->
<div class="canvas" bind:this={containerEl}></div>
<!-- Focus-mode detail card (slides in from the right when a node is selected). -->
{#if selectedNode}
<DetailCard
node={selectedNode}
color={colorForCollection(selectedNode.collection)}
item={selectedItem}
itemLoading={selectedItemLoading}
onopen={openSelected}
onclose={deselect}
/>
{/if}
<!-- Overlay states (the canvas stays mounted underneath so the renderer keeps
its WebGL context across reloads). -->
{#if error}
@@ -0,0 +1,268 @@
<script lang="ts">
// Focus-mode detail card for the 3D workspace graph (PLAN-1730 / TASK-1734).
//
// Slides in from the right when a node is selected. The graph +page.svelte owns
// selection state and the richer item fetch; this component is pure presentation
// — it takes the selected node, the (optionally still-loading) full Item, and
// emits open/close callbacks. Styling mirrors the page's .toolbar / .state-card
// (backdrop-blur, color-mix surfaces) so the focus layer reads as the same UI.
import { relativeTime } from '$lib/utils/markdown';
import type { Item } from '$lib/types';
// The selected node's renderer-facing shape (a subset of the page's GraphNode3D).
// Kept structural so the page can pass its mapped node straight through.
interface SelectedNode {
ref: string;
title: string;
collection: string;
status?: string;
is_terminal: boolean;
child_count: number;
updated_at: string;
}
let {
node,
color,
item,
itemLoading,
onopen,
onclose
}: {
node: SelectedNode;
/** collection color (hex) — shared with the node's renderer color. */
color: string;
/** full item, fetched lazily by the page; null until it arrives. */
item: Item | null;
itemLoading: boolean;
onopen: () => void;
onclose: () => void;
} = $props();
// item.fields is a JSON string on the Item type. Parse defensively — handle a
// pre-parsed object too (some callers/snapshots hydrate it eagerly) and never
// throw on malformed JSON.
const fields = $derived.by<Record<string, unknown>>(() => {
const raw = item?.fields;
if (!raw) return {};
if (typeof raw === 'object') return raw as Record<string, unknown>;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
});
const priority = $derived(
typeof fields.priority === 'string' && fields.priority ? fields.priority : null
);
const assignee = $derived(item?.assigned_user_name ?? null);
</script>
<aside class="detail-card" aria-label="Selected item">
<button class="close" onclick={onclose} aria-label="Close detail card">×</button>
<header class="card-head">
<span class="dot" style:background-color={color} aria-hidden="true"></span>
<span class="ref">{node.ref}</span>
</header>
<h2 class="title">{node.title}</h2>
<div class="pills">
{#if node.status}
<span class="pill" class:terminal={node.is_terminal}>{node.status}</span>
{/if}
{#if node.child_count > 0}
<span class="meta">{node.child_count} {node.child_count === 1 ? 'child' : 'children'}</span>
{/if}
<span class="meta">Updated {relativeTime(node.updated_at)}</span>
</div>
<!-- Richer detail fetched separately; shows a shimmer line until it lands. -->
<div class="detail-rows">
{#if itemLoading}
<div class="shimmer" aria-hidden="true"></div>
{:else if item}
{#if priority}
<div class="row">
<span class="row-key">Priority</span>
<span class="row-val">{priority}</span>
</div>
{/if}
{#if assignee}
<div class="row">
<span class="row-key">Assignee</span>
<span class="row-val">{assignee}</span>
</div>
{/if}
{/if}
</div>
<button class="open-btn" onclick={onopen}>Open item</button>
</aside>
<style>
.detail-card {
position: absolute;
top: 50%;
right: var(--space-4);
transform: translateY(-50%);
z-index: 12;
width: 18rem;
max-width: calc(100% - var(--space-8));
padding: var(--space-5);
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(8px);
/* Slide in from the right. */
animation: slide-in 180ms ease-out;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translate(12px, -50%);
}
to {
opacity: 1;
transform: translate(0, -50%);
}
}
.close {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
padding: 0;
font-size: 1.2em;
line-height: 1;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
}
.close:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
}
.card-head {
display: flex;
align-items: center;
gap: var(--space-2);
padding-right: 1.6rem;
}
.dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex: 0 0 auto;
}
.ref {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75em;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.02em;
}
.title {
margin: var(--space-2) 0 var(--space-3);
font-size: 1.02em;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
}
.pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.pill {
padding: 0.1rem 0.5rem;
font-size: 0.72em;
font-weight: 600;
text-transform: capitalize;
color: var(--text-secondary);
background: color-mix(in srgb, var(--text-secondary) 12%, transparent);
border-radius: 999px;
}
.pill.terminal {
color: var(--success, #10b981);
background: color-mix(in srgb, var(--success, #10b981) 16%, transparent);
}
.meta {
font-size: 0.72em;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.detail-rows {
min-height: 1.2rem;
margin: var(--space-3) 0;
}
.row {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: 0.15rem 0;
font-size: 0.8em;
}
.row-key {
color: var(--text-muted);
}
.row-val {
color: var(--text-primary);
text-transform: capitalize;
text-align: right;
}
.shimmer {
height: 0.85rem;
width: 60%;
border-radius: var(--radius-sm, 4px);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--text-muted) 10%, transparent) 25%,
color-mix(in srgb, var(--text-muted) 22%, transparent) 50%,
color-mix(in srgb, var(--text-muted) 10%, transparent) 75%
);
background-size: 200% 100%;
animation: shimmer 1.1s ease-in-out infinite;
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.open-btn {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: 0.82em;
font-weight: 600;
color: var(--btn-primary-text, #fff);
background: var(--accent, #6366f1);
border: none;
border-radius: var(--radius);
cursor: pointer;
}
.open-btn:hover {
filter: brightness(1.08);
}
</style>