Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions src/components/ui/surfaces/notch/Notch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Notch width={200} height={150} notchWidth={60} notchHeight={40} headOnly />,
);
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(
<Notch width={200} height={150} notchWidth={40} notchHeight={50} fill="red" stroke="blue" strokeWidth={2} />,
Expand Down
30 changes: 25 additions & 5 deletions src/components/ui/surfaces/notch/Notch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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`);
Expand Down Expand Up @@ -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; }
Expand All @@ -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.
<svg
width={svgW}
height={svgH}
viewBox={`${-pad} ${-pad} ${svgW + strokeWidth} ${svgH + strokeWidth}`}
overflow="visible"
className={cn("pointer-events-none", className)}
style={style}
>
Expand Down
Loading