Skip to content

spec(AST-105): animated layout; architecture(react-component-runtime): its mechanism (INV12) - #8463

Merged
cixzhang merged 33 commits into
mainfrom
spec/disclosure-height-module
Oct 26, 2026
Merged

cixzhang merged 33 commits into
mainfrom
spec/disclosure-height-module

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Decided (Cindy, 2026-10-25): OQ1–OQ5 all resolved.

Exact decision needed

How does Core designate the content that moves when a disclosure changes height (OQ1)? Strategy is fixed by Cindy (16:12): measure before and after, translate on the compositor.

Existing authority searched

  • architecture:react-component-runtime INV10 and INV11 (one internal module per cross-component behavior), INV3 and INV5.
  • spec:AST-094 FR5 (compositor-only motion), design:reduced-motion DR2 (cross-fade), architecture:component-style-authoring INV7 (no added elements).
  • architecture:public-component-api INV18–INV21 (architecture(public-component-api): four API-shape principles (INV18–INV21) #8459, approved): INV21 for any public boundary.
  • architecture:layer-runtime is for layers, so it is not the home; this is a runtime module like INV10/INV11.
  • Current state: feat(TreeList): the expand moves as one under a finger #8088's TreeList walks every in-flow sibling up to the scroll container; INV12 replaces that walk with designation.
  • Search terms: disclosure, expand, collapse, following content, scroll container, FLIP.

Decision

Split per knowledge-contracts INV18 (chief OK, 2026-10-25): the public contract (hook, animatedLayout prop, glide behavior, coverage table) is a new system spec, spec:AST-105; architecture:react-component-runtime INV12 keeps only the mechanism (measure, walk, translate, internal reporting function), as INV10/INV11 project AST-086/AST-099. The text is unchanged apart from the move.

architecture:react-component-runtime INV12:

  • Strategy (Cindy 16:12): measure before and after, translate on the compositor; never animate height.
  • Handle (decided, Cindy 17:09, OQ1 = (c) with a generic boundary): any container element registered by ref is a boundary; ScrollableArea registers itself; an app registers its own container or panel with useAnimatedLayout(containerRef). A disclosure reports to its nearest registered ancestor boundary, which moves only participants after it.
  • Edge cases: fixed or sticky elements, non-participants, and content outside take their final place at once; a nested boundary moves as one participant.
  • Reduce Motion: cross-fade. INV7: participants translate existing elements.

Reframe: any size change (Cindy 19:14)

INV12 is a general size-change (layout-shift) module: disclosures, async content loading in, messages appearing, items inserted or removed, text expanding. Names are generic: public useAnimatedLayout(containerRef: RefObject<HTMLElement>): void (ScrollableArea registers itself); internal reportLayoutChange(ref) (not exported).

API shape (Cindy 20:18)

const scrollRef = useRef(null);
const animatedLayout = useAnimatedLayout({containerRef: scrollRef});
<ScrollableArea ref={scrollRef}>
  <TreeList items={items} animatedLayout={animatedLayout} />
</ScrollableArea>
  • useAnimatedLayout({containerRef}) returns an opaque handle (no public methods or fields); size-changing components take an animatedLayout prop and report through it. Explicit composition replaces implicit registration; reporting is an internal method on the handle.
  • No handle: today's behavior (content takes its place at once).
  • ScrollableArea does not self-register (least magic): the app passes the ref it already gives ScrollableArea, which forwards it to the native scroll viewport on main.
  • API principles: INV18, the prop is configuration, not a rendered slot; INV21, no other API does this job; INV20, not a visual boolean. AST-002 FR5: two otherwise identical pages differ only in whether content glides and which container bounds it, which only the caller knows.
  • CodeBunny consistency in the same push: the motion role cites architecture:theme-tokens INV3 (the layout role name waits for the motion scheme); decision and rollout notes removed per knowledge-contracts INV10; JSDoc says "stuck sticky" and "motion role"; fine-pointer Reduce Motion and visible-follower checks; display: contents carve-out; INV12 in the review-applicability block.

What the hook does (Cindy 19:15); name decided 19:28: useAnimatedLayout

All the logic (measure, walk, translate) lives inside one internal module. The public hook only marks the area where the walk stops; the internal reportLayoutChange(ref) only triggers it. Name (OQ4), each for what it does:

Name What it does Vibe test (4 agents)
useSmoothLayoutShift(ref) layout shifts in this area are smoothed 4/4 reach, 4/4 avoid, correct usage
useGlideOnResize(ref) (runner-up) content after a resized element glides into place 4/4, 4/4
useAnimatedLayout(ref) (DECIDED, Cindy 19:28: "pick the one that has the least specialized words") when something inside this area changes size, the content after it glides instead of jumping 4/4, 4/4
useAnimatedReflow(ref) reflow in this area is animated 4/4, 4/4
useDisclosureAnimatedShift(ref) (component-specific) 2.5/4: missed the form-error case

Authoring guidance now lists the points every agent was unsure about: only content after the change moves; fixed and stuck sticky stay put; Reduce Motion handled; List rows use the same module; nesting stops at the nearest area; scroll position kept.

Timing and pointer scope (decided)

  • Cindy 19:29–19:31: the changed element animates itself and the module moves only what follows. Both read one shared generic motion role (the layout-change role in the D16 scheme, pending), so timing is governed centrally through theming; reportLayoutChange(ref) only triggers the walk and lines up the start frame; Reduce Motion reaches both through the role.
  • Cindy 19:29 ("Yes, a layout fix"): the glide applies under every pointer. It is a named fine-pointer change under AST-017 FR59 (a layout jump mouse users see too); adopting PRs show fine-pointer before/after evidence.

Element selection (Cindy 19:16)

The walk collects candidates; recommended (OQ5 a): only candidates whose layout rectangle changed between the two measurements move.

Case Rule
display: contents no box; the walk takes its children
display: none, hidden, zero size skipped
absolute, fixed, sticky skipped; an absolute child of a moving element rides with it
inline vs block, flex row, order, wrap, floats decided by measured change
transformed ancestor, other containing block compared in boundary coordinates
portals, top layer never
text nodes ignored
already moving retarget from on-screen value (AST-094 FR6)
Rule Correctness Cost
(a) measured change, recommended moves exactly what shifted; handles flex order, wrap, floats, side-by-side content one rectangle comparison per candidate (pending lane 1)
(b) pure DOM siblings moves content that didn't shift; misses reordered content skips the comparison

Mixed following content (OQ2, decided: (i))

Cindy (19:11): "I think a sibling walk on the container ref would be how we do it. It needs to account for the disclosure existing within a parent that has siblings in the container, and arbitrary nesting between the disclosure and the container."

  • From the disclosure, walk up each ancestor to the boundary; at every level, following in-flow siblings move by how far layout moved them (measured before and after). The walk stops at the boundary.
  • Fixed, sticky, and other out-of-flow elements are skipped; a nested boundary moves as one unit of its parent's walk.
  • Mixed Astryx and app content moves with no opt-in. Public API: only useAnimatedLayout(containerRef: RefObject<HTMLElement>): void; ScrollableArea registers itself. useDisclosureParticipant is removed.
  • Several disclosures compose without wrappers: toggles in one boundary in the same frame are measured together, so each element moves once by its total change.
  • Option (iii), a wrapper, was rejected (Cindy 19:09: "A wrapper will create excessive tree layers when there are multiple disclosures and it doesn't compose well.").

Walk cost (lane 1, final; 4× CPU Chromium, mobile viewport, software GPU):

Case Result
Visible-only, deep 10×50 9.2 ms median / 10.3 ms max, 63 fps, 13 animated of 480
Visible-only, deep 3×200 8.7 / 9.3 ms, 63 fps
Visible-only, typical TreeList page 2.0 / 2.4 ms, 60 fps; one forced layout
Manual walk vs TreeWalker 0.9–1.2 ms vs 3.4–5.4 ms
OQ5 (a) move only changed vs (b) every sibling (a) ~9 ms vs (b) ~30 ms with culling; (a) is right for grid, flex followers, and sticky

Fixture findings folded into the coverage table: display: contents children need explicit handling; inline elements cannot take a transform (they take their final place, their block moves); flex items placed later by order must be candidates; a scaled ancestor needs a scale correction; elements mid-animation retarget (additive composite in the implementation).

Cost rule (in INV12): animate only elements whose rectangle changed and that intersect the boundary's visible area; offscreen elements take their final place at once. Setup is about 0.05 ms per animated element, so the cost is bounded by what is on screen. No per-level wrappers (rejected 19:09).

Correctness fixtures (pure sibling rule vs move-only-changed) are next from lane 1.

Perf

Headless Chromium on a server CPU; a disclosure with 400 following rows of wrapped text; forced layout timed over 60 frames, median of 15 runs (three runs agree):

Step Main-thread layout
Animating the disclosure's real height 0.028 ms per frame
Translating the following content 0.012 ms per frame
One before/after measurement 0.028 ms, once per change

On a 4–6× slower phone that is roughly 0.1–0.2 ms per frame either way, so main-thread layout is not where the approaches differ. These numbers do not capture paint and raster: a height animation repaints the moved region every frame, while a translate is composited (AST-094 FR5). That is the reason to measure once and translate.

A demo view exists for owner review.

First adopters

TreeList (groups expand inside its scroll area) and Collapsible.

Rejected alternatives

  • Animating height; walking siblings to find what follows.

Writing rubric results

  • R1 narration: none.
  • R2 research in the record: none.
  • R3 evidence naming a change: none.
  • R4 rejected alternatives over one line or outside a decision: none in the record.
  • R5 pull request or issue references remaining: none.
  • R6 ruled open questions still listing options: none; the record has no open questions.

Scope

  • This PR contains specification records only.
  • It records observable behavior and preserves equivalent internal implementations.
  • Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract.
  • Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
  • It records one intentional durable decision, not a rescue for a separable tagalong.
  • The canonical knowledge owner, affected current claims, and overlapping open work are named; any ownership collision is resolved or called out for human decision.
  • Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
  • Public text and artifacts contain no internal Meta context.

Validation

  • pnpm check:knowledge (CI lint)
  • Prettier
  • git diff --check

@vercel

vercel Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Error Error Oct 26, 2026 12:03pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 26, 2026, 12:40 PM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 25, 2026
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview.

No new or modified components detected.

Bundle Size Summary

No component packages changed.

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proposal waits on an engineering owner's decision on INV12: whether to adopt handle (c) from OQ1, and whether the travel applies on every pointer or only on a coarse primary pointer. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Because a new commit invalidates approval, please fix these in the record first so the approved head is final:

  • The Reduce Motion sentence cross-fades on every pointer, but design:reduced-motion DR3 requires an immediate change when the primary pointer is not coarse; defer to DR1–DR3 or scope INV12 to touch.
  • ScrollArea is not a Core component; the scroll container is ScrollableArea.
  • architecture:public-component-api INV21 is not on main yet, so cite only spec:AST-002 admission or land that change first.
  • Remove the "Proposed; pending owner decision" and "(proposed)" markers and collapse OQ1 into INV12 in the head that gets approved.

[Automated review]

its own elements, and follows INV3 and INV5. The hook follows INV3 and
INV5.
- **INV12 — Disclosure height changes have one module and one handle.**
**Proposed; pending owner decision (OQ1).** In `@astryxdesign/core`, one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This marker, the INV12 (proposed) verification row and OQ1's options should come out in the head an owner approves; otherwise a current record merges a claim that calls itself pending.

change and moves following content with a compositor-only translate from
its old place to its new one (`spec:AST-094` FR5); it never animates height,
which lays out every frame. What moves is designated, never found by walking
siblings: a scroll-area container, such as ScrollArea, is the boundary that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core has no ScrollArea; its scroll container is ScrollableArea (component:ScrollableArea). The same name appears in the example at lines 186, 201, 202 and 205.

Comment on lines +177 to +178
around it. Under Reduce Motion nothing travels, and following content
cross-fades into place (`design:reduced-motion` DR2). The module and its hook

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

design:reduced-motion DR2 applies only on a coarse primary pointer or a touch release; DR3 requires an immediate change, with no animated property, otherwise. As written this cross-fades on every pointer. Suggest deferring to DR1–DR3 here and adding "an animated property under reduced motion with a fine pointer" to the verification row's failure signal.

cross-fades into place (`design:reduced-motion` DR2). The module and its hook
are not exported; a scroll container an app owns is not a boundary, so
content in it keeps today's flow, and making a boundary public needs
`spec:AST-002` admission and `architecture:public-component-api` INV21.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

architecture:public-component-api INV21 is not on main (that record ends at INV17); it exists only in an open proposal. Cite only spec:AST-002 admission here, or land INV21 first.

// A disclosing element measures before its commit and animates after it.
const change = useDisclosureChange(groupRef);
const toggle = () => {
change.measureBefore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This takes the before-measurement only in the disclosure's own toggle. A CollapsibleGroup in its default single mode closes another item whose toggle never runs, and a controlled isOpen changes without this handler; a boundary-level snapshot would cover both.

@cixzhang

Copy link
Copy Markdown
Contributor Author

Posted by Cindy's agent on her behalf.

Cindy's decision on how following content is designated (OQ1): (c), with a generic boundary.

  • The boundary is any container element passed by ref, e.g. useDisclosureBoundary(containerRef).
  • ScrollArea wires itself as a boundary automatically. That's the recommended, default path.
  • Other containers, such as an app's own scroll container or a panel, can opt in by providing a ref.
  • Inside a boundary, registered content after a disclosure moves together with a measure-then-translate, and nothing outside the boundary moves.

Since apps can provide a ref, the record should state the minimal public shape for that integration point and check it against the API principles.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proposal waits on an engineering owner's decision on INV12: whether to adopt it with OQ2 settled as (ii), which makes useDisclosureParticipant public beside useDisclosureBoundary, and whether the travel applies on every pointer or only on a coarse primary pointer. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Because a new commit invalidates approval, please fix these in the record first so the approved head is final:

  • The Reduce Motion sentence cross-fades on every pointer, but design:reduced-motion DR3 requires an immediate change when the primary pointer is not coarse; defer to DR1–DR3 or scope INV12 to touch.
  • ScrollArea is not a Core component; the scroll container is ScrollableArea.
  • architecture:public-component-api INV21 is not on main yet, so cite only current claims or land that change first.
  • Remove the "Proposed; pending owner approval", "INV12 proposes (ii)" and "is decided by … 2026-10-25" wording and the "(proposed)" row label, and fold the ruled OQ2 into INV12.

[Automated review]

Comment on lines +180 to +182
as one participant of the boundary around it. Under Reduce Motion nothing
travels, and following content cross-fades into place
(`design:reduced-motion` DR2). The handle (a container boundary with opt-in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

design:reduced-motion DR2 cross-fades only on a coarse primary pointer or a touch release; DR3 requires an immediate change, with no animated property, otherwise. As written this cross-fades on every pointer. Deferring to DR1–DR3 here, and adding "any animated property under reduced motion with a fine pointer" to the INV12 row's failure signal, would fix it; so would scoping INV12 to a coarse primary pointer.

translate from its old place to its new one (`spec:AST-094` FR5); it never
animates height, which lays out every frame. What moves is designated, never
found by walking siblings. A boundary is any container element registered by
ref: ScrollArea registers its scrolling element itself, and an app registers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core has no ScrollArea; its scroll container is ScrollableArea (component:ScrollableArea). The usage comment at line 194 repeats the name.

(ii), `useDisclosureParticipant(elementRef)`: each takes a ref and returns
nothing, because which container scopes the motion and which app content
moves with it are the caller's intent (`spec:AST-002` FR1), and no other API
does either job (`architecture:public-component-api` INV21). The change hook

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

architecture:public-component-api INV21 is not on main (that record ends at INV17); it exists only in an open proposal. Cite only current claims here, such as spec:AST-002 FR1 and FR5, or land INV21 first.

its own elements, and follows INV3 and INV5. The hook follows INV3 and
INV5.
- **INV12 — Disclosure height changes have one module and one handle.**
**Proposed; pending owner approval of the public hook.** In

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This marker, "INV12 proposes (ii)" (line 172), "is decided by cixzhang, 2026-10-25" (lines 182–183) and the INV12 (proposed) row label (line 358) should come out of the head an owner approves, with the ruled OQ2 folded into INV12; otherwise a current record merges proposal and decision history (architecture:knowledge-contracts INV10).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proposal waits on an engineering owner's decision on INV12: whether to adopt it with OQ2 settled as (ii), which makes useDisclosureParticipant public beside useDisclosureBoundary, and whether the travel applies on every pointer or only on a coarse primary pointer (OQ3). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Because a new commit invalidates approval, please make these changes first so the approved head is final:

  • Remove "Proposed; pending owner approval", "INV12 proposes (ii)", "is decided by … 2026-10-25" and the "(proposed)" row label, and fold the OQ2 and OQ3 rulings into INV12 (architecture:knowledge-contracts INV10); the description's "R1 narration: none" does not match the record.
  • Add a fine-pointer Reduce Motion case to the INV12 verification row, since INV12 now requires an immediate change there (design:reduced-motion DR3).
  • State the spec:AST-002 FR5 admission argument for each hook: the two otherwise identical cases and why Core cannot derive the outcome.
  • For OQ2, note that under (ii) unregistered content after the participants takes its final place at once, so it overlaps them while they slide back on a collapse.

[Automated review]

| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop |
| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement |
| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change |
| INV12 (proposed) | Real-browser tests on a throttled phone profile plus a source boundary check, over a disclosure inside a boundary with participants, non-participants, a sticky header, a nested boundary, content outside, and Reduce Motion | A per-frame height or layout, a sibling walk, a moved non-participant, an added element, or travel under Reduce Motion fails |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row tests only a throttled phone profile and fails only on "travel under Reduce Motion". INV12 now also requires an immediate change with a fine pointer (design:reduced-motion DR3: "MUST complete immediately, with no animated property"), and OQ3 recommends travel under every pointer, so a cross-fade or a travel with a mouse would pass. Suggest adding a fine primary pointer state and "any animated property under Reduce Motion with a fine pointer" to the failure signal, plus a fine-pointer travel case if OQ3 lands on (a).

Comment on lines +185 to +189
The public pieces are `useDisclosureBoundary(containerRef)` and, under OQ2's
(ii), `useDisclosureParticipant(elementRef)`: each takes a ref and returns
nothing, because which container scopes the motion and which app content
moves with it are the caller's intent (`spec:AST-002` FR1), and no other API
does either job. The change hook

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec:AST-002 FR5 asks a public-API proposal to name "the two otherwise identical cases, the different required outcomes, why the caller knows the difference, and why the component cannot derive it". This gives caller intent under FR1 (written for props) and "no other API does either job", but not the two cases or why Core cannot derive the outcome, and OQ2's own option (i) derives what moves from layout (FR2). Stating that argument for each hook would let the owner rule OQ2 on it.

@cixzhang

Copy link
Copy Markdown
Contributor Author

Posted by Cindy's agent on her behalf.

Cindy's decision on mixed following content (OQ2): (i), a sibling walk bounded by the container ref.

  • From the disclosure, the module walks up each ancestor until it reaches the container. At every level, the following in-flow siblings move by how far layout moved them, measured once before and once after.
  • The walk stops at the container and never goes past it. Fixed, sticky and other out-of-flow elements take their final place at once.
  • It works for any mix of Astryx and app content with no opt-in. The only public piece is useDisclosureBoundary(containerRef), which ScrollableArea calls for itself. There's no participant hook.
  • Multiple and nested disclosures compose, and nothing adds a wrapper. A wrapper was rejected because it adds tree layers with several disclosures and doesn't compose.

Still open: whether the glide applies under every pointer or only under touch.

@cixzhang
cixzhang force-pushed the spec/disclosure-height-module branch from b651643 to f1747db Compare October 26, 2026 02:15
@cixzhang cixzhang changed the title architecture(react-component-runtime): proposed: one disclosure-height module with a designated handle (INV12) architecture(react-component-runtime): proposed: one size-change module with a container boundary (INV12) Oct 26, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

Posted by Cindy's agent on her behalf.

Cindy's decision on the name (OQ4): useAnimatedLayout(ref). Of the names that tested well, it has the least specialized words.

When something inside this area changes size, only the content after it animates to its new place. Fixed and sticky elements stay put, Reduce Motion is respected, ScrollableArea applies it automatically, and List rows use the same module.

The internal trigger is reportLayoutChange(ref), which isn't exported. Still open: whether the glide applies under every pointer, and the coverage rules for which elements move.

@cixzhang

Copy link
Copy Markdown
Contributor Author

Posted by Cindy's agent on her behalf.

Cindy's decision on pointer scope (OQ3): every pointer, mouse included. "Yes, a layout fix." This isn't a touch gesture: mouse users saw the same jump. It's recorded as a named desktop change under spec:AST-017 FR59, so the implementation needs desktop before/after evidence.

The changed element animates itself, and the content after it follows with matching timing and easing.

@cixzhang

Copy link
Copy Markdown
Contributor Author

Posted by Cindy's agent on her behalf.

Cindy's decision on which elements move (OQ5): only elements whose position actually changed and that are visible in the area. The walk collects candidates; offscreen ones take their final place without animating. It descends into display: contents children, corrects for scaled ancestors, and adds to animations already running. The out-of-coverage cases are listed in the record.

With this, every open question on useAnimatedLayout is decided:

  • the area is any container passed by ref;
  • a bounded ancestor-by-ancestor sibling walk;
  • it applies under every pointer;
  • the name is useAnimatedLayout;
  • only changed, visible elements move;
  • the area owns the shared timing, which the changed element uses for its own animation.

@cixzhang cixzhang changed the title architecture(react-component-runtime): proposed: one size-change module with a container boundary (INV12) architecture(react-component-runtime): one size-change module with a container area (INV12) Oct 26, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This record change waits on an engineering owner's decision on INV12, including what happens when a changed element has no registered area above it, such as a Collapsible on a page that scrolls the document; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Because a new commit invalidates approval, please make these record fixes first so the approved head is final:

  • Lines 190–192 cite a layout-change role from spec:AST-094 FR4, which names no such role and covers touch only; add the role through its owner first or cite the roles and tokens that exist.
  • Remove "are decided by cixzhang, 2026-10-25" and "TreeList and Collapsible adopt the module first" (lines 206–210), per architecture:knowledge-contracts INV10.
  • Make the hook documentation at line 217 say "Fixed and stuck sticky elements stay put" and "motion role", matching the coverage table and the authoring guidance.
  • Give rows 422–425 a fine-pointer Reduce Motion case and a theme-override case and scope the jump signal to visible followers; also carve display: contents out of line 182, state the spec:AST-002 FR5 argument for useAnimatedLayout, and add INV12 to the review-applicability block.

[Automated review]

An area is any container element registered by ref: ScrollableArea
registers its scrolling element itself, and an app registers its own scroll
container or panel with `useAnimatedLayout(containerRef)`. An element whose
size changes reports the change, around its commit, to its nearest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when the changed element has no registered area above it, such as a Collapsible on a page that scrolls the document outside any ScrollableArea? Lines 189–190 have the element play its own resize unconditionally, so if its followers then take their final place at once they overlap it on a collapse and leave a gap under it on an expand. The record should state the no-area behavior: the document as the area, or the element taking its final size at once.

ancestor to the area: at every level, each following in-flow sibling moves
by the distance layout moved it, measured before and after. The walk visits
only the following element siblings at each ancestor level, through
`parentElement` and `nextElementSibling`, never descending into a subtree,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"never descending into a subtree" conflicts with the coverage row at line 249, which takes a display: contents element's children in its place. Carving that case out here keeps both sentences true.

area's visible part, so the setup cost is bounded by what the person can
see. Which elements move is listed under Size-change coverage below. The changed element plays its own animation, entering, exiting, or
resizing, and the module moves only the content after it. Both read the
same generic theme motion role, the layout-change role of the motion scheme

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec:AST-094 FR4 lists no layout-change role (its table names only springs.* and timing.* roles), FR4 covers touch presentations only, and AST-094's Non-goals keep pointer timing on the theme's duration and easing tokens. Add the role through its owner first, with a measured default and source, or cite the role and token that time each pointer; the INV12 timing row (line 424) depends on it.

a named fine-pointer change under `spec:AST-017` FR59, and its adopting pull
requests show fine-pointer evidence before and after. The container area,
the bounded walk, the changed-and-visible rule, every-pointer scope, the
shared motion role, and the name `useAnimatedLayout` are decided by

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This attribution, with "TreeList and Collapsible adopt the module first" on lines 209–210, is decision and project history in a current record (architecture:knowledge-contracts INV10: "a record states the fact a change established, not the change"; rubric R1). Keeping only "any Core component whose size changes in place uses it rather than its own layout animation" states the durable rule.

changes in place uses it rather than its own layout animation.
`useAnimatedLayout` is the one public piece: it takes the container's ref
and returns nothing, because which container scopes the motion is the
caller's intent (`spec:AST-002` FR1), and no other API does that job.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec:AST-002 FR5 asks a public-API proposal to name "the two otherwise identical cases, the different required outcomes, why the caller knows the difference, and why the component cannot derive it", and FR2 keeps a difference internal when Core can derive it from measured or CSS layout, such as the nearest scroll container. Stating that argument here lets the owner admit useAnimatedLayout on it.

caller's intent (`spec:AST-002` FR1), and no other API does that job.
`reportLayoutChange` stays internal. Its documentation reads: "When
something inside this area changes size, the changed element and the content
after it animate together on one shared motion token. Fixed and sticky elements stay put, Reduce Motion is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Fixed and sticky elements stay put" contradicts the coverage row at line 251 (an unstuck sticky element moves) and the guidance at line 279 ("stuck sticky elements stay put"); this text also says "token" where INV12 says role, and omits the nesting and scroll-position points the guidance at lines 277–282 says the documentation states.

| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop |
| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement |
| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change |
| INV12 | Real-browser tests on a throttled phone profile plus a source boundary check, over a disclosure and an inserted item nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, an unstuck sticky header, a nested boundary, content outside the boundary, two disclosures toggled in one frame, a 10-level by 50-sibling tree for cost, and Reduce Motion | A per-frame height or layout, a walk past the boundary, a moved out-of-flow element, a following sibling at any level that jumps, an element moved twice for one frame, an added element, or travel under Reduce Motion fails |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row tests only a throttled phone profile and fails only on "travel under Reduce Motion", so a cross-fade with a mouse (design:reduced-motion DR3: "MUST complete immediately, with no animated property") or a theme override of the role that restores the glide (DR4) passes every INV12 row. "A following sibling at any level that jumps" also fails the offscreen and inline followers that lines 186–189 and 264–265 send to their final place at once; scoping it to visible, in-coverage followers fixes that.

@cixzhang

Copy link
Copy Markdown
Contributor Author

Posted by Cindy's agent on her behalf.

Cindy's decision on the API shape:

const animatedLayout = useAnimatedLayout({containerRef});
<TreeList animatedLayout={animatedLayout}>…</TreeList>
  • The hook takes an options object, where containerRef is the area the walk stops at. It returns an opaque animatedLayout handle.
  • Size-changing components (TreeList, Collapsible, and others later) accept an animatedLayout prop and report their changes through it. Composition is explicit, with no implicit registration.
  • Without a handle, today's behavior holds: the content after the change takes its place at once.
  • Everything else decided earlier stands: the bounded sibling walk, only changed and visible elements move, every pointer, and one shared motion token.

…; additive retarget; scale correction; plain out-of-coverage list
…(decided), with its documentation; restore INV12's batching, INV7, Reduce Motion, and adoption text
…tself; followers match its timing via reportLayoutChange(ref, motion)
…g; reportLayoutChange returns the motion the element plays
… element and its followers; reportLayoutChange only triggers
…f}) returns a handle passed as animatedLayout; ScrollableArea gets the ref; CodeBunny consistency
…ll position kept; no unmerged citations; OQ6 motion role, OQ7 ownership
…e names a layout role; this record owns the hook; scroll position is tested
… the module never scrolls; scope to reported changes; drop the TreeWalker note
@cixzhang
cixzhang force-pushed the spec/disclosure-height-module branch from a96e822 to 0c0c8f4 Compare October 26, 2026 11:57
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 0c0c8f4

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both records carry an engineering owner's approval of this revision, and the four record fixes from the last review are in, so this looks ready to land. When TreeList, Collapsible or another component adopts animatedLayout, that pull request owes the fine-pointer before-and-after evidence FR5 names. If you touch AST-105 again, ending FR4's Reduce Motion sentence at the design:reduced-motion DR1–DR3 reference and adding a fine-pointer Reduce Motion failure signal to its verification row would tighten it.

[Automated review]

@cixzhang
cixzhang merged commit 423aaab into main Oct 26, 2026
72 of 73 checks passed
@github-actions
github-actions Bot deleted the spec/disclosure-height-module branch October 27, 2026 06:22
cixzhang added a commit that referenced this pull request Nov 9, 2026
…an item in a long list (#8629)

* feat(core): useAnimatedLayout, one module that glides the layout shift 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.

* feat(core): useAnimatedLayout({containerRef}) returns a handle that Collapsible 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.

* fix(core): the layout glide corrects only a real ancestor scale, not 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.

* chore(storybook): the Animated layout story's tags row is narrow, so + Add tag wraps and its followers move

* Merge 8ba53b4 (main, AST-105 current) into the animated layout draft

* wip(core): animated layout follows AST-105 (springs.settle, Reduce Motion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring)

* feat(core): useAnimatedLayout follows AST-105

- 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.

* chore(storybook): the story's internal-reporting demos say they are story-only

* chore(docsite): the ShadCN route lock lists the useAnimatedLayout hook page

* fix(core): the animated layout reads springs.settle from the internal 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.

* test(core): the animated-layout browser proofs settle between changes 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.

* fix(core): animated layout measures every grouped change, drops refused 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.

* fix(core): a retargeted glide keeps its motion and adds the new move

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.

* docs(architecture): INV12 plays the glide through the shared rectangle morph once Core has it, its own compositor translate until then

* docs(architecture): rewrap INV12

* refactor(core): useAnimatedLayout lands alone; Collapsible and TreeList 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.

* feat(core): Collapsible and TreeList adopt useAnimatedLayout

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.

* feat(core): the changed element's exit, held out of flow on the same 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.

* Merge the module's exit (feat/animated-layout) into the adoption

* feat(core): a Collapsible's or TreeList row's close plays the content'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.

* test(core): the exiting group's rows leave the tree's keyboard order

* chore(changeset): Collapsible and TreeList adopt animatedLayout

* fix(core): the internal module alone; a displaced changed element moves 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.

* Merge feat/animated-layout into the adoption; the public hook and the 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.

* fix(core): the exit frees exactly its space and is inert; elements the 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.

* Merge feat/animated-layout (an exact, inert exit; 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).

* fix(core): the exit measures the content after it, holds only a measured 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.

* Merge feat/animated-layout (the exit measures the content after it and holds only a measured close) into the adoption

* fix(core): nested changes compose in any order; the exit finds the real 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.

* Merge feat/animated-layout (nested changes in any order; the exit's next box and container checks) into the adoption

* fix(core): the exit's correction follows a scaled ancestor, and its measurement 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.

* Merge feat/animated-layout (scale-corrected exit; measured out of flow) into the adoption

* fix(core): Collapsible and TreeList report every change, whatever caused it

spec:AST-105 FR1: the component reports each change. A click was the only
path measured; an owner-set isOpen, a group's controlled value or
isExpanded changed in items landed at once. Both components now measure in
the commit itself, before React changes the DOM, through a small internal
LayoutChangeSnapshot (getSnapshotBeforeUpdate): it runs only for a render
that commits, once under StrictMode, and keeps render pure. The exit state
is derived from the open state as it changes, during render, so any close
keeps the content for its exit; the chevron takes the spring on every
change. CollapsibleGroup no longer needs its measurement registry: each
item measures its own change.

Also: the set-state-in-effect reasons name the layout reads holdLayoutExit
decides from, the Verification rows fill every column, and a browser spec
renders the Core/Animated layout story (a ScrollableArea ref, Collapsible
and TreeList opening and closing). Tests: an owner-set isOpen, a group's
controlled value and isExpanded in items glide; one measurement in
StrictMode and none for a refused change.

* test(core): the adopters' browser spec serves Storybook through a11y-spec, a dependency core already has

The interaction harness is not one of core's dependencies, so importing it
broke the package typecheck.

* fix(core): a TreeList row collapsing with an expanded child moves what follows once; the chevron drops its spring without a handle

- A changed row inside another changed row (a child left expanded when its
  parent collapses) is not reported as its own change: only the outer group
  exits and gives up its space, the child's group leaves with it when that
  exit ends, and the content after them moves once, with no jump back
  (AST-105 FR3).
- Without a handle, Collapsible's and TreeList's chevrons drop the spring a
  handle set, so they keep the theme's own turn (AST-105 FR2).
- Tests: a nested collapse holds only the outer group (jsdom) and moves the
  rows after it once with no jump (real browser, the story's Photos with
  Trips open); removing the handle clears the chevron's spring.

* docs(core): Collapsible FR10 and TreeList FR8 cite AST-108 FR4a for the chevron

The chevron turning while the content glides is a coordinated pair: it
derives its transition from the same springs.settle motion, as AST-108
FR4a (proposed) asks of coordinated animations.

* feat(sandbox): Animated Layout Perf, a fullscreen page measuring useAnimatedLayout in a long list

Cindy asked for a perf test of useAnimatedLayout for an item in the middle
of a long list. The page follows Markdown Perf's layout: heading and
description, a muted toolbar, a Run metrics card beside the output. A list
of 1k, 5k or 10k rows (every row rendered, or a virtualized window) has a
Collapsible in the middle that takes the list's handle; a run expands and
collapses it six times and reports the commit and walk until the glide
starts, rectangles read, elements animated, time to the first frame, long
tasks, frames and drops, each against a saved baseline.

Sandbox only: no package change.

* fix(sandbox): Animated Layout Perf toggles only the middle item, so a run measures its commit and the walk, not a list re-render

The open state lived on the page, so each toggle re-rendered every row
(10k rows: most of the 275 ms). The middle item now owns it, and the rows
are built once per list and window.

* feat(sandbox): Animated Layout Perf compares a run without the handle, the browser's own layout cost

A Glide control (With handle / No handle) runs the same list without
useAnimatedLayout, so the walk's share of a toggle is the difference.

* fix(sandbox): Animated Layout Perf keeps baselines across reloads, reports nothing for a stopped run, and takes a true median

- Saved baselines go to localStorage per rows, rendering and glide, so the
  reload a build change needs keeps them (read after hydration).
- A stopped run publishes no metrics, so a partial run can neither read as
  a full one nor be saved as a baseline.
- The median of an even count is the mean of its two middle values: a
  run's three opens and three closes both count.
- The middle item's copy says what moves (the rows after it that are in
  view) and what the page measures.

* fix(sandbox): Animated Layout Perf charges the browser's layout to both modes, and a saved baseline reads as saved after a reload

- An uncounted layout read closes each toggle's clock, so No handle times
  the browser's own style and layout for the change too, and with the
  handle minus without is the walk alone.
- A metric reads no baseline only when none is saved for the
  configuration; before a run it reads baseline saved.

* Merge origin/main into feat/animated-layout-perf

The base branches (#8482, #8568) merged to main as squashes, so every file they touched takes main's version, and their already-released changesets go. This PR's own three sandbox files are unchanged; the sandbox route list keeps main's new entry beside this page's.

* sandbox(animated-layout-perf): show no long-task reading without the Long Tasks API

Firefox has no Long Tasks API, so the page reported 'Long tasks 0'. It now shows no reading, as DiffViewer Perf does.

* sandbox(animated-layout-perf): use the perf pages' existing format and long-task helpers

The page imports formatDuration and formatCount from DiffViewer Perf, formatBenchmarkChange from Markdown Perf, and watchLongTasks, which moves from DiffViewer Perf's page into its benchmark module because a Next.js page exports only page members. formatBenchmarkChange gains optional empty-state labels (default '—', so Markdown Perf is unchanged), which keep this page's 'no baseline' and 'baseline saved'. The Virtualized comment now says the middle item renders only inside the window.

This branch had an error being deployed

1 failed deployment
Preview — 0c0c8f4e Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant