diff options
| author | Adam Malczewski <[email protected]> | 2026-09-06 16:00:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-06 16:07:33 +0900 |
| commit | 706aa41955f86eeb6fb35af0b844048e2f53a788 (patch) | |
| tree | 29df364617c2cc2baf78e9c94becb08fa5c7d5f8 /src/features/layout/ui | |
| parent | 21de98f8929b566badec7390afb26c5821c8931d (diff) | |
| download | dispatch-web-706aa41955f86eeb6fb35af0b844048e2f53a788.tar.gz dispatch-web-706aa41955f86eeb6fb35af0b844048e2f53a788.zip | |
fix(layout): draw diagnostic tile markers inset + frame the sidebar viewport, not its scrolled content
Two renderer bugs made the diagnostic layout plugin's borders useless:
1. Borders went past the panels and were invisible at screen edges: CSS
outlines draw OUTSIDE the border box by default, and tiles span their
containers edge-to-edge, so the 4px ring overflowed the visible area
and was clipped. The outline is now inset (outline-offset: -4px) so
it draws inside the tile's box.
2. The green side border scrolled with the sidebar: the marker was drawn
on the TilePanels root, which is the SCROLLED CONTENT of the split's
collapsible child. The marker now lives on the split's scroll-viewport
div — the sidebar's fixed, opaquely-painted frame: its box never moves
(only its contents scroll), and its own outline is never clipped by its
overflow. (The aside behind it can't carry the marker — the scroll
viewport's bg-base-100 paints over an outline drawn on the aside.)
markerStyle() centralizes the full declaration (outline + inset offset)
and returns undefined for unmarked/unknown markers, so unmarked trees
still render with zero outline CSS (pinned by the existing tests).
Paint layering verified with a headless-Chromium screenshot of a probe
page reproducing the exact DOM + computed styles.
Diffstat (limited to 'src/features/layout/ui')
| -rw-r--r-- | src/features/layout/ui/TileLeaf.svelte | 4 | ||||
| -rw-r--r-- | src/features/layout/ui/TilePanels.svelte | 16 | ||||
| -rw-r--r-- | src/features/layout/ui/TileRoot.test.ts | 28 | ||||
| -rw-r--r-- | src/features/layout/ui/TileSplit.svelte | 40 | ||||
| -rw-r--r-- | src/features/layout/ui/TileStack.svelte | 4 |
5 files changed, 80 insertions, 12 deletions
diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte index 2d4a91b..0fd6430 100644 --- a/src/features/layout/ui/TileLeaf.svelte +++ b/src/features/layout/ui/TileLeaf.svelte @@ -11,7 +11,7 @@ */ import type { TileMarker } from "@dispatch/layout-contract"; import { viewRegistry, type RegisteredView } from "../logic/registry"; - import { markerOutline } from "../logic/marker"; + import { MARKER_OUTLINE_OFFSET, markerOutline } from "../logic/marker"; import FallbackTile from "./FallbackTile.svelte"; let { @@ -38,7 +38,7 @@ {/snippet} {#if outline !== undefined} - <div style:outline={outline}>{@render content()}</div> + <div style:outline={outline} style:outline-offset={MARKER_OUTLINE_OFFSET}>{@render content()}</div> {:else} {@render content()} {/if} diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte index 877ce2e..78dd7d1 100644 --- a/src/features/layout/ui/TilePanels.svelte +++ b/src/features/layout/ui/TilePanels.svelte @@ -9,7 +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 { markerStyle } from "../logic/marker"; import TileLeaf from "./TileLeaf.svelte"; let { @@ -17,11 +17,20 @@ 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 @@ -30,7 +39,10 @@ const addView = $derived(firstPanelView(panelViews)); </script> -<div class="flex min-h-0 flex-col gap-2" style:outline={markerOutline(node.marker)}> +<div + class="flex min-h-0 flex-col gap-2" + style={renderMarker ? markerStyle(node.marker) : undefined} +> {#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"> diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts index ace280a..2f95854 100644 --- a/src/features/layout/ui/TileRoot.test.ts +++ b/src/features/layout/ui/TileRoot.test.ts @@ -245,8 +245,34 @@ describe("TileRoot", () => { }); 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(".gap-2"); - expect(panels?.getAttribute("style")).toContain("outline: 4px solid green"); + expect(panels?.getAttribute("style") ?? "").not.toContain("outline"); }); it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => { diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte index b41f9fc..b526ccd 100644 --- a/src/features/layout/ui/TileSplit.svelte +++ b/src/features/layout/ui/TileSplit.svelte @@ -6,9 +6,9 @@ * 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 { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract"; import type { CollapseStore } from "../logic/collapse"; - import { markerOutline } from "../logic/marker"; + import { markerStyle } from "../logic/marker"; import TileNode from "./TileNode.svelte"; let { @@ -36,10 +36,31 @@ 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:outline={markerOutline(node.marker)}> + <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 @@ -51,11 +72,19 @@ {/each} </div> {:else} - <div class="flex min-h-0 h-full flex-row" style:outline={markerOutline(node.marker)}> + <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 --> <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" @@ -65,7 +94,8 @@ > <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%'})" + style={scrollViewportStyle(child.basis, open, child.node.marker)} + data-testid="sidebar-scroll-viewport" > <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> </div> diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte index d2ee267..c8f8cae 100644 --- a/src/features/layout/ui/TileStack.svelte +++ b/src/features/layout/ui/TileStack.svelte @@ -12,7 +12,7 @@ */ import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract"; import type { CollapseStore } from "../logic/collapse"; - import { markerOutline } from "../logic/marker"; + import { markerStyle } from "../logic/marker"; import TileNode from "./TileNode.svelte"; let { @@ -32,7 +32,7 @@ <div class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden" - style:outline={markerOutline(node.marker)} + 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%")}> |
