diff options
Diffstat (limited to 'src/features/layout/ui')
| -rw-r--r-- | src/features/layout/ui/FallbackTile.svelte | 13 | ||||
| -rw-r--r-- | src/features/layout/ui/TileLeaf.svelte | 44 | ||||
| -rw-r--r-- | src/features/layout/ui/TileNode.svelte | 37 | ||||
| -rw-r--r-- | src/features/layout/ui/TilePanels.svelte | 185 | ||||
| -rw-r--r-- | src/features/layout/ui/TileRoot.svelte | 39 | ||||
| -rw-r--r-- | src/features/layout/ui/TileRoot.test.ts | 394 | ||||
| -rw-r--r-- | src/features/layout/ui/TileSplit.svelte | 130 | ||||
| -rw-r--r-- | src/features/layout/ui/TileStack.svelte | 42 | ||||
| -rw-r--r-- | src/features/layout/ui/fixtures/FixtureA.svelte | 6 | ||||
| -rw-r--r-- | src/features/layout/ui/fixtures/FixtureB.svelte | 6 | ||||
| -rw-r--r-- | src/features/layout/ui/fixtures/FixtureC.svelte | 6 | ||||
| -rw-r--r-- | src/features/layout/ui/panel-motion.ts | 83 |
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", + }, + ); +} |
