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:/);
}
});