summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/layout/ui')
-rw-r--r--src/features/layout/ui/FallbackTile.svelte13
-rw-r--r--src/features/layout/ui/TileLeaf.svelte44
-rw-r--r--src/features/layout/ui/TileNode.svelte37
-rw-r--r--src/features/layout/ui/TilePanels.svelte185
-rw-r--r--src/features/layout/ui/TileRoot.svelte39
-rw-r--r--src/features/layout/ui/TileRoot.test.ts394
-rw-r--r--src/features/layout/ui/TileSplit.svelte130
-rw-r--r--src/features/layout/ui/TileStack.svelte42
-rw-r--r--src/features/layout/ui/fixtures/FixtureA.svelte6
-rw-r--r--src/features/layout/ui/fixtures/FixtureB.svelte6
-rw-r--r--src/features/layout/ui/fixtures/FixtureC.svelte6
-rw-r--r--src/features/layout/ui/panel-motion.ts83
12 files changed, 985 insertions, 0 deletions
diff --git a/src/features/layout/ui/FallbackTile.svelte b/src/features/layout/ui/FallbackTile.svelte
new file mode 100644
index 0000000..8fafa7f
--- /dev/null
+++ b/src/features/layout/ui/FallbackTile.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ /**
+ * Shown when a leaf names a view this client has not registered — the tree
+ * is data and can arrive from a backend (plan 02) that knows views this
+ * build lacks. A titled placeholder, never a crash.
+ */
+ let { viewId }: { viewId: string } = $props();
+</script>
+
+<div class="flex flex-col gap-1 rounded-lg bg-base-200 p-3" data-testid="fallback-tile">
+ <h3 class="text-xs font-semibold uppercase opacity-60">{viewId}</h3>
+ <p class="text-sm opacity-60">View not registered.</p>
+</div>
diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte
new file mode 100644
index 0000000..0fd6430
--- /dev/null
+++ b/src/features/layout/ui/TileLeaf.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+ /**
+ * Mounts a registered view for a leaf node. Resolution goes through the
+ * REACTIVE registry, so a late registration (plan 03) swaps the tile in
+ * without a tree change. An unknown view id renders a fallback tile —
+ * NEVER a crash (backend-owned trees can name views this client lacks).
+ *
+ * A diagnostic `marker` on the leaf draws an outline around it — the only
+ * node kind without a natural tile wrapper, so the wrapper exists only
+ * when marked (unmarked leaves render exactly as before).
+ */
+ import type { TileMarker } from "@dispatch/layout-contract";
+ import { viewRegistry, type RegisteredView } from "../logic/registry";
+ import { MARKER_OUTLINE_OFFSET, markerOutline } from "../logic/marker";
+ import FallbackTile from "./FallbackTile.svelte";
+
+ let {
+ node,
+ ports,
+ }: {
+ node: { readonly view: string; readonly marker?: TileMarker };
+ ports?: unknown;
+ } = $props();
+
+ const entry: RegisteredView | undefined = $derived(
+ $viewRegistry.find((v) => v.id === node.view),
+ );
+ const View = $derived(entry?.component);
+ const outline = $derived(markerOutline(node.marker));
+</script>
+
+{#snippet content()}
+ {#if View}
+ <View {ports} />
+ {:else}
+ <FallbackTile viewId={node.view} />
+ {/if}
+{/snippet}
+
+{#if outline !== undefined}
+ <div style:outline={outline} style:outline-offset={MARKER_OUTLINE_OFFSET}>{@render content()}</div>
+{:else}
+ {@render content()}
+{/if}
diff --git a/src/features/layout/ui/TileNode.svelte b/src/features/layout/ui/TileNode.svelte
new file mode 100644
index 0000000..5ed8a89
--- /dev/null
+++ b/src/features/layout/ui/TileNode.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ /**
+ * The recursive tile dispatcher: renders the node for its `kind` and recurses
+ * through the container components. Leaf nodes resolve through the view
+ * registry (TileLeaf).
+ */
+ import type { LayoutNode, LayoutOp, Path } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileLeaf from "./TileLeaf.svelte";
+ import TilePanels from "./TilePanels.svelte";
+ import TileSplit from "./TileSplit.svelte";
+ import TileStack from "./TileStack.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: LayoutNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+</script>
+
+{#if node.kind === "split"}
+ <TileSplit {node} {path} {ports} {collapse} {onEdit} />
+{:else if node.kind === "stack"}
+ <TileStack {node} {path} {ports} {collapse} {onEdit} />
+{:else if node.kind === "panels"}
+ <TilePanels {node} {path} {ports} {onEdit} />
+{:else if node.kind === "leaf"}
+ <TileLeaf {node} {ports} />
+{/if}
diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte
new file mode 100644
index 0000000..cc5fa89
--- /dev/null
+++ b/src/features/layout/ui/TilePanels.svelte
@@ -0,0 +1,185 @@
+<script lang="ts">
+ /**
+ * A scrolling stack of swappable panels — the generalized ViewSidebar. The
+ * panel list comes from the tree node; dropdown options from the (reactive)
+ * registry's panel-eligible views. Edits emit tree ops upward via `onEdit`;
+ * the OWNER (App today, the backend store in plan 02) applies them and
+ * decides persistence.
+ */
+ import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract";
+ import PlusIcon from "phosphor-svelte/lib/PlusIcon";
+ import { untrack } from "svelte";
+ import { viewRegistry } from "../logic/registry";
+ import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit";
+ import {
+ initialPanelKeys,
+ type PanelKeys,
+ reconcilePanelKeys,
+ swappedPanels,
+ } from "../logic/panel-keys";
+ import { markerStyle } from "../logic/marker";
+ import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion";
+ import TileLeaf from "./TileLeaf.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ onEdit,
+ renderMarker = false,
+ }: {
+ node: PanelsNode;
+ path: Path;
+ ports?: unknown;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ /**
+ * Whether to draw the node's diagnostic marker on this root. Default NO:
+ * a panels node is the scrolled CONTENT of a split's collapsible child,
+ * so an outline here wraps the full scrollable region and scrolls with
+ * it — the split draws the marker on the aside (the fixed viewport)
+ * instead. Pass `true` only for a panels node used outside a split.
+ */
+ renderMarker?: boolean;
+ } = $props();
+
+ // Panel-eligible views only (decision 5): chat/composer/message-queue never
+ // appear in the dropdowns. Reactive so late registrations re-render options.
+ const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible));
+ const addView = $derived(firstPanelView(panelViews));
+
+ // Stable per-panel keys (pure reconcile, memoised across tree updates) so a
+ // closed panel is the one that animates out, not whichever is last.
+ // A local ✕ click hints WHICH panel left (consumed by the next tree change;
+ // ignored if that change isn't exactly this removal, e.g. a remote edit).
+ let keyMemo: PanelKeys | null = null;
+ let removeHint: number | undefined;
+ const keyed = $derived.by(() => {
+ const views = node.panels.map((p) => p.view);
+ keyMemo =
+ keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint);
+ removeHint = undefined;
+ return keyMemo;
+ });
+
+ function removePanel(idx: number): void {
+ removeHint = idx;
+ onEdit([removePanelOp(path, idx)]);
+ }
+
+ // View SWAP: the panel BOX (the well) animates from the old view's height
+ // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are
+ // the per-panel in:/out: transitions below.)
+ let columnEl = $state<HTMLDivElement | undefined>();
+ /** The keys the DOM currently shows (set after each update). */
+ let shownKeys: PanelKeys | null = null;
+ let before: { keys: PanelKeys; heights: Map<number, number> } | null = null;
+ $effect.pre(() => {
+ const next = keyed;
+ untrack(() => {
+ const heights = new Map<number, number>();
+ for (const el of panelEls()) {
+ const well = wellOf(el);
+ if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight);
+ }
+ before = { keys: shownKeys ?? next, heights };
+ });
+ });
+ $effect(() => {
+ const now = keyed;
+ untrack(() => {
+ const prev = before;
+ before = null;
+ shownKeys = now;
+ if (prev === null) return;
+ for (const key of swappedPanels(prev.keys, now).keys()) {
+ const el = panelEls().find((e) => Number(e.dataset.panelKey) === key);
+ const well = el === undefined ? null : wellOf(el);
+ const from = prev.heights.get(key);
+ if (well !== null && from !== undefined) morphPanelHeight(well, from);
+ }
+ });
+ });
+
+ function wellOf(panel: HTMLElement): HTMLElement | null {
+ return panel.querySelector<HTMLElement>(":scope > [data-panel-well]");
+ }
+
+ function panelEls(): HTMLElement[] {
+ return columnEl === undefined
+ ? []
+ : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")];
+ }
+</script>
+
+<!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its
+ natural height — if it shrank to the scroller (min-h-0 in a flex column),
+ its children overflowed it and the trailing margin was never scrollable. -->
+<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so
+ it grows/shrinks WITH the panel's height animation (a flex gap would jump). -->
+<div
+ bind:this={columnEl}
+ class="flex min-h-0 shrink-0 flex-col"
+ style={renderMarker ? markerStyle(node.marker) : undefined}
+ data-panels
+>
+ {#each node.panels as panel, idx (keyed.keys[idx])}
+ <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap,
+ so panels below slide smoothly); a view swap animates the WELL's
+ height. Either way only a height is tweened (clipped) — the contents
+ keep their natural size. `.inset-well` (app.css): recessed into the
+ sidebar, like the chat's thinking/tool blocks. -->
+ <div
+ class="shrink-0 pb-2"
+ data-panel-key={keyed.keys[idx]}
+ in:panelGrowIn
+ out:panelShrinkOut
+ >
+ <div class="inset-well flex flex-col p-3" data-panel-well>
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.view}
+ onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])}
+ >
+ <option value="" disabled>Select a view</option>
+ {#each panelViews as view (view.id)}
+ <option value={view.id}>{view.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => removePanel(idx)}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
+
+ <div class="mt-2">
+ <TileLeaf node={panel} {ports} />
+ </div>
+ </div>
+ </div>
+ {/each}
+
+ {#if addView !== null}
+ <!-- `.raised-btn` (app.css): the mirror of the view wells above it —
+ pops out where they sink in; pressed, it sinks in like them. Height =
+ DaisyUI's default button size (`--size-field` × 10), as the `btn` it
+ replaced; `shrink-0` so the column can never squash it. The last panel's
+ `pb-2` is the gap above it; `mb-2` = the same gap below, so it
+ doesn't sit tight to the end. -->
+ <button
+ type="button"
+ class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0"
+ aria-label="Add view"
+ onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])}
+ >
+ <PlusIcon size={18} weight="bold" aria-hidden="true" />
+ </button>
+ {/if}
+</div>
diff --git a/src/features/layout/ui/TileRoot.svelte b/src/features/layout/ui/TileRoot.svelte
new file mode 100644
index 0000000..82ad430
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The tile-tree root. Renders the tree (titlebar slot first — unset renders
+ * nothing, D-L5), applies panel edit ops to its `tree` prop, and forwards the
+ * new tree via `onTreeChange`. The OWNER decides what persistence means
+ * (App/localStorage today; the backend layout store in plan 02).
+ */
+ import { applyOps, type LayoutOp, type LayoutTree } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ tree,
+ onTreeChange,
+ ports,
+ collapse,
+ }: {
+ tree: LayoutTree;
+ onTreeChange?: (tree: LayoutTree) => void;
+ ports?: unknown;
+ collapse: CollapseStore;
+ } = $props();
+
+ function handleEdit(ops: readonly LayoutOp[]): void {
+ const result = applyOps(tree, ops);
+ if (result.ok) {
+ onTreeChange?.(result.tree);
+ } else {
+ // A panel edit that fails validation (e.g. removing the last panel) is
+ // refused loudly in dev — the tree keeps its previous state.
+ console.error(`TileRoot: layout edit refused: ${result.error}`);
+ }
+ }
+</script>
+
+{#if tree.titlebar !== undefined}
+ <TileNode node={tree.titlebar} path={["titlebar"]} {ports} {collapse} onEdit={handleEdit} />
+{/if}
+<TileNode node={tree.root} path={[]} {ports} {collapse} onEdit={handleEdit} />
diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts
new file mode 100644
index 0000000..4b404a1
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.test.ts
@@ -0,0 +1,394 @@
+import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { gsap } from "gsap";
+import { describe, expect, it, vi } from "vitest";
+import { createCollapseStore, registerView, unregisterView } from "../index";
+import FixtureA from "./fixtures/FixtureA.svelte";
+import FixtureB from "./fixtures/FixtureB.svelte";
+import FixtureC from "./fixtures/FixtureC.svelte";
+import TileRoot from "./TileRoot.svelte";
+
+// Fixture registry: two panel-eligible views + one content-only view (a THIRD
+// component, so every fixture testid is unique in the rendered tree).
+registerView({ id: "fix-a", label: "Alpha View", component: FixtureA });
+registerView({ id: "fix-b", label: "Beta View", component: FixtureB });
+registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false });
+
+function leaf(view: string): { kind: "leaf"; view: string } {
+ return { kind: "leaf", view };
+}
+
+/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */
+function defaultTestTree(): LayoutTree {
+ return {
+ version: 1,
+ root: {
+ kind: "split",
+ id: "root",
+ dir: "row",
+ children: [
+ {
+ size: 1,
+ node: {
+ kind: "stack",
+ id: "main",
+ children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }],
+ },
+ },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", id: "side", panels: [leaf("fix-b")] },
+ },
+ ],
+ },
+ };
+}
+
+/** A bare panels root — panel EDITS without the collapsible chrome in the way. */
+function panelsOnlyTree(...views: string[]): LayoutTree {
+ return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } };
+}
+
+/** The default test tree WITH the diagnostic markers on its two root tiles. */
+function markedTestTree(): LayoutTree {
+ return {
+ version: 1,
+ root: {
+ kind: "split",
+ id: "root",
+ dir: "row",
+ children: [
+ {
+ size: 1,
+ node: {
+ kind: "stack",
+ id: "main",
+ marker: "main",
+ children: [{ grow: 1, node: leaf("fix-a") }],
+ },
+ },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", id: "side", marker: "side", panels: [leaf("fix-b")] },
+ },
+ ],
+ },
+ };
+}
+
+function sidePanelsOf(tree: LayoutTree | null): string[] {
+ if (tree === null) return [];
+ const side = (tree.root as SplitNode).children?.[1]?.node;
+ return side?.kind === "panels"
+ ? side.panels.map((p) => p.view)
+ : tree.root.kind === "panels"
+ ? tree.root.panels.map((p) => p.view)
+ : [];
+}
+
+describe("TileRoot", () => {
+ it("renders every node kind: split, stack, panels, leaf", () => {
+ // A FRESH collapse store per test — the store is stateful chrome.
+ render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ // Stack children mount their registered components.
+ expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
+ expect(screen.getByTestId("fixture-c")).toBeInTheDocument();
+ // The panels node renders its panel leaf + the dropdown.
+ expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
+ expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument();
+ });
+
+ it("shows a fallback tile (never a crash) for an unknown view id", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: { kind: "leaf", view: "not-registered-anywhere" },
+ };
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const fallback = screen.getByTestId("fallback-tile");
+ expect(fallback).toHaveTextContent("not-registered-anywhere");
+ expect(fallback).toHaveTextContent("View not registered.");
+ });
+
+ it("renders nothing for the titlebar slot while it is unset (D-L5)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0);
+ });
+
+ it("a set titlebar renders its node (the slot works; nothing populates it today)", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ titlebar: { kind: "leaf", view: "fix-a" },
+ root: { kind: "leaf", view: "fix-b" },
+ };
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
+ expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
+ });
+
+ it("the collapsible child reflects the collapse store and toggles through it", async () => {
+ const collapse = createCollapseStore({ viewportWidth: 1024 });
+ const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } });
+ const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']");
+ expect(aside).not.toBeNull();
+ // Wide viewport default: open → full basis width.
+ expect(aside).toHaveAttribute("data-open", "true");
+ expect(aside?.style.width).toBe("320px");
+
+ await collapse.toggle("side");
+ expect(aside).toHaveAttribute("data-open", "false");
+ expect(aside?.style.width).toBe("0px");
+ });
+
+ it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a");
+ expect(sidePanelsOf(changed)).toEqual(["fix-a"]);
+ // TileRoot is a CONTROLLED component: applying the op is the owner's job
+ // (App re-renders with the new tree; the test doesn't feed it back).
+ });
+
+ it("the + button emits insertPanel pre-selecting the first eligible view", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]);
+ });
+
+ it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b", "fix-a"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ // Only panels after the first can be removed — like ViewSidebar today.
+ const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
+ expect(removeButtons).toHaveLength(1);
+ await user.click(removeButtons[0] as HTMLElement);
+ expect(sidePanelsOf(changed)).toEqual(["fix-b"]);
+ });
+
+ it("an invalid edit is refused: the first panel has no remove button", () => {
+ const tree = panelsOnlyTree("fix-b");
+ let changed: LayoutTree | null = null;
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ render(TileRoot, {
+ props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) },
+ });
+ expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
+ expect(changed).toBeNull();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("a stack child with grow renders in a flex-grown wrapper; others natural", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0");
+ expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%");
+ const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0");
+ expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto");
+ });
+
+ it("the stack column is full-height so a grow child fills it even when empty", () => {
+ // jsdom can't compute flex layout — the honest seam is the CSS class.
+ // WITHOUT `h-full` the stack column's height is content-based, so the
+ // grow child's `flex: 1 1 0%` has no space to fill: an EMPTY chat leaf
+ // collapses to ~0 and the composer below it rides to the top (instead of
+ // staying pinned at the bottom of the column).
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ const stackColumn = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stackColumn).toHaveClass("h-full");
+ });
+
+ it("a marked stack/panels node draws its diagnostic outline (main red, side green)", () => {
+ // The inline outline IS the honest seam: marker values arrive in runtime
+ // tree data (Tailwind can't see them — dynamic classes would silently
+ // render nothing), so the renderer applies an inline style, and the test
+ // asserts exactly what lands on the tile wrapper.
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stack?.getAttribute("style")).toContain("outline: 4px solid red");
+ // The SIDE marker frames the SCROLL CONTAINER — the sidebar's fixed,
+ // opaquely-painted viewport (the aside's bg is transparent; this div's
+ // bg-base-100 would cover an outline drawn on the aside). The panels
+ // root is its SCROLLED content, so an outline there would wrap the
+ // whole scrollable region and scroll along with it.
+ const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']");
+ expect(viewport?.getAttribute("style")).toContain("outline: 4px solid green");
+ });
+
+ it("outlines are INSET so they draw inside the tile's box (never clipped at screen edges)", () => {
+ // Tiles span their containers edge-to-edge, so a default (outside)
+ // outline overflows the visible area and is clipped away — the offset
+ // pulls the ring inside the box. Pin it on both marked tiles.
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stack?.getAttribute("style")).toContain("outline-offset: -4px");
+ const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']");
+ expect(viewport?.getAttribute("style")).toContain("outline-offset: -4px");
+ });
+
+ it("the panels CONTENT carries no outline (it scrolls — the marker must not scroll with it)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]");
+ expect(panels?.getAttribute("style") ?? "").not.toContain("outline");
+ });
+
+ it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0);
+ });
+
+ it("an unknown marker value draws nothing (the renderer ignores what it doesn't know)", () => {
+ // Both root tiles marked with a value the renderer doesn't know.
+ const tree = markedTestTree();
+ const root = tree.root;
+ if (root.kind !== "split") return;
+ const junked = root.children.map((child) => ({
+ ...child,
+ node: { ...child.node, marker: "purple" } as unknown as typeof child.node,
+ }));
+ const marked: LayoutTree = { version: 1, root: { ...root, children: junked } };
+ const { container } = render(TileRoot, {
+ props: { tree: marked, collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0);
+ });
+
+ it("a marked LEAF draws its outline around a wrapper (the only wrapperless node kind)", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: { kind: "leaf", view: "fix-a", marker: "side" },
+ };
+ const { container } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const fixture = container.querySelector("[data-testid='fixture-a']");
+ expect(fixture?.parentElement?.getAttribute("style")).toContain("outline: 4px solid green");
+ });
+
+ it("the dropdown lists panel-eligible views only (decision 5)", () => {
+ render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } });
+ const select = screen.getByRole("combobox", { name: "Select a view" });
+ const labels = [...select.querySelectorAll("option")].map((o) => o.textContent);
+ expect(labels).toContain("Alpha View");
+ expect(labels).toContain("Beta View");
+ expect(labels).not.toContain("Content");
+ });
+
+ it("an unregistered view id inside a panel renders the fallback (not a crash)", () => {
+ render(TileRoot, {
+ props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() },
+ });
+ expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
+ });
+});
+
+describe("TileRoot registry cleanup", () => {
+ it("unregisters the fixture views (the registry is module-global per test file)", () => {
+ unregisterView("fix-a");
+ unregisterView("fix-b");
+ unregisterView("fix-content");
+ const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } };
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ // Unregistered → the fallback tile, not a crash.
+ expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+});
+
+describe("TilePanels — panel identity + height animation", () => {
+ const wrappers = (): HTMLElement[] => [
+ ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"),
+ ];
+
+ it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() },
+ });
+ const [first, , third] = wrappers();
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() });
+ // Identity, not position: the third panel's node survived (index keying
+ // would have reused the middle node and dropped the last one).
+ expect(wrappers()).toEqual([first, third]);
+ expect(screen.queryByTestId("fixture-a")).toBeNull();
+ });
+
+ it("✕ on one of two IDENTICAL views removes exactly that panel", async () => {
+ const user = userEvent.setup();
+ const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a");
+ const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const [first, , third] = wrappers();
+ // Click the MIDDLE panel's ✕ (the first panel has none), then the owner
+ // applies the edit — the two fix-a panels are indistinguishable by data.
+ const [middleClose] = screen.getAllByRole("button", { name: "Remove view" });
+ await user.click(middleClose as HTMLElement);
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() });
+ expect(wrappers()).toEqual([first, third]);
+ });
+
+ it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
+ );
+ try {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() },
+ });
+ const closing = wrappers()[1];
+ await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() });
+ // jsdom's stubbed Element.animate never finishes, so the outro holds.
+ expect(closing?.isConnected).toBe(true);
+ expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte
new file mode 100644
index 0000000..84fa075
--- /dev/null
+++ b/src/features/layout/ui/TileSplit.svelte
@@ -0,0 +1,130 @@
+<script lang="ts">
+ /**
+ * A flex row/column of child subtrees. Child sizing comes from the tree:
+ * `size` → flex-grow (+ optional `basis`), and `collapsible` children get
+ * today's EXACT sidebar behavior — width/translate transition, `lg:relative`
+ * in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state
+ * is chrome (per-node-id), owned by the injected collapse store.
+ */
+ import type { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import { markerStyle } from "../logic/marker";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: SplitNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+
+ const state = $derived($collapse);
+
+ /** Collapse state key: the node's id when present, else its path (stable enough for chrome). */
+ function keyOf(i: number, id: string | undefined): string {
+ return id ?? [...path, i].map((s) => String(s)).join(":");
+ }
+
+ function isOpen(i: number, id: string | undefined): boolean {
+ const key = keyOf(i, id);
+ return state.openByNode[key] ?? state.defaultOpen;
+ }
+
+ /**
+ * The scroll container's inline style: the fixed sidebar width + slide
+ * transform + any diagnostic marker (inset) — none when unmarked.
+ *
+ * The marker goes on THIS div, not the aside: the inner div paints an
+ * OPAQUE full-height background (bg-base-100) that would cover an outline
+ * drawn on the aside. This div's own box never moves — only its contents
+ * scroll — and overflow-y-auto clips descendants, never an element's own
+ * outline, so the marker frames exactly the visible sidebar.
+ */
+ function scrollViewportStyle(
+ basis: number | undefined,
+ open: boolean,
+ marker: TileMarker | undefined,
+ ): string {
+ const parts = [`width: ${basis ?? 320}px`, `transform: translateX(${open ? "0" : "100%"})`];
+ const markerCss = markerStyle(marker);
+ if (markerCss !== undefined) parts.push(markerCss);
+ return parts.join("; ");
+ }
+</script>
+
+{#if node.dir === "col"}
+ <div class="flex min-h-0 w-full flex-col" style={markerStyle(node.marker)}>
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ <div
+ class="min-h-0 min-w-0"
+ style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/each}
+ </div>
+{:else}
+ <div class="flex min-h-0 h-full flex-row" style={markerStyle(node.marker)}>
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ {#if child.collapsible}
+ {@const open = isOpen(i, child.node.id)}
+ <!--
+ A diagnostic marker on a collapsible child is drawn on the SCROLL
+ CONTAINER below — the sidebar's fixed, opaquely-painted viewport —
+ NOT on the child's own root element: that root renders INSIDE the
+ scroll container, so an outline there would wrap the full
+ scrollable content and scroll along with it instead of framing the
+ visible panel.
+ -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <!-- Viewport padding: top/right/bottom always; LEFT only in overlay mode
+ (< lg) — inline (≥ lg) the chat column's gutter already separates the
+ views, but the overlay's left edge is the panel's own edge. -->
+ <aside
+ class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
+ style="width: {open ? (child.basis ?? 320) : 0}px"
+ data-testid="collapsible-child"
+ data-open={open}
+ >
+ <div
+ class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 max-lg:pl-3 transition-transform duration-300 ease-out"
+ style={scrollViewportStyle(child.basis, open, child.node.marker)}
+ data-testid="sidebar-scroll-viewport"
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ </aside>
+ {#if open}
+ <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class="fixed inset-0 z-20 bg-black/30 lg:hidden"
+ role="button"
+ tabindex="0"
+ aria-label="Close sidebar"
+ onclick={() => collapse.setOpen(key, false)}
+ onkeydown={(e) => {
+ if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false);
+ }}
+ ></div>
+ {/if}
+ {:else}
+ <div
+ class="min-h-0 min-w-0"
+ style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/if}
+ {/each}
+ </div>
+{/if}
diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte
new file mode 100644
index 0000000..c8f8cae
--- /dev/null
+++ b/src/features/layout/ui/TileStack.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ /**
+ * A plain vertical container (today's left column). `grow` children flex
+ * (`flex: grow 1 0%` — the chat region scrolls internally, exactly like
+ * today's `flex-1`); children without grow take natural height.
+ *
+ * `h-full` keeps the container's main size DEFINITE (the parent is the
+ * split-child wrapper, cross-stretched to the full-height split row). With
+ * an indefinite height, `flex: 1 1 0%` degenerates to content sizing — an
+ * EMPTY grow child collapses to ~0 and the siblings below it (composer)
+ * ride to the top instead of staying pinned at the bottom.
+ */
+ import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import { markerStyle } from "../logic/marker";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: StackNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+</script>
+
+<div
+ class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
+ style={markerStyle(node.marker)}
+>
+ {#each node.children as child, i (i)}
+ <div class="min-h-0 min-w-0" style={"flex: " + (child.grow ?? 0) + (child.grow === undefined ? " 0 auto" : " 1 0%")}>
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/each}
+</div>
diff --git a/src/features/layout/ui/fixtures/FixtureA.svelte b/src/features/layout/ui/fixtures/FixtureA.svelte
new file mode 100644
index 0000000..e55ba2a
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureA.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Alpha" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-a">{label}</div>
diff --git a/src/features/layout/ui/fixtures/FixtureB.svelte b/src/features/layout/ui/fixtures/FixtureB.svelte
new file mode 100644
index 0000000..1c48584
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureB.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Beta" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-b">{label}</div>
diff --git a/src/features/layout/ui/fixtures/FixtureC.svelte b/src/features/layout/ui/fixtures/FixtureC.svelte
new file mode 100644
index 0000000..2bceb67
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureC.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Gamma" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-c">{label}</div>
diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts
new file mode 100644
index 0000000..7fc5edf
--- /dev/null
+++ b/src/features/layout/ui/panel-motion.ts
@@ -0,0 +1,83 @@
+import { gsap } from "gsap";
+import type { TransitionConfig } from "svelte/transition";
+
+/**
+ * GSAP height animations for sidebar panels — the DOM EFFECT half (stable
+ * panel identity lives in `../logic/panel-keys.ts`).
+ *
+ * Every animation changes ONLY the panel wrapper's height (clipped with
+ * `overflow: hidden`); the panel's contents keep their natural size the whole
+ * time — the wrapper reveals / hides them like a drawer, it never scales
+ * them. The wrapper's bottom padding is the gap between panels, so it grows /
+ * shrinks with the panel and the panels below slide smoothly.
+ *
+ * - `panelGrowIn` — Svelte `in:` transition: an added panel grows 0 → auto.
+ * - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0
+ * while staying in flow (Svelte keeps the node for the returned duration).
+ * - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its
+ * edges move with it — no empty gap below a shrinking box) animates from
+ * the old view's height to the new one's.
+ *
+ * Svelte's `in:` does not run on first mount, so loading the page never
+ * animates. Disabled when the user prefers reduced motion (or the
+ * environment can't tell, e.g. jsdom) — then every change is instant.
+ */
+
+const DURATION = 0.35;
+const EASE = "power2.inOut";
+
+export function panelMotionAllowed(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+export function panelGrowIn(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: 0, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+ return { duration: DURATION * 1000 };
+}
+
+export function panelShrinkOut(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ // e.g. closed mid-grow / mid-swap: shrink from wherever it is.
+ gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]);
+ gsap.to(node, {
+ height: 0,
+ overflow: "hidden",
+ duration: DURATION,
+ ease: EASE,
+ });
+ return { duration: DURATION * 1000 };
+}
+
+/**
+ * Animate `node` from `fromHeight` (its height before the view swapped) to
+ * its new natural height. Call AFTER the DOM shows the new view.
+ */
+export function morphPanelHeight(node: HTMLElement, fromHeight: number): void {
+ if (!panelMotionAllowed()) return;
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: fromHeight, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+}