Skip to content

release: v0.1.7 — F4 clustering + F5 cleanup + F6 UX + cookie bump#32

Merged
explosivebit merged 56 commits into
mainfrom
release/v0.1.7
May 5, 2026
Merged

release: v0.1.7 — F4 clustering + F5 cleanup + F6 UX + cookie bump#32
explosivebit merged 56 commits into
mainfrom
release/v0.1.7

Conversation

@explosivebit
Copy link
Copy Markdown
Contributor

Summary

Release v0.1.7 rolling up F4 + F5 + F6 + Feature/playground + LOW security bump.

What ships

  • F4 hierarchy clustering (PRD-005, RFC-004, EVID-012) — geometry-first RadialView (chord rule, radial-gap rule, INTER_CLUSTER_GAP), detectClusters placement around the largest cluster, ForceView extended with cluster-aware d3 forces.
  • F5 audit cleanup (PRD-005, EVID-013) — closes 2 CRITICAL + 5 HIGH + 3 MEDIUM + 1 LOW from the post-merge 4-expert audit. Includes a real regression fix (radii cache populated out of dependency order) covered by 2 dedicated vitest tests. noUncheckedIndexedAccess: true enabled. Total: 24 vitest tests passing.
  • F6 UX follow-ups — RadialView cluster collapse ("−/+" toggle on clusters with ≥3 rings); shared keyboard-nav.ts lib with cone-based ArrowKey navigation between nodes (RadialView + ForceView).
  • PR Feature/playground #27 (Feature/playground)npm run dev:playground mode + a 123-artifact Helios observability workspace for dogfood testing; <NodeRef> + nodeHover action; BFS distance-based hover fade.
  • SecurityCVE-2024-47764 (cookie<0.7.0, GHSA-pxg6-pf52-xh8x) closed via template/package.json#overrides. Dependabot alert resolved.

Verify

  • svelte-check 0/0/410 files locally.
  • vitest 24/24 passed (16 cluster + 2 regression + 6 keyboard-nav).
  • npm run smoke PASS (build + bin scaffold + start + /api/health + /api/list).
  • DOM verification on the live workspace: 0 bbox overlaps, 23/23 cards exact-on-orbit, ArrowRight from PRD-001 → RFC-002, collapse hides ring ≥ 2.

Test plan

  • CI smoke matrix (3-OS × Node 22) green.
  • After merge: tag v0.1.7 annotated, Draft GitHub Release → publish fires release.ymlnpm publish --provenance.
  • Back-merge release/v0.1.7develop so the version bump doesn't get lost.

Refs: PRD-005 RFC-004 EVID-012 EVID-013

🤖 Generated with Claude Code

Back-merge per Git Flow §2.5 step 4. Brings v0.1.6 commits (version
bump, CHANGELOG reorg) back into develop so feature branches start from
the latest baseline.
Any throw in load/render or in a  (e.g. one of 8 pollers on HomePage) used to crash to the default unstyled SvelteKit error page. New +error.svelte renders a styled fallback: large status code in --accent, error message, monospace Go home link. Reuses CSS tokens from app/styles/app.css; no new deps; Svelte 5 runes ( from $app/state).

Refs: PRD-003
All 5 graph views (Force/Lanes/Matrix/Radial/Tree) declared role=application — screen readers treat that as do not interpret content. Today these views have only click interaction; role=img with descriptive aria-label is the conformant semantic. If we ever add custom keyboard pan/zoom to ForceView, that one can flip back.

aria-label per view names the layout: Force-directed dependency graph, Lanes view by status, Adjacency matrix, Radial hierarchy, Tree hierarchy.

Refs: PRD-003 (FR-002)
…-004)

New shared helper template/src/widgets/dependency-graph/lib/reduced-motion.ts exports motionDuration(defaultMs) — returns 0 when window.matchMedia('(prefers-reduced-motion: reduce)') matches, otherwise the default. SSR-safe (window guard).

Note: imports + .transition().duration(motionDuration(300)) call-site wrapping in 5 view files landed together with the role=img swap (51edb7a) because the same files were touched. Keeping that as a single revertable surface for the views; this commit owns the helper + ForceView simulation guard (alphaDecay(0.1).alphaMin(0.05) when reduce-motion is set).

Refs: PRD-003 (FR-004)
…003)

Two row patterns in InsightsRail (recent activity at L107 and agents claim card at L133) used <li role=button tabindex=0 onclick=...> with svelte-ignore a11y_no_noninteractive_element_to_interactive_role. That suppresses the rule rather than fixing it; native focus order was masked.

Refactored to <li><button class=row-trigger>...</button></li>. Inner button strips browser defaults (background:transparent, border:0, padding:0, font:inherit, etc.) and carries hover/focus styles. .row.card uses :has(.row-trigger:hover/:focus-visible) on the outer <li> to keep card-outline accent. Removed both svelte-ignore directives, the rowKey helper, and all tabindex=0.

Refs: PRD-003 (FR-003)
At <1100px, .layout.has-panel had grid-template-columns:180px 1fr 360px (3 cols) while markup has 4 children (filters + canvas + insights rail + panel). The aside.rail was display:none under a separate selector but still claimed a grid cell, so the panel implicitly flowed out of the grid.

Fix: consolidate .layout aside.rail and .layout.has-panel aside.rail into one display:none rule; bump narrow .layout.has-panel grid to 200px 1fr 380px (matching desktop filters/panel widths) so 3 cells exactly host filters+canvas+panel. Bumped .layout (no-panel) from 180px to 200px for consistency.

Refs: PRD-003 (FR-005)
Five FRs from PRD-003: error.svelte boundary, role=img, nested button, prefers-reduced-motion, narrow-viewport grid.

Refs: PRD-003
Standard depth, 6 FRs, 4 NFRs, 5 risks. Drives PR F1 frontend recovery + a11y. Linked artifact: PRD-002 prior tactical model. EVID-F1 planned post-merge.

Refs: PRD-003
#22)

Closes 5 HIGH frontend audit findings: +error.svelte boundary (FR-001),
role=img on 5 graph SVGs (FR-002), nested button in InsightsRail
(FR-003), prefers-reduced-motion guards (FR-004), narrow-viewport grid
alignment (FR-005). Refs PRD-003. 7 commits revertable per NFR-003.
Local smoke PASS, svelte-check 0/0.
Live browser verification of PR #22 (F1 frontend recovery + a11y) via MCP Playwright DOM assertions and viewport screenshots. Goes beyond compile-time review (svelte-check) and CI smoke (server-only) — captures the integration layer where SvelteKit hydration meets browser DOM. All 5 FRs and 7 SCs pass with deterministic CSS-selector + getAttribute / getComputedStyle queries; visual screenshots at 1024×900 and 1600×900 confirm pixel-level layout.

Activates EVID-010 and PRD-003 (draft to active). R_eff=1.00 / CL3 / F-G-R grade A.

Refs: PRD-003 EVID-010
Forge-cycle Step 7-8 for PR #22 (PRD-003 F1 frontend recovery + a11y).
EVID-010 verifies all 7 SC via live MCP Playwright DOM eval + visual
screenshots. R_eff=1.00 CL3 grade A. Activates both. Refs PRD-003
EVID-010.
New file template/src/widgets/dependency-graph/lib/highlight.svelte.ts exports a runed shared state object {hoveredId: string|null}, mutator helpers (setHovered/clearHovered), and an edge classifier edgeClass(from, to, hovered) that returns 'edge-active' | 'edge-dim' | ''. Drives FR-002..FR-006 of PRD-004 across all 5 graph views.

Top-level  restriction in Svelte 5 means the rune lives in an exported object — components mutate .hoveredId, which is reactive and re-renders only what reads it (single class string per link, not the whole graph).

Refs: PRD-004 (FR-002, FR-003, FR-004, FR-006)
… highlight (FR-001..FR-006)

FR-001 — Selection now renders as a dedicated <rect class=selection-ring> sized exactly to the card content (node.w × node.h). The status dot keeps its own position outside the ring. Selection styling moved off the .box element so the dot's accent color no longer clashes with the ring's accent stroke. pointer-events:none on the ring keeps click hit-testing on the parent <g>.

FR-002..FR-006 — onmouseenter/onmouseleave (plus onfocus/onblur for keyboard a11y) on each node <g> drive shared highlight.hoveredId. Each link <line> takes class={edgeClass(a.id, b.id, highlight.hoveredId)} which returns edge-active for connected edges (accent stroke, width 2) or edge-dim for unrelated (opacity 0.25).

Refs: PRD-004 (FR-001, FR-002, FR-003, FR-004, FR-005, FR-006)
Replicates ForceView's FR-001..FR-006 across the remaining 4 views with the same shared highlight state and edgeClass classifier:

- TreeView / RadialView — edge primitive is <path> (parent-child curves); EdgePath gained from/to fields so edgeClass() can match. Selection-ring rect added (rx/ry=3) sized to card-content, status dot kept outside.

- MatrixView — no card edges; cells representing relationships become the edge primitive. edgeClass on cell <rect>; .edge-active fills with accent, .edge-dim drops opacity to 0.25. Hover handlers on both row-header and col-header <g> (same artifact id from either axis). FR-001 N/A — Matrix has no encircled card.

- LanesView — uses cubic Bezier <path> (not <line> as the spec hinted; following the actual code). Selection-ring rect + hover/focus handlers identical to Force/Tree/Radial.

All 4 files: svelte-check 0/0; >=3 highlight-related symbols per file; .edge-active and .edge-dim CSS rules in each <style> block.

Refs: PRD-004 (FR-005)
Two new bullets under [Unreleased] Changed for PRD-004: FR-001 (selection sans status dot) and FR-002..FR-006 (hover-edge highlight Mid variant). Plus a .gitignore rule for f[0-9]-*.png — ad-hoc screenshots from MCP Playwright L4 verification runs that should not land in commits.

Refs: PRD-004 (FR-007)
Standard depth, 7 FRs, 4 NFRs, 4 risks. Drives this PR. EVID-F2 planned post-merge with live Playwright DOM verification.

Refs: PRD-004
…light (5 views) (#24)

User-driven UX during F1 live walkthrough: (1) selection ring no longer
encloses status dot (separate inner rect sized to card content; dot
stays independent), (2) hover on a node highlights its connected edges
(.edge-active accent stroke) and dims unrelated edges to opacity 0.25
(.edge-dim) — Mid variant. New shared lib/highlight.svelte.ts owns the
runed state. Pattern applied across all 5 views — Force / Tree / Radial
/ Matrix / Lanes; Matrix uses cell rect as edge primitive. Refs PRD-004.
Local smoke PASS, svelte-check 0/0. 5 commits revertable.
Three-layer verification of PR #24: source review, svelte-check 0/0, CI smoke 3/3 OS green. R_eff=1.00 / CL3 / F-G-R grade A. Activates EVID-011 and PRD-004. Live Playwright DOM verification deferred to a follow-up evidence pack.

Refs: PRD-004 EVID-011
Forge-cycle Step 7-8 for PR #24 (F2-graph). EVID-011 verifies all 7 SC.
R_eff=1.00 CL3 grade A. Refs PRD-004 EVID-011.
Per RFC-004: detectClusters picks the most senior TYPE_ORDER artifact as
cluster root, BFS over hierarchy edges (informs/refines/belongs-to/
contains/supersedes) assigns members. computeOrbitRing assigns each
member a ring index via min(typeRank, edgeDepth) — connected nodes pull
inward, orphans fall back to type rank. computeRingRadius adapts each
ring to N · MIN_NODE_SPACING / 2π so circumference fits all members
without overlap. Two-pass cluster detection: pass 1 assigns members,
pass 2 uses each cluster's actual maxRadius (not estimate) for grid
spacing, capped at viewport/2 so clusters stay visually close.

Refs: PRD-005 RFC-004
Coulomb-style inter-cluster repel keeping centroids apart in ForceView.
strength=800, minDistance=250, alpha-scaled, naive O(N²) with cluster-
skip pruning. Mutates vx/vy in place to integrate with d3-force pipeline.
No new runtime deps — implements the d3 Force interface directly.

Refs: PRD-005 RFC-004
Refactor RadialView to use the shared cluster lib: detectClusters picks
roots, computeOrbitRing assigns ring indices, computeRingRadius gives
each ring a radius wide enough to fit its N members (R(n) = max(prev +
RING_GAP, N · MIN_NODE_SPACING / 2π)). Multi-cluster layout positions
each centroid via the lib grid; per-cluster ring set is rendered around
its centroid. Post-layout 16-iteration pairwise sweep guarantees no two
cards occupy the same coordinate; viewport clamp keeps everything in
canvas. Eliminates rfc001/EVID-004 direct overlap reported on the prior
build.

Refs: PRD-005 RFC-004
Wire detectClusters + computeOrbitRing + computeRingRadius into the d3
force simulation. New forces per RFC-004: clusterX/clusterY (centripetal
pull, strength 0.25), forceClusterOrbital (per-node target radius from
the cluster's ring map, strength 0.15), forceClusterRepel (inter-cluster
spacing, 800/250). Existing forces re-tuned: charge -150 (was -300, too
strong for cluster cohesion), link distance 80, alphaDecay 0.025, collide
0.6w + 12 padding × 2 iter. prefers-reduced-motion pre-ticks 80 frames
then sim.stop() instead of animating. Initial node positions seeded near
each node's cluster centroid with ±10 px jitter so forceCollide always
has a non-zero gradient at t=0 (eliminates startup degeneracy at origin).

Refs: PRD-005 RFC-004
5 bullets under Added (PRD-005 + RFC-004): cluster lib, force-cluster-
repel custom d3 force, RadialView multi-cluster layout, ForceView force
chain, compact cluster placement using actual maxRadius.

Refs: PRD-005 RFC-004
PRD-005 (Standard depth) drives the F4-clustering PR — 8 FRs, 4 NFRs,
5 risks. RFC-004 pins force parameters: cluster gravity 0.25, orbital
0.15, clusterRepel 800/250, charge -150, link 80, alphaDecay 0.025,
collide padding 6 × 2 iter. Constants table updated to MIN_NODE_SPACING
=140 (was 28 — too small for ~120px wide cards). Documents Pass A
(member assignment) + Pass B (centroid placement with actual maxRadius
not estimate).

Refs: PRD-005 RFC-004
Rewrite RadialView placement so card centres sit EXACTLY on orbit
rings — replacing the empirical sweep + arc-based ring radius with
chord-based geometry that is provably non-overlapping.

Math:
- Same-ring chord constraint: 2r·sin(π/N) ≥ MIN_CHORD where
  MIN_CHORD = √(W² + H²) + SAFE_GAP. Chord-based, not arc-based.
- Adjacent-ring radial gap: ≥ RING_GAP = max(W, H) + SAFE_GAP.
- Compact ring assignment: orbit index = position in TYPE_ORDER ∩
  present types per cluster. Missing types collapse inward.
- Parent-anchored angles: each member's angle = circular mean of its
  inner-ring neighbours (atan2 of unit-vector sum). Orphans fill
  largest free angular gap.
- Cluster placement: largest cluster at canvas centre, others on a
  regular polygon around it. outerRadius = max(R_centre +
  INTER_CLUSTER_GAP + R_outer, chord-bound). Uniform centre-to-outer
  edge-gap.

Removes:
- Anti-collision sweep (geometry now guarantees non-overlap).
- Odd-ring phase π/12 hack (no longer needed).
- Viewport clamp on individual nodes (broke "centre on orbit"
  invariant; fitToView handles framing).
- Per-cluster radius scale (broke chord constraint).
- distributeCentroids grid layout (replaced by radial-around-largest).

Visual: orbit rings stroke 0.08→0.16, dasharray 2 4→3 5 — visible
without competing with relation edges.

Refs: PRD-005 RFC-004
RFC-004 rewrite documents the chord-based radius rule (r ≥ MIN_CHORD /
(2·sin(π/N))), radial-gap rule for adjacent rings, type-rank orbit
assignment, parent-anchored angles via circular mean, and the
radial-around-largest cluster placement with combined radial+chord
outerRadius bound. Captures the invariants: centre-on-orbit, no
sweep, no scale, uniform centre-to-outer edge-gap.

CHANGELOG [Unreleased] reflects the same — replaces the earlier
sweep + arc-based + grid-spacing description.

Refs: PRD-005 RFC-004
The chord/gap math no longer needs a "pair-specific minDist" floor —
sweep was removed. Drops the backwards-compat const export and its
import in RadialView.

Refs: PRD-005 RFC-004
DOM-verified acceptance pack for the geometry-first RadialView:
- 0 bbox overlaps across all 22 cards
- 21/22 cards centre EXACTLY on orbit ring (error = 0; outlier is
  PRD-005 orphan with no parent ring)
- All 3 outer clusters at uniform 567 px from PRD-001 centre, edge-
  gap 63 px each
- Largest cluster (R=378) at canvas centre, smaller clusters on a
  regular polygon around it

Structured Fields: verdict supports / CL3 / measurement.
Closes PRD-005 acceptance criteria SC-1..SC-7. Linked to RFC-004
which pins the chord-based geometry the evidence verifies.

Refs: PRD-005 RFC-004
explosivebit and others added 26 commits May 5, 2026 18:15
EVID-012 closes PRD-005 acceptance (DOM-verified geometry: 0 overlaps,
21/22 cards exact-on-orbit, uniform inter-cluster gap).

Status transitions:
- PRD-005: draft → active
- RFC-004: draft → active (+ Proposed Direction + Implementation Phases)
- EVID-012: active (links: informs PRD-005, informs RFC-004)

Renames to match new title slugs:
- RFC-004-force-parameter-design-* → RFC-004-geometry-first-hierarchy-clustering-layout-*
- EVID-012-*-21-of-22-* → EVID-012-*-21-22-*-0-bbox-overlap-* (Lance index expectation)

Final scores:
- PRD-005: R_eff 1.00 (Adequate, F-G-R 0.79 B)
- RFC-004: R_eff 1.00 (Adequate, F-G-R 0.64 B)

Refs: PRD-005 RFC-004
Parallel SvelteKit dev workflow for sandbox experiments without hitting
this repo's own .forgeplan/ workspace.

- playground/ — local Forgeplan workspace (config.yaml + dir skeleton);
  derived state (lance/, claims/, journal/, …) gitignored via
  playground/.gitignore.
- template/vite.config.ts — FORGEPLAN_CWD override gated on
  `mode === 'playground'`; plain `vite dev` leaves env untouched and
  falls back to runForgeplan's default workspace resolution.
- npm scripts: `dev:playground` at template/ (vite --mode playground)
  and repo root via `scripts/dev.mjs --playground`.

Verified:
- `npm run dev`            → /api/health: ForgePlanWeb (11 artifacts).
- `npm run dev:playground` → /api/health: Playground   (0 artifacts).
- `npm run check`          → 358 files, 0 errors.
- `npm run smoke`          → PASS on fresh dist/ rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the banner data and `printBanner()` into a sibling module
`bin/banner.mjs` and call it as the first line of `start()`. The
banner respects `NO_COLOR`, `TERM=dumb`, non-TTY stdout and the
existing `-q`/`--quiet` flag. ANSI Shadow figlet + 24-bit orange.

Rule 23 clarified: relative imports of sibling `.mjs` files inside
`bin/` are first-party — they ship in the published tarball and do
not introduce third-party resolution at `npx` time. The verification
grep is rewritten to handle multi-line `import { … } from "node:fs"`
correctly and to explicitly allow `./` and `../` specifiers, which
removes a false positive that the previous one-liner produced after
this extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hovering a node now runs a BFS over filteredEdges and applies an
opacity class scaled by hop distance — direct neighbours stay near
full opacity, distant nodes fade further, disconnected nodes drop
to 0.12. Edges and nodes both transition smoothly (180ms ease-out).

Applied across all five views: Force, Tree, Radial, Lanes, and
Matrix (row/col headers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ix (#26)

## Summary

Hierarchy-based clustering for ForceView and fix for RadialView card
overlap.

- **`lib/cluster.svelte.ts`** — `detectClusters` (BFS from senior
`TYPE_ORDER` root over hierarchy edges), `computeOrbitRing` with
`min(typeRank, edgeDepth)`, `computeRingRadius` adaptive: `R(n) =
max(prev + RING_GAP, N · MIN_NODE_SPACING / 2π)`.
- **`lib/force-cluster-repel.ts`** — custom d3 Force keeping cluster
centroids apart (Coulomb-style, strength=800, minDistance=250).
- **`RadialView`** — multi-cluster layout, post-layout 16-iter pairwise
sweep + viewport clamp. Eliminates rfc001/EVID-004 overlap.
- **`ForceView`** — d3 simulation extended with `clusterX/Y` (0.25),
`forceClusterOrbital` (0.15), `forceClusterRepel` (800/250). Initial
positions seeded near centroid + ±10 jitter so `forceCollide` has a
non-zero gradient at t=0. `prefers-reduced-motion` pre-ticks 80 +
`sim.stop()`.
- **Compact placement** — grid spacing uses each cluster's ACTUAL
computed maxRadius, capped at viewport/2 so clusters stay close instead
of scattering across canvas.

## Why

Audit + UX feedback: (1) RadialView showed direct overlap (rfc001 on
EVID-004) because `MIN_NODE_SPACING=28` was much smaller than card width
(~120px); (2) per-type rings let dense rings overlap at the type radius;
(3) `worst-case` cluster spacing scattered clusters across the entire
viewport. F4 reworks ring assignment to BFS-from-root with adaptive
radius and uses actual computed radii for cluster spacing.

Refs: `PRD-005` `RFC-004`

## Test plan

- [ ] CI smoke matrix green on ubuntu/macos/windows × Node 22
- [ ] svelte-check 0 errors / 0 warnings (verified locally pre-push)
- [ ] Manual visual inspection: ForceView shows distinct clusters;
RadialView shows no overlapping cards
- [ ] `prefers-reduced-motion: reduce` skips ForceView animation
(pre-tick + stop)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Populate playground/.forgeplan/ with a fictional "Helios" observability
platform — six epics, sixteen PRDs, nineteen RFCs, thirteen ADRs,
seventeen specs, thirteen problems, nine solutions, twenty-six evidence
packs, four notes — wired with ~80 typed dependency links.

Used as dogfood data for the SvelteKit graph UI: gives the force /
lanes / matrix / radial / tree views a realistic, dense dataset
exercising every artifact kind, every link relation (refines, informs,
based_on, supersedes), and every lifecycle state.

Lifecycle distribution mirrors a mature project:
  active 105 / deprecated 10 / superseded 4 / draft 4 (3.25%).

All 26 EvidencePacks include the `## Structured Fields` block so
`forgeplan score` produces non-trivial R_eff. Lance index, claims,
state, and logs stay gitignored as elsewhere.
Selected/opened node now triggers the same BFS distance-based fade as
hover. Hover keeps a stronger dim; selection-without-hover uses a softer
dim via .graph.focus-soft override. Hover takes priority over selection
when both apply.

Applied uniformly to Force/Tree/Lanes/Radial/Matrix views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a brief "Local dev modes" subsection to README.md and README.ru.md
explaining `npm run dev` vs `npm run dev:playground` and what the
fictional Helios-themed playground/ workspace is for (exercising the
viewer at realistic ~123-artifact scale, not published, not copied to
user projects).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
forgeplan health --json returns blind_spots as [{id,title,issue}],
not string[]. Update HealthResponse type and InsightsRail render so
the rail shows the artifact id + title instead of toString'd objects.
…anels

Move the hover store from widgets/dependency-graph down to entities/graph
so widgets/insights-rail and widgets/artifact-panel can share it without
crossing FSD layers. Add a `nodeHover` Svelte action and a `<NodeRef>`
component as the single primitive for rendering an artifact id anywhere,
and replace ad-hoc id renderings in InsightsRail (Recent / Agents /
Drafts / Blocked / Cycles / Ready / Blind spots / Orphans / Stale /
Lowest R_eff) and ArtifactPanel (header id, Outgoing, Incoming,
parent_epic) so hovering any of them lights up the matching graph node.

The original highlight module under widgets/dependency-graph/lib is
kept as a re-export shim to avoid touching the five graph views in
this commit; a TODO(fsd-cleanup) marks the follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add Unreleased entries for non-PRD additions (dev:playground + Helios
seed, bin/banner.mjs, NodeRef + nodeHover) and the /api/health blind_spots
render fix. Expand PRD-004 Changed with the three follow-up entries:
distance-based BFS fade, selection-soft dim, and the FSD relocation of
the hover store from widgets/dependency-graph/lib/ to entities/graph/lib/
(re-export shim with // TODO(fsd-cleanup)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CRITICAL:
- ForceView: stable each-block link key built from source/target/relation
  string. Was keyed by object identity; every rebuild recreated all
  <line> elements (post-merge audit on PR #26).
- detectClusters: extend ClusterInfo with `orbits` + `radii` (computed
  in pass-2) and ClusterDetectionResult with `nodeAdjacency`. Both
  views read these instead of recomputing computeOrbitRing /
  computeRingRadius / buildHierarchyAdjacency per render — the work
  is now done once.
- detectClusters: iterate `counts.keys()` in SORTED order in both the
  actualMaxR pass and the radii[] population. Map.keys() returns
  insertion order, which depended on orbit assignment order; without
  sorted iteration computeRingRadius's cache resolved ring 2 before
  ring 1 and produced ring 2 = 126 instead of 252. Visible regression:
  RFC + ADR sharing a single orbit position in RadialView.
- Filter memo (RadialView + ForceView): wrap filterArtifacts /
  filterEdges / scoreById in $derived.by guarded by content
  signature so a 10s poll with identical payload doesn't invalidate
  the layout.

HIGH:
- force-cluster-repel: drop unsafe `as ForceClusterRepel<NodeT>` cast.
  Constrain `ForceClusterRepelOptions<NodeT extends SimulationNodeDatum>`.
  Use typed Object.assign so the returned value has all members at
  construction time.
- ForceView layout-signature effect: drop redundant re-bind of
  forceX/forceY/orbital (their accessors already read `layout` fresh
  per tick). Re-bind clusterRepel only when nodeToCluster changes,
  then explicitly call `.initialize?.(simNodes)` on the new instance
  to refresh cached cluster ids.
- RadialView: didFit is `$state` with a layout-shape signature effect
  that resets it on substantial transitions; fitToView re-runs after
  filter clears / dataset reloads.
- RadialView + ForceView: `scaleExtent([0.45, 4])` (was 0.2). Same
  floor in RadialView fitToView. Labels stay legible at min zoom.
- force-cluster-repel: early-exit when n < 2 or all nodes share one
  cluster id. Skip pair when both nodes are fixed (a.fx/fy and
  b.fx/fy non-null).
- tsconfig: enable `noUncheckedIndexedAccess`. Resolve fallout in
  cluster.svelte.ts (Array index narrowing in the orphans-fill block).

MEDIUM:
- cluster.svelte.ts: drop unused `_adjacency` parameter from
  computeOrbitRing (call sites updated).
- ForceView: rename `_tick` → `_invalidationTick` with comment;
  demote misleading FIXME(reactivity-loop) to a plain explanatory
  comment per .claude/rules/10-comments-policy.md.
- a11y: verified RadialView + ForceView keep `role="img"` on the SVG
  (per PRD-003 FR-002) AND `role="button" tabindex="0"` on each
  `<g.node>`. No code change needed.

Refs: PRD-005 RFC-004
Add overrides → cookie ">=0.7.0" in template/package.json. npm install
refreshes lockfile to cookie@1.1.1. Closes GHSA-pxg6-pf52-xh8x (LOW).
Practical impact ≈ 0 (we don't serialize user-controlled cookie values),
but eliminates the open dependabot alert on develop.

Refs: PRD-005 RFC-004
…ession

Add vitest as devDep (--ignore-scripts) and a "test" npm script to
template/. Two test files:

cluster.test.ts (16 tests):
- computeRingRadius chord rule for N=2..12, radial-gap rule, N=1
  special case, ring-0 pin, monotonicity.
- computeOrbitRing root pin, same-kind same-ring, missing-types
  collapse inward.
- computeAnchoredAngles even spread when no anchors, root angle 0.
- ringCounts grouping; detectClusters K=0/1/2/≥3, hierarchy routing.

regression.test.ts (2 tests):
- ring N radius ≥ ring N-1 radius + RING_GAP across all populated
  rings of a real workspace shape (PRD + 3 RFC + 2 ADR + 8 EVID).
- RFC (ring 1) and ADR (ring 2) cannot share radius.

These two regression tests would FAIL against the pre-fix
detectClusters (Map.keys() insertion order was non-monotonic, cache
resolved out-of-order).

Verify: npm test → 18 passed (2 files); svelte-check 0/0/405.

Refs: PRD-005 RFC-004
# Conflicts:
#	.forgeplan/session.yaml
#	CHANGELOG.md
#	template/src/widgets/dependency-graph/ui/ForceView.svelte
#	template/src/widgets/dependency-graph/ui/RadialView.svelte
## Summary

4-expert audit on `develop` (post-PR #26 merge `bf842a7`) flagged 2
CRITICAL, 5 HIGH, 3 MEDIUM, 1 LOW. This PR closes them all and adds
vitest unit-test coverage for the cluster geometry.

## CRITICAL (2)

- **ForceView link key by object identity** — every `rebuild()`
reassigned `simLinks` so Svelte destroyed and recreated every `<line>`.
Now keyed on `${source.id}>${target.id}:${relation}` stable string.
- **`detectClusters` double-work + radii cache out of order** —
`Map.keys()` insertion order from `ringCounts` was non-monotonic.
`computeRingRadius`'s cache then resolved ring 2 before ring 1,
producing wrong radii (visible regression: RFC + ADR sharing one orbit
position in RadialView). Fix: sorted iteration in both passes; lib also
exposes `cluster.orbits` / `cluster.radii` / `nodeAdjacency` so views
don't recompute them.

## HIGH (5)

- `force-cluster-repel` — typed `Object.assign` construction, drop
unsafe cast, constrain `NodeT extends SimulationNodeDatum`.
- ForceView signature effect — only re-bind clusterRepel + explicit
`.initialize?.(simNodes)` for fresh cluster ids.
- Score memo + `didFit` reactive — gate Map rebuild on content
signature; reset fitToView on layout-shape changes.
- Zoom-floor `0.45` (was `0.2`) — labels stay legible, fitToView floor
matches.
- `noUncheckedIndexedAccess: true` — fallout resolved in
cluster.svelte.ts orphans-fill block.
- `forceClusterRepel` short-circuits when `n < 2` or all nodes share one
cluster.

## MEDIUM (3)

- Drop `_adjacency` parameter from `computeOrbitRing`; rename `_tick` →
`_invalidationTick`; demote misleading FIXME comment.
- a11y: verified `role="img"` + `role="button"` setup intact in both
views.

## LOW (1)

- Bump transitive `cookie` ≥ `0.7.0` (CVE-2024-47764,
GHSA-pxg6-pf52-xh8x). Resolved to `1.1.1` via
`template/package.json#overrides`.

## Tests

- `cluster.test.ts` (16): chord rule, radial gap, N=1, ring-0 pin,
monotonic, anchored angles, multi-cluster.
- `regression.test.ts` (2): ring N − ring N-1 ≥ RING_GAP for the
workspace shape that triggered the bug; RFC/ADR cannot share radius.
Would FAIL against the pre-fix code.

## Verify

- `npx svelte-check` — 0 errors / 0 warnings / 405 files.
- `npm test` — 18/18 passed.
- `npm run smoke` (root) — PASS.
- DOM check on live workspace (23 nodes / 5 clusters): **0 bbox
overlaps**, **23/23 cards exact-on-orbit** (was 21/22 in EVID-012; also
2 overlaps before this fix).

Refs: `PRD-005` `RFC-004`

## Test plan

- [ ] CI smoke matrix (3-OS × Node 22) green.
- [ ] svelte-check clean on PR (verified locally).
- [ ] vitest suite passes on PR.
- [ ] Manual visual: switch to Radial → confirm no overlap; switch to
Force → simulation settles.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
# Conflicts:
#	template/src/widgets/dependency-graph/ui/ForceView.svelte
#	template/src/widgets/dependency-graph/ui/RadialView.svelte
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<string>); 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
…ceView)

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
`<g class="node">`. 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
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
…ps-f6

Pull in PR #27 (Feature/playground) merged to develop after this branch
was cut. Auto-merge clean for ForceView.svelte and RadialView.svelte
(both files added per-node `nodeClass` and BFS hover-distance imports
on develop, complementary to F6's data-id and onNodeKeydown hooks).

Conflict resolved: drop `.forgeplan/session.yaml` (deleted on develop).

Refs: PRD-005 RFC-004
…D-013 (#31)

## Summary

Two UX follow-ups from the F5 audit, plus the F5 acceptance evidence
pack.

### F6-T1 — RadialView cluster collapse

Toggle ("−/+") near each cluster's centroid. Visible only for clusters
with ≥ 3 rings. Click/Enter collapses that cluster to root + ring 1;
click again to expand. State per-view; fitToView re-runs on toggle.

### F6-T2 — ArrowKey navigation between nodes

New shared lib `keyboard-nav.ts` with `pickNextNode(current, candidates,
direction)` — cone-based selection (±60° around axis), cost = `distance
· (1 + 2·angle/π)`, falls back to nearest. Wired into both RadialView
and ForceView. Adds `data-id` on each `<g class="node">`. 6 unit tests.

### F6-T3 — EVID-013

Forgeplan acceptance pack closing the F5 audit-cleanup work that landed
in PR #28. Linked `informs` to PRD-005, RFC-004, EVID-012. R_eff 0.80;
PRD-005 quality bumped to A (0.82).

## Verify

- `npx svelte-check` — 0 errors / 0 warnings / 406 files.
- `npm test` — 24 / 24 passed (16 cluster + 2 regression + 6
keyboard-nav).
- `npm run smoke` — PASS.
- DOM check on live workspace: collapse hides ring ≥ 2 (rings 8 → 6,
nodes 23 → 13 when 1 cluster collapsed). ArrowRight from PRD-001 →
RFC-002 (correctly picks rightward neighbour).

Refs: `PRD-005` `RFC-004` `EVID-013`

## Test plan

- [ ] CI smoke matrix (3-OS × Node 22) green.
- [ ] svelte-check clean on PR.
- [ ] vitest 24/24 pass.
- [ ] Manual: tab into a node, press arrows — focus moves directionally.
Click "−" on a centre cluster — outer rings hide.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
F4 hierarchy clustering + RadialView (PRD-005 / RFC-004 / EVID-012),
F5 audit cleanup (CRITICAL+HIGH+MEDIUM fixes + 18→24 vitest tests +
radii cache regression fix; EVID-013), F6 UX follow-ups (cluster
collapse, ArrowKey navigation), and CVE-2024-47764 (cookie<0.7.0)
closed via overrides. Plus the dev:playground mode and the Helios
sandbox workspace from PR #27.

Refs: PRD-005 RFC-004
@explosivebit explosivebit merged commit dbe0b75 into main May 5, 2026
3 checks passed
@explosivebit explosivebit deleted the release/v0.1.7 branch May 5, 2026 20:51
explosivebit added a commit that referenced this pull request May 5, 2026
## Summary

Back-merge of `release/v0.1.7` (now merged into `main` and tagged
`v0.1.7`) into `develop` so the version bump (0.1.6 → 0.1.7) and any
release-only commits don't get lost on the next feature branch cut from
`develop`.

Per [`guides/GIT-FLOW-GUIDE.ru.md`](guides/GIT-FLOW-GUIDE.ru.md) §6.10.

## What's in this back-merge

- `b323f75` — `chore(release): bump version to 0.1.7`
- `dbe0b75` — release PR #32 merge commit (already on main + tagged
v0.1.7 + npm published).

No new code changes — release was already QA'd through PR #32 (smoke
matrix green, npm publish success).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants