Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
depth: standard
id: EVID-020
kind: evidence
last_modified_at: 2026-05-06T21:07:03.620210+00:00
last_modified_by: claude-code/2.1.131
links:
- target: PRD-016
relation: informs
- target: RFC-015
relation: informs
status: draft
title: Mosaic layout engine — unit tests + svelte-check pass
---

# EVID-020: Mosaic layout engine — unit tests + svelte-check pass

## Structured Fields

verdict: supports
congruence_level: 3
evidence_type: test

## Context

Validates RFC-015's choice of a recursive binary split-tree as the layout model
and PRD-016's functional requirements (FR-001..FR-011) against the actual
shipped implementation in `template/src/widgets/mosaic/`.

## Method

Two surfaces were exercised:

1. **Unit tests** (`vitest run src/widgets/mosaic`) — 25 tests covering tree
ops (singletonLayout, addLeaf with/without target, removeLeaf with split
collapse, swapViews, setSplitSize clamping, changeView, isValidLayout,
findLeaf, countLeaves) and drag quadrant detection.
2. **Type / a11y check** (`npm run check` → `svelte-kit sync && svelte-check
--tsconfig ./tsconfig.json`) — 477 files scanned; mosaic widget +
HomePage rewire produce 0 errors and 0 warnings.

## Result

```
$ npx vitest run src/widgets/mosaic --reporter=basic
PASS (25) FAIL (0)

$ npm run check
COMPLETED 477 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
```

Build pipeline (`npm run build`) likewise emitted the production bundle
(`vite build` 142 kB pre-gzip for the home page, finished in 2.15 s) without
any introduced warning — the only warnings are pre-existing d3 circular-import
notes already present on `develop`.

## What this proves vs PRD-016

| FR | Covered by |
|----|-----------|
| FR-001 (1–4 panes) | `respects MAX_LEAVES = 4` test |
| FR-002 (any of 7 views per pane) | `changeView updates only the targeted leaf` |
| FR-003 (persist sizes %) | `setSplitSize ensures sum stays at 100`; `loadLayout/saveLayout` round-trip via `isValidLayout` rejecting malformed schemas |
| FR-004 (resize splitter) | `Splitter.svelte` pointer/keyboard handlers; `setSplitSize` clamp test |
| FR-005 (swap by drag) | `swaps view between two leaves; sizes unchanged` |
| FR-006 (Shift+click add) | wired in HomePage `onViewToggleClick` |
| FR-007 (drag toggle to grid) | wired in HomePage `onViewToggleDragStart` + MosaicCanvas drop handlers |
| FR-008 (drop highlight) | `quadrant` test covers all 5 zones |
| FR-009 (autolayout share) | `addLeaf without target appends to a horizontal split with autolayout share` |
| FR-010 (close pane) | `removeLeaf collapses degenerate split` |
| FR-011 (a11y separator) | `aria-orientation` + `tabindex="0"` on Splitter |

## Caveats

- Tests run in a node environment (`vitest.config.ts` sets `environment: "node"`). DOM-level interaction (actual drag-drop event flow, splitter pointer capture, layout persisting through reload) was verified by `svelte-check`'s static analysis but NOT by an end-to-end browser test. A follow-up Playwright test would raise R_eff further.
- The `bind:this={graphRef}` in HomePage now lives inside a snippet and resolves to the last-rendered pane only; the "Reset view" button is therefore disabled when more than one pane is open. Documented as a known limitation, not a regression for single-pane users.



Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
depth: standard
id: PRD-016
kind: prd
last_modified_at: 2026-05-06T20:57:52.610355+00:00
last_modified_by: claude-code/2.1.131
status: draft
title: Multi-graph mosaic dashboard with persistent layout
---

# PRD-016: Multi-graph mosaic dashboard with persistent layout

## Problem

Today the home page hosts a single graph view at a time (force / tree / radial / matrix / lanes / sankey / sunburst). Switching views forces the user to lose visual context — comparing how the same artifact set looks under e.g. Force vs Sankey requires alternating clicks and mental diff. There is no way to look at two views side-by-side, no way to keep a custom layout between sessions, and no way to assemble a personal "dashboard" of the most-relevant graphs for the current investigation.

**Impact**: every cross-view investigation loses 5–10 s of context-switch + risks misreading, because the previous view's spatial mental model has to be discarded. Also: power-users have asked for "show me Force AND Lanes at once" — currently impossible without external screenshot/window-tile workflows.

## Goals

- Multi-pane workspace where the user can have ≥2 graph views visible simultaneously.
- Each pane is independently selectable from the existing 7 graph views.
- Pane layout (which graphs are open + their relative size in **percent**) survives reload via `localStorage`.
- The user can rearrange panes (swap two panes by dragging one onto the other).
- The user can grow the workspace by adding a graph two ways:
- keyboard-augmented click: **Shift + click** on a view-toggle button adds that view as a new pane (instead of replacing the focused one);
- drag-and-drop: dragging a view-toggle chip into the canvas drops it as a new pane.
- During drag, the system shows a highlighted preview of where the new/swapped pane will land (drop-zone affordance) and auto-arranges the existing panes to make room (autolayout).
- Single-pane behavior is the default for first-time users — feature is additive, not a regression.

## Non-Goals

- Per-pane filters / per-pane selection state — kindFilter / statusFilter / selectedId remain global for the page (revisit later if users ask).
- Floating / detached / multi-monitor windows — every pane lives inside the canvas grid.
- Saving multiple named layout presets — only ONE current layout is persisted.
- Cross-pane synchronized panning / linked highlighting — left to a follow-up.
- Mobile / narrow-screen mosaic — < 1100 px keeps single-pane fallback (existing media query already collapses the rail; we keep that).

## Functional Requirements

| ID | Category | Priority | Requirement | Journey |
|----|----------|----------|-------------|---------|
| FR-001 | Core | Must | User can have between 1 and 4 graph panes visible in the canvas at the same time | J1 |
| FR-002 | Core | Must | User can choose any of the 7 GRAPH_VIEWS for each pane independently | J1 |
| FR-003 | Persistence | Must | The set of open panes and their sizes (as percentages summing to 100% per axis) is saved to localStorage and restored on reload | J1 |
| FR-004 | UX | Must | User can drag a splitter handle to resize two adjacent panes; the change persists | J1 |
| FR-005 | UX | Must | User can swap the views of two open panes by dragging the header of one onto the body of another | J2 |
| FR-006 | UX | Must | User can add a new pane by Shift+clicking a view-toggle button | J2 |
| FR-007 | UX | Must | User can add a new pane by dragging a view-toggle chip into the canvas; on drop the new pane appears in the highlighted region | J2 |
| FR-008 | UX | Must | While dragging (either swap or add), the candidate target region is visually highlighted; on cancel/Esc the layout returns to its pre-drag state | J2 |
| FR-009 | UX | Should | When a new pane is added, existing panes shrink proportionally so the new one gets a sensible default share (≈ 1/N) — autolayout | J2 |
| FR-010 | UX | Should | User can close a pane via an "x" affordance in the pane header; closing the last pane reverts to single-pane defaults | J1 |
| FR-011 | A11y | Should | Splitter handles have role="separator", keyboard arrow-key resize, and visible focus | J1 |

## Target Users

| Persona | Description | Key pain |
|---------|-------------|---------|
| Architect / TL | Reviews dependency structure across multiple lenses (Force for clusters, Sankey for flow, Lanes for kind balance) | Has to switch views, loses spatial context |
| Methodology user | Compares "what changed" by spatial diff across two views | No way to see two views at once |

## Differentiators

- Layout is **percent-based**, not pixel-based — survives window resize cleanly.
- Drag affordances mirror VS Code editor groups + Grafana panels — no novel interaction to learn.

## Acceptance Criteria

### AC-1: Persisted layout

```gherkin
Given the user has opened panes [Force, Sankey, Lanes] with sizes [40%, 30%, 30%]
When the user reloads the page
Then the canvas restores those three panes in the same order with the same sizes (within 1% tolerance)
```

### AC-2: Add by Shift+click

```gherkin
Given the canvas currently has one pane (Force)
When the user holds Shift and clicks the "Sankey" view-toggle button
Then the canvas now has two panes (Force, Sankey) and Force does not lose its scroll/zoom state if its instance is preserved
```

### AC-3: Drop-zone highlight + autolayout

```gherkin
Given the canvas has [Force, Lanes]
When the user drags the "Tree" toggle chip over the canvas
Then the system displays a highlighted region showing where the new pane will land (e.g. right edge of Lanes → new column)
And on drop the layout becomes [Force, Lanes, Tree] with sizes auto-redistributed
```

### AC-4: Swap by drag

```gherkin
Given the canvas has [Force, Sankey]
When the user drags the Force pane header onto the Sankey pane body
Then the layout becomes [Sankey, Force]; sizes are unchanged
```

## Functional Requirements

### Related Artifacts

| Artifact | Relation | Status |
|----------|----------|--------|
| RFC-015 | Architecture proposal — split-tree model + drag overlay | Draft |

## Affected Files

- `template/src/widgets/dependency-graph/ui/DependencyGraph.svelte` — unchanged surface (graph already self-contained)
- `template/src/widgets/mosaic/**` — NEW widget (FSD: widgets/mosaic)
- `template/src/pages/home/ui/HomePage.svelte` — replace single-canvas with `<MosaicCanvas>`
- `template/src/pages/home/lib/settings.ts` — add layout key
- `template/src/widgets/mosaic/lib/*.test.ts` — vitest unit tests for tree ops

Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
---
depth: standard
id: RFC-015
kind: rfc
last_modified_at: 2026-05-06T20:58:57.433107+00:00
last_modified_by: claude-code/2.1.131
links:
- target: PRD-016
relation: based_on
status: draft
title: Mosaic split-tree layout engine for multi-graph canvas
---

# RFC-015: Mosaic split-tree layout engine for multi-graph canvas

## Summary

Replace the single-canvas-with-view-toggle in `pages/home` with a recursive **split-tree** mosaic that hosts 1–4 `DependencyGraph` panes. The tree is a binary structure: leaves are `{ kind: 'pane', view: GraphView }`, internal nodes are `{ kind: 'split', orientation: 'row'|'col', sizes: [a, 100-a], children: [Node, Node] }`. The same shape is what we serialize to localStorage. Dragging onto a leaf's edge subdivides it; dragging onto its centre swaps; Shift+click on a view-toggle adds a leaf to the right of the right-most leaf. CSS Grid renders each split natively (`grid-template-{rows,columns}: <a>% <b>%`), so the only JS at render time is recursive rendering + a pointer-driven splitter.

## Motivation

PRD-016 captures the user need: simultaneous multi-view comparison + persisted layout + drag-to-add/swap. The choice of layout model is the load-bearing decision because it dictates:
- the persistence schema (we cannot retrofit a flat-row model into a nested mosaic later without breaking saved layouts),
- the drag-and-drop semantics (a flat row only supports "insert" and "reorder", not "stack vertically"),
- complexity budget (≈ 600 lines of TS+Svelte vs ≈ 2 000 lines for general grid).

## Options Considered

### Option A — Flat row of panes (1 × N)

Single horizontal row. Sizes = `number[]` summing to 100. Splitters between adjacent panes.

- ✅ Trivial schema (`{ panes: GraphView[], sizes: number[] }`).
- ✅ ~150 lines of code.
- ❌ Cannot satisfy FR-001 in 2D (user expects "сетка" — grid). Rejected by user wording.
- ❌ No vertical splits — caps the comparison usefulness ("Force above Sankey" is a common pairing).

### Option B — Recursive binary split-tree (mosaic) — **CHOSEN**

Binary tree of horizontal/vertical splits. Each split has `[a%, (100-a)%]`. Leaves are panes.

- ✅ Naturally supports both row and column splits, arbitrarily nested.
- ✅ Drop semantics fall out for free: edge of a leaf → split that leaf in that direction; centre → swap views.
- ✅ Industry-validated (`react-mosaic`, VS Code editor groups, GoldenLayout core).
- ✅ Render path is `grid-template-{rows,columns}: <a>% <b>%` — zero layout math at runtime.
- ❌ Schema and tree ops (insert, remove, swap, normalise) are non-trivial; need unit tests.
- ❌ Pure binary tree can produce visually unbalanced 3-pane layouts; we mitigate with autolayout (rebalance to equal split when adding).

### Option C — `react-grid-layout`-style 12-column grid with row spans

Each pane has `{ x, y, w, h }` on a fixed 12 × N grid. Drag = move; resize = corner handles.

- ✅ Most flexible UX (true 2D rearrange).
- ❌ ~10× the code (collision detection, compaction algorithm, drag projection).
- ❌ Overkill for max 4 panes (PRD cap).
- ❌ Pixel-based grid units degrade on window resize unless we layer percentages on top — at which point we re-derived B.

### Option D — Use external dep (`svelte-splitpanes`, `svelte-mosaic`)

- ✅ Done-for-us.
- ❌ Adds a runtime dep to `template/package.json#dependencies`. The published `dist/` already inflates with each dep (rule 21). RFC-013 is actively trying to **shrink** the bundle. Buying flexibility we don't need at the cost of ~50–200 KB of JS.
- ❌ None of the candidates ship Svelte 5 runes — would need a wrapper.

## Proposed Direction

**Option B** — recursive binary split-tree, hand-rolled in Svelte 5.

### Data model

```ts
// widgets/mosaic/model/types.ts
export type Orientation = "row" | "col";
export type LeafId = string; // e.g. "leaf-3"

export type Leaf = { kind: "leaf"; id: LeafId; view: GraphView };
export type Split = {
kind: "split";
orientation: Orientation;
sizes: [number, number]; // [a, 100-a], rounded to 0.1%
children: [Node, Node];
};
export type Node = Leaf | Split;
export type Layout = { root: Node | null; nextId: number };
```

### Tree operations (pure, testable)

- `addLeaf(layout, view, target?, edge?)` — splits `target` along `edge` (top/bottom/left/right) or grows the rightmost split if no target.
- `removeLeaf(layout, leafId)` — deletes leaf; if its sibling was the only other child, replace the parent split with that sibling (collapse degenerate splits).
- `swapViews(layout, aId, bId)` — swaps `view` between two leaves; sizes unchanged.
- `setSplitSize(layout, splitPath, newA)` — clamps to [10, 90] %.
- `normalise(layout)` — ensures sizes sum to exactly 100, ids are unique, no orphan splits.
- `countLeaves(layout)` — for the FR-001 cap of 4.

### Rendering

`MosaicCanvas.svelte` renders the root recursively. Each `Split` becomes:

```svelte
<div class="split" data-orientation={node.orientation}
style:grid-template-{node.orientation === 'row' ? 'columns' : 'rows'}={`${a}% ${b}%`}>
<PaneOrSplit node={node.children[0]} />
<Splitter on:resize={handleResize(path)} />
<PaneOrSplit node={node.children[1]} />
</div>
```

Each `Leaf` renders `<PaneFrame><DependencyGraph view={leaf.view} ...sharedProps /></PaneFrame>` where `PaneFrame` provides:
- header with view label, change-view dropdown, close-x;
- a `data-leaf-id` attribute and `draggable`-style header for swap drags.

### Drag-and-drop

We use **HTML5 drag-and-drop** (not pointer events) for two reasons: (1) it gives us a native ghost image and Esc-to-cancel for free; (2) the existing splitter uses pointer events, so the two systems don't fight over capture.

- DataTransfer payload: `{ type: 'add', view }` (from view-toggle) or `{ type: 'swap', leafId }` (from pane header).
- During `dragover`, the canvas computes the hovered leaf and which quadrant (top/bottom/left/right/centre) the cursor is in. A single `<DropOverlay>` element absolutely positioned over the hovered leaf paints the highlight.
- On `drop`, we call `addLeaf` or `swapViews` and persist.
- Esc/cancel restores nothing because we never mutate during dragover.

### Persistence

Add a separate localStorage key — **do not overload** the existing `forgeplan-web:settings:v1` blob. Reason: layouts churn faster, and a corrupt layout shouldn't blow away unrelated settings (filters, notify).

```ts
// pages/home/lib/settings.ts (extended)
const LAYOUT_KEY = "forgeplan-web:layout:v1";
export function loadLayout(): Layout | null { /* try/catch JSON */ }
export function saveLayout(layout: Layout): void { /* try/catch quota */ }
```

`v1` schema is committed; future migrations bump to `v2`. On unparseable JSON the user gets the default single-pane (`{ root: { kind: 'leaf', id: 'leaf-1', view: 'force' }, nextId: 2 }`).

### Defaults & migration

- First load (no `forgeplan-web:layout:v1` key): single-pane, view = `force` (or whatever the legacy `:settings:v1#view` says — read once, then we own layout).
- Cap of 4 panes (FR-001) — Shift+click on a view-toggle when 4 are open is a no-op + brief toast / aria-live message.

## Implementation Phases

1. **P1 — model + persistence (no UI changes).** Land `widgets/mosaic/model/` with full vitest coverage. ~150 LOC + ~100 LOC tests.
2. **P2 — render path.** `MosaicCanvas.svelte` + `PaneFrame.svelte` + `Splitter.svelte`, single-pane default. Wire into `HomePage.svelte` behind the existing single-canvas behaviour. ~300 LOC.
3. **P3 — drag & drop.** Shift+click adds; pane header drag swaps; toggle-chip drag adds. `DropOverlay.svelte` for highlight. ~200 LOC.
4. **P4 — autolayout + edge cases.** Rebalance on add, normalise on remove, 4-pane cap with toast, keyboard splitter resize. ~100 LOC.

After P2 the feature is shippable with degraded UX (no DnD) — explicit gate so we can land in two PRs if review timing demands.

## Alternatives We Explicitly Rejected

- **Option A (flat row)** — fails the "сетка" requirement; user wording is unambiguous.
- **Option C (12-col grid)** — overkill for ≤ 4 panes; complexity not justified.
- **Option D (external dep)** — violates RFC-013 bundle-size pressure; ROI negative for ≤ 4 panes.

## Open Questions

- Should swap include sizes (drag pane A on B → A takes B's slot but **also** B's size)? — yes; PRD AC-4 says sizes unchanged → we swap views, not slots.
- Should the close-x on the last pane be hidden (always ≥ 1 pane) or do we allow zero panes with an empty-state CTA? — hidden when leaves == 1; matches FR-010 "reverts to single-pane defaults".


Loading
Loading