diff --git a/.changeset/pinned-seam-continuous.md b/.changeset/pinned-seam-continuous.md new file mode 100644 index 00000000..b9f8074b --- /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/apps/bench/tests/cascade-override.spec.ts b/apps/bench/tests/cascade-override.spec.ts index 11c15772..10f9dc4c 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 ({ diff --git a/packages/react/src/__tests__/right-pin-surface.test.tsx b/packages/react/src/__tests__/right-pin-surface.test.tsx index 1e7d6baf..13f98ea3 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 df774133..251bf3dc 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 e87bbd7c..45219bf2 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 91887bbc..6e517927 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 ce39e751..07b02e73 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:/); } });