diff --git a/.claude/rules/23-bin-zero-deps.md b/.claude/rules/23-bin-zero-deps.md index bdcb6e2..eee745d 100644 --- a/.claude/rules/23-bin-zero-deps.md +++ b/.claude/rules/23-bin-zero-deps.md @@ -10,6 +10,11 @@ import. - `node:*` modules (`node:fs`, `node:child_process`, `node:path`, `node:url`, `node:os`, `node:crypto`, `node:util`). +- Relative imports of sibling `.mjs` files **inside `bin/`** (e.g. + `import { … } from "./banner.mjs"`). These are first-party code that + ships in the `bin/` folder of the published tarball; they introduce + no third-party resolution at `npx` time. Each such sibling file is + itself bound by this rule (zero `node_modules/` imports, `node:*`-only). - Synchronous I/O (`mkdirSync`, `cpSync`, …) — the script is short and CLI-bound; readability beats event-loop nicety. @@ -52,9 +57,23 @@ shipping a pre-built artifact. ## Verification ```bash -# bin must not reference anything outside node:* in its imports -grep -E "^import |^const .*= require\\(" bin/forgeplan-web.mjs | \ - grep -v "from 'node:" | grep -v "require('node:" || echo "OK: no third-party deps" +# bin/* must only import node:* modules or relative sibling .mjs files. +# Allowed: from 'node:fs', from "./banner.mjs", from "../bin/x.mjs" +# Forbidden: from 'chalk', from 'figlet', from any bare specifier. +# Also handles multi-line `import { … } from "node:fs";` because we match +# the `from "…"` line itself, regardless of where the `import` keyword sits. +for f in bin/*.mjs; do + hits=$(grep -E "(from|require\()\s*['\"]" "$f" \ + | grep -vE "(from|require\()\s*['\"]node:" \ + | grep -vE "(from|require\()\s*['\"]\\.{1,2}/" || true) + if [ -z "$hits" ]; then + echo "OK ($f): no third-party deps" + else + echo "FAIL ($f): third-party imports found:" + echo "$hits" + exit 1 + fi +done # root package must not declare runtime deps node -e "const p=require('./package.json'); if (p.dependencies && Object.keys(p.dependencies).length) { console.error('FAIL: runtime deps present', p.dependencies); process.exit(1)} else { console.log('OK: no runtime deps') }" diff --git a/.forgeplan/evidence/EVID-010-prd-003-f1-acceptance-5-fr-live-verified-via-playwright-dom-eval-visual-screenshots.md b/.forgeplan/evidence/EVID-010-prd-003-f1-acceptance-5-fr-live-verified-via-playwright-dom-eval-visual-screenshots.md new file mode 100644 index 0000000..9c11b5a --- /dev/null +++ b/.forgeplan/evidence/EVID-010-prd-003-f1-acceptance-5-fr-live-verified-via-playwright-dom-eval-visual-screenshots.md @@ -0,0 +1,113 @@ +--- +created: 2026-05-05 +depth: tactical +id: EVID-010 +kind: evidence +links: +- target: PRD-003 + relation: informs +status: active +title: 'PRD-003 F1 acceptance: 5 FR live-verified via Playwright DOM eval + visual screenshots' +updated: 2026-05-05 +--- + +# EVID-010: PRD-003 F1 acceptance — live browser verification + +| Field | Value | +| ----------- | --------------------------------------------------------------- | +| Status | Draft | +| Created | 2026-05-05 | +| Valid Until | 2026-08-05 (3 months — re-verify if any view component changes) | +| Target | PRD-003 | + +## Structured Fields + +verdict: supports +congruence_level: 3 +evidence_type: test + +## Measurement + +PR #22 (`feature/frontend-recovery-a11y-f1 -> develop`, merge commit `d939f88`) +shipped 5 HIGH a11y/recovery fixes. This evidence pack verifies each FR +against the **live dev server** (`npm run dev` in `template/`) via the +MCP Playwright integration: DOM-property assertions on the rendered +output plus visual screenshots saved to repo root for review. + +Layers: + +- **Code review** — already done by frontend-developer sub-agents during + T-1..T-5 implementation; svelte-check passed 0/0. +- **CI smoke matrix** — PR #22 ubuntu/macos/windows × Node 22 all green. +- **Live DOM verification** — this layer; goes beyond compile-time and + catches integration issues invisible to type-checking. + +### Layer A — DOM assertions (live, http://localhost:5174) + +| FR | Assertion | Result | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| FR-001 | `navigate /nonexistent` renders `.error-shell` with `404` status, `Not Found` message, link `/` | ✅ all 4 properties present | +| FR-002 | All 5 graph views (Force/Tree/Radial/Matrix/Lanes) expose `role="img"` + descriptive aria-label | ✅ 5/5 verified by switching views and re-querying SVG | +| FR-003 | `aside.rail .row.clickable` uses nested ` +{:else} + + {#if children}{@render children()}{:else}{id}{/if} + +{/if} + + diff --git a/template/src/entities/graph/index.ts b/template/src/entities/graph/index.ts index 5282e88..8195cab 100644 --- a/template/src/entities/graph/index.ts +++ b/template/src/entities/graph/index.ts @@ -1,2 +1,13 @@ export type { GraphEdge, GraphResponse } from './model/types'; export { graphPoller } from './api/store'; + +export { + highlight, + setHovered, + clearHovered, + edgeClass, + bfsDistances, + nodeClass, + type HighlightEdge +} from './lib/highlight.svelte'; +export { nodeHover } from './lib/node-hover'; diff --git a/template/src/entities/graph/lib/highlight.svelte.ts b/template/src/entities/graph/lib/highlight.svelte.ts new file mode 100644 index 0000000..9b7ecac --- /dev/null +++ b/template/src/entities/graph/lib/highlight.svelte.ts @@ -0,0 +1,70 @@ +export type HighlightEdge = { from: string; to: string }; + +export const highlight = $state<{ hoveredId: string | null }>({ + hoveredId: null, +}); + +export function setHovered(id: string | null): void { + highlight.hoveredId = id; +} + +export function clearHovered(): void { + highlight.hoveredId = null; +} + +export function edgeClass( + from: string, + to: string, + hovered: string | null, +): string { + if (hovered === null) return ""; + if (hovered === from || hovered === to) return "edge-active"; + return "edge-dim"; +} + +export function bfsDistances( + start: string | null, + edges: ReadonlyArray, +): Map { + const out = new Map(); + if (start === null) return out; + + const adj = new Map(); + for (const { from, to } of edges) { + if (!adj.has(from)) adj.set(from, []); + if (!adj.has(to)) adj.set(to, []); + adj.get(from)!.push(to); + adj.get(to)!.push(from); + } + + out.set(start, 0); + const queue: string[] = [start]; + let head = 0; + while (head < queue.length) { + const cur = queue[head++]!; + const d = out.get(cur)!; + const neighbors = adj.get(cur); + if (!neighbors) continue; + for (const n of neighbors) { + if (!out.has(n)) { + out.set(n, d + 1); + queue.push(n); + } + } + } + return out; +} + +export function nodeClass( + id: string, + hovered: string | null, + distances: Map, +): string { + if (hovered === null) return ""; + if (id === hovered) return "node-active"; + const d = distances.get(id); + if (d === undefined) return "node-outside"; + if (d === 1) return "node-near"; + if (d === 2) return "node-mid"; + return "node-far"; +} diff --git a/template/src/entities/graph/lib/node-hover.ts b/template/src/entities/graph/lib/node-hover.ts new file mode 100644 index 0000000..a536d0d --- /dev/null +++ b/template/src/entities/graph/lib/node-hover.ts @@ -0,0 +1,40 @@ +import type { Action } from 'svelte/action'; +import { setHovered, clearHovered } from './highlight.svelte'; + +export const nodeHover: Action = ( + node, + initialId +) => { + let currentId: string | null = normalize(initialId); + + const onEnter = () => { + if (currentId) setHovered(currentId); + }; + const onLeave = () => { + clearHovered(); + }; + + node.addEventListener('mouseenter', onEnter); + node.addEventListener('mouseleave', onLeave); + node.addEventListener('focusin', onEnter); + node.addEventListener('focusout', onLeave); + + return { + update(nextId: string | null | undefined) { + currentId = normalize(nextId); + }, + destroy() { + node.removeEventListener('mouseenter', onEnter); + node.removeEventListener('mouseleave', onLeave); + node.removeEventListener('focusin', onEnter); + node.removeEventListener('focusout', onLeave); + clearHovered(); + } + }; +}; + +function normalize(id: string | null | undefined): string | null { + if (typeof id !== 'string') return null; + const trimmed = id.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/template/src/entities/health/model/types.ts b/template/src/entities/health/model/types.ts index 05ef701..2ecd607 100644 --- a/template/src/entities/health/model/types.ts +++ b/template/src/entities/health/model/types.ts @@ -1,9 +1,15 @@ +export interface BlindSpot { + id: string; + title?: string; + issue?: string; +} + export interface HealthResponse { total: number; by_kind: { kind: string; count: number }[]; by_status: { status: string; count: number }[]; by_derived_status: { status: string; count: number }[]; - blind_spots: string[]; + blind_spots: BlindSpot[]; orphans: string[]; active_stubs: string[]; stale_count: number; diff --git a/template/src/pages/home/ui/HomePage.svelte b/template/src/pages/home/ui/HomePage.svelte index 9500e83..e5c68a3 100644 --- a/template/src/pages/home/ui/HomePage.svelte +++ b/template/src/pages/home/ui/HomePage.svelte @@ -283,14 +283,12 @@ } @media (max-width: 1100px) { .layout { - grid-template-columns: 180px 1fr; + grid-template-columns: 200px 1fr; } .layout.has-panel { - grid-template-columns: 180px 1fr 360px; - } - .layout :global(aside.rail) { - display: none; + grid-template-columns: 200px 1fr 380px; } + .layout :global(aside.rail), .layout.has-panel :global(aside.rail) { display: none; } diff --git a/template/src/routes/+error.svelte b/template/src/routes/+error.svelte new file mode 100644 index 0000000..e1b70b0 --- /dev/null +++ b/template/src/routes/+error.svelte @@ -0,0 +1,87 @@ + + + +
+
+

forgeplan-web

+

{page.status}

+

{page.error?.message ?? 'Unexpected error'}

+ + + Go home + +
+
+ + diff --git a/template/src/widgets/artifact-panel/ui/ArtifactPanel.svelte b/template/src/widgets/artifact-panel/ui/ArtifactPanel.svelte index ff8322a..2616287 100644 --- a/template/src/widgets/artifact-panel/ui/ArtifactPanel.svelte +++ b/template/src/widgets/artifact-panel/ui/ArtifactPanel.svelte @@ -4,9 +4,11 @@ kindLabel, kindLabelColor, statusRing, + NodeRef, type ArtifactDetail } from '@/entities/artifact'; import type { GraphEdge } from '@/entities/graph'; + import { nodeHover } from '@/entities/graph'; import { reffTone } from '@/entities/score'; let { @@ -56,7 +58,7 @@
- {id} + {id} {#if detail} {kindLabel(detail.kind)} {detail.status} @@ -82,7 +84,7 @@ {#if detail.depth || detail.parent_epic || detail.valid_until}
{#if detail.depth}
depth
{detail.depth}
{/if} - {#if detail.parent_epic}
epic
{detail.parent_epic}
{/if} + {#if detail.parent_epic}
epic
onNavigate?.({ id: next })} />
{/if} {#if detail.valid_until}
valid until
{detail.valid_until}
{/if} {#if detail.updated_at}
updated
{new Date(detail.updated_at).toLocaleString()}
{/if}
@@ -96,7 +98,7 @@ {#each outgoing as e (e.from + e.to + e.relation)}
  • {e.relation} - + onNavigate?.({ id: next })} />
  • {/each} @@ -106,7 +108,7 @@
      {#each incoming as e (e.from + e.to + e.relation)}
    • - + onNavigate?.({ id: next })} /> {e.relation}
    • {/each} @@ -246,17 +248,6 @@ text-transform: uppercase; letter-spacing: 0.12em; } - .ref { - background: transparent; - border: 0; - padding: 0; - color: var(--accent); - cursor: pointer; - font: inherit; - } - .ref:hover { - text-decoration: underline; - } .body { padding: 12px 18px 28px; border-top: 1px solid var(--line); diff --git a/template/src/widgets/dependency-graph/lib/cluster.svelte.ts b/template/src/widgets/dependency-graph/lib/cluster.svelte.ts new file mode 100644 index 0000000..35626e9 --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/cluster.svelte.ts @@ -0,0 +1,534 @@ +import type { ArtifactSummary } from "@/entities/artifact"; +import type { GraphEdge } from "@/entities/graph"; + +// Type seniority — picks which artifact becomes a cluster root, NOT a +// per-type ring radius. Ring radii are computed adaptively per-cluster +// (see computeOrbitRing + computeRingRadius below). See RFC-004. +export const TYPE_ORDER = [ + "epic", + "prd", + "spec", + "rfc", + "adr", + "evidence", + "note", + "problem", + "solution", +] as const; + +// Geometry-driven constants. Card is roughly NODE_W × NODE_H (85 × 20 +// for a typical "EVID-001" label; some longer ids reach ~110×20). The +// bounding-circle diameter D = √(W² + H²) ≈ 87 for 85×20. Pad with +// SAFE_GAP for breathing. +// +// Ring radius rules (rigorous): +// 1. Two nodes on the SAME ring at angular separation Δθ have chord +// c = 2r·sin(Δθ/2). For no bbox overlap with N evenly-spaced nodes: +// 2r·sin(π/N) ≥ D + SAFE_GAP → r ≥ (D + SAFE_GAP) / (2·sin(π/N)) +// This is CHORD-based and is what the renderer actually needs. +// The previous N·MIN_NODE_SPACING/(2π) was ARC-based and underestimates. +// 2. Two nodes on ADJACENT rings at the same angle have radial gap +// |r₂ − r₁|. Worst case (angle = 0 or π) the cards are aligned +// horizontally and overlap when |r₂ − r₁| < W + SAFE_GAP. +// So RING_GAP must be at least max(W, H) + SAFE_GAP. +export const NODE_W_NOMINAL = 110; +export const NODE_H_NOMINAL = 20; +export const SAFE_GAP = 16; +export const CARD_DIAG = Math.sqrt( + NODE_W_NOMINAL * NODE_W_NOMINAL + NODE_H_NOMINAL * NODE_H_NOMINAL, +); +export const MIN_CHORD = CARD_DIAG + SAFE_GAP; +export const RING_GAP = Math.max(NODE_W_NOMINAL, NODE_H_NOMINAL) + SAFE_GAP; +// Inter-cluster gap is the visible space between two clusters' OUTER +// rings (edge-to-edge), independent of each cluster's maxR. Held +// constant by the centroid sweep so clusters look uniformly spaced. +export const INTER_CLUSTER_GAP = RING_GAP * 1.5; +export const BASE_RADIUS = 90; +export const MAX_RINGS = 8; + +const HIERARCHY_RELATIONS: ReadonlySet = new Set([ + "informs", + "refines", + "belongs-to", + "contains", + "supersedes", +]); + +export interface ClusterInfo { + id: string; + centroid: { x: number; y: number }; + members: string[]; + /** Multiplier on per-ring radii. <1 when the cluster's natural outer + * ring would exceed the viewport slot; sweep then resolves residual + * card overlap. 1 when no scaling needed. */ + radiusScale: number; + /** Per-member ring assignment (root → ring 0). Computed once in + * detectClusters and reused by both RadialView and ForceView so the + * orbit pipeline isn't re-run per render. */ + orbits: RingDepthMap; + /** Per-ring radius (already multiplied by radiusScale), indexed by + * ring number. Sparse array — entry undefined for rings absent in + * this cluster. */ + radii: number[]; +} + +export interface ClusterDetectionResult { + clusters: ClusterInfo[]; + /** node id → cluster id (root id) */ + nodeToCluster: Record; + /** Per-node hierarchy adjacency built from the input edges. Reused + * by callers that need anchored angular layout (RadialView). */ + nodeAdjacency: Map; +} + +/** Per-node ring (orbit index) within its cluster. Ring 0 = root. */ +export type RingDepthMap = Record; + +interface Viewport { + width: number; + height: number; +} + +function findTopType(nodes: ArtifactSummary[]): string | null { + const present = new Set(nodes.map((n) => String(n.kind).toLowerCase())); + return TYPE_ORDER.find((t) => present.has(t)) ?? null; +} + +function buildHierarchyAdjacency( + edges: GraphEdge[], + knownIds: ReadonlySet, +): Map { + const adj = new Map(); + for (const id of knownIds) adj.set(id, []); + for (const e of edges) { + if (!HIERARCHY_RELATIONS.has(e.relation)) continue; + if (!adj.has(e.from) || !adj.has(e.to)) continue; + adj.get(e.from)!.push(e.to); + // FIXME(directionality): treat hierarchy edges as undirected for + // root-walk because relation direction varies (Evidence informs PRD + // vs PRD refines RFC). Tighten when forgeplan settles a convention. + adj.get(e.to)!.push(e.from); + } + return adj; +} + +/** + * For a single cluster, compute each member's ring index by COMPACT + * type rank: take all distinct types present in the cluster, sort them + * by TYPE_ORDER seniority, then the ring index of a member is the + * position of its type in that sorted list. So if a cluster has PRD, + * RFC, EVID — PRD→0, RFC→1, EVID→2. If a cluster lacks some type, the + * remaining types collapse inward (no empty ring). Root is always + * ring 0 even if it shares a type with a sibling. + */ +export function computeOrbitRing( + rootId: string, + members: ArtifactSummary[], +): RingDepthMap { + const presentTypes = new Set(); + for (const m of members) presentTypes.add(String(m.kind).toLowerCase()); + const orderedTypes: string[] = []; + for (const t of TYPE_ORDER) { + if (presentTypes.has(t)) orderedTypes.push(t); + } + for (const t of presentTypes) { + if (!orderedTypes.includes(t)) orderedTypes.push(t); + } + const typeToRing = new Map(); + orderedTypes.forEach((t, i) => typeToRing.set(t, Math.min(i, MAX_RINGS - 1))); + + const out: RingDepthMap = {}; + for (const m of members) { + const ring = typeToRing.get(String(m.kind).toLowerCase()) ?? MAX_RINGS - 1; + out[m.id] = ring; + } + out[rootId] = 0; + return out; +} + +/** + * Parent-anchored angular layout. Within a cluster, place each ring's + * members so that members connected (by hierarchy) to the previous + * ring are angularly close to their parent. Members on ring 1 spread + * evenly across [0, 2π). Members on ring N >= 2 are grouped by their + * parent on ring N-1 (BFS-ancestor through hierarchy edges); each + * group occupies an angular sector centred on the parent's angle, the + * sector width proportional to group count. Orphans (no parent on the + * inner ring) get the largest free angular slot. + */ +export function computeAnchoredAngles( + rootId: string, + members: ArtifactSummary[], + rings: RingDepthMap, + adjacency: Map, +): Map { + const angle = new Map(); + angle.set(rootId, 0); + + const byRing = new Map(); + for (const m of members) { + const r = rings[m.id] ?? 0; + if (r === 0) continue; + if (!byRing.has(r)) byRing.set(r, []); + byRing.get(r)!.push(m.id); + } + const ringIndices = Array.from(byRing.keys()).sort((a, b) => a - b); + + for (const r of ringIndices) { + const ids = byRing.get(r)!; + if (r === ringIndices[0]) { + ids.forEach((id, i) => { + angle.set(id, (2 * Math.PI * i) / ids.length); + }); + continue; + } + // Find each member's preferred angle from its inner-ring neighbours. + const innerCandidates = (id: string): number[] => { + const cands: number[] = []; + for (const nbr of adjacency.get(id) ?? []) { + const ar = rings[nbr]; + if (ar !== undefined && ar < r && angle.has(nbr)) { + cands.push(angle.get(nbr)!); + } + } + return cands; + }; + const preferred = new Map(); + for (const id of ids) { + const c = innerCandidates(id); + if (c.length === 0) preferred.set(id, null); + else { + // circular mean + let sx = 0, + sy = 0; + for (const a of c) { + sx += Math.cos(a); + sy += Math.sin(a); + } + preferred.set(id, Math.atan2(sy, sx)); + } + } + const anchored = ids + .filter((id) => preferred.get(id) !== null) + .sort((a, b) => preferred.get(a)! - preferred.get(b)!); + const orphans = ids.filter((id) => preferred.get(id) === null); + const ordered = [...anchored, ...orphans]; + const N = ordered.length; + if (N === 0) continue; + if (anchored.length === 0) { + ordered.forEach((id, i) => { + angle.set(id, (2 * Math.PI * i) / N); + }); + continue; + } + // Place anchored ones close to their preferred angle but enforce + // minimum angular separation 2π/N. Then orphans fill remaining gaps. + const slot = (2 * Math.PI) / N; + const anchoredAngles: number[] = []; + let prev = -Infinity; + for (const id of anchored) { + let target = preferred.get(id)!; + if (target < 0) target += 2 * Math.PI; + if (target < prev + slot) target = prev + slot; + anchoredAngles.push(target); + prev = target; + } + // Wrap-around fairness: shift everything so the centroid stays put. + anchored.forEach((id, i) => { + const a = anchoredAngles[i]; + if (a !== undefined) angle.set(id, a % (2 * Math.PI)); + }); + if (orphans.length > 0) { + const used = anchoredAngles + .map((a) => a % (2 * Math.PI)) + .sort((a, b) => a - b); + const gaps: { start: number; size: number }[] = []; + for (let i = 0; i < used.length; i++) { + const a = used[i]!; + const b = i + 1 < used.length ? used[i + 1]! : used[0]! + 2 * Math.PI; + gaps.push({ start: a, size: b - a }); + } + gaps.sort((a, b) => b.size - a.size); + orphans.forEach((id, i) => { + const g = gaps[i % gaps.length]; + if (!g) return; + const frac = (i + 1) / (orphans.length + 1); + let a = g.start + g.size * frac; + a = ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + angle.set(id, a); + }); + } + } + return angle; +} + +/** + * Adaptive ring radius — chord-based, not arc-based. Returns a function + * `radius(ring)` whose values grow monotonically and obey two rules: + * - same-ring chord ≥ MIN_CHORD: r ≥ MIN_CHORD / (2·sin(π/N)) for N≥2, + * and r ≥ MIN_CHORD/2 for N=1 of a non-root ring. + * - adjacent-ring radial gap ≥ RING_GAP (handles same-angle overlap + * on neighbouring rings). + * Result is cached per ring. + */ +export function computeRingRadius( + nodesPerRing: (ring: number) => number, +): (ring: number) => number { + const cache = new Map(); + cache.set(0, 0); + return (ring: number) => { + if (cache.has(ring)) return cache.get(ring)!; + const N = Math.max(0, nodesPerRing(ring)); + const prev = cache.get(ring - 1) ?? 0; + const minByGap = prev + RING_GAP; + let minByChord: number; + if (N <= 0) minByChord = 0; + else if (N === 1) minByChord = MIN_CHORD / 2; + else minByChord = MIN_CHORD / (2 * Math.sin(Math.PI / N)); + const r = Math.max(BASE_RADIUS, minByGap, minByChord); + cache.set(ring, r); + return r; + }; +} + +/** + * Build a map ringIndex → count for a given ring assignment. Helper for + * `computeRingRadius` callers who need to know N per ring. + */ +export function ringCounts(rings: RingDepthMap): Map { + const counts = new Map(); + for (const ring of Object.values(rings)) { + counts.set(ring, (counts.get(ring) ?? 0) + 1); + } + return counts; +} + +export function detectClusters( + nodes: ArtifactSummary[], + edges: GraphEdge[], + viewport: Viewport, +): ClusterDetectionResult { + if (nodes.length === 0) { + return { clusters: [], nodeToCluster: {}, nodeAdjacency: new Map() }; + } + + const kindById = new Map(); + for (const node of nodes) { + kindById.set(node.id, String(node.kind).toLowerCase()); + } + + const topType = findTopType(nodes); + + if (topType === null) { + const members = nodes.map((n) => n.id); + const fallbackOrbits: RingDepthMap = {}; + members.forEach((id, i) => { + fallbackOrbits[id] = i === 0 ? 0 : 1; + }); + const fallbackCounts = ringCounts(fallbackOrbits); + const fallbackRadius = computeRingRadius((r) => fallbackCounts.get(r) ?? 0); + const fallbackRadii: number[] = []; + for (const r of fallbackCounts.keys()) { + fallbackRadii[r] = fallbackRadius(r); + } + const cluster: ClusterInfo = { + id: "__single__", + centroid: { x: viewport.width / 2, y: viewport.height / 2 }, + members, + radiusScale: 1, + orbits: fallbackOrbits, + radii: fallbackRadii, + }; + const nodeToCluster: Record = {}; + for (const id of members) nodeToCluster[id] = cluster.id; + return { + clusters: [cluster], + nodeToCluster, + nodeAdjacency: buildHierarchyAdjacency(edges, new Set(members)), + }; + } + + const centroidIds: string[] = []; + for (const node of nodes) { + if (kindById.get(node.id) === topType) centroidIds.push(node.id); + } + + const knownIds = new Set(nodes.map((n) => n.id)); + const adjacency = buildHierarchyAdjacency(edges, knownIds); + + // Pass 1: assign members to centroids without committing centroid + // positions yet — we need the largest cluster's member count to pick a + // safe grid spacing in pass 2. + const memberIdsByCluster = new Map(); + for (const id of centroidIds) memberIdsByCluster.set(id, [id]); + + const centroidSet = new Set(centroidIds); + const nodeToCluster: Record = {}; + for (const id of centroidIds) nodeToCluster[id] = id; + + function findAncestorCentroid(startId: string): string | null { + const visited = new Set([startId]); + const queue: string[] = [startId]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const next of adjacency.get(current) ?? []) { + if (visited.has(next)) continue; + visited.add(next); + if (centroidSet.has(next)) return next; + queue.push(next); + } + } + return null; + } + + for (const node of nodes) { + if (centroidSet.has(node.id)) continue; + const ancestor = findAncestorCentroid(node.id); + const target = ancestor ?? centroidIds[0]!; + nodeToCluster[node.id] = target; + memberIdsByCluster.get(target)!.push(node.id); + } + + // FR-005: compute the ACTUAL outermost-ring radius per cluster (using + // the same orbit + ring-radius pipeline that the renderer will use), + // pick the largest as `globalMaxR`, then derive the ideal + // centroid-to-centroid spacing as `2 * globalMaxR + RING_GAP`. Cap by + // half the smaller viewport dimension so a √K × √K grid still fits; + // when capped, the post-layout sweep handles residual overlap. + const memberMetaById = new Map(); + for (const node of nodes) memberMetaById.set(node.id, node); + + const actualMaxByCluster = new Map(); + const orbitsByCluster = new Map(); + const ringCountsByCluster = new Map>(); + const radiusFnByCluster = new Map number>(); + let globalMaxR = BASE_RADIUS; + for (const [rootId, ids] of memberIdsByCluster) { + const memberSummaries = ids + .map((id) => memberMetaById.get(id)) + .filter((m): m is ArtifactSummary => m !== undefined); + const orbits = computeOrbitRing(rootId, memberSummaries); + const counts = ringCounts(orbits); + const radius = computeRingRadius((r) => counts.get(r) ?? 0); + // CRITICAL: iterate in sorted order so the radius cache populates + // ring 0 → ring 1 → ring 2 → ... Each ring needs `prev` (ring-1) + // already cached for the radial-gap rule. Map.keys() returns + // insertion order, which is hash-bucket order from ringCounts and + // does not match orbit ordering. + const sortedRings = [...counts.keys()].sort((a, b) => a - b); + let actualMaxR = 0; + for (const r of sortedRings) { + const v = radius(r); + if (v > actualMaxR) actualMaxR = v; + } + if (actualMaxR === 0) actualMaxR = BASE_RADIUS; + actualMaxByCluster.set(rootId, actualMaxR); + orbitsByCluster.set(rootId, orbits); + ringCountsByCluster.set(rootId, counts); + radiusFnByCluster.set(rootId, radius); + if (actualMaxR > globalMaxR) globalMaxR = actualMaxR; + } + + // Place centroids so neighbouring outer rings sit at a uniform + // edge-gap. K(N) is over-determined (you can't keep ALL pairwise + // gaps equal for N≥4), so we relax to NEIGHBOUR-pair uniformity: + // - Find the largest cluster; place it at canvas centre. + // - The biggest cluster's "reach" is R₀ + INTER_CLUSTER_GAP + R_max + // where R_max = max remaining maxR. + // - Arrange the rest on a circle of radius `outerRadius` around the + // centre, evenly spaced in angle. Each circumferential neighbour + // pair has the same centroid distance, hence the same edge-gap + // (because all outer-ring clusters share the same maxR class + // when angles are uniform). When sizes differ, the gap-to-centre + // is constant and gap-between-siblings is computed via chord. + const naturalRArr = centroidIds.map( + (id) => actualMaxByCluster.get(id) ?? BASE_RADIUS, + ); + + let safePositions: { x: number; y: number }[]; + if (centroidIds.length === 1) { + safePositions = [{ x: viewport.width / 2, y: viewport.height / 2 }]; + } else if (centroidIds.length === 2) { + const cx = viewport.width / 2; + const cy = viewport.height / 2; + const d = naturalRArr[0]! + naturalRArr[1]! + INTER_CLUSTER_GAP; + safePositions = [ + { x: cx - d / 2, y: cy }, + { x: cx + d / 2, y: cy }, + ]; + } else { + // Pick the index of the largest cluster as the centre. + let centreIdx = 0; + for (let k = 1; k < naturalRArr.length; k++) { + if (naturalRArr[k]! > naturalRArr[centreIdx]!) centreIdx = k; + } + const others = naturalRArr.map((_, k) => k).filter((k) => k !== centreIdx); + const M = others.length; + // Outer-circle radius from centre = max(R_centre + GAP + R_outer) + // for each outer cluster, so every outer ring is at the same + // edge-gap from the centre cluster's outer ring. + let outerRadius = 0; + for (const k of others) { + const need = + naturalRArr[centreIdx]! + INTER_CLUSTER_GAP + naturalRArr[k]!; + if (need > outerRadius) outerRadius = need; + } + // Also enforce circumferential separation: chord between adjacent + // outer clusters at angular step 2π/M must satisfy + // chord = 2·outerRadius·sin(π/M) ≥ R_a + R_b + INTER_CLUSTER_GAP + // Use the worst-case (two largest among `others`) for the bound. + if (M >= 2) { + const sortedOthers = others + .slice() + .sort((a, b) => naturalRArr[b]! - naturalRArr[a]!); + const worstPair = + naturalRArr[sortedOthers[0]!]! + + naturalRArr[sortedOthers[1]!]! + + INTER_CLUSTER_GAP; + const minByChord = worstPair / (2 * Math.sin(Math.PI / M)); + if (minByChord > outerRadius) outerRadius = minByChord; + } + const cx = viewport.width / 2; + const cy = viewport.height / 2; + safePositions = new Array(centroidIds.length); + safePositions[centreIdx] = { x: cx, y: cy }; + others.forEach((k, idx) => { + const a = -Math.PI / 2 + (2 * Math.PI * idx) / M; + safePositions[k] = { + x: cx + Math.cos(a) * outerRadius, + y: cy + Math.sin(a) * outerRadius, + }; + }); + } + + const clusterById = new Map(); + centroidIds.forEach((id, index) => { + const orbits = orbitsByCluster.get(id) ?? {}; + const counts = ringCountsByCluster.get(id); + const radiusFn = radiusFnByCluster.get(id); + const radii: number[] = []; + if (counts && radiusFn) { + // Sorted iteration so radiusFn's cache resolves dependencies in + // order (ring N reads ring N-1 from the cache). Match the + // actualMaxR pass above. + const sortedRings = [...counts.keys()].sort((a, b) => a - b); + for (const r of sortedRings) { + radii[r] = radiusFn(r); + } + } + clusterById.set(id, { + id, + centroid: safePositions[index]!, + members: memberIdsByCluster.get(id)!, + radiusScale: 1, + orbits, + radii, + }); + }); + + return { + clusters: centroidIds.map((id) => clusterById.get(id)!), + nodeToCluster, + nodeAdjacency: adjacency, + }; +} diff --git a/template/src/widgets/dependency-graph/lib/cluster.test.ts b/template/src/widgets/dependency-graph/lib/cluster.test.ts new file mode 100644 index 0000000..0d5dd87 --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/cluster.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from "vitest"; +import { + computeRingRadius, + computeOrbitRing, + computeAnchoredAngles, + detectClusters, + ringCounts, + MIN_CHORD, + RING_GAP, + INTER_CLUSTER_GAP, + BASE_RADIUS, +} from "./cluster.svelte"; +import type { ArtifactSummary } from "@/entities/artifact"; +import type { GraphEdge } from "@/entities/graph"; + +const mk = (id: string, kind: ArtifactSummary["kind"]): ArtifactSummary => ({ + id, + kind, + title: "", + status: "active", +}); + +describe("computeRingRadius — chord rule", () => { + it("same-ring chord ≥ MIN_CHORD for N=2..12 on ring 1", () => { + for (let N = 2; N <= 12; N++) { + const r1 = computeRingRadius((ring) => (ring === 0 ? 0 : N))(1); + const chord = 2 * r1 * Math.sin(Math.PI / N); + expect(chord).toBeGreaterThanOrEqual(MIN_CHORD - 0.01); + } + }); + + it("adjacent-ring radial gap ≥ RING_GAP across rings 1..3", () => { + const r = computeRingRadius((ring) => + ring === 0 ? 0 : ring === 1 ? 5 : 8, + ); + const r1 = r(1); + const r2 = r(2); + const r3 = r(3); + expect(r2 - r1).toBeGreaterThanOrEqual(RING_GAP - 0.01); + expect(r3 - r2).toBeGreaterThanOrEqual(RING_GAP - 0.01); + }); + + it("N=1 special-case: radius ≥ MIN_CHORD/2", () => { + const r1 = computeRingRadius((ring) => (ring === 0 ? 0 : 1))(1); + expect(r1).toBeGreaterThanOrEqual(MIN_CHORD / 2); + }); + + it("ring 0 is always 0 (root pinned at centroid)", () => { + const r = computeRingRadius(() => 5); + expect(r(0)).toBe(0); + }); + + it("radius is monotonically non-decreasing with ring index", () => { + const r = computeRingRadius((ring) => (ring === 0 ? 0 : ring + 2)); + let prev = r(0); + for (let i = 1; i <= 5; i++) { + const cur = r(i); + expect(cur).toBeGreaterThanOrEqual(prev); + prev = cur; + } + }); +}); + +describe("computeOrbitRing — compact type-rank", () => { + it("root sits on ring 0", () => { + const members = [mk("PRD-1", "prd"), mk("RFC-1", "rfc")]; + const out = computeOrbitRing("PRD-1", members); + expect(out["PRD-1"]).toBe(0); + }); + + it("members of the same kind get the same ring", () => { + const members = [ + mk("PRD-1", "prd"), + mk("RFC-1", "rfc"), + mk("RFC-2", "rfc"), + mk("EVID-1", "evidence"), + ]; + const out = computeOrbitRing("PRD-1", members); + expect(out["RFC-1"]).toBe(out["RFC-2"]); + expect(out["EVID-1"]).toBeGreaterThan(out["RFC-1"]!); + }); + + it("missing types collapse inward (no empty ring)", () => { + const members = [ + mk("PRD-1", "prd"), + mk("EVID-1", "evidence"), + mk("EVID-2", "evidence"), + ]; + const out = computeOrbitRing("PRD-1", members); + expect(out["PRD-1"]).toBe(0); + expect(out["EVID-1"]).toBe(1); + expect(out["EVID-2"]).toBe(1); + }); +}); + +describe("computeAnchoredAngles", () => { + it("ring-1 with no anchors spreads evenly", () => { + const members = [ + mk("PRD-1", "prd"), + mk("RFC-1", "rfc"), + mk("RFC-2", "rfc"), + mk("RFC-3", "rfc"), + mk("RFC-4", "rfc"), + ]; + const rings = { + "PRD-1": 0, + "RFC-1": 1, + "RFC-2": 1, + "RFC-3": 1, + "RFC-4": 1, + }; + const adj = new Map([ + ["PRD-1", []], + ["RFC-1", []], + ["RFC-2", []], + ["RFC-3", []], + ["RFC-4", []], + ]); + const angles = computeAnchoredAngles("PRD-1", members, rings, adj); + const sorted = ["RFC-1", "RFC-2", "RFC-3", "RFC-4"] + .map((id) => angles.get(id)!) + .sort((a, b) => a - b); + expect(sorted.length).toBe(4); + const expectedStep = (Math.PI * 2) / 4; + for (let i = 1; i < sorted.length; i++) { + const diff = sorted[i]! - sorted[i - 1]!; + expect(Math.abs(diff - expectedStep)).toBeLessThan(0.01); + } + const wrap = sorted[0]! + Math.PI * 2 - sorted[sorted.length - 1]!; + expect(Math.abs(wrap - expectedStep)).toBeLessThan(0.01); + }); + + it("root angle is 0", () => { + const members = [mk("PRD-1", "prd"), mk("RFC-1", "rfc")]; + const rings = { "PRD-1": 0, "RFC-1": 1 }; + const adj = new Map([ + ["PRD-1", []], + ["RFC-1", []], + ]); + const angles = computeAnchoredAngles("PRD-1", members, rings, adj); + expect(angles.get("PRD-1")).toBe(0); + }); +}); + +describe("ringCounts", () => { + it("counts members per ring", () => { + const counts = ringCounts({ a: 0, b: 1, c: 1, d: 2, e: 2, f: 2 }); + expect(counts.get(0)).toBe(1); + expect(counts.get(1)).toBe(2); + expect(counts.get(2)).toBe(3); + }); +}); + +describe("detectClusters — multi-cluster placement", () => { + it("K=0: empty input returns empty clusters", () => { + const result = detectClusters([], [], { width: 1000, height: 600 }); + expect(result.clusters.length).toBe(0); + expect(Object.keys(result.nodeToCluster).length).toBe(0); + }); + + it("K=1: centroid sits at canvas centre", () => { + const nodes = [mk("PRD-1", "prd"), mk("RFC-1", "rfc")]; + const result = detectClusters(nodes, [], { width: 1000, height: 600 }); + expect(result.clusters.length).toBe(1); + expect(result.clusters[0]!.centroid.x).toBeCloseTo(500, 5); + expect(result.clusters[0]!.centroid.y).toBeCloseTo(300, 5); + expect(result.nodeToCluster["PRD-1"]).toBe("PRD-1"); + expect(result.nodeToCluster["RFC-1"]).toBe("PRD-1"); + }); + + it("K=2: centroids span the canvas centre symmetrically", () => { + const nodes = [mk("PRD-1", "prd"), mk("PRD-2", "prd")]; + const result = detectClusters(nodes, [], { width: 2000, height: 1500 }); + expect(result.clusters.length).toBe(2); + const a = result.clusters[0]!; + const b = result.clusters[1]!; + const cx = 1000; + const cy = 750; + expect(a.centroid.y).toBeCloseTo(cy, 5); + expect(b.centroid.y).toBeCloseTo(cy, 5); + expect((a.centroid.x + b.centroid.x) / 2).toBeCloseTo(cx, 5); + const d = Math.hypot( + b.centroid.x - a.centroid.x, + b.centroid.y - a.centroid.y, + ); + const minExpected = 2 * BASE_RADIUS + INTER_CLUSTER_GAP - 0.01; + expect(d).toBeGreaterThanOrEqual(minExpected); + }); + + it("K≥3: largest cluster at centre, others on regular polygon (equal radii)", () => { + const nodes = [ + mk("PRD-1", "prd"), + mk("E1", "evidence"), + mk("E2", "evidence"), + mk("E3", "evidence"), + mk("PRD-2", "prd"), + mk("E4", "evidence"), + mk("PRD-3", "prd"), + mk("E5", "evidence"), + mk("PRD-4", "prd"), + mk("E6", "evidence"), + ]; + const edges: GraphEdge[] = [ + { from: "E1", to: "PRD-1", relation: "informs" }, + { from: "E2", to: "PRD-1", relation: "informs" }, + { from: "E3", to: "PRD-1", relation: "informs" }, + { from: "E4", to: "PRD-2", relation: "informs" }, + { from: "E5", to: "PRD-3", relation: "informs" }, + { from: "E6", to: "PRD-4", relation: "informs" }, + ]; + const result = detectClusters(nodes, edges, { width: 2000, height: 2000 }); + expect(result.clusters.length).toBe(4); + const centre = result.clusters.find((c) => c.id === "PRD-1")!; + expect(centre.centroid.x).toBeCloseTo(1000, 5); + expect(centre.centroid.y).toBeCloseTo(1000, 5); + const others = result.clusters.filter((c) => c.id !== "PRD-1"); + const distances = others.map((o) => + Math.hypot( + o.centroid.x - centre.centroid.x, + o.centroid.y - centre.centroid.y, + ), + ); + for (let i = 1; i < distances.length; i++) { + expect(Math.abs(distances[i]! - distances[0]!)).toBeLessThan(0.01); + } + }); + + it("hierarchy edges route members to their ancestor centroid", () => { + const nodes = [ + mk("PRD-1", "prd"), + mk("PRD-2", "prd"), + mk("E1", "evidence"), + mk("E2", "evidence"), + ]; + const edges: GraphEdge[] = [ + { from: "E1", to: "PRD-1", relation: "informs" }, + { from: "E2", to: "PRD-2", relation: "informs" }, + ]; + const result = detectClusters(nodes, edges, { width: 2000, height: 1500 }); + expect(result.nodeToCluster["E1"]).toBe("PRD-1"); + expect(result.nodeToCluster["E2"]).toBe("PRD-2"); + }); +}); diff --git a/template/src/widgets/dependency-graph/lib/force-cluster-repel.ts b/template/src/widgets/dependency-graph/lib/force-cluster-repel.ts new file mode 100644 index 0000000..c83c72c --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/force-cluster-repel.ts @@ -0,0 +1,142 @@ +import type { Force, SimulationNodeDatum } from "d3-force"; + +export interface ForceClusterRepelOptions { + strength?: number; + minDistance?: number; + getClusterId: (node: NodeT) => string | undefined; +} + +export interface ForceClusterRepel + extends Force { + strength(): number; + strength(value: number): this; + minDistance(): number; + minDistance(value: number): this; +} + +const DEFAULT_STRENGTH = 800; +const DEFAULT_MIN_DISTANCE = 250; +// TODO(epsilon): kept tiny to avoid masking the inverse-square law at +// d ≈ 0; sufficient because two nodes ending exactly co-located is +// extremely rare in d3 simulations (phyllotaxis init + collide force). +const EPSILON = 1e-4; + +export function forceClusterRepel( + options: ForceClusterRepelOptions, +): ForceClusterRepel { + let strength = options.strength ?? DEFAULT_STRENGTH; + let minDistance = options.minDistance ?? DEFAULT_MIN_DISTANCE; + const getClusterId = options.getClusterId; + + let cachedNodes: NodeT[] = []; + let clusterIds: (string | undefined)[] = []; + + function recomputeClusterIds(): void { + clusterIds = cachedNodes.map((n) => getClusterId(n)); + } + + // O(N²) pair iteration. Acceptable per PRD-005 SC-6 (target ≤ 270 nodes, + // settle < 5 s). d3-quadtree would give O(N log N) but ships without TS + // types in this repo and adding @types/d3-quadtree expands devDeps; the + // strict-mode rule forbids `any` casts. Naive loop stays type-clean. + const tickFn = (alpha: number): void => { + const nodes = cachedNodes; + const n = nodes.length; + if (n < 2) return; + + let firstId: string | undefined; + let firstSeen = false; + let allSame = true; + for (let k = 0; k < n; k++) { + const cid = clusterIds[k]; + if (!firstSeen) { + firstId = cid; + firstSeen = true; + } else if (cid !== firstId) { + allSame = false; + break; + } + } + if (allSame) return; + + const minDistSq = minDistance * minDistance; + + for (let i = 0; i < n; i++) { + const a = nodes[i]!; + const ax = a.x ?? 0; + const ay = a.y ?? 0; + const aCluster = clusterIds[i]; + const aFixed = a.fx != null && a.fy != null; + + for (let j = i + 1; j < n; j++) { + const bCluster = clusterIds[j]; + if ( + aCluster !== undefined && + bCluster !== undefined && + aCluster === bCluster + ) { + continue; + } + + const b = nodes[j]!; + if (aFixed && b.fx != null && b.fy != null) continue; + const bx = b.x ?? 0; + const by = b.y ?? 0; + + let dx = ax - bx; + let dy = ay - by; + const distSq = dx * dx + dy * dy; + if (distSq >= minDistSq) continue; + + const dist = Math.sqrt(distSq); + if (dist < EPSILON) { + // FIXME(coincident): tiny deterministic jitter so two co-located + // nodes from different clusters separate; sign derived from + // index parity to stay reproducible across ticks. + dx = (i % 2 === 0 ? 1 : -1) * EPSILON; + dy = (j % 2 === 0 ? 1 : -1) * EPSILON; + } + + const safeDistSq = distSq + EPSILON; + const magnitude = (strength * alpha) / safeDistSq; + const invDist = 1 / Math.max(dist, EPSILON); + const fx = dx * invDist * magnitude; + const fy = dy * invDist * magnitude; + + a.vx = (a.vx ?? 0) + fx; + a.vy = (a.vy ?? 0) + fy; + b.vx = (b.vx ?? 0) - fx; + b.vy = (b.vy ?? 0) - fy; + } + } + }; + + const initializeFn = (nodes: NodeT[]): void => { + cachedNodes = nodes; + recomputeClusterIds(); + }; + + function strengthFn(): number; + function strengthFn(value: number): ForceClusterRepel; + function strengthFn(value?: number): number | ForceClusterRepel { + if (value === undefined) return strength; + strength = value; + return force; + } + + function minDistanceFn(): number; + function minDistanceFn(value: number): ForceClusterRepel; + function minDistanceFn(value?: number): number | ForceClusterRepel { + if (value === undefined) return minDistance; + minDistance = value; + return force; + } + + const force: ForceClusterRepel = Object.assign(tickFn, { + initialize: initializeFn, + strength: strengthFn, + minDistance: minDistanceFn, + }); + + return force; +} diff --git a/template/src/widgets/dependency-graph/lib/highlight.svelte.ts b/template/src/widgets/dependency-graph/lib/highlight.svelte.ts new file mode 100644 index 0000000..42ae78c --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/highlight.svelte.ts @@ -0,0 +1,13 @@ +// TODO(fsd-cleanup): consumers should import from '@/entities/graph' directly; +// this re-export keeps existing graph-view imports stable while the store +// has moved down to the entities layer so widgets/insights-rail and +// widgets/artifact-panel can drive hover too. +export { + highlight, + setHovered, + clearHovered, + edgeClass, + bfsDistances, + nodeClass, + type HighlightEdge +} from '@/entities/graph'; diff --git a/template/src/widgets/dependency-graph/lib/keyboard-nav.test.ts b/template/src/widgets/dependency-graph/lib/keyboard-nav.test.ts new file mode 100644 index 0000000..2b7e2ff --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/keyboard-nav.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { pickNextNode, type FocusNode } from "./keyboard-nav"; + +const mk = (id: string, x: number, y: number): FocusNode => ({ id, x, y }); + +describe("pickNextNode — direction-based focus pick", () => { + it("ArrowRight picks the rightward neighbour over the upward one", () => { + const current = mk("A", 0, 0); + const candidates = [current, mk("RIGHT", 100, 0), mk("UP", 0, -100)]; + const next = pickNextNode(current, candidates, "ArrowRight"); + expect(next?.id).toBe("RIGHT"); + }); + + it("ArrowUp picks the upward node even when a rightward one is closer", () => { + const current = mk("A", 0, 0); + const candidates = [current, mk("UP", 0, -100), mk("RIGHT", 30, 0)]; + const next = pickNextNode(current, candidates, "ArrowUp"); + expect(next?.id).toBe("UP"); + }); + + it("ArrowLeft falls back to nearest when no candidate inside cone", () => { + const current = mk("A", 0, 0); + // Both candidates are to the right, none to the left → fall back to nearest. + const candidates = [current, mk("R1", 50, 0), mk("R2", 100, 0)]; + const next = pickNextNode(current, candidates, "ArrowLeft"); + expect(next?.id).toBe("R1"); + }); + + it("returns null when there is only the current node", () => { + const current = mk("A", 0, 0); + expect(pickNextNode(current, [current], "ArrowDown")).toBeNull(); + }); + + it("ArrowDown prefers a slightly-off-axis closer node over a far on-axis one", () => { + const current = mk("A", 0, 0); + // CLOSE_OFF: 10° off the down axis at distance 50. + // FAR_ON: exactly down at distance 200. + const ang = (10 * Math.PI) / 180; + const closeOff = mk("CLOSE_OFF", Math.sin(ang) * 50, Math.cos(ang) * 50); + const farOn = mk("FAR_ON", 0, 200); + const next = pickNextNode(current, [current, closeOff, farOn], "ArrowDown"); + expect(next?.id).toBe("CLOSE_OFF"); + }); + + it("ignores nodes at exactly the same position (avoid divide-by-zero)", () => { + const current = mk("A", 0, 0); + const dup = mk("DUP", 0, 0); + const right = mk("R", 50, 0); + const next = pickNextNode(current, [current, dup, right], "ArrowRight"); + expect(next?.id).toBe("R"); + }); +}); diff --git a/template/src/widgets/dependency-graph/lib/keyboard-nav.ts b/template/src/widgets/dependency-graph/lib/keyboard-nav.ts new file mode 100644 index 0000000..1490acd --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/keyboard-nav.ts @@ -0,0 +1,67 @@ +/** + * Direction-based focus navigation for graph views. + * + * Given the currently focused node and a desired direction (one of the + * four arrow keys), pick the next node whose centre lies inside the + * angular cone of that direction (±60° around the cardinal axis) and + * minimises a cost = distance · (1 + 2·angularDeviation/π). + * + * If no candidate falls inside the cone, fall back to the nearest + * node by Euclidean distance — so the user always moves. + */ + +export type Direction = "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown"; + +export interface FocusNode { + id: string; + /** Centre coordinates in the same coordinate space (logical or screen). */ + x: number; + y: number; +} + +const HALF_CONE = Math.PI / 3; // 60° each side → 120° cone total + +const directionVector = (dir: Direction): { dx: number; dy: number } => { + switch (dir) { + case "ArrowLeft": + return { dx: -1, dy: 0 }; + case "ArrowRight": + return { dx: 1, dy: 0 }; + case "ArrowUp": + return { dx: 0, dy: -1 }; + case "ArrowDown": + return { dx: 0, dy: 1 }; + } +}; + +export function pickNextNode( + current: FocusNode, + candidates: FocusNode[], + dir: Direction, +): FocusNode | null { + if (candidates.length === 0) return null; + const others = candidates.filter((n) => n.id !== current.id); + if (others.length === 0) return null; + + const target = directionVector(dir); + let best: { node: FocusNode; cost: number } | null = null; + let nearest: { node: FocusNode; dist: number } | null = null; + + for (const n of others) { + const dx = n.x - current.x; + const dy = n.y - current.y; + const dist = Math.hypot(dx, dy); + if (dist === 0) continue; + + if (!nearest || dist < nearest.dist) nearest = { node: n, dist }; + + const cosAngle = (dx * target.dx + dy * target.dy) / dist; + const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle))); + if (angle > HALF_CONE) continue; + + const cost = dist * (1 + (2 * angle) / Math.PI); + if (!best || cost < best.cost) best = { node: n, cost }; + } + + return best?.node ?? nearest?.node ?? null; +} diff --git a/template/src/widgets/dependency-graph/lib/reduced-motion.ts b/template/src/widgets/dependency-graph/lib/reduced-motion.ts new file mode 100644 index 0000000..8ac722b --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/reduced-motion.ts @@ -0,0 +1,9 @@ +// FR-004 (PRD-003): single source of truth for the prefers-reduced-motion +// preference. Returns 0 (no animation) when reduce is set, otherwise the +// passed default. Safe on server: matchMedia is window-only, so we guard. +export function motionDuration(defaultMs: number): number { + if (typeof window === "undefined") return defaultMs; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? 0 + : defaultMs; +} diff --git a/template/src/widgets/dependency-graph/lib/regression.test.ts b/template/src/widgets/dependency-graph/lib/regression.test.ts new file mode 100644 index 0000000..a840f73 --- /dev/null +++ b/template/src/widgets/dependency-graph/lib/regression.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { detectClusters, RING_GAP } from "./cluster.svelte"; +import type { ArtifactSummary } from "@/entities/artifact"; + +const mk = (id: string, kind: string): ArtifactSummary => + ({ id, kind, title: "", status: "active" }) as ArtifactSummary; + +describe("regression: cluster.radii populated in dependency order", () => { + // The bug: ringCounts iterated in Map-insertion order, which depended + // on orbit-assignment order. detectClusters seeded computeRingRadius's + // cache out of dependency order: e.g. radius(2) before radius(1). + // Then `prev = cache.get(1) ?? 0` was 0 (not 126), and the radial-gap + // rule produced ring 2 = 126 instead of 252. Visible as RFC + ADR + // sharing the same orbit position in RadialView. + it("ring N radius ≥ ring N-1 radius + RING_GAP for all populated rings", () => { + const nodes = [ + mk("PRD-001", "prd"), + mk("ADR-001", "adr"), + mk("ADR-002", "adr"), + mk("EVID-001", "evidence"), + mk("EVID-002", "evidence"), + mk("EVID-003", "evidence"), + mk("EVID-004", "evidence"), + mk("EVID-005", "evidence"), + mk("EVID-006", "evidence"), + mk("EVID-007", "evidence"), + mk("EVID-008", "evidence"), + mk("RFC-001", "rfc"), + mk("RFC-002", "rfc"), + mk("RFC-003", "rfc"), + ]; + const result = detectClusters(nodes, [], { width: 1000, height: 700 }); + const cluster = result.clusters.find((c) => c.id === "PRD-001")!; + const radii = cluster.radii; + expect(radii[1]! - radii[0]!).toBeGreaterThanOrEqual(RING_GAP - 0.01); + expect(radii[2]! - radii[1]!).toBeGreaterThanOrEqual(RING_GAP - 0.01); + expect(radii[3]! - radii[2]!).toBeGreaterThanOrEqual(RING_GAP - 0.01); + }); + + it("RFC (ring 1) and ADR (ring 2) cannot share radius", () => { + const nodes = [ + mk("PRD-001", "prd"), + mk("ADR-001", "adr"), + mk("RFC-001", "rfc"), + ]; + const result = detectClusters(nodes, [], { width: 1000, height: 700 }); + const cluster = result.clusters.find((c) => c.id === "PRD-001")!; + const orbits = cluster.orbits; + expect(orbits["RFC-001"]).toBe(1); + expect(orbits["ADR-001"]).toBe(2); + expect(cluster.radii[2]).toBeGreaterThan(cluster.radii[1]!); + }); +}); diff --git a/template/src/widgets/dependency-graph/ui/ForceView.svelte b/template/src/widgets/dependency-graph/ui/ForceView.svelte index f8f5a99..cffa3a4 100644 --- a/template/src/widgets/dependency-graph/ui/ForceView.svelte +++ b/template/src/widgets/dependency-graph/ui/ForceView.svelte @@ -8,6 +8,7 @@ forceCollide, forceX, forceY, + type Force, type Simulation, type SimulationNodeDatum, type SimulationLinkDatum @@ -24,6 +25,14 @@ import { CHAR_W, NODE_H, NODE_PAD_X } from '@/widgets/dependency-graph/lib/sizing'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationClass } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; + import { highlight, setHovered, clearHovered, edgeClass, bfsDistances, nodeClass } from '../lib/highlight.svelte'; + import { + detectClusters, + type ClusterInfo + } from '../lib/cluster.svelte'; + import { forceClusterRepel } from '../lib/force-cluster-repel'; + import { pickNextNode, type Direction } from '../lib/keyboard-nav'; interface Node extends SimulationNodeDatum { id: string; @@ -84,7 +93,12 @@ tickGen++; } - function nodePos(n: Node, _tick: number): [number, number] { + // The `_invalidationTick` parameter exists solely so call sites can pass + // `tickGen` from the template. Reading tickGen at the call site is what + // makes Svelte invalidate the surrounding {@const} when the d3 tick + // listener bumps it — d3 mutates n.x/n.y in-place on a $state.raw array, + // so without this dependency the template would render stale positions. + function nodePos(n: Node, _invalidationTick: number): [number, number] { return [n.x ?? 0, n.y ?? 0]; } @@ -107,11 +121,81 @@ return { x: cx - dx * t, y: cy - dy * t }; } - const scoreById = $derived(new Map(scores.map((s) => [s.id, s.r_eff]))); + // Score memoization: the 10s scores poll returns a fresh array even when + // r_eff values are identical, which would rebuild the Map and invalidate + // every $effect reading scoreById. Gate on a content signature. + const scoresSig = $derived(scores.map((s) => `${s.id}:${s.r_eff}`).join('|')); + let lastScoresSig = ''; + let cachedScoreById = new Map(); + const scoreById = $derived.by(() => { + if (scoresSig === lastScoresSig) return cachedScoreById; + lastScoresSig = scoresSig; + cachedScoreById = new Map(scores.map((s) => [s.id, s.r_eff])); + return cachedScoreById; + }); - const filteredNodes = $derived(filterArtifacts(nodes, kindFilter, statusFilter)); + // Filter memoization: the 10s nodes/edges poll always returns fresh array + // references even when the underlying payload is identical. A naive + // `$derived(filterArtifacts(...))` would invalidate the entire layout + // pipeline on every poll. Reduce the inputs to a content signature first; + // recompute only when that signature actually changes. + const nodesSig = $derived( + nodes.map((n) => `${n.id}:${n.kind}:${n.status}`).join('|') + + '||' + + Array.from(kindFilter).sort().join(',') + + '||' + + Array.from(statusFilter).sort().join(',') + ); + let lastNodesSig = ''; + let cachedFilteredNodes: ArtifactSummary[] = []; + const filteredNodes = $derived.by(() => { + if (nodesSig === lastNodesSig) return cachedFilteredNodes; + lastNodesSig = nodesSig; + cachedFilteredNodes = filterArtifacts(nodes, kindFilter, statusFilter); + return cachedFilteredNodes; + }); const filteredIds = $derived(new Set(filteredNodes.map((n) => n.id))); - const filteredEdges = $derived(filterEdges(edges, filteredIds)); + const edgesSig = $derived( + edges.map((e) => `${e.from}>${e.to}:${e.relation}`).join('|') + + '||' + + Array.from(filteredIds).sort().join(',') + ); + let lastEdgesSig = ''; + let cachedFilteredEdges: GraphEdge[] = []; + const filteredEdges = $derived.by(() => { + if (edgesSig === lastEdgesSig) return cachedFilteredEdges; + lastEdgesSig = edgesSig; + cachedFilteredEdges = filterEdges(edges, filteredIds); + return cachedFilteredEdges; + }); + const focusId = $derived(highlight.hoveredId ?? selectedId); + const hoverDistances = $derived(bfsDistances(focusId, filteredEdges)); + + type Layout = { + clusters: ClusterInfo[]; + nodeToCluster: Record; + clusterById: Map; + signature: string; + }; + + const layout: Layout = $derived.by(() => { + const ns = filteredNodes; + const es = filteredEdges; + const viewport = { width: Math.max(1, width), height: Math.max(1, height) }; + const { clusters, nodeToCluster } = detectClusters(ns, es, viewport); + + const clusterById = new Map(); + for (const cluster of clusters) clusterById.set(cluster.id, cluster); + + // Signature for re-bind detection: cluster set changed OR membership + // changed. Cheap to compute and stable across rerenders that don't + // structurally change layout. + const signature = clusters + .map((c) => `${c.id}:${c.members.slice().sort().join(',')}`) + .join('|'); + + return { clusters, nodeToCluster, clusterById, signature }; + }); function nodeStructureKey(items: { id: string }[]): string { return items @@ -138,6 +222,53 @@ .join('|'); } + // d3-force's built-in `forceRadial` accepts a single fixed center per + // instance, which doesn't fit a multi-cluster layout. We need each node + // pulled toward _its_ cluster centroid by _its_ ring radius. This custom + // force does per-node radial pull using `getCenter` + `getTargetRadius` + // closures bound to the current layout. + function forceClusterOrbital(opts: { + strength: number; + getCenter: (n: NodeT) => { x: number; y: number }; + getTargetRadius: (n: NodeT) => number; + }): Force { + let cached: NodeT[] = []; + const force: Force = (alpha: number) => { + const k = opts.strength * alpha; + for (const n of cached) { + const c = opts.getCenter(n); + const r = opts.getTargetRadius(n); + const nx = (n.x ?? 0) - c.x; + const ny = (n.y ?? 0) - c.y; + const dist = Math.hypot(nx, ny) || 1e-6; + const delta = (r - dist) * k; + // FIXME(radial-zero): when dist≈0 (node spawned at centroid), nx/ny + // give an arbitrary direction. Acceptable: collide + clusterRepel + // perturb it within the first few ticks. + n.vx = (n.vx ?? 0) + (nx / dist) * delta; + n.vy = (n.vy ?? 0) + (ny / dist) * delta; + } + }; + force.initialize = (nodes: NodeT[]) => { + cached = nodes; + }; + return force; + } + + function getCentroid(id: string): { x: number; y: number } { + const c = layout.clusterById.get(layout.nodeToCluster[id] ?? ''); + return c?.centroid ?? { x: width / 2, y: height / 2 }; + } + + function getRingRadius(id: string): number { + const cid = layout.nodeToCluster[id]; + if (!cid) return 0; + const cluster = layout.clusterById.get(cid); + if (!cluster) return 0; + const ring = cluster.orbits[id] ?? 0; + return (cluster.radii[ring] ?? 0) * (cluster.radiusScale ?? 1); + } + function rebuild( nextNodes: ArtifactSummary[], nextEdges: GraphEdge[], @@ -154,6 +285,13 @@ const cy = height / 2; simNodes = nextNodes.map((n) => { const p = prev.get(n.id); + // FR-005 + radial-zero guard: seed new nodes around their cluster + // centroid (not viewport centre) with a small random offset so + // forceCollide has a non-degenerate gradient. Two coincident + // nodes produce NaN unit vectors and let the sim drift. + const c = getCentroid(n.id); + const seedX = (c.x ?? cx) + (Math.random() - 0.5) * 20; + const seedY = (c.y ?? cy) + (Math.random() - 0.5) * 20; return { id: n.id, kind: n.kind, @@ -162,8 +300,8 @@ r_eff: scoreMap.get(n.id) ?? 0, w: nodeWidth(n.id), h: NODE_H, - x: p?.x ?? cx + (Math.random() - 0.5) * 80, - y: p?.y ?? cy + (Math.random() - 0.5) * 80, + x: p?.x ?? seedX, + y: p?.y ?? seedY, vx: p?.vx, vy: p?.vy, fx: p?.fx ?? null, @@ -213,34 +351,60 @@ } } + // Tuning per RFC-004 §"Tuning constants". clusterX/Y strength 0.25 group; + // orbital strength 0.15 sits between link (0.4) and centre pull; + // clusterRepel strength 800/minDistance 250 keeps centroids apart; + // collide iterations(2) for accuracy; charge -150 (weaker than current + // -380) so clusters stay cohesive. function startSim() { sim = forceSimulation(simNodes) .force( 'link', forceLink(simLinks) .id((n) => n.id) - .distance(140) - .strength(0.5) + .distance(80) + .strength(0.4) ) - .force('charge', forceManyBody().strength(-380)) + .force('charge', forceManyBody().strength(-150)) .force('center', forceCenter(width / 2, height / 2)) - // Gentle pull toward centre — keeps disconnected components from - // sailing off the canvas after a filter change. - .force('x', forceX(width / 2).strength(0.05)) - .force('y', forceY(height / 2).strength(0.05)) + .force('clusterX', forceX((d) => getCentroid(d.id).x).strength(0.25)) + .force('clusterY', forceY((d) => getCentroid(d.id).y).strength(0.25)) + .force( + 'orbital', + forceClusterOrbital({ + strength: 0.15, + getCenter: (d) => getCentroid(d.id), + getTargetRadius: (d) => getRingRadius(d.id) + }) + ) + .force( + 'clusterRepel', + forceClusterRepel({ + strength: 800, + minDistance: 250, + getClusterId: (d) => layout.nodeToCluster[d.id] + }) + ) .force( 'collide', - forceCollide().radius((n) => Math.max(n.w, n.h) * 0.6 + 12) + forceCollide() + .radius((n) => Math.max(n.w, n.h) * 0.6 + 12) + .iterations(2) ) .alphaDecay(0.025) .on('tick', bumpTick); - // Pre-settle synchronously so the first paint already shows nodes - // arranged near the canvas centre instead of the (0,0) phyllotaxis seed. - // sim.tick(N) does NOT fire 'tick' listeners (by design in d3-force), so - // pre-settle stays render-free; we bump once below for the initial paint. - sim.tick(60); - bumpTick(); + // Reduced-motion (RFC-004 §"Reduced-motion compat"): pre-tick 80 then + // stop, no animation. Otherwise pre-settle 60 ticks for a stable first + // paint before live ticks take over. + if (motionDuration(300) === 0) { + sim.tick(80); + sim.stop(); + bumpTick(); + } else { + sim.tick(60); + bumpTick(); + } } function handleResize() { @@ -250,8 +414,6 @@ height = rect.height; if (sim) { sim.force('center', forceCenter(width / 2, height / 2)); - sim.force('x', forceX(width / 2).strength(0.05)); - sim.force('y', forceY(height / 2).strength(0.05)); } } @@ -264,7 +426,7 @@ window.addEventListener('resize', handleResize); zoomBehavior = zoom() - .scaleExtent([0.2, 4]) + .scaleExtent([0.45, 4]) .on('zoom', (event) => { transform = { x: event.transform.x, @@ -281,12 +443,12 @@ }; }); - // FIXME(reactivity-loop): rebuild reads simNodes/simLinks for diffing AND - // writes them back, which would create an effect-feedback loop. We track - // only the upstream deps (filtered* + scoreById) and untrack rebuild's - // internal sim-state reads. d3-force mutations of node.x/y mutate the raw - // backing objects (simNodes is $state.raw); template re-render is driven - // by tickGen, bumped from the d3 'tick' listener in startSim. + // rebuild reads simNodes/simLinks for diffing AND writes them back; without + // untrack that would form an effect-feedback loop. We track only the + // upstream deps (filtered* + scoreById) and untrack rebuild's internal + // sim-state reads. d3-force mutations of node.x/y mutate the raw backing + // objects (simNodes is $state.raw); template re-render is driven by + // tickGen, bumped from the d3 'tick' listener in startSim. $effect(() => { const fn = filteredNodes; const fe = filteredEdges; @@ -294,9 +456,50 @@ untrack(() => rebuild(fn, fe, sb)); }); + // Re-bind ONLY clusterRepel + perturb the simulation when the layout + // signature changes (cluster set or membership). The other cluster-aware + // forces (clusterX/clusterY/orbital) use accessor closures that already + // read `layout` fresh on every tick via getCentroid/getRingRadius — + // re-binding them would just rerun d3's internal initialize() walk for + // no behavioural gain. clusterRepel, by contrast, caches per-node + // cluster ids inside the force at .initialize() time, so when + // nodeToCluster changes we must either swap the instance or call + // .initialize(simNodes) on the existing one. Re-binding triggers the + // initialize call automatically. + $effect(() => { + const sig = layout.signature; + if (!sim) return; + untrack(() => { + const repel = forceClusterRepel({ + strength: 800, + minDistance: 250, + getClusterId: (d) => layout.nodeToCluster[d.id] + }); + sim!.force('clusterRepel', repel); + // d3 calls force.initialize(simNodes) when sim.force() is reassigned, + // but only if the simulation has nodes set. Call it explicitly so the + // cached cluster-id array inside repel is rebuilt against the current + // simNodes — relying on d3's implicit path made the cache go stale + // when only nodeToCluster changed without simNodes turnover. + const installed = sim!.force('clusterRepel') as + | { initialize?: (nodes: Node[]) => void } + | undefined; + installed?.initialize?.(simNodes); + if (motionDuration(300) === 0) { + sim!.alpha(0.3); + sim!.tick(40); + sim!.stop(); + bumpTick(); + } else { + sim!.alpha(0.3).restart(); + } + }); + void sig; + }); + export function resetZoom() { if (!svgEl || !zoomBehavior) return; - select(svgEl).transition().duration(300).call(zoomBehavior.transform, zoomIdentity); + select(svgEl).transition().duration(motionDuration(300)).call(zoomBehavior.transform, zoomIdentity); } function endpoint(p: Node | string | undefined): Node | null { @@ -307,13 +510,44 @@ function onNodeClick(id: string) { onSelect?.({ id }); } + + function focusNodeById(id: string) { + const target = svgEl?.querySelector(`g.node[data-id="${id}"]`); + target?.focus(); + } + + function onNodeKeydown(e: KeyboardEvent, currentId: string) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onNodeClick(currentId); + return; + } + if ( + e.key !== 'ArrowLeft' && + e.key !== 'ArrowRight' && + e.key !== 'ArrowUp' && + e.key !== 'ArrowDown' + ) { + return; + } + e.preventDefault(); + const current = simNodes.find((n) => n.id === currentId); + if (!current) return; + const next = pickNextNode( + { id: current.id, x: current.x ?? 0, y: current.y ?? 0 }, + simNodes.map((n) => ({ id: n.id, x: n.x ?? 0, y: n.y ?? 0 })), + e.key as Direction, + ); + if (next) focusNodeById(next.id); + } @@ -323,7 +557,7 @@ - {#each simLinks as link (link)} + {#each simLinks as link (`${typeof link.source === 'string' ? link.source : (link.source as Node).id}>${typeof link.target === 'string' ? link.target : (link.target as Node).id}:${link.relation}`)} {@const a = endpoint(link.source as Node | string | undefined)} {@const b = endpoint(link.target as Node | string | undefined)} {#if a && b} @@ -332,7 +566,7 @@ {@const start = clipEndAt(bx, by, ax, ay, a.w / 2, a.h / 2)} {@const end = clipEndAt(ax, ay, bx, by, b.w / 2, b.h / 2)} { e.stopPropagation(); onNodeClick(node.id); }} - onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onNodeClick(node.id)} + onkeydown={(e) => onNodeKeydown(e, node.id)} + onmouseenter={() => setHovered(node.id)} + onmouseleave={clearHovered} + onfocus={() => setHovered(node.id)} + onblur={clearHovered} role="button" tabindex="0" aria-label={`${node.id}: ${node.title}`} @@ -368,6 +607,14 @@ > {node.id} + {#if node.id === selectedId} + + {/if} diff --git a/template/src/widgets/dependency-graph/ui/LanesView.svelte b/template/src/widgets/dependency-graph/ui/LanesView.svelte index fcd2010..b7eaa02 100644 --- a/template/src/widgets/dependency-graph/ui/LanesView.svelte +++ b/template/src/widgets/dependency-graph/ui/LanesView.svelte @@ -12,6 +12,8 @@ import { CHAR_W, NODE_H, NODE_PAD_X } from '@/widgets/dependency-graph/lib/sizing'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationClass } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; + import { highlight, setHovered, clearHovered, edgeClass, bfsDistances, nodeClass } from '../lib/highlight.svelte'; let { nodes = [], @@ -55,6 +57,8 @@ const filteredNodes = $derived(filterArtifacts(nodes, kindFilter, statusFilter)); const filteredIds = $derived(new Set(filteredNodes.map((n) => n.id))); const filteredEdges = $derived(filterEdges(edges, filteredIds)); + const focusId = $derived(highlight.hoveredId ?? selectedId); + const hoverDistances = $derived(bfsDistances(focusId, filteredEdges)); type Placed = { id: string; @@ -142,7 +146,7 @@ return { placed, lanes, width: totalW, height: totalH }; } - type EdgePath = { d: string; relation: string; key: string }; + type EdgePath = { d: string; relation: string; from: string; to: string; key: string }; const edgePaths = $derived(computePaths(filteredEdges, layout)); @@ -169,7 +173,7 @@ const dx = (x1 - x2) * 0.5; d = `M ${a.x - a.w / 2} ${y1} C ${a.x - a.w / 2 - dx} ${y1}, ${b.x + b.w / 2 + dx} ${y2}, ${b.x + b.w / 2} ${y2}`; } - out.push({ d, relation: e.relation, key: `${e.from}>${e.to}:${e.relation}` }); + out.push({ d, relation: e.relation, from: e.from, to: e.to, key: `${e.from}>${e.to}:${e.relation}` }); } return out; } @@ -188,7 +192,7 @@ const tx = (viewportW - layout.width * k) / 2; const ty = (viewportH - layout.height * k) / 2; const target = zoomIdentity.translate(tx, ty).scale(k); - const sel = animated ? select(svgEl).transition().duration(300) : select(svgEl); + const sel = animated ? select(svgEl).transition().duration(motionDuration(300)) : select(svgEl); sel.call(zoomBehavior.transform, target); } @@ -225,7 +229,7 @@ } - + @@ -259,16 +263,20 @@ {/each} {#each edgePaths as p (p.key)} - + {/each} {#each layout.placed as node (node.id)} { e.stopPropagation(); onNodeClick(node.id); }} onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onNodeClick(node.id)} + onmouseenter={() => setHovered(node.id)} + onmouseleave={clearHovered} + onfocus={() => setHovered(node.id)} + onblur={clearHovered} role="button" tabindex="0" aria-label={`${node.id}: ${node.title}`} @@ -277,6 +285,16 @@ {node.id} + {#if node.id === selectedId} + + {/if} {#if (scoreById.get(node.id) ?? 0) > 0} diff --git a/template/src/widgets/dependency-graph/ui/MatrixView.svelte b/template/src/widgets/dependency-graph/ui/MatrixView.svelte index f62a81f..f9f5567 100644 --- a/template/src/widgets/dependency-graph/ui/MatrixView.svelte +++ b/template/src/widgets/dependency-graph/ui/MatrixView.svelte @@ -9,6 +9,8 @@ import type { ScoreEntry } from '@/entities/score'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationFill } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; + import { highlight, setHovered, clearHovered, edgeClass, bfsDistances, nodeClass } from '../lib/highlight.svelte'; let { nodes = [], @@ -53,6 +55,8 @@ const orderedIds = $derived(new Set(ordered.map((n) => n.id))); const filteredEdges = $derived(filterEdges(edges, orderedIds)); + const focusId = $derived(highlight.hoveredId ?? selectedId); + const hoverDistances = $derived(bfsDistances(focusId, filteredEdges)); type Cell = { row: number; col: number; relation: string; from: string; to: string }; @@ -87,7 +91,7 @@ const tx = (viewportW - totalW * k) / 2; const ty = (viewportH - totalH * k) / 2; const target = zoomIdentity.translate(tx, ty).scale(k); - const sel = animated ? select(svgEl).transition().duration(300) : select(svgEl); + const sel = animated ? select(svgEl).transition().duration(motionDuration(300)) : select(svgEl); sel.call(zoomBehavior.transform, target); } @@ -126,7 +130,7 @@ const selectedRow = $derived(selectedId ? indexById.get(selectedId) ?? -1 : -1); - + FROM \ TO @@ -150,11 +154,15 @@ {#each ordered as n, i (n.id)} { e.stopPropagation(); selectId(n.id); }} onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectId(n.id)} + onmouseenter={() => setHovered(n.id)} + onmouseleave={clearHovered} + onfocus={() => setHovered(n.id)} + onblur={clearHovered} role="button" tabindex="0" aria-label={`row ${n.id}`} @@ -166,11 +174,15 @@ { e.stopPropagation(); selectId(n.id); }} onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectId(n.id)} + onmouseenter={() => setHovered(n.id)} + onmouseleave={clearHovered} + onfocus={() => setHovered(n.id)} + onblur={clearHovered} role="button" tabindex="0" aria-label={`col ${n.id}`} @@ -193,7 +205,7 @@ {#each cells as c (c.from + '>' + c.to + ':' + c.relation)} diff --git a/template/src/widgets/dependency-graph/ui/RadialView.svelte b/template/src/widgets/dependency-graph/ui/RadialView.svelte index a84ee5b..2351e93 100644 --- a/template/src/widgets/dependency-graph/ui/RadialView.svelte +++ b/template/src/widgets/dependency-graph/ui/RadialView.svelte @@ -11,6 +11,14 @@ import { CHAR_W, NODE_H, NODE_PAD_X } from '@/widgets/dependency-graph/lib/sizing'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationClass } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; + import { highlight, setHovered, clearHovered, edgeClass, bfsDistances, nodeClass } from '../lib/highlight.svelte'; + import { + detectClusters, + computeAnchoredAngles, + type ClusterInfo + } from '../lib/cluster.svelte'; + import { pickNextNode, type Direction } from '../lib/keyboard-nav'; let { nodes = [], @@ -30,8 +38,6 @@ onSelect?: (detail: { id: string }) => void; } = $props(); - const RING_GAP = 110; - const INNER_R = 70; const MARGIN = 60; function nodeWidth(id: string): number { @@ -43,13 +49,61 @@ let viewportH = $state(600); let zoomBehavior = $state | null>(null); let transform = $state({ x: 0, y: 0, k: 1 }); - let didFit = false; + let didFit = $state(false); + // Per-cluster collapse: when a cluster id is in this set, the layout + // hides ring ≥ 2 (keeps only root + ring 1 visible). Toggled by a "−/+" + // button rendered at the cluster centroid for clusters with ≥ 3 rings. + let collapsedClusters = $state(new Set()); - const scoreById = $derived(new Map(scores.map((s) => [s.id, s.r_eff]))); + // Score memoization: the 10s scores poll returns a fresh array even when + // r_eff values are identical, which would rebuild the Map and invalidate + // every $effect reading scoreById. Gate on a content signature. + const scoresSig = $derived(scores.map((s) => `${s.id}:${s.r_eff}`).join('|')); + let lastScoresSig = ''; + let cachedScoreById = new Map(); + const scoreById = $derived.by(() => { + if (scoresSig === lastScoresSig) return cachedScoreById; + lastScoresSig = scoresSig; + cachedScoreById = new Map(scores.map((s) => [s.id, s.r_eff])); + return cachedScoreById; + }); - const filteredNodes = $derived(filterArtifacts(nodes, kindFilter, statusFilter)); + // Filter memoization: the 10s nodes/edges poll always returns fresh array + // references even when the underlying payload is identical. A naive + // `$derived(filterArtifacts(...))` would invalidate the entire layout + // pipeline on every poll. Reduce the inputs to a content signature first; + // recompute only when that signature actually changes. + const nodesSig = $derived( + nodes.map((n) => `${n.id}:${n.kind}:${n.status}`).join('|') + + '||' + + Array.from(kindFilter).sort().join(',') + + '||' + + Array.from(statusFilter).sort().join(',') + ); + let lastNodesSig = ''; + let cachedFilteredNodes: ArtifactSummary[] = []; + const filteredNodes = $derived.by(() => { + if (nodesSig === lastNodesSig) return cachedFilteredNodes; + lastNodesSig = nodesSig; + cachedFilteredNodes = filterArtifacts(nodes, kindFilter, statusFilter); + return cachedFilteredNodes; + }); const filteredIds = $derived(new Set(filteredNodes.map((n) => n.id))); - const filteredEdges = $derived(filterEdges(edges, filteredIds)); + const edgesSig = $derived( + edges.map((e) => `${e.from}>${e.to}:${e.relation}`).join('|') + + '||' + + Array.from(filteredIds).sort().join(',') + ); + let lastEdgesSig = ''; + let cachedFilteredEdges: GraphEdge[] = []; + const filteredEdges = $derived.by(() => { + if (edgesSig === lastEdgesSig) return cachedFilteredEdges; + lastEdgesSig = edgesSig; + cachedFilteredEdges = filterEdges(edges, filteredIds); + return cachedFilteredEdges; + }); + const focusId = $derived(highlight.hoveredId ?? selectedId); + const hoverDistances = $derived(bfsDistances(focusId, filteredEdges)); type Placed = { id: string; @@ -62,84 +116,157 @@ y: number; }; - type Layout = { placed: Placed[]; rings: number[]; cx: number; cy: number; radius: number }; + type ClusterLayout = { + cluster: ClusterInfo; + rings: number[]; + radii: number[]; + /** Total ring count BEFORE collapse — used to decide whether to + * render the toggle button (only ≥ 3 rings → user can collapse). */ + totalRingCount: number; + collapsed: boolean; + }; + + type Layout = { + placed: Placed[]; + clusters: ClusterLayout[]; + bbox: { minX: number; minY: number; maxX: number; maxY: number }; + }; const layout = $derived(computeLayout(filteredNodes, filteredEdges)); function computeLayout(ns: ArtifactSummary[], es: GraphEdge[]): Layout { - if (ns.length === 0) return { placed: [], rings: [], cx: 0, cy: 0, radius: 0 }; + if (ns.length === 0) { + return { + placed: [], + clusters: [], + bbox: { minX: 0, minY: 0, maxX: 0, maxY: 0 } + }; + } - const incoming = new Map(); - for (const n of ns) incoming.set(n.id, []); - for (const e of es) { - if (!incoming.has(e.to)) continue; - if (e.from === e.to) continue; - incoming.get(e.to)!.push(e.from); + const viewport = { + width: Math.max(1, viewportW), + height: Math.max(1, viewportH) + }; + const { clusters, nodeToCluster, nodeAdjacency } = detectClusters(ns, es, viewport); + + if (clusters.length === 0) { + return { + placed: [], + clusters: [], + bbox: { minX: 0, minY: 0, maxX: 0, maxY: 0 } + }; } - const layer = new Map(); - function dfs(id: string, stack: Set): number { - const cached = layer.get(id); - if (cached !== undefined) return cached; - if (stack.has(id)) return 0; - stack.add(id); - let max = 0; - for (const p of incoming.get(id) ?? []) { - max = Math.max(max, dfs(p, stack) + 1); + const meta = new Map(ns.map((n) => [n.id, n] as const)); + + const membersByCluster = new Map(); + for (const c of clusters) membersByCluster.set(c.id, []); + for (const n of ns) { + const cid = nodeToCluster[n.id]; + if (cid && membersByCluster.has(cid)) { + membersByCluster.get(cid)!.push(n); } - stack.delete(id); - layer.set(id, max); - return max; } - for (const n of ns) dfs(n.id, new Set()); - const byLayer = new Map(); - for (const [id, l] of layer) { - if (!byLayer.has(l)) byLayer.set(l, []); - byLayer.get(l)!.push(id); - } - const meta = new Map(ns.map((n) => [n.id, n] as const)); - for (const arr of byLayer.values()) { - arr.sort((a, b) => { - const ma = meta.get(a)!; - const mb = meta.get(b)!; - if (ma.kind !== mb.kind) return ma.kind.localeCompare(mb.kind); - return a.localeCompare(b); + const placed: Placed[] = []; + const clusterLayouts: ClusterLayout[] = []; + + for (const cluster of clusters) { + const members = membersByCluster.get(cluster.id) ?? []; + if (members.length === 0) continue; + + const isFallback = cluster.id === '__single__'; + const orbits = cluster.orbits; + const scale = cluster.radiusScale ?? 1; + + const isCollapsed = collapsedClusters.has(cluster.id); + const byRing = new Map(); + for (const m of members) { + const r = orbits[m.id] ?? 0; + if (isCollapsed && r >= 2) continue; + if (!byRing.has(r)) byRing.set(r, []); + byRing.get(r)!.push(m.id); + } + + const ringIndices = [...byRing.keys()].sort((a, b) => a - b); + const radii: number[] = ringIndices.map((ri) => (cluster.radii[ri] ?? 0) * scale); + + const cx = cluster.centroid.x; + const cy = cluster.centroid.y; + + const angleMap = isFallback + ? new Map() + : computeAnchoredAngles(cluster.id, members, orbits, nodeAdjacency); + + ringIndices.forEach((ri, ringIdx) => { + const ids = byRing.get(ri)!; + const N = ids.length; + const r = radii[ringIdx]!; + ids.forEach((id, i) => { + const m = meta.get(id)!; + const w = nodeWidth(id); + let x: number; + let y: number; + if (ri === 0 && (N === 1 || id === cluster.id)) { + x = cx; + y = cy; + } else { + const baseAngle = angleMap.has(id) + ? angleMap.get(id)! + : (i / Math.max(1, N)) * Math.PI * 2; + const angle = -Math.PI / 2 + baseAngle; + x = cx + Math.cos(angle) * r; + y = cy + Math.sin(angle) * r; + } + placed.push({ id, kind: m.kind, status: m.status, title: m.title, w, h: NODE_H, x, y }); + }); + }); + + // Total ring count from cluster.orbits (independent of collapse). + const totalRingCount = new Set(Object.values(cluster.orbits)).size; + clusterLayouts.push({ + cluster, + rings: ringIndices, + radii, + totalRingCount, + collapsed: isCollapsed, }); } - const layerOrder = [...byLayer.keys()].sort((a, b) => a - b); - const rings = layerOrder.map((_, i) => INNER_R + i * RING_GAP); - const radius = (rings[rings.length - 1] ?? INNER_R) + 60; - const cx = radius + MARGIN; - const cy = radius + MARGIN; + // No anti-collision sweep: computeRingRadius enforces both same-ring + // chord (2r·sin(π/N) ≥ MIN_CHORD) and adjacent-ring radial gap + // (≥ RING_GAP), so every card centre stays exactly on its orbit + // and bboxes cannot overlap. Sweep would only push nodes off the + // ring without geometric justification. Cluster centroids are also + // spaced so neighbouring clusters' outer rings don't collide. - const placed: Placed[] = []; - layerOrder.forEach((l, li) => { - const ids = byLayer.get(l)!; - const r = rings[li]; - const count = ids.length; - ids.forEach((id, i) => { - const m = meta.get(id)!; - const w = nodeWidth(id); - let x: number; - let y: number; - if (li === 0 && count === 1) { - x = cx; - y = cy; - } else { - const angle = (-Math.PI / 2) + (i / count) * Math.PI * 2; - x = cx + Math.cos(angle) * r; - y = cy + Math.sin(angle) * r; - } - placed.push({ id, kind: m.kind, status: m.status, title: m.title, w, h: NODE_H, x, y }); - }); - }); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of placed) { + const halfW = p.w / 2; + const halfH = p.h / 2; + if (p.x - halfW < minX) minX = p.x - halfW; + if (p.y - halfH < minY) minY = p.y - halfH; + if (p.x + halfW > maxX) maxX = p.x + halfW; + if (p.y + halfH > maxY) maxY = p.y + halfH; + } + if (!Number.isFinite(minX)) { + minX = 0; + minY = 0; + maxX = 0; + maxY = 0; + } - return { placed, rings, cx, cy, radius }; + return { + placed, + clusters: clusterLayouts, + bbox: { minX, minY, maxX, maxY } + }; } - type EdgePath = { d: string; relation: string; key: string }; + type EdgePath = { d: string; relation: string; from: string; to: string; key: string }; const edgePaths = $derived(computePaths(filteredEdges, layout)); @@ -163,6 +290,8 @@ out.push({ d: `M ${a.x} ${a.y} Q ${cxp} ${cyp}, ${b.x} ${b.y}`, relation: e.relation, + from: e.from, + to: e.to, key: `${e.from}>${e.to}:${e.relation}` }); } @@ -178,19 +307,33 @@ function fitToView(animated = true) { if (!svgEl || !zoomBehavior) return; - const w = layout.radius * 2 + MARGIN * 2; - const h = layout.radius * 2 + MARGIN * 2; - if (w === 0 || h === 0) return; - const k = Math.max(0.2, Math.min(1, (viewportW - 40) / w, (viewportH - 40) / h)); - const tx = (viewportW - w * k) / 2; - const ty = (viewportH - h * k) / 2; + const w = layout.bbox.maxX - layout.bbox.minX + MARGIN * 2; + const h = layout.bbox.maxY - layout.bbox.minY + MARGIN * 2; + if (w <= 0 || h <= 0) return; + const k = Math.max(0.45, Math.min(1, (viewportW - 40) / w, (viewportH - 40) / h)); + const tx = (viewportW - w * k) / 2 - (layout.bbox.minX - MARGIN) * k; + const ty = (viewportH - h * k) / 2 - (layout.bbox.minY - MARGIN) * k; const target = zoomIdentity.translate(tx, ty).scale(k); - const sel = animated ? select(svgEl).transition().duration(300) : select(svgEl); + const sel = animated ? select(svgEl).transition().duration(motionDuration(300)) : select(svgEl); sel.call(zoomBehavior.transform, target); } + // Reset didFit when the layout shape changes substantially (node count or + // bbox dimensions). Without this, a major filter change (e.g. clearing all + // chips) repaints with the previous fit and the user sees an off-screen or + // tiny graph. Signature is coarse-grained — only structural transitions + // trigger a re-fit, not every micro tick. + let lastLayoutShape = ''; $effect(() => { - if (svgEl && zoomBehavior && layout.radius > 0 && !didFit) { + const shape = `${layout.placed.length}:${(layout.bbox.maxX - layout.bbox.minX) | 0}x${(layout.bbox.maxY - layout.bbox.minY) | 0}`; + if (shape !== lastLayoutShape) { + lastLayoutShape = shape; + didFit = false; + } + }); + + $effect(() => { + if (svgEl && zoomBehavior && layout.placed.length > 0 && !didFit) { didFit = true; queueMicrotask(() => fitToView(false)); } @@ -201,7 +344,7 @@ handleResize(); window.addEventListener('resize', handleResize); const zb = zoom() - .scaleExtent([0.2, 4]) + .scaleExtent([0.45, 4]) .on('zoom', (event) => { transform = { x: event.transform.x, y: event.transform.y, k: event.transform.k }; }); @@ -220,9 +363,47 @@ function onNodeClick(id: string) { onSelect?.({ id }); } + + function focusNodeById(id: string) { + const target = svgEl?.querySelector(`g.node[data-id="${id}"]`); + target?.focus(); + } + + function onNodeKeydown(e: KeyboardEvent, currentId: string) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onNodeClick(currentId); + return; + } + if ( + e.key !== 'ArrowLeft' && + e.key !== 'ArrowRight' && + e.key !== 'ArrowUp' && + e.key !== 'ArrowDown' + ) { + return; + } + e.preventDefault(); + const current = layout.placed.find((p) => p.id === currentId); + if (!current) return; + const next = pickNextNode( + { id: current.id, x: current.x, y: current.y }, + layout.placed.map((p) => ({ id: p.id, x: p.x, y: p.y })), + e.key as Direction, + ); + if (next) focusNodeById(next.id); + } + + function toggleClusterCollapse(clusterId: string) { + const next = new Set(collapsedClusters); + if (next.has(clusterId)) next.delete(clusterId); + else next.add(clusterId); + collapsedClusters = next; + didFit = false; + } - + @@ -230,19 +411,42 @@ - {#each layout.rings as r (r)} - + {#each layout.clusters as cl (cl.cluster.id)} + {#each cl.radii as r, idx (`${cl.cluster.id}:${cl.rings[idx]}`)} + {#if r > 0} + + {/if} + {/each} + {#if cl.totalRingCount >= 3} + { e.stopPropagation(); toggleClusterCollapse(cl.cluster.id); }} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleClusterCollapse(cl.cluster.id); } }} + > + + {cl.collapsed ? '+' : '−'} + + {/if} {/each} {#each edgePaths as p (p.key)} - + {/each} {#each layout.placed as node (node.id)} { e.stopPropagation(); onNodeClick(node.id); }} - onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onNodeClick(node.id)} + onkeydown={(e) => onNodeKeydown(e, node.id)} + onmouseenter={() => setHovered(node.id)} + onmouseleave={clearHovered} + onfocus={() => setHovered(node.id)} + onblur={clearHovered} role="button" tabindex="0" aria-label={`${node.id}: ${node.title}`} @@ -251,6 +455,16 @@ {node.id} + {#if node.id === selectedId} + + {/if} {#if (scoreById.get(node.id) ?? 0) > 0} diff --git a/template/src/widgets/dependency-graph/ui/TreeView.svelte b/template/src/widgets/dependency-graph/ui/TreeView.svelte index d7cb57b..e726927 100644 --- a/template/src/widgets/dependency-graph/ui/TreeView.svelte +++ b/template/src/widgets/dependency-graph/ui/TreeView.svelte @@ -11,6 +11,8 @@ import { CHAR_W, NODE_H, NODE_PAD_X } from '@/widgets/dependency-graph/lib/sizing'; import { filterArtifacts, filterEdges } from '../lib/filter'; import { relationClass } from '../lib/relation'; + import { motionDuration } from '../lib/reduced-motion'; + import { highlight, setHovered, clearHovered, edgeClass, bfsDistances, nodeClass } from '../lib/highlight.svelte'; let { nodes = [], @@ -50,6 +52,8 @@ const filteredNodes = $derived(filterArtifacts(nodes, kindFilter, statusFilter)); const filteredIds = $derived(new Set(filteredNodes.map((n) => n.id))); const filteredEdges = $derived(filterEdges(edges, filteredIds)); + const focusId = $derived(highlight.hoveredId ?? selectedId); + const hoverDistances = $derived(bfsDistances(focusId, filteredEdges)); type Placed = { id: string; @@ -171,7 +175,7 @@ }; } - type EdgePath = { d: string; relation: string; key: string }; + type EdgePath = { d: string; relation: string; from: string; to: string; key: string }; function computeEdgePaths(es: GraphEdge[], lay: Layout): EdgePath[] { const byId = new Map(lay.placed.map((p) => [p.id, p])); @@ -188,7 +192,7 @@ const c1y = y1 + dy * 0.5; const c2y = y2 - dy * 0.5; const d = `M ${x1} ${y1} C ${x1} ${c1y}, ${x2} ${c2y}, ${x2} ${y2}`; - out.push({ d, relation: e.relation, key: `${e.from}>${e.to}:${e.relation}` }); + out.push({ d, relation: e.relation, from: e.from, to: e.to, key: `${e.from}>${e.to}:${e.relation}` }); } return out; } @@ -212,7 +216,7 @@ const tx = (viewportW - layout.width * k) / 2; const ty = (viewportH - layout.height * k) / 2; const target = zoomIdentity.translate(tx, ty).scale(k); - const sel = animated ? select(svgEl).transition().duration(300) : select(svgEl); + const sel = animated ? select(svgEl).transition().duration(motionDuration(300)) : select(svgEl); sel.call(zoomBehavior.transform, target); } @@ -258,8 +262,9 @@ @@ -304,7 +309,7 @@ {#each layoutPaths as p (p.key)} { e.stopPropagation(); onNodeClick(node.id); }} onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onNodeClick(node.id)} + onmouseenter={() => setHovered(node.id)} + onmouseleave={clearHovered} + onfocus={() => setHovered(node.id)} + onblur={clearHovered} role="button" tabindex="0" aria-label={`${node.id}: ${node.title}`} @@ -334,6 +343,16 @@ > {node.id} + {#if node.id === selectedId} + + {/if} diff --git a/template/src/widgets/insights-rail/ui/InsightsRail.svelte b/template/src/widgets/insights-rail/ui/InsightsRail.svelte index 56c2aa1..728b5a3 100644 --- a/template/src/widgets/insights-rail/ui/InsightsRail.svelte +++ b/template/src/widgets/insights-rail/ui/InsightsRail.svelte @@ -4,9 +4,10 @@ stalePoller, kindLabel, kindColor, - kindLabelColor, - statusRing + statusRing, + NodeRef } from '@/entities/artifact'; + import { nodeHover } from '@/entities/graph'; import { healthPoller } from '@/entities/health'; import { scorePoller, reffTone } from '@/entities/score'; import { claimsPoller } from '@/entities/claim'; @@ -78,13 +79,6 @@ function selectId(id: string) { onSelect?.({ id }); } - - function rowKey(ev: KeyboardEvent, id: string) { - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - selectId(id); - } - }
        {#each logPoller.state.data.entries.slice(0, 30) as e} - - -
      • selectId(e.artifact_id)} - onkeydown={(ev) => rowKey(ev, e.artifact_id)} - > - {relTime(e.timestamp)} - {e.artifact_id} - {e.action}{e.field ? ` · ${e.field}` : ''} - {#if e.new_value} - {e.new_value} - {/if} +
      • +
      • {/each}
      @@ -139,37 +132,36 @@ {#if claimsPoller.state.data?.claims?.length}
        {#each claimsPoller.state.data.claims as c} - - -
      • selectId(c.id)} - onkeydown={(ev) => rowKey(ev, c.id)} - > -
        - - - {c.agent_id} - - - ttl {relTimeFuture(c.expires_at)} - -
        -
        - {c.id} - {#if kindById.has(c.id)} - {kindLabel(kindById.get(c.id) ?? '')} +
      • +
    - {#if titleById.has(c.id)} -

    {titleById.get(c.id)}

    - {/if} - {#if c.note} -

    "{c.note}"

    - {/if} - claimed {relTime(c.claimed_at)} + claimed {relTime(c.claimed_at)} + {/each} @@ -200,16 +192,14 @@
      {#each b.blocked as item}
    • - + {#if item.reason}— {item.reason}{/if} {#if item.blocked_by?.length} waits on {#each item.blocked_by as dep, i} {#if i > 0}, {/if} - + {/each} {/if} @@ -226,7 +216,7 @@ {#each cycle as id, j} {#if j > 0} → {/if} - + {/each}
    • @@ -238,7 +228,7 @@
        {#each b.ready as id}
      • - + {#if titleById.has(id)} {titleById.get(id)} {/if} @@ -262,9 +252,7 @@
      • {kindLabel(a.kind)} - + {a.title}
      • {/each} @@ -310,10 +298,11 @@ {#if h.blind_spots?.length}

        Blind spots ({h.blind_spots.length})

          - {#each h.blind_spots as id} + {#each h.blind_spots as b} + {@const title = b.title ?? titleById.get(b.id)}
        • - - {#if titleById.has(id)}{titleById.get(id)}{/if} + + {#if title}{title}{/if}
        • {/each}
        @@ -323,7 +312,7 @@

        Orphans ({h.orphans.length})

          {#each h.orphans as id} -
        • +
        • {/each}
        {/if} @@ -332,7 +321,7 @@

        Stale ({stalePoller.state.data.stale.length})

          {#each stalePoller.state.data.stale as s} -
        • +
        • {/each}
        {/if} @@ -352,7 +341,7 @@ {#each lowestReff as [id, reff]} {@const tone = reffTone(reff)}
      • - + @@ -457,14 +446,27 @@ font-size: 11px; } .row.clickable { + margin: 0 -8px; + } + .row-trigger { + background: transparent; + border: 0; + padding: 0; + font: inherit; + color: inherit; + text-align: left; + width: 100%; cursor: pointer; + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: baseline; padding: 4px 8px; - margin: 0 -8px; border-left: 1px solid transparent; transition: background 120ms, border-color 120ms; } - .row.clickable:hover, - .row.clickable:focus-visible { + .row-trigger:hover, + .row-trigger:focus-visible { background: var(--bg-1); border-left-color: var(--accent); outline: none; @@ -474,14 +476,26 @@ align-items: stretch; background: var(--bg-1); border: 1px solid var(--line-2); - padding: 10px 12px 12px; gap: 6px; margin: 0; } - .row.card:hover, - .row.card:focus-visible { + .row.card .row-trigger { + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: 10px 12px 12px; + border-left: 0; + transition: border-color 120ms, background 120ms; + } + .row.card:has(.row-trigger:hover), + .row.card:has(.row-trigger:focus-visible) { border-color: var(--accent); background: var(--bg-1); + } + .row.card .row-trigger:hover, + .row.card .row-trigger:focus-visible { + background: transparent; + border-left: 0; outline: none; } .row.card header { @@ -530,7 +544,6 @@ color: var(--fg-4); min-width: 60px; } - .id, .strong { color: var(--fg); font-weight: 600; @@ -571,20 +584,6 @@ font-family: var(--font-sans); font-size: 11px; } - .link { - background: transparent; - border: 0; - padding: 0; - color: var(--accent); - font: inherit; - cursor: pointer; - } - .link:hover { - text-decoration: underline; - } - .link.warn { - color: var(--accent); - } .err { color: var(--bad); } diff --git a/template/tsconfig.json b/template/tsconfig.json index 4344710..a8fed3d 100644 --- a/template/tsconfig.json +++ b/template/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, + "noUncheckedIndexedAccess": true, "moduleResolution": "bundler" } } diff --git a/template/vite.config.ts b/template/vite.config.ts index 2e741c6..4767bd1 100644 --- a/template/vite.config.ts +++ b/template/vite.config.ts @@ -1,16 +1,31 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -export default defineConfig({ - plugins: [sveltekit()], - build: { - sourcemap: false - }, - server: { - port: 5174, - strictPort: false, - fs: { - strict: false - } +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig(({ command, mode }) => { + // TODO(dev-only): `npm run dev:playground` (vite --mode playground) + // points at the repo-local playground/ workspace. Plain `npm run dev` + // leaves FORGEPLAN_CWD untouched — the server then falls back to the + // repo root's .forgeplan/ via runForgeplan's default resolution. + // `dist/` (shipped via init) never executes this file (rule 21). + if (command === 'serve' && mode === 'playground' && !process.env.FORGEPLAN_CWD) { + process.env.FORGEPLAN_CWD = resolve(__dirname, '..', 'playground'); } + + return { + plugins: [sveltekit()], + build: { + sourcemap: false + }, + server: { + port: 5174, + strictPort: false, + fs: { + strict: false + } + } + }; }); diff --git a/template/vitest.config.ts b/template/vitest.config.ts new file mode 100644 index 0000000..fe5c7ba --- /dev/null +++ b/template/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +const r = (p: string) => fileURLToPath(new URL(p, import.meta.url)); + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + globals: false, + }, + resolve: { + alias: { + "@/app": r("./src/app"), + "@/pages": r("./src/pages"), + "@/widgets": r("./src/widgets"), + "@/features": r("./src/features"), + "@/entities": r("./src/entities"), + "@/shared": r("./src/shared"), + }, + }, +});