From 3d364ba70fa64f3bb4eae3dde8ea612f1c8e495c Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 23:04:23 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(graph):=20F6=20=E2=80=94=20collapse/dr?= =?UTF-8?q?ill-down=20for=20large=20RadialView=20clusters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small "−/+" toggle near each cluster's centroid (rendered only for clusters with ≥ 3 rings). Click or Enter/Space collapses the cluster to root + ring 1 (hides ring ≥ 2 members and their orbits); click again to expand. State per-view in $state(Set); fitToView re-fires on toggle so the new bbox fits. Improves the UX on workspaces with one dense centre cluster — labels stay legible because the outermost ring (largest radius) collapses out, and the user can still see siblings without panning. Refs: PRD-005 RFC-004 --- .../dependency-graph/ui/RadialView.svelte | 95 ++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/template/src/widgets/dependency-graph/ui/RadialView.svelte b/template/src/widgets/dependency-graph/ui/RadialView.svelte index 8c7169a..58cb0f8 100644 --- a/template/src/widgets/dependency-graph/ui/RadialView.svelte +++ b/template/src/widgets/dependency-graph/ui/RadialView.svelte @@ -18,6 +18,7 @@ computeAnchoredAngles, type ClusterInfo } from '../lib/cluster.svelte'; + import { pickNextNode, type Direction } from '../lib/keyboard-nav'; let { nodes = [], @@ -49,6 +50,10 @@ let zoomBehavior = $state | null>(null); let transform = $state({ x: 0, y: 0, k: 1 }); 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()); // Score memoization: the 10s scores poll returns a fresh array even when // r_eff values are identical, which would rebuild the Map and invalidate @@ -113,6 +118,10 @@ 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 = { @@ -168,9 +177,11 @@ 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); } @@ -209,7 +220,15 @@ }); }); - clusterLayouts.push({ cluster, rings: ringIndices, radii }); + // 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, + }); } // No anti-collision sweep: computeRingRadius enforces both same-ring @@ -342,6 +361,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 = 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; + } @@ -358,6 +415,20 @@ {/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)} @@ -366,9 +437,10 @@ { 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)} @@ -422,6 +494,25 @@ stroke-width: 1; stroke-dasharray: 3 5; } + .cluster-toggle { cursor: pointer; } + .cluster-toggle circle { + fill: var(--bg-1); + stroke: rgba(255, 255, 255, 0.45); + stroke-width: 1; + transition: stroke-width 120ms, stroke 120ms; + } + .cluster-toggle:hover circle, + .cluster-toggle:focus-visible circle { + stroke: var(--accent); + stroke-width: 1.5; + outline: none; + } + .cluster-toggle .toggle-glyph { + font-family: var(--font-mono); + font-size: 14px; + fill: rgba(255, 255, 255, 0.85); + pointer-events: none; + } .edge { stroke: rgba(255, 255, 255, 0.45); stroke-width: 1; From 1efd7e12c51e9be09f9181a550fc73b2316d173b Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 23:04:23 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(graph):=20F6=20=E2=80=94=20ArrowKey=20?= =?UTF-8?q?navigation=20between=20nodes=20(RadialView=20+=20ForceView)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New shared lib `keyboard-nav.ts` exporting `pickNextNode(current, candidates, direction)`. Cone-based selection: candidates inside a ±60° cone around the cardinal axis, scored by `distance · (1 + 2·angularDeviation/π)`. Falls back to nearest neighbour when no candidate falls inside the cone, so the user always moves. Wired into both RadialView and ForceView via `onNodeKeydown` on each ``. Each card gets `data-id` attribute so focus can be routed by id. Enter/Space still triggers selection (unchanged). 6 vitest unit tests in `keyboard-nav.test.ts` cover: cardinal-axis preference; cone fallback to nearest; same-position dedup; close-off vs far-on-axis tradeoff; null on single-node graph. Closes the audit's MEDIUM "no keyboard navigation between nodes" finding. Refs: PRD-005 RFC-004 --- .../dependency-graph/lib/keyboard-nav.test.ts | 52 ++++++++++++++ .../dependency-graph/lib/keyboard-nav.ts | 67 +++++++++++++++++++ .../dependency-graph/ui/ForceView.svelte | 34 +++++++++- 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 template/src/widgets/dependency-graph/lib/keyboard-nav.test.ts create mode 100644 template/src/widgets/dependency-graph/lib/keyboard-nav.ts 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/ui/ForceView.svelte b/template/src/widgets/dependency-graph/ui/ForceView.svelte index 3bd69a6..2d3b6bb 100644 --- a/template/src/widgets/dependency-graph/ui/ForceView.svelte +++ b/template/src/widgets/dependency-graph/ui/ForceView.svelte @@ -32,6 +32,7 @@ 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; @@ -507,6 +508,36 @@ 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); + } { 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)} From 955d69a0a5c83737573c3f65d24eb0ba78194fb5 Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 23:04:37 +0300 Subject: [PATCH 3/3] docs(forgeplan): EVID-013 closes F5 audit-cleanup acceptance DOM-verified acceptance pack for the post-merge audit cleanup: - All CRITICAL/HIGH/MEDIUM/LOW findings closed in PR #28 - 18 vitest unit tests passing (16 cluster + 2 regression) - Live workspace: 0 bbox overlaps, 23/23 cards exact-on-orbit (was 21/22 in EVID-012 baseline) - CVE-2024-47764 (cookie<0.7.0) closed via overrides Structured Fields: verdict supports / CL3 / measurement. Linked: informs PRD-005, informs RFC-004, informs EVID-012. Score after activation: - EVID-013: R_eff 0.80 (Adequate, F-G-R B) - PRD-005: R_eff 1.00 / Quality 0.82 (A) with 2 evidence Refs: PRD-005 RFC-004 --- ...-regression-fixed-cve-2024-47764-closed.md | 134 ++++++++++++++++++ .forgeplan/session.yaml | 2 +- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 .forgeplan/evidence/EVID-013-prd-005-f5-audit-cleanup-acceptance-pr-28-merged-18-tests-passing-radii-cache-regression-fixed-cve-2024-47764-closed.md diff --git a/.forgeplan/evidence/EVID-013-prd-005-f5-audit-cleanup-acceptance-pr-28-merged-18-tests-passing-radii-cache-regression-fixed-cve-2024-47764-closed.md b/.forgeplan/evidence/EVID-013-prd-005-f5-audit-cleanup-acceptance-pr-28-merged-18-tests-passing-radii-cache-regression-fixed-cve-2024-47764-closed.md new file mode 100644 index 0000000..18d5aa7 --- /dev/null +++ b/.forgeplan/evidence/EVID-013-prd-005-f5-audit-cleanup-acceptance-pr-28-merged-18-tests-passing-radii-cache-regression-fixed-cve-2024-47764-closed.md @@ -0,0 +1,134 @@ +--- +created: 2026-05-05 +depth: tactical +id: EVID-013 +kind: evidence +links: + - target: PRD-005 + relation: informs + - target: RFC-004 + relation: informs + - target: EVID-012 + relation: informs +status: active +title: "PRD-005 F5 audit-cleanup acceptance: PR #28 merged, 18 tests passing, radii cache regression fixed, CVE-2024-47764 closed" +updated: 2026-05-05 +--- + +# EVID-013: PRD-005 F5 audit-cleanup — PR #28 acceptance + +| Field | Value | +| ----------- | -------------------------------------------------------- | +| Status | Active | +| Created | 2026-05-05 | +| Valid Until | 2026-08-05 (3 months — re-verify if cluster lib changes) | +| Target | PRD-005, RFC-004 | + +## Structured Fields + +verdict: supports +congruence_level: 3 +evidence_type: measurement + +## Measurement + +PR #28 (`feature/audit-cleanup-f5 -> develop`, merge commit `6dac7b4`) +closed all post-merge audit findings on PR #26 (4 expert agents: +TypeScript / Frontend / Security / Performance) plus a regression +caught during live verification. + +Three layers of acceptance: + +- **Audit closure** — every CRITICAL/HIGH/MEDIUM/LOW finding mapped + to a code change with a `Refs: PRD-005 RFC-004` commit footer. +- **Unit tests** — 18 vitest tests in `template/src/widgets/dependency-graph/lib/`: + 16 in `cluster.test.ts` (chord rule, radial gap, type-rank, + anchored angles, multi-cluster placement); 2 in `regression.test.ts` + that fail against the pre-fix code. +- **Live DOM verification** — Playwright on `http://127.0.0.1:5177` + confirms `0 bbox overlaps` and `23/23 cards exact-on-orbit` for the + current 23-artifact / 5-cluster workspace. + +### Layer A — audit findings closed + +| Severity | Finding | Closed by | +| ---------- | ------------------------------------------------------------------------- | ---------------------------------- | +| CRITICAL-1 | ForceView each-block link key by object identity (recreates all ``) | `8fd3f53` | +| CRITICAL-2 | `detectClusters` double-work + filter memo + radii cache dependency order | `8fd3f53` + `f062b38` (regression) | +| HIGH-1 | `force-cluster-repel` unsafe cast → typed `Object.assign` | `8fd3f53` | +| HIGH-2 | `forceClusterRepel` re-init only; drop redundant force re-bind | `8fd3f53` | +| HIGH-3 | `scoreById` memo + `didFit` reactive | `8fd3f53` | +| HIGH-4 | Zoom legibility floor 0.45 + repel short-circuit | `8fd3f53` | +| HIGH-5 | `noUncheckedIndexedAccess: true` + fallout | `8fd3f53` | +| MEDIUM-1 | a11y: `role="img"` + `role="button"` setup verified | `8fd3f53` (verify-only) | +| MEDIUM-2 | Drop unused `_adjacency` param; rename `_tick` → `_invalidationTick` | `8fd3f53` | +| MEDIUM-3 | Demote misleading `FIXME(reactivity-loop)` to plain comment | `8fd3f53` | +| LOW-1 | Bump transitive `cookie ≥ 0.7.0` (CVE-2024-47764) | `06574d3` | + +### Layer B — vitest unit tests (18 / 18) + +| Suite | Test count | What it asserts | +| -------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | +| `cluster.test.ts` | 16 | chord rule N=2..12; ring-gap rule; N=1 floor; ring-0 pin; monotonicity; type-rank; anchored angles; K=0/1/2/≥3 placement | +| `regression.test.ts` | 2 | radii dependency order — fails against pre-fix code where `Map.keys()` returned non-monotonic ring order | + +### Layer C — live DOM verification + +| Assertion | Measured | Verdict | +| ------------------------------------------------------------------------------------ | ---------------------------------- | ------- | +| Number of bbox overlaps across all rendered cards | **0** | ✅ pass | +| Cards whose centre is EXACTLY on their orbit ring (error ≤ 0.5 px) | **23 / 23** | ✅ pass | +| Centre cluster `R_centre + R_outer + INTER_CLUSTER_GAP` ≤ measured centroid distance | satisfied for all 4 outer clusters | ✅ pass | +| `npx svelte-check` | 0 errors / 0 warnings / 405 files | ✅ pass | +| `npm test` | 18 / 18 passed | ✅ pass | +| `npm run smoke` | PASS | ✅ pass | +| CI `smoke` on PR #28 | success | ✅ pass | + +## Result + +| ID | Target | Verdict | +| ---- | ---------------------------------------------------------------------------------------- | ------- | +| SC-1 | All CRITICAL audit findings closed in code | ✅ pass | +| SC-2 | All HIGH audit findings closed in code | ✅ pass | +| SC-3 | All MEDIUM audit findings closed in code | ✅ pass | +| SC-4 | LOW finding (CVE-2024-47764) closed via overrides | ✅ pass | +| SC-5 | Vitest suite covers chord rule + ring-gap rule + radii cache dependency-order regression | ✅ pass | +| SC-6 | Live DOM `0 overlaps`, `23/23 exact-on-orbit` (improved from `21/22` baseline) | ✅ pass | + +## Interpretation + +The post-merge audit on PR #26 found a real regression (radii cache +populated out of dependency order — `Map.keys()` returned rings in +insertion order, not sorted). Without it, two cluster members ended +up at exactly the same logical position on overlapping rings. + +The fix (sorted iteration in `detectClusters` pass-2) is now backed +by a regression test that fails against the pre-fix code, plus the +live DOM verification on the actual 23-artifact / 5-cluster workspace +that triggered the bug originally. + +The geometry guarantees from RFC-004 (chord rule, radial-gap rule, +inter-cluster gap rule) hold end-to-end: card centres sit exactly on +their orbits, no bbox overlap is possible by construction. The audit +loop also tightened the TypeScript posture (`noUncheckedIndexedAccess`) +and closed the open dependabot CVE. + +## Congruence Level Justification + +**CL3 (same-context, penalty 0.0)**: + +- DOM measurement runs against the same SvelteKit dev server users + see. No proxy. +- Regression test reproduces the exact failure mode that affected + the live render. It runs in CI on every PR. +- `evidence_type: measurement` — every assertion is a numeric + comparison against a closed-form bound (chord, ring-gap, + centroid distance). + +## Related Artifacts + +| Artifact | Relation | Notes | +| -------- | -------- | -------------------------------------------------------------------- | +| PRD-005 | informs | F5 closes audit findings on the F4 work that closed PRD-005. | +| RFC-004 | informs | The geometry RFC; F5 protects its invariants with regression tests. | +| EVID-012 | informs | F4 acceptance baseline — F5 builds on it (now `23/23`, was `21/22`). | diff --git a/.forgeplan/session.yaml b/.forgeplan/session.yaml index cd14c33..6a6e6a3 100644 --- a/.forgeplan/session.yaml +++ b/.forgeplan/session.yaml @@ -1,4 +1,4 @@ -phase: idle +phase: coding active_artifact: null route_depth: null phase_started_at: 2026-05-05T15:15:06.572441+00:00