summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-06 16:00:12 +0900
committerAdam Malczewski <[email protected]>2026-09-06 16:07:33 +0900
commit706aa41955f86eeb6fb35af0b844048e2f53a788 (patch)
tree29df364617c2cc2baf78e9c94becb08fa5c7d5f8 /src/features/layout
parent21de98f8929b566badec7390afb26c5821c8931d (diff)
downloaddispatch-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')
-rw-r--r--src/features/layout/logic/marker.test.ts15
-rw-r--r--src/features/layout/logic/marker.ts21
-rw-r--r--src/features/layout/ui/TileLeaf.svelte4
-rw-r--r--src/features/layout/ui/TilePanels.svelte16
-rw-r--r--src/features/layout/ui/TileRoot.test.ts28
-rw-r--r--src/features/layout/ui/TileSplit.svelte40
-rw-r--r--src/features/layout/ui/TileStack.svelte4
7 files changed, 115 insertions, 13 deletions
diff --git a/src/features/layout/logic/marker.test.ts b/src/features/layout/logic/marker.test.ts
index d9e3826..53b91b0 100644
--- a/src/features/layout/logic/marker.test.ts
+++ b/src/features/layout/logic/marker.test.ts
@@ -1,5 +1,6 @@
+import type { TileMarker } from "@dispatch/layout-contract";
import { describe, expect, it } from "vitest";
-import { markerOutline } from "./marker";
+import { markerOutline, markerStyle } from "./marker";
describe("markerOutline — the diagnostic tile-border mapping", () => {
it("main → a 4px solid red outline (the main tile's marker)", () => {
@@ -14,3 +15,15 @@ describe("markerOutline — the diagnostic tile-border mapping", () => {
expect(markerOutline(undefined)).toBeUndefined();
});
});
+
+describe("markerStyle — the complete inline style (outline + inset offset)", () => {
+ it("insets the outline so it draws INSIDE the tile's box (never clipped at screen edges)", () => {
+ expect(markerStyle("main")).toBe("outline: 4px solid red; outline-offset: -4px");
+ expect(markerStyle("side")).toBe("outline: 4px solid green; outline-offset: -4px");
+ });
+
+ it("no/unknown marker → NO style at all (unmarked nodes carry zero outline CSS)", () => {
+ expect(markerStyle(undefined)).toBeUndefined();
+ expect(markerStyle("purple" as unknown as TileMarker)).toBeUndefined();
+ });
+});
diff --git a/src/features/layout/logic/marker.ts b/src/features/layout/logic/marker.ts
index eb1ff84..46259e0 100644
--- a/src/features/layout/logic/marker.ts
+++ b/src/features/layout/logic/marker.ts
@@ -10,6 +10,12 @@
* (not a border) is used so the diagnostic draws WITHOUT changing any
* tile's layout/size (4px solid, clearly visible).
*
+ * The outline is INSET (`outline-offset: -4px`) so it draws INSIDE the
+ * tile's box: tiles span their containers edge-to-edge, so the default
+ * (outside) outline overflows the visible area and is clipped away at the
+ * screen edges. The views inside the tiles have no opaque full-bleed
+ * background, so the inset ring stays on top of the tile's content.
+ *
* The marker is purely presentational: unknown/absent → no outline
* (existing trees render exactly as before), and no other behavior is
* attached to it anywhere in the renderer.
@@ -23,3 +29,18 @@ export function markerOutline(marker: TileMarker | undefined): string | undefine
if (marker === "side") return "4px solid green";
return undefined;
}
+
+/** The inset that keeps the outline INSIDE the tile's box (see the header note). */
+export const MARKER_OUTLINE_OFFSET = "-4px";
+
+/**
+ * The COMPLETE inline style for a marked element — outline + inset offset in
+ * one declaration — or `undefined` when unmarked/unknown, so unmarked nodes
+ * carry no outline CSS at all (existing trees render exactly as before).
+ */
+export function markerStyle(marker: TileMarker | undefined): string | undefined {
+ const outline = markerOutline(marker);
+ return outline === undefined
+ ? undefined
+ : `outline: ${outline}; outline-offset: ${MARKER_OUTLINE_OFFSET}`;
+}
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%")}>