diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2df3ce0..f8f2132a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ Notable API additions and breaking changes. For the full commit log, see [GitHub Releases](https://github.com/mirrorstack-ai/web-ui-kit/releases). +## 0.6.0 + +- **Fix: `NotchGrid` no longer lets distinct panels overlap or render flush at + `gap=0`.** Two unrelated components (different keys, no shared `groupKey`, + different themes) placed on touching cells used to render with their + outlines overlapping by `2·panelBleed` px — the later-painted panel visually + cutting up into the one above it — because `panelBleed` dilates every + outline outward and `gap=0` applies no counter-erosion. `NotchGrid` now + nudges each component's rendered POSITION apart from a touching distinct + neighbour by enough to cancel that dilation plus a guaranteed + quarter-cell seam, along whichever axis the pair is touching on. Every + component's own outline shape (`gap`/`panelBleed`) is left exactly as + configured — nothing about how an individual panel renders changes, only + where it sits. Components with room to spare are unaffected. + ## 0.5.16 - **New `ServiceLogcat` dev component.** A service log console (promoted from diff --git a/package.json b/package.json index 52d9c55cd..32494ece2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.5.16", + "version": "0.6.0", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/layout/app-shell/AppShell.tsx b/src/components/layout/app-shell/AppShell.tsx index 56f1c2c7b..b8343f68d 100644 --- a/src/components/layout/app-shell/AppShell.tsx +++ b/src/components/layout/app-shell/AppShell.tsx @@ -1,4 +1,10 @@ -import { useState, useRef, useEffect, type ReactNode } from "react"; +import { + useState, + useRef, + useEffect, + useLayoutEffect, + type ReactNode, +} from "react"; import { cn } from "@/utils/cn"; import type { ComponentMeta } from "@/types/component-meta"; import { IconButton } from "@/components/ui/actions/icon-button/IconButton"; @@ -331,9 +337,21 @@ function AppShellInner({ // the single window shape always matches it through opens, drag-resize, and // viewport changes. Re-runs when the sidebar mounts/unmounts (isOpen) so the // observer attaches once the body element exists. - useEffect(() => { + // + // `useLayoutEffect` + a synchronous `getBoundingClientRect()` seed — NOT + // `useEffect` relying on the observer's own first callback — because + // ResizeObserver callbacks are spec'd to fire asynchronously even for the + // very first observation. That left a real (if usually brief) window on + // every open where `agentBodyH` was still its initial 0, `windowBodyHeight` + // was omitted below, and the transparent body — which depends entirely on + // AgentSidebarHeader's single-shape fill reaching down over it — rendered + // with NO background at all until the async callback finally landed. + // Seeding synchronously before paint closes that gap; the observer still + // owns every SUBSEQUENT resize (drag, viewport changes, content growth). + useLayoutEffect(() => { const el = agentBodyRef.current; if (!el) return; + setAgentBodyH(el.getBoundingClientRect().height); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { setAgentBodyH(entries[0].contentRect.height); diff --git a/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx b/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx index b29627e1c..90f39f1bc 100644 --- a/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx +++ b/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx @@ -161,6 +161,86 @@ describe("NotchGrid", () => { expect(container.querySelectorAll("svg")).toHaveLength(1); }); + it("keeps two distinct, differently-themed panels from overlapping when stacked flush (gap=0 + panelBleed)", () => { + // Regression: two UNRELATED panels (different keys, no shared groupKey, + // different themes) stacked vertically with NO empty cell between them. + // With gap=0 + panelBleed>0 the later panel's outline used to dilate up into + // the earlier one (Manage's top cutting into Summary's bottom). They must + // (a) stay TWO separate chromes and (b) not overlap — a seam between them. + const items: NotchGridItem[] = [ + { + key: "summary", + desire: { position: [0, 0], shape: M(4, 2) }, + theme: { type: "filled", variant: "secondary" }, + subItems: [ + { desire: { position: [0, 0], shape: M(4, 2) }, ui: { type: "X" } }, + ], + }, + { + key: "manage", + // Directly below summary (rows 0-1) — touching at the row1/row2 seam. + desire: { position: [0, 2], shape: M(4, 1) }, + theme: { type: "filled", variant: "tertiary" }, + subItems: [ + { desire: { position: [0, 0], shape: M(4, 1) }, ui: { type: "X" } }, + ], + }, + ]; + const { container } = render( + , + ); + + // (a) Two DISTINCT chromes — the panels are never merged into one shape. + const svgs = Array.from(container.querySelectorAll("svg")); + expect(svgs).toHaveLength(2); + + // (b) Their outlines must not overlap. Each path's Y coords are local to its + // component wrapper (positioned at `minRow * block + nudge` via style.top, + // where the nudge pushes a touching component apart from its neighbour + // WITHOUT touching either one's own outline shape); convert to absolute and + // check the earlier panel's bottom sits above the later's top. + const absYExtent = (svg: Element) => { + const wrapper = svg.parentElement!.parentElement as HTMLElement; + const top = parseFloat(wrapper.style.top) || 0; + const d = svg.querySelector("path")!.getAttribute("d")!; + const ys: number[] = []; + const re = /(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(d))) ys.push(Number(m[2]) + top); + return { top: Math.min(...ys), bottom: Math.max(...ys) }; + }; + const [a, b] = svgs.map(absYExtent); + const [upper, lower] = a.top <= b.top ? [a, b] : [b, a]; + // The seam must be a REAL, visible quarter-cell gap (block=24 here, so + // >= 6px) — not just "non-negative". A token 1-2px seam would pass a + // >= 0 check but still read as touching at a glance. + const MIN_COMPONENT_SEP_FRACTION = 0.25; + const block = 24; + expect(lower.top - upper.bottom).toBeGreaterThanOrEqual( + MIN_COMPONENT_SEP_FRACTION * block, + ); + + // (c) The fix must be POSITION-only: each panel's own outline (svg + // width/height, driven by its shape + the full panelBleed=4) is unchanged + // — no per-component shrinking of bleed to make room for the seam. + // summary's mask is 4x2 cells, manage's is 4x1; svg dims = mask*block + + // 2*panelBleed on each axis. + const dims = svgs.map((svg) => ({ + w: Number(svg.getAttribute("width")), + h: Number(svg.getAttribute("height")), + })); + const expected = [ + { w: 4 * 24 + 2 * 4, h: 2 * 24 + 2 * 4 }, // summary: 4x2 + { w: 4 * 24 + 2 * 4, h: 1 * 24 + 2 * 4 }, // manage: 4x1 + ]; + for (let i = 0; i < dims.length; i++) { + const match = expected.some( + (e) => e.w === dims[i].w && e.h === dims[i].h, + ); + expect(match).toBe(true); + } + }); + it("applies inline color from resolved theme (variant=primary)", () => { const items: NotchGridItem[] = [ { diff --git a/src/components/ui/surfaces/notch-grid/NotchGrid.tsx b/src/components/ui/surfaces/notch-grid/NotchGrid.tsx index bc8fdbf1a..b6142af0a 100644 --- a/src/components/ui/surfaces/notch-grid/NotchGrid.tsx +++ b/src/components/ui/surfaces/notch-grid/NotchGrid.tsx @@ -138,6 +138,16 @@ export interface NotchGridProps { /** Content inset inside a tile / sub-cell (8px sides, 16px top/bottom). Matches BlockShape's default. */ const DEFAULT_CONTENT_PAD = "16px 8px"; +/** Minimum seam the kit guarantees between two DISTINCT components, as a + * FRACTION OF ONE CELL — so it scales with `block` (the grid's live cell + * size, which callers resize responsively) instead of a fixed pixel value. + * `0.25` matches a quarter-cell gap, the same unit callers already use for + * their own panel spacing. Enforced by NUDGING each component's rendered + * POSITION apart from a touching neighbour (see `computeComponentNudges`) — + * every component's own outline shape (`gap`/`panelBleed`) stays exactly as + * the caller configured it; only where it sits shifts. */ +const MIN_COMPONENT_SEP_FRACTION = 0.25; + /** Pointer capture, tolerant of test envs (jsdom) that lack the API. */ function safePointerCapture(el: Element, pointerId: number): void { try { @@ -352,6 +362,103 @@ export function findConnectedComponents( return out; } +/** A component's outer bounding box, in cell units (half-open: `maxCol`/`maxRow` + * are one past the last occupied cell — matching `NotchComponent`'s own bbox + * math). */ +interface ComponentBBox { + minCol: number; + minRow: number; + maxCol: number; + maxRow: number; +} + +function componentBBox( + members: ReadonlyArray>, +): ComponentBBox { + let minCol = Infinity; + let minRow = Infinity; + let maxCol = 0; + let maxRow = 0; + for (const m of members) { + minCol = Math.min(minCol, m.col); + minRow = Math.min(minRow, m.row); + maxCol = Math.max(maxCol, m.col + m.cols); + maxRow = Math.max(maxRow, m.row + m.rows); + } + return { minCol, minRow, maxCol, maxRow }; +} + +/** Per-component pixel nudge (`[dx, dy]`) that keeps distinct components from + * rendering flush/overlapping, WITHOUT touching either component's own + * outline shape (`gap`/`panelBleed` stay exactly as the caller configured — + * see the module doc for `MIN_COMPONENT_SEP_FRACTION`). + * + * `panelBleed` dilates each component's outline OUTWARD by a KNOWN, constant + * amount, and `gap=0` applies no counter-erosion, so two distinct components + * on touching (0-cell-gap) cells overlap by `2·panelBleed` px. Rather than + * shrink either component's bleed to compensate (a "shape" fix — the frame + * itself would look thinner near a neighbour than elsewhere), this shifts + * each component's rendered POSITION apart by enough to (a) cancel out its + * own share of that dilation and (b) add the `MIN_COMPONENT_SEP_FRACTION` + * seam on top — `panelBleed` is READ here only to compute how far to move, + * never changed. Nudges along whichever axis the pair is touching on + * (vertically stacked ↔ nudge Y; horizontally adjacent ↔ nudge X). Handles + * the common axis-aligned touching case (the reported bug: two panels + * stacked with zero empty cells between them); a component touching + * multiple neighbours on the SAME axis (a middle panel sandwiched between + * two others) may net out with a smaller total nudge than a from-scratch + * solve would give — a real limitation, not silently wrong, and not the + * scenario this fixes. */ +function computeComponentNudges( + components: ReadonlyArray>>, + block: number, + gap: number, + panelBleed: number, +): ReadonlyArray { + const boxes = components.map(componentBBox); + const minSep = MIN_COMPONENT_SEP_FRACTION * block; + // Net outward dilation per side, mirroring BlockShape's own erosion formula + // (`erosion = min(gap, cell-2)/2 - bleed`): `gap`'s own counter-erosion + // already claws back some of `bleed`'s outward reach, so only the leftover + // (floored at 0 — a caller-configured gap can already fully cover it) needs + // a nudge, plus half the desired seam on top. + const netDilation = Math.max(0, panelBleed - Math.min(gap, block - 2) / 2); + const perSide = netDilation + minSep / 2; + const nudgeX = new Array(components.length).fill(0); + const nudgeY = new Array(components.length).fill(0); + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i]; + const b = boxes[j]; + const colsOverlap = a.minCol < b.maxCol && b.minCol < a.maxCol; + const rowsOverlap = a.minRow < b.maxRow && b.minRow < a.maxRow; + // Vertically stacked & touching: share some column range, rows meet + // edge-to-edge (0 empty cells between them). + if (colsOverlap) { + if (a.maxRow === b.minRow) { + nudgeY[i] -= perSide; + nudgeY[j] += perSide; + } else if (b.maxRow === a.minRow) { + nudgeY[j] -= perSide; + nudgeY[i] += perSide; + } + } + // Horizontally adjacent & touching: share some row range, columns meet + // edge-to-edge. + if (rowsOverlap) { + if (a.maxCol === b.minCol) { + nudgeX[i] -= perSide; + nudgeX[j] += perSide; + } else if (b.maxCol === a.minCol) { + nudgeX[j] -= perSide; + nudgeX[i] += perSide; + } + } + } + } + return components.map((_, i) => [nudgeX[i], nudgeY[i]] as const); +} + /** Bucket placements by their effective group (from `groupOf`, keyed by item * key), then split each bucket into 8-connected components. Items without a * group are singleton buckets so they never merge with anything. */ @@ -776,6 +883,16 @@ export function NotchGrid({ }; }, [keyedItems, resolvedCols, nest, overrides, promoted]); + // Per-component position nudge (pushes a component apart from a touching + // distinct neighbour — see `computeComponentNudges`) — memoised separately + // from the solve so it only recomputes on resize (block change), not on + // every drag tick. Each component's own outline (`gap`/`panelBleed`) is + // untouched; only where it renders shifts. + const componentNudges = useMemo( + () => computeComponentNudges(components, block, gap, panelBleed), + [components, block, gap, panelBleed], + ); + const unfitKey = layout?.unfit.join(",") ?? ""; useEffect(() => { if (isDev && layout && layout.unfit.length > 0) { @@ -796,8 +913,12 @@ export function NotchGrid({ ...style, }} > - {components.map((members) => { + {components.map((members, compIndex) => { const compKey = members.map((m) => m.key).join("|"); + // Static position nudge — pushes this component away from a touching + // distinct neighbour. Its own outline shape (gap/panelBleed, passed + // through unchanged below) never varies by neighbour. + const nudge = componentNudges[compIndex] ?? ([0, 0] as const); // Outer-drag offset, if a member of this component is being dragged. const dragMember = drag ? members.find((m) => m.key === drag.key) : undefined; const dragOffset: readonly [number, number] | undefined = @@ -815,6 +936,7 @@ export function NotchGrid({ members={members} block={block} gap={gap} + nudge={nudge} contentPad={contentPad} panelBleed={panelBleed} primitives={primitives} @@ -931,6 +1053,10 @@ interface NotchComponentProps { members: Placement[]; block: number; gap: number; + /** Static [dx, dy] px offset (see `computeComponentNudges`) that pushes this + * component away from a touching distinct neighbour. Applied to its + * rendered position only — never affects `gap`/`panelBleed`. */ + nudge?: readonly [number, number]; contentPad: number | string; panelBleed: number; primitives?: PrimitiveRegistry; @@ -971,6 +1097,7 @@ const NotchComponent = memo(function NotchComponent({ members, block, gap, + nudge, contentPad, panelBleed, primitives, @@ -1080,8 +1207,8 @@ const NotchComponent = memo(function NotchComponent({ const wrapperStyle: CSSProperties = { position: "absolute", - left: minCol * block, - top: minRow * block, + left: minCol * block + (nudge?.[0] ?? 0), + top: minRow * block + (nudge?.[1] ?? 0), color: resolved.color, }; if (wrapperMoves && dragOffset) {