Repository navigation
chore(core): the internal module for size-change glides (spec:AST-105, INV12) - #8482
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
…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
PR Analysis Report
|
…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.
There was a problem hiding this comment.
Thanks for narrowing this to the internal module: without a handle nothing changes, and the follower glide, the entrance and Reduce Motion check out against AST-105 in the browser tests. Approval waits on a spec decision: the INV12 change (the module plays its own compositor translate until Core has the shared rectangle morph) amends a current invariant, so an engineering owner needs to record the decision by commenting /approve-spec a09ce7412eb240ebb1e5f335c60f70df4c985d67 on this pull request (the author may do so as an engineering owner), or the module waits for the shared morph. Independently of that decision, these need changes:
holdLayoutExitgives back the border box plusmargin-top, so in agapcontainer or under collapsed margins the content after a closing element ends 8–10 px off and jumps when the element is hidden (animatedLayout.ts:662-691, AST-105 FR3).- Elements the app moves with its own
transform,translateor animation are pulled into the glide, where AST-105's Coverage says they take their final place at once. - A held element stays focusable and readable by screen readers while it conceals, and nothing says who makes it inert.
- At 2,241 authored lines, the size is covered by the verification AST-105 and INV12 require only once the INV12 change is approved; otherwise it needs to come under 2,000.
[Automated review]
| through the shared rectangle morph (`architecture:layer-runtime` INV15; | ||
| `spec:AST-094` FR5); it never animates height. Its walk visits only the | ||
| following element siblings at each ancestor level up to the area, through | ||
| `spec:AST-094` FR5) once Core has that morph; until then, the module plays |
There was a problem hiding this comment.
This rewords INV12, which on main requires the glide to play through the shared rectangle morph (architecture:layer-runtime INV15), and Core has no such morph yet. The module's own translate depends on this interim, so it needs an engineering owner's /approve-spec with the full head SHA, or the module waits for the morph.
| if (isRow) { | ||
| const inlineStart = | ||
| style?.direction === 'rtl' ? 'margin-right' : 'margin-left'; | ||
| const start = | ||
| Number.parseFloat(style?.getPropertyValue(inlineStart) ?? '') || 0; | ||
| element.style.setProperty( | ||
| 'margin-inline-end', | ||
| `${-(element.offsetWidth + start)}px`, | ||
| ); | ||
| } else { | ||
| const start = | ||
| Number.parseFloat(style?.getPropertyValue('margin-top') ?? '') || 0; | ||
| element.style.setProperty( | ||
| 'margin-block-end', | ||
| `${-(element.offsetHeight + start)}px`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
The border box plus margin-top is the space the element frees only in plain block flow. With gap: 8px (flex or grid) the row after a closing element glides to 8 px short of its place and jumps up when the element is hidden; with a 20 px margin above and a 10 px margin-top here it overshoots by 10 px and jumps down (AST-105 FR3). Measuring the first follower's place with the element hidden, in the same layout effect before paint, gives the real amount; a grid track's gap needs another route, since a margin cannot free it.
| // The element's own `translate`, which the glide plays on top of; a | ||
| // value in other units cannot be offset in px, so it lands at once. | ||
| const own = parseTranslate( | ||
| view?.getComputedStyle(element).getPropertyValue('translate') ?? '', | ||
| ); | ||
| if (own == null) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
AST-105's out-of-coverage list says "elements the app moves itself with its own transforms" take their final place at once, but here the glide plays on top of the element's own translate, and nothing checks transform or animations the app runs: a row with its own running transform animation, a static transform and a static translate each get a glide. Skipping followers whose computed transform or translate is not none, or that run animations this module did not start, would match the record.
| `${-(element.offsetHeight + start)}px`, | ||
| ); | ||
| } | ||
| element.style.setProperty('pointer-events', 'none'); |
There was a problem hiding this comment.
Only pointer events are blocked, so for about 735 ms a keyboard user can Tab into content that has visually closed and a screen reader still reads it; focus inside it is lost when the component hides it. Setting inert while the exit holds the element (and removing it in releaseLayoutExit), or stating that the adopting component does, would define it.
…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.
|
/approve-spec 2f7cedb |
There was a problem hiding this comment.
Thanks for the fixes: the exit now frees exactly its space with gaps and collapsing margins, followers the app moves are left alone, and the INV12 interim has an engineering owner's approval on this head. One change is still needed before approval: when the closing element is the last child of a plain block wrapper, as Collapsible's content is, holdLayoutExit measures the wrapper's edge, which the collapsed negative margin never moves, so the exit lands at once and the content vanishes while the rows glide (animatedLayout.ts:659-663, AST-105 FR4).
- Measure where the content after the element actually sits instead of the parent's edge, and add that layout to the exit browser cases.
- The grid-gap fallback also skips FR4's exit: hold the element another way there, or have an engineering owner name it in AST-105's out-of-coverage list.
holdLayoutExit's comment asks for the call beforeanimate(), which holds a close nothing measured, andreleaseLayoutExitstripsinertand inline values the exit did not set.
[Automated review]
| const parent = element.parentElement?.getBoundingClientRect(); | ||
| if (parent == null) { | ||
| return 0; | ||
| } | ||
| return isRow ? (rtl ? -parent.left : parent.right) : parent.bottom; |
There was a problem hiding this comment.
For a last child this reads the parent's end edge. In block flow, when the parent has no bottom border, padding or formatting context of its own (Collapsible's root), the negative end margin collapses through it: in a Chromium probe the wrapper's bottom stayed at 124 px while the paragraph after it moved from 124 px to 40 px, exactly where it sits once the content is hidden. The check at line 698 then refuses the hold, so the exit lands at once, against AST-105 FR4. Measuring the first element the walk reaches after it would see the real target.
| * fades instead. Call it in the layout effect of the commit that closes the | ||
| * element, before the change's `animate()`. |
There was a problem hiding this comment.
Only animate() knows whether the change was measured, so holding first can hold a close nothing measured: in a probe the row after it moved up at once into the held element's place and overlapped it by 81 px for the whole exit. #8568 calls animate() first and holds only a measured close, which works because the flush is a microtask; documenting that order would keep the next adopter from tripping on it.
| } | ||
| exits.get(element)?.cancel(); | ||
| exits.delete(element); | ||
| element.removeAttribute('inert'); |
There was a problem hiding this comment.
This removes inert (and the loop below removes pointer-events and both end margins) even when the element had them before the exit, so content an app or component made inert is reachable again after a release. Recording the prior values in holdLayoutExit and restoring them here would keep the promise in the comment above: remove only what the exit set.
…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.
|
/approve-spec 52841d7 |
There was a problem hiding this comment.
Thanks for the fixes: the exit now measures the content after it, holds only a measured close and puts back what it replaced, and the INV12 interim and the new AST-105 line carry an engineering owner's approval on this head. A few changes are still needed before approval, and because this pull request edits both records, the fix commit will need a fresh /approve-spec from an engineering owner:
- Nested changes in one frame compose only when the outer one is measured first; measured inner first, rows inside the outer change's follower start 80 px too high (
animatedLayout.ts:451-512, AST-105 FR3). - The exit is held where its margin can't free the space, a
display: contentsnext element (as Tooltip and Theme render) or a flex-row item that sets its row's height, so the content after it jumps 8 px or 80 px when the element hides. - The grid fallback still glides the content after the item, which the new AST-105 line says takes its final place at once, and a refused hold removes the element's own inline end margin.
- Please add a nested inner-first case and a Core
display: contentsfollower to the browser spec.
[Automated review]
| running.animation.cancel(); | ||
| glides.delete(element); | ||
| } | ||
| const after = element.getBoundingClientRect(); |
There was a problem hiding this comment.
This reads the follower's new place after the glides of followers processed before it have started, so a follower inside another follower gets the right move only when its moving ancestor came first. With folder A (80 px) and folder B1 inside the following folder B (40 px) opening in one frame, measuring B1 first gave row B2 a 120 px glide inside B's 80 px glide: it started 80 px above where it was and travelled 200 px (Chromium). AST-105 FR3 says nested changes compose. Reading every follower's rectangle before starting any glide, then subtracting its nearest moving ancestor's move, would make this independent of the order.
| let sibling = node.nextElementSibling; | ||
| while (sibling != null) { | ||
| const style = view?.getComputedStyle(sibling); | ||
| if (style == null || takesPart(sibling, style)) { |
There was a problem hiding this comment.
A display: contents sibling passes takesPart and is returned here, but it has no box, so every startEdge reading is 0 and the margin falls back to the border box. In a flex column with gap: 8px before a display: contents wrapper (Tooltip and Theme render one), the row after it ended 8 px short and jumped when the element hid. Descending into it, as collectFollowers does, would measure the real next box.
| } | ||
|
|
||
| /** An element's start edge along the main axis (RTL rows read right to left). */ | ||
| function startEdge(element: Element, isRow: boolean, rtl: boolean): number { |
There was a problem hiding this comment.
For a closing flex-row item this reads only the inline axis, even when next is the element after the row. With the closing item 120 px tall beside a 40 px item, the hold was accepted, nothing below glided, and the row below jumped 80 px when the item hid. Measuring the block axis when next sits outside the row, and refusing the hold when either axis misses, would let the follower glide instead.
| const guess = startEdge(next, isRow, rtl); | ||
| element.style.setProperty(property, `${-size + (target - guess)}px`); | ||
| if (Math.abs(startEdge(next, isRow, rtl) - target) > 1 && before !== target) { | ||
| element.style.removeProperty(property); |
There was a problem hiding this comment.
This removes the end margin outright, so an element that had its own inline margin-block-end: 12px comes back with none after a refused exit, against “Returns false, setting nothing” above. Putting back the prior value, which holdLayoutExit already records, would keep that promise.
| ] as const, | ||
| ), | ||
| }; | ||
| if (!giveUpSpace(element, area)) { |
There was a problem hiding this comment.
When the hold is refused here, the change stays measured in the batch, so once the adopter hides the item the flush glides the content after it (88 px in a grid with row-gap: 8px). The new AST-105 out-of-coverage line says that content takes its final place at once. Either drop this change's followers when the exit is refused, or have the line say the content glides.
…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.
|
/approve-spec bc32d66 |
There was a problem hiding this comment.
Thanks for the fixes: nested changes now compose in any order, the exit finds the real next box and checks what follows its container, a refused hold keeps the element's own margin, and the INV12 interim and the new AST-105 line carry an engineering owner's approval on this head. Two changes are still needed before approval, and because this pull request edits both records, the fix commit will need a fresh /approve-spec <full head SHA> from an engineering owner (the author may do so as one):
- Under a scaled ancestor the exit adds a screen-pixel correction to a layout-pixel margin (
animatedLayout.ts:784-786), so in a gap stack or with collapsing margins the hold is refused and the content after it jumps; dividing the correction by the element's scale and adding a scaled exit case to the browser spec would fix it. - The
display: nonemeasurement (animatedLayout.ts:776-781) restarts CSS animations inside the closing section and firesanimationstartagain; measuring with the element taken out of flow another way, such as a temporaryposition: absolute, would keep them running.
[Automated review]
| element.style.setProperty(property, `${-size}px`); | ||
| const guess = placeOf(next, rtl)[axis]; | ||
| element.style.setProperty(property, `${-size + (target[axis] - guess)}px`); |
There was a problem hiding this comment.
size is layout pixels (offsetHeight), but target[axis] - guess comes from getBoundingClientRect(), which is screen pixels. Under a scale(0.5) ancestor an 8 px gap reads as 4 px, the margin lands 4 px of layout short, the check below refuses the hold and the content after the element jumps (Chromium probe: 44 px on screen; the same layout unscaled glides 88 px). AST-105 Coverage says elements under a scaled ancestor move corrected for the scale. Dividing the correction by the element's scale on this axis (scaleOf, as the glide does) would fix it.
| const display = element.style.getPropertyValue('display'); | ||
| const priority = element.style.getPropertyPriority('display'); | ||
| element.style.setProperty('display', 'none', 'important'); | ||
| const targets = checked.map(box => placeOf(box, rtl)); | ||
| const target = targets[0]; | ||
| element.style.setProperty('display', display, priority); |
There was a problem hiding this comment.
Toggling display: none here, even for one forced layout, cancels the CSS animations inside the element and restarts them when the display comes back: a spinner moved 1 s in came back at 0 ms with animationcancel and animationstart fired (Chromium probe). Taking the element out of flow for the measurement without changing its display, such as a temporary position: absolute, would keep them running.
…easurement keeps CSS animations running - The space correction is read in screen px and the margin is in the element's own: it is now divided by the element's scale on that axis, so an exit under a scaled ancestor frees exactly its space (AST-105 Coverage). - The layout without the element is read with it taken out of flow by a temporary position: absolute, not display: none, which restarted the CSS animations inside it. - Real-browser cases: a gap stack under a scaled ancestor, and a spinner inside the closing element that keeps its time with no animationstart.
|
/approve-spec e4b19b0 |
There was a problem hiding this comment.
Thanks for the fixes: the exit now lands exactly under a scaled ancestor and keeps CSS animations inside a closing section running, and the new browser cases for both pass here and fail against the previous revision. Every change matches spec:AST-105 and INV12, the INV12 interim and the new AST-105 out-of-coverage line carry an engineering owner's approval on this head, and the module stays internal until #8568 adopts it, so nothing further is needed from this review. Optionally, asserting the exit's start time and duration against its follower in the closing browser case would guard AST-105 FR4's same-motion exit.
[Automated review]
| exitDuration: Number(timing?.duration), | ||
| followerDuration: window.motionOf('#a')[0]?.duration ?? null, |
There was a problem hiding this comment.
Optional: exitDuration and followerDuration are read here but never asserted. Adding expect(result.exitDuration).toBeCloseTo(result.followerDuration, 3) (and comparing the exit's start time with the follower's) would guard AST-105 FR4's same-motion exit; a probe shows both start together over 734.8 ms today.
|
Posted by Cindy's agent on her behalf. Cindy's decision on content before a changed element: the animated layout also moves earlier siblings. At each level, the bounded walk collects earlier siblings as candidates too, and the same rule applies: only an element whose rect changed and that is visible moves. This covers centered rows, |
# Conflicts: # packages/core/src/motion/animatedLayout.boundary.test.ts # packages/core/src/motion/animatedLayout.test.ts # packages/core/src/motion/animatedLayout.ts
User need
When something inside a scroll container or app panel changes size (a folder in a tree opens, a settings section expands), the content after it jumps to its new place. People lose their place in a list, on a phone and with a mouse alike. An app can't fix this by composition: the content after the change belongs to other components and the app's own elements, and none of them know a size change happened above them.
Problem and solution fit
useAnimatedLayout({containerRef})returns one opaque handle per area. A component whose size changes takes it and reports each change through it.useAnimatedLayouthook and Collapsible's and TreeList'sanimatedLayoutprop arrive together in feat(core): useAnimatedLayout, and Collapsible and TreeList glide the content after them (spec:AST-105) #8568, which is stacked on this one, so no public API ships before a component can take the handle.Current specification
spec:AST-105(current): FR1–FR7, Coverage and Verification.architecture:react-component-runtimeINV12: one internal module, a compositor-only translate (through the shared rectangle morph once Core has it; this module's own compositor translate until then, as amended here), a walk throughparentElement/nextElementSibling, and changes batched per area per frame.springs.settle(spec:AST-094FR4), read from the internal role registry. Roles are internal (spec:AST-094DEC-11): no theme, scope or custom property overrides it.design:reduced-motionDR1–DR3. This is a named fine-pointer change underspec:AST-017FR59 (spec:AST-105FR5).Public delta and caller burden
None: the module is internal and unexported, and no component uses it yet. The public hook and prop are in #8568.
Design and alternatives
animate()reports whether the change was measured, so a component lands an unmeasured change at once.playLayoutChange). Its exit (holdLayoutExit) keeps it where it is, in flow and inert, while it gives up exactly its space: the end margin is measured against the layout with the element taken out of flow (so CSS animations inside it keep running) and corrected for a scaled ancestor, so gaps and collapsing margins come out exact, and the content after it glides up at once while it still scrolls and clips with its container. It conceals upward on the same motion before the component hides it. Only a measured close is held. Where no margin can free the space (a grid item whose track or gap changes, or a flex-row item that sets its row's height), the element hides at once without its exit and the content after it takes its final place at once, as AST-105's out-of-coverage list now names. A changed element that an earlier change in the same frame displaced moves with its row, by its total change, and nested changes compose whichever is reported first.display: contents.transform,translate,rotateorscale, or a transform animation the module did not start) takes its final place at once.translate, which the compositor runs (INV12), sampled fromsprings.settleinto linear keyframes and corrected for a scaled ancestor. An element already gliding keeps that motion and adds the new move: the rest of its running curve and the new move are summed into one new glide, so it keeps its speed with no jump. Every animation of a change starts on the document timeline's current time. The glide, the entrance and the exit all read the one internalsprings.settlevalue. Height is never animated, and the area is never scrolled.spec:AST-105DEC-1): wrapping the following content in an element, or opting in each following element.Evidence
The module's proof is its real-browser spec (below): one case per
spec:AST-105Coverage row plus INV12's throttled-phone case, run in real Chromium. The fine-pointer before-and-after imagesspec:AST-105FR5 asks of adopting pull requests are on #8568 (Collapsible and TreeList, open and close).Scope
This introduces a new behavior module (
motion/animatedLayout.ts). The adoption is split into #8568.Testing
animatedLayout.test.ts(jsdom, 29 tests): the handle has no members; nothing runs without a handle; the walk at every level and nothing past the area;display: contents; absolute, fixed, hidden anddisplay: noneskipped; unchanged and out-of-view elements left alone; two changes in one frame move once by the total; the area is never scrolled; one duration fromsprings.settle, unchanged by a scope's--motion-springs-settle; Reduce Motion (a cross-fade on a coarse pointer, at once on a fine one); a running glide continued with its speed plus the new move; a refused measurement dropped by the next frame; an unmeasured change moving nothing; the exit kept in place, inert, giving up exactly its space (a gap included), landing at once where a grid track can't free it, refusing an unmeasured close, putting back the element's own inert and inline values, a refused hold keeping its own margin and gliding nothing, concealed, handed back, stopped by a reopen, and landing at once or fading under Reduce Motion; a changed element an earlier change displaced moving by its total change; an element not rendered before a change not moving; app-moved followers left alone.animatedLayout.boundary.test.ts: INV12's source boundary check (no layout property animated; the walk only throughparentElement/nextElementSibling, enteringdisplay: contentsalone; nothing public; nothing uses the module yet).animatedLayout.interaction.browser.spec.ts(the interaction proofs, Chromium and WebKit; run locally in real Chromium: 32 of 32 pass, three repeats): one case perspec:AST-105Coverage row (the sticky row with a stuck header after the change, the flex row with an item that wraps and a row-direction neighbor whose rectangle does not change); INV12's throttled-phone case (a 10-level by 50-sibling tree, two changes in one frame: one move per follower, none inside a subtree, none past the area, no height); FR2; FR3 (two changes in one frame, the displaced changed element included; scrolling identical with and without a handle); FR4 (one start frame and duration, the exit while the content after glides up, the exit in a scrolled area staying in place, scrolling and keeping its width, the exit freeing exactly its space in a flex gap, with collapsing margins, before adisplay: contentswrapper and as the last child of a plain wrapper (Collapsible's shape), a tall flex-row item refused with nothing gliding, nested changes reported inner-first, a gap stack under a scaled ancestor, a spinner inside the closing element keeping its time, app-moved followers left alone, a scope's--motion-springs-settlechanging nothing, Reduce Motion on each pointer); FR5.pnpm -F @astryxdesign/core typecheck, eslint,pnpm check:repo,pnpm check:knowledge; motion and hooks tests: 956 pass.Writing rubric results
Records touched:
architecture:react-component-runtimeINV12: the glide plays through the shared rectangle morph once Core has it, and its own compositor translate until then.spec:AST-105: one out-of-coverage line for a closing element whose space no end margin frees (a grid item whose track or gap changes, or a flex-row item that sets its row's height).Rubric:
animatedLayout.test.ts(the grid fallback and refused-hold cases) and the tall flex-row browser case.