From 268b63f8c672636735570169d991ee7385ed8e8b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 16:16:17 -0700 Subject: [PATCH 01/33] architecture(react-component-runtime): proposed: one disclosure-height module with a designated handle (INV12) --- docs/architecture/react-component-runtime.md | 54 ++++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index d4c30429155b9..f726553a72d80 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -158,6 +158,27 @@ those contracts. does, blurs as FR5 does, applies the height through a functional style on 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 + shared internal module animates the content that follows an element whose + disclosure changes its height. It measures the layout before and after the + 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 + coordinates, and content inside it that should move with a disclosure + registers as a participant through the module's internal hook. A disclosing + element (a TreeList group, Collapsible) reports its change to the nearest + boundary, which moves only the participants after it, each by translating an + element it already renders, so the module adds no elements + (`architecture:component-style-authoring` INV7). Anything else inside the + boundary, fixed or sticky elements, and content outside it take their final + place at once; a nested boundary moves 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 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. ## Authoring guidance @@ -292,13 +313,26 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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 | +| Invariant | Evidence | Failure signal | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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 | + +## Open questions + +- **OQ1 — How following content is designated for a disclosure.** + (`human-design`) INV12 proposes (c). Options: (a) the scroll-area container + is the boundary and moves every element registered with it, so each + following component registers with the container; (b) following content + opts in as a participant, with no boundary, so each participant finds the + disclosure it follows on its own; or (c, recommended) both: the container is + the boundary that scopes and orders the move, and participants opt in inside + it, so a disclosure moves exactly the registered content after it in its own + container and nothing outside. From ffc8c07e612437da7a8e2eba6e69d42fb4b2c0d2 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 16:31:48 -0700 Subject: [PATCH 02/33] architecture(react-component-runtime): INV12's internal usage shape --- docs/architecture/react-component-runtime.md | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index f726553a72d80..8ad81c860cc88 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -180,6 +180,31 @@ those contracts. content in it keeps today's flow, and making a boundary public needs `spec:AST-002` admission and `architecture:public-component-api` INV21. + Internal usage, not exported: + + ```tsx + // A boundary: ScrollArea provides it around its scrolling element. + const boundary = useDisclosureBoundary(scrollRef); + {children}; + + // A participant registers an element it already renders. + useDisclosureParticipant(rowRef); + + // A disclosing element measures before its commit and animates after it. + const change = useDisclosureChange(groupRef); + const toggle = () => { + change.measureBefore(); + setExpanded(open => !open); + }; + useLayoutEffect(() => change.animate(), [expanded]); + + // App content inside a ScrollArea needs nothing; it takes its place at once. + + +

Notes

+
; + ``` + ## Authoring guidance The following are preferred techniques, not independent compatibility promises: From 0c3e730125b071c6f05fb484a6c3915a6f3e2389 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 17:10:27 -0700 Subject: [PATCH 03/33] architecture(react-component-runtime): INV12 handle decided: any container by ref; ScrollArea registers itself; one public hook useDisclosureBoundary(ref) --- docs/architecture/react-component-runtime.md | 81 +++++++++----------- 1 file changed, 36 insertions(+), 45 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 8ad81c860cc88..443c4836b78d5 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -159,50 +159,53 @@ those contracts. 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 - shared internal module animates the content that follows an element whose - disclosure changes its height. It measures the layout before and after the - 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 - coordinates, and content inside it that should move with a disclosure - registers as a participant through the module's internal hook. A disclosing - element (a TreeList group, Collapsible) reports its change to the nearest - boundary, which moves only the participants after it, each by translating an - element it already renders, so the module adds no elements - (`architecture:component-style-authoring` INV7). Anything else inside the - boundary, fixed or sticky elements, and content outside it take their final - place at once; a nested boundary moves 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 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. - - Internal usage, not exported: + **Proposed; pending owner approval of the public hook.** In + `@astryxdesign/core`, one shared module animates the content that follows an + element whose disclosure changes its height. It measures the layout before + and after the 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 boundary is any container element registered by + ref: ScrollArea registers its scrolling element itself, and an app registers + its own scroll container or panel with `useDisclosureBoundary(containerRef)`. + Content inside a boundary that should move with a disclosure registers as a + participant. A disclosing element (a TreeList group, Collapsible) reports its + change to its nearest registered ancestor boundary, which moves only the + participants after it, each by translating an element it already renders, + so the module adds no elements (`architecture:component-style-authoring` + INV7). Anything else inside the boundary, fixed or sticky elements, and + content outside it take their final place at once; a nested boundary moves + 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 + participants, any container by ref) is decided by `cixzhang`, 2026-10-25. + `useDisclosureBoundary` 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 + (`architecture:public-component-api` INV21). The participant and change + hooks stay internal. + + Usage: ```tsx - // A boundary: ScrollArea provides it around its scrolling element. - const boundary = useDisclosureBoundary(scrollRef); - {children}; + // Public: an app container opts in as a boundary. ScrollArea does this itself. + const panelRef = useRef(null); + useDisclosureBoundary(panelRef); +
+ +

Notes

+
; - // A participant registers an element it already renders. + // Internal: a participant registers an element it already renders. useDisclosureParticipant(rowRef); - // A disclosing element measures before its commit and animates after it. + // Internal: a disclosing element measures before its commit and animates after it. const change = useDisclosureChange(groupRef); const toggle = () => { change.measureBefore(); setExpanded(open => !open); }; useLayoutEffect(() => change.animate(), [expanded]); - - // App content inside a ScrollArea needs nothing; it takes its place at once. - - -

Notes

-
; ``` ## Authoring guidance @@ -349,15 +352,3 @@ component suite is not blanket conformance with every invariant. | 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 | - -## Open questions - -- **OQ1 — How following content is designated for a disclosure.** - (`human-design`) INV12 proposes (c). Options: (a) the scroll-area container - is the boundary and moves every element registered with it, so each - following component registers with the container; (b) following content - opts in as a participant, with no boundary, so each participant finds the - disclosure it follows on its own; or (c, recommended) both: the container is - the boundary that scopes and orders the move, and participants opt in inside - it, so a disclosure moves exactly the registered content after it in its own - container and nothing outside. From 2d8d278587b0edc81f1447e03e395437e0d25cef Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 17:10:55 -0700 Subject: [PATCH 04/33] architecture(react-component-runtime): INV12 mixed following content (OQ2), recommending opt-in participants --- docs/architecture/react-component-runtime.md | 40 +++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 443c4836b78d5..baf5d2e9a29d0 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -168,8 +168,10 @@ those contracts. found by walking siblings. A boundary is any container element registered by ref: ScrollArea registers its scrolling element itself, and an app registers its own scroll container or panel with `useDisclosureBoundary(containerRef)`. - Content inside a boundary that should move with a disclosure registers as a - participant. A disclosing element (a TreeList group, Collapsible) reports its + Following content inside a boundary can mix Astryx components and plain app + elements; how each moves is OQ2, and INV12 proposes (ii): an Astryx + component that can follow a disclosure registers itself as a participant, + and an app element registers with `useDisclosureParticipant(elementRef)`. A disclosing element (a TreeList group, Collapsible) reports its change to its nearest registered ancestor boundary, which moves only the participants after it, each by translating an element it already renders, so the module adds no elements (`architecture:component-style-authoring` @@ -179,11 +181,12 @@ those contracts. travels, and following content cross-fades into place (`design:reduced-motion` DR2). The handle (a container boundary with opt-in participants, any container by ref) is decided by `cixzhang`, 2026-10-25. - `useDisclosureBoundary` 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 - (`architecture:public-component-api` INV21). The participant and change - hooks stay internal. + 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 (`architecture:public-component-api` INV21). The change hook + stays internal. Usage: @@ -193,11 +196,12 @@ those contracts. useDisclosureBoundary(panelRef);
-

Notes

+

Notes

; - // Internal: a participant registers an element it already renders. - useDisclosureParticipant(rowRef); + // Public under OQ2 (ii): an app element moves with the disclosure. + // Astryx components that can follow a disclosure register themselves. + useDisclosureParticipant(noteRef); // Internal: a disclosing element measures before its commit and animates after it. const change = useDisclosureChange(groupRef); @@ -352,3 +356,19 @@ component suite is not blanket conformance with every invariant. | 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 | + +## Open questions + +- **OQ2 — Mixed following content.** (`human-design`) Content after a + disclosure inside a boundary can be Astryx components or plain app elements. + Options: (i) the boundary moves everything after the disclosure in its own + flow, by a walk bounded by the container that skips fixed, sticky, and + absolutely positioned descendants and moves a nested boundary as one unit, + with no app API, but it walks the DOM and measures every element it finds; + (ii, recommended) Astryx components register themselves and an app element + opts in with `useDisclosureParticipant(elementRef)`, so only registered + content moves, unregistered app content takes its final place at once, and + the cost grows with the number of participants; or (iii) the app wraps the + following content in one element passed by ref and translated as a block, + which needs one measurement but cannot separate a disclosure from content + inside the same wrapper. From c90ea1c0d3dab017d6e777e70cda12942678613f Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 17:58:57 -0700 Subject: [PATCH 05/33] =?UTF-8?q?architecture(react-component-runtime):=20?= =?UTF-8?q?INV12=20defers=20Reduce=20Motion=20to=20DR1=E2=80=93DR3;=20Scro?= =?UTF-8?q?llableArea;=20pointer=20scope=20OQ3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/react-component-runtime.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index baf5d2e9a29d0..3706de2916dbd 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -166,7 +166,7 @@ those contracts. 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 + ref: ScrollableArea registers its scrolling element itself, and an app registers its own scroll container or panel with `useDisclosureBoundary(containerRef)`. Following content inside a boundary can mix Astryx components and plain app elements; how each moves is OQ2, and INV12 proposes (ii): an Astryx @@ -177,21 +177,22 @@ those contracts. so the module adds no elements (`architecture:component-style-authoring` INV7). Anything else inside the boundary, fixed or sticky elements, and content outside it take their final place at once; a nested boundary moves - 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 + as one participant of the boundary around it. Under Reduce Motion, following + content changes as `design:reduced-motion` DR1–DR3 require: a cross-fade + under a coarse primary pointer, an immediate change otherwise. Whether the + travel applies under every pointer is OQ3. The handle (a container boundary with opt-in participants, any container by ref) is decided by `cixzhang`, 2026-10-25. 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 (`architecture:public-component-api` INV21). The change hook + does either job. The change hook stays internal. Usage: ```tsx - // Public: an app container opts in as a boundary. ScrollArea does this itself. + // Public: an app container opts in as a boundary. ScrollableArea does this itself. const panelRef = useRef(null); useDisclosureBoundary(panelRef);
@@ -372,3 +373,7 @@ component suite is not blanket conformance with every invariant. following content in one element passed by ref and translated as a block, which needs one measurement but cannot separate a disclosure from content inside the same wrapper. +- **OQ3 — Which pointers get the travel.** (`human-design`) Options: (a, + recommended) every pointer, because a disclosure's following content jumps + the same way under a mouse; or (b) only a coarse primary pointer, with an + immediate change elsewhere, as other touch presentations do. From efadb0499c9972f743709206a08e120e78f9a762 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:10:25 -0700 Subject: [PATCH 06/33] architecture(react-component-runtime): OQ2 drops the wrapper option; INV12 adds no wrapper elements under either option --- docs/architecture/react-component-runtime.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 3706de2916dbd..8ae3950d7989a 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -174,8 +174,9 @@ those contracts. and an app element registers with `useDisclosureParticipant(elementRef)`. A disclosing element (a TreeList group, Collapsible) reports its change to its nearest registered ancestor boundary, which moves only the participants after it, each by translating an element it already renders, - so the module adds no elements (`architecture:component-style-authoring` - INV7). Anything else inside the boundary, fixed or sticky elements, and + so the module adds no elements: the boundary is the existing container passed + by ref, and participants register elements they already render, under + either OQ2 option (`architecture:component-style-authoring` INV7). Anything else inside the boundary, fixed or sticky elements, and content outside it take their final place at once; a nested boundary moves as one participant of the boundary around it. Under Reduce Motion, following content changes as `design:reduced-motion` DR1–DR3 require: a cross-fade @@ -366,13 +367,11 @@ component suite is not blanket conformance with every invariant. flow, by a walk bounded by the container that skips fixed, sticky, and absolutely positioned descendants and moves a nested boundary as one unit, with no app API, but it walks the DOM and measures every element it finds; - (ii, recommended) Astryx components register themselves and an app element + or (ii, recommended) Astryx components register themselves and an app element opts in with `useDisclosureParticipant(elementRef)`, so only registered content moves, unregistered app content takes its final place at once, and - the cost grows with the number of participants; or (iii) the app wraps the - following content in one element passed by ref and translated as a block, - which needs one measurement but cannot separate a disclosure from content - inside the same wrapper. + the cost grows with the number of participants. A wrapper around the following content, translated as a block, is + rejected: it adds tree layers with several disclosures and does not compose. - **OQ3 — Which pointers get the travel.** (`human-design`) Options: (a, recommended) every pointer, because a disclosure's following content jumps the same way under a mouse; or (b) only a coarse primary pointer, with an From 1e6a757a9890f9159eefde5947a1ed933eac57d7 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:12:32 -0700 Subject: [PATCH 07/33] architecture(react-component-runtime): INV12's bounded ancestor-and-sibling walk to the container (decided); one public hook --- docs/architecture/react-component-runtime.md | 98 ++++++++------------ 1 file changed, 41 insertions(+), 57 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 8ae3950d7989a..6af598e60fe62 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -158,37 +158,36 @@ those contracts. does, blurs as FR5 does, applies the height through a functional style on 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 - `@astryxdesign/core`, one shared module animates the content that follows an - element whose disclosure changes its height. It measures the layout before - and after the 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 boundary is any container element registered by - ref: ScrollableArea registers its scrolling element itself, and an app registers +- **INV12 — Disclosure height changes have one module and one boundary.** + **Proposed; pending owner decision (OQ3).** In `@astryxdesign/core`, one + shared module animates the content that follows an element whose disclosure + changes its height. It measures the layout before and after the 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. A boundary is any container element registered by ref: + ScrollableArea registers its scrolling element itself, and an app registers its own scroll container or panel with `useDisclosureBoundary(containerRef)`. - Following content inside a boundary can mix Astryx components and plain app - elements; how each moves is OQ2, and INV12 proposes (ii): an Astryx - component that can follow a disclosure registers itself as a participant, - and an app element registers with `useDisclosureParticipant(elementRef)`. A disclosing element (a TreeList group, Collapsible) reports its - change to its nearest registered ancestor boundary, which moves only the - participants after it, each by translating an element it already renders, - so the module adds no elements: the boundary is the existing container passed - by ref, and participants register elements they already render, under - either OQ2 option (`architecture:component-style-authoring` INV7). Anything else inside the boundary, fixed or sticky elements, and - content outside it take their final place at once; a nested boundary moves - as one participant of the boundary around it. Under Reduce Motion, following - content changes as `design:reduced-motion` DR1–DR3 require: a cross-fade - under a coarse primary pointer, an immediate change otherwise. Whether the - travel applies under every pointer is OQ3. The handle (a container boundary with opt-in - participants, any container by ref) is decided by `cixzhang`, 2026-10-25. - 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 - stays internal. + A disclosing element (a TreeList group, Collapsible) reports its change to + its nearest registered ancestor boundary. From the disclosing element, the + module walks up each ancestor to the boundary: at every level, each + following in-flow sibling moves by the distance layout moved it, measured + before and after. The walk stops at the boundary and never passes it, so + content outside the boundary takes its final place at once. Fixed, sticky, + and other out-of-flow elements are skipped and take their final place at + once, and a nested boundary moves as one unit of its parent's walk. Following + content may mix Astryx components and plain app elements, and none of it + opts in. Several disclosures, nested or not, compose without wrappers: + toggles in one boundary in the same frame are measured together before and + after, so each element moves once, by its total change. Each moving element + is one the page already renders, so the module adds no elements + (`architecture:component-style-authoring` INV7). Under Reduce Motion, + following content changes as `design:reduced-motion` DR1–DR3 require: a + cross-fade under a coarse primary pointer, an immediate change otherwise. + Whether the travel applies under every pointer is OQ3. The bounded walk is + decided by `cixzhang`, 2026-10-25. `useDisclosureBoundary` 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. The change hook stays internal. Usage: @@ -198,13 +197,9 @@ those contracts. useDisclosureBoundary(panelRef);
-

Notes

+

Notes

; - // Public under OQ2 (ii): an app element moves with the disclosure. - // Astryx components that can follow a disclosure register themselves. - useDisclosureParticipant(noteRef); - // Internal: a disclosing element measures before its commit and animates after it. const change = useDisclosureChange(groupRef); const toggle = () => { @@ -347,31 +342,20 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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 | +| Invariant | Evidence | Failure signal | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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 nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, a 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 | ## Open questions -- **OQ2 — Mixed following content.** (`human-design`) Content after a - disclosure inside a boundary can be Astryx components or plain app elements. - Options: (i) the boundary moves everything after the disclosure in its own - flow, by a walk bounded by the container that skips fixed, sticky, and - absolutely positioned descendants and moves a nested boundary as one unit, - with no app API, but it walks the DOM and measures every element it finds; - or (ii, recommended) Astryx components register themselves and an app element - opts in with `useDisclosureParticipant(elementRef)`, so only registered - content moves, unregistered app content takes its final place at once, and - the cost grows with the number of participants. A wrapper around the following content, translated as a block, is - rejected: it adds tree layers with several disclosures and does not compose. - **OQ3 — Which pointers get the travel.** (`human-design`) Options: (a, recommended) every pointer, because a disclosure's following content jumps the same way under a mouse; or (b) only a coarse primary pointer, with an From 322a9d6d2ed4c7908850a8a9f0a2ff928ebc6ae8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:15:36 -0700 Subject: [PATCH 08/33] architecture(react-component-runtime): INV12 is a general size-change module (useLayoutBoundary, internal useSizeChange) on the shared rectangle morph --- docs/architecture/react-component-runtime.md | 92 ++++++++++---------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 6af598e60fe62..4fc2afae12fd7 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -158,50 +158,54 @@ those contracts. does, blurs as FR5 does, applies the height through a functional style on its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. -- **INV12 — Disclosure height changes have one module and one boundary.** - **Proposed; pending owner decision (OQ3).** In `@astryxdesign/core`, one - shared module animates the content that follows an element whose disclosure - changes its height. It measures the layout before and after the 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. A boundary is any container element registered by ref: - ScrollableArea registers its scrolling element itself, and an app registers - its own scroll container or panel with `useDisclosureBoundary(containerRef)`. - A disclosing element (a TreeList group, Collapsible) reports its change to - its nearest registered ancestor boundary. From the disclosing element, the - module walks up each ancestor to the boundary: at every level, each - following in-flow sibling moves by the distance layout moved it, measured - before and after. The walk stops at the boundary and never passes it, so - content outside the boundary takes its final place at once. Fixed, sticky, - and other out-of-flow elements are skipped and take their final place at - once, and a nested boundary moves as one unit of its parent's walk. Following - content may mix Astryx components and plain app elements, and none of it - opts in. Several disclosures, nested or not, compose without wrappers: - toggles in one boundary in the same frame are measured together before and - after, so each element moves once, by its total change. Each moving element - is one the page already renders, so the module adds no elements - (`architecture:component-style-authoring` INV7). Under Reduce Motion, - following content changes as `design:reduced-motion` DR1–DR3 require: a - cross-fade under a coarse primary pointer, an immediate change otherwise. - Whether the travel applies under every pointer is OQ3. The bounded walk is - decided by `cixzhang`, 2026-10-25. `useDisclosureBoundary` 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. The change hook stays internal. +- **INV12 — Size changes inside a container have one module and one + boundary.** **Proposed; pending owner decision (OQ3).** In + `@astryxdesign/core`, one shared module animates the layout shift around any + element whose size changes inside a container: a disclosure opening, content + loading in, a message appearing, an item inserted or removed. It measures the + layout before and after the change and moves the shifted content with a + compositor-only translate from its old place to its new one, played through + the shared rectangle morph (`architecture:layer-runtime` INV15; + `spec:AST-094` FR5); it never animates height, which lays out every frame. A + boundary is any container element registered by ref: ScrollableArea + registers its scrolling element itself, and an app registers its own scroll + container or panel with `useLayoutBoundary(containerRef)`. An element whose + size changes reports the change, around its commit, to its nearest + registered ancestor boundary. From that element, the module walks up each + ancestor to the boundary: at every level, each following in-flow sibling + moves by the distance layout moved it, measured before and after. The walk + stops at the boundary and never passes it, so content outside the boundary + takes its final place at once. Fixed, sticky, and other out-of-flow elements + are skipped and take their final place at once, and a nested boundary moves + as one unit of its parent's walk. Shifted content may mix Astryx components + and plain app elements, and none of it opts in. Several changes, nested or + not, compose without wrappers: changes in one boundary in the same frame are + measured together before and after, so each element moves once, by its + total change. Each moving element is one the page already renders, so the + module adds no elements (`architecture:component-style-authoring` INV7). + Under Reduce Motion, shifted content changes as `design:reduced-motion` + DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate + change otherwise. Whether the travel applies under every pointer is OQ3. The + bounded walk is decided by `cixzhang`, 2026-10-25. TreeList and Collapsible + adopt the module first, and any Core component whose size changes in place + uses it rather than its own layout animation. `useLayoutBoundary` 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. `useSizeChange` stays internal. Usage: ```tsx // Public: an app container opts in as a boundary. ScrollableArea does this itself. const panelRef = useRef(null); - useDisclosureBoundary(panelRef); + useLayoutBoundary(panelRef);

Notes

; - // Internal: a disclosing element measures before its commit and animates after it. - const change = useDisclosureChange(groupRef); + // Internal: an element whose size changes measures before its commit and animates after it. + const change = useSizeChange(groupRef); const toggle = () => { change.measureBefore(); setExpanded(open => !open); @@ -342,17 +346,17 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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 nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, a 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 | +| Invariant | Evidence | Failure signal | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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 and an inserted item nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, a 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 | ## Open questions From 7a4abcbefe965ac2d4d6c801903e78bd761396b0 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:15:53 -0700 Subject: [PATCH 09/33] architecture(react-component-runtime): INV12 names the walk's traversal --- docs/architecture/react-component-runtime.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 4fc2afae12fd7..228b89216d215 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -174,6 +174,10 @@ those contracts. registered ancestor boundary. From that element, the module walks up each ancestor to the boundary: 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, so + its cost grows with depth plus following siblings and it needs no + TreeWalker, which visits subtrees and text nodes. It stops at the boundary and never passes it, so content outside the boundary takes its final place at once. Fixed, sticky, and other out-of-flow elements are skipped and take their final place at once, and a nested boundary moves From 7ebe8cd9c67bed981dfc1f46325e84a67701c0e5 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:16:43 -0700 Subject: [PATCH 10/33] architecture(react-component-runtime): INV12 says what the hook does and where the logic lives; hook name is OQ4 --- docs/architecture/react-component-runtime.md | 27 ++++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 228b89216d215..1dffee28b9540 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -158,9 +158,13 @@ those contracts. does, blurs as FR5 does, applies the height through a functional style on its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. -- **INV12 — Size changes inside a container have one module and one - boundary.** **Proposed; pending owner decision (OQ3).** In - `@astryxdesign/core`, one shared module animates the layout shift around any +- **INV12 — Size changes inside a container have one module.** **Proposed; + pending owner decision (OQ3, OQ4).** When something inside an area changes + size, the content after it glides to its new place instead of jumping. The + measuring, the walk, and the translate all live inside one internal module; + the public hook only marks the area where the walk stops, and the internal + `reportLayoutChange` only tells the module that a size is about to change. In + `@astryxdesign/core`, the module animates the layout shift around any element whose size changes inside a container: a disclosure opening, content loading in, a message appearing, an item inserted or removed. It measures the layout before and after the change and moves the shifted content with a @@ -169,7 +173,7 @@ those contracts. `spec:AST-094` FR5); it never animates height, which lays out every frame. A boundary is any container element registered by ref: ScrollableArea registers its scrolling element itself, and an app registers its own scroll - container or panel with `useLayoutBoundary(containerRef)`. An element whose + container or panel with `useAnimatedLayout(containerRef)`. An element whose size changes reports the change, around its commit, to its nearest registered ancestor boundary. From that element, the module walks up each ancestor to the boundary: at every level, each following in-flow sibling @@ -192,24 +196,24 @@ those contracts. change otherwise. Whether the travel applies under every pointer is OQ3. The bounded walk is decided by `cixzhang`, 2026-10-25. TreeList and Collapsible adopt the module first, and any Core component whose size changes in place - uses it rather than its own layout animation. `useLayoutBoundary` is the one + 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. `useSizeChange` stays internal. + FR1), and no other API does that job. `reportLayoutChange` stays internal. Usage: ```tsx // Public: an app container opts in as a boundary. ScrollableArea does this itself. const panelRef = useRef(null); - useLayoutBoundary(panelRef); + useAnimatedLayout(panelRef);

Notes

; // Internal: an element whose size changes measures before its commit and animates after it. - const change = useSizeChange(groupRef); + const change = reportLayoutChange(groupRef); const toggle = () => { change.measureBefore(); setExpanded(open => !open); @@ -368,3 +372,10 @@ component suite is not blanket conformance with every invariant. recommended) every pointer, because a disclosure's following content jumps the same way under a mouse; or (b) only a coarse primary pointer, with an immediate change elsewhere, as other touch presentations do. +- **OQ4 — The public hook's name.** (`human-design`) Options, each named for + what it does: (a, recommended) `useAnimatedLayout(ref)`: when something + inside this area changes size, the content after it glides instead of + jumping; (b) `useSmoothLayoutShift(ref)`: layout shifts in this area are + smoothed; (c) `useLayoutTransitionArea(ref)`: this element is the area + layout transitions stay within; or (d) `useGlideAfterResize(ref)`: content + after a resized element glides into place. From ecc40ab111ec1b347e7af7cd59d2614059308e40 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:17:41 -0700 Subject: [PATCH 11/33] architecture(react-component-runtime): INV12's element-selection rules; move by measured change (OQ5) --- docs/architecture/react-component-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 1dffee28b9540..aff731e4e7923 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -159,7 +159,7 @@ those contracts. its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. - **INV12 — Size changes inside a container have one module.** **Proposed; - pending owner decision (OQ3, OQ4).** When something inside an area changes + pending owner decision (OQ3–OQ5).** When something inside an area changes size, the content after it glides to its new place instead of jumping. The measuring, the walk, and the translate all live inside one internal module; the public hook only marks the area where the walk stops, and the internal From 7534a6e4951a39257092089bd0394fbc738dbab9 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:18:00 -0700 Subject: [PATCH 12/33] architecture(react-component-runtime): INV12's element-selection rules; move by measured change (OQ5) --- docs/architecture/react-component-runtime.md | 78 +++++++++++++------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index aff731e4e7923..d74376976013d 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -183,23 +183,37 @@ those contracts. its cost grows with depth plus following siblings and it needs no TreeWalker, which visits subtrees and text nodes. It stops at the boundary and never passes it, so content outside the boundary - takes its final place at once. Fixed, sticky, and other out-of-flow elements - are skipped and take their final place at once, and a nested boundary moves - as one unit of its parent's walk. Shifted content may mix Astryx components - and plain app elements, and none of it opts in. Several changes, nested or - not, compose without wrappers: changes in one boundary in the same frame are - measured together before and after, so each element moves once, by its - total change. Each moving element is one the page already renders, so the - module adds no elements (`architecture:component-style-authoring` INV7). - Under Reduce Motion, shifted content changes as `design:reduced-motion` - DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate - change otherwise. Whether the travel applies under every pointer is OQ3. The - bounded walk is decided by `cixzhang`, 2026-10-25. TreeList and Collapsible - adopt the module first, and any Core component whose size 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. `reportLayoutChange` stays internal. + takes its final place at once. The walk only collects + candidates; an element moves only when its layout rectangle changed between + the two measurements (OQ5). Candidates follow these rules: + - an element with `display: contents` has no box, so the walk takes its + children in its place; + - an element with `display: none`, hidden, or of zero size is skipped; + - an absolutely positioned, fixed, or sticky element is skipped, and an + absolute child of an element that moves rides with that element; + - text nodes are ignored, and inline, block, flex, grid, wrapped, reordered, + and floated content is decided by its measured change, not by its place + in the DOM; + - positions are compared in the boundary's coordinates, so a transformed + ancestor or another containing block does not count as a change; + - portals and top-layer content are never candidates; + - an element already moving retargets from where it is on screen + (`spec:AST-094` FR6); + - a nested boundary moves as one unit of its parent's walk. Shifted content may mix Astryx components + and plain app elements, and none of it opts in. Several changes, nested or + not, compose without wrappers: changes in one boundary in the same frame are + measured together before and after, so each element moves once, by its + total change. Each moving element is one the page already renders, so the + module adds no elements (`architecture:component-style-authoring` INV7). + Under Reduce Motion, shifted content changes as `design:reduced-motion` + DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate + change otherwise. Whether the travel applies under every pointer is OQ3. The + bounded walk is decided by `cixzhang`, 2026-10-25. TreeList and Collapsible + adopt the module first, and any Core component whose size 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. `reportLayoutChange` stays internal. Usage: @@ -354,17 +368,18 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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 and an inserted item nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, a 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 | +| Invariant | Evidence | Failure signal | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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 and an inserted item nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, a 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 | +| INV12 element selection | Real-browser tests of each candidate rule: `display: contents`, `display: none`, hidden and zero-size elements; absolute, fixed, and sticky elements and an absolute child of a moving element; inline flow; a flex row, `order`, and a wrapping grid; a float; a transformed ancestor; a portal; an element mid-move | A moved element whose rectangle did not change, a changed element left jumping, a skipped element that moves, a portal touched, or a mid-move element that snaps fails | ## Open questions @@ -379,3 +394,10 @@ component suite is not blanket conformance with every invariant. smoothed; (c) `useLayoutTransitionArea(ref)`: this element is the area layout transitions stay within; or (d) `useGlideAfterResize(ref)`: content after a resized element glides into place. +- **OQ5 — How the walk chooses what moves.** (`human-design`) Options: (a, + recommended) the walk collects candidates and only those whose layout + rectangle changed between the two measurements move, which handles flex + order, wrapping, floats, and side-by-side content that does not shift; or + (b) every following in-flow sibling the walk finds moves by its offset, + which skips one rectangle comparison per element but moves content that did + not shift and misses reordered content. From 8e6a367506ead0310cd332ecc216a907ed57d658 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:18:49 -0700 Subject: [PATCH 13/33] architecture(react-component-runtime): INV12 animates only changed elements in the visible area --- docs/architecture/react-component-runtime.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index d74376976013d..ea3968ebfdedb 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -185,7 +185,9 @@ those contracts. stops at the boundary and never passes it, so content outside the boundary takes its final place at once. The walk only collects candidates; an element moves only when its layout rectangle changed between - the two measurements (OQ5). Candidates follow these rules: + the two measurements (OQ5) and it intersects the boundary's visible area. An + element outside the visible area takes its final place at once, so the setup + cost is bounded by what the person can see. Candidates follow these rules: - an element with `display: contents` has no box, so the walk takes its children in its place; - an element with `display: none`, hidden, or of zero size is skipped; From 22d7a0404556b0fb430466df29fc170d0fcdcf4f Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:20:11 -0700 Subject: [PATCH 14/33] architecture(react-component-runtime): INV12's coverage table and out-of-coverage list --- docs/architecture/react-component-runtime.md | 77 +++++++++----------- 1 file changed, 35 insertions(+), 42 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index ea3968ebfdedb..8e7def72c6dbc 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -187,36 +187,8 @@ those contracts. candidates; an element moves only when its layout rectangle changed between the two measurements (OQ5) and it intersects the boundary's visible area. An element outside the visible area takes its final place at once, so the setup - cost is bounded by what the person can see. Candidates follow these rules: - - an element with `display: contents` has no box, so the walk takes its - children in its place; - - an element with `display: none`, hidden, or of zero size is skipped; - - an absolutely positioned, fixed, or sticky element is skipped, and an - absolute child of an element that moves rides with that element; - - text nodes are ignored, and inline, block, flex, grid, wrapped, reordered, - and floated content is decided by its measured change, not by its place - in the DOM; - - positions are compared in the boundary's coordinates, so a transformed - ancestor or another containing block does not count as a change; - - portals and top-layer content are never candidates; - - an element already moving retargets from where it is on screen - (`spec:AST-094` FR6); - - a nested boundary moves as one unit of its parent's walk. Shifted content may mix Astryx components - and plain app elements, and none of it opts in. Several changes, nested or - not, compose without wrappers: changes in one boundary in the same frame are - measured together before and after, so each element moves once, by its - total change. Each moving element is one the page already renders, so the - module adds no elements (`architecture:component-style-authoring` INV7). - Under Reduce Motion, shifted content changes as `design:reduced-motion` - DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate - change otherwise. Whether the travel applies under every pointer is OQ3. The - bounded walk is decided by `cixzhang`, 2026-10-25. TreeList and Collapsible - adopt the module first, and any Core component whose size 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. `reportLayoutChange` stays internal. - + cost is bounded by what the person can see. Which elements move is listed under + Size-change coverage below. Usage: ```tsx @@ -237,6 +209,27 @@ those contracts. useLayoutEffect(() => change.animate(), [expanded]); ``` +### Size-change coverage + +What INV12 moves inside an area, each case with its test: + +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Normal-flow block and inline siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block and inline siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned, fixed, or sticky elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Floats | Moves when its rectangle changed | A float beside and after the changed element | +| Flex and grid content, including `order`, wrap, and row direction | Moves when its rectangle changed, whatever its DOM order | A wrapping flex row, a reordered item, a row-direction neighbor that does not shift | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Retarget from where they are on screen (`spec:AST-094` FR6) | Two changes 100 ms apart | +| Nested registered areas | The change stops at its nearest registered area; an inner area moves as one unit of the outer walk | An area inside an area, changed in the inner one | + +Out of coverage: content outside the area, content in another document or an +iframe, and elements the app moves itself with its own transforms, which take +their final place without the module's translate. + ## Authoring guidance The following are preferred techniques, not independent compatibility promises: @@ -370,18 +363,18 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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 and an inserted item nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, a 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 | -| INV12 element selection | Real-browser tests of each candidate rule: `display: contents`, `display: none`, hidden and zero-size elements; absolute, fixed, and sticky elements and an absolute child of a moving element; inline flow; a flex row, `order`, and a wrapping grid; a float; a transformed ancestor; a portal; an element mid-move | A moved element whose rectangle did not change, a changed element left jumping, a skipped element that moves, a portal touched, or a mid-move element that snaps fails | +| Invariant | Evidence | Failure signal | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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 and an inserted item nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, a 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 | +| INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | ## Open questions From ea8086f8ed1c20230d088188b47ccc640fe94ae7 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:20:34 -0700 Subject: [PATCH 15/33] architecture(react-component-runtime): coverage for inline lines, flex order candidates, and scaled ancestors --- docs/architecture/react-component-runtime.md | 25 ++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 8e7def72c6dbc..d101461eff682 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -189,6 +189,7 @@ those contracts. element outside the visible area takes its final place at once, so the setup cost is bounded by what the person can see. Which elements move is listed under Size-change coverage below. + Usage: ```tsx @@ -213,18 +214,18 @@ those contracts. What INV12 moves inside an area, each case with its test: -| Case | Behavior | Test | -| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| Normal-flow block and inline siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block and inline siblings at three levels | -| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | -| Absolutely positioned, fixed, or sticky elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | -| Floats | Moves when its rectangle changed | A float beside and after the changed element | -| Flex and grid content, including `order`, wrap, and row direction | Moves when its rectangle changed, whatever its DOM order | A wrapping flex row, a reordered item, a row-direction neighbor that does not shift | -| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | -| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | -| Portals and top-layer content | Never moved | A popover open from a following row | -| Elements already moving | Retarget from where they are on screen (`spec:AST-094` FR6) | Two changes 100 ms apart | -| Nested registered areas | The change stops at its nearest registered area; an inner area moves as one unit of the outer walk | An area inside an area, changed in the inner one | +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned, fixed, or sticky elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Floats | Moves when its rectangle changed | A float beside and after the changed element | +| Flex and grid content, including `order`, wrap, and row direction | Every item of a flex or grid container on the walk is a candidate, since `order` can place an earlier item after the changed one; each moves when its rectangle changed | A wrapping flex row, an item placed later by `order`, a row-direction neighbor that does not shift | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Retarget from where they are on screen (`spec:AST-094` FR6) | Two changes 100 ms apart | +| Nested registered areas | The change stops at its nearest registered area; an inner area moves as one unit of the outer walk | An area inside an area, changed in the inner one | Out of coverage: content outside the area, content in another document or an iframe, and elements the app moves itself with its own transforms, which take From 8ba07c86a384fdaf2ac37407d00f548cffa48bd4 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:21:19 -0700 Subject: [PATCH 16/33] architecture(react-component-runtime): coverage: unstuck sticky moves; additive retarget; scale correction; plain out-of-coverage list --- docs/architecture/react-component-runtime.md | 66 +++++++++++--------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index d101461eff682..ab52037439a1e 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -214,22 +214,32 @@ those contracts. What INV12 moves inside an area, each case with its test: -| Case | Behavior | Test | -| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | -| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | -| Absolutely positioned, fixed, or sticky elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | -| Floats | Moves when its rectangle changed | A float beside and after the changed element | -| Flex and grid content, including `order`, wrap, and row direction | Every item of a flex or grid container on the walk is a candidate, since `order` can place an earlier item after the changed one; each moves when its rectangle changed | A wrapping flex row, an item placed later by `order`, a row-direction neighbor that does not shift | -| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | -| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | -| Portals and top-layer content | Never moved | A popover open from a following row | -| Elements already moving | Retarget from where they are on screen (`spec:AST-094` FR6) | Two changes 100 ms apart | -| Nested registered areas | The change stops at its nearest registered area; an inner area moves as one unit of the outer walk | An area inside an area, changed in the inner one | - -Out of coverage: content outside the area, content in another document or an -iframe, and elements the app moves itself with its own transforms, which take -their final place without the module's translate. +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | +| Floats | Moves when its rectangle changed | A float after the changed element | +| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | +| Nested registered areas | The change stops at its nearest registered area; an inner area moves as one unit of the outer walk | An area inside an area, changed in the inner one | + +Out of coverage, each taking its final place at once: + +- content outside the area, and content in another document or an iframe; +- inline text and other non-replaced inline content, which cannot take a + transform; +- flex or grid items earlier in the DOM that `order` places after the change, + since the walk visits only following siblings; +- text reflowing inside a paragraph beside a float; +- elements the app moves itself with its own transforms; +- an anchored popover tied to a moving element, which layer anchoring keeps + in place (`architecture:layer-runtime`). ## Authoring guidance @@ -364,18 +374,18 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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 and an inserted item nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, a 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 | -| INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | +| Invariant | Evidence | Failure signal | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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 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 | +| INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | ## Open questions From a28eeedd01154fa525897e46ecbe5baed83414ad Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:22:26 -0700 Subject: [PATCH 17/33] architecture(react-component-runtime): OQ4 names from the vibe test; documentation points for the hook --- docs/architecture/react-component-runtime.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index ab52037439a1e..56c7609b3be73 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -245,6 +245,12 @@ Out of coverage, each taking its final place at once: The following are preferred techniques, not independent compatibility promises: +- The size-change hook's documentation states what callers ask about: only + content after the change moves, and the changed element's own content is + not animated by INV12; fixed elements and stuck sticky elements stay put; + Reduce Motion is handled; List rows use the same module; nesting stops at + the nearest area, so nothing animates twice; and the scroll position is + kept. - Use an Effect Event when an external-system Effect needs a stable callback that reads the latest props or state without making those reads reactive. Do not use an Effect Event to hide a value whose change should resynchronize the system. @@ -394,12 +400,12 @@ component suite is not blanket conformance with every invariant. the same way under a mouse; or (b) only a coarse primary pointer, with an immediate change elsewhere, as other touch presentations do. - **OQ4 — The public hook's name.** (`human-design`) Options, each named for - what it does: (a, recommended) `useAnimatedLayout(ref)`: when something - inside this area changes size, the content after it glides instead of - jumping; (b) `useSmoothLayoutShift(ref)`: layout shifts in this area are - smoothed; (c) `useLayoutTransitionArea(ref)`: this element is the area - layout transitions stay within; or (d) `useGlideAfterResize(ref)`: content - after a resized element glides into place. + what it does: (a, recommended) `useSmoothLayoutShift(ref)`: layout shifts + in this area are smoothed; (b) `useGlideOnResize(ref)`: content after a + resized element glides into place; (c) `useAnimatedLayout(ref)`: when + something inside this area changes size, the content after it glides + instead of jumping; or (d) `useAnimatedReflow(ref)`: reflow in this area is + animated. - **OQ5 — How the walk chooses what moves.** (`human-design`) Options: (a, recommended) the walk collects candidates and only those whose layout rectangle changed between the two measurements move, which handles flex From 476f482657ce240bf0512ef3a56ea39056587de1 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:22:47 -0700 Subject: [PATCH 18/33] architecture(react-component-runtime): INV12 uses OQ4's recommended name --- docs/architecture/react-component-runtime.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 56c7609b3be73..e572972562890 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -173,7 +173,7 @@ those contracts. `spec:AST-094` FR5); it never animates height, which lays out every frame. A boundary 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 + container or panel with `useSmoothLayoutShift(containerRef)`. An element whose size changes reports the change, around its commit, to its nearest registered ancestor boundary. From that element, the module walks up each ancestor to the boundary: at every level, each following in-flow sibling @@ -195,7 +195,7 @@ those contracts. ```tsx // Public: an app container opts in as a boundary. ScrollableArea does this itself. const panelRef = useRef(null); - useAnimatedLayout(panelRef); + useSmoothLayoutShift(panelRef);

Notes

From f072779de750134151ae127661db4b00eaa24bdc Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:29:16 -0700 Subject: [PATCH 19/33] architecture(react-component-runtime): the hook is useAnimatedLayout (decided), with its documentation; restore INV12's batching, INV7, Reduce Motion, and adoption text --- docs/architecture/react-component-runtime.md | 63 ++++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index e572972562890..78f4ba5776929 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -159,7 +159,7 @@ those contracts. its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. - **INV12 — Size changes inside a container have one module.** **Proposed; - pending owner decision (OQ3–OQ5).** When something inside an area changes + pending owner decision (OQ3, OQ5).** When something inside an area changes size, the content after it glides to its new place instead of jumping. The measuring, the walk, and the translate all live inside one internal module; the public hook only marks the area where the walk stops, and the internal @@ -170,32 +170,50 @@ those contracts. layout before and after the change and moves the shifted content with a compositor-only translate from its old place to its new one, played through the shared rectangle morph (`architecture:layer-runtime` INV15; - `spec:AST-094` FR5); it never animates height, which lays out every frame. A - boundary is any container element registered by ref: ScrollableArea + `spec:AST-094` FR5); it never animates height, which lays out every frame. + 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 `useSmoothLayoutShift(containerRef)`. An element whose + container or panel with `useAnimatedLayout(containerRef)`. An element whose size changes reports the change, around its commit, to its nearest - registered ancestor boundary. From that element, the module walks up each - ancestor to the boundary: 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, so - its cost grows with depth plus following siblings and it needs no - TreeWalker, which visits subtrees and text nodes. It - stops at the boundary and never passes it, so content outside the boundary - takes its final place at once. The walk only collects - candidates; an element moves only when its layout rectangle changed between - the two measurements (OQ5) and it intersects the boundary's visible area. An - element outside the visible area takes its final place at once, so the setup - cost is bounded by what the person can see. Which elements move is listed under - Size-change coverage below. + registered ancestor area. From that element, the module walks up each + 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, + so its cost grows with depth plus following siblings and it needs no + TreeWalker, which visits subtrees and text nodes. It stops at the area and + never passes it, so content outside the area takes its final place at once. + The walk only collects candidates; an element moves only when its layout + rectangle changed between the two measurements (OQ5) and it intersects the + 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. Shifted + content may mix Astryx components and plain app elements, and none of it + opts in. Several changes, nested or not, compose without wrappers: changes + in one area in the same frame are measured together before and after, so + each element moves once, by its total change. Each moving element is one + the page already renders, so the module adds no elements + (`architecture:component-style-authoring` INV7). Under Reduce Motion, + shifted content changes as `design:reduced-motion` DR1–DR3 require: a + cross-fade under a coarse primary pointer, an immediate change otherwise. + Whether the travel applies under every pointer is OQ3. The bounded walk and + the name `useAnimatedLayout` are decided by `cixzhang`, 2026-10-25. TreeList + and Collapsible adopt the module first, and any Core component whose size + 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. + `reportLayoutChange` stays internal. Its documentation reads: "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." Usage: ```tsx // Public: an app container opts in as a boundary. ScrollableArea does this itself. const panelRef = useRef(null); - useSmoothLayoutShift(panelRef); + useAnimatedLayout(panelRef);

Notes

@@ -399,13 +417,6 @@ component suite is not blanket conformance with every invariant. recommended) every pointer, because a disclosure's following content jumps the same way under a mouse; or (b) only a coarse primary pointer, with an immediate change elsewhere, as other touch presentations do. -- **OQ4 — The public hook's name.** (`human-design`) Options, each named for - what it does: (a, recommended) `useSmoothLayoutShift(ref)`: layout shifts - in this area are smoothed; (b) `useGlideOnResize(ref)`: content after a - resized element glides into place; (c) `useAnimatedLayout(ref)`: when - something inside this area changes size, the content after it glides - instead of jumping; or (d) `useAnimatedReflow(ref)`: reflow in this area is - animated. - **OQ5 — How the walk chooses what moves.** (`human-design`) Options: (a, recommended) the walk collects candidates and only those whose layout rectangle changed between the two measurements move, which handles flex From 2a867737cb75c4382b190dd9d35b1ea338631859 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:30:11 -0700 Subject: [PATCH 20/33] architecture(react-component-runtime): the changed element animates itself; followers match its timing via reportLayoutChange(ref, motion) --- docs/architecture/react-component-runtime.md | 26 +++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 78f4ba5776929..1ebeae2a94d4b 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -160,7 +160,8 @@ those contracts. INV5. - **INV12 — Size changes inside a container have one module.** **Proposed; pending owner decision (OQ3, OQ5).** When something inside an area changes - size, the content after it glides to its new place instead of jumping. The + size, the changed element animates itself and the content after it follows + with matching timing instead of jumping. The measuring, the walk, and the translate all live inside one internal module; the public hook only marks the area where the walk stops, and the internal `reportLayoutChange` only tells the module that a size is about to change. In @@ -186,7 +187,12 @@ those contracts. The walk only collects candidates; an element moves only when its layout rectangle changed between the two measurements (OQ5) and it intersects the 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. Shifted + see. Which elements move is listed under Size-change coverage below. The changed element runs its own animation, entering, exiting, or + resizing, and the module moves only the content after it. Both use the same + timing and easing and start on the same frame, so they move as one: the + element passes its spring, or its duration and curve, with + `reportLayoutChange(ref, motion)`, from a theme motion role + (`spec:AST-094` FR4), and Reduce Motion applies to both. Shifted content may mix Astryx components and plain app elements, and none of it opts in. Several changes, nested or not, compose without wrappers: changes in one area in the same frame are measured together before and after, so @@ -203,8 +209,8 @@ those contracts. 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. `reportLayoutChange` stays internal. Its documentation reads: "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 + something inside this area changes size, the changed element animates + itself and the content after it follows with matching timing. Fixed and sticky elements stay put, Reduce Motion is respected, ScrollableArea applies it automatically, and List rows use the same module." @@ -219,8 +225,9 @@ those contracts.

Notes

; - // Internal: an element whose size changes measures before its commit and animates after it. - const change = reportLayoutChange(groupRef); + // Internal: an element whose size changes measures before its commit and animates after it, + // passing the motion it plays itself so its followers match it. + const change = reportLayoutChange(groupRef, springs.settle); const toggle = () => { change.measureBefore(); setExpanded(open => !open); @@ -263,9 +270,9 @@ Out of coverage, each taking its final place at once: The following are preferred techniques, not independent compatibility promises: -- The size-change hook's documentation states what callers ask about: only - content after the change moves, and the changed element's own content is - not animated by INV12; fixed elements and stuck sticky elements stay put; +- The size-change hook's documentation states what callers ask about: the + changed element animates itself and the module moves only content after it, + with matching timing; fixed elements and stuck sticky elements stay put; Reduce Motion is handled; List rows use the same module; nesting stops at the nearest area, so nothing animates twice; and the scroll position is kept. @@ -410,6 +417,7 @@ component suite is not blanket conformance with every invariant. | 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 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 | | INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | +| INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion | The element and its followers starting on different frames or with different durations or curves, or Reduce Motion applied to one and not the other, fails | ## Open questions From 0266a3b2c45b4db99c23131ca33e7121dcfe138c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:30:37 -0700 Subject: [PATCH 21/33] architecture(react-component-runtime): every-pointer scope decided, as an AST-017 FR59 named fine-pointer change --- docs/architecture/react-component-runtime.md | 41 ++++++++++---------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 1ebeae2a94d4b..929019fd48150 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -159,7 +159,7 @@ those contracts. its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. - **INV12 — Size changes inside a container have one module.** **Proposed; - pending owner decision (OQ3, OQ5).** When something inside an area changes + pending owner decision (OQ5).** When something inside an area changes size, the changed element animates itself and the content after it follows with matching timing instead of jumping. The measuring, the walk, and the translate all live inside one internal module; @@ -201,8 +201,12 @@ those contracts. (`architecture:component-style-authoring` INV7). Under Reduce Motion, shifted content changes as `design:reduced-motion` DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate change otherwise. - Whether the travel applies under every pointer is OQ3. The bounded walk and - the name `useAnimatedLayout` are decided by `cixzhang`, 2026-10-25. TreeList + The glide applies under every pointer, a mouse included: it fixes a layout + jump that people using a fine pointer see too, not a touch gesture, so it is + a named fine-pointer change under `spec:AST-017` FR59, and its adopting pull + requests show fine-pointer evidence before and after. The bounded walk, the + name `useAnimatedLayout`, and every-pointer scope are decided by `cixzhang`, + 2026-10-25. TreeList and Collapsible adopt the module first, and any Core component whose size changes in place uses it rather than its own layout animation. `useAnimatedLayout` is the one public piece: it takes the container's ref @@ -405,26 +409,23 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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 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 | -| INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | -| INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion | The element and its followers starting on different frames or with different durations or curves, or Reduce Motion applied to one and not the other, fails | +| Invariant | Evidence | Failure signal | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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 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 | +| INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | +| INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion | The element and its followers starting on different frames or with different durations or curves, or Reduce Motion applied to one and not the other, fails | +| INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | ## Open questions -- **OQ3 — Which pointers get the travel.** (`human-design`) Options: (a, - recommended) every pointer, because a disclosure's following content jumps - the same way under a mouse; or (b) only a coarse primary pointer, with an - immediate change elsewhere, as other touch presentations do. - **OQ5 — How the walk chooses what moves.** (`human-design`) Options: (a, recommended) the walk collects candidates and only those whose layout rectangle changed between the two measurements move, which handles flex From 163bef40ead1148eebae1b12d3b2979030909d5d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:31:08 -0700 Subject: [PATCH 22/33] architecture(react-component-runtime): the area owns the shared timing; reportLayoutChange returns the motion the element plays --- docs/architecture/react-component-runtime.md | 37 +++++++++++--------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 929019fd48150..4f408d9d0dc1e 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -160,8 +160,8 @@ those contracts. INV5. - **INV12 — Size changes inside a container have one module.** **Proposed; pending owner decision (OQ5).** When something inside an area changes - size, the changed element animates itself and the content after it follows - with matching timing instead of jumping. The + size, the changed element animates itself using timing the area provides, + and the content after it follows in step instead of jumping. The measuring, the walk, and the translate all live inside one internal module; the public hook only marks the area where the walk stops, and the internal `reportLayoutChange` only tells the module that a size is about to change. In @@ -187,12 +187,13 @@ those contracts. The walk only collects candidates; an element moves only when its layout rectangle changed between the two measurements (OQ5) and it intersects the 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 runs its own animation, entering, exiting, or - resizing, and the module moves only the content after it. Both use the same - timing and easing and start on the same frame, so they move as one: the - element passes its spring, or its duration and curve, with - `reportLayoutChange(ref, motion)`, from a theme motion role - (`spec:AST-094` FR4), and Reduce Motion applies to both. Shifted + see. Which elements move is listed under Size-change coverage below. The module owns the shared timing: `reportLayoutChange(ref)` returns the + motion to use, a spring or a duration and curve, taken from the area's + generic theme motion role (`spec:AST-094` FR4) with Reduce Motion already + applied. The changed element plays its own animation, entering, exiting, or + resizing, with that motion, so it decides what animates, and the module + moves only the content after it, starting on the same frame with the same + motion. The timing is themeable in the motion role, never per component. Shifted content may mix Astryx components and plain app elements, and none of it opts in. Several changes, nested or not, compose without wrappers: changes in one area in the same frame are measured together before and after, so @@ -214,7 +215,8 @@ those contracts. 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 animates - itself and the content after it follows with matching timing. Fixed and sticky elements stay put, Reduce Motion is + itself using timing this area provides, and the content after it follows in + step. Fixed and sticky elements stay put, Reduce Motion is respected, ScrollableArea applies it automatically, and List rows use the same module." @@ -229,14 +231,17 @@ those contracts.

Notes

; - // Internal: an element whose size changes measures before its commit and animates after it, - // passing the motion it plays itself so its followers match it. - const change = reportLayoutChange(groupRef, springs.settle); + // Internal: an element whose size changes measures before its commit; the module + // returns the area's motion, and the element plays its own animation with it. + const change = reportLayoutChange(groupRef); const toggle = () => { change.measureBefore(); setExpanded(open => !open); }; - useLayoutEffect(() => change.animate(), [expanded]); + useLayoutEffect(() => { + const motion = change.animate(); + playOwnExpand(groupRef.current, motion); + }, [expanded]); ``` ### Size-change coverage @@ -275,8 +280,8 @@ Out of coverage, each taking its final place at once: The following are preferred techniques, not independent compatibility promises: - The size-change hook's documentation states what callers ask about: the - changed element animates itself and the module moves only content after it, - with matching timing; fixed elements and stuck sticky elements stay put; + changed element animates itself with timing the area provides and the module + moves only content after it, in step; fixed elements and stuck sticky elements stay put; Reduce Motion is handled; List rows use the same module; nesting stops at the nearest area, so nothing animates twice; and the scroll position is kept. @@ -421,7 +426,7 @@ component suite is not blanket conformance with every invariant. | 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 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 | | INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | -| INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion | The element and its followers starting on different frames or with different durations or curves, or Reduce Motion applied to one and not the other, fails | +| INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion, and with a theme overriding the area's motion role | The element and its followers starting on different frames or with different durations or curves, a component-specific timing, or Reduce Motion applied to one and not the other, fails | | INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | ## Open questions From 6e75feb18ea398bb04af9e8da2437f5b0da795d4 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:31:41 -0700 Subject: [PATCH 23/33] architecture(react-component-runtime): INV12 decided by cixzhang; no open questions --- docs/architecture/react-component-runtime.md | 22 ++++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 4f408d9d0dc1e..f65791e3330c8 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -158,8 +158,7 @@ those contracts. does, blurs as FR5 does, applies the height through a functional style on its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. -- **INV12 — Size changes inside a container have one module.** **Proposed; - pending owner decision (OQ5).** When something inside an area changes +- **INV12 — Size changes inside a container have one module.** When something inside an area changes size, the changed element animates itself using timing the area provides, and the content after it follows in step instead of jumping. The measuring, the walk, and the translate all live inside one internal module; @@ -185,7 +184,7 @@ those contracts. TreeWalker, which visits subtrees and text nodes. It stops at the area and never passes it, so content outside the area takes its final place at once. The walk only collects candidates; an element moves only when its layout - rectangle changed between the two measurements (OQ5) and it intersects the + rectangle changed between the two measurements and it intersects the 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 module owns the shared timing: `reportLayoutChange(ref)` returns the motion to use, a spring or a duration and curve, taken from the area's @@ -205,9 +204,10 @@ those contracts. The glide applies under every pointer, a mouse included: it fixes a layout jump that people using a fine pointer see too, not a touch gesture, so it is a named fine-pointer change under `spec:AST-017` FR59, and its adopting pull - requests show fine-pointer evidence before and after. The bounded walk, the - name `useAnimatedLayout`, and every-pointer scope are decided by `cixzhang`, - 2026-10-25. TreeList + requests show fine-pointer evidence before and after. The container area, + the bounded walk, the changed-and-visible rule, every-pointer scope, the + area's shared timing, and the name `useAnimatedLayout` are decided by + `cixzhang`, 2026-10-25. TreeList and Collapsible adopt the module first, and any Core component whose size changes in place uses it rather than its own layout animation. `useAnimatedLayout` is the one public piece: it takes the container's ref @@ -428,13 +428,3 @@ component suite is not blanket conformance with every invariant. | INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | | INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion, and with a theme overriding the area's motion role | The element and its followers starting on different frames or with different durations or curves, a component-specific timing, or Reduce Motion applied to one and not the other, fails | | INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | - -## Open questions - -- **OQ5 — How the walk chooses what moves.** (`human-design`) Options: (a, - recommended) the walk collects candidates and only those whose layout - rectangle changed between the two measurements move, which handles flex - order, wrapping, floats, and side-by-side content that does not shift; or - (b) every following in-flow sibling the walk finds moves by its offset, - which skips one rectangle comparison per element but moves content that did - not shift and misses reordered content. From e8da4fb93eab257b16fcacc2d16f53adbbc313f8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 19:34:03 -0700 Subject: [PATCH 24/33] architecture(react-component-runtime): one shared motion role for the element and its followers; reportLayoutChange only triggers --- docs/architecture/react-component-runtime.md | 43 +++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index f65791e3330c8..b2ca9c31ecc9a 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -159,11 +159,11 @@ those contracts. its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. - **INV12 — Size changes inside a container have one module.** When something inside an area changes - size, the changed element animates itself using timing the area provides, - and the content after it follows in step instead of jumping. The + size, the changed element and the content after it animate together on one + shared motion role instead of jumping. The measuring, the walk, and the translate all live inside one internal module; the public hook only marks the area where the walk stops, and the internal - `reportLayoutChange` only tells the module that a size is about to change. In + `reportLayoutChange` only triggers the walk and lines up the start frame. In `@astryxdesign/core`, the module animates the layout shift around any element whose size changes inside a container: a disclosure opening, content loading in, a message appearing, an item inserted or removed. It measures the @@ -186,13 +186,12 @@ those contracts. The walk only collects candidates; an element moves only when its layout rectangle changed between the two measurements and it intersects the 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 module owns the shared timing: `reportLayoutChange(ref)` returns the - motion to use, a spring or a duration and curve, taken from the area's - generic theme motion role (`spec:AST-094` FR4) with Reduce Motion already - applied. The changed element plays its own animation, entering, exiting, or - resizing, with that motion, so it decides what animates, and the module - moves only the content after it, starting on the same frame with the same - motion. The timing is themeable in the motion role, never per component. Shifted + 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 + (`spec:AST-094` FR4), so the timing is governed centrally and is themeable + there, never per component; both start on the same frame, and Reduce Motion + reaches both through that role. Shifted content may mix Astryx components and plain app elements, and none of it opts in. Several changes, nested or not, compose without wrappers: changes in one area in the same frame are measured together before and after, so @@ -206,7 +205,7 @@ those contracts. 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 - area's shared timing, and the name `useAnimatedLayout` are decided by + shared motion role, and the name `useAnimatedLayout` are decided by `cixzhang`, 2026-10-25. TreeList and Collapsible adopt the module first, and any Core component whose size changes in place uses it rather than its own layout animation. @@ -214,9 +213,8 @@ those contracts. 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. `reportLayoutChange` stays internal. Its documentation reads: "When - something inside this area changes size, the changed element animates - itself using timing this area provides, and the content after it follows in - step. Fixed and sticky elements stay put, Reduce Motion is + 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 respected, ScrollableArea applies it automatically, and List rows use the same module." @@ -231,17 +229,14 @@ those contracts.

Notes

; - // Internal: an element whose size changes measures before its commit; the module - // returns the area's motion, and the element plays its own animation with it. + // Internal: an element whose size changes measures before its commit and starts the + // walk after it; it plays its own animation on the same shared motion role. const change = reportLayoutChange(groupRef); const toggle = () => { change.measureBefore(); setExpanded(open => !open); }; - useLayoutEffect(() => { - const motion = change.animate(); - playOwnExpand(groupRef.current, motion); - }, [expanded]); + useLayoutEffect(() => change.animate(), [expanded]); ``` ### Size-change coverage @@ -280,8 +275,8 @@ Out of coverage, each taking its final place at once: The following are preferred techniques, not independent compatibility promises: - The size-change hook's documentation states what callers ask about: the - changed element animates itself with timing the area provides and the module - moves only content after it, in step; fixed elements and stuck sticky elements stay put; + changed element and the content after it animate together on one shared + motion role; fixed elements and stuck sticky elements stay put; Reduce Motion is handled; List rows use the same module; nesting stops at the nearest area, so nothing animates twice; and the scroll position is kept. @@ -424,7 +419,7 @@ component suite is not blanket conformance with every invariant. | 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 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 | +| 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 | | INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | -| INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion, and with a theme overriding the area's motion role | The element and its followers starting on different frames or with different durations or curves, a component-specific timing, or Reduce Motion applied to one and not the other, fails | +| INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion, and with a theme overriding the layout-change motion role | The element and its followers starting on different frames or reading different motion, a component-specific timing, or Reduce Motion applied to one and not the other, fails | | INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | From f7315cd5123f1af2689f3b9e8ae8671fdc82dcdf Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:20:33 -0700 Subject: [PATCH 25/33] architecture(react-component-runtime): useAnimatedLayout({containerRef}) returns a handle passed as animatedLayout; ScrollableArea gets the ref; CodeBunny consistency --- docs/architecture/react-component-runtime.md | 171 +++++++++---------- 1 file changed, 81 insertions(+), 90 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index b2ca9c31ecc9a..61e39eba62cf9 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -35,7 +35,8 @@ deciding_specs: [spec:AST-086/DEC-2, spec:AST-099/DEC-1] "INV6", "INV8", "INV10", - "INV11" + "INV11", + "INV12" ] } } @@ -158,85 +159,75 @@ those contracts. does, blurs as FR5 does, applies the height through a functional style on its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. -- **INV12 — Size changes inside a container have one module.** When something inside an area changes - size, the changed element and the content after it animate together on one - shared motion role instead of jumping. The - measuring, the walk, and the translate all live inside one internal module; - the public hook only marks the area where the walk stops, and the internal - `reportLayoutChange` only triggers the walk and lines up the start frame. In - `@astryxdesign/core`, the module animates the layout shift around any - element whose size changes inside a container: a disclosure opening, content - loading in, a message appearing, an item inserted or removed. It measures the - layout before and after the change and moves the shifted content with a - compositor-only translate from its old place to its new one, played through - the shared rectangle morph (`architecture:layer-runtime` INV15; - `spec:AST-094` FR5); it never animates height, which lays out every frame. - 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 - registered ancestor area. From that element, the module walks up each - 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, - so its cost grows with depth plus following siblings and it needs no - TreeWalker, which visits subtrees and text nodes. It stops at the area and - never passes it, so content outside the area takes its final place at once. - The walk only collects candidates; an element moves only when its layout - rectangle changed between the two measurements and it intersects the - 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 - (`spec:AST-094` FR4), so the timing is governed centrally and is themeable - there, never per component; both start on the same frame, and Reduce Motion - reaches both through that role. Shifted - content may mix Astryx components and plain app elements, and none of it - opts in. Several changes, nested or not, compose without wrappers: changes - in one area in the same frame are measured together before and after, so - each element moves once, by its total change. Each moving element is one - the page already renders, so the module adds no elements - (`architecture:component-style-authoring` INV7). Under Reduce Motion, - shifted content changes as `design:reduced-motion` DR1–DR3 require: a - cross-fade under a coarse primary pointer, an immediate change otherwise. - The glide applies under every pointer, a mouse included: it fixes a layout - jump that people using a fine pointer see too, not a touch gesture, so it is - 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 - `cixzhang`, 2026-10-25. TreeList - and Collapsible adopt the module first, and any Core component whose size - 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. - `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 - respected, ScrollableArea applies it automatically, and List rows use the - same module." +- **INV12 — Size changes inside a container have one module.** When + something inside an area changes size, the changed element and the content + after it animate together on one shared motion role instead of jumping. The + measuring, the walk, and the translate all live inside one internal module. + An app opts in with `useAnimatedLayout({containerRef})`, which returns an + opaque `animatedLayout` handle with no public methods or fields, and passes + it to a component whose size changes through that component's + `animatedLayout` prop; the component reports each change through the handle. + Without a handle, content after a change takes its place at once, as before. + `containerRef` names the area where the walk stops; ScrollableArea does not + register itself, and an app passes the ref it already gives ScrollableArea, + which reaches the native scroll viewport. In `@astryxdesign/core`, the module + animates the layout shift around any element whose size changes inside an + area: a disclosure opening, content loading in, a message appearing, an item + inserted or removed. It measures the layout before and after the change and + moves the shifted content with a compositor-only translate from its old + place to its new one, played through the shared rectangle morph + (`architecture:layer-runtime` INV15; `spec:AST-094` FR5); it never animates + height, which lays out every frame. From the changed element, the module + walks up each 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`, and descends into a subtree + only for an element with `display: contents`, which has no box; its cost + grows with depth plus following siblings and it needs no TreeWalker, which + visits subtrees and text nodes. It stops at the area and never passes it, so + content outside the area takes its final place at once. The walk only + collects candidates; an element moves only when its layout rectangle changed + between the two measurements and it intersects the 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 theme motion role + (`architecture:theme-tokens` INV3), so the timing is governed centrally and + is themeable there, never per component; both start on the same frame, and + Reduce Motion reaches both through that role, as `design:reduced-motion` + DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate + change otherwise. Shifted content may mix Astryx components and plain app + elements, and none of it opts in. Several changes, nested or not, compose + without wrappers: changes in one area in the same frame are measured + together before and after, so each element moves once, by its total change. + Each moving element is one the page already renders, so the module adds no + elements (`architecture:component-style-authoring` INV7). The glide applies + under every pointer, a mouse included: it fixes a layout jump that people + using a fine pointer see too, not a touch gesture, so it is a named + fine-pointer change under `spec:AST-017` FR59, and adopting pull requests + show fine-pointer evidence before and after. `useAnimatedLayout` and the + `animatedLayout` prop are the public pieces. They pass `spec:AST-002` FR5: + two otherwise identical pages differ in whether content after a size change + glides or jumps, and in which container bounds it, which only the caller + knows. The handle is configuration, not a rendered slot + (`architecture:public-component-api` INV18); no other API does this job + (INV21); and it is not a visual boolean (INV20). Reporting a change is a + method on the handle and is not exported. The hook's documentation reads: + "Returns a handle to pass to a component whose size changes, such as + TreeList or Collapsible. When that component changes size, it and the + content after it inside `containerRef` animate together on one shared + motion role. Fixed and stuck sticky elements stay put, Reduce Motion is + respected, and List rows use the same module." Usage: ```tsx - // Public: an app container opts in as a boundary. ScrollableArea does this itself. - const panelRef = useRef(null); - useAnimatedLayout(panelRef); -
- + const scrollRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); + +

Notes

-
; - - // Internal: an element whose size changes measures before its commit and starts the - // walk after it; it plays its own animation on the same shared motion role. - const change = reportLayoutChange(groupRef); - const toggle = () => { - change.measureBefore(); - setExpanded(open => !open); - }; - useLayoutEffect(() => change.animate(), [expanded]); + ; ``` ### Size-change coverage @@ -275,7 +266,7 @@ Out of coverage, each taking its final place at once: The following are preferred techniques, not independent compatibility promises: - The size-change hook's documentation states what callers ask about: the - changed element and the content after it animate together on one shared + changed component and the content after it animate together on one shared motion role; fixed elements and stuck sticky elements stay put; Reduce Motion is handled; List rows use the same module; nesting stops at the nearest area, so nothing animates twice; and the scroll position is @@ -409,17 +400,17 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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 | -| INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | -| INV12 timing | Real-browser frame capture of a changed element and its followers, with and without Reduce Motion, and with a theme overriding the layout-change motion role | The element and its followers starting on different frames or reading different motion, a component-specific timing, or Reduce Motion applied to one and not the other, fails | -| INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | +| Invariant | Evidence | Failure signal | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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, with and without a handle, 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 visible following sibling at any level that jumps, an element moved twice for one frame, an added element, or travel under Reduce Motion fails | +| INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | +| INV12 timing | Real-browser frame capture of a changed element and its visible followers under a coarse and a fine pointer, with and without Reduce Motion, and with a theme overriding the motion role | The element and its visible followers starting on different frames or reading different motion, a component-specific timing, Reduce Motion applied to one and not the other, or a fine-pointer Reduce Motion change that is not immediate, fails | +| INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | From 4d61826efb732dbb9253d66f3cc038ca89a85882 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:20:54 -0700 Subject: [PATCH 26/33] architecture(react-component-runtime): nested areas follow the handle --- docs/architecture/react-component-runtime.md | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 61e39eba62cf9..7a1ef113d5e9f 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -234,20 +234,20 @@ those contracts. What INV12 moves inside an area, each case with its test: -| Case | Behavior | Test | -| ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | -| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | -| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | -| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | -| Floats | Moves when its rectangle changed | A float after the changed element | -| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | -| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | -| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | -| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | -| Portals and top-layer content | Never moved | A popover open from a following row | -| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | -| Nested registered areas | The change stops at its nearest registered area; an inner area moves as one unit of the outer walk | An area inside an area, changed in the inner one | +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | +| Floats | Moves when its rectangle changed | A float after the changed element | +| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | +| Nested areas | The walk stops at the area of the handle the changed component holds; an inner area moves as one unit of an outer walk | An area inside an area, changed in the inner one | Out of coverage, each taking its final place at once: @@ -268,8 +268,8 @@ The following are preferred techniques, not independent compatibility promises: - The size-change hook's documentation states what callers ask about: the changed component and the content after it animate together on one shared motion role; fixed elements and stuck sticky elements stay put; - Reduce Motion is handled; List rows use the same module; nesting stops at - the nearest area, so nothing animates twice; and the scroll position is + Reduce Motion is handled; List rows use the same module; a change stops at + its handle's area, so nothing animates twice; and the scroll position is kept. - Use an Effect Event when an external-system Effect needs a stable callback that reads the latest props or state without making those reads reactive. Do not use From 14155a2bf1baef3f671262ccb08aa8c111704f87 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:55:54 -0700 Subject: [PATCH 27/33] architecture(react-component-runtime): handle stays member-free; scroll position kept; no unmerged citations; OQ6 motion role, OQ7 ownership --- docs/architecture/react-component-runtime.md | 43 ++++++++++++-------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 7a1ef113d5e9f..479b448f87222 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -68,8 +68,8 @@ A component runtime has four distinct roles: Component and family contracts continue to own public state semantics, controlled and optimistic behavior, focus, announcements, gestures, and other user-facing -outcomes. This record owns only the shared React lifecycle boundary used to fulfill -those contracts. +outcomes. This record owns the shared React lifecycle boundary used to fulfill +those contracts and, for now, INV12's caller-visible hook and prop (OQ7). ## Boundaries and invariants @@ -166,8 +166,10 @@ those contracts. An app opts in with `useAnimatedLayout({containerRef})`, which returns an opaque `animatedLayout` handle with no public methods or fields, and passes it to a component whose size changes through that component's - `animatedLayout` prop; the component reports each change through the handle. - Without a handle, content after a change takes its place at once, as before. + `animatedLayout` prop. The component reports each change through an + internal, non-exported function that takes the handle, so the handle stays + member-free. Without a handle, content after a change takes its place at + once. `containerRef` names the area where the walk stops; ScrollableArea does not register itself, and an app passes the ref it already gives ScrollableArea, which reaches the native scroll viewport. In `@astryxdesign/core`, the module @@ -191,8 +193,8 @@ those contracts. 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 theme motion role - (`architecture:theme-tokens` INV3), so the timing is governed centrally and + content after it. Both read the same theme motion role (OQ6; + `architecture:theme-tokens` INV3), so the timing is governed centrally and is themeable there, never per component; both start on the same frame, and Reduce Motion reaches both through that role, as `design:reduced-motion` DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate @@ -200,24 +202,23 @@ those contracts. elements, and none of it opts in. Several changes, nested or not, compose without wrappers: changes in one area in the same frame are measured together before and after, so each element moves once, by its total change. - Each moving element is one the page already renders, so the module adds no + The area's scroll position does not change. Each moving element is one the + page already renders, so the module adds no elements (`architecture:component-style-authoring` INV7). The glide applies - under every pointer, a mouse included: it fixes a layout jump that people + under every pointer, a mouse included: it removes a layout jump that people using a fine pointer see too, not a touch gesture, so it is a named fine-pointer change under `spec:AST-017` FR59, and adopting pull requests show fine-pointer evidence before and after. `useAnimatedLayout` and the `animatedLayout` prop are the public pieces. They pass `spec:AST-002` FR5: two otherwise identical pages differ in whether content after a size change glides or jumps, and in which container bounds it, which only the caller - knows. The handle is configuration, not a rendered slot - (`architecture:public-component-api` INV18); no other API does this job - (INV21); and it is not a visual boolean (INV20). Reporting a change is a - method on the handle and is not exported. The hook's documentation reads: + knows. The handle is configuration, not a rendered slot; no other API does this + job; and it is not a visual boolean. Reporting a change is not exported. The hook's documentation reads: "Returns a handle to pass to a component whose size changes, such as TreeList or Collapsible. When that component changes size, it and the content after it inside `containerRef` animate together on one shared - motion role. Fixed and stuck sticky elements stay put, Reduce Motion is - respected, and List rows use the same module." + motion role. Fixed and stuck sticky elements stay put, the area keeps its scroll + position, and Reduce Motion is respected." Usage: @@ -268,8 +269,7 @@ The following are preferred techniques, not independent compatibility promises: - The size-change hook's documentation states what callers ask about: the changed component and the content after it animate together on one shared motion role; fixed elements and stuck sticky elements stay put; - Reduce Motion is handled; List rows use the same module; a change stops at - its handle's area, so nothing animates twice; and the scroll position is + Reduce Motion is handled; a change stops at its handle's area, so nothing animates twice; and the scroll position is kept. - Use an Effect Event when an external-system Effect needs a stable callback that reads the latest props or state without making those reads reactive. Do not use @@ -414,3 +414,14 @@ component suite is not blanket conformance with every invariant. | INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | | INV12 timing | Real-browser frame capture of a changed element and its visible followers under a coarse and a fine pointer, with and without Reduce Motion, and with a theme overriding the motion role | The element and its visible followers starting on different frames or reading different motion, a component-specific timing, Reduce Motion applied to one and not the other, or a fine-pointer Reduce Motion change that is not immediate, fails | | INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | + +## Open questions + +- **OQ6 — The motion role INV12 reads.** (`human-design`) No layout role + exists yet; the motion scheme will name one. Options until then: (a, + recommended) `springs.settle` (`spec:AST-094` FR4), a spring with no bounce + that suits a layout settle; or (b) hold INV12 until the scheme lands. +- **OQ7 — Where `useAnimatedLayout` and `animatedLayout` are owned.** + (`human-design`) Options: (a, recommended) this record, beside the module, + with each adopting component's record documenting its `animatedLayout` prop; + or (b) a system spec that this record's module serves. From 53744422b52be8bd9fb6c7d60e32812aa5b3d48d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 20:56:46 -0700 Subject: [PATCH 28/33] architecture(react-component-runtime): springs.settle until the scheme names a layout role; this record owns the hook; scroll position is tested --- docs/architecture/react-component-runtime.md | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 479b448f87222..31257e7cf8e19 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -69,7 +69,8 @@ A component runtime has four distinct roles: Component and family contracts continue to own public state semantics, controlled and optimistic behavior, focus, announcements, gestures, and other user-facing outcomes. This record owns the shared React lifecycle boundary used to fulfill -those contracts and, for now, INV12's caller-visible hook and prop (OQ7). +those contracts and INV12's caller-visible hook and prop; each adopting component's record +documents its `animatedLayout` prop. ## Boundaries and invariants @@ -193,8 +194,8 @@ those contracts and, for now, INV12's caller-visible hook and prop (OQ7). 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 theme motion role (OQ6; - `architecture:theme-tokens` INV3), so the timing is governed centrally and + content after it. Both read the same theme motion role, `springs.settle` + (`spec:AST-094` FR4), until the motion scheme names a public layout role, so the timing is governed centrally and is themeable there, never per component; both start on the same frame, and Reduce Motion reaches both through that role, as `design:reduced-motion` DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate @@ -412,16 +413,5 @@ component suite is not blanket conformance with every invariant. | 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, with and without a handle, 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 visible following sibling at any level that jumps, an element moved twice for one frame, an added element, or travel under Reduce Motion fails | | INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | -| INV12 timing | Real-browser frame capture of a changed element and its visible followers under a coarse and a fine pointer, with and without Reduce Motion, and with a theme overriding the motion role | The element and its visible followers starting on different frames or reading different motion, a component-specific timing, Reduce Motion applied to one and not the other, or a fine-pointer Reduce Motion change that is not immediate, fails | +| INV12 timing | Real-browser frame capture of a changed element and its visible followers under a coarse and a fine pointer, with and without Reduce Motion, with a theme overriding the motion role, and with the area scrolled mid-list | The element and its visible followers starting on different frames or reading different motion, a component-specific timing, Reduce Motion applied to one and not the other, a fine-pointer Reduce Motion change that is not immediate, or a change in the area's scroll position, fails | | INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | - -## Open questions - -- **OQ6 — The motion role INV12 reads.** (`human-design`) No layout role - exists yet; the motion scheme will name one. Options until then: (a, - recommended) `springs.settle` (`spec:AST-094` FR4), a spring with no bounce - that suits a layout settle; or (b) hold INV12 until the scheme lands. -- **OQ7 — Where `useAnimatedLayout` and `animatedLayout` are owned.** - (`human-design`) Options: (a, recommended) this record, beside the module, - with each adopting component's record documenting its `animatedLayout` prop; - or (b) a system spec that this record's module serves. From 6fc2ce012ea2188c1af71f79c2a9bf035b9d00fc Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 21:42:10 -0700 Subject: [PATCH 29/33] architecture(react-component-runtime): System model admits the glide; the module never scrolls; scope to reported changes; drop the TreeWalker note --- docs/architecture/react-component-runtime.md | 24 ++++++++------------ 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 31257e7cf8e19..865606f7f7d45 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -69,8 +69,8 @@ A component runtime has four distinct roles: Component and family contracts continue to own public state semantics, controlled and optimistic behavior, focus, announcements, gestures, and other user-facing outcomes. This record owns the shared React lifecycle boundary used to fulfill -those contracts and INV12's caller-visible hook and prop; each adopting component's record -documents its `animatedLayout` prop. +those contracts and INV12's caller-visible hook and prop and the glide they produce; each +adopting component's record documents its `animatedLayout` prop. ## Boundaries and invariants @@ -174,8 +174,8 @@ documents its `animatedLayout` prop. `containerRef` names the area where the walk stops; ScrollableArea does not register itself, and an app passes the ref it already gives ScrollableArea, which reaches the native scroll viewport. In `@astryxdesign/core`, the module - animates the layout shift around any element whose size changes inside an - area: a disclosure opening, content loading in, a message appearing, an item + animates the layout shift around any change that a component holding the + handle reports: a disclosure opening, content loading in, a message appearing, an item inserted or removed. It measures the layout before and after the change and moves the shifted content with a compositor-only translate from its old place to its new one, played through the shared rectangle morph @@ -185,9 +185,7 @@ documents its `animatedLayout` prop. 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`, and descends into a subtree - only for an element with `display: contents`, which has no box; its cost - grows with depth plus following siblings and it needs no TreeWalker, which - visits subtrees and text nodes. It stops at the area and never passes it, so + only for an element with `display: contents`, which has no box; its cost grows with depth plus following siblings. It stops at the area and never passes it, so content outside the area takes its final place at once. The walk only collects candidates; an element moves only when its layout rectangle changed between the two measurements and it intersects the area's visible part, so @@ -195,7 +193,7 @@ documents its `animatedLayout` prop. 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 theme motion role, `springs.settle` - (`spec:AST-094` FR4), until the motion scheme names a public layout role, so the timing is governed centrally and + (`spec:AST-094` FR4), so the timing is governed centrally and is themeable there, never per component; both start on the same frame, and Reduce Motion reaches both through that role, as `design:reduced-motion` DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate @@ -203,7 +201,7 @@ documents its `animatedLayout` prop. elements, and none of it opts in. Several changes, nested or not, compose without wrappers: changes in one area in the same frame are measured together before and after, so each element moves once, by its total change. - The area's scroll position does not change. Each moving element is one the + The module never scrolls the area. Each moving element is one the page already renders, so the module adds no elements (`architecture:component-style-authoring` INV7). The glide applies under every pointer, a mouse included: it removes a layout jump that people @@ -218,8 +216,7 @@ documents its `animatedLayout` prop. "Returns a handle to pass to a component whose size changes, such as TreeList or Collapsible. When that component changes size, it and the content after it inside `containerRef` animate together on one shared - motion role. Fixed and stuck sticky elements stay put, the area keeps its scroll - position, and Reduce Motion is respected." + motion role. Fixed and stuck sticky elements stay put, the module never scrolls the area, and Reduce Motion is respected." Usage: @@ -270,8 +267,7 @@ The following are preferred techniques, not independent compatibility promises: - The size-change hook's documentation states what callers ask about: the changed component and the content after it animate together on one shared motion role; fixed elements and stuck sticky elements stay put; - Reduce Motion is handled; a change stops at its handle's area, so nothing animates twice; and the scroll position is - kept. + Reduce Motion is handled; a change stops at its handle's area, so nothing animates twice; and the module never scrolls the area. - Use an Effect Event when an external-system Effect needs a stable callback that reads the latest props or state without making those reads reactive. Do not use an Effect Event to hide a value whose change should resynchronize the system. @@ -413,5 +409,5 @@ component suite is not blanket conformance with every invariant. | 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, with and without a handle, 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 visible following sibling at any level that jumps, an element moved twice for one frame, an added element, or travel under Reduce Motion fails | | INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | -| INV12 timing | Real-browser frame capture of a changed element and its visible followers under a coarse and a fine pointer, with and without Reduce Motion, with a theme overriding the motion role, and with the area scrolled mid-list | The element and its visible followers starting on different frames or reading different motion, a component-specific timing, Reduce Motion applied to one and not the other, a fine-pointer Reduce Motion change that is not immediate, or a change in the area's scroll position, fails | +| INV12 timing | Real-browser frame capture of a changed element and its visible followers under a coarse and a fine pointer, with and without Reduce Motion, with a theme overriding the motion role, and with the area scrolled mid-list | The element and its visible followers starting on different frames or reading different motion, a component-specific timing, Reduce Motion applied to one and not the other, a fine-pointer Reduce Motion change that is not immediate, or the module scrolling the area, fails | | INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | From 1761e7330d19cb61894d4511f6141b73393b9738 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 22:35:21 -0700 Subject: [PATCH 30/33] architecture(react-component-runtime): INV12's no-handle behavior and its test --- docs/architecture/react-component-runtime.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 865606f7f7d45..885ef4d907f2a 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -169,8 +169,9 @@ adopting component's record documents its `animatedLayout` prop. it to a component whose size changes through that component's `animatedLayout` prop. The component reports each change through an internal, non-exported function that takes the handle, so the handle stays - member-free. Without a handle, content after a change takes its place at - once. + member-free. Without a handle, the component keeps its behavior from before INV12: the + changed element shows its change as its own record says, and content after + it takes its place at once. `containerRef` names the area where the walk stops; ScrollableArea does not register itself, and an app passes the ref it already gives ScrollableArea, which reaches the native scroll viewport. In `@astryxdesign/core`, the module @@ -407,7 +408,7 @@ component suite is not blanket conformance with every invariant. | 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, with and without a handle, 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 visible following sibling at any level that jumps, an element moved twice for one frame, an added element, or travel under Reduce Motion fails | +| INV12 | Real-browser tests on a throttled phone profile plus a source boundary check, with and without a handle, 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, with a handle, a visible following sibling at any level that jumps; without one, content after the change that moves; an element moved twice for one frame, an added element, or travel under Reduce Motion fails | | INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | | INV12 timing | Real-browser frame capture of a changed element and its visible followers under a coarse and a fine pointer, with and without Reduce Motion, with a theme overriding the motion role, and with the area scrolled mid-list | The element and its visible followers starting on different frames or reading different motion, a component-specific timing, Reduce Motion applied to one and not the other, a fine-pointer Reduce Motion change that is not immediate, or the module scrolling the area, fails | | INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | From c3d4e454d19306cd1fae5b83dc52444b90d1a6c1 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 01:01:24 -0700 Subject: [PATCH 31/33] spec(AST-105): the animated-layout public contract moves into a system spec; INV12 keeps the mechanism --- docs/architecture/react-component-runtime.md | 151 ++++------------- docs/specs/AST-105-animated-layout/spec.md | 164 +++++++++++++++++++ 2 files changed, 194 insertions(+), 121 deletions(-) create mode 100644 docs/specs/AST-105-animated-layout/spec.md diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 885ef4d907f2a..31752d8b5cd63 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -68,9 +68,8 @@ A component runtime has four distinct roles: Component and family contracts continue to own public state semantics, controlled and optimistic behavior, focus, announcements, gestures, and other user-facing -outcomes. This record owns the shared React lifecycle boundary used to fulfill -those contracts and INV12's caller-visible hook and prop and the glide they produce; each -adopting component's record documents its `animatedLayout` prop. +outcomes. This record owns only the shared React lifecycle boundary used to fulfill +those contracts. ## Boundaries and invariants @@ -160,115 +159,26 @@ adopting component's record documents its `animatedLayout` prop. does, blurs as FR5 does, applies the height through a functional style on its own elements, and follows INV3 and INV5. The hook follows INV3 and INV5. -- **INV12 — Size changes inside a container have one module.** When - something inside an area changes size, the changed element and the content - after it animate together on one shared motion role instead of jumping. The - measuring, the walk, and the translate all live inside one internal module. - An app opts in with `useAnimatedLayout({containerRef})`, which returns an - opaque `animatedLayout` handle with no public methods or fields, and passes - it to a component whose size changes through that component's - `animatedLayout` prop. The component reports each change through an - internal, non-exported function that takes the handle, so the handle stays - member-free. Without a handle, the component keeps its behavior from before INV12: the - changed element shows its change as its own record says, and content after - it takes its place at once. - `containerRef` names the area where the walk stops; ScrollableArea does not - register itself, and an app passes the ref it already gives ScrollableArea, - which reaches the native scroll viewport. In `@astryxdesign/core`, the module - animates the layout shift around any change that a component holding the - handle reports: a disclosure opening, content loading in, a message appearing, an item - inserted or removed. It measures the layout before and after the change and - moves the shifted content with a compositor-only translate from its old - place to its new one, played through the shared rectangle morph - (`architecture:layer-runtime` INV15; `spec:AST-094` FR5); it never animates - height, which lays out every frame. From the changed element, the module - walks up each 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`, and descends into a subtree - only for an element with `display: contents`, which has no box; its cost grows with depth plus following siblings. It stops at the area and never passes it, so - content outside the area takes its final place at once. The walk only - collects candidates; an element moves only when its layout rectangle changed - between the two measurements and it intersects the 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 theme motion role, `springs.settle` - (`spec:AST-094` FR4), so the timing is governed centrally and - is themeable there, never per component; both start on the same frame, and - Reduce Motion reaches both through that role, as `design:reduced-motion` - DR1–DR3 require: a cross-fade under a coarse primary pointer, an immediate - change otherwise. Shifted content may mix Astryx components and plain app - elements, and none of it opts in. Several changes, nested or not, compose - without wrappers: changes in one area in the same frame are measured - together before and after, so each element moves once, by its total change. - The module never scrolls the area. Each moving element is one the - page already renders, so the module adds no - elements (`architecture:component-style-authoring` INV7). The glide applies - under every pointer, a mouse included: it removes a layout jump that people - using a fine pointer see too, not a touch gesture, so it is a named - fine-pointer change under `spec:AST-017` FR59, and adopting pull requests - show fine-pointer evidence before and after. `useAnimatedLayout` and the - `animatedLayout` prop are the public pieces. They pass `spec:AST-002` FR5: - two otherwise identical pages differ in whether content after a size change - glides or jumps, and in which container bounds it, which only the caller - knows. The handle is configuration, not a rendered slot; no other API does this - job; and it is not a visual boolean. Reporting a change is not exported. The hook's documentation reads: - "Returns a handle to pass to a component whose size changes, such as - TreeList or Collapsible. When that component changes size, it and the - content after it inside `containerRef` animate together on one shared - motion role. Fixed and stuck sticky elements stay put, the module never scrolls the area, and Reduce Motion is respected." - - Usage: - - ```tsx - const scrollRef = useRef(null); - const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); - - -

Notes

-
; - ``` - -### Size-change coverage - -What INV12 moves inside an area, each case with its test: - -| Case | Behavior | Test | -| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | -| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | -| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | -| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | -| Floats | Moves when its rectangle changed | A float after the changed element | -| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | -| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | -| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | -| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | -| Portals and top-layer content | Never moved | A popover open from a following row | -| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | -| Nested areas | The walk stops at the area of the handle the changed component holds; an inner area moves as one unit of an outer walk | An area inside an area, changed in the inner one | - -Out of coverage, each taking its final place at once: - -- content outside the area, and content in another document or an iframe; -- inline text and other non-replaced inline content, which cannot take a - transform; -- flex or grid items earlier in the DOM that `order` places after the change, - since the walk visits only following siblings; -- text reflowing inside a paragraph beside a float; -- elements the app moves itself with its own transforms; -- an anchored popover tied to a moving element, which layer anchoring keeps - in place (`architecture:layer-runtime`). +- **INV12 — Size changes inside a container have one module.** In + `@astryxdesign/core`, one internal module produces the size-change glide + `spec:AST-105` defines. It measures the layout before and after a reported + change and moves shifted content with a compositor-only translate played + through the shared rectangle morph (`architecture:layer-runtime` INV15; + `spec:AST-094` FR5); it never animates height. Its walk visits only the + following element siblings at each ancestor level up to the area, through + `parentElement` and `nextElementSibling`, descending only into an element + with `display: contents`, so its cost grows with depth plus following + siblings. Changes in one area in the same frame are measured together. A + component reports a change through an internal, non-exported function that + takes the `useAnimatedLayout` handle, so the handle stays member-free, and + each moving element is one the page already renders + (`architecture:component-style-authoring` INV7). The module follows INV3 + and INV5. ## Authoring guidance The following are preferred techniques, not independent compatibility promises: -- The size-change hook's documentation states what callers ask about: the - changed component and the content after it animate together on one shared - motion role; fixed elements and stuck sticky elements stay put; - Reduce Motion is handled; a change stops at its handle's area, so nothing animates twice; and the module never scrolls the area. - Use an Effect Event when an external-system Effect needs a stable callback that reads the latest props or state without making those reads reactive. Do not use an Effect Event to hide a value whose change should resynchronize the system. @@ -385,6 +295,8 @@ contracts. component that announces its own rendered content (INV10). - `spec:AST-099/DEC-1` — one keyboard height for every component, kept internal and applied through functional styles (INV11). +- `spec:AST-105/DEC-1` — the size-change glide's public shape and behavior + (INV12). Component, family, API, accessibility, layer, interaction, and style records remain authoritative for the other user-facing outcomes delegated above. @@ -398,17 +310,14 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| 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, with and without a handle, 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, with a handle, a visible following sibling at any level that jumps; without one, content after the change that moves; an element moved twice for one frame, an added element, or travel under Reduce Motion fails | -| INV12 coverage | Real-browser tests, one per Size-change coverage row | Any case behaving other than its row says fails | -| INV12 timing | Real-browser frame capture of a changed element and its visible followers under a coarse and a fine pointer, with and without Reduce Motion, with a theme overriding the motion role, and with the area scrolled mid-list | The element and its visible followers starting on different frames or reading different motion, a component-specific timing, Reduce Motion applied to one and not the other, a fine-pointer Reduce Motion change that is not immediate, or the module scrolling the area, fails | -| INV12 pointer scope | Fine-pointer before-and-after evidence on each adopting pull request (`spec:AST-017` FR59), from the demo or a story | A glide missing under a mouse, or an adopting pull request without fine-pointer evidence, fails | +| Invariant | Evidence | Failure signal | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| 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 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md new file mode 100644 index 0000000000000..a6a9249d3eece --- /dev/null +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -0,0 +1,164 @@ +--- +schema_version: 4 +template_version: 2 +kind: system-spec +id: spec:AST-105 +authority: current +archive_reason: null +superseded_by: null +approved_by: cixzhang +approved_at: 2026-10-25 +phase: accepted +owners: [cixzhang] +affects_architecture: [architecture:react-component-runtime] +affects_families: [] +affects_contributing: [] +affects_consumer_docs: [] +--- + +# Animated layout system spec + + + +```json +{ + "paths": ["packages/core/src/**"] +} +``` + + + +## Intent + +When something inside an area changes size, the changed element and the +content after it animate together on one shared motion role instead of +jumping. An app opts in once per area; without it, nothing changes. + +## Non-goals + +- Content outside the area, and content in another document or an iframe. +- How the glide is produced, which `architecture:react-component-runtime` + INV12 owns. + +## Public contract + +```tsx +const scrollRef = useRef(null); +const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); + + +

Notes

+
; +``` + +## Requirements + +- **FR1 — One hook, one handle, one prop.** `useAnimatedLayout({containerRef})` + returns an opaque `animatedLayout` handle with no public methods or fields. + An app passes it to a component whose size changes through that + component's `animatedLayout` prop, and the component reports each change. + `containerRef` names the area; ScrollableArea does not register itself, and + an app passes the ref it already gives ScrollableArea, which reaches the + native scroll viewport. Each adopting component's record documents its + `animatedLayout` prop. +- **FR2 — Without a handle, nothing changes.** A component without a handle + keeps its behavior: the changed element shows its change as its own record + says, and content after it takes its place at once. +- **FR3 — The content after a change glides within the area.** For any change + a component holding the handle reports, such as a disclosure opening, + content loading in, a message appearing, or an item inserted or removed, + the content after it inside the area moves from its old place to its new + one instead of jumping, at every ancestor level up to the area and never + past it. An element moves only when its layout rectangle changed and it is + in the area's visible part. Which elements move is listed under Coverage. + Shifted content may mix Astryx components and plain app elements, and none + of it opts in. Several changes in one area, nested or not, compose: each + element moves once, by its total change. The area never scrolls because of + the glide. +- **FR4 — One shared motion.** The changed element plays its own animation, + entering, exiting, or resizing, and the content after it moves with it. + Both read the same theme motion role, `springs.settle` (`spec:AST-094` + FR4), so the timing is governed centrally, never per component; both start + on the same frame, and Reduce Motion reaches both through that role, as + `design:reduced-motion` DR1–DR3 require: a cross-fade under a coarse + primary pointer, an immediate change otherwise. +- **FR5 — Every pointer.** The glide applies under every pointer, a mouse + included: it removes a layout jump that people using a fine pointer see + too, so it is a named fine-pointer change under `spec:AST-017` FR59, and + adopting pull requests show fine-pointer evidence before and after. +- **FR6 — Admission.** Two otherwise identical pages differ in whether content + after a size change glides or jumps, and in which container bounds it, + which only the caller knows (`spec:AST-002` FR1, FR5). The handle is + configuration, not a rendered slot; no other API does this job; and it is + not a visual boolean. +- **FR7 — Documentation.** The hook's documentation reads: "Returns a handle + to pass to a component whose size changes, such as TreeList or Collapsible. + When that component changes size, it and the content after it inside + `containerRef` animate together on one shared motion role. Fixed and stuck + sticky elements stay put, the module never scrolls the area, and Reduce + Motion is respected." + +## Coverage + +What moves inside an area, each case with its test: + +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | +| Floats | Moves when its rectangle changed | A float after the changed element | +| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | +| Nested areas | The walk stops at the area of the handle the changed component holds; an inner area moves as one unit of an outer walk | An area inside an area, changed in the inner one | + +Out of coverage, each taking its final place at once: + +- content outside the area, and content in another document or an iframe; +- inline text and other non-replaced inline content, which cannot take a + transform; +- flex or grid items earlier in the DOM that `order` places after the change, + since the walk visits only following siblings; +- text reflowing inside a paragraph beside a float; +- elements the app moves itself with its own transforms; +- an anchored popover tied to a moving element, which layer anchoring keeps + in place (`architecture:layer-runtime`). + +## Current-state impact + +- No Core component takes `animatedLayout` yet; TreeList and Collapsible are + the first adopters. + +## Verification + +| Contract | Verification | Representative states | Mutation or failure expectation | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR2 | Unit and browser tests with and without a handle | a handle on TreeList; no handle; a ScrollableArea ref | a handle with public members, behavior that changes without a handle, or ScrollableArea registering itself fails | +| FR3, Coverage | Real-browser tests, one per Coverage row | each Coverage row; mixed Astryx and plain content; two changes in one frame; an area scrolled mid-list | an element that behaves other than its row says, an element moved twice for one frame, content past the area that moves, or the area scrolling fails | +| FR4 | Real-browser frame capture under a coarse and a fine pointer, with and without Reduce Motion, and with a theme overriding the motion role | a changed element and its visible followers | starting on different frames or reading different motion, a component-specific timing, or Reduce Motion applied to one and not the other fails | +| FR5 | Fine-pointer before-and-after evidence on each adopting pull request, from the component's Storybook story | a mouse on each adopting component | a glide missing under a mouse, or an adopting pull request without that evidence, fails | + +## Decision log + +### DEC-1 — One opt-in handle per area; the content after a change glides + +**Reference:** `spec:AST-105/DEC-1` +**Decider:** `cixzhang`, `2026-10-25` + +Content that jumps when something above it changes size is disorienting, on +any pointer. An app passes one handle per area to the components whose size +changes, so the opt-in is explicit and composes without wrappers; the +content after a change glides on one shared motion role with the changed +element, inside the area and only where it is visible. + +Rejected: wrapping following content in an element, and opting in each +following element. + +## Open questions + +None. From 879f50e76d864fc8a37264f953fbe7c4be3b6b1c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 03:14:15 -0700 Subject: [PATCH 32/33] spec(AST-105): the motion role is not themeable yet (AST-094 DEC-11) --- docs/specs/AST-105-animated-layout/spec.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index a6a9249d3eece..e1caeb10ec022 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -77,7 +77,7 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); the glide. - **FR4 — One shared motion.** The changed element plays its own animation, entering, exiting, or resizing, and the content after it moves with it. - Both read the same theme motion role, `springs.settle` (`spec:AST-094` + Both read the same motion role, `springs.settle` (`spec:AST-094` FR4), so the timing is governed centrally, never per component; both start on the same frame, and Reduce Motion reaches both through that role, as `design:reduced-motion` DR1–DR3 require: a cross-fade under a coarse @@ -136,12 +136,12 @@ Out of coverage, each taking its final place at once: ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1, FR2 | Unit and browser tests with and without a handle | a handle on TreeList; no handle; a ScrollableArea ref | a handle with public members, behavior that changes without a handle, or ScrollableArea registering itself fails | -| FR3, Coverage | Real-browser tests, one per Coverage row | each Coverage row; mixed Astryx and plain content; two changes in one frame; an area scrolled mid-list | an element that behaves other than its row says, an element moved twice for one frame, content past the area that moves, or the area scrolling fails | -| FR4 | Real-browser frame capture under a coarse and a fine pointer, with and without Reduce Motion, and with a theme overriding the motion role | a changed element and its visible followers | starting on different frames or reading different motion, a component-specific timing, or Reduce Motion applied to one and not the other fails | -| FR5 | Fine-pointer before-and-after evidence on each adopting pull request, from the component's Storybook story | a mouse on each adopting component | a glide missing under a mouse, or an adopting pull request without that evidence, fails | +| Contract | Verification | Representative states | Mutation or failure expectation | +| ------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR2 | Unit and browser tests with and without a handle | a handle on TreeList; no handle; a ScrollableArea ref | a handle with public members, behavior that changes without a handle, or ScrollableArea registering itself fails | +| FR3, Coverage | Real-browser tests, one per Coverage row | each Coverage row; mixed Astryx and plain content; two changes in one frame; an area scrolled mid-list | an element that behaves other than its row says, an element moved twice for one frame, content past the area that moves, or the area scrolling fails | +| FR4 | Real-browser frame capture under a coarse and a fine pointer, with and without Reduce Motion | a changed element and its visible followers | starting on different frames or reading different motion, a component-specific timing, or Reduce Motion applied to one and not the other fails | +| FR5 | Fine-pointer before-and-after evidence on each adopting pull request, from the component's Storybook story | a mouse on each adopting component | a glide missing under a mouse, or an adopting pull request without that evidence, fails | ## Decision log From 0c0c8f4efcffdb6b412deb6d2bbd8dbac21f78be Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 04:57:32 -0700 Subject: [PATCH 33/33] spec(AST-105): observable wording, ScrollableArea label, consumer docs; runtime lists AST-105/DEC-1 --- docs/architecture/react-component-runtime.md | 2 +- docs/specs/AST-105-animated-layout/spec.md | 40 ++++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 31752d8b5cd63..55aa43644fe40 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -15,7 +15,7 @@ verified_by: packages/core/src/Layer/useLayer.test.tsx, packages/core/src/utils/sharedResizeObserver.test.ts, ] -deciding_specs: [spec:AST-086/DEC-2, spec:AST-099/DEC-1] +deciding_specs: [spec:AST-086/DEC-2, spec:AST-099/DEC-1, spec:AST-105/DEC-1] --- # React component runtime architecture diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index e1caeb10ec022..da6e3b060e54c 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -13,7 +13,7 @@ owners: [cixzhang] affects_architecture: [architecture:react-component-runtime] affects_families: [] affects_contributing: [] -affects_consumer_docs: [] +affects_consumer_docs: [useAnimatedLayout, TreeList, Collapsible] --- # Animated layout system spec @@ -45,7 +45,7 @@ jumping. An app opts in once per area; without it, nothing changes. ```tsx const scrollRef = useRef(null); const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); - +

Notes

; @@ -95,27 +95,27 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); to pass to a component whose size changes, such as TreeList or Collapsible. When that component changes size, it and the content after it inside `containerRef` animate together on one shared motion role. Fixed and stuck - sticky elements stay put, the module never scrolls the area, and Reduce + sticky elements stay put, the glide never scrolls the area, and Reduce Motion is respected." ## Coverage What moves inside an area, each case with its test: -| Case | Behavior | Test | -| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | -| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | -| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | -| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | -| Floats | Moves when its rectangle changed | A float after the changed element | -| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | -| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | -| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | -| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | -| Portals and top-layer content | Never moved | A popover open from a following row | -| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | -| Nested areas | The walk stops at the area of the handle the changed component holds; an inner area moves as one unit of an outer walk | An area inside an area, changed in the inner one | +| Case | Behavior | Test | +| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Normal-flow block siblings after the changed element, at each ancestor level up to the area | Moves when its rectangle changed | Nested changed element with block siblings at three levels | +| An element with `display: contents` | Has no box; its children are taken in its place | A following `display: contents` wrapper with two children | +| Absolutely positioned or fixed elements | Skipped and take their final place; an absolute child of a moving element rides with it | One of each, plus an absolute badge on a moving row | +| Sticky elements | In flow while unstuck, so moves when its rectangle changed; a stuck element takes its final place | A sticky header above and below its stuck point | +| Floats | Moves when its rectangle changed | A float after the changed element | +| Flex and grid items after the changed element, including wrap and row direction | Moves when its rectangle changed | A wrapping flex row and a row-direction neighbor that does not shift | +| Hidden, `display: none`, or zero-size elements | Skipped | One of each after the changed element | +| Changed elements outside the area's visible part | Take their final place without animation | A long list with changed rows above and below the fold | +| Elements under a scaled or transformed ancestor | Moves by its layout change, corrected for the ancestor's scale | A moving row inside a scaled panel | +| Portals and top-layer content | Never moved | A popover open from a following row | +| Elements already moving | Keep their running motion and add the new move to it | Two changes 100 ms apart | +| Nested areas | Motion stops at the area of the handle the changed component holds; an inner area moves as one unit within an outer one | An area inside an area, changed in the inner one | Out of coverage, each taking its final place at once: @@ -123,7 +123,7 @@ Out of coverage, each taking its final place at once: - inline text and other non-replaced inline content, which cannot take a transform; - flex or grid items earlier in the DOM that `order` places after the change, - since the walk visits only following siblings; + since only content after the change in the DOM moves; - text reflowing inside a paragraph beside a float; - elements the app moves itself with its own transforms; - an anchored popover tied to a moving element, which layer anchoring keeps @@ -132,7 +132,9 @@ Out of coverage, each taking its final place at once: ## Current-state impact - No Core component takes `animatedLayout` yet; TreeList and Collapsible are - the first adopters. + the first adopters, and their docs gain the `animatedLayout` prop. +- Core gains the `useAnimatedLayout` export and its documentation (FR7). +- `architecture:react-component-runtime` INV12 produces the glide. ## Verification