Skip to content

fix(ui): draw the frozen-column seam once per plane, not once per cell - #577

Merged
blove merged 2 commits into
mainfrom
blove/pinned-seam-continuous
Sep 4, 2026
Merged

fix(ui): draw the frozen-column seam once per plane, not once per cell#577
blove merged 2 commits into
mainfrom
blove/pinned-seam-continuous

Conversation

@blove

@blove blove commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

You were right that it still starts and stops. Landing the seam on the header closed the header-sized gap, but the seam also fades out at every row boundary — and that is structural, not a missing rule.

Why a per-cell shadow can never be continuous

The seam was box-shadow: 8px 0 8px -8px var(--pretable-seam-color) on each pinned cell. Spread and blur leave no third option:

  • Spread ≤ -blur/2 (what shipped): the blur is held inside the cell, so the shadow ramps to zero at the cell's own top and bottom. Stacked cells scallop — the edge fades once per row.
  • Spread any larger: the shadow extends past the cell vertically, neighbours overlap, and the alpha doubles into a dark band at every boundary.

The stylesheet documented that trade and had picked the fading half. A 4× crop of the shipped seam, one 16px-wide strip crossing four rows, is the first image below.

Draw it once per plane

A gradient has no falloff along its own axis, so one box spanning a whole plane is uniform top to bottom. There are two planes — the sticky header row and the scroll content — and they 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 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 already drawn.

The surface publishes the only thing CSS cannot work out — where each edge falls:

--pretable-pinned-left-edge the pinned run's own width
--pretable-pinned-right-edge getPinnedRightEdge(viewportWidth, pinnedRightWidth)the same call the right-pinned cells make
data-pretable-pinned-left / -right what the rules key on, so a side with nothing pinned draws nothing rather than a seam down the grid's own border

Both edges are viewport-x and both seams are anchored by left, because a right-pinned cell is stuck by a computed left and never a right inset — a seam anchored the other way would clamp differently from the columns it marks when the content is narrower than the viewport. Before the scrollport is measured the right edge is withheld, exactly as those cells go unpinned.

--pretable-seam-color still colours it; it is now the stop of a gradient rather than a shadow's colour, which is the same reason the token holds a colour and not a whole shadow.

One regression on the way, caught by the suite rather than by me

The header row's pinned cells are in flow — sticky, not absolute — so a seam consuming 8px of that line shoved the frozen columns 8px clear of the grid's left edge. smoke.spec.ts failed on the header being off innerLeft while the body cell was fine. A negative end margin gives the space back; the box is placed by left regardless of where the flow put it. Both header and body pinned cells now measure offset 0.

Guards

The seam guard was rewritten and tightened after its own mutation run: matching any rule mentioning the side let the shared block satisfy it, so a side could lose a whole plane unnoticed. It now matches on the rule carrying that side's own gradient.

FAILS (good)  seam drops the header plane
FAILS (good)  right seam anchored by `right` instead of `left`
FAILS (good)  planes go back to block, so the seams stack
FAILS (good)  the seam comes back as a per-cell shadow
FAILS (good)  seam becomes a pointer target

Three more on the React side — attributes dropped, right edge published before measurement, edges published with nothing pinned — each fail the new right-pin-surface tests. The two edge properties are registered as RUNTIME_VARS in the token contract, and a typo'd one still fails it.

Verification

pnpm format, lint, typecheck, api:check, test all pass from the repo root.

In a browser on a production build:

  • Hero grid scrolled 280px sideways — one unbroken gradient from the top of the header through every row (second image).
  • The showcase's right-pinned column mirrors it at the group's leading edge: seam left: 912px against the cell's left: 920px, gradient to left.
  • A grouped grid crosses its group bands without a notch.
  • Pinned header and body cells both sit flush at offset 0.

136 Playwright specs pass across smoke, grid-header-popover-scroll, grid-header-keyboard, grid-keyboard-a11y, grouping and range-selection.

🤖 Generated with Claude Code

@blove
blove enabled auto-merge (squash) September 4, 2026 15:42
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
pretable Ignored Ignored Sep 4, 2026 4:10pm UTC

Request Review

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 <noreply@anthropic.com>
@blove
blove force-pushed the blove/pinned-seam-continuous branch from abfff83 to 364157a Compare September 4, 2026 16:05
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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-o20mryyjf-cacheplane.vercel.app
Commit: 2f9b3c0d1f7b9ac0961414864f1a3bc0c2295f68

Updated automatically by the deploy-preview job.

@blove
blove merged commit 3e5ad85 into main Sep 4, 2026
21 checks passed
@blove
blove deleted the blove/pinned-seam-continuous branch September 4, 2026 16:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant