summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 00:34:45 +0900
committerAdam Malczewski <[email protected]>2026-09-05 00:34:45 +0900
commita104c094b588560f955ff52dc7f949cf3c355f94 (patch)
tree61983d5568f47fd23739705aaec815be1336b595 /src/features/layout
parent60a726883be85c174e88ae832ef95f3c0f084ec9 (diff)
downloaddispatch-web-a104c094b588560f955ff52dc7f949cf3c355f94.tar.gz
dispatch-web-a104c094b588560f955ff52dc7f949cf3c355f94.zip
feat(layout): plan 01 — tile engine + view registry
Diffstat (limited to 'src/features/layout')
-rw-r--r--src/features/layout/index.ts40
-rw-r--r--src/features/layout/logic/collapse.test.ts120
-rw-r--r--src/features/layout/logic/collapse.ts95
-rw-r--r--src/features/layout/logic/panels-edit.test.ts29
-rw-r--r--src/features/layout/logic/panels-edit.ts33
-rw-r--r--src/features/layout/logic/registry.test.ts80
-rw-r--r--src/features/layout/logic/registry.ts107
-rw-r--r--src/features/layout/logic/seed.test.ts99
-rw-r--r--src/features/layout/logic/seed.ts78
-rw-r--r--src/features/layout/logic/tree-query.ts60
-rw-r--r--src/features/layout/ui/FallbackTile.svelte13
-rw-r--r--src/features/layout/ui/TileLeaf.svelte23
-rw-r--r--src/features/layout/ui/TileNode.svelte37
-rw-r--r--src/features/layout/ui/TilePanels.svelte75
-rw-r--r--src/features/layout/ui/TileRoot.svelte39
-rw-r--r--src/features/layout/ui/TileRoot.test.ts228
-rw-r--r--src/features/layout/ui/TileSplit.svelte96
-rw-r--r--src/features/layout/ui/TileStack.svelte32
-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
21 files changed, 1302 insertions, 0 deletions
diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts
new file mode 100644
index 0000000..a2e93a6
--- /dev/null
+++ b/src/features/layout/index.ts
@@ -0,0 +1,40 @@
+/**
+ * The layout feature — a generic tile-tree renderer + the view registry.
+ *
+ * The frontend stops *having* a layout and starts *rendering* one: the tree is
+ * data (`@dispatch/layout-contract`), the renderer walks it, and every view a
+ * leaf mounts comes from the registry. Chrome (top bar, alerts, modals) stays
+ * OUTSIDE the tree — the tree owns the content region.
+ */
+
+export {
+ bootDefaultOpen,
+ type CollapseState,
+ type CollapseStore,
+ type CollapseStoreOptions,
+ createCollapseStore,
+ parsePersistedOpen,
+ SIDEBAR_OPEN_KEY,
+ WIDE_BREAKPOINT,
+} from "./logic/collapse";
+export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit";
+export {
+ getView,
+ listPanelViews,
+ listViews,
+ type RegisteredView,
+ registerView,
+ unregisterView,
+ type ViewComponent,
+ type ViewRegistration,
+ viewRegistry,
+} from "./logic/registry";
+export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed";
+export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query";
+export { default as TileRoot } from "./ui/TileRoot.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "layout",
+ description: "Tile-tree renderer + view registry",
+} as const;
diff --git a/src/features/layout/logic/collapse.test.ts b/src/features/layout/logic/collapse.test.ts
new file mode 100644
index 0000000..77e9f3c
--- /dev/null
+++ b/src/features/layout/logic/collapse.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vitest";
+import {
+ bootDefaultOpen,
+ createCollapseStore,
+ parsePersistedOpen,
+ SIDEBAR_OPEN_KEY,
+ WIDE_BREAKPOINT,
+} from "./collapse";
+
+function fakeStorage(): { storage: Storage; raw(key: string): string | null } {
+ const map = new Map<string, string>();
+ const storage: Storage = {
+ get length() {
+ return map.size;
+ },
+ clear: () => map.clear(),
+ getItem: (key) => map.get(key) ?? null,
+ key: () => null,
+ removeItem: (key) => map.delete(key),
+ setItem: (key, value) => void map.set(key, value),
+ };
+ return { storage, raw: (key) => map.get(key) ?? null };
+}
+
+describe("bootDefaultOpen", () => {
+ it("defaults to open on wide screens (>= 1024px) and closed on narrow ones", () => {
+ expect(bootDefaultOpen(WIDE_BREAKPOINT)).toBe(true);
+ expect(bootDefaultOpen(1440)).toBe(true);
+ expect(bootDefaultOpen(1023)).toBe(false);
+ expect(bootDefaultOpen(0)).toBe(false);
+ });
+
+ it("defaults to open when the viewport is unknown (SSR/tests)", () => {
+ expect(bootDefaultOpen(undefined)).toBe(true);
+ });
+});
+
+describe("parsePersistedOpen", () => {
+ it("accepts today's bare JSON boolean format", () => {
+ expect(parsePersistedOpen("true")).toBe(true);
+ expect(parsePersistedOpen("false")).toBe(false);
+ });
+
+ it("ignores null, malformed JSON, and non-boolean values", () => {
+ expect(parsePersistedOpen(null)).toBeNull();
+ expect(parsePersistedOpen("{")).toBeNull();
+ expect(parsePersistedOpen('"true"')).toBeNull();
+ expect(parsePersistedOpen("{}")).toBeNull();
+ });
+});
+
+describe("createCollapseStore", () => {
+ it("uses the viewport default when nothing is persisted", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ expect(store.isOpen("side")).toBe(true);
+ const narrow = createCollapseStore({ viewportWidth: 800 });
+ expect(narrow.isOpen("side")).toBe(false);
+ });
+
+ it("restores the persisted boolean over the viewport default", () => {
+ const { storage } = fakeStorage();
+ storage.setItem(SIDEBAR_OPEN_KEY, "false");
+ const store = createCollapseStore({ storage, viewportWidth: 1440 });
+ expect(store.isOpen("side")).toBe(false);
+ });
+
+ it("persists toggles as today's bare JSON boolean (same key, same bytes)", () => {
+ const { storage, raw } = fakeStorage();
+ const store = createCollapseStore({ storage, viewportWidth: 1024 });
+ store.toggle("side");
+ expect(raw(SIDEBAR_OPEN_KEY)).toBe("false");
+ store.toggle("side");
+ expect(raw(SIDEBAR_OPEN_KEY)).toBe("true");
+ });
+
+ it("tracks per-node state reactively", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ const seen: boolean[] = [];
+ const unsubscribe = store.subscribe((state) =>
+ seen.push(state.openByNode.side ?? state.defaultOpen),
+ );
+ try {
+ store.setOpen("side", false);
+ store.setOpen("side", true);
+ } finally {
+ unsubscribe();
+ }
+ expect(seen).toEqual([true, false, true]);
+ expect(store.isOpen("side")).toBe(true);
+ });
+
+ it("an untracked node falls back to the default", () => {
+ const store = createCollapseStore({ viewportWidth: 800 });
+ expect(store.isOpen("other")).toBe(false);
+ });
+
+ it("survives storage failures (quota errors are swallowed)", () => {
+ const broken: Storage = {
+ get length() {
+ return 0;
+ },
+ clear: () => undefined,
+ getItem: () => null,
+ key: () => null,
+ removeItem: () => undefined,
+ setItem: () => {
+ throw new Error("quota exceeded");
+ },
+ };
+ const store = createCollapseStore({ storage: broken, viewportWidth: 1024 });
+ expect(() => store.toggle("side")).not.toThrow();
+ expect(store.isOpen("side")).toBe(false);
+ });
+
+ it("works without storage (session-only chrome state)", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ store.toggle("side");
+ expect(store.isOpen("side")).toBe(false);
+ });
+});
diff --git a/src/features/layout/logic/collapse.ts b/src/features/layout/logic/collapse.ts
new file mode 100644
index 0000000..28e90b4
--- /dev/null
+++ b/src/features/layout/logic/collapse.ts
@@ -0,0 +1,95 @@
+/**
+ * Collapse chrome state — the open/closed state of the tree's collapsible
+ * split children (today: the sidebar). Renderer chrome, NOT tree data
+ * (layout-00 decision 3): tree nodes merely declare `collapsible`; this store
+ * owns the per-node state, persisted device-locally.
+ *
+ * Persistence is BYTE-COMPATIBLE with today: the same `dispatch.sidebar.open`
+ * key holding a JSON boolean, defaulting to open on wide screens (>= 1024px,
+ * Tailwind `lg`). Today's UI has exactly one collapsible node, and the
+ * persisted boolean tracks the toggled node's state — identical bytes and
+ * behavior. A future with several collapsible nodes migrates the format in
+ * plan 02, not here.
+ */
+
+import { type Readable, writable } from "svelte/store";
+
+/** The localStorage key — unchanged from today (no migration). */
+export const SIDEBAR_OPEN_KEY = "dispatch.sidebar.open";
+/** Tailwind `lg`: today's boot default is open on wide screens. */
+export const WIDE_BREAKPOINT = 1024;
+
+export interface CollapseState {
+ /** Explicit per-node open state (node id → open). Absent = the default. */
+ readonly openByNode: Readonly<Record<string, boolean>>;
+ /** The boot default (persisted boolean, else viewport width >= 1024). */
+ readonly defaultOpen: boolean;
+}
+
+export interface CollapseStore extends Readable<CollapseState> {
+ /** Is the named node open? (explicit state, else the default) */
+ isOpen(nodeId: string): boolean;
+ setOpen(nodeId: string, open: boolean): void;
+ toggle(nodeId: string): void;
+}
+
+export interface CollapseStoreOptions {
+ /** Injected storage (the app store's `Storage`; undefined → session-only). */
+ readonly storage?: Storage | undefined;
+ /** Viewport width at boot (today: `window.innerWidth`). Undefined → default open. */
+ readonly viewportWidth?: number | undefined;
+ /** Storage key override (tests). Defaults to `dispatch.sidebar.open`. */
+ readonly key?: string | undefined;
+}
+
+/** The boot default: open on wide screens (narrow screens start closed). */
+export function bootDefaultOpen(viewportWidth: number | undefined): boolean {
+ if (viewportWidth === undefined) return true;
+ return viewportWidth >= WIDE_BREAKPOINT;
+}
+
+/** Parse the persisted value. Today's format is a bare JSON boolean; anything else is ignored. */
+export function parsePersistedOpen(raw: string | null): boolean | null {
+ if (raw === null) return null;
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return typeof parsed === "boolean" ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function createCollapseStore(options?: CollapseStoreOptions): CollapseStore {
+ const key = options?.key ?? SIDEBAR_OPEN_KEY;
+ const storage = options?.storage;
+ const persisted = parsePersistedOpen(storage === undefined ? null : storage.getItem(key));
+ const defaultOpen = persisted ?? bootDefaultOpen(options?.viewportWidth);
+
+ let openByNode: Record<string, boolean> = {};
+ const store = writable<CollapseState>({ openByNode, defaultOpen });
+
+ function set(nodeId: string, open: boolean): void {
+ openByNode = { ...openByNode, [nodeId]: open };
+ store.set({ openByNode, defaultOpen });
+ // Persist the toggled node's state as today's bare boolean. With one
+ // collapsible node (always, today) this is exactly today's behavior.
+ if (storage !== undefined) {
+ try {
+ storage.setItem(key, JSON.stringify(open));
+ } catch {
+ // Swallow quota / write errors — persistence is best-effort.
+ }
+ }
+ }
+
+ function isOpen(nodeId: string): boolean {
+ return openByNode[nodeId] ?? defaultOpen;
+ }
+
+ return {
+ subscribe: store.subscribe,
+ isOpen,
+ setOpen: (nodeId, open) => set(nodeId, open),
+ toggle: (nodeId) => set(nodeId, !isOpen(nodeId)),
+ };
+}
diff --git a/src/features/layout/logic/panels-edit.test.ts b/src/features/layout/logic/panels-edit.test.ts
new file mode 100644
index 0000000..b4c3099
--- /dev/null
+++ b/src/features/layout/logic/panels-edit.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./panels-edit";
+
+describe("panel edit → op mapping", () => {
+ it("maps a dropdown swap to a replaceView on the panel leaf's path", () => {
+ expect(swapPanelOp([1], 2, "model")).toEqual({
+ type: "replaceView",
+ target: [1, 2],
+ view: "model",
+ });
+ });
+
+ it("maps the + button to an append insertPanel", () => {
+ expect(addPanelOp(["titlebar"], "tasks")).toEqual({
+ type: "insertPanel",
+ panels: ["titlebar"],
+ view: "tasks",
+ });
+ });
+
+ it("maps the ✕ button to a removeNode on the panel leaf's path", () => {
+ expect(removePanelOp([1], 0)).toEqual({ type: "removeNode", target: [1, 0] });
+ });
+
+ it("the + button pre-selects the first eligible view (or null when none)", () => {
+ expect(firstPanelView([{ id: "tabs" }, { id: "model" }])).toBe("tabs");
+ expect(firstPanelView([])).toBeNull();
+ });
+});
diff --git a/src/features/layout/logic/panels-edit.ts b/src/features/layout/logic/panels-edit.ts
new file mode 100644
index 0000000..8a56859
--- /dev/null
+++ b/src/features/layout/logic/panels-edit.ts
@@ -0,0 +1,33 @@
+/**
+ * Panel-edit → tree-op mapping: the pure decision logic behind TilePanels'
+ * dropdown swap / add / remove buttons. The component builds the ops; the
+ * renderer's owner (TileRoot → onTreeChange) applies them. Zero Svelte, zero
+ * DOM.
+ */
+
+import type { LayoutOp, Path } from "@dispatch/layout-contract";
+
+/** Swap the view a panel leaf mounts (panel `index` of the panels node at `path`). */
+export function swapPanelOp(path: Path, index: number, view: string): LayoutOp {
+ return { type: "replaceView", target: [...path, index], view };
+}
+
+/** Add a panel mounting `view` (appended to the panels node at `path`). */
+export function addPanelOp(path: Path, view: string): LayoutOp {
+ return { type: "insertPanel", panels: path, view };
+}
+
+/** Remove the panel at `index` from the panels node at `path`. */
+export function removePanelOp(path: Path, index: number): LayoutOp {
+ return { type: "removeNode", target: [...path, index] };
+}
+
+/**
+ * The view a new panel starts with. The tree schema has no "empty panel"
+ * state (a leaf's `view` is required — today's "Select a view" placeholder has
+ * no tree representation), so the + button pre-selects the first panel-eligible
+ * view. Returns null when nothing is eligible (the + button hides).
+ */
+export function firstPanelView(views: readonly { readonly id: string }[]): string | null {
+ return views[0]?.id ?? null;
+}
diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts
new file mode 100644
index 0000000..443e50d
--- /dev/null
+++ b/src/features/layout/logic/registry.test.ts
@@ -0,0 +1,80 @@
+import type { Component } from "svelte";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ getView,
+ listPanelViews,
+ listViews,
+ registerView,
+ unregisterView,
+ type ViewRegistration,
+ viewRegistry,
+} from "./registry";
+
+const fakeComponent: Component = () => {
+ throw new Error("fixture component should never be constructed");
+};
+
+function registration(overrides?: Partial<ViewRegistration>): ViewRegistration {
+ return { id: "test-view", label: "Test View", component: fakeComponent, ...overrides };
+}
+
+const registeredIds = (): string[] => listViews().map((v) => v.id);
+
+afterEach(() => {
+ for (const id of registeredIds()) unregisterView(id);
+});
+
+describe("view registry", () => {
+ it("registers a view and exposes it via getView/listViews", () => {
+ registerView(registration());
+ expect(getView("test-view")?.label).toBe("Test View");
+ expect(registeredIds()).toContain("test-view");
+ });
+
+ it("throws loudly on a duplicate id", () => {
+ registerView(registration());
+ expect(() => registerView(registration({ label: "Other" }))).toThrow(/duplicate view id/);
+ });
+
+ it("normalizes panelEligible to true by default and respects false", () => {
+ registerView(registration({ id: "a" }));
+ registerView(registration({ id: "b", panelEligible: false }));
+ expect(getView("a")?.panelEligible).toBe(true);
+ expect(getView("b")?.panelEligible).toBe(false);
+ expect(listPanelViews().map((v) => v.id)).toContain("a");
+ expect(listPanelViews().map((v) => v.id)).not.toContain("b");
+ });
+
+ it("keeps pluginId when given", () => {
+ registerView(registration({ id: "plugin-view", pluginId: "my-plugin" }));
+ expect(getView("plugin-view")?.pluginId).toBe("my-plugin");
+ });
+
+ it("unregisterView removes the view (idempotent for unknown ids)", () => {
+ registerView(registration({ id: "gone" }));
+ unregisterView("gone");
+ expect(getView("gone")).toBeUndefined();
+ expect(() => unregisterView("never-registered")).not.toThrow();
+ });
+
+ it("is reactive: subscribers see registrations and unregistrations", () => {
+ const seen: string[][] = [];
+ const unsubscribe = viewRegistry.subscribe((views) => seen.push(views.map((v) => v.id)));
+ try {
+ registerView(registration({ id: "reactive-a" }));
+ registerView(registration({ id: "reactive-b" }));
+ unregisterView("reactive-a");
+ } finally {
+ unsubscribe();
+ }
+ expect(seen[0]).toEqual([]);
+ expect(seen.at(-1)).toEqual(["reactive-b"]);
+ });
+
+ it("preserves registration order in listViews", () => {
+ registerView(registration({ id: "z-first" }));
+ registerView(registration({ id: "a-second" }));
+ const ids = registeredIds();
+ expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second"));
+ });
+});
diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts
new file mode 100644
index 0000000..b072d2f
--- /dev/null
+++ b/src/features/layout/logic/registry.ts
@@ -0,0 +1,107 @@
+/**
+ * The view registry — the map from view id → component that tile leaves mount.
+ *
+ * Every view the renderer can place (the 14 sidebar views + chat / composer /
+ * message-queue today; plugin views from plan 03 later) registers here. The
+ * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render
+ * open dropdowns, and the tree renderer resolves leaves through it per render.
+ *
+ * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02)
+ * can name views a given client lacks; the renderer shows a fallback tile.
+ *
+ * This module holds a small amount of reactive state by design (the plan places
+ * the registry in `logic/` with Svelte-store reactivity). It is NOT decision
+ * logic — no DOM, no effects.
+ */
+
+import type { Component } from "svelte";
+import { type Readable, writable } from "svelte/store";
+
+/**
+ * The props type of a registered view component. The composition root hands
+ * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`);
+ * fixture/generic components may ignore it.
+ *
+ * The registry is a HETEROGENEOUS component bag (the plan types it `Component`
+ * outright), so its props are opaque `Record<string, any>` — the wrappers
+ * re-narrow to their concrete props at the composition-root seam.
+ */
+export type ViewComponent =
+ // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag
+ Component<Record<string, any>>;
+
+export interface ViewRegistration {
+ /** Registry id — the same id a leaf's `view` field names. */
+ readonly id: string;
+ /** Human label shown in panel dropdowns. */
+ readonly label: string;
+ /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */
+ readonly component: ViewComponent;
+ /** Whether the view may appear in panel dropdowns (default true). */
+ readonly panelEligible?: boolean;
+ /** The plugin that contributed the view (plan 03 requires it for those). */
+ readonly pluginId?: string;
+}
+
+/** A registered view with `panelEligible` normalized (default true). */
+export interface RegisteredView {
+ readonly id: string;
+ readonly label: string;
+ readonly component: ViewComponent;
+ readonly panelEligible: boolean;
+ readonly pluginId: string | undefined;
+}
+
+const byId = new Map<string, RegisteredView>();
+const store = writable<readonly RegisteredView[]>([]);
+
+function publish(): void {
+ store.set([...byId.values()]);
+}
+
+/**
+ * Register a view. A duplicate id throws LOUDLY at dev time — two components
+ * claiming one id is a composition-root bug, never a runtime fallback case.
+ */
+export function registerView(registration: ViewRegistration): void {
+ if (byId.has(registration.id)) {
+ throw new Error(`registerView: duplicate view id "${registration.id}"`);
+ }
+ byId.set(registration.id, {
+ id: registration.id,
+ label: registration.label,
+ component: registration.component,
+ panelEligible: registration.panelEligible ?? true,
+ pluginId: registration.pluginId,
+ });
+ publish();
+}
+
+/** Remove a view. Idempotent (removing an unknown id is a no-op). */
+export function unregisterView(id: string): void {
+ if (byId.delete(id)) publish();
+}
+
+/** Look a view up by id (undefined → the renderer shows its fallback tile). */
+export function getView(id: string): RegisteredView | undefined {
+ return byId.get(id);
+}
+
+/** All registered views, in registration order (snapshot). */
+export function listViews(): readonly RegisteredView[] {
+ return [...byId.values()];
+}
+
+/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */
+export function listPanelViews(): readonly RegisteredView[] {
+ return listViews().filter((v) => v.panelEligible);
+}
+
+/**
+ * Reactive registry contents (registration order). Subscribe in components —
+ * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03)
+ * re-render open dropdowns.
+ */
+export const viewRegistry: Readable<readonly RegisteredView[]> = {
+ subscribe: store.subscribe,
+};
diff --git a/src/features/layout/logic/seed.test.ts b/src/features/layout/logic/seed.test.ts
new file mode 100644
index 0000000..dfe166a
--- /dev/null
+++ b/src/features/layout/logic/seed.test.ts
@@ -0,0 +1,99 @@
+import { DEFAULT_LAYOUT, type LayoutTree, type SplitNode } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./seed";
+import { firstCollapsibleNodeId, firstPanelsNode } from "./tree-query";
+
+function sideViews(tree: LayoutTree): string[] {
+ const root = tree.root as SplitNode;
+ const side = root.children[1]?.node;
+ return side?.kind === "panels" ? side.panels.map((p) => p.view) : [];
+}
+
+describe("seedLayout", () => {
+ it("uses the default panels when nothing is stored", () => {
+ const tree = seedLayout(null, ["tabs", "model", "settings"]);
+ expect(sideViews(tree)).toEqual(DEFAULT_PANEL_VIEWS);
+ });
+
+ it("overlays the stored selection, dropping unknown/removed kinds", () => {
+ const tree = seedLayout(
+ ["extensions", "ghost-view", "model"],
+ ["tabs", "model", "extensions", "settings"],
+ );
+ expect(sideViews(tree)).toEqual(["extensions", "model"]);
+ });
+
+ it("falls back to the defaults when the stored selection is all-unknown or empty", () => {
+ expect(sideViews(seedLayout(["ghost"], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout([], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ });
+
+ it("falls back to the defaults when the stored value is not a string array", () => {
+ expect(sideViews(seedLayout("junk", ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout([42], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout({ a: 1 }, ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ });
+
+ it("preserves the default tree's ids and the titlebar-unset invariant", () => {
+ const tree = seedLayout(["model"], ["tabs", "model"]);
+ expect((tree.root as SplitNode).id).toBe("root");
+ expect(tree.titlebar).toBeUndefined();
+ });
+});
+
+describe("overlaySidePanels", () => {
+ it("replaces only the side panels of the given base tree", () => {
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, ["settings", "tasks"]);
+ expect(sideViews(tree)).toEqual(["settings", "tasks"]);
+ // The rest of the tree is untouched.
+ expect((tree.root as SplitNode).children[0]?.node).toEqual(
+ (DEFAULT_LAYOUT.root as SplitNode).children[0]?.node,
+ );
+ });
+});
+
+describe("tree queries", () => {
+ it("firstPanelsNode finds the side panels with its path", () => {
+ const tree = seedLayout(["model"], ["tabs", "model"]);
+ const found = firstPanelsNode(tree);
+ expect(found?.path).toEqual([1]);
+ expect(found?.node.panels.map((p) => p.view)).toEqual(["model"]);
+ });
+
+ it("firstCollapsibleNodeId returns the default tree's side node id", () => {
+ expect(firstCollapsibleNodeId(DEFAULT_LAYOUT)).toBe("side");
+ });
+
+ it("firstCollapsibleNodeId falls back to a path key when the node has no id", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: {
+ kind: "split",
+ dir: "row",
+ children: [
+ { size: 1, node: { kind: "leaf", view: "chat" } },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", panels: [{ kind: "leaf", view: "tabs" }] },
+ },
+ ],
+ },
+ };
+ // The collapsible child sits at root path index 1 → key "1".
+ expect(firstCollapsibleNodeId(tree)).toBe("1");
+ });
+
+ it("firstCollapsibleNodeId returns null when nothing is collapsible", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: {
+ kind: "split",
+ dir: "row",
+ children: [{ size: 1, node: { kind: "leaf", view: "chat" } }],
+ },
+ };
+ expect(firstCollapsibleNodeId(tree)).toBeNull();
+ });
+});
diff --git a/src/features/layout/logic/seed.ts b/src/features/layout/logic/seed.ts
new file mode 100644
index 0000000..7cd0ba3
--- /dev/null
+++ b/src/features/layout/logic/seed.ts
@@ -0,0 +1,78 @@
+/**
+ * Boot-time tree seeding: the default layout with today's sidebar selection
+ * overlaid from localStorage. Byte-compatible with today's boot behavior
+ * (`dispatch.sidebar.views` read; unknown/removed kinds dropped — the tree
+ * schema has no "unknown view" panel state, so a stale id is skipped exactly
+ * like a view that no longer exists).
+ */
+
+import {
+ DEFAULT_LAYOUT,
+ findById,
+ type LayoutNode,
+ type LayoutTree,
+ type LeafNode,
+ type PanelsNode,
+ type Path,
+} from "@dispatch/layout-contract";
+
+/**
+ * Today's default sidebar selection (the moved-from-top tab bar + the model
+ * controls) — the fallback when nothing (or nothing valid) is stored.
+ */
+export const DEFAULT_PANEL_VIEWS: readonly string[] = ["tabs", "model"];
+
+/**
+ * Seed a tree from DEFAULT_LAYOUT with `storedViews` (the parsed
+ * `dispatch.sidebar.views` value, or null when absent) overlaid onto the
+ * default tree's panels node.
+ *
+ * - null / non-array / array without any known view → the defaults.
+ * - Known views are kept in the stored order; unknown ids are dropped.
+ */
+export function seedLayout(storedViews: unknown, panelViewIds: readonly string[]): LayoutTree {
+ const known = new Set(panelViewIds);
+ const stored = Array.isArray(storedViews)
+ ? storedViews.filter((v): v is string => typeof v === "string")
+ : [];
+ const panels = stored.filter((v) => known.has(v));
+ const selected = panels.length > 0 ? panels : DEFAULT_PANEL_VIEWS.filter((v) => known.has(v));
+ return overlaySidePanels(DEFAULT_LAYOUT, selected);
+}
+
+/**
+ * A NEW tree: `base` with its `"side"` panels node replaced by leaves mounting
+ * `views` (in order). Targets the panels node by id (falls back to no-op when
+ * the base has none); the rest of the tree is shared, not copied.
+ */
+export function overlaySidePanels(base: LayoutTree, views: readonly string[]): LayoutTree {
+ const found = findById(base, "side");
+ if (found === null || found.node.kind !== "panels") return base;
+ const panels: readonly LeafNode[] = views.map((view) => ({ kind: "leaf", view }));
+ const next: PanelsNode = { ...found.node, panels };
+ return { version: 1, root: replaceChild(base.root, found.path, next) };
+}
+
+/** Rebuild the node at `path` (immutably); the replacement must be a panels node. */
+function replaceChild(node: LayoutNode, path: Path, replacement: PanelsNode): LayoutNode {
+ if (path.length === 0) return replacement;
+ const [head, ...rest] = path;
+ if (typeof head !== "number") return node;
+ if (node.kind === "split") {
+ return {
+ ...node,
+ children: node.children.map((child, i) =>
+ i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child,
+ ),
+ };
+ }
+ if (node.kind === "stack") {
+ return {
+ ...node,
+ children: node.children.map((child, i) =>
+ i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child,
+ ),
+ };
+ }
+ return node;
+}
diff --git a/src/features/layout/logic/tree-query.ts b/src/features/layout/logic/tree-query.ts
new file mode 100644
index 0000000..aa8dfe9
--- /dev/null
+++ b/src/features/layout/logic/tree-query.ts
@@ -0,0 +1,60 @@
+/**
+ * Tree queries the chrome needs — pure walks over the layout tree.
+ */
+
+import type { LayoutNode, LayoutTree, PanelsNode, Path } from "@dispatch/layout-contract";
+
+/** The first panels node in the tree (the panel-selection owner), if any. */
+export function firstPanelsNode(tree: LayoutTree): { node: PanelsNode; path: Path } | null {
+ const seen: { node: PanelsNode; path: Path }[] = [];
+ const visit = (node: LayoutNode, path: Path): void => {
+ if (seen.length > 0) return;
+ if (node.kind === "panels") {
+ seen.push({ node, path });
+ return;
+ }
+ const kids: readonly LayoutNode[] =
+ node.kind === "split"
+ ? node.children.map((c) => c.node)
+ : node.kind === "stack"
+ ? node.children.map((c) => c.node)
+ : [];
+ for (const [i, child] of kids.entries()) visit(child, [...path, i]);
+ };
+ visit(tree.root, []);
+ return seen[0] ?? null;
+}
+
+/**
+ * The node id of the tree's FIRST collapsible split child — the one the chrome
+ * top bar's toggle reads/writes (`findById("side")` on the default tree;
+ * generically the first collapsible child, root-first then titlebar). Falls
+ * back to a path-derived key when the child's node has no id. Null → the tree
+ * has nothing collapsible (the toggle button hides).
+ */
+export function firstCollapsibleNodeId(tree: LayoutTree): string | null {
+ const visit = (node: LayoutNode, path: Path): string | null => {
+ if (node.kind === "split") {
+ for (const [i, child] of node.children.entries()) {
+ if (!child.collapsible) continue;
+ if (child.node.id !== undefined) return child.node.id;
+ return [...path, i].map((s) => String(s)).join(":");
+ }
+ }
+ const kids: readonly LayoutNode[] =
+ node.kind === "split"
+ ? node.children.map((c) => c.node)
+ : node.kind === "stack"
+ ? node.children.map((c) => c.node)
+ : [];
+ for (const [i, child] of kids.entries()) {
+ const found = visit(child, [...path, i]);
+ if (found !== null) return found;
+ }
+ return null;
+ };
+ const inRoot = visit(tree.root, []);
+ if (inRoot !== null) return inRoot;
+ if (tree.titlebar !== undefined) return visit(tree.titlebar, ["titlebar"]);
+ return null;
+}
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..cd61b6b
--- /dev/null
+++ b/src/features/layout/ui/TileLeaf.svelte
@@ -0,0 +1,23 @@
+<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).
+ */
+ import { viewRegistry, type RegisteredView } from "../logic/registry";
+ import FallbackTile from "./FallbackTile.svelte";
+
+ let { node, ports }: { node: { readonly view: string }; ports?: unknown } = $props();
+
+ const entry: RegisteredView | undefined = $derived(
+ $viewRegistry.find((v) => v.id === node.view),
+ );
+ const View = $derived(entry?.component);
+</script>
+
+{#if View}
+ <View {ports} />
+{:else}
+ <FallbackTile viewId={node.view} />
+{/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..ca597fe
--- /dev/null
+++ b/src/features/layout/ui/TilePanels.svelte
@@ -0,0 +1,75 @@
+<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 { viewRegistry } from "../logic/registry";
+ import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit";
+ import TileLeaf from "./TileLeaf.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ onEdit,
+ }: {
+ node: PanelsNode;
+ path: Path;
+ ports?: unknown;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $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));
+</script>
+
+<div class="flex min-h-0 flex-col gap-2">
+ {#each node.panels as panel, idx (idx)}
+ <div class="flex flex-col rounded-lg bg-base-200 p-3">
+ <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={() => onEdit([removePanelOp(path, idx)])}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
+
+ <div class="mt-2">
+ <TileLeaf node={panel} {ports} />
+ </div>
+ </div>
+ {/each}
+
+ {#if addView !== null}
+ <button
+ type="button"
+ class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
+ aria-label="Add view"
+ onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])}
+ >
+ +
+ </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..27b8e0a
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.test.ts
@@ -0,0 +1,228 @@
+import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+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) } };
+}
+
+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 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();
+ }
+ });
+});
diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte
new file mode 100644
index 0000000..1f0ec64
--- /dev/null
+++ b/src/features/layout/ui/TileSplit.svelte
@@ -0,0 +1,96 @@
+<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 } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ 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;
+ }
+</script>
+
+{#if node.dir === "col"}
+ <div class="flex min-h-0 w-full flex-col">
+ {#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">
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ {#if child.collapsible}
+ {@const open = isOpen(i, child.node.id)}
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <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 transition-transform duration-300 ease-out"
+ style="width: {child.basis ?? 320}px; transform: translateX({open ? '0' : '100%'})"
+ >
+ <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..13dab09
--- /dev/null
+++ b/src/features/layout/ui/TileStack.svelte
@@ -0,0 +1,32 @@
+<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.
+ */
+ import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ 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 min-h-0 min-w-0 flex-col overflow-hidden">
+ {#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>