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/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) {