From 5fea8408dfc8694511ed631dcbe072b63fe90bba Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Wed, 1 Jul 2026 14:40:42 +0800 Subject: [PATCH 1/5] fix(NotchGrid): guarantee distinct panels never overlap at gap=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated components (different keys, no shared groupKey, different themes) placed on touching cells rendered with their outlines overlapping by 2*panelBleed px — the later-painted panel cutting up into the one above — because panelBleed dilates every outline outward and gap=0 applies no counter-erosion. Grouping was correct (they stayed two separate chromes); the defect was purely in outline geometry (grid-outline erosion = gap/2 - bleed going negative with no inter-component protection). NotchGrid now caps each component's outward bleed at half the empty space to its nearest distinct neighbour and forces a small seam when a neighbour is flush, so distinct components always stay visually separated regardless of the gap prop. Well-separated layouts keep the full panelBleed unchanged. Bump 0.5.17; add regression test. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 13 +++ package.json | 2 +- .../ui/surfaces/notch-grid/NotchGrid.test.tsx | 53 ++++++++++++ .../ui/surfaces/notch-grid/NotchGrid.tsx | 84 ++++++++++++++++++- 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2df3ce0..1590f3e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ Notable API additions and breaking changes. For the full commit log, see [GitHub Releases](https://github.com/mirrorstack-ai/web-ui-kit/releases). +## 0.5.17 + +- **Fix: `NotchGrid` no longer lets distinct panels overlap 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 caps each component's outward + bleed at half the empty space to its nearest distinct neighbour and forces a + small seam when a neighbour is flush, so distinct components always stay + visually separated regardless of the `gap` prop. Components with room to + spare keep the full `panelBleed`, so well-separated layouts are unchanged. + ## 0.5.16 - **New `ServiceLogcat` dev component.** A service log console (promoted from diff --git a/package.json b/package.json index 52d9c55cd..27c30bf10 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.5.17", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx b/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx index b29627e1c..e4a4a60c9 100644 --- a/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx +++ b/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx @@ -161,6 +161,59 @@ 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` via style.top); convert + // to absolute and check the earlier panel's bottom sits above the later's + // top (a positive seam, not a negative overlap). + 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]; + // Seam >= 0: the upper panel's outline bottom does not cross the lower's top. + expect(lower.top).toBeGreaterThanOrEqual(upper.bottom); + }); + 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..67ee5120b 100644 --- a/src/components/ui/surfaces/notch-grid/NotchGrid.tsx +++ b/src/components/ui/surfaces/notch-grid/NotchGrid.tsx @@ -138,6 +138,12 @@ 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 px seam the kit guarantees between two DISTINCT components' outlines, + * so they never render overlapping or fully flush — even at `gap=0` with a + * `panelBleed` that would otherwise dilate them into each other. Small enough + * to read as a hairline; the differing per-panel fills do the rest. */ +const MIN_COMPONENT_SEP = 2; + /** Pointer capture, tolerant of test envs (jsdom) that lack the API. */ function safePointerCapture(el: Element, pointerId: number): void { try { @@ -352,6 +358,67 @@ export function findConnectedComponents( return out; } +/** Minimum Chebyshev cell distance between two components' filled cells. + * `1` = touching (edge OR diagonal); larger = that-many-plus empty cells apart. + * Short-circuits at 1 — the closest two non-overlapping components can sit. */ +function minComponentCheb( + a: ReadonlyArray, + b: ReadonlyArray, +): number { + let best = Infinity; + for (const [ax, ay] of a) { + for (const [bx, by] of b) { + const d = Math.max(Math.abs(ax - bx), Math.abs(ay - by)); + if (d < best) { + best = d; + if (best <= 1) return best; + } + } + } + return best; +} + +/** Per-component outline geometry that GUARANTEES distinct components never + * render with overlapping (or fully flush) outlines — the frame separation a + * caller would otherwise have to hand-maintain by leaving empty cells between + * panels. + * + * `panelBleed` dilates each component's outline OUTWARD, and `gap=0` applies no + * counter-erosion, so two distinct components on touching cells overlap by + * `2·panelBleed` px (the later-painted one visually cutting into the earlier). + * Here each component's outward bleed is capped at half the empty-pixel space + * to its nearest distinct neighbour, and a flush neighbour forces a small + * inward erosion (`MIN_COMPONENT_SEP`) so a seam always shows. Components with + * room to spare keep the full `panelBleed`, so nothing changes for layouts that + * already separate their panels. */ +function computeComponentOutlines( + components: ReadonlyArray>>, + block: number, + gap: number, + panelBleed: number, +): Array<{ gap: number; bleed: number }> { + const cells = components.map((members) => + members.flatMap((m) => placedCells(m)), + ); + return components.map((_, i) => { + let minCheb = Infinity; + for (let j = 0; j < components.length; j++) { + if (j === i) continue; + const d = minComponentCheb(cells[i], cells[j]); + if (d < minCheb) minCheb = d; + } + if (minCheb === Infinity) return { gap, bleed: panelBleed }; + const emptyPx = Math.max(0, minCheb - 1) * block; + // Symmetric outward offset each component may take while still leaving a + // MIN_COMPONENT_SEP seam between the two (both offset toward the seam). + const offset = Math.min(panelBleed, emptyPx / 2 - MIN_COMPONENT_SEP / 2); + if (offset >= 0) return { gap, bleed: offset }; + // Flush / near neighbour: drop the outward bleed and erode inward instead, + // so the two outlines pull apart into a visible seam (erosion = |offset|). + return { gap: Math.max(gap, -2 * offset), bleed: 0 }; + }); +} + /** 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 +843,14 @@ export function NotchGrid({ }; }, [keyedItems, resolvedCols, nest, overrides, promoted]); + // Per-component outline geometry (bleed capped / seam enforced against the + // nearest distinct neighbour) — memoised separately from the solve so it only + // recomputes on resize (block change), not on every drag tick. + const componentOutlines = useMemo( + () => computeComponentOutlines(components, block, gap, panelBleed), + [components, block, gap, panelBleed], + ); + const unfitKey = layout?.unfit.join(",") ?? ""; useEffect(() => { if (isDev && layout && layout.unfit.length > 0) { @@ -796,8 +871,11 @@ export function NotchGrid({ ...style, }} > - {components.map((members) => { + {components.map((members, compIndex) => { const compKey = members.map((m) => m.key).join("|"); + // Neighbour-aware outline params: bleed capped (and a seam eroded) so + // this component can't overlap a distinct one that ended up flush. + const outline = componentOutlines[compIndex] ?? { gap, bleed: panelBleed }; // 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 = @@ -814,9 +892,9 @@ export function NotchGrid({ key={compKey} members={members} block={block} - gap={gap} + gap={outline.gap} contentPad={contentPad} - panelBleed={panelBleed} + panelBleed={outline.bleed} primitives={primitives} onItemError={onItemError} draggable={draggable} From 3b2e5d282841cb14c7eb56187b8f8f1c1137ed4f Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Wed, 1 Jul 2026 15:03:50 +0800 Subject: [PATCH 2/5] fix(NotchGrid): enforce the min component seam as a cell fraction, not a fixed px MIN_COMPONENT_SEP was a flat 2px, imperceptible at typical block sizes and not what "leave a gap between distinct panels" actually needs. Replaced with MIN_COMPONENT_SEP_FRACTION (0.25 of one cell), so the guaranteed seam scales with the grid's live block size the same way callers already reason about spacing in cell/tile units. --- .../ui/surfaces/notch-grid/NotchGrid.test.tsx | 10 ++++++-- .../ui/surfaces/notch-grid/NotchGrid.tsx | 24 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx b/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx index e4a4a60c9..cb67d8e39 100644 --- a/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx +++ b/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx @@ -210,8 +210,14 @@ describe("NotchGrid", () => { }; const [a, b] = svgs.map(absYExtent); const [upper, lower] = a.top <= b.top ? [a, b] : [b, a]; - // Seam >= 0: the upper panel's outline bottom does not cross the lower's top. - expect(lower.top).toBeGreaterThanOrEqual(upper.bottom); + // 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, + ); }); it("applies inline color from resolved theme (variant=primary)", () => { diff --git a/src/components/ui/surfaces/notch-grid/NotchGrid.tsx b/src/components/ui/surfaces/notch-grid/NotchGrid.tsx index 67ee5120b..341f48ba2 100644 --- a/src/components/ui/surfaces/notch-grid/NotchGrid.tsx +++ b/src/components/ui/surfaces/notch-grid/NotchGrid.tsx @@ -138,11 +138,15 @@ 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 px seam the kit guarantees between two DISTINCT components' outlines, - * so they never render overlapping or fully flush — even at `gap=0` with a - * `panelBleed` that would otherwise dilate them into each other. Small enough - * to read as a hairline; the differing per-panel fills do the rest. */ -const MIN_COMPONENT_SEP = 2; +/** Minimum seam the kit guarantees between two DISTINCT components' outlines, + * as a FRACTION OF ONE CELL — so it scales with `block` (the grid's live cell + * size, which callers resize responsively) instead of staying a fixed pixel + * value that reads as imperceptible at typical block sizes. `0.25` matches a + * quarter-cell gap, the same unit callers already use for their own panel + * spacing (e.g. `gap` in visual-tile callers), so a component never renders + * closer to a distinct neighbour than that — even at `gap=0` with a + * `panelBleed` that would otherwise dilate them into each other. */ +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 { @@ -387,8 +391,9 @@ function minComponentCheb( * counter-erosion, so two distinct components on touching cells overlap by * `2·panelBleed` px (the later-painted one visually cutting into the earlier). * Here each component's outward bleed is capped at half the empty-pixel space - * to its nearest distinct neighbour, and a flush neighbour forces a small - * inward erosion (`MIN_COMPONENT_SEP`) so a seam always shows. Components with + * to its nearest distinct neighbour, and a flush/near neighbour forces a small + * inward erosion so a seam of at least `MIN_COMPONENT_SEP_FRACTION * block` + * (a quarter-cell, not a fixed pixel value) always shows. Components with * room to spare keep the full `panelBleed`, so nothing changes for layouts that * already separate their panels. */ function computeComponentOutlines( @@ -400,6 +405,7 @@ function computeComponentOutlines( const cells = components.map((members) => members.flatMap((m) => placedCells(m)), ); + const minSep = MIN_COMPONENT_SEP_FRACTION * block; return components.map((_, i) => { let minCheb = Infinity; for (let j = 0; j < components.length; j++) { @@ -410,8 +416,8 @@ function computeComponentOutlines( if (minCheb === Infinity) return { gap, bleed: panelBleed }; const emptyPx = Math.max(0, minCheb - 1) * block; // Symmetric outward offset each component may take while still leaving a - // MIN_COMPONENT_SEP seam between the two (both offset toward the seam). - const offset = Math.min(panelBleed, emptyPx / 2 - MIN_COMPONENT_SEP / 2); + // minSep seam between the two (both offset toward the seam). + const offset = Math.min(panelBleed, emptyPx / 2 - minSep / 2); if (offset >= 0) return { gap, bleed: offset }; // Flush / near neighbour: drop the outward bleed and erode inward instead, // so the two outlines pull apart into a visible seam (erosion = |offset|). From eae90a5a3ac6aa7861a4b6df6eeb9ff1fe4037e8 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Wed, 1 Jul 2026 15:19:03 +0800 Subject: [PATCH 3/5] fix(NotchGrid): enforce the min seam via position, not shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-component bleed-capping/eroding approach (computeComponentOutlines) with a position nudge (computeComponentNudges): distinct touching components get pushed apart by enough to cancel their bleed's outward dilation plus the desired MIN_COMPONENT_SEP_FRACTION seam, applied to where each component renders (its wrapper's left/top, the same mechanism already used for drag offsets) rather than varying its gap/panelBleed. Every component's own outline shape is now identical regardless of its neighbors — only its position shifts. Strengthened the regression test to also assert each panel's own outline dimensions are unchanged (proving the fix is position-only), and fixed the nudge magnitude to account for panelBleed's full outward reach (previously only offset by half the target seam, which undershot when panelBleed was nonzero). --- .../ui/surfaces/notch-grid/NotchGrid.test.tsx | 27 ++- .../ui/surfaces/notch-grid/NotchGrid.tsx | 185 +++++++++++------- 2 files changed, 138 insertions(+), 74 deletions(-) diff --git a/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx b/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx index cb67d8e39..90f39f1bc 100644 --- a/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx +++ b/src/components/ui/surfaces/notch-grid/NotchGrid.test.tsx @@ -195,9 +195,10 @@ describe("NotchGrid", () => { 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` via style.top); convert - // to absolute and check the earlier panel's bottom sits above the later's - // top (a positive seam, not a negative overlap). + // 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; @@ -218,6 +219,26 @@ describe("NotchGrid", () => { 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)", () => { diff --git a/src/components/ui/surfaces/notch-grid/NotchGrid.tsx b/src/components/ui/surfaces/notch-grid/NotchGrid.tsx index 341f48ba2..b6142af0a 100644 --- a/src/components/ui/surfaces/notch-grid/NotchGrid.tsx +++ b/src/components/ui/surfaces/notch-grid/NotchGrid.tsx @@ -138,14 +138,14 @@ 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' outlines, - * as a FRACTION OF ONE CELL — so it scales with `block` (the grid's live cell - * size, which callers resize responsively) instead of staying a fixed pixel - * value that reads as imperceptible at typical block sizes. `0.25` matches a - * quarter-cell gap, the same unit callers already use for their own panel - * spacing (e.g. `gap` in visual-tile callers), so a component never renders - * closer to a distinct neighbour than that — even at `gap=0` with a - * `panelBleed` that would otherwise dilate them into each other. */ +/** 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. */ @@ -362,67 +362,101 @@ export function findConnectedComponents( return out; } -/** Minimum Chebyshev cell distance between two components' filled cells. - * `1` = touching (edge OR diagonal); larger = that-many-plus empty cells apart. - * Short-circuits at 1 — the closest two non-overlapping components can sit. */ -function minComponentCheb( - a: ReadonlyArray, - b: ReadonlyArray, -): number { - let best = Infinity; - for (const [ax, ay] of a) { - for (const [bx, by] of b) { - const d = Math.max(Math.abs(ax - bx), Math.abs(ay - by)); - if (d < best) { - best = d; - if (best <= 1) return best; - } - } +/** 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 best; + return { minCol, minRow, maxCol, maxRow }; } -/** Per-component outline geometry that GUARANTEES distinct components never - * render with overlapping (or fully flush) outlines — the frame separation a - * caller would otherwise have to hand-maintain by leaving empty cells between - * panels. +/** 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, and `gap=0` applies no - * counter-erosion, so two distinct components on touching cells overlap by - * `2·panelBleed` px (the later-painted one visually cutting into the earlier). - * Here each component's outward bleed is capped at half the empty-pixel space - * to its nearest distinct neighbour, and a flush/near neighbour forces a small - * inward erosion so a seam of at least `MIN_COMPONENT_SEP_FRACTION * block` - * (a quarter-cell, not a fixed pixel value) always shows. Components with - * room to spare keep the full `panelBleed`, so nothing changes for layouts that - * already separate their panels. */ -function computeComponentOutlines( + * `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, -): Array<{ gap: number; bleed: number }> { - const cells = components.map((members) => - members.flatMap((m) => placedCells(m)), - ); +): ReadonlyArray { + const boxes = components.map(componentBBox); const minSep = MIN_COMPONENT_SEP_FRACTION * block; - return components.map((_, i) => { - let minCheb = Infinity; - for (let j = 0; j < components.length; j++) { - if (j === i) continue; - const d = minComponentCheb(cells[i], cells[j]); - if (d < minCheb) minCheb = d; + // 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; + } + } } - if (minCheb === Infinity) return { gap, bleed: panelBleed }; - const emptyPx = Math.max(0, minCheb - 1) * block; - // Symmetric outward offset each component may take while still leaving a - // minSep seam between the two (both offset toward the seam). - const offset = Math.min(panelBleed, emptyPx / 2 - minSep / 2); - if (offset >= 0) return { gap, bleed: offset }; - // Flush / near neighbour: drop the outward bleed and erode inward instead, - // so the two outlines pull apart into a visible seam (erosion = |offset|). - return { gap: Math.max(gap, -2 * offset), bleed: 0 }; - }); + } + return components.map((_, i) => [nudgeX[i], nudgeY[i]] as const); } /** Bucket placements by their effective group (from `groupOf`, keyed by item @@ -849,11 +883,13 @@ export function NotchGrid({ }; }, [keyedItems, resolvedCols, nest, overrides, promoted]); - // Per-component outline geometry (bleed capped / seam enforced against the - // nearest distinct neighbour) — memoised separately from the solve so it only - // recomputes on resize (block change), not on every drag tick. - const componentOutlines = useMemo( - () => computeComponentOutlines(components, block, gap, panelBleed), + // 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], ); @@ -879,9 +915,10 @@ export function NotchGrid({ > {components.map((members, compIndex) => { const compKey = members.map((m) => m.key).join("|"); - // Neighbour-aware outline params: bleed capped (and a seam eroded) so - // this component can't overlap a distinct one that ended up flush. - const outline = componentOutlines[compIndex] ?? { gap, bleed: panelBleed }; + // 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 = @@ -898,9 +935,10 @@ export function NotchGrid({ key={compKey} members={members} block={block} - gap={outline.gap} + gap={gap} + nudge={nudge} contentPad={contentPad} - panelBleed={outline.bleed} + panelBleed={panelBleed} primitives={primitives} onItemError={onItemError} draggable={draggable} @@ -1015,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; @@ -1055,6 +1097,7 @@ const NotchComponent = memo(function NotchComponent({ members, block, gap, + nudge, contentPad, panelBleed, primitives, @@ -1164,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) { From 925ed615b2988fe398c9c911d14d549a64de626e Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Wed, 1 Jul 2026 15:28:26 +0800 Subject: [PATCH 4/5] fix(AppShell): seed agent body height synchronously to close a background race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentBodyH started at 0 and was only ever set by ResizeObserver's own callback, which the spec requires to fire asynchronously — even for the very first observation. That left a real (if usually brief) window on every sidebar open where windowBodyHeight was omitted from AgentSidebarHeader, so the transparent body (which depends entirely on the header's single-shape fill reaching down over it) rendered with no background at all until the async callback landed. Switch the effect to useLayoutEffect and seed agentBodyH synchronously via getBoundingClientRect() before attaching the observer, so the very first paint already has a real height. The observer still owns every subsequent resize (drag, viewport changes, content growth) — this only closes the initial gap. --- src/components/layout/app-shell/AppShell.tsx | 22 ++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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); From e55b514305dc0fde831926268a2c8a13a6b5b7cc Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Wed, 1 Jul 2026 17:03:33 +0800 Subject: [PATCH 5/5] chore(release): bump to 0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflects the NotchGrid position-nudge fix (gap=0 + panelBleed distinct-panel overlap) as a minor rather than patch — the fix changed the resolved geometry of any component that touches a distinct neighbor, not just an edge case. --- CHANGELOG.md | 26 ++++++++++++++------------ package.json | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1590f3e83..f8f2132a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,18 +3,20 @@ Notable API additions and breaking changes. For the full commit log, see [GitHub Releases](https://github.com/mirrorstack-ai/web-ui-kit/releases). -## 0.5.17 - -- **Fix: `NotchGrid` no longer lets distinct panels overlap 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 caps each component's outward - bleed at half the empty space to its nearest distinct neighbour and forces a - small seam when a neighbour is flush, so distinct components always stay - visually separated regardless of the `gap` prop. Components with room to - spare keep the full `panelBleed`, so well-separated layouts are unchanged. +## 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 diff --git a/package.json b/package.json index 27c30bf10..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.17", + "version": "0.6.0", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": {