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): rows expand and collapse the way an iOS outline list does #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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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