From f6c0b6975534d1f87829ad2e68ed561a0e05f7e5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 20:27:55 -0700 Subject: [PATCH] fix(ui): the pinned seam runs through the header, not just the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frozen edge is one boundary running the height of the grid, and only the body drew it. `[data-pretable-cell][data-pretable-pinned="left"|"right"]` carried the --pretable-seam-color shadow; the pinned HEADER cells took the opaque --pretable-bg-header fill and nothing else. So the seam started 51px down — a header-tall gap at the top of it, on any grid with a pinned column and somewhere to scroll sideways. Measured on the deployed hero grid before the fix: the pinned body cell computed `rgba(16,17,26,0.2) 8px 0 8px -8px` while the pinned header cell beside it computed `none`. Split the header's one two-selector rule in two, each side carrying the mirrored offset its body counterpart already has — the same reason --pretable-seam-color holds a colour rather than a whole shadow. Nothing else changes: pinned siblings share z-index 1, so each paints over the shadow the one before it cast and only the OUTERMOST seam is ever seen, exactly as in the body. The guard is the same story as last time: "the pinned seam is wired, mirrored, and outlives the group-row band" named [data-pretable-cell] alone, so the half that was missing was the half it could not see. It now checks both header rules for the mirrored offsets and for the fill the seam must not have cost. Mutation-tested: dropping the left header's seam, giving the right header the unmirrored offset, and trading the fill for the seam each make it fail. Splitting the rule also tripped the existing opaque-background guard, which is now written per side. Verified in Chrome on a production build, at the pixel: with the hero grid scrolled 280px sideways the seam is continuous from the top of the header band down through the rows, and the showcase's right-pinned column mirrors it at -8px. `pnpm format`, `lint`, `typecheck` and `test` pass, as do 38 Playwright smoke specs including the right-pin and column-reorder cases. Co-Authored-By: Claude Opus 5 --- .changeset/pinned-header-seam.md | 18 +++++++++ packages/ui/grid.css | 21 ++++++++--- packages/ui/src/__tests__/css-cascade.test.ts | 37 +++++++++++++++++-- 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 .changeset/pinned-header-seam.md diff --git a/.changeset/pinned-header-seam.md b/.changeset/pinned-header-seam.md new file mode 100644 index 00000000..aec7e0f8 --- /dev/null +++ b/.changeset/pinned-header-seam.md @@ -0,0 +1,18 @@ +--- +"@pretable/ui": patch +--- + +The frozen-column seam now runs through the header row instead of starting +below it. + +`--pretable-seam-color` was drawn by `[data-pretable-cell][data-pretable-pinned]` +only. The pinned HEADER cells took the opaque `--pretable-bg-header` fill and +nothing else, so the shadow that marks the frozen edge stopped dead at the +header — a header-tall gap at the top of a boundary that is meant to run the +height of the grid, visible on any grid with a pinned column and a horizontal +scroll. The header's pinned rule is now split per side, each carrying the +mirrored offset its body counterpart has. + +The guard that should have caught this named `[data-pretable-cell]` alone; it +now covers the header rules too, on both sides, and checks that the seam did +not cost the opaque fill. diff --git a/packages/ui/grid.css b/packages/ui/grid.css index a6146b3d..e87bbd7c 100644 --- a/packages/ui/grid.css +++ b/packages/ui/grid.css @@ -91,12 +91,23 @@ 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. */ - :where( - [data-pretable-header-cell][data-pretable-pinned="left"], - [data-pretable-header-cell][data-pretable-pinned="right"] - ) { + 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"]) { 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) */ diff --git a/packages/ui/src/__tests__/css-cascade.test.ts b/packages/ui/src/__tests__/css-cascade.test.ts index b2a93019..ce39e751 100644 --- a/packages/ui/src/__tests__/css-cascade.test.ts +++ b/packages/ui/src/__tests__/css-cascade.test.ts @@ -51,10 +51,17 @@ 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. - 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]).toMatch(/background:\s*var\(--pretable-bg-header\)/); + // 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\)/); + } }); test("pinned body cells and group rows have their own surface tokens", () => { @@ -97,6 +104,28 @@ describe("grid.css cascade contract", () => { /box-shadow:\s*-8px 0 8px -8px var\(--pretable-seam-color\)/, ); + // 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\)/, + ); + expect(headerRight).toMatch( + /box-shadow:\s*-8px 0 8px -8px var\(--pretable-seam-color\)/, + ); + // 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\)/); + // 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 // restoring rule has to come after BOTH of them.