diff --git a/.forgeplan/evidence/EVID-020-mosaic-layout-engine-unit-tests-svelte-check-pass.md b/.forgeplan/evidence/EVID-020-mosaic-layout-engine-unit-tests-svelte-check-pass.md new file mode 100644 index 0000000..eff498e --- /dev/null +++ b/.forgeplan/evidence/EVID-020-mosaic-layout-engine-unit-tests-svelte-check-pass.md @@ -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. + + + diff --git a/.forgeplan/prds/PRD-016-multi-graph-mosaic-dashboard-with-persistent-layout.md b/.forgeplan/prds/PRD-016-multi-graph-mosaic-dashboard-with-persistent-layout.md new file mode 100644 index 0000000..e5c02fd --- /dev/null +++ b/.forgeplan/prds/PRD-016-multi-graph-mosaic-dashboard-with-persistent-layout.md @@ -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 `` +- `template/src/pages/home/lib/settings.ts` — add layout key +- `template/src/widgets/mosaic/lib/*.test.ts` — vitest unit tests for tree ops + diff --git a/.forgeplan/rfcs/RFC-015-mosaic-split-tree-layout-engine-for-multi-graph-canvas.md b/.forgeplan/rfcs/RFC-015-mosaic-split-tree-layout-engine-for-multi-graph-canvas.md new file mode 100644 index 0000000..7bac12d --- /dev/null +++ b/.forgeplan/rfcs/RFC-015-mosaic-split-tree-layout-engine-for-multi-graph-canvas.md @@ -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}: % %`), 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}: % %` — 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 +
+ + + +
+``` + +Each `Leaf` renders `` 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 `` 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". + + diff --git a/template/src/pages/home/ui/HomePage.svelte b/template/src/pages/home/ui/HomePage.svelte index 184a1d5..fa9c4e1 100644 --- a/template/src/pages/home/ui/HomePage.svelte +++ b/template/src/pages/home/ui/HomePage.svelte @@ -21,15 +21,27 @@ import { ArtifactPanel } from '@/widgets/artifact-panel'; import { InsightsRail } from '@/widgets/insights-rail'; import { VersionFooter } from '@/widgets/version-footer'; - import { GRAPH_VIEWS, type GraphView, type InsightTab } from '@/shared/config'; + import { + MosaicCanvas, + changeView, + leaves, + loadLayout, + saveLayout, + singletonLayout, + type Layout + } from '@/widgets/mosaic'; + import { type GraphView, type InsightTab } from '@/shared/config'; import { loadSettings, saveSettings } from '../lib/settings'; let view = $state('force'); + let layout = $state(singletonLayout('force')); + let layoutHydrated = $state(false); let kindFilter = $state(new Set()); let statusFilter = $state(new Set()); let activeTab = $state('agents'); let selectedId = $state(null); - let graphRef = $state<{ resetZoom: () => void } | undefined>(); + type GraphRef = { resetZoom: () => void }; + let graphRefs = $state>({}); let settingsHydrated = $state(false); let notifyEnabled = $state(false); let liveText = $state(''); @@ -54,10 +66,6 @@ const scores = $derived(scorePoller.state.data ?? []); const globalError = $derived(listPoller.state.error ?? graphPoller.state.error ?? null); - function setView(next: GraphView) { - view = next; - } - function selectNode(detail: { id: string }) { selectedId = detail.id; } @@ -70,8 +78,8 @@ selectedId = detail.id; } - function reset() { - graphRef?.resetZoom(); + function resetZoomFor(leafId: string) { + graphRefs[leafId]?.resetZoom(); } $effect(() => { @@ -82,6 +90,8 @@ activeTab = initial.activeTab; notifyEnabled = initial.notify; settingsHydrated = true; + layout = loadLayout(initial.view); + layoutHydrated = true; listPoller.start(); graphPoller.start(); @@ -117,6 +127,13 @@ return () => clearTimeout(timer); }); + $effect(() => { + if (!layoutHydrated) return; + const snapshot = layout; + const timer = setTimeout(() => saveLayout(snapshot), 250); + return () => clearTimeout(timer); + }); + $effect(() => { const health = healthPoller.state.data; if (!health) return; @@ -239,40 +256,23 @@
{nodes.length} ARTIFACTS · {edges.length} EDGES -
-
- {#each GRAPH_VIEWS as v (v.id)} - - {/each} -
- -
-
-
- {GRAPH_VIEWS.find((v) => v.id === view)?.hint ?? ''}
- selectNode(detail)} - /> + + {#snippet leafSnippet(paneView: GraphView, leafId: string)} + selectNode(detail)} + /> + {/snippet} +
selectNode(detail)} /> @@ -371,61 +371,6 @@ color: var(--fg-3); letter-spacing: 0.04em; } - .canvas-hint { - padding: 4px 14px; - background: var(--bg); - border-bottom: 1px solid var(--line); - font-family: var(--font-mono); - font-size: 11px; - color: var(--fg-3); - letter-spacing: 0.04em; - } - .ghost { - background: transparent; - border: 1px solid var(--line-2); - color: var(--fg-2); - padding: 3px 12px; - cursor: pointer; - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.04em; - transition: border-color 120ms, color 120ms; - } - .ghost:hover { - border-color: var(--accent); - color: var(--accent); - } - .toolbar-right { - display: flex; - align-items: center; - gap: 10px; - } - .view-toggle { - display: inline-flex; - border: 1px solid var(--line-2); - } - .seg { - background: transparent; - border: none; - color: var(--fg-3); - padding: 3px 12px; - cursor: pointer; - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.06em; - text-transform: uppercase; - transition: color 120ms, background 120ms; - } - .seg + .seg { - border-left: 1px solid var(--line-2); - } - .seg:hover { - color: var(--fg-1); - } - .seg.active { - background: var(--accent-dim); - color: var(--accent); - } .canvas-body { flex: 1; min-height: 0; diff --git a/template/src/widgets/mosaic/index.ts b/template/src/widgets/mosaic/index.ts new file mode 100644 index 0000000..c9658ea --- /dev/null +++ b/template/src/widgets/mosaic/index.ts @@ -0,0 +1,15 @@ +export { default as MosaicCanvas } from "./ui/MosaicCanvas.svelte"; +export { loadLayout, saveLayout } from "./lib/persist"; +export { setDragPayload, endDrag, beginDrag } from "./lib/drag"; +export { + addLeaf, + changeView, + countLeaves, + emptyLayout, + leaves, + removeLeaf, + singletonLayout, + swapViews, +} from "./model/tree"; +export { MAX_LEAVES } from "./model/types"; +export type { Layout, MosaicNode, Leaf, Split } from "./model/types"; diff --git a/template/src/widgets/mosaic/lib/drag.test.ts b/template/src/widgets/mosaic/lib/drag.test.ts new file mode 100644 index 0000000..5e54b33 --- /dev/null +++ b/template/src/widgets/mosaic/lib/drag.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { quadrant } from "./drag"; + +function rect(): DOMRect { + return { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; +} + +describe("quadrant", () => { + it("returns center for the middle area", () => { + expect(quadrant(rect(), 50, 50)).toBe("center"); + }); + it("returns left for the leftmost edge", () => { + expect(quadrant(rect(), 5, 50)).toBe("left"); + }); + it("returns right for the rightmost edge", () => { + expect(quadrant(rect(), 95, 50)).toBe("right"); + }); + it("returns top for top edge", () => { + expect(quadrant(rect(), 50, 5)).toBe("top"); + }); + it("returns bottom for bottom edge", () => { + expect(quadrant(rect(), 50, 95)).toBe("bottom"); + }); +}); diff --git a/template/src/widgets/mosaic/lib/drag.ts b/template/src/widgets/mosaic/lib/drag.ts new file mode 100644 index 0000000..1abf985 --- /dev/null +++ b/template/src/widgets/mosaic/lib/drag.ts @@ -0,0 +1,111 @@ +import type { DropEdge } from "../model/types"; + +export type DragPayload = + | { type: "add"; view: string } + | { type: "swap"; leafId: string }; + +const MIME = "application/x-forgeplan-mosaic"; + +// Module-level singleton: dataTransfer.getData() returns "" during dragover +// for security reasons (only readable on drop). We mirror the payload here +// so dragover can know what's being dragged and paint the right overlay. +// Cleared on dragend / drop. +let activeDrag: DragPayload | null = null; + +export function beginDrag(p: DragPayload): void { + activeDrag = p; +} + +export function endDrag(): void { + activeDrag = null; +} + +export function getActiveDrag(): DragPayload | null { + return activeDrag; +} + +export function setDragPayload(dt: DataTransfer, p: DragPayload): void { + // Stash on DataTransfer so cross-window / cross-frame drops still work, + // and stash in module state for same-window dragover (where getData is + // not allowed by the spec). + beginDrag(p); + try { + dt.setData(MIME, JSON.stringify(p)); + } catch { + // Some browsers/test environments block setData on certain MIME types. + } + // text/plain fallback so the drag actually starts in browsers that + // require some non-empty data on the drag. + try { + dt.setData("text/plain", p.type === "add" ? p.view : p.leafId); + } catch { + // FIXME(drag-fallback): if this also throws, drag may not initialise. + } + dt.effectAllowed = "move"; +} + +export function getDragPayload(dt: DataTransfer): DragPayload | null { + const fromModule = getActiveDrag(); + if (fromModule) return fromModule; + const raw = dt.getData(MIME); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as DragPayload; + if (parsed.type === "add" && typeof parsed.view === "string") return parsed; + if (parsed.type === "swap" && typeof parsed.leafId === "string") return parsed; + return null; + } catch { + return null; + } +} + +export function hasDragPayload(dt: DataTransfer): boolean { + if (getActiveDrag()) return true; + return dt.types.includes(MIME); +} + +const EDGE_THRESHOLD = 0.25; + +export function quadrant(rect: DOMRect, x: number, y: number): DropEdge { + const relX = (x - rect.left) / rect.width; + const relY = (y - rect.top) / rect.height; + + if ( + relX > EDGE_THRESHOLD && + relX < 1 - EDGE_THRESHOLD && + relY > EDGE_THRESHOLD && + relY < 1 - EDGE_THRESHOLD + ) { + return "center"; + } + + const dLeft = relX; + const dRight = 1 - relX; + const dTop = relY; + const dBottom = 1 - relY; + const min = Math.min(dLeft, dRight, dTop, dBottom); + if (min === dLeft) return "left"; + if (min === dRight) return "right"; + if (min === dTop) return "top"; + return "bottom"; +} + +export function highlightStyle(edge: DropEdge): { + left: string; + top: string; + width: string; + height: string; +} { + switch (edge) { + case "left": + return { left: "0%", top: "0%", width: "50%", height: "100%" }; + case "right": + return { left: "50%", top: "0%", width: "50%", height: "100%" }; + case "top": + return { left: "0%", top: "0%", width: "100%", height: "50%" }; + case "bottom": + return { left: "0%", top: "50%", width: "100%", height: "50%" }; + case "center": + return { left: "10%", top: "10%", width: "80%", height: "80%" }; + } +} diff --git a/template/src/widgets/mosaic/lib/persist.ts b/template/src/widgets/mosaic/lib/persist.ts new file mode 100644 index 0000000..bbaab96 --- /dev/null +++ b/template/src/widgets/mosaic/lib/persist.ts @@ -0,0 +1,39 @@ +import { browser } from "$app/environment"; +import { GRAPH_VIEW_IDS, type GraphView } from "@/shared/config"; +import { isValidLayout, singletonLayout } from "../model/tree"; +import type { Layout, MosaicNode } from "../model/types"; + +const LAYOUT_KEY = "forgeplan-web:layout:v1"; + +export function loadLayout(fallbackView: GraphView = "force"): Layout { + if (!browser) return singletonLayout(fallbackView); + try { + const raw = localStorage.getItem(LAYOUT_KEY); + if (!raw) return singletonLayout(fallbackView); + const parsed = JSON.parse(raw) as unknown; + if (!isValidLayout(parsed)) return singletonLayout(fallbackView); + if (parsed.root && !allViewsKnown(parsed.root)) { + // FIXME(layout-migration): a saved view id was removed from GRAPH_VIEWS; + // safest fallback is to drop the layout rather than render a broken pane. + return singletonLayout(fallbackView); + } + return parsed; + } catch { + // TODO(persisted-layout): corrupt JSON — silent fallback. + return singletonLayout(fallbackView); + } +} + +export function saveLayout(layout: Layout): void { + if (!browser) return; + try { + localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout)); + } catch { + // TODO(persisted-layout): quota exceeded or storage disabled. + } +} + +function allViewsKnown(node: MosaicNode): boolean { + if (node.kind === "leaf") return GRAPH_VIEW_IDS.has(node.view); + return allViewsKnown(node.children[0]) && allViewsKnown(node.children[1]); +} diff --git a/template/src/widgets/mosaic/model/tree.test.ts b/template/src/widgets/mosaic/model/tree.test.ts new file mode 100644 index 0000000..9576dcb --- /dev/null +++ b/template/src/widgets/mosaic/model/tree.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { + addLeaf, + changeView, + clampSplit, + countLeaves, + emptyLayout, + findLeaf, + isValidLayout, + leaves, + removeLeaf, + setSplitSize, + singletonLayout, + swapViews, +} from "./tree"; +import type { Layout, Split } from "./types"; + +describe("singletonLayout", () => { + it("creates a single leaf with view", () => { + const l = singletonLayout("force"); + expect(l.root?.kind).toBe("leaf"); + if (l.root?.kind === "leaf") expect(l.root.view).toBe("force"); + expect(l.nextId).toBe(2); + }); +}); + +describe("countLeaves", () => { + it("returns 0 for empty layout", () => { + expect(countLeaves(emptyLayout().root)).toBe(0); + }); + it("counts nested leaves", () => { + const l = addLeaf(addLeaf(singletonLayout("force"), "tree"), "lanes"); + expect(countLeaves(l.root)).toBe(3); + }); +}); + +describe("addLeaf without target", () => { + it("appends to a horizontal split with autolayout share", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + expect(l.root?.kind).toBe("split"); + expect(countLeaves(l.root)).toBe(2); + if (l.root?.kind === "split") { + const sum = l.root.sizes[0] + l.root.sizes[1]; + expect(Math.abs(sum - 100)).toBeLessThan(0.5); + expect(l.root.orientation).toBe("row"); + } + }); + + it("respects MAX_LEAVES = 4", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + l = addLeaf(l, "lanes"); + l = addLeaf(l, "matrix"); + expect(countLeaves(l.root)).toBe(4); + const blocked = addLeaf(l, "sankey"); + expect(countLeaves(blocked.root)).toBe(4); + }); +}); + +describe("addLeaf with target edge", () => { + it("splits target horizontally on right edge", () => { + let l = singletonLayout("force"); + const targetId = (l.root as { id: string }).id; + l = addLeaf(l, "tree", { leafId: targetId, edge: "right" }); + expect(l.root?.kind).toBe("split"); + if (l.root?.kind === "split") { + expect(l.root.orientation).toBe("row"); + expect((l.root.children[0] as { view: string }).view).toBe("force"); + expect((l.root.children[1] as { view: string }).view).toBe("tree"); + } + }); + + it("splits target vertically on top edge — new leaf becomes first child", () => { + let l = singletonLayout("force"); + const id = (l.root as { id: string }).id; + l = addLeaf(l, "tree", { leafId: id, edge: "top" }); + expect(l.root?.kind).toBe("split"); + if (l.root?.kind === "split") { + expect(l.root.orientation).toBe("col"); + expect((l.root.children[0] as { view: string }).view).toBe("tree"); + expect((l.root.children[1] as { view: string }).view).toBe("force"); + } + }); + + it("does nothing if target leaf is missing", () => { + const l = singletonLayout("force"); + const next = addLeaf(l, "tree", { leafId: "missing", edge: "right" }); + expect(countLeaves(next.root)).toBe(1); + }); +}); + +describe("removeLeaf", () => { + it("collapses degenerate split when sibling remains alone", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + const treeLeaf = leaves(l.root).find((x) => x.view === "tree"); + expect(treeLeaf).toBeDefined(); + l = removeLeaf(l, treeLeaf?.id ?? ""); + expect(l.root?.kind).toBe("leaf"); + if (l.root?.kind === "leaf") expect(l.root.view).toBe("force"); + }); + + it("returns empty when removing the only leaf", () => { + let l = singletonLayout("force"); + const id = (l.root as { id: string }).id; + l = removeLeaf(l, id); + expect(l.root).toBeNull(); + }); + + it("preserves remaining structure on three-leaf removal", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + l = addLeaf(l, "lanes"); + expect(countLeaves(l.root)).toBe(3); + const lanesLeaf = leaves(l.root).find((x) => x.view === "lanes"); + expect(lanesLeaf).toBeDefined(); + l = removeLeaf(l, lanesLeaf?.id ?? ""); + expect(countLeaves(l.root)).toBe(2); + }); +}); + +describe("swapViews", () => { + it("swaps view between two leaves; sizes unchanged", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + const ids = leaves(l.root).map((x) => x.id); + expect(ids.length).toBe(2); + const sizesBefore = (l.root as Split).sizes.slice(); + l = swapViews(l, ids[0]!, ids[1]!); + const after = leaves(l.root).map((x) => x.view); + expect(after).toEqual(["tree", "force"]); + expect((l.root as Split).sizes).toEqual(sizesBefore); + }); + + it("noop when same id", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + const ids = leaves(l.root).map((x) => x.id); + const before = JSON.stringify(l); + l = swapViews(l, ids[0]!, ids[0]!); + expect(JSON.stringify(l)).toBe(before); + }); +}); + +describe("setSplitSize", () => { + it("clamps to [10, 90]", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + l = setSplitSize(l, [], 5); + if (l.root?.kind === "split") expect(l.root.sizes[0]).toBe(10); + l = setSplitSize(l, [], 95); + if (l.root?.kind === "split") expect(l.root.sizes[0]).toBe(90); + }); + + it("ensures sum stays at 100", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + l = setSplitSize(l, [], 33.3); + if (l.root?.kind === "split") { + expect(Math.abs(l.root.sizes[0] + l.root.sizes[1] - 100)).toBeLessThan(0.5); + } + }); +}); + +describe("changeView", () => { + it("updates only the targeted leaf", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + const id = leaves(l.root)[0]?.id ?? ""; + l = changeView(l, id, "sankey"); + const views = leaves(l.root).map((x) => x.view); + expect(views).toContain("sankey"); + expect(views).toContain("tree"); + }); +}); + +describe("clampSplit", () => { + it("returns 50 for non-finite", () => { + expect(clampSplit(NaN)).toBe(50); + expect(clampSplit(Infinity)).toBe(50); + }); +}); + +describe("isValidLayout", () => { + it("accepts well-formed layouts", () => { + const l = addLeaf(singletonLayout("force"), "tree"); + expect(isValidLayout(l)).toBe(true); + }); + it("rejects malformed", () => { + expect(isValidLayout(null)).toBe(false); + expect(isValidLayout({ root: { kind: "weird" }, nextId: 1 })).toBe(false); + const bad: Layout = { + root: { + kind: "split", + orientation: "row", + sizes: [60, 50], + children: [ + { kind: "leaf", id: "a", view: "force" }, + { kind: "leaf", id: "b", view: "tree" }, + ], + }, + nextId: 3, + }; + expect(isValidLayout(bad)).toBe(false); + }); +}); + +describe("findLeaf", () => { + it("finds nested leaf by id", () => { + let l = singletonLayout("force"); + l = addLeaf(l, "tree"); + const id = leaves(l.root)[1]?.id ?? ""; + const found = findLeaf(l.root, id); + expect(found?.kind).toBe("leaf"); + }); +}); diff --git a/template/src/widgets/mosaic/model/tree.ts b/template/src/widgets/mosaic/model/tree.ts new file mode 100644 index 0000000..bd900a8 --- /dev/null +++ b/template/src/widgets/mosaic/model/tree.ts @@ -0,0 +1,264 @@ +import type { GraphView } from "@/shared/config"; +import { + MAX_LEAVES, + MAX_SPLIT_PCT, + MIN_SPLIT_PCT, + type DropEdge, + type Layout, + type Leaf, + type MosaicNode, + type Split, +} from "./types"; + +export function emptyLayout(): Layout { + return { root: null, nextId: 1 }; +} + +export function singletonLayout(view: GraphView): Layout { + return { root: { kind: "leaf", id: "leaf-1", view }, nextId: 2 }; +} + +export function countLeaves(node: MosaicNode | null): number { + if (!node) return 0; + if (node.kind === "leaf") return 1; + return countLeaves(node.children[0]) + countLeaves(node.children[1]); +} + +export function leaves(node: MosaicNode | null): Leaf[] { + if (!node) return []; + if (node.kind === "leaf") return [node]; + return [...leaves(node.children[0]), ...leaves(node.children[1])]; +} + +export function findLeaf(node: MosaicNode | null, id: string): Leaf | null { + if (!node) return null; + if (node.kind === "leaf") return node.id === id ? node : null; + return findLeaf(node.children[0], id) ?? findLeaf(node.children[1], id); +} + +function mintLeafId(layout: Layout): { id: string; nextId: number } { + return { id: `leaf-${layout.nextId}`, nextId: layout.nextId + 1 }; +} + +function makeSplitForEdge( + existing: MosaicNode, + newLeaf: Leaf, + edge: DropEdge, +): Split { + // edge is the side of `existing` where the new leaf lands. + const isHoriz = edge === "left" || edge === "right"; + const orientation = isHoriz ? "row" : "col"; + const newFirst = edge === "left" || edge === "top"; + const children: [MosaicNode, MosaicNode] = newFirst + ? [newLeaf, existing] + : [existing, newLeaf]; + return { kind: "split", orientation, sizes: [50, 50], children }; +} + +function replaceNode( + root: MosaicNode, + targetId: string, + replacement: MosaicNode, +): MosaicNode { + if (root.kind === "leaf") { + return root.id === targetId ? replacement : root; + } + return { + ...root, + children: [ + replaceNode(root.children[0], targetId, replacement), + replaceNode(root.children[1], targetId, replacement), + ], + }; +} + +export function addLeaf( + layout: Layout, + view: GraphView, + target?: { leafId: string; edge: DropEdge }, +): Layout { + if (countLeaves(layout.root) >= MAX_LEAVES) return layout; + const { id, nextId } = mintLeafId(layout); + const newLeaf: Leaf = { kind: "leaf", id, view }; + + if (!layout.root) { + return { root: newLeaf, nextId }; + } + + if (target) { + const found = findLeaf(layout.root, target.leafId); + if (!found) return layout; + if (target.edge === "center") { + // Replace the leaf's view (rare; treat as add-by-replace) — we + // don't get here from drag because center = swap, not add. + const replaced: Leaf = { ...found, view }; + return { + root: replaceNode(layout.root, target.leafId, replaced), + nextId: layout.nextId, + }; + } + const split = makeSplitForEdge(found, newLeaf, target.edge); + return { + root: replaceNode(layout.root, target.leafId, split), + nextId, + }; + } + + // Default: append to the right of the right-most leaf, splitting + // the root into a horizontal split with autolayout (1/N share). + const totalLeaves = countLeaves(layout.root) + 1; + const newShare = Math.round((100 / totalLeaves) * 10) / 10; + const split: Split = { + kind: "split", + orientation: "row", + sizes: [round1(100 - newShare), newShare], + children: [layout.root, newLeaf], + }; + return { root: split, nextId }; +} + +export function removeLeaf(layout: Layout, leafId: string): Layout { + if (!layout.root) return layout; + if (layout.root.kind === "leaf") { + return layout.root.id === leafId + ? { root: null, nextId: layout.nextId } + : layout; + } + const next = stripLeaf(layout.root, leafId); + return { root: next, nextId: layout.nextId }; +} + +function stripLeaf(node: MosaicNode, leafId: string): MosaicNode | null { + if (node.kind === "leaf") return node.id === leafId ? null : node; + const left = stripLeaf(node.children[0], leafId); + const right = stripLeaf(node.children[1], leafId); + if (left === null && right === null) return null; + if (left === null) return right; + if (right === null) return left; + return { ...node, children: [left, right] }; +} + +export function swapViews(layout: Layout, aId: string, bId: string): Layout { + if (aId === bId || !layout.root) return layout; + const a = findLeaf(layout.root, aId); + const b = findLeaf(layout.root, bId); + if (!a || !b) return layout; + const root = swapInTree(layout.root, aId, bId, a.view, b.view); + return { root, nextId: layout.nextId }; +} + +function swapInTree( + node: MosaicNode, + aId: string, + bId: string, + aView: GraphView, + bView: GraphView, +): MosaicNode { + if (node.kind === "leaf") { + if (node.id === aId) return { ...node, view: bView }; + if (node.id === bId) return { ...node, view: aView }; + return node; + } + return { + ...node, + children: [ + swapInTree(node.children[0], aId, bId, aView, bView), + swapInTree(node.children[1], aId, bId, aView, bView), + ], + }; +} + +export function setSplitSize( + layout: Layout, + path: number[], + newFirstPct: number, +): Layout { + if (!layout.root) return layout; + const clamped = clampSplit(newFirstPct); + const next = updateSplitAt(layout.root, path, clamped); + return { root: next, nextId: layout.nextId }; +} + +function updateSplitAt( + node: MosaicNode, + path: number[], + newFirstPct: number, +): MosaicNode { + if (node.kind === "leaf") return node; + if (path.length === 0) { + return { + ...node, + sizes: [round1(newFirstPct), round1(100 - newFirstPct)], + }; + } + const head = path[0]; + if (head !== 0 && head !== 1) return node; + const rest = path.slice(1); + const child = node.children[head]; + const updatedChild = updateSplitAt(child, rest, newFirstPct); + const children: [MosaicNode, MosaicNode] = + head === 0 + ? [updatedChild, node.children[1]] + : [node.children[0], updatedChild]; + return { ...node, children }; +} + +export function changeView( + layout: Layout, + leafId: string, + view: GraphView, +): Layout { + if (!layout.root) return layout; + const root = mapLeaves(layout.root, (l) => + l.id === leafId ? { ...l, view } : l, + ); + return { root, nextId: layout.nextId }; +} + +function mapLeaves(node: MosaicNode, fn: (l: Leaf) => Leaf): MosaicNode { + if (node.kind === "leaf") return fn(node); + return { + ...node, + children: [mapLeaves(node.children[0], fn), mapLeaves(node.children[1], fn)], + }; +} + +export function clampSplit(pct: number): number { + if (!Number.isFinite(pct)) return 50; + if (pct < MIN_SPLIT_PCT) return MIN_SPLIT_PCT; + if (pct > MAX_SPLIT_PCT) return MAX_SPLIT_PCT; + return round1(pct); +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +export function isValidLayout(layout: unknown): layout is Layout { + if (!layout || typeof layout !== "object") return false; + const l = layout as Partial; + if (typeof l.nextId !== "number" || l.nextId < 1) return false; + if (l.root === null) return true; + return isValidNode(l.root); +} + +function isValidNode(node: unknown): node is MosaicNode { + if (!node || typeof node !== "object") return false; + const n = node as { kind?: string }; + if (n.kind === "leaf") { + const leaf = node as Partial; + return typeof leaf.id === "string" && typeof leaf.view === "string"; + } + if (n.kind === "split") { + const s = node as Partial; + if (s.orientation !== "row" && s.orientation !== "col") return false; + if (!Array.isArray(s.sizes) || s.sizes.length !== 2) return false; + if (!s.sizes.every((v) => typeof v === "number" && Number.isFinite(v))) { + return false; + } + if (Math.abs(s.sizes[0] + s.sizes[1] - 100) > 0.5) return false; + if (!Array.isArray(s.children) || s.children.length !== 2) return false; + return isValidNode(s.children[0]) && isValidNode(s.children[1]); + } + return false; +} diff --git a/template/src/widgets/mosaic/model/types.ts b/template/src/widgets/mosaic/model/types.ts new file mode 100644 index 0000000..1992717 --- /dev/null +++ b/template/src/widgets/mosaic/model/types.ts @@ -0,0 +1,28 @@ +import type { GraphView } from "@/shared/config"; + +export type Orientation = "row" | "col"; +export type DropEdge = "left" | "right" | "top" | "bottom" | "center"; + +export interface Leaf { + kind: "leaf"; + id: string; + view: GraphView; +} + +export interface Split { + kind: "split"; + orientation: Orientation; + sizes: [number, number]; + children: [MosaicNode, MosaicNode]; +} + +export type MosaicNode = Leaf | Split; + +export interface Layout { + root: MosaicNode | null; + nextId: number; +} + +export const MAX_LEAVES = 4; +export const MIN_SPLIT_PCT = 10; +export const MAX_SPLIT_PCT = 90; diff --git a/template/src/widgets/mosaic/ui/DropOverlay.svelte b/template/src/widgets/mosaic/ui/DropOverlay.svelte new file mode 100644 index 0000000..aa26777 --- /dev/null +++ b/template/src/widgets/mosaic/ui/DropOverlay.svelte @@ -0,0 +1,58 @@ + + +
+ {label} +
+ + diff --git a/template/src/widgets/mosaic/ui/MosaicCanvas.svelte b/template/src/widgets/mosaic/ui/MosaicCanvas.svelte new file mode 100644 index 0000000..0b65188 --- /dev/null +++ b/template/src/widgets/mosaic/ui/MosaicCanvas.svelte @@ -0,0 +1,257 @@ + + + +
+ {#if !layout.root} +
No panes — pick a graph view from the toolbar.
+ {:else} + {#snippet leafContent(leaf: Leaf)} +
+ 1} + canAdd={total < MAX_LEAVES} + onChangeView={(v) => onChangeView(leaf.id, v)} + onClose={() => onClose(leaf.id)} + onAdd={onAddPane} + onResetZoom={onResetZoom ? () => onResetZoom(leaf.id) : undefined} + > + {@render leafSnippet(leaf.view, leaf.id)} + + {#if dropTarget?.leafId === leaf.id} + + {/if} +
+ {/snippet} + + {/if} +
+ + diff --git a/template/src/widgets/mosaic/ui/MosaicNodeView.svelte b/template/src/widgets/mosaic/ui/MosaicNodeView.svelte new file mode 100644 index 0000000..31b6cc2 --- /dev/null +++ b/template/src/widgets/mosaic/ui/MosaicNodeView.svelte @@ -0,0 +1,80 @@ + + +{#if node.kind === "leaf"} + {@render leafSnippet(node)} +{:else if node.orientation === "row"} +
+
+ +
+ onResize(path, p)} + /> +
+ +
+
+{:else} +
+
+ +
+ onResize(path, p)} + /> +
+ +
+
+{/if} + + diff --git a/template/src/widgets/mosaic/ui/PaneFrame.svelte b/template/src/widgets/mosaic/ui/PaneFrame.svelte new file mode 100644 index 0000000..0c07cfa --- /dev/null +++ b/template/src/widgets/mosaic/ui/PaneFrame.svelte @@ -0,0 +1,185 @@ + + +
+ + +
+ {@render children()} +
+
+ + diff --git a/template/src/widgets/mosaic/ui/Splitter.svelte b/template/src/widgets/mosaic/ui/Splitter.svelte new file mode 100644 index 0000000..b270861 --- /dev/null +++ b/template/src/widgets/mosaic/ui/Splitter.svelte @@ -0,0 +1,106 @@ + + + + + + +