From d39cce17765c809cef4555c9640d12ed171cf4af Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 5 Sep 2026 20:44:09 +0900 Subject: feat(layout): renderer renders tile border markers (main red / side green) --- src/features/layout/ui/TileLeaf.svelte | 29 ++++++++++-- src/features/layout/ui/TilePanels.svelte | 3 +- src/features/layout/ui/TileRoot.test.ts | 76 ++++++++++++++++++++++++++++++++ src/features/layout/ui/TileSplit.svelte | 5 ++- src/features/layout/ui/TileStack.svelte | 6 ++- 5 files changed, 111 insertions(+), 8 deletions(-) (limited to 'src/features/layout/ui') diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte index cd61b6b..2d4a91b 100644 --- a/src/features/layout/ui/TileLeaf.svelte +++ b/src/features/layout/ui/TileLeaf.svelte @@ -4,20 +4,41 @@ * 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 { markerOutline } from "../logic/marker"; import FallbackTile from "./FallbackTile.svelte"; - let { node, ports }: { node: { readonly view: string }; ports?: unknown } = $props(); + 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)); -{#if View} - +{#snippet content()} + {#if View} + + {:else} + + {/if} +{/snippet} + +{#if outline !== undefined} +
{@render content()}
{:else} - + {@render content()} {/if} diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte index ca597fe..877ce2e 100644 --- a/src/features/layout/ui/TilePanels.svelte +++ b/src/features/layout/ui/TilePanels.svelte @@ -9,6 +9,7 @@ import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract"; import { viewRegistry } from "../logic/registry"; import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit"; + import { markerOutline } from "../logic/marker"; import TileLeaf from "./TileLeaf.svelte"; let { @@ -29,7 +30,7 @@ const addView = $derived(firstPanelView(panelViews)); -
+
{#each node.panels as panel, idx (idx)}
diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts index aba22a9..ace280a 100644 --- a/src/features/layout/ui/TileRoot.test.ts +++ b/src/features/layout/ui/TileRoot.test.ts @@ -51,6 +51,35 @@ 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; @@ -206,6 +235,53 @@ describe("TileRoot", () => { 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"); + const panels = container.querySelector("[data-testid='fixture-b']")?.closest(".gap-2"); + expect(panels?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + 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" }); diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte index 1f0ec64..b41f9fc 100644 --- a/src/features/layout/ui/TileSplit.svelte +++ b/src/features/layout/ui/TileSplit.svelte @@ -8,6 +8,7 @@ */ import type { LayoutOp, Path, SplitNode } from "@dispatch/layout-contract"; import type { CollapseStore } from "../logic/collapse"; + import { markerOutline } from "../logic/marker"; import TileNode from "./TileNode.svelte"; let { @@ -38,7 +39,7 @@ {#if node.dir === "col"} -
+
{#each node.children as child, i (i)} {@const key = keyOf(i, child.node.id)}
{:else} -
+
{#each node.children as child, i (i)} {@const key = keyOf(i, child.node.id)} {#if child.collapsible} diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte index ac7091a..d2ee267 100644 --- a/src/features/layout/ui/TileStack.svelte +++ b/src/features/layout/ui/TileStack.svelte @@ -12,6 +12,7 @@ */ import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract"; import type { CollapseStore } from "../logic/collapse"; + import { markerOutline } from "../logic/marker"; import TileNode from "./TileNode.svelte"; let { @@ -29,7 +30,10 @@ } = $props(); -
+
{#each node.children as child, i (i)}
-- cgit v1.2.3