diff --git a/package.json b/package.json index 47574ec74..3af983163 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.13", + "version": "0.5.14", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/ui/surfaces/notch/Notch.test.tsx b/src/components/ui/surfaces/notch/Notch.test.tsx index 44c6bd566..08a015e8b 100644 --- a/src/components/ui/surfaces/notch/Notch.test.tsx +++ b/src/components/ui/surfaces/notch/Notch.test.tsx @@ -50,6 +50,19 @@ describe("Notch", () => { expect(svg).toHaveAttribute("viewBox", "-0.5 -0.5 67 47"); }); + it("bleeds the headOnly connecting edge to avoid a fractional-DPI seam", () => { + const { container } = render( + , + ); + const svg = container.querySelector("svg"); + // The flat connecting edge (build x=0) is extended to x=-1 so the tab fill + // OVERLAPS the surface below rather than abutting it edge-to-edge — otherwise + // the shared boundary aliases into a 1px seam on non-integer DPR. overflow + // must be visible so that 1px bleed isn't clipped to the box. + expect(svg).toHaveAttribute("overflow", "visible"); + expect(svg?.querySelector("path")?.getAttribute("d")).toMatch(/^M -1,/); + }); + it("applies fill and stroke", () => { const { container } = render( , diff --git a/src/components/ui/surfaces/notch/Notch.tsx b/src/components/ui/surfaces/notch/Notch.tsx index 518407222..637ec853a 100644 --- a/src/components/ui/surfaces/notch/Notch.tsx +++ b/src/components/ui/surfaces/notch/Notch.tsx @@ -80,17 +80,34 @@ function buildPath( return d.join(" "); } -function buildHeadPath(nw: number, nh: number, r: number, ir: number, atStart: boolean, atEnd: boolean) { +// A headOnly tab sits flush on the surface below it. Extending its fill 1px past +// that flat CONNECTING edge so the two same-colour fills OVERLAP (rather than abut +// edge-to-edge) removes a sub-pixel seam: when the shared boundary lands on a +// fractional device-pixel row — sub-pixel transforms or non-integer DPR such as +// Windows display scaling — each fill paints <100% coverage on that physical row +// and the layer beneath shows through as a 1px line. A whole-pixel overlap closes +// the gap. (Invisible on integer-DPR/retina, where the edges snap and abut cleanly.) +const HEAD_SEAM_BLEED = 1; + +function buildHeadPath( + nw: number, nh: number, r: number, ir: number, + atStart: boolean, atEnd: boolean, + // px to extend the flat connecting edge (build x = 0) past the box, toward the + // surface the tab attaches to. Only the build-x=0 edge moves; the radiused tab + // corners (build x = w, the tab TOP) are untouched. `bleed = 0` ⇒ original path. + bleed = 0, +) { const w = nw + ir; const topIr = atStart ? 0 : ir; const botIr = atEnd ? 0 : ir; + const x0 = -bleed; const d: string[] = []; if (!atStart) { - d.push(`M 0,0`); + d.push(`M ${x0},0`); d.push(`A ${ir},${ir} 0 0,0 ${ir},${topIr}`); } else { - d.push(`M 0,${topIr}`); + d.push(`M ${x0},${topIr}`); } d.push(`H ${w - r}`); @@ -100,7 +117,7 @@ function buildHeadPath(nw: number, nh: number, r: number, ir: number, atStart: b d.push(`H ${ir}`); if (!atEnd) { - d.push(`A ${ir},${ir} 0 0,0 0,${topIr + nh + botIr}`); + d.push(`A ${ir},${ir} 0 0,0 ${x0},${topIr + nh + botIr}`); } d.push(`V 0`); @@ -188,7 +205,7 @@ export function Notch({ const botIr = atEnd ? 0 : ir; const pathW = notchWidth + ir; const pathH = notchHeight + topIr + botIr; - const headPath = buildHeadPath(notchWidth, notchHeight, radius, ir, atStart, atEnd); + const headPath = buildHeadPath(notchWidth, notchHeight, radius, ir, atStart, atEnd, HEAD_SEAM_BLEED); let svgW: number, svgH: number; if (horiz) { svgW = pathW; svgH = pathH; } @@ -201,10 +218,13 @@ export function Notch({ // every side so the centered stroke renders fully INSIDE the box. (Sizing // the element to svgW+strokeWidth instead pins it `pad` larger and pushes // the right & bottom strokes outside the box.) + // `overflow: visible` lets the HEAD_SEAM_BLEED extend the fill 1px past the + // box on the connecting edge without being clipped to the viewport.