From 8cd6eae3366d2becfce068e38dcee1ef635dc760 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 29 May 2026 00:20:14 +0800 Subject: [PATCH 01/13] docs: spec for animated energy flow on active branch path Design for a continuous stream of glowing dots traveling the first-parent chain root -> HEAD, with reduced-motion fallback and a headless-testable pure path-builder module. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-29-graph-energy-flow-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md diff --git a/docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md b/docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md new file mode 100644 index 0000000..141767e --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md @@ -0,0 +1,111 @@ +# Graph Energy Flow — Design Spec + +**Date:** 2026-05-29 +**Status:** Approved, ready for implementation plan + +## Goal + +Add an animated "energy flow" effect to the git DAG visualization: a continuous +stream of glowing dots that travels along the current branch's first-parent chain, +from the root commit toward the current HEAD tip. Communicates "where you are" and +gives the graph a living, active feel. + +## Behavior + +- **Style:** traveling pulse / comet — discrete glowing dots, not a gradient or dashes. +- **Scope:** active branch path only — the first-parent ancestry chain from the root + commit to the current HEAD commit. At merge commits, follow the first parent only + (this is the "current branch" mainline). Works for both attached HEAD (branch tip) + and detached HEAD (arbitrary commit). +- **Cadence:** continuous stream — several evenly-spaced dots flow nonstop root → HEAD, + looping forever. Reads as a steady energy current. +- **Direction:** always root (oldest) → HEAD (newest). +- **Constant speed:** the perceived dot speed stays the same regardless of how many + commits are on the path. Animation duration scales with the path's segment count. +- **Degenerate cases:** when the active path has fewer than 2 nodes (empty repo with a + phantom node, or a single root commit that is also HEAD), render no flow. +- **Reduced motion:** when `prefers-reduced-motion: reduce` is set, render no moving + dots. Instead draw the active path as a static, brighter cyan stroke so the + "where am I" information is still conveyed without animation. + +## Architecture + +Two pieces, matching the existing engine/renderer separation. + +### 1. Pure module: `src/graph/flow.ts` + +DOM-free, headless-testable (same class as the engine and `src/graph/layout.ts`). + +```ts +export function buildActiveFlow( + nodes: GraphNode[], + headCommitHash: string, + orientation: Orientation, +): { d: string; segmentCount: number } | null; +``` + +Algorithm: + +1. Look up the HEAD node by `headCommitHash`. If missing, return `null`. +2. Walk first-parent ancestry: starting from HEAD, repeatedly follow `node.parents[0]` + (resolved against a hash→node map built from `nodes`) until a node has no parent in + the map. Collect the chain. +3. Reverse the chain to get root → HEAD order. +4. If fewer than 2 nodes, return `null`. +5. For each consecutive pair `(parent, child)`, append one cubic-Bézier segment to a + single combined path string `d`. The curve math mirrors the existing `edgePath` in + `Graph.svelte`, but in parent → child direction: + - horizontal: control points at the mid-X between the two nodes. + - vertical: control points at the mid-Y between the two nodes. + The first segment starts with `M `; subsequent segments append `C ...` + (the path stays connected because each segment's start equals the previous end). +6. Return `{ d, segmentCount }` where `segmentCount` is the number of hops. + +This function owns the curve geometry so the Svelte component stays declarative. +To avoid duplicated Bézier math, the shared single-segment curve helper may be +extracted/exported so both `Graph.svelte`'s `edgePath` and `flow.ts` use one source +of truth (parametrized by direction + orientation). + +### 2. Renderer: `Graph.svelte` + +- New `$derived` value computes the active flow from `layout.nodes` + the existing + `headCommitHash` derived value, by calling `buildActiveFlow`. +- New `prefersReducedMotion` `$state`, wired with `matchMedia('(prefers-reduced-motion: reduce)')` + using the same `$effect` + listener pattern as the existing `isMobile` media query + (Graph.svelte:14-22). +- Render the flow layer **after the edges loop and before the nodes loop**, so dots + ride on top of the edge wires but slide underneath the commit circles. +- When motion is allowed and a flow path exists: + - One invisible guide ``. + - `N` dot groups, where `N` scales with `segmentCount` (≈ one dot per hop) so spacing + stays constant. Each dot `` contains a bright core circle + a softer, larger halo + circle (cyan `#22d3ee`). + - Each dot `` carries ``. + - `D` (total traversal duration) scales with `segmentCount` for constant speed, + e.g. `D = segmentCount * PER_SEGMENT_SECONDS`. +- When `prefersReducedMotion` is true and a flow path exists: render a single static + `` with a brighter cyan stroke and no animation; render no dots. + +### Why SMIL, not JS + +The codebase already uses SVG SMIL (`` on the HEAD glow ring, Graph.svelte:210). +`animateMotion` + `mpath` over a single concatenated guide path gives free, continuous +flow across multi-segment Béziers with zero per-frame JavaScript and no requestAnimationFrame +loop to manage. It stays consistent with the existing animation approach. + +## Testing + +- **Headless unit tests** (`tests/graph/flow.test.ts`) on `buildActiveFlow`: + - linear history: chain is ordered root → HEAD; `segmentCount === commits - 1`. + - returns `null` for 0 or 1 nodes. + - detached HEAD: path ends at the detached commit, not a branch tip. + - merge commit: chain follows first parent only (does not branch into the second parent). + - missing `headCommitHash`: returns `null`. +- **Animation/visual:** verified manually in the browser (SMIL motion is not unit-testable). + +## Out of scope + +- Pulsing other branches or all edges. +- Gradient/marching-dash styles. +- Event-driven (one-shot) surges on HEAD movement. +- User toggle to enable/disable the effect (reduced-motion handling aside). From 4c1a72dd92ed5bbcf138e0d11e3662df1f8ac422 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 29 May 2026 00:21:47 +0800 Subject: [PATCH 02/13] docs: spec self-review fixes for energy flow design Add known-risks section and tighten the renderer spec: remount SMIL on path change, xlink:href for mpath, pointer-events/aria-hidden on dots, phantom-node guard, and the non-uniform-speed caveat. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-29-graph-energy-flow-design.md | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md b/docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md index 141767e..e767a14 100644 --- a/docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md +++ b/docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md @@ -49,7 +49,8 @@ Algorithm: 1. Look up the HEAD node by `headCommitHash`. If missing, return `null`. 2. Walk first-parent ancestry: starting from HEAD, repeatedly follow `node.parents[0]` (resolved against a hash→node map built from `nodes`) until a node has no parent in - the map. Collect the chain. + the map. Collect the chain. Skip any node with `type === 'phantom'` or empty hash so + the empty-repo phantom node never produces a (degenerate) flow. 3. Reverse the chain to get root → HEAD order. 4. If fewer than 2 nodes, return `null`. 5. For each consecutive pair `(parent, child)`, append one cubic-Bézier segment to a @@ -77,12 +78,21 @@ of truth (parametrized by direction + orientation). ride on top of the edge wires but slide underneath the commit circles. - When motion is allowed and a flow path exists: - One invisible guide ``. + Use `stroke="none" fill="none"` (NOT `display:none`) so the geometry stays live for + `mpath` to reference. - `N` dot groups, where `N` scales with `segmentCount` (≈ one dot per hop) so spacing stays constant. Each dot `` contains a bright core circle + a softer, larger halo circle (cyan `#22d3ee`). - - Each dot `` carries ``. + - Each dot `` carries ``. - `D` (total traversal duration) scales with `segmentCount` for constant speed, e.g. `D = segmentCount * PER_SEGMENT_SECONDS`. + - Dots are decorative: every flow element gets `pointer-events="none"` and the layer + is `aria-hidden="true"` so dots never intercept clicks on nodes/edges and add no + a11y noise. + - **Force remount on path change:** wrap the whole flow layer in a Svelte `{#key flow.d}` + block. SMIL `animateMotion` snapshots the `mpath` geometry when the animation begins + and does not reliably re-read a mutated `d`; re-keying recreates the elements so the + flow tracks HEAD movement / new commits. (See Known Risks.) - When `prefersReducedMotion` is true and a flow path exists: render a single static `` with a brighter cyan stroke and no animation; render no dots. @@ -93,6 +103,29 @@ The codebase already uses SVG SMIL (`` on the HEAD glow ring, Graph.sve flow across multi-segment Béziers with zero per-frame JavaScript and no requestAnimationFrame loop to manage. It stays consistent with the existing animation approach. +## Known risks & implementation notes + +Surfaced during spec self-review — verify each in the browser during implementation: + +1. **SMIL does not track a mutated path (HIGH).** `animateMotion` + `mpath` snapshot the + referenced path geometry at animation start. Mutating the guide `` in place + (HEAD moves, commit added, orientation flip) will not redirect in-flight dots. + Mitigation: `{#key flow.d}` around the flow layer to remount the SMIL elements. +2. **`mpath` href attribute (MED).** Safari historically requires `xlink:href` on + ``; plain `href` is the modern form. Use `xlink:href` (works across Chromium, + Firefox, Safari). Confirm dots actually move in Safari. +3. **`` / fragment-reference resolution (LOW–MED).** The app builds under base path + `/gitverse/`. If the document ends up with a `` element, in-document + fragment refs like `xlink:href="#head-flow-path"` can resolve against the base URL and + break in some browsers. Vite's `base` config rewrites asset URLs, not necessarily via a + `` element — verify there is no `` tag, or use an absolute-to-current-URL + ref if one exists. +4. **Non-uniform speed on lane-change segments (LOW).** `animateMotion` advances at a + constant rate over the path *parameter*, not arc length. Commits are evenly spaced, so + straight mainline segments are near-equal length and look uniform, but a segment that + also changes lane is longer and the dot will appear to briefly speed up. Acceptable for + v1; revisit with `keyPoints`/`keyTimes` only if it reads badly. + ## Testing - **Headless unit tests** (`tests/graph/flow.test.ts`) on `buildActiveFlow`: From b08d56bb5c66ef3d3f64b806334e2c5e5e229bcb Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 29 May 2026 00:24:58 +0800 Subject: [PATCH 03/13] docs: implementation plan for graph energy flow Two-task TDD plan: pure flow-path builder module with headless tests, then SMIL animateMotion rendering in Graph.svelte with reduced-motion fallback and manual browser verification checklist. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-29-graph-energy-flow.md | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-29-graph-energy-flow.md diff --git a/docs/superpowers/plans/2026-05-29-graph-energy-flow.md b/docs/superpowers/plans/2026-05-29-graph-energy-flow.md new file mode 100644 index 0000000..2ab95ee --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-graph-energy-flow.md @@ -0,0 +1,459 @@ +# Graph Energy Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render a continuous stream of glowing cyan dots flowing along the current branch's first-parent chain (root commit → HEAD) in the git DAG visualization. + +**Architecture:** A new pure, DOM-free module `src/graph/flow.ts` builds a single concatenated SVG cubic-Bézier path string for the active first-parent chain and is unit-tested headless. `Graph.svelte` consumes it, rendering an invisible guide path plus SMIL `animateMotion` dots that ride it, with a `prefers-reduced-motion` static-stroke fallback. + +**Tech Stack:** Svelte 5 (runes), TypeScript (strict), SVG SMIL animation, Vitest (`vitest run`), svelte-check, ESLint. + +**Spec:** `docs/superpowers/specs/2026-05-29-graph-energy-flow-design.md` + +--- + +## File Structure + +- **Create** `src/graph/flow.ts` — pure geometry: `cubicSegment()` (one Bézier command) + `buildActiveFlow()` (chain walk → combined path `d` + segment count). Zero DOM deps. +- **Create** `tests/graph/flow.test.ts` — headless unit tests for both exports. +- **Modify** `src/ui/Graph.svelte` — import the helpers, DRY `edgePath` onto `cubicSegment`, add reduced-motion state + `activeFlow` derived, render the flow layer between edges and nodes. + +Reference for existing conventions: +- Existing pure layout module: `src/graph/layout.ts` (exports `Orientation`, spacing consts). +- Existing test style + `makeNode` helper: `tests/graph/layout.test.ts`. +- Existing SMIL precedent: HEAD glow `` at `src/ui/Graph.svelte:200-217`. +- Existing media-query pattern: `isMobile` `$effect` at `src/ui/Graph.svelte:14-22`. +- Existing `edgePath`: `src/ui/Graph.svelte:159-166`. +- Existing `headCommitHash` derived: `src/ui/Graph.svelte:122-131`. + +--- + +## Task 1: Pure flow module (`src/graph/flow.ts`) + +**Files:** +- Create: `src/graph/flow.ts` +- Test: `tests/graph/flow.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/graph/flow.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { buildActiveFlow, cubicSegment } from '$graph/flow'; +import type { GraphNode } from '$graph/types'; + +function makeNode(hash: string, parents: string[], overrides: Partial = {}): GraphNode { + return { + hash, + type: 'commit' as const, + parents, + message: `commit ${hash}`, + branches: [], + tags: [], + isHEAD: false, + lane: 0, + x: 0, + y: 0, + ...overrides, + }; +} + +describe('cubicSegment', () => { + it('horizontal: control points at mid-X', () => { + expect(cubicSegment(0, 0, 100, 40, 'horizontal')).toBe('C 50 0, 50 40, 100 40'); + }); + + it('vertical: control points at mid-Y', () => { + expect(cubicSegment(0, 0, 40, 100, 'vertical')).toBe('C 0 50, 40 50, 40 100'); + }); +}); + +describe('buildActiveFlow', () => { + it('returns null when head hash is empty', () => { + const nodes = [makeNode('a', [], { x: 10, y: 10 })]; + expect(buildActiveFlow(nodes, '', 'horizontal')).toBeNull(); + }); + + it('returns null when head hash is not found', () => { + const nodes = [makeNode('a', [], { x: 10, y: 10 })]; + expect(buildActiveFlow(nodes, 'zzz', 'horizontal')).toBeNull(); + }); + + it('returns null for a single-node chain (root == head)', () => { + const nodes = [makeNode('a', [], { x: 10, y: 10 })]; + expect(buildActiveFlow(nodes, 'a', 'horizontal')).toBeNull(); + }); + + it('builds an ordered root -> head path for linear history', () => { + // a (root) <- b <- c (head) + const nodes = [ + makeNode('a', [], { x: 100, y: 50 }), + makeNode('b', ['a'], { x: 200, y: 50 }), + makeNode('c', ['b'], { x: 300, y: 50, isHEAD: true }), + ]; + const flow = buildActiveFlow(nodes, 'c', 'horizontal'); + expect(flow).not.toBeNull(); + expect(flow!.segmentCount).toBe(2); + // starts at the root (a), ends at the head (c) + expect(flow!.d.startsWith('M 100 50')).toBe(true); + expect(flow!.d.endsWith('300 50')).toBe(true); + }); + + it('follows first parent only at a merge commit', () => { + // mainline: a <- b <- m(head); feature: a <- f; m merges b (first) + f (second) + const nodes = [ + makeNode('a', [], { x: 100, y: 50 }), + makeNode('b', ['a'], { x: 200, y: 50 }), + makeNode('f', ['a'], { x: 200, y: 150 }), + makeNode('m', ['b', 'f'], { x: 300, y: 50, isHEAD: true }), + ]; + const flow = buildActiveFlow(nodes, 'm', 'horizontal'); + expect(flow).not.toBeNull(); + // chain is a -> b -> m (3 nodes, 2 hops); the second parent f is excluded + expect(flow!.segmentCount).toBe(2); + expect(flow!.d).not.toContain('150'); // f's y-coord never appears + }); + + it('handles detached HEAD ending at an interior commit', () => { + const nodes = [ + makeNode('a', [], { x: 100, y: 50 }), + makeNode('b', ['a'], { x: 200, y: 50, isHEAD: true }), + makeNode('c', ['b'], { x: 300, y: 50 }), + ]; + const flow = buildActiveFlow(nodes, 'b', 'horizontal'); + expect(flow).not.toBeNull(); + expect(flow!.segmentCount).toBe(1); // a -> b only + expect(flow!.d.endsWith('200 50')).toBe(true); + }); + + it('ignores phantom nodes', () => { + const nodes = [makeNode('', [], { type: 'phantom', x: 168, y: 112 })]; + expect(buildActiveFlow(nodes, '', 'horizontal')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run tests/graph/flow.test.ts` +Expected: FAIL — cannot resolve `$graph/flow` (module does not exist yet). + +- [ ] **Step 3: Write the implementation** + +Create `src/graph/flow.ts`: + +```ts +import type { GraphNode } from './types'; +import type { Orientation } from './layout'; + +/** + * One cubic-Bézier command (no leading `M`) from (fromX,fromY) to (toX,toY). + * Mirrors the curve shape used by edges so the flow rides the same wires. + */ +export function cubicSegment( + fromX: number, + fromY: number, + toX: number, + toY: number, + orientation: Orientation, +): string { + if (orientation === 'horizontal') { + const midX = (fromX + toX) / 2; + return `C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; + } + const midY = (fromY + toY) / 2; + return `C ${fromX} ${midY}, ${toX} ${midY}, ${toX} ${toY}`; +} + +/** + * Builds the active-branch flow path: the first-parent chain from the root + * commit to the HEAD commit, as a single connected SVG path string. + * Returns null when there is no drawable flow (<2 real nodes). + */ +export function buildActiveFlow( + nodes: GraphNode[], + headCommitHash: string, + orientation: Orientation, +): { d: string; segmentCount: number } | null { + if (!headCommitHash) return null; + + const byHash = new Map(); + for (const n of nodes) { + if (n.type === 'phantom' || !n.hash) continue; + byHash.set(n.hash, n); + } + + let cur = byHash.get(headCommitHash); + if (!cur) return null; + + // Walk first-parent ancestry HEAD -> root. + const chain: GraphNode[] = []; + const seen = new Set(); + while (cur && !seen.has(cur.hash)) { + seen.add(cur.hash); + chain.push(cur); + const parent = cur.parents[0]; + cur = parent ? byHash.get(parent) : undefined; + } + + chain.reverse(); // root -> HEAD + if (chain.length < 2) return null; + + let d = `M ${chain[0].x} ${chain[0].y}`; + for (let i = 1; i < chain.length; i++) { + const from = chain[i - 1]; + const to = chain[i]; + d += ` ${cubicSegment(from.x, from.y, to.x, to.y, orientation)}`; + } + + return { d, segmentCount: chain.length - 1 }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run tests/graph/flow.test.ts` +Expected: PASS — all cases green. + +- [ ] **Step 5: Typecheck** + +Run: `npm run typecheck` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/graph/flow.ts tests/graph/flow.test.ts +git commit -m "feat: pure active-branch flow path builder for graph energy effect" +``` + +--- + +## Task 2: Render the flow in `Graph.svelte` + +No automated test — SMIL motion is visual. Verified by typecheck, build, and manual browser check. Follow the steps exactly. + +**Files:** +- Modify: `src/ui/Graph.svelte` + +- [ ] **Step 1: Import the flow helpers** + +In `src/ui/Graph.svelte`, find (line ~3): + +```ts + import { computeLayout, NODE_SPACING_X, LANE_SPACING_Y } from '$graph/layout'; + import type { Orientation } from '$graph/layout'; +``` + +Add immediately after the `Orientation` import: + +```ts + import { buildActiveFlow, cubicSegment } from '$graph/flow'; +``` + +- [ ] **Step 2: Add flow constants** + +Find: + +```ts + const LANE_COLORS = ['#4ade80', '#60a5fa', '#c084fc', '#f87171', '#facc15', '#22d3ee']; + const NODE_RADIUS = 25; + const GRAPH_PADDING = 80; +``` + +Add after `GRAPH_PADDING`: + +```ts + const FLOW_COLOR = '#22d3ee'; + const FLOW_PER_SEGMENT_SEC = 0.6; +``` + +- [ ] **Step 3: DRY `edgePath` onto `cubicSegment`** + +Replace the whole `edgePath` function (lines ~159-166): + +```ts + function edgePath(edge: GraphEdge): string { + if (orientation === 'horizontal') { + const midX = (edge.fromX + edge.toX) / 2; + return `M ${edge.fromX} ${edge.fromY} C ${midX} ${edge.fromY}, ${midX} ${edge.toY}, ${edge.toX} ${edge.toY}`; + } + const midY = (edge.fromY + edge.toY) / 2; + return `M ${edge.fromX} ${edge.fromY} C ${edge.fromX} ${midY}, ${edge.toX} ${midY}, ${edge.toX} ${edge.toY}`; + } +``` + +with: + +```ts + function edgePath(edge: GraphEdge): string { + return `M ${edge.fromX} ${edge.fromY} ${cubicSegment(edge.fromX, edge.fromY, edge.toX, edge.toY, orientation)}`; + } +``` + +- [ ] **Step 4: Add `prefersReducedMotion` state** + +Find the `isMobile` effect (lines ~12-22): + +```ts + let isMobile = $state(false); + + $effect(() => { + const mq = window.matchMedia('(max-width: 640px)'); + isMobile = mq.matches; + function onChange(e: MediaQueryListEvent) { + isMobile = e.matches; + } + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }); +``` + +Add immediately after that closing `});`: + +```ts + let prefersReducedMotion = $state(false); + + $effect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + prefersReducedMotion = mq.matches; + function onChange(e: MediaQueryListEvent) { + prefersReducedMotion = e.matches; + } + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }); +``` + +- [ ] **Step 5: Add the `activeFlow` derived value** + +Find the `headCommitHash` derived block (ends ~line 131): + +```ts + const headCommitHash = $derived.by(() => { + const eng = $engine; + void $engineVersion; + try { + const h = eng.getHEAD(); + return h.attached ? (eng.allBranches().get(h.target) ?? '') : h.target; + } catch { + return ''; + } + }); +``` + +Add immediately after that closing `});`: + +```ts + const activeFlow = $derived(buildActiveFlow(layout.nodes, headCommitHash, orientation)); + const flowDur = $derived(activeFlow ? activeFlow.segmentCount * FLOW_PER_SEGMENT_SEC : 0); + const flowDots = $derived(activeFlow ? activeFlow.segmentCount : 0); +``` + +- [ ] **Step 6: Render the flow layer between edges and nodes** + +Find the end of the edges `{#each}` block and the start of the nodes block (lines ~192-194): + +```svelte + {/each} + + +``` + +Replace that exact region with: + +```svelte + {/each} + + + {#if activeFlow} + {#if prefersReducedMotion} + + {:else} + {#key activeFlow.d} + + {/key} + {/if} + {/if} + + +``` + +- [ ] **Step 7: Typecheck** + +Run: `npm run typecheck` +Expected: no errors. + +If svelte-check flags `xlink:href` as deprecated/unknown, replace `xlink:href="#head-flow-path"` with `href="#head-flow-path"` and add a code comment noting Safari may need xlink; re-run typecheck. + +- [ ] **Step 8: Lint** + +Run: `npm run lint` +Expected: no errors. The `{#each Array(flowDots) as _, i (i)}` uses `_` for the unused item; if ESLint flags it, rename to `{#each Array(flowDots) as _unused, i (i)}`. + +- [ ] **Step 9: Build** + +Run: `npm run build` +Expected: build succeeds. + +- [ ] **Step 10: Manual browser verification** + +Run: `npm run dev`, open the app, then: + +1. `git init` then run several commits (use the simulate-changes command/button + `git add -A` + `git commit`) until there are 4+ commits on `main`. + - Expect: cyan dots stream continuously from the root commit toward the HEAD commit along `main`. Speed looks even. +2. `git commit` one more time. + - Expect: the stream extends to the new tip (the `{#key}` remount picks up the new path) — dots flow all the way to the new HEAD, no frozen/stale path. +3. `git checkout -b feature`, commit, then `git checkout main`. + - Expect: flow follows whichever branch HEAD is on; on `main` it runs root → main tip. +4. Create a merge (`git merge feature` onto main) so a merge commit exists. + - Expect: the flow follows the first-parent mainline only; it does not divert down the feature lane. +5. `git checkout ` to detach HEAD. + - Expect: flow ends at that commit, not the branch tip. +6. Empty repo (fresh `git init`, no commits) and single-commit repo. + - Expect: no dots, no errors in console. +7. Toggle OS "reduce motion" on and reload. + - Expect: no moving dots; the active path shows as a static brighter cyan stroke. +8. Resize to mobile width (vertical orientation). + - Expect: flow renders correctly along the vertical layout. +9. Click a commit circle that a dot is passing over. + - Expect: the click still selects the commit (dots have `pointer-events="none"`). + +Confirm zero console errors throughout. + +- [ ] **Step 11: Commit** + +```bash +git add src/ui/Graph.svelte +git commit -m "feat: animated energy flow along active branch path in graph" +``` + +--- + +## Self-Review (completed by plan author) + +- **Spec coverage:** traveling-pulse style ✓ (dots), active-path-only scope ✓ (`buildActiveFlow` first-parent walk), continuous stream ✓ (N staggered indefinite dots), constant speed ✓ (`dur` scales with `segmentCount`), root→HEAD direction ✓, <2-node guard ✓, reduced-motion static fallback ✓ (Task 2 Step 6), pure headless module + tests ✓ (Task 1). All four "Known risks" addressed: SMIL remount via `{#key}` (Step 6), `xlink:href` (Step 6 + Step 7 fallback), no `` tag confirmed during planning, non-uniform speed accepted as documented. +- **Placeholder scan:** none — all code blocks complete, all commands explicit. +- **Type consistency:** `buildActiveFlow(nodes, headCommitHash, orientation)` and `cubicSegment(fromX, fromY, toX, toY, orientation)` signatures identical across Task 1 (definition + tests) and Task 2 (call sites). Return shape `{ d, segmentCount }` used consistently. From 1ae07beb0632f8aa7cf46d261a10a8ae5cf3edc3 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 29 May 2026 00:28:12 +0800 Subject: [PATCH 04/13] feat: pure active-branch flow path builder for graph energy effect Co-Authored-By: Claude Sonnet 4.6 --- src/graph/flow.ts | 65 ++++++++++++++++++++++++++++ tests/graph/flow.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 src/graph/flow.ts create mode 100644 tests/graph/flow.test.ts diff --git a/src/graph/flow.ts b/src/graph/flow.ts new file mode 100644 index 0000000..3f08db7 --- /dev/null +++ b/src/graph/flow.ts @@ -0,0 +1,65 @@ +import type { GraphNode } from './types'; +import type { Orientation } from './layout'; + +/** + * One cubic-Bézier command (no leading `M`) from (fromX,fromY) to (toX,toY). + * Mirrors the curve shape used by edges so the flow rides the same wires. + */ +export function cubicSegment( + fromX: number, + fromY: number, + toX: number, + toY: number, + orientation: Orientation, +): string { + if (orientation === 'horizontal') { + const midX = (fromX + toX) / 2; + return `C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; + } + const midY = (fromY + toY) / 2; + return `C ${fromX} ${midY}, ${toX} ${midY}, ${toX} ${toY}`; +} + +/** + * Builds the active-branch flow path: the first-parent chain from the root + * commit to the HEAD commit, as a single connected SVG path string. + * Returns null when there is no drawable flow (<2 real nodes). + */ +export function buildActiveFlow( + nodes: GraphNode[], + headCommitHash: string, + orientation: Orientation, +): { d: string; segmentCount: number } | null { + if (!headCommitHash) return null; + + const byHash = new Map(); + for (const n of nodes) { + if (n.type === 'phantom' || !n.hash) continue; + byHash.set(n.hash, n); + } + + let cur = byHash.get(headCommitHash); + if (!cur) return null; + + // Walk first-parent ancestry HEAD -> root. + const chain: GraphNode[] = []; + const seen = new Set(); + while (cur && !seen.has(cur.hash)) { + seen.add(cur.hash); + chain.push(cur); + const parent: string | undefined = cur.parents[0]; + cur = parent ? byHash.get(parent) : undefined; + } + + chain.reverse(); // root -> HEAD + if (chain.length < 2) return null; + + let d = `M ${chain[0].x} ${chain[0].y}`; + for (let i = 1; i < chain.length; i++) { + const from = chain[i - 1]; + const to = chain[i]; + d += ` ${cubicSegment(from.x, from.y, to.x, to.y, orientation)}`; + } + + return { d, segmentCount: chain.length - 1 }; +} diff --git a/tests/graph/flow.test.ts b/tests/graph/flow.test.ts new file mode 100644 index 0000000..05657b0 --- /dev/null +++ b/tests/graph/flow.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { buildActiveFlow, cubicSegment } from '$graph/flow'; +import type { GraphNode } from '$graph/types'; + +function makeNode(hash: string, parents: string[], overrides: Partial = {}): GraphNode { + return { + hash, + type: 'commit' as const, + parents, + message: `commit ${hash}`, + branches: [], + tags: [], + isHEAD: false, + lane: 0, + x: 0, + y: 0, + ...overrides, + }; +} + +describe('cubicSegment', () => { + it('horizontal: control points at mid-X', () => { + expect(cubicSegment(0, 0, 100, 40, 'horizontal')).toBe('C 50 0, 50 40, 100 40'); + }); + + it('vertical: control points at mid-Y', () => { + expect(cubicSegment(0, 0, 40, 100, 'vertical')).toBe('C 0 50, 40 50, 40 100'); + }); +}); + +describe('buildActiveFlow', () => { + it('returns null when head hash is empty', () => { + const nodes = [makeNode('a', [], { x: 10, y: 10 })]; + expect(buildActiveFlow(nodes, '', 'horizontal')).toBeNull(); + }); + + it('returns null when head hash is not found', () => { + const nodes = [makeNode('a', [], { x: 10, y: 10 })]; + expect(buildActiveFlow(nodes, 'zzz', 'horizontal')).toBeNull(); + }); + + it('returns null for a single-node chain (root == head)', () => { + const nodes = [makeNode('a', [], { x: 10, y: 10 })]; + expect(buildActiveFlow(nodes, 'a', 'horizontal')).toBeNull(); + }); + + it('builds an ordered root -> head path for linear history', () => { + // a (root) <- b <- c (head) + const nodes = [ + makeNode('a', [], { x: 100, y: 50 }), + makeNode('b', ['a'], { x: 200, y: 50 }), + makeNode('c', ['b'], { x: 300, y: 50, isHEAD: true }), + ]; + const flow = buildActiveFlow(nodes, 'c', 'horizontal'); + expect(flow).not.toBeNull(); + expect(flow!.segmentCount).toBe(2); + // starts at the root (a), ends at the head (c) + expect(flow!.d.startsWith('M 100 50')).toBe(true); + expect(flow!.d.endsWith('300 50')).toBe(true); + }); + + it('follows first parent only at a merge commit', () => { + // mainline: a <- b <- m(head); feature: a <- f; m merges b (first) + f (second) + const nodes = [ + makeNode('a', [], { x: 100, y: 50 }), + makeNode('b', ['a'], { x: 200, y: 50 }), + makeNode('f', ['a'], { x: 200, y: 150 }), + makeNode('m', ['b', 'f'], { x: 300, y: 50, isHEAD: true }), + ]; + const flow = buildActiveFlow(nodes, 'm', 'horizontal'); + expect(flow).not.toBeNull(); + // chain is a -> b -> m (3 nodes, 2 hops); the second parent f is excluded + expect(flow!.segmentCount).toBe(2); + expect(flow!.d).not.toContain('200 150'); // f's position (x=200, y=150) never appears as a node endpoint + }); + + it('handles detached HEAD ending at an interior commit', () => { + const nodes = [ + makeNode('a', [], { x: 100, y: 50 }), + makeNode('b', ['a'], { x: 200, y: 50, isHEAD: true }), + makeNode('c', ['b'], { x: 300, y: 50 }), + ]; + const flow = buildActiveFlow(nodes, 'b', 'horizontal'); + expect(flow).not.toBeNull(); + expect(flow!.segmentCount).toBe(1); // a -> b only + expect(flow!.d.endsWith('200 50')).toBe(true); + }); + + it('ignores phantom nodes', () => { + const nodes = [makeNode('', [], { type: 'phantom', x: 168, y: 112 })]; + expect(buildActiveFlow(nodes, '', 'horizontal')).toBeNull(); + }); +}); From ef1ff460d8120d00c6e12870dca113a30b5eed21 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 29 May 2026 00:33:11 +0800 Subject: [PATCH 05/13] test: exercise phantom-node filtering in active flow path --- tests/graph/flow.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/graph/flow.test.ts b/tests/graph/flow.test.ts index 05657b0..51f8632 100644 --- a/tests/graph/flow.test.ts +++ b/tests/graph/flow.test.ts @@ -86,8 +86,17 @@ describe('buildActiveFlow', () => { expect(flow!.d.endsWith('200 50')).toBe(true); }); - it('ignores phantom nodes', () => { - const nodes = [makeNode('', [], { type: 'phantom', x: 168, y: 112 })]; - expect(buildActiveFlow(nodes, '', 'horizontal')).toBeNull(); + it('ignores phantom nodes when walking the chain', () => { + const nodes = [ + makeNode('p', [], { type: 'phantom', x: 0, y: 0 }), + makeNode('a', ['p'], { x: 100, y: 50 }), + makeNode('b', ['a'], { x: 200, y: 50, isHEAD: true }), + ]; + const flow = buildActiveFlow(nodes, 'b', 'horizontal'); + expect(flow).not.toBeNull(); + // phantom 'p' (a's recorded parent) is excluded, so the chain stops at root 'a' + expect(flow!.segmentCount).toBe(1); + expect(flow!.d.startsWith('M 100 50')).toBe(true); + expect(flow!.d.endsWith('200 50')).toBe(true); }); }); From 1081cccf9fb6d763a0b8e6b1af40df9ad276f1ec Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 29 May 2026 00:35:42 +0800 Subject: [PATCH 06/13] feat: animated energy flow along active branch path in graph Co-Authored-By: Claude Sonnet 4.6 --- src/ui/Graph.svelte | 59 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/ui/Graph.svelte b/src/ui/Graph.svelte index b23b7d2..6bfd317 100644 --- a/src/ui/Graph.svelte +++ b/src/ui/Graph.svelte @@ -2,12 +2,15 @@ import { engine, engineVersion, executeCommand } from '$store/engine'; import { computeLayout, NODE_SPACING_X, LANE_SPACING_Y } from '$graph/layout'; import type { Orientation } from '$graph/layout'; + import { buildActiveFlow, cubicSegment } from '$graph/flow'; import type { GraphNode, GraphEdge } from '$graph/types'; import CommitDetail from './CommitDetail.svelte'; const LANE_COLORS = ['#4ade80', '#60a5fa', '#c084fc', '#f87171', '#facc15', '#22d3ee']; const NODE_RADIUS = 25; const GRAPH_PADDING = 80; + const FLOW_COLOR = '#22d3ee'; + const FLOW_PER_SEGMENT_SEC = 0.6; let isMobile = $state(false); @@ -21,6 +24,18 @@ return () => mq.removeEventListener('change', onChange); }); + let prefersReducedMotion = $state(false); + + $effect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + prefersReducedMotion = mq.matches; + function onChange(e: MediaQueryListEvent) { + prefersReducedMotion = e.matches; + } + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }); + const orientation: Orientation = $derived(isMobile ? 'vertical' : 'horizontal'); const layout = $derived.by(() => { @@ -130,6 +145,10 @@ } }); + const activeFlow = $derived(buildActiveFlow(layout.nodes, headCommitHash, orientation)); + const flowDur = $derived(activeFlow ? activeFlow.segmentCount * FLOW_PER_SEGMENT_SEC : 0); + const flowDots = $derived(activeFlow ? activeFlow.segmentCount : 0); + function selectNode(node: GraphNode) { if (node.type === 'phantom') return; @@ -157,12 +176,7 @@ } function edgePath(edge: GraphEdge): string { - if (orientation === 'horizontal') { - const midX = (edge.fromX + edge.toX) / 2; - return `M ${edge.fromX} ${edge.fromY} C ${midX} ${edge.fromY}, ${midX} ${edge.toY}, ${edge.toX} ${edge.toY}`; - } - const midY = (edge.fromY + edge.toY) / 2; - return `M ${edge.fromX} ${edge.fromY} C ${edge.fromX} ${midY}, ${edge.toX} ${midY}, ${edge.toX} ${edge.toY}`; + return `M ${edge.fromX} ${edge.fromY} ${cubicSegment(edge.fromX, edge.fromY, edge.toX, edge.toY, orientation)}`; } @@ -191,6 +205,39 @@ /> {/each} + + {#if activeFlow} + {#if prefersReducedMotion} + + {:else} + {#key activeFlow.d} + + {/key} + {/if} + {/if} + {#each layout.nodes as node, ni (node.type === 'phantom' ? `phantom-${ni}` : node.hash)} {@const color = laneColor(node.lane)} From ec139c7afa9a182d173a7ce2350a79016393f30f Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 29 May 2026 00:39:21 +0800 Subject: [PATCH 07/13] fix: add xlink:href on mpath for Safari flow animation compat --- src/ui/Graph.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/Graph.svelte b/src/ui/Graph.svelte index 6bfd317..499beb0 100644 --- a/src/ui/Graph.svelte +++ b/src/ui/Graph.svelte @@ -228,8 +228,8 @@ begin="{(i * flowDur) / flowDots}s" repeatCount="indefinite" > - - + + {/each} From 496ed87b1c23488257aa46c501097d384ec4e800 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 29 May 2026 00:54:56 +0800 Subject: [PATCH 08/13] docs: explain the load-bearing {#key} SMIL remount in flow layer Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ui/Graph.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/Graph.svelte b/src/ui/Graph.svelte index 499beb0..75e9ca6 100644 --- a/src/ui/Graph.svelte +++ b/src/ui/Graph.svelte @@ -216,6 +216,9 @@ opacity="0.55" /> {:else} + {#key activeFlow.d}