From 364157ab7e8adca08ace108eb3769fc57efe4314 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 08:41:45 -0700 Subject: [PATCH 1/2] fix(ui): draw the frozen-column seam once per plane, not once per cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam started and stopped once per row. It was a `box-shadow` on every pinned cell, and a per-cell shadow cannot tile into a continuous edge: the blur has to be held inside the cell (spread <= -blur/2) or neighbouring shadows overlap and double into a dark band at each row boundary — so it faded to nothing at every boundary instead, and read as a dashed line rather than the edge of a frozen pane. This file had the trade documented and had picked the fading half. Draw it once per plane instead. A gradient has no falloff along its own axis, so one box spanning the sticky header row and one spanning the scroll content are each uniform for their full height, and the two meet exactly at the header's lower edge with nothing between them. Both sides come from the two pseudo-elements of each plane, which is why those planes are flex here: two in-flow boxes in a block container stack VERTICALLY, which would put the right-hand seam below the content instead of beside the left one. Rows are absolutely positioned, so making their container a flex box moves nothing that is already drawn. The surface publishes the only thing CSS cannot work out — where each edge falls — as `--pretable-pinned-left-edge` / `--pretable-pinned-right-edge`, gated by `data-pretable-pinned-left` / `-right` so a side with nothing pinned draws nothing rather than a seam down the grid's own border. Both are viewport-x and the right-hand one goes through the same `getPinnedRightEdge` the right-pinned CELLS use: a right-pinned cell is stuck by a computed `left`, never a `right` inset, so a seam anchored the other way would clamp differently from the columns it marks. Before the scrollport is measured the right edge is withheld, exactly as those cells go unpinned. One regression on the way, caught by the smoke suite rather than by me: the header row's pinned cells are IN FLOW (sticky, not absolute), so a seam that consumed 8px of that line shoved the frozen columns 8px clear of the grid's left edge. The negative end margin gives the space back; the box is placed by `left` regardless of where the flow put it. Guards: the seam guard now matches on the rule carrying each side's own gradient rather than the shared block — reading the shared block let a side lose a whole plane with the guard none the wiser, which the mutation run caught. Five mutations fail it (header plane dropped, right side anchored by `right`, planes back to block, seam returning as a per-cell shadow, seam made a pointer target); three more fail the new React tests (attributes dropped, right edge published unmeasured, edges published with nothing pinned). The two edge properties are registered as RUNTIME_VARS, and a typo'd one still fails the token contract. Verified in a browser on a production build: hero grid scrolled sideways, the seam is one unbroken gradient from the top of the header through every row; the showcase's right-pinned column mirrors it at the group's leading edge (912px against the cell's 920px); a grouped grid crosses its group bands without a notch; and both pinned header and body cells sit flush at offset 0. 136 Playwright specs pass across smoke, popover-scroll, header-keyboard, keyboard-a11y, grouping and range-selection. Co-Authored-By: Claude Opus 5 --- .changeset/pinned-seam-continuous.md | 22 +++ .../src/__tests__/right-pin-surface.test.tsx | 69 +++++++ packages/react/src/pretable-surface.tsx | 22 +++ packages/react/src/styles.ts | 45 +++++ packages/ui/grid.css | 149 ++++++++++----- packages/ui/src/__tests__/contract.test.ts | 11 +- packages/ui/src/__tests__/css-cascade.test.ts | 177 ++++++++++++------ 7 files changed, 392 insertions(+), 103 deletions(-) create mode 100644 .changeset/pinned-seam-continuous.md diff --git a/.changeset/pinned-seam-continuous.md b/.changeset/pinned-seam-continuous.md new file mode 100644 index 000000000..b9f8074b5 --- /dev/null +++ b/.changeset/pinned-seam-continuous.md @@ -0,0 +1,22 @@ +--- +"@pretable/ui": patch +"@pretable/react": patch +--- + +The frozen-column seam is now one continuous edge instead of a shadow that +faded out at every row boundary. + +It was a `box-shadow` on each pinned CELL, and a per-cell shadow cannot tile. +The blur has to stay inside the cell — a spread any less negative bleeds above +and below it and doubles into a dark band at each boundary — so the seam faded +to nothing once per row and read as a dashed edge rather than a frozen pane's. + +grid.css now draws it as one full-height gradient per plane: the sticky header +row and the scroll content, meeting exactly at the header's lower edge. A +gradient has no falloff along its own axis, so each box is uniform for its +plane's whole height. The surface publishes where each edge falls +(`--pretable-pinned-left-edge` / `--pretable-pinned-right-edge`, gated by +`data-pretable-pinned-left` / `-right`), taking the right-hand one through the +same `getPinnedRightEdge` the right-pinned cells use so the seam cannot land a +pixel off the column it marks. `--pretable-seam-color` still colours it, and a +side with nothing pinned draws nothing. diff --git a/packages/react/src/__tests__/right-pin-surface.test.tsx b/packages/react/src/__tests__/right-pin-surface.test.tsx index 1e7d6bafa..13f98ea3c 100644 --- a/packages/react/src/__tests__/right-pin-surface.test.tsx +++ b/packages/react/src/__tests__/right-pin-surface.test.tsx @@ -1100,3 +1100,72 @@ describe("right pin × row grouping", () => { expect(colCount(container)).toBe("4"); }); }); + +describe("the frozen edges the seam is drawn from", () => { + // grid.css draws the seam ONCE per plane rather than per cell — a per-cell + // box-shadow cannot tile into a continuous edge — so the surface has to + // publish where each edge falls. jsdom proves only what is emitted; that the + // gradient lands on the boundary is a browser assertion. + const viewport = (container: HTMLElement) => + container.querySelector("[data-pretable-scroll-viewport]")!; + + it("publishes both edges, in the same viewport-x the cells are pinned at", () => { + const { container } = renderSurface(); + const el = viewport(container); + + expect(el).toHaveAttribute("data-pretable-pinned-left"); + expect(el).toHaveAttribute("data-pretable-pinned-right"); + // Left: the pinned run's own width. Right: taken through the same + // getPinnedRightEdge the cells use, so the seam cannot land a pixel off + // the column it marks. + expect(el.style.getPropertyValue("--pretable-pinned-left-edge")).toBe( + `${LEFT_WIDTH}px`, + ); + expect(el.style.getPropertyValue("--pretable-pinned-right-edge")).toBe( + `${VIEWPORT_WIDTH - RIGHT_PREV_WIDTH - RIGHT_LAST_WIDTH}px`, + ); + // The right-pinned CELLS agree with the edge the seam is given: the + // leading one starts exactly there. + expect(bodyCell(container, "status")!.style.left).toBe( + `${VIEWPORT_WIDTH - RIGHT_PREV_WIDTH - RIGHT_LAST_WIDTH}px`, + ); + }); + + it("publishes nothing for a side with nothing pinned", () => { + // Without this the stylesheet would resolve an unset edge to zero and draw + // a seam down the grid's own left border, marking a boundary that is not + // there. + const { container } = render( + row.id} + overscan={0} + rows={rows} + viewportHeight={200} + />, + ); + const el = viewport(container); + + expect(el).not.toHaveAttribute("data-pretable-pinned-left"); + expect(el).not.toHaveAttribute("data-pretable-pinned-right"); + expect(el.style.getPropertyValue("--pretable-pinned-left-edge")).toBe(""); + expect(el.style.getPropertyValue("--pretable-pinned-right-edge")).toBe(""); + }); + + it("withholds the right edge until the scrollport has been measured", () => { + // The right edge is viewportWidth-relative, exactly like the cells' own + // sticky inset: before the first measure those cells are not pinned at + // all, and a seam drawn at a guessed edge would sit in open space. + clientWidth = 0; + const { container } = renderSurface(); + const el = viewport(container); + + expect(el).toHaveAttribute("data-pretable-pinned-left"); + expect(el).not.toHaveAttribute("data-pretable-pinned-right"); + expect(el.style.getPropertyValue("--pretable-pinned-right-edge")).toBe(""); + }); +}); diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index df7741338..251bf3dc2 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -121,6 +121,7 @@ import { getScrollContentStyle, getToolPanelGridAreaStyle, getToolPanelLayoutStyle, + getSeamStyle, getViewportStyle, } from "./styles"; import { @@ -6324,9 +6325,30 @@ export function PretableSurface< setViewportWidth(el.clientWidth); } }} + // The frozen edges, for the seam grid.css draws once per plane. Both + // planes (header row and scroll content) are descendants, so the two + // custom properties are published here and inherit; the attributes are + // what the stylesheet keys on, so a side with nothing pinned draws + // nothing rather than a seam at x=0. AFTER the consumer's + // `viewportStyle` on purpose: these are the surface's own layout math, + // not skin, and a stray consumer style must not move a frozen edge off + // the column it marks. + data-pretable-pinned-left={ + renderSnapshot.pinnedLeftWidth > 0 ? "" : undefined + } + data-pretable-pinned-right={ + renderSnapshot.pinnedRightWidth > 0 && viewportWidth > 0 + ? "" + : undefined + } style={{ ...getViewportStyle(scrollViewportHeight), ...viewportStyle, + ...getSeamStyle( + renderSnapshot.pinnedLeftWidth, + renderSnapshot.pinnedRightWidth, + viewportWidth, + ), }} >
0 + ? getPinnedRightEdge(viewportWidth, pinnedRightWidth) + : undefined; + + return { + ...(pinnedLeftWidth > 0 + ? { "--pretable-pinned-left-edge": `${pinnedLeftWidth}px` } + : {}), + ...(rightEdge !== undefined + ? { "--pretable-pinned-right-edge": `${rightEdge}px` } + : {}), + } as CSSProperties; +} + /** * The horizontal row the surface renders when the tool panel is enabled: * `[vertical grid stack][pane?][rail]`. Stretch (the default cross-axis diff --git a/packages/ui/grid.css b/packages/ui/grid.css index e87bbd7ca..45219bf23 100644 --- a/packages/ui/grid.css +++ b/packages/ui/grid.css @@ -91,23 +91,15 @@ the whole strip, but each header cell on top of it is a transparent box, so an unpinned header's label reads straight through a pinned one once it scrolls underneath. Pinned header cells need their own opaque fill, the - same one their body counterparts get below. - They need the SEAM as well, and for a while they had only the fill: the - frozen edge is one boundary running the height of the grid, and the body - rule below draws it for the body rows only, so it stopped dead at the - header and left a header-tall gap in the middle of the seam. Split in two - rather than one rule for both sides because the offset has to mirror — - the same reason --pretable-seam-color holds a colour and not a shadow. - Only the OUTERMOST pinned cell's seam is ever seen: pinned siblings share - z-index 1, so each one paints over the shadow the cell before it cast, - exactly as the body's do. */ - :where([data-pretable-header-cell][data-pretable-pinned="left"]) { - background: var(--pretable-bg-header); - box-shadow: 8px 0 8px -8px var(--pretable-seam-color); - } - :where([data-pretable-header-cell][data-pretable-pinned="right"]) { + same one their body counterparts get below. The seam that marks the + frozen edge is NOT here: it is one gradient per plane, drawn at the end + of this file's pinned section, because no per-cell rule can draw a + continuous edge. */ + :where( + [data-pretable-header-cell][data-pretable-pinned="left"], + [data-pretable-header-cell][data-pretable-pinned="right"] + ) { background: var(--pretable-bg-header); - box-shadow: -8px 0 8px -8px var(--pretable-seam-color); } /* Multi-sort priority badge (rendered only when 2+ columns are sorted) */ @@ -254,38 +246,107 @@ ); } - /* Pinned cells (sticky left) — an opaque fill so scrolled columns pass - underneath, on their own token rather than the header's. - The seam shadow marks the frozen edge for themes that do not mark it any - other way. A theme that drops the vertical rule AND gives --pretable-bg-pinned - no tone step has no boundary at all otherwise: unpinned cells scroll under - these at z-index 1, so text would appear clipped mid-glyph at an invisible - line. The spread is <= -blur/2 on purpose — any less negative and the blur - bleeds above and below each cell and paints a dark band at every row - boundary inside the pinned region. - This box-shadow slot is the seam's alone. It used to be contested: the - cell focus ring was an inset box-shadow too, and box-shadow is not - additive across rules, so a FOCUSED pinned cell showed the ring and lost - its seam for as long as it held focus. That was accepted while both - shipped themes set --pretable-seam-color: transparent and the loss was - unobservable — a precondition that failed the moment the house theme drew - a visible seam. The focus ring is now an `outline` (see the Focus rule - below), which composes with this shadow, so the seam survives focus. - Anything added here must keep that property: a second box-shadow on this - element silently wins the whole slot. */ - :where([data-pretable-cell][data-pretable-pinned="left"]) { + /* Pinned cells — an opaque fill so scrolled columns pass underneath, on + their own token rather than the header's. The seam that marks the frozen + edge is drawn once per plane below, NOT here; these rules carry only the + fill and the stacking that lets the scrolled columns run under them. */ + :where( + [data-pretable-cell][data-pretable-pinned="left"], + [data-pretable-cell][data-pretable-pinned="right"] + ) { background: var(--pretable-bg-pinned); - box-shadow: 8px 0 8px -8px var(--pretable-seam-color); z-index: 1; } - /* Pinned cells (sticky right) — mirror of the left rule, including the seam, - whose offset has to flip. That mirroring is why --pretable-seam-color holds - a COLOUR and not a whole shadow: one shadow value cannot be reversed. */ - :where([data-pretable-cell][data-pretable-pinned="right"]) { - background: var(--pretable-bg-pinned); - box-shadow: -8px 0 8px -8px var(--pretable-seam-color); - z-index: 1; + /* ---- The frozen edge's seam ------------------------------------------ + It marks the boundary for themes that do not mark it any other way. A + theme that drops the vertical rule AND gives --pretable-bg-pinned no tone + step has no boundary at all otherwise: unpinned cells scroll under the + pinned ones at z-index 1, so text would appear clipped mid-glyph at an + invisible line. + + ONE gradient per plane, not a shadow per cell. A per-cell `box-shadow` + cannot tile into a continuous edge, and both ways of trying are visibly + wrong: a spread at or below -blur/2 keeps the blur inside each cell, so + the seam fades to nothing at every row boundary and reads as a dashed + edge; a spread large enough to reach the cell's own edges makes + neighbouring shadows overlap and doubles the alpha into a dark band at + each boundary instead. This file shipped the first of those. A gradient + has no falloff along its own axis, so one box spanning a whole plane is + uniform top to bottom, and the two planes — the sticky header row and the + scroll content — meet exactly at the header's lower edge with nothing + between them. + + Each plane draws both sides from its own two pseudo-elements, which is + why the planes are laid out as flex here: two in-flow boxes in a block + container would stack VERTICALLY, putting the second seam below the + content instead of beside the first. Rows are absolutely positioned, so + making their container a flex box moves nothing that is already drawn. + + Sticky, and anchored by `left` on BOTH sides — the same way the cells + themselves are pinned (a right-pinned cell is stuck by a computed `left`, + never a `right` inset), so the seam clamps exactly as its columns do when + the content is narrower than the viewport. The two edges arrive as custom + properties from the surface, which is the only place that knows them; the + attributes are what gate the rules, so a side with nothing pinned draws + nothing rather than a seam at x=0. */ + :where( + [data-pretable-pinned-left] [data-pretable-header-row], + [data-pretable-pinned-left] [data-pretable-scroll-content], + [data-pretable-pinned-right] [data-pretable-header-row], + [data-pretable-pinned-right] [data-pretable-scroll-content] + ) { + display: flex; + } + :where( + [data-pretable-pinned-left] [data-pretable-header-row], + [data-pretable-pinned-left] [data-pretable-scroll-content] + )::after, + :where( + [data-pretable-pinned-right] [data-pretable-header-row], + [data-pretable-pinned-right] [data-pretable-scroll-content] + )::before { + content: ""; + position: sticky; + align-self: stretch; + /* 8px wide, and ZERO wide as far as the flow is concerned. The header + row's pinned cells are in flow (they are sticky, not absolute), so a + seam that consumed 8px of the line would shove the frozen columns 8px + clear of the grid's own left edge — which is exactly what it did, and + what apps/website/e2e/smoke.spec.ts caught. The negative end margin + gives the space straight back; the box itself is placed by `left` + anyway, never by where the flow put it. */ + flex: 0 0 8px; + margin-inline-end: -8px; + /* Above the columns that scroll under it (z-index auto), and never a + pointer target: it is a boundary, not a control. */ + z-index: 2; + pointer-events: none; + } + :where( + [data-pretable-pinned-left] [data-pretable-header-row], + [data-pretable-pinned-left] [data-pretable-scroll-content] + )::after { + left: var(--pretable-pinned-left-edge); + background: linear-gradient( + to right, + var(--pretable-seam-color), + transparent + ); + } + /* The mirror. Its own 8px sits to the LEFT of the edge, so it starts where + the right-pinned group does — this is why --pretable-seam-color holds a + COLOUR rather than a whole shadow: one value could not be reversed. */ + :where( + [data-pretable-pinned-right] [data-pretable-header-row], + [data-pretable-pinned-right] [data-pretable-scroll-content] + )::before { + left: calc(var(--pretable-pinned-right-edge) - 8px); + background: linear-gradient( + to left, + var(--pretable-seam-color), + transparent + ); } /* A pinned cell inside a group row is still part of the band. Both pinned diff --git a/packages/ui/src/__tests__/contract.test.ts b/packages/ui/src/__tests__/contract.test.ts index 91887bbcb..6e5179270 100644 --- a/packages/ui/src/__tests__/contract.test.ts +++ b/packages/ui/src/__tests__/contract.test.ts @@ -78,8 +78,17 @@ const GRID_CSS = path.resolve(__dirname, "../../grid.css"); * * - `--pretable-group-depth`: a row's grouping depth, set inline on group cells * and once on the scroll content for leaf rows. + * - `--pretable-pinned-left-edge` / `--pretable-pinned-right-edge`: where each + * frozen edge falls in viewport-x, published on the scroll viewport so the + * seam can be drawn as one gradient per plane instead of a shadow per cell. + * Layout math, not skin: a theme colours the seam through + * `--pretable-seam-color` and has no business moving it off its column. */ -const RUNTIME_VARS = new Set(["--pretable-group-depth"]); +const RUNTIME_VARS = new Set([ + "--pretable-group-depth", + "--pretable-pinned-left-edge", + "--pretable-pinned-right-edge", +]); /** * `--pretable-*` custom properties grid.css DECLARES on an element and then diff --git a/packages/ui/src/__tests__/css-cascade.test.ts b/packages/ui/src/__tests__/css-cascade.test.ts index ce39e7514..07b02e735 100644 --- a/packages/ui/src/__tests__/css-cascade.test.ts +++ b/packages/ui/src/__tests__/css-cascade.test.ts @@ -51,17 +51,11 @@ describe("grid.css cascade contract", () => { const css = fs.readFileSync(GRID_CSS, "utf8"); // The header row's own background sits BEHIND its cells; a transparent // pinned header cell lets a scrolled-under header's label read through it. - // One rule per side, because each also carries its own mirrored seam (see - // the pinned-seam test) — a shared rule cannot hold two offsets. - for (const side of ["left", "right"]) { - const rule = css.match( - new RegExp( - `:where\\(\\[data-pretable-header-cell\\]\\[data-pretable-pinned="${side}"\\]\\)\\s*\\{[^}]*\\}`, - ), - ); - expect(rule?.[0], `no ${side}-pinned header rule`).toBeDefined(); - expect(rule?.[0]).toMatch(/background:\s*var\(--pretable-bg-header\)/); - } + const rule = css.match( + /:where\(\s*\[data-pretable-header-cell\]\[data-pretable-pinned="left"\],\s*\[data-pretable-header-cell\]\[data-pretable-pinned="right"\]\s*\)\s*\{[^}]*\}/, + ); + expect(rule?.[0], "no pinned header rule").toBeDefined(); + expect(rule?.[0]).toMatch(/background:\s*var\(--pretable-bg-header\)/); }); test("pinned body cells and group rows have their own surface tokens", () => { @@ -69,7 +63,7 @@ describe("grid.css cascade contract", () => { // restyle a frozen data column without also restyling the header strip. const css = fs.readFileSync(GRID_CSS, "utf8"); const pinnedBody = css.match( - /:where\(\[data-pretable-cell\]\[data-pretable-pinned="left"\]\)\s*\{([\s\S]*?)\}/, + /:where\(\s*\[data-pretable-cell\]\[data-pretable-pinned="left"\],\s*\[data-pretable-cell\]\[data-pretable-pinned="right"\]\s*\)\s*\{([\s\S]*?)\}/, )?.[1]; expect(pinnedBody, "no left-pinned body rule").toBeDefined(); expect(pinnedBody).toMatch(/background:\s*var\(--pretable-bg-pinned\)/); @@ -81,50 +75,117 @@ describe("grid.css cascade contract", () => { expect(groupRow).toMatch(/background:\s*var\(--pretable-bg-group-row\)/); }); - test("the pinned seam is wired, mirrored, and outlives the group-row band", () => { - // --pretable-seam-color had ZERO consumers before this: it was declared by - // every theme and read by nothing, so a theme that dropped both the vertical - // rule and the pinned tone step had no frozen-column boundary at all. - // The right edge must MIRROR the left offset — that is why the token holds a - // colour rather than a whole shadow, since one shadow value cannot be - // reversed. - const css = fs.readFileSync(GRID_CSS, "utf8"); - const left = css.match( - /:where\(\[data-pretable-cell\]\[data-pretable-pinned="left"\]\)\s*\{([\s\S]*?)\}/, - )?.[1]; - const right = css.match( - /:where\(\[data-pretable-cell\]\[data-pretable-pinned="right"\]\)\s*\{([\s\S]*?)\}/, - )?.[1]; - expect(left, "no left-pinned rule").toBeDefined(); - expect(right, "no right-pinned rule").toBeDefined(); - expect(left).toMatch( - /box-shadow:\s*8px 0 8px -8px var\(--pretable-seam-color\)/, + test("the seam is one full-height gradient per plane, both sides", () => { + // --pretable-seam-color had ZERO consumers once: declared by every theme + // and read by nothing, so a theme that dropped both the vertical rule and + // the pinned tone step had no frozen-column boundary at all. It was then + // wired as a `box-shadow` on every pinned CELL, which cannot tile: the + // blur has to stay inside each cell to avoid doubling into a dark band at + // the row boundaries, so the edge faded out at every one of them and read + // as a dashed line. A gradient has no falloff along its own axis, so one + // box per plane is uniform for that plane's whole height. + const css = strippedCss(); + + // Both sides, and each side reaching BOTH planes — a seam that covers the + // body but not the sticky header is the gap this replaced. Matched on the + // rule that carries THAT SIDE's gradient, never on the shared block: the + // shared block names every plane, so reading it would let a side lose a + // plane with the guard none the wiser (it did, until this was tightened). + for (const [side, direction] of [ + ["left", "to right"], + ["right", "to left"], + ]) { + const painting = rulesSelecting(css, () => true).filter(([, , body]) => + body.includes(`linear-gradient(\n ${direction},`), + ); + expect( + painting.length, + `nothing paints the ${side} seam`, + ).toBeGreaterThan(0); + const selectors = painting.map((m) => m[1]).join(""); + expect(selectors).toContain(`data-pretable-pinned-${side}]`); + for (const plane of ["header-row", "scroll-content"]) { + expect(selectors, `no ${side} seam on the ${plane} plane`).toContain( + `data-pretable-${plane}]`, + ); + } + } + + // And the shared block that makes them boxes at all reaches all four. + const shared = rulesSelecting(css, () => true).filter(([, , body]) => + /content:\s*""/.test(body), + ); + const sharedSeam = shared.filter(([, selector]) => + selector.includes("data-pretable-pinned-"), ); - expect(right).toMatch( - /box-shadow:\s*-8px 0 8px -8px var\(--pretable-seam-color\)/, + expect(sharedSeam.length, "no shared seam block").toBeGreaterThan(0); + const sharedSelectors = sharedSeam.map((m) => m[1]).join(""); + for (const side of ["left", "right"]) { + for (const plane of ["header-row", "scroll-content"]) { + expect( + sharedSelectors, + `the shared seam block misses ${side}/${plane}`, + ).toContain(`data-pretable-${plane}]`); + expect(sharedSelectors).toContain(`data-pretable-pinned-${side}]`); + } + } + + const seamBodies = rulesSelecting( + css, + (selector) => + selector.includes("data-pretable-pinned-left]") || + selector.includes("data-pretable-pinned-right]"), + ) + .map((m) => m[2]) + .join(""); + // Anchored by `left` on BOTH sides, exactly as the cells are: a + // right-pinned cell is stuck by a computed left, never a `right` inset, so + // a seam using `right` would clamp differently from the column it marks. + expect(seamBodies).toMatch(/left:\s*var\(--pretable-pinned-left-edge\)/); + expect(seamBodies).toMatch( + /left:\s*calc\(var\(--pretable-pinned-right-edge\) - 8px\)/, + ); + expect(seamBodies).not.toMatch(/\bright:/); + // Mirrored gradients — the reason the token holds a COLOUR and not a whole + // shadow, since one value could not be reversed. + expect(seamBodies).toMatch( + /linear-gradient\(\s*to right,\s*var\(--pretable-seam-color\),\s*transparent\s*\)/, + ); + expect(seamBodies).toMatch( + /linear-gradient\(\s*to left,\s*var\(--pretable-seam-color\),\s*transparent\s*\)/, ); + // Full height of its plane, and never a pointer target. + expect(seamBodies).toMatch(/align-self:\s*stretch/); + expect(seamBodies).toMatch(/pointer-events:\s*none/); - // The HEADER's pinned cells draw the same seam with the same offsets. The - // frozen edge is one boundary running the height of the grid; a rule that - // reaches the body rows only leaves a header-tall gap in the middle of it, - // which is what shipped while this guard named the body cell alone. - const headerLeft = css.match( - /:where\(\[data-pretable-header-cell\]\[data-pretable-pinned="left"\]\)\s*\{([\s\S]*?)\}/, - )?.[1]; - const headerRight = css.match( - /:where\(\[data-pretable-header-cell\]\[data-pretable-pinned="right"\]\)\s*\{([\s\S]*?)\}/, - )?.[1]; - expect(headerLeft, "no left-pinned HEADER rule").toBeDefined(); - expect(headerRight, "no right-pinned HEADER rule").toBeDefined(); - expect(headerLeft).toMatch( - /box-shadow:\s*8px 0 8px -8px var\(--pretable-seam-color\)/, + // Two in-flow boxes in a BLOCK container stack vertically, which would put + // the right-hand seam below the content instead of beside the left one. + const planes = rulesSelecting( + css, + (selector) => + selector.includes("data-pretable-scroll-content]") && + !selector.includes("::"), ); - expect(headerRight).toMatch( - /box-shadow:\s*-8px 0 8px -8px var\(--pretable-seam-color\)/, + expect( + planes.map((m) => m[2]).join(""), + "the seam planes must be flex or the two seams stack", + ).toMatch(/display:\s*flex/); + + // The seam is off the cells and must not come back: that is the shape that + // cannot tile. + const pinnedCellRules = rulesSelecting( + css, + (selector) => + selector.includes("data-pretable-cell][data-pretable-pinned") || + selector.includes("data-pretable-header-cell][data-pretable-pinned"), ); - // Same opaque fill as before — the seam must not have cost it. - expect(headerLeft).toMatch(/background:\s*var\(--pretable-bg-header\)/); - expect(headerRight).toMatch(/background:\s*var\(--pretable-bg-header\)/); + expect(pinnedCellRules.length).toBeGreaterThan(0); + for (const [, selector, body] of pinnedCellRules) { + expect( + body, + `"${selector.trim()}" draws a per-cell seam, which cannot tile across rows`, + ).not.toMatch(/box-shadow:/); + } // And a frozen column must not punch a notch through a group band: the // pinned rules follow the group-row rule at equal specificity, so the @@ -145,12 +206,12 @@ describe("grid.css cascade contract", () => { test("a focused cell draws its ring with `outline`, never `box-shadow`", () => { // Two reasons, both load-bearing: // - // 1. The pinned seam is a `box-shadow` on the same element, and box-shadow - // is not additive across rules — the last one wins outright. A focus - // ring in that slot erases the frozen-column seam for as long as the - // cell holds focus, which is observable in the house theme (pretable.css - // draws a visible --pretable-seam-color; the two themes that shipped - // when the trade was first accepted both set it to `transparent`). + // 1. box-shadow is not additive across rules — the last one wins outright, + // so a ring in that slot takes the cell's ONE shadow with it. That cost + // the frozen-column seam for as long as a cell held focus, back when the + // seam was a per-cell shadow; the seam has since moved off the cell + // entirely, but the slot is still winner-takes-all and the next shadow a + // cell wants would lose it the same way. // 2. Every other focus affordance in this stylesheet — twisty, group chip, // menu item — is a 2px outline. A cell drawing an inset shadow instead // is an inconsistency a consumer cannot restyle in one place. @@ -166,7 +227,7 @@ describe("grid.css cascade contract", () => { for (const [, selector, body] of focusRules) { expect( body, - `focus rule "${selector.trim()}" draws its ring with box-shadow, which collides with the pinned seam`, + `focus rule "${selector.trim()}" draws its ring with box-shadow, which takes the cell's one shadow slot`, ).not.toMatch(/box-shadow:/); } }); From 2f9b3c0d1f7b9ac0961414864f1a3bc0c2295f68 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 09:10:15 -0700 Subject: [PATCH 2/2] test(bench): the seam/ring pair is proven on two elements, not one slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bench suite's `a focused pinned cell keeps its seam` was written when the seam WAS the cell's box-shadow, and asserted the cell still computed `rgb(1,2,3) 8px 0px 8px -8px` under focus. Moving the seam onto the plane made that assertion false — correctly, and I missed it: I ran the website's Playwright suites and the package unit tests, but never `pnpm bench:e2e`, which is its own project with its own tests. CI found it. The invariant it protects is still worth a browser: the plane paints its seam AND the focused cell draws its ring, at the same time, from one stylesheet. What changed is that the collision it was written against — box-shadow being winner-takes-all on one element — is now impossible by construction, since the two live on different elements. So the test asserts the pair directly: the cell has the outline ring and an EMPTY shadow slot (a ring must never take it back), and the plane's ::after paints the seam colour at the published edge while that cell holds focus. Mutation-checked: with the plane's gradient stopped, it fails. The full bench suite passes locally (28 specs), which is the step that should have preceded the first push. Co-Authored-By: Claude Opus 5 --- apps/bench/tests/cascade-override.spec.ts | 54 +++++++++++++++-------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/bench/tests/cascade-override.spec.ts b/apps/bench/tests/cascade-override.spec.ts index 11c157729..10f9dc4c9 100644 --- a/apps/bench/tests/cascade-override.spec.ts +++ b/apps/bench/tests/cascade-override.spec.ts @@ -33,38 +33,54 @@ test("an unlayered consumer rule beats the layered grid default", async ({ test("a focused pinned cell keeps its seam, and draws exactly one ring", async ({ page, }) => { - // The structural test in @pretable/ui can prove grid.css no longer DECLARES a + // The structural test in @pretable/ui can prove grid.css declares no // box-shadow focus ring. Only a browser can prove the consequence: that the - // ring and the frozen-column seam now coexist on the same cell. + // ring and the frozen-column seam coexist while one cell holds focus. // - // box-shadow is not additive across rules — the winning declaration replaces - // the slot outright, it does not stack. While the ring lived there too, the - // seam vanished for as long as a pinned cell held focus, which is visible in - // the house theme (pretable.css draws a real --pretable-seam-color; the two - // themes shipping when that trade was accepted both set it to transparent). + // The collision this guards used to be literal — the seam was a box-shadow + // on the CELL, box-shadow is not additive across rules, and a ring in that + // slot replaced the seam outright for as long as the cell held focus. The + // seam is now one gradient per plane (a per-cell shadow cannot tile into a + // continuous edge), so the two live on different elements and the old + // collision is impossible by construction. What is still worth proving in a + // real browser is the pair: the plane paints its seam AND the cell draws its + // ring, at the same time, from one stylesheet. // - // The fixture mirrors what @pretable/react renders: role="gridcell" AND - // data-pretable-cell AND data-pretable-focused, all on one element. That is - // the whole reason the doubled ring was invisible in review — each rule read - // correct on its own, and only the real DOM put both on one cell. + // The fixture mirrors what @pretable/react renders: the edge published on + // the viewport, and role="gridcell" AND data-pretable-cell AND + // data-pretable-focused all on one element. That last part is the whole + // reason a doubled ring was once invisible in review — each rule read + // correct alone, and only the real DOM put both on one cell. await page.setContent( - "
' + + "--pretable-focus-ring: rgb(4, 5, 6); " + + '--pretable-pinned-left-edge: 40px">' + + '
' + "
" + 'x' + - "
", + "
", ); await page.addStyleTag({ path: GRID_CSS }); const cell = page.locator("#cell"); - // The seam survives focus — it owns the box-shadow slot alone now. - await expect(cell).toHaveCSS("box-shadow", "rgb(1, 2, 3) 8px 0px 8px -8px"); - // And the ring is drawn, once, as an outline. `inset` in the shadow would - // mean the second ring came back and took the seam's slot with it. + // The ring, drawn once, as an outline. await expect(cell).toHaveCSS("outline", "rgb(4, 5, 6) solid 2px"); - await expect(cell).not.toHaveCSS("box-shadow", /inset/); + // The cell's shadow slot is empty: the seam has left it, and a ring must + // never take it (box-shadow does not stack across rules — the winner + // replaces the slot, which is how the seam was lost the first time). + await expect(cell).toHaveCSS("box-shadow", "none"); + + // And the seam is painted, on the plane, while that cell holds focus. + const seam = await page.evaluate(() => { + const content = document.querySelector("[data-pretable-scroll-content]")!; + const s = getComputedStyle(content, "::after"); + return { backgroundImage: s.backgroundImage, left: s.left, width: s.width }; + }); + expect(seam.backgroundImage).toContain("rgb(1, 2, 3)"); + expect(seam.left).toBe("40px"); + expect(seam.width).toBe("8px"); }); test("a focused cell in the REAL grid actually paints its ring", async ({