Repository navigation
feat(core): Table rows glide when a row's panel opens or closes, and their dividers move with them - #8656
Conversation
…t around a size change When something inside a registered area changes size, the content after it glides from its old place to its new one with a compositor-only translate instead of jumping (architecture:react-component-runtime INV12, proposed in #8463). - motion/animatedLayout.ts: one batched measurement before and after the change; a bounded parentElement/nextElementSibling walk up to the area (no TreeWalker), descending only through display: contents; absolute and fixed elements skipped; only elements whose rectangle changed and that are visible move; scale-corrected; Web Animations translate with composite: 'add', feature-tested per call with fallbacks; offscreen elements land silently; Reduce Motion cross-fades with no travel; every pointer. - useAnimatedLayout(ref) (public) registers an area; reportLayoutChange stays internal. ScrollableArea registers its viewport itself. - Collapsible and TreeList adopt it: the shown content plays its own entrance, and the chevron turns, on the same placeholder layout-change role as the glide. - Storybook: Core/Animated layout, with a nested tree, a Collapsible, a field error, load more, wrapping tags, plain divs, a display: contents wrapper, a sticky header and an absolute badge. Draft for trying it out; tests, docs and a changeset follow.
…ollapsible and TreeList take as animatedLayout
The API shape the owner set: the hook takes {containerRef} and returns an
opaque handle; Collapsible and TreeList accept an animatedLayout prop and
report their size changes through it, with no context registration.
Without the prop, a component keeps today's behavior, its chevron
included. ScrollableArea no longer registers itself: an app passes the
ScrollableArea's ref as containerRef. The story uses exactly this shape.
Adds the changeset.
…subpixel rounding A real-Chromium check showed unscaled followers moving by 79.92 px for an 80 px shift: subpixel rounding made rect.width / offsetWidth read a hair off 1. A scale within 1% of 1 is now treated as none.
…+ Add tag wraps and its followers move
…tion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring)
- One shared motion role: the glide, the changed element's entrance and the disclosure chevron all play springs.settle, resolved through the internal motion-role registry (a theme's --motion-springs-settle reaches all three); the glide's keyframes are sampled from the spring and played linearly. - Reduce Motion by pointer (design:reduced-motion DR1-DR3): a cross-fade under a coarse primary pointer, an immediate change otherwise. - The changed element outside the area's visible part lands without animation; hidden and top-layer elements are skipped. - Collapsible and TreeList take the chevron from their Icon's icon target and set its transition before the change commits; no handle leaves every inline value untouched. - Tests: the walk, motion and handle in jsdom; Collapsible and TreeList with and without a handle; one real-browser case per AST-105 Coverage row, plus FR2-FR5. - Docs: the useAnimatedLayout hook doc (FR7 text), the animatedLayout prop in the Collapsible and TreeList docs and records, and a Before and after story.
… registry only Motion roles are internal (spec:AST-094 DEC-11): the glide, the changed element and the chevron share the registry's one value, and a scope's --motion-springs-settle changes nothing. The tests assert that instead of an override.
… and compare scrolling with and without a handle Run in real Chromium (all 20 cases, three repeats): the page helper's microtask wait is plain JavaScript; the out-of-view and nested-area cases wait for the first change's glide to finish before the second; and the FR3 scroll case compares the area's scroll frame by frame with and without a handle, since the browser's own scroll anchoring moves it either way.
…ed ones and retargets on the compositor - A single-mode CollapsibleGroup measures every item a toggle changes (the one that closes as well as the one that opens), so the items between glide with the content after the group. Items holding a handle register their measurement with the group. - A measurement no commit claims by the next frame (a controlled owner refused the toggle) is dropped, so it never moves unchanged rows later. A change nothing measured (an owner set isOpen directly) lands at once, entrance included. - The follower glide is one replace-composited translate, which the compositor runs (architecture:react-component-runtime INV12). An element already gliding is retargeted: its glide stops and the new one starts from where it is on screen, so it keeps moving without a jump. - INV12's source boundary check (no layout property animated, the walk only through parentElement / nextElementSibling, nothing public, only the hook and the adopters use it) and a real-browser case on a throttled phone over a 10-level by 50-sibling tree with two changes in one frame.
spec:AST-105's Coverage row: an element already moving keeps its running motion and adds the new move. The new glide now samples the sum of the running glide's remaining curve and the new move into one replace-composited translate, so the element keeps its speed through the change instead of restarting from rest. Every animation of a change starts on the document timeline's current time, so a replaced glide moves in the very frame it is replaced and the changed element and its followers share one start. Tests: the carried speed (jsdom) and, in a real browser, no jump and no lost speed across the change.
…e morph once Core has it, its own compositor translate until then
…st adopt it in their own pull request The module, the hook, their tests and docs, and the INV12 record line stay here. Collapsible's and TreeList's adoption (their animatedLayout prop, docs and records), the CollapsibleGroup measurement, the adopter tests and the Storybook story move to a stacked pull request, so each lands and reverts on its own. The changeset now describes the hook alone.
Restores the adoption from the module pull request onto it: the animatedLayout prop on Collapsible and TreeList (docs and records), the CollapsibleGroup measuring every item a toggle changes, the adopter tests, and the Storybook story. The story keeps only adopting components and plain following content; its demos that drove the internal reporting functions directly are gone.
…motion spec:AST-105 FR4: the changed element plays its own animation when it exits too. holdLayoutExit keeps a closing element on screen but out of flow (absolutely positioned where it is), so the content after it glides up at once, and conceals it upward from its bottom edge on springs.settle (a fade under Reduce Motion with a coarse pointer, nothing with a fine one); the component hides it when the exit finishes, and releaseLayoutExit puts it back, including when it opens again mid-exit. The throttled-tree browser case now reads its animations in the same task, so a slow round trip can't outlast the glide.
…'s exit spec:AST-105 FR4: the changed element animates when it exits too. A measured close keeps the Collapsible's content (or the TreeList row's child group) rendered, inert and hidden from assistive technology, while holdLayoutExit holds it out of flow and conceals it on springs.settle; the rows after it glide up at once, and the content hides (or the group unmounts) when the exit ends. Opening again mid-exit ends it. Rows of an exiting group leave the tree's keyboard order at once. An unmeasured close, no handle, or an exit that lands at once hides it in the same commit.
…es with its row; the exit stays in flow - This pull request is now the internal module only: the public hook, its docs and the chevron's transition move to the adoption pull request, so nothing public appears before a component can take the handle. - A changed element that an earlier change in the same frame displaced moves with its row, by its total change (AST-105 FR3), while it plays its own entrance or exit; an element not rendered before the change has no old place and does not move. - The exit keeps the element in flow where it is and gives up its space with a negative end margin as large as its box, so it scrolls and clips with its container and keeps its width (no offset-parent positioning). - Browser proofs: the sticky row uses a stuck header after the change, the flex row a wrapped item and a row-direction neighbor whose rectangle does not change; new cases for the displaced changed element and the exit in a scrolled area.
… chevron move here The module pull request is now internal only, so this one carries the public face with the components that take it: useAnimatedLayout, its doc page and export (and its docsite route), applyLayoutChangeTransition and its tests, and the boundary test's list of users (the hook, Collapsible and TreeList). The adopters follow the module's in-flow exit: their tests check the exit's end margin instead of absolute positioning. The changeset describes the hook and the two adopters together.
…e app moves are left alone - holdLayoutExit measures the layout after the element with it hidden and sets the end margin that puts that layout there, so a gap or collapsing margins come out exact and nothing jumps when the element hides; where no margin can free the space (a grid track and its gap) the exit lands at once instead. - The held element is inert while it conceals (no focus, no pointer, nothing read); the module sets and releases it. - A follower with its own transform, translate, rotate or scale, or with a running transform animation the module did not start, takes its final place at once, as AST-105's Coverage lists for elements the app moves. - Tests: the exact space with a gap (jsdom), the grid fallback, app-moved followers; real-browser cases for a flex gap and collapsing margins, and for app-moved followers.
… left alone) into the adoption The module now makes a held element inert, so Collapsible's content and TreeList's child group drop their own inert and aria-hidden while exiting (TreeList keeps the marker that takes the group's rows out of the keyboard order).
…red close, and restores what it replaced - giveUpSpace measures the first element after the closing one in the area (its next sibling, or an ancestor's below the area), not the parent's edge, which a collapsed negative margin never moves: a Collapsible-shaped close (the last child of a plain wrapper) now plays its exit. - holdLayoutExit holds only a close a change in the area measured, and its documentation asks for the call after animate() reports it measured. - releaseLayoutExit puts back the element's own inert and inline values. - AST-105's out-of-coverage list names a closing grid item whose track or gap changes when it hides (owner-approved): it hides at once, without its exit. - Tests: an unmeasured close holds nothing; prior values restored; a real- browser case for the plain-wrapper shape.
…d holds only a measured close) into the adoption
…al next box and checks what follows its container - The flush stops every running glide and reads every new place before starting any glide, and an element inside another moving element moves only by its own change: nested changes compose whichever is reported first (AST-105 FR3). - The exit's search for the next box enters a display: contents wrapper, as the follower walk does. - The exit checks the content after its container on both axes, so a flex-row item that sets its row's height is refused instead of leaving the content below to jump. - A refused exit puts back the element's own end margin and drops its change, so the content after it takes its final place at once. - AST-105's out-of-coverage line names both layouts no margin frees (owner- approved). - Tests: a refused hold keeps its own margin and glides nothing; real- browser cases for nested changes reported inner-first, a display: contents next box, and a tall flex-row item.
…ext box and container checks) into the adoption
# Conflicts: # docs/architecture/react-component-runtime.md # docs/specs/AST-105-animated-layout/spec.md # packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts # packages/core/src/motion/animatedLayout.boundary.test.ts # packages/core/src/motion/animatedLayout.test.ts # packages/core/src/motion/animatedLayout.ts
|
/approve-spec 202f3da |
There was a problem hiding this comment.
The glide, the column-lines exception and the Reduce Motion behaviour match component:Table FR15 and spec:AST-105 as they stand on main, but one case FR14 names still breaks, so this needs changes before it can be approved. When an app styles a row's own border (tr), each glide draws that divider twice, the carried copy moving with the row and the table's own copy left at the row's final place, and with a pinned column the moving copy stops short of the pinned cells. Please conform that case (for example, make the row's own bottom border transparent while the row is marked, and let a gliding pinned cell read the row's carried divider instead of its own carry), and add FR14's row-border case, mid-glide and on a pinned cell, to the story and the browser spec, which today exercise only a td override.
[Automated review]
| // line at the row's static place. | ||
| if (cell instanceof HTMLElement) { | ||
| inlineColorOf.set(cell, cell.style.borderBottomColor); | ||
| cell.style.borderBottomColor = 'transparent'; |
There was a problem hiding this comment.
Only the cells' own bottom colours go transparent here. When the row's own border wins the collapse (an app rule such as tr { border-bottom: 3px solid … }, which Table FR14 names), the table keeps painting it at the row's static place while the line cell's ::after draws the carried copy: 90 ms into opening Ada Lovelace in Gliding rows with that rule on Grace Hopper's row, the green line shows both under the moving row and at its final place. Writing the row's own border-bottom-color (and a winning row group's) transparent with the marks, and restoring it with them, removes the static copy.
| borderBottomStyle: { | ||
| default: 'inherit', | ||
| [CARRYING]: `var(${PINNED_DIVIDER_STYLE}, solid)`, | ||
| [CELL_GLIDING]: `var(${PINNED_DIVIDER_STYLE}, solid)`, |
There was a problem hiding this comment.
For a row whose own border wins, syncPinnedDividers leaves the pinned cell in carry with inline --_table-row-divider-app-*: 0px none transparent (:383-385), and those cell-level values outrank the ones the glide sets on the tr, so mid-glide this rule resolves to 0px none and the moving divider stops short of the pinned columns. Reading the row's carried values here while the cell glides (through a custom property the cell never sets, say), or clearing the cell's carry for the glide, would draw it.
| return ( | ||
| <div ref={containerRef} data-testid="gliding-rows" style={style}> | ||
| {themed && ( | ||
| <style>{`[data-testid="gliding-rows"] tbody tr:nth-child(3) td { border-bottom-color: rgb(220, 38, 38); }`}</style> |
There was a problem hiding this comment.
This override reaches td only, so neither this story nor TableRowGlide.a11y.browser.spec.ts exercises FR14's verification case of "an app override of a row's border; both mid-glide and on a pinned cell", which is where the glide currently draws the divider twice. A tr border in the themed state, with an assertion of the moving row's pinned and unpinned line and nothing at its static place, would cover it.
…, pinned cells included CodeBunny on #8656 (Table FR14's row-border case): - The table kept painting a row's own border (an app's tr rule) at the row's static place while the line drew the carried copy, so the divider showed twice. The row's own bottom colour now goes transparent with the marks and comes back with them. - A pinned cell's own carry (0px none for a row winner) outranked the values the glide set on the row, so the moving divider stopped short of the pinned cells. The glide carries the row's divider in names no cell sets (--_table-row-glide-*), which the pinned divider reads while it glides. GlidingRows gains rowBorder (an app's 3 px green border on a tr); a browser case checks the moving line pinned and unpinned, and nothing at the static place. It fails on the previous head. derivedVarRegistry lists the glide names.
|
/approve-spec 6b1bb99 |
There was a problem hiding this comment.
An app's border on a row's tr now rides with its row, and the glide matches component:Table FR15 and spec:AST-105, but two divider cases that FR14 covers still need fixing before this can be approved. A tbody bottom border, or a top border on the next row's cells, is still drawn twice while rows glide, because only the row's and its cells' own bottom borders are hidden while the moving copy reproduces whichever edge wins the collapse; and a tr border's moving copy sits about 3.75 px above where the border rests, so it steps at the pinned columns' edge and jumps at the start and end of each glide. Please hide the winning edge's source for the glide, offset the line by the row's own border width, and add story and browser-spec cases that compare the pinned and unpinned lines' positions (the current check's 7 px window passes the step).
[Automated review]
| // by the table at the row's static place: it goes transparent too, and | ||
| // the line carries it. | ||
| inlineColorOf.set(row, row.style.borderBottomColor); | ||
| row.style.borderBottomColor = 'transparent'; |
There was a problem hiding this comment.
Only the row's and its cells' own bottom colours go transparent here, but the copy carries whichever edge rowEdge reads from collapsedBottom, which can be the next row's or cell's top border or, under the body's last row, the tbody border (rowDivider.ts:80-87). Those stay painted at the static place, so with tbody { border-bottom: 3px solid … } or a 3 px top border on the next row's cells the divider is drawn twice for each glide (FR14 names tbody and td overrides). Hiding the source of the winning edge with the marks, and restoring it with them, would cover both.
| content: {default: null, [CELL_LINE]: '""'}, | ||
| position: {default: null, [CELL_LINE]: 'absolute'}, | ||
| insetInline: {default: null, [CELL_LINE]: 0}, | ||
| bottom: {default: null, [CELL_LINE]: 0}, |
There was a problem hiding this comment.
bottom: 0 places the copy inside the row's padding box, which ends above the row's own bottom border. With a 3 px tr border (the story's rowBorder), 90 ms into opening the copy under the unpinned cells is 3.75 px above the pinned cells' copy and above where the border rests, so the line steps at the pinned edge and jumps at the start and end of each glide. Offsetting the line by the row's own bottom width would level it.
| ['pinned', pinnedX], | ||
| ['unpinned', unpinnedX], | ||
| ] as const) { | ||
| const [r, g] = await darkestNear(page, x, y); |
There was a problem hiding this comment.
Both columns are read in the same 7 px window around the pinned cell's bottom (darkestNear), so this passes while the unpinned copy sits 3.75 px above the pinned one. Comparing the y of the green line under each column would catch it.
…s own border's line sits where it rests CodeBunny on #8656 (Table FR14): - The moving line carries whichever edge wins the collapse, but only the row's and its cells' bottoms were hidden, so a tbody's bottom border or a top border on the next row's cells stayed at the static place and the divider showed twice. A glide now hides every edge the collapse can take the divider from (the row's bottom, the row below's and its cells' tops, and at a section edge the row groups' borders) and gives them back with the marks. - A row's own border is drawn below its box, so the line sat its width above where the border rests and stepped at the pinned edge. The line is offset by the row's own border width. GlidingRows gains edgeBorder (tbody, top). Browser cases: the tr line is level across the pinned and scrolling cells (within 1.5 px), and a tbody or next-row-top divider is drawn once while it glides; all three fail on the previous head.
|
/approve-spec 3ecdc48 |
There was a problem hiding this comment.
The glide matches component:Table FR15 and spec:AST-105, and a tbody or next-row top border now rides once with its row, but one divider case still needs a small fix before this can be approved. For any divider wider than 1 px (an app's 2–3 px border, or a theme's --border-width: 2px), the moving copy under the scrolling cells sits 1 px above the pinned copy and above where the border rests, so the line steps at the pinned edge and drops 1 px as the glide ends; lowering the line by the part of the collapsed edge that lies below the row's box, or placing it from a cell's border box as the pinned layer does, should fix it. Please also make the browser spec's level check fail on a 1 px step and add a 2 px divider case; the Reduce Motion wording in the doc entry and changeset (a finger sees a fade, not an instant change) can be corrected alongside.
[Automated review]
| const ownWidth = Number.parseFloat( | ||
| getComputedStyle(row).borderBottomWidth, | ||
| ); | ||
| // (Read before any write of this batch's next row: the row's own | ||
| // width does not change with its color.) | ||
| row.style.setProperty( | ||
| '--_table-row-glide-offset', | ||
| `${Number.isFinite(ownWidth) ? ownWidth : 0}px`, | ||
| ); |
There was a problem hiding this comment.
The offset counts only the row's own border, but the collapsed edge also extends below the row's box by part of its own width, so any divider wider than 1 px still glides 1 px high under the scrolling cells. In Chromium on Gliding rows, with edgeBorder: tbody held at 0 ms the pinned copy covers rows 354.0–356.5 and the scrolling copy 353.0–355.5; a 3 px tr border, a next row's 3 px top border and a theme's --border-width: 2px show the same 1 px, while the default 1 px divider is level. Adding the edge's lower part (half its width rounded down in Chromium: 1 px at 2 and 3 px), or placing the line from a cell's border box as the pinned layer does, would put it where the border rests.
| expect(Math.abs(Math.min(...pinned) - Math.min(...unpinned))).toBeLessThan( | ||
| 1.5, | ||
| ); | ||
| expect(Math.abs(Math.max(...pinned) - Math.max(...unpinned))).toBeLessThan( | ||
| 1.5, | ||
| ); |
There was a problem hiding this comment.
With a 1.5 px tolerance this passes while the scrolling copy sits a full pixel above the pinned one. Requiring the two copies' rows to match within under 1 px, and adding a 2 px divider case, would make it fail on the step it is meant to catch.
| name: 'animatedLayout', | ||
| type: 'AnimatedLayout', | ||
| description: | ||
| "A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion, and in a table with column lines (dividers columns or grid, which do not move yet), the rows change at once. Without it, rows jump.", |
There was a problem hiding this comment.
Under Reduce Motion with a coarse primary pointer the rows fade in place (spec:AST-105 FR4), as this PR's own browser test asserts, so "the rows change at once" holds only for a fine pointer. "Move without travel" would cover both; the same sentence is in the dense entry and the changeset.
…idth CodeBunny on #8656 (Table FR14): - For a divider wider than 1 px (a theme's 2 px, an app's 3 px tr border) the moving line sat a pixel above the pinned copy and above where the border rests, so it stepped at the pinned edge and dropped as the glide ended. When it marks a row, the glide reads where the collapsed edge rests (a cell's padding-box bottom, from the whole pixel the table paints it on) against the row's padding box the line hangs from, and offsets the line by that. - The browser level checks now fail on a 1 px step (exact rows of pixels), with a 2 px theme divider case beside the 3 px tr one; both fail on the previous head. GlidingRows gains wideBorder, and its rowBorder targets the fourth order by key, so the border stays on one row as a panel opens. - The doc entry and Changeset say what Reduce Motion does: rows fade in place on touch and change at once with a mouse.
|
/approve-spec eeacd26 |
There was a problem hiding this comment.
The row glide now matches component:Table FR14, FR15 and DEC-3 and spec:AST-105, and dividers wider than 1 px (a theme's 2 px border or an app's 3 px row border) now glide level across the pinned columns and land where they rest, so nothing in this review blocks approval. Two small optional touch-ups: the markGlidingRows doc comment still says the function only writes (glidingDivider.ts:183-185) although it now reads each moving row's offset first, and the PR description still says only dividers="grid" skips the glide and that the branch targets a stack base. Separately, in Firefox at 1x the default 1 px divider sits 1 px below its resting place while a row opens, which could be a follow-up.
[Automated review]
| * visible part, so this walks out from the changed row each way and stops at | ||
| * still rows: it never visits the rest of a long table. It asks the motion | ||
| * module which rows glide and takes the borders read before the glide | ||
| * started (`edges`), so it reads nothing from the layout: it only writes. |
There was a problem hiding this comment.
This sentence is no longer true: lines 229-241 now read each moving row's and its last cell's rectangle and client box before the writes. Rewording it to say the function reads each moving row's edge offset once, before any write, keeps the comment accurate.
# Conflicts: # packages/core/src/motion/animatedLayout.boundary.test.ts
|
/approve-spec b7aaa63 |
There was a problem hiding this comment.
The merge of main keeps the row glide in line with component:Table FR14, FR15 and DEC-3 and spec:AST-105: the rows after an opening or closing panel glide with their dividers, nothing changes without the handle, and nothing in this review blocks approval. Three optional touch-ups remain from the last review: the markGlidingRows doc comment still says the function only writes (glidingDivider.ts:184-185) although it now reads each moving row's offset first; the PR description still says only dividers="grid" skips the glide, that the branch is based on a stack branch, and that the glide reads nothing after it starts; and in Firefox at 1x each moving divider starts 1 px below its resting place while a row opens, which could be a follow-up.
[Automated review]
What changes for people using Table
Opening or closing a row's detail panel made every row after it jump. With an
animatedLayouthandle (useAnimatedLayout),useTableRowExpansionnow glides those rows (and the content after the table) to their new places, plays an opening panel in on the same motion, and turns the chevron on it. Without a handle rows jump, as before. Under Reduce Motion the rows change at once (under a finger, a short fade in place). Tables withdividers="grid"do not glide yet (Table FR15): their column lines are painted at the cells' static places too, so their rows change at once; the vertical stand-ins come in a follow-up.Each row's divider moves with its row. With collapsed borders the table paints a border at its cell's static place, so a moving row used to leave its divider behind:
::after, positioned against the row. The cell is not positioned, so its overflow does not clip the line; and it is a box inside a cell, not a box in the row, so the table's column widths stay put (checked in a table its columns do not fill: 54/298/298/298/298 px before, during and after a glide). A table nested in a moving panel keeps its own dividers, since only the moving row's own cells are marked.table-cellcolour or an app's stylesheet ontdcannot keep the line at its static place. Each moving row is its own layer (the table's last row, which can glide a pixel farther, under the others), so no row covers the divider above it.animate()takes abeforeMovecallback, run once the new places are read and before any glide starts, where the rows' borders are read and the panel's entrance starts (playLayoutChangenow returns it);glideOftells which rows glide without a DOM read.A closing panel shrinks the table's scroll wrapper at once, which would cut off the rows gliding up into its place. For the glide the wrapper keeps its old height as bottom padding, given back with an equal negative margin, so nothing outside moves differently; both go when the rows stop.
Based on
stack/table-row-glide-base, which is #8639 (the animated-layout stack) merged with #8654, so this compare shows only the glide; it retargets tomainonce both land.Evidence (real Chromium, desktop and 390 px)
Base (no handle: rows jump) and head (rows glide) at the same instant, 90 ms into opening and closing a row on the 2,000-row story:
Frames at 0.05× speed, opening and closing a row with the Customer column pinned and the table scrolled 60 px: the rows glide and every divider (pinned and unpinned) stays on its row; the closing rows are not cut off; the settled state matches the start.
animate(beforeMove)), finding the moving rows from the module instead of the DOM, and unmarking them with writes only removed the rest. A 3-row table: 24 ms.Tests
TableRowGlide.a11y.browser.spec.ts(Table FR14, FR15, real Chromium and WebKit): no animation without a handle; mid-glide, a divider under the moving row's pinned and unpinned cells; column widths unchanged through a glide; an open panel's divider.useTableRowExpansion.glide.test.tsx: with a handle the rows after an opening panel glide and the chevron turns on the glide's motion; without one nothing animates; a moving row's cells are marked (the first that scrolls draws the line; a pinned cell and a nested table's cells are not) and carry its divider only while it moves; an open panel carries its row's app-set border.animatedLayout.boundary.test.ts: the row-expansion plugin joins the module's adopters.pnpm -F @astryxdesign/core typecheck; Table and motion suites pass.