Repository navigation
feat(core): the animated-layout module's in-place swap (spec:AST-105 FR8) - #8734
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
There was a problem hiding this comment.
Approval recommended: every change matches spec:AST-105 FR8, architecture:react-component-runtime INV12 and architecture:component-style-authoring INV7 as they now stand on main, and the right-to-left glide fix restores FR3. The two defects raised on the previous revision are fixed: a handed-off copy keeps its travel, clip and text fade, and copied style sheets and textareas keep their text. The description could also mention playLayoutFrames, handOff and onEnd, which it currently leaves out.
[Automated review]
| } | ||
| }); | ||
|
|
||
| it("keeps the swap's copy mechanism private to the module", () => { |
There was a problem hiding this comment.
Optional: the public-entry check above names only the four reporting functions. Adding holdLayoutSwap, playLayoutSwap and playLayoutFrames to its list would make spec:AST-105 FR8's "a public export fails" mechanical; none of them is exported today.
…nd is told how far it has moved With rideWith, the copy, its text and the incoming parts all play over the ridden element's glide, and their frames receive its travel so far, so a caller can keep an edge on that glide (a clip that follows a column); isCopyMoved false keeps the copy in place. handOff is removed: a reversed swap is drawn with one copy.
There was a problem hiding this comment.
Changes requested for one fix: the swap's transient copy is appended to host, so it loses whatever it inherited from, or matched through, the ancestors between host and the copied element; for example, a copy riding a column with its own 13 px type opens at 16 px and spills from its box, and an ancestor's new collapsed class hides the old rail copy's labels. Carrying the element's computed inherited properties onto the copy root in inertCopy, or at least listing this beside the id limit in transientCopy.ts, would resolve it. Everything else matches spec:AST-105 FR8, INV12 and INV7 as they stand on main, and riding a glide now keeps every part on its clock. The description could also mention playLayoutFrames, onEnd, isCopyMoved and the ride passed to every part.
[Automated review]
| width: `${rect.width}px`, | ||
| height: `${rect.height}px`, | ||
| }); | ||
| host.appendChild(element); |
There was a problem hiding this comment.
The copy joins host's cascade here, and with rideWith that host sits outside the ridden element, so whatever the element inherited from, or matched through, the ancestors in between stops applying for the swap. In Chromium, a copy laid outside a column with font-size: 13px; line-height: 1.45 opened at 16 px with line-height: normal and spilled from its box, and an ancestor's new collapsed class hid the old rail copy's labels. Writing the element's computed inherited properties that differ from the host's onto the copy root in inertCopy, or at least naming this beside the id limit in the header, would keep the copy faithful, or the limit known.
| copy: (_p, ride) => ({clipPath: `inset(0px ${-ride.x}px 0px 0px)`}), | ||
| rideWith: {element: column, travel: {x: 212, y: 0}, isCopyMoved: false}, | ||
| incoming: [ | ||
| {element: title, frames: (_p, ride) => ({marginLeft: `${ride.x}px`})}, |
There was a problem hiding this comment.
Optional: this frame plays marginLeft per frame through the module, the per-frame layout architecture:react-component-runtime INV12's verification fails. translate: `${ride.x}px 0px` would prove the same ride without modelling it.
|
/approve-spec 179b3bf |
…ited from them inertCopy reads the element's inherited properties and custom properties; placeOver writes those that differ from the host's on the copy, so a copy riding a column keeps that column's type and variables. Rules matched through an ancestor between the host and the element are named as a limit. The boundary test also checks that the swap functions are not exported.
|
/approve-spec a234c3c |
There was a problem hiding this comment.
Changes requested for one follow-up in the copy's new inherited-property carry: readInherited reads line-height from getComputedStyle, which reports a unitless line-height in pixels, so a descendant with another font size loses its factor (a 16.25 px title in a 13px / 1.45 column drops from 23.56 px to 18.85 px lines, and the text under it starts 9.4 px higher during the swap). Carrying the computed number instead, for example from computedStyleMap().get('line-height') where it is available, or naming the limit beside the id one in transientCopy.ts, would resolve it. Everything else matches spec:AST-105 FR8, INV12 and INV7 as they stand on main: the inherited type, colour and custom properties now carry, and the right-to-left glide fix behaves as described. The description's test count (24 cases now) and product size could also be updated.
[Automated review]
| const values = new Map<string, string>(); | ||
| const style = getComputedStyle(element); | ||
| for (const name of INHERITED_PROPERTIES) { | ||
| values.set(name, style.getPropertyValue(name)); |
There was a problem hiding this comment.
getComputedStyle reports line-height as its resolved length, so a unitless line-height (Astryx's reset sets 1.5 on html) is written onto the copy root as pixels at :210, and every descendant with another font size inherits those pixels instead of the factor: in Chromium a copied 16.25 px title in a 13px / 1.45 column drops from 23.56 px to 18.85 px lines and the paragraph under it starts 9.4 px higher. Carrying line-height from element.computedStyleMap?.().get('line-height') when it is a number (Chromium reports 1.45 here), or naming the limit beside the id one in the header, would keep the copy as it showed.
| } | ||
| } | ||
|
|
||
| /** Scroll offsets a copy restores once laid out (a clone carries none). */ |
There was a problem hiding this comment.
Optional: this doc comment now sits above INHERITED_PROPERTIES; it describes scrollOffsets at :120.
The computed style reports a unitless line-height in pixels; the copy now takes the factor from the typed style map where it has one, so descendants with another font size keep their line spacing. A stray doc comment moves back to scrollOffsets.
|
/approve-spec 9b6ad1c |
There was a problem hiding this comment.
Changes requested for one Firefox fix: the swap matches spec:AST-105 FR8, architecture:react-component-runtime INV12 and architecture:component-style-authoring INV7 as they now stand on main, and the right-to-left glide change restores FR3's glide. Firefox has no computedStyleMap, so readInherited (transientCopy.ts:110-120) keeps the pixel line-height and a copy laid outside its ancestors (the rideWith path) hands that fixed value to every descendant: in a 16 px area with a 13 px column and line-height: 1.5, the copy's 24 px title drops from 36 px to 19.5 px lines, so the swap opens on squeezed text. Leaving line-height to inherit when the element's line-height-to-font-size ratio equals the host's, with a unit case that has no computedStyleMap, would fix it, and the module header should also list playLayoutFrames.
[Automated review]
| } | ||
| // A unitless line-height inherits as its factor, but the computed style | ||
| // reports it in pixels: carry the factor where the typed style map has it. | ||
| const lineHeight = element.computedStyleMap?.().get('line-height'); |
There was a problem hiding this comment.
Firefox has no computedStyleMap (in Firefox 151 it is undefined), so this falls back to getComputedStyle's pixels, and placeOver writes them on a copy laid outside its ancestors. With a 16 px host, a 13 px element and line-height: 1.5, the copy root gets line-height: 19.5px and a 24 px descendant's lines shrink from 36 px to 19.5 px. Without Typed OM, leaving line-height to inherit when the element's line-height-to-font-size ratio equals the host's keeps the shared factor. The case at animatedLayout.swap.test.ts:695 stubs computedStyleMap, so one without it would cover this path.
| * handle, `reportLayoutChange`, `playLayoutChange`, `holdLayoutExit` / | ||
| * `releaseLayoutExit` and `applyLayoutChangeTransition`: the one module | ||
| * `releaseLayoutExit`, the in-place swap (`holdLayoutSwap` / | ||
| * `playLayoutSwap`) and `applyLayoutChangeTransition`: the one module |
There was a problem hiding this comment.
Minor: this export list, and the How it works list below it, leave out playLayoutFrames, the third new export.
…ight as a factor too Where computedStyleMap is missing (Firefox), the copy takes the computed line-height over the font size, and leaves it to inherit when the host passes the same factor. The module header also names playLayoutFrames.
|
/approve-spec 81388fd |
There was a problem hiding this comment.
Changes requested for one fix. The swap's copy rewrites a length line-height (such as 20px) as a factor of the copied element's font size whenever the typed style map does not report a number (packages/core/src/motion/transientCopy.ts, lines 130-134), so copied text in another font size re-spaces during the swap; in Chromium, a 20px child's lines became 30.76px. Using the factor only where computedStyleMap is missing, comparing a carried length with the host's computed line-height in placeOver, and adding a test whose typed map reports px would fix it. Everything else matches spec:AST-105 FR8, architecture:react-component-runtime INV12 and architecture:component-style-authoring INV7 as they stand on main, and the right-to-left glide fix restores FR3.
[Automated review]
| } else { | ||
| const factor = lineHeightFactor(style); | ||
| if (factor != null) { | ||
| values.set('line-height', String(factor)); | ||
| } |
There was a problem hiding this comment.
This fallback also runs when a typed style map exists and reports a length (20px, 1.25rem, 150%). A length line-height is then carried as a factor of this element's font size, and placeOver writes it whenever the host's factor differs, so descendants in another font size inherit the factor instead of the length. In Chromium, with a 16 px host at line-height: 20px, a 13 px element and a 20 px child, the copy's child lines went from 20px to 30.76px and its content from 60 to 81 px. Taking the factor only when computedStyleMap is missing (keeping a typed length as its computed pixels), and comparing a carried length with the host's computed line-height at line 236, would keep both cases faithful.
The typed style map tells a factor from a length; only without one does the copy take the computed pixels over the font size. A carried length is compared with the host's computed line-height, a factor with the host's factor.
|
/approve-spec 3e3322e |
There was a problem hiding this comment.
Changes requested for one remaining fix in the swap copy's line-height carry; the length case from the last round now keeps its spacing. placeOver compares a carried line-height with the host's computed value without checking whether the host passes a factor or a length (packages/core/src/motion/transientCopy.ts, lines 241-244), so when the numbers agree but the kinds differ nothing is written and the copy re-spaces: in Chromium, a 13px copy whose column sets 20px inside a line-height: 1.25 host drops from 20px to 16.25px lines. Reading the host's typed line-height and writing the carried value whenever the kinds differ, with a test for each direction, would fix it. The branch also conflicts with main, where #8634 rewrote the same rawX lines in flush, so the merge needs to keep both its scroll netting and the inline-start edge.
[Automated review]
| const passed = | ||
| name === 'line-height' && /^[\d.]+$/.test(value) && hostFactor != null | ||
| ? String(hostFactor) | ||
| : hostStyle.getPropertyValue(name); |
There was a problem hiding this comment.
This compares a carried line-height with the host's computed value without asking whether the host passes a factor or a length. When the numbers agree but the kinds differ, nothing is written and the copy inherits the host's kind. In Chromium, with the host at line-height: 1.25 (20px at 16px) and the column between them at 20px, a 13px copy's lines drop from 20px to 16.25px and its 20px child's grow to 25px; with the host at 24px and the column at 1.5, they go from 19.5px to 24px and from 30px to 24px. Reading the host's typed line-height where computedStyleMap exists, and writing the carried value whenever the kinds differ, would fix both; a test for each direction would pin it.
…writes its carried line-height The merge keeps main's scroll netting in flush beside the inline-start edge read from the container's direction. The copy now always writes its carried line-height: a host can pass the same number as the other kind (a factor against a length), which descendants inherit differently.
|
/approve-spec 1cf5eab |
There was a problem hiding this comment.
Approval recommended: the latest commit fixes the swap copy's line-height carry, so in Chromium the copy now keeps its original's line spacing in both cases from the last round, and the merge from main keeps both the scroll netting and the inline-start edge in flush. Every change matches spec:AST-105 FR8, architecture:react-component-runtime INV12 and architecture:component-style-authoring INV7 on main, so an engineering owner can approve. One optional follow-up: Firefox has no typed style map, so a length line-height reaching the copy is still written as a factor and can re-space descendants of another font size during the swap; naming that limit in the transientCopy.ts header would tell adopters what to expect.
[Automated review]
| // A line-height is always written: the host may pass the same number as | ||
| // the other kind (a factor against a length), which descendants inherit | ||
| // differently. | ||
| if ( | ||
| value !== '' && | ||
| (name === 'line-height' || hostStyle.getPropertyValue(name) !== value) | ||
| ) { | ||
| element.style.setProperty(name, value); | ||
| } |
There was a problem hiding this comment.
Optional: without a typed style map (Firefox), readInherited carries the computed pixels over the font size as the factor, so writing it unconditionally re-spaces descendants of another font size whenever the line-height reaching the copy is a length. In Firefox 151 a copy laid in its own parent with line-height: 24px keeps its 16 px text at 24 px but takes its 12 px child from 24 to 18 px lines; the previous comparison left that case alone and misread the opposite one. Naming this beside the header's other limits would tell adopters what to expect.
… fix, as asked on the review
Based on main, which carries
useAnimatedLayoutand the size-change module (#8568) and the D38 records (#8720). #8723 (SideNav/AppShell rail motion) is its first adopter and stacks on this.What this adds
The animated-layout module's in-place swap,
spec:AST-105FR8: an area can hold its old content as one transient copy while new content enters in the same place, on FR4's one motion.holdLayoutSwap(handle, element, {isVisiblePart?}), before a change commits: copies the element. WithisVisiblePart, the copy is the visible part of a scroll container, with its content scrolled as it was, so it adds no scroll overflow. It returns nothing without a handle, outside the area, under an immediate change (Reduce Motion with a fine pointer), or for content over 1,500 elements.playLayoutSwap(swap, {host, copy, copyText?, isOpaque?, incoming}), after the commit and before paint: lays the copy over its old place inhost, wherever its containing block is (no ancestor's position changes). It plays the copy out while the incoming elements enter, every part on the same duration and timeline start time. The caller shapes the frames over the swap's progress. Under Reduce Motion with a coarse pointer, it plays opacity only (DR2). The copy is removed when the swap ends. It returns a function that ends the swap at once.rideWith: {element, travel, isCopyMoved?}plays every part of the swap (the copy, its text, the incoming parts) over that element's actual glide, frame by frame, and hands each frame function how far the element has moved. By default the copy moves with it. WithisCopyMoved: falsethe copy stays put and a caller can follow the glide another way, such as a clip that keeps an edge on it. The parts start after the batch flushes and before paint. When the element lands silently (out of view, or moved by the app), the ride is the wholetravelat once.onEndruns once when the swap ends.playLayoutFrames(handle, parts)plays frames on elements the page renders, on the same motion with no copy, and releases them when the motion ends.inertandaria-hidden, and it carries noidordata-testid. It also has noname/form(a copied checked radio would otherwise uncheck the live one), and its frames and media load nothing.scroll-behavior: smooth). SVG labels fade through theirtextelement. Laid in a host outside the element's own ancestors (a copy riding a column), it keeps what it inherited from them: inherited properties and custom properties that differ from the host's are written on it. Canvases are read back at their own size, so thin marks are kept. Limits: content holding a canvas that reads back empty isn't copied, and the new content enters without an old copy. That covers a canvas that can't be read back (a WebGL canvas withoutpreserveDrawingBuffer, or a tainted one) and a blank one. A style rule keyed on a removedid, or matched through an ancestor between the host and the element, doesn't style the copy as it styled the element.motion/transientCopy.ts, which only the module imports (boundary test). That keeps the module's walk and property checks intact.Authority:
spec:AST-105FR8 andarchitecture:react-component-runtimeINV12, which excepts the swap's one copy, plusarchitecture:component-style-authoringINV7 (the one shared transient-copy mechanism), all current on main since #8720 merged (as the round-3 records state them. Nothing public changes, and nothing adopts the swap in this PR. One glide fix rides along because the adopter exposed it: the glide measures a move by the inline-start edge, read from the container's direction. In a right-to-left container, content after a Collapsible or TreeList size change (with a handle) now glides instead of jumping, and the changeset says so.Tests
Failing first:
animatedLayout.swap.test.tshas 25 cases, each failing before its change.for, form or radio group, and no frame or media load.onEndruns once.isCopyMoved: falsethe copy stays put.playLayoutFramesplays on the same motion (opacity only under DR2) and releases its frames when the motion ends.The motion, Collapsible and TreeList suites pass (about 800 tests). Real-browser proof is the adopter's: #8723.
Size
Product +642 lines across
animatedLayout.tsandtransientCopy.ts, about a third of them the doc comments the module's conventions ask for. That's over the size cap because the swap is one feature: the shared copy mechanism and the player that uses it.