Skip to content

feat(core): one shared edge reveal in the animated-layout module, which the SideNav rail moves onto - #8721

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/f1-glide-on-morph
Nov 1, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/f1-glide-on-morph

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Implements the records #8783 put on main: spec:AST-105 FR2 and FR9, architecture:react-component-runtime INV12, component:SideNav FR6, component:Resizable FR8, component:LayoutPanel FR7 and AR2, and component:Table FR15. Resizable's and LayoutPanel's slide and LayoutPanel's focus rule (AR2: focus moves to the ResizeHandle only when a collapsing pane holds it) are built on this path in #9197 and #9202. This PR moves no focus anywhere.

Behavior-module PR: packages/core/src/motion/animatedLayout.ts (FR9's shared edge reveal).

User need

A panel that collapses beside content, a Collapsible closed while it opens, a resizable pane sliding shut: each reveals or conceals an element from one of its edges while the content beside it moves. Before this change Core had one such reveal of its own per component. The SideNav rail clipped and rode its content in its own code. Collapsible's reveal could only open downward, and a close while it opened restarted the conceal from fully shown while the content below kept gliding, so a gap opened. Nothing a resize controller could reuse existed. #8783 made these one shared edge reveal in the animated-layout module.

Problem and solution fit

The animated-layout module now produces spec:AST-105 FR9's edge reveal, in three forms over one ride:

  • One-state (playLayoutChange, holdLayoutExit): reveals and conceals from the block-end edge by default, or from the block-start or an inline edge it is given, mirrored right to left. Once the content on that edge's side glides, the moving edge rides that glide frame by frame (its keyframes replaced, its clock the glide's), so no gap opens. It rides the content's offset relative to the element: when the same batch also moves the element itself, such as an item above them closing in a single-open CollapsibleGroup, the edge follows only the difference. A conceal by a start edge frees the element's space on that edge's side, so the element stays where it shows while the content before it moves; a layout where that would move the element itself is not held, and the element goes at once. A reversal starts from what the element shows. Collapsible, TreeList and Table's row expansion already play this block-end form, so they gain the ride, and Collapsible and TreeList, which hold their exits, the reversal too.
  • Two-state (holdLayoutEdge, playLayoutEdge): holds the old state as FR8's swap copy, clips the larger state on the edge from the extent it shows, rides a gliding element, and raises the element above the copy while it conceals. The SideNav rail now plays through it on its inline-end edge and keeps only its labels' and icons' fades (component:SideNav FR6); its frames are unchanged.
  • A resized element with no copy (reportLayoutEdge): measured before a size change commits and played after the new size applies. A larger extent reveals from the extent shown. A smaller one holds the element at the extent it was laid out at, freeing the difference by the margin on that edge's side so what lies beyond stays where the new size puts it, then clips down to the new extent. This is the path a resize controller's pane uses (component:Resizable FR8 and component:LayoutPanel FR7 in refactor(Resizable): the resize model's pure parts live in one non-React module #9197 and feat(Resizable): deprecate useResizable's configuration-object form for useResizable(handleRef, options) #9202).

Reduce Motion follows design:reduced-motion DR2 under a coarse primary pointer and DR3 otherwise.

Current specification

spec:AST-105 FR9 ("One shared edge reveal") and FR2, architecture:react-component-runtime INV12, component:SideNav FR6, component:Resizable FR8, component:LayoutPanel FR7 and AR2, and component:Table FR15, all current on main since #8783. A LayoutPanel given a hand-built ResizableProps gets no slide, and the hook's own props passed through resizable slide; both are #9197 and #9202's to implement.

Public delta and caller burden

None. The new functions and types stay internal: no entry point exports them (animatedLayout.boundary.test.ts checks this), and useAnimatedLayout's contract is unchanged. The visible changes are Collapsible's and TreeList's: content closed while it opens, or opened while it closes, continues from what it shows, and its edge stays on the content after it ([fix] changeset). Table's row expansion reveal rides the rows after it.

Design and alternatives

  • Riding: the edge's keyframes are sampled on the glide's own sample grid and clock, so the clip and the glide are complementary by construction. The alternative, a separate spring tuned to match, drifts whenever the glide carries a running motion.
  • Resized element without a copy: holding the old extent keeps the pane's content laid out as it was. Clipping a pane already at its new size would need its content to overflow, or a copy, which the runtime record rules out for a resize controller.
  • Morph routing of the glide stays out until the shared rect morph lands (feat(core): one rect morph for touch presentations that grow out of a source rect #8329), as INV12's "until then" clause says.

Evidence

Status at this head: rebased onto main 8a03d75 after the component records moved to docs/specs/components/; the patch is byte-identical to 70ebcc7's. Here, build, type check, CI-strict lint, check:knowledge, the record-path tests (knowledge paths, check-knowledge, specs-for-file), check:changesets and the motion, SideNav and AppShell unit suites (1,020) pass, and specs:for still returns component:SideNav and spec:AST-105 for the changed files. At 70ebcc7 every gate finished: test:ui, check:repo, the node suites (only the two cache.test.mjs lock cases that fail on main), and the three motion specs ×10 in WebKit under the CI job's command (740, with 100 Chromium-only skips) and in Chromium pinned to 2 cores at 4 workers (840). Still running: test:ui, the node suites, check:repo and the three motion specs ×10 in both engines at this head.

Each new proof failed on main first:

  • animatedLayout.edge.test.ts: 17 of the 18 one- and two-state cases fail on main (the block-end default passes there and guards it), and the resized-element cases have no function there.
  • SideNavRailMotion.test.tsx: a new case asserts the rail plays through the module's two-state form with no clip, copy or ride of its own. It fails on main; the rail's existing cases pass unchanged with the rail moved.
  • animatedLayout.interaction.browser.spec.ts (FR9): a block-end reveal reversed into a conceal, an inline-end reveal and conceal (LTR and RTL), the two-state form, and a resized pane (start pane LTR to 48 px, start pane RTL to 0, end pane LTR to 48), each with a reversal. On every frame the moving edge stays within 1.5 px of what it rides. All fail on main: the block-end reversal jumped 40 px, the inline edge was clipped from the bottom, and the other forms don't exist there.
  • animatedLayoutAdopters.interaction.browser.spec.ts: a Collapsible closed while it opens, and a TreeList row collapsed while it expands, keep their edge on the content after them. On main each conceal restarted from fully shown: −16 px and −36 px gaps.

Two more cases guard this PR's own paths. A block-start reveal and conceal in a column that stacks from its end keep the edge on the content before it, with the element staying where it shows; before the start-edge fix the conceal freed its space at its end, so the element jumped by its 80 px and an 80 px gap opened. And in a single-open CollapsibleGroup switching from a taller item to a shorter one below it, the opening item's edge stays on the next item on every frame; before the ride was taken relative to the element, the content showed 31 px over the next item.

How the proofs read a ride: both sides on the module's clock, the edge from its clip animation's keyframes and what it rides from its layout position plus its glide's keyframes, at the animations' current time. Chromium also asserts that its evaluated clip and rect equal those (under 0.5 px). In WebKit the two run a little apart, and that skew is reported per test, not hidden: a compositor-run translate is evaluated a little ahead of the frame and, on a late first frame, a main-thread clip reads one frame behind. Under the CI WebKit job's command (LP_NUM_THREADS=4), the summed skew per frame reached 0.7 to 3.4 px depending on the test. The reversals pause and seek every animation to 120 ms, and the adopters' wait for the component to commit the close before playing on, so neither a late first frame nor a component committing in a later task moves the moment they test.

Scope

Overlaps: #8677 also edits motion/animatedLayout.ts (the theme's motion knobs), and #3890 edits SideNav/SideNav.tsx. This PR leaves SideNav.tsx unchanged.

Testing

pnpm exec vitest run --project ui packages/core/src/motion packages/core/src/SideNav packages/core/src/AppShell packages/core/src/Collapsible packages/core/src/TreeList packages/core/src/Table
pnpm build && pnpm storybook:build
pnpm test:interaction packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts packages/core/src/SideNav/__tests__/SideNavRailMotion.interaction.browser.spec.ts --project chromium

For WebKit, use the CI WebKit job's container command (internal/interaction-harness/scripts/webkit-container.mjs) with podman --cpus=4, CI=1, LP_NUM_THREADS=4 and 1 worker.

@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 1, 2026 6:37am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 6:46 AM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested. This pull request's own change is the 278-line glide routing in animatedLayout.ts and rectMorph.ts, but its diff against main carries the whole #8329 stack (19,553 authored lines), so please retarget it onto #8329 or rebase once #8329 lands. Two fixes are needed in the glide itself:

  • A retarget keeps only the velocity along the new straight path, so in a wrapping flex row or a grid an element caught mid-glide by a sideways move turns abruptly, where spec:AST-105's Coverage row "Elements already moving" says it keeps its running motion; please carry the full velocity and add an out-of-line case to the retarget test.
  • The rect morph sets transform-origin inline and removes it later, so a shifted element's own inline transform-origin is lost after its first glide; set it only when the path scales, or restore the element's value.

The comment at animatedLayout.ts:494-497 still describes the old curve summing.

[Automated review]

Comment on lines +572 to +576
const travel = fromX * fromX + fromY * fromY;
const velocity =
travel > 0
? -(entry.velocity.x * fromX + entry.velocity.y * fromY) / travel
: 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This keeps only the part of the running velocity that lies along the new straight path. When the new move is out of line with the running glide (a wrapping flex row or a grid), the element turns at this frame instead of keeping its running motion, which spec:AST-105's Coverage row "Elements already moving" asks for: a glide down at 539 px/s whose place moves 160 px sideways 100 ms in restarts at (211, 102) px/s. Carrying the full velocity, for example a start velocity per axis for a path that keeps its size, plus an out-of-line case in the retarget test, would close this.

Comment on lines 495 to 497
// running glide stops (so its rectangle is its new layout place) and the
// rest of its curve carries into the one new glide, velocity included,
// summed with the new move.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This comment still describes the curve summing this change removes; the code below now keeps only the velocity along the new move, as the file header says.

Comment thread packages/core/src/motion/rectMorph.ts Outdated
ownsOpacity ||= start.opacityAt != null;
// A dismissal on a copy let the element go (dismiss, below).
animator.attach(element);
element.style.setProperty('transform-origin', '0px 0px');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On a layout glide this element is page content that never opted in (spec:AST-105 FR3). This line overwrites any inline transform-origin it has, and clearInline later removes the property, so the element's own value is gone after its first glide. A path that keeps its size plays no scale and needs no origin, so setting it only when the path scales, or restoring the element's own value, would avoid the loss.

@vjeux

vjeux commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. This pull request depends on #8329, and its own change is vjeux/astryx@d0f24e1...cf20a38. #8329's branch lives on a fork, which a pull request here can't target, so this one stays against main and updates from it once #8329 lands.

  • Retarget: fixed. The rest of a running glide now plays on top of the new move (the rect morph's carry), so an element caught mid-glide keeps its speed and direction. The retarget test has an out-of-line case, a row gliding down that a sideways move catches mid-glide, which fails on the previous head.
  • transform-origin: fixed. The morph writes 0 0 only for a path that scales and gives the element's own value back; a test covers a move and a scale.
  • The comment at animatedLayout.ts:494-497 now describes the carried glide.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested on scope: the diff against main still carries the whole #8329 stack, 19,768 authored lines of which this change is 509, so please update the branch from main once #8329 lands. The glide change itself now looks right: a retargeted glide keeps its running motion in any direction, the morph leaves an element's own transform-origin alone, and the retarget comment matches the code.

[Automated review]

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 27, 2026
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · f20b096

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC
PowerSearch 174.5 kB (+1011 B) · 20.3 kB CSS (0) —
Collapsible 20.6 kB (+920 B) · 4.5 kB CSS (0) —
TreeList 42.6 kB (+869 B) · 5.0 kB CSS (0) —
6 more components
Component Size LoC
SideNav 67.7 kB (+388 B) · 10.9 kB CSS (0) —
AppShell 71.5 kB (+3 B) · 12.0 kB CSS (0) —
SideNavCollapseButton 49.5 kB (0) · 9.8 kB CSS (0) —
SideNavHeading 61.5 kB (0) · 10.5 kB CSS (0) —
SideNavItem 57.6 kB (0) · 10.4 kB CSS (0) —
SideNavSection 6.6 kB (0) · 3.7 kB CSS (0) —

Package size: measured; main has no measurement of the merge base to compare yet.

API changes: core +8 · ~20 · −0 (report stale: run pnpm api:report); richtext +0 · ~1 · −0 (report stale: run pnpm api:report)

29 API changes (advisory)
Package Where Signature Usage
+ core . · Types · interface FieldStatusProps message: string import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {message: …}
+ core . · Types · interface FieldStatusProps ref?: React.Ref<HTMLDivElement> import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {ref: …}
+ core . · Types · interface FieldStatusProps type: InputStatusType import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {type: …}
+ core . · Types · interface FieldStatusProps variant?: FieldStatusVariant = 'attached' import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {variant: …}
+ core . · Types · interface FieldStatusProps xstyle?: StyleXStyles import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {xstyle: …}
+ core . · Types · interface FieldStatusProps inherits AriaAttributes, DOMAttributes, HTMLAttributes import type {FieldStatusProps} from '@astryxdesign/core'
+ core . · Types · interface SearchContext interface SearchContext<T extends SearchableItem = SearchableItem> import type {SearchContext} from '@astryxdesign/core'
+ core ./Typeahead · Also exported from an earlier entry point · SearchContext SearchContext (.) import {SearchContext} from '@astryxdesign/core/Typeahead'
~ core . · Components · component ComplexSelector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary and ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary and ghost selectors <ComplexSelector statusVariant="attached" />
~ core . · Components · component DateInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <DateInput statusVariant="attached" />
~ core . · Components · component DateRangeInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <DateRangeInput statusVariant="attached" />
~ core . · Components · component Field prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Field statusVariant="attached" />
~ core . · Components · component FieldStatus component FieldStatus(props: FieldStatusProps) → component FieldStatus @deprecated <FieldStatus />
~ core . · Components · component FieldStatus prop variant?: FieldStatusVariant = 'attached' → prop variant: FieldStatusVariant = 'attached' <FieldStatus variant="attached" />
~ core . · Components · component FileInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <FileInput statusVariant="attached" />
~ core . · Components · component MultiSelector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary and ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary and ghost selectors <MultiSelector statusVariant="attached" />
~ core . · Components · component NumberInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <NumberInput statusVariant="attached" />
~ core . · Components · component PowerSearch prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <PowerSearch statusVariant="attached" />
~ core . · Components · component Selector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary/ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary/ghost selectors <Selector statusVariant="attached" />
~ core . · Components · component TextArea prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TextArea statusVariant="attached" />
~ core . · Components · component TextInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TextInput statusVariant="attached" />
~ core . · Components · component TimeInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TimeInput statusVariant="attached" />
~ core . · Components · component Tokenizer prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Tokenizer statusVariant="attached" />
~ core . · Components · component Typeahead prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Typeahead statusVariant="attached" />
~ core . · Types · interface FieldStatusProps interface FieldStatusProps extends BaseProps<HTMLDivElement> (props of FieldStatus) → interface FieldStatusProps extends BaseProps<HTMLDivElement> import type {FieldStatusProps} from '@astryxdesign/core'
~ core . · Types · interface FieldStatusVariantMap tooltip: true → tooltip: true @deprecated import type {FieldStatusVariantMap} from '@astryxdesign/core'; const x: FieldStatusVariantMap = {tooltip: …}
~ core . · Types · interface SearchSource search: (query: string) => Promise<T[]> | T[] → search: (query: string, context?: SearchContext<T>) => Promise<T[]> | T[] import type {SearchSource} from '@astryxdesign/core'; const x: SearchSource = {search: …}
~ core . · Types · interface UseInputStatusIconOptions statusVariant?: FieldStatusVariant → statusVariant?: FieldStatusPlacement import type {UseInputStatusIconOptions} from '@astryxdesign/core'; const x: UseInputStatusIconOptions = {statusVariant: …}
~ richtext . · Components · component RichTextEditor prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusVariant | 'tooltip' = 'attached' <RichTextEditor statusVariant="tooltip" />

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Merging main here isn't mechanical: animatedLayout.ts now carries a running glide's motion itself and exports glideOf for Table (#8656). What's left of this PR, playing the glide through the shared rect morph (INV12), would mean rebuilding that handle on the morph. Does INV12's rect-morph clause still stand? If not, I'll close this; if it does, I'll rework it as a narrow follow-up that keeps glideOf.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

A heads-up on this conflict: it isn't only textual. Main now has the in-place swap in motion/animatedLayout.ts (#8734, spec:AST-105 FR8), and the shipped AppShell/SideNav rail collapse (#8723) depends on it, along with the follower riding the glide. When you merge main, please keep the swap and the rail's behavior intact, routing the size-change glide through the rect morph without breaking them. SideNav's rail-motion tests and the rail-collapse browser spec are the oracle. An edge-axis update to the same module is also in spec review (#8783), so it's worth a look before you restructure.

@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, will do. I'll rework this onto main keeping the in-place swap (#8734), the rail collapse (#8723) and the follower's ride intact, with SideNav's rail-motion tests and the rail-collapse browser spec as the oracle, and I'll read #8783 first. The glide keeps the glideOf and offsetAt contracts, so Table's dividers (#8656) and riders still follow it.

@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Routing the glide through the rect morph would break three things your module now guarantees and tests: the rail's one clock (the animator holds a curve's end for 60 s), the swap's parts sampling frame for frame with the glide, and #8639's single layout pass (the animator reads each element's style as it starts it). Making the morph honor them means giving it your sampler, so it would share only a name. Since the module already plays a compositor-only translate and keeps a retargeted glide's velocity, shall I drop INV12's rect-morph clause and close this?

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:39
@vjeux
vjeux force-pushed the uikit/f1-glide-on-morph branch from cf20a38 to 14bcc93 Compare October 31, 2026 23:07
@vjeux vjeux changed the title feat(core): the size-change glide plays through the shared rect morph feat(core): one shared edge reveal in the animated-layout module, which the SideNav rail moves onto Oct 31, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an engineering owner's decision before it can be approved. spec:AST-105 FR9, which covers the edges other than block-end and the resized-element path, is not on main yet, so either #8783 lands first or an engineering owner approves that direction here by commenting /approve-spec 14bcc93db8f5c08542e9f7a5bec1ddd996ce9688 (the author can do this as an engineering owner). Whatever the decision, three changes are needed:

  • When two changes compose, as in a CollapsibleGroup in single mode switching items, the edge rides the neighbour's whole glide offset, so please ride it relative to the glide that moves the element (animatedLayout.ts:1401-1407) and add an exclusive-group case, since the opening content now overlaps the next item by about 72 px at 60 ms.
  • The changeset says a reversal always continues from what it shows, but under Reduce Motion with a coarse pointer the cross-fade still restarts, so please track the fade or qualify that sentence.
  • At 2,809 authored lines the PR is over the size threshold, so please either say in the description that it is a behavior-module PR for motion/animatedLayout.ts or split it.

[Automated review]

}
// The neighbour's offset from its place, towards the edge's side.
const sign = isEnd ? 1 : -1;
const hidden = (t: number): number => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This uses the neighbour glide's whole offset. When another change in the same batch also moves the element (its root glides, as with a CollapsibleGroup in single mode switching from an upper item to a lower one), the edge is off by that change's remaining travel: about 72 px of overlap at 60 ms in Chromium, where the base is flush. Subtracting the offset of the glide that moves the element itself (its own, or its nearest gliding ancestor's) before hiddenAt would keep the two together.

Comment thread .changeset/edge-reveal-reversal.md Outdated
'@astryxdesign/core': patch
---

[fix] With an `animatedLayout` handle, a Collapsible or a TreeList row closed while it opens, or opened again while it closes, continues from what it shows: the content's edge stays on the content after it on every frame, instead of jumping back to fully shown or hidden.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under Reduce Motion with a coarse pointer, neither cross-fade is tracked (playLayoutChange's fade returns early, and holdLayoutExit tracks only its spring branch), so a reversal still restarts at opacity 1 or 0. Please either track the fade or qualify this sentence.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: the shared edge reveal fixes a Collapsible or TreeList row reversed mid-motion, but its ride regresses when another change in the same batch moves the revealed element. Please address the points below and add a browser case for an exclusive CollapsibleGroup switch between items of unequal height.

  • rideNeighbour uses the neighbour glide's whole offset, so in a CollapsibleGroup (single) with a handle, opening an item while a taller one above it closes shows its content in full over the next trigger (about 52 px at 60 ms) where main stays flush; subtract the offset of the glide that moves the element itself.
  • The changeset says a reversal always continues from what it shows, but under Reduce Motion with a coarse pointer the cross-fades are not tracked and still restart; track them or qualify the sentence.
  • At 2,874 authored lines, please declare this a behavior-module PR naming packages/core/src/motion/animatedLayout.ts in the description, or split it.

[Automated review]

Comment on lines +1401 to +1407
const hidden = (t: number): number => {
const offset = glide.offsetAt(t);
return hiddenAt(
sign *
(edge.startsWith('inline') ? (rtl ? -offset.x : offset.x) : offset.y),
);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This uses the neighbour glide's whole offset. When another change in the same batch also moves the revealed element (an ancestor glides, as in a CollapsibleGroup in single mode switching between items of unequal height), the edge is off by that glide's remaining travel: the opening content shows in full over the next trigger, and a closing one is cut short. Subtract the offset of the glide that moves the element itself (its own, or its nearest gliding ancestor's) at the same t.

Comment thread .changeset/edge-reveal-reversal.md Outdated
'@astryxdesign/core': patch
---

[fix] With an `animatedLayout` handle, a Collapsible or a TreeList row closed while it opens, or opened again while it closes, continues from what it shows: the content's edge stays on the content after it on every frame, instead of jumping back to fully shown or hidden.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under Reduce Motion with a coarse pointer neither cross-fade is tracked (playLayoutChange returns its fade untracked, and holdLayoutExit tracks only its spring branch), so a reversal there still restarts at opacity 1 or 0. Track the fades as edge motions, or qualify this sentence with "with motion on".

@vjeux
vjeux force-pushed the uikit/f1-glide-on-morph branch from dd3eeec to d76a1a9 Compare November 1, 2026 01:46
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in d76a1a9:

  • The edge now rides the neighbour's offset relative to any glide that moves the revealed element itself, so in a single-open CollapsibleGroup the opening item stays on the next one. New browser case, with the AnimatedLayout story gaining a CollapsibleGroup of unequal heights: it failed on dd3eeec with the content 31 px over the next item, and passes now (Chromium ×5, WebKit ×3).
  • The changeset now says that under Reduce Motion with a touch pointer, where the content cross-fades, a reversal still starts its fade over.
  • The description declares the behavior module, packages/core/src/motion/animatedLayout.ts.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: the shared edge reveal fixes Collapsible and TreeList reversals and moves the SideNav rail onto the module cleanly, but the one-state conceal from a start edge does not work yet. holdLayoutExit with edge: 'block-start' or 'inline-start' still frees the element's space by its end margin, so the element jumps by its own size the moment the conceal starts and a gap opens beside its moving edge (80 px for a block-start conceal in an end-aligned column, 120 px for an end panel's inline-start conceal). Please free the space on the edge's side (as holdExtent does with margin-${edge}) or refuse start edges in holdLayoutExit, and add the block-start reveal-and-conceal browser case that the FR9 Coverage row lists.

[Automated review]

handle: AnimatedLayout | null | undefined,
element: HTMLElement,
done: () => void,
{edge = 'block-end'}: {edge?: LayoutEdge} = {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This option reaches the clip and the ride, but the space is still freed by giveUpSpace below, which always uses the end margin. For block-start or inline-start the content that moves is the content before the element, so the negative end margin moves the element itself by its whole size when the conceal starts. In a Chromium probe, a block-start conceal in an end-aligned column jumped 80 px and kept its shown edge 84 px from the row it rides (4 px at rest), and an end panel's inline-start conceal jumped 120 px. Freeing the start margin against the content before (as holdExtent does with margin-${edge}), or refusing start edges here, would fix it. A block-start reveal-and-conceal browser case, as the FR9 Coverage row lists, would cover it.

@vjeux
vjeux force-pushed the uikit/f1-glide-on-morph branch from d76a1a9 to 7eb2a20 Compare November 1, 2026 02:37
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 7eb2a20: holdLayoutExit with a start edge now frees the element's space on that edge's side (margin-block-start / margin-inline-start) and checks that the element itself stays where it shows and the content before it lands. A layout where it would move is not held, and the element goes at once. New browser case: a block-start reveal and conceal in a column that stacks from its end. On d76a1a9 the conceal jumped the element by its 80 px, opening an 80 px gap; now its edge stays on the content before it on every frame, and its bottom doesn't move (Chromium ×2, WebKit ×3).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommending approval: the shared edge reveal now matches spec:AST-105 FR9 at this head, and the start-edge conceal raised on the previous revision frees space on its own side and holds the element in place. Collapsible and TreeList reversals continue from what they show, and the SideNav rail passes its existing browser spec after its move onto the module's two-state form. Two optional follow-ups: reportLayoutEdge and its tests could ship with #9197 for a smaller diff, and SideNav FR6's implementation-gap note can be updated once this lands.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommending approval: the shared edge reveal matches spec:AST-105 FR9, Collapsible and TreeList reversals now continue from what they show, and the SideNav rail moves onto the module with its existing proofs passing. Two optional follow-ups, neither blocking:

  • giveUpSpace still picks its scale axis from isRow while the margin now follows the edge (see the inline note).
  • component:SideNav FR6 and spec:AST-105's current-state impact still describe the rail's move as an open gap; a record-only follow-up can drop that text.

[Automated review]

element.style.setProperty(property, `${-size}px`);
const guess = placeOf(next, rtl)[axis];
const guess = placeOf(checked[0], rtl)[axis];
const correction = (target[axis] - guess) / perPx;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit, non-blocking: perPx (line 1665) still follows isRow, but the margin now follows edge, so an inline-edge exit in a block container, or a block-start exit in a flex row, divides this correction by the other axis's scale. Under a non-uniformly scaled ancestor that misses the landing check and the exit hides at once. const perPx = isInline ? scale.x : scale.y; fixes it and leaves the default block-end path unchanged.

…ch the SideNav rail moves onto

spec:AST-105 FR9's edge reveal, the one edge reveal in Core:

- The one-state form (playLayoutChange, holdLayoutExit) reveals and conceals
  from the block-end edge by default, or from the block-start or an inline
  edge it is given, right to left mirrored. Once the content on that edge's
  side glides, the moving edge rides that glide frame by frame, so no gap
  opens, and a reversal (a close while it opens, an open while it closes)
  starts from what the element shows.
- The two-state form (holdLayoutEdge, playLayoutEdge) holds the old state as
  the swap's copy, clips the larger state on the edge from the extent it
  shows, rides a gliding element, and raises the element above the copy while
  it conceals; the caller adds only its own fades. Reduce Motion follows
  design:reduced-motion DR2 and DR3.
- A resized element with no copy (reportLayoutEdge, a resize controller's
  pane): measured before its size change commits and played after the new
  size applies; a smaller extent is held at the extent it was laid out at,
  its edge's margin freeing the difference, and clipped to the new one.
- A conceal by a start edge frees the element's space on that edge's side, so
  the element stays where it shows while the content before it moves; a layout
  where that would move the element itself is not held.
- The edge rides the content beside it relative to any glide that moves the
  element itself in the same batch, such as an item above them closing in a
  single-open CollapsibleGroup; the AnimatedLayout story gains one.
- The SideNav rail plays through the two-state form on its inline-end edge and
  keeps only its labels' and icons' fades. Collapsible and TreeList keep their
  block-end form and gain the ride and the reversal.

Nothing public changes: the new functions stay out of every entry point.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommending approval: the shared edge reveal matches spec:AST-105 FR9 as it stands on main, Collapsible and TreeList reversals now continue from what they show in both directions, and the SideNav rail moves onto the module with its existing proofs passing. Two small follow-ups, neither blocking: giveUpSpace still takes its scale axis from isRow while the margin now follows the edge (see the inline note), and component:SideNav FR6 and spec:AST-105's current-state impact still describe the rail's move as an open implementation gap.

[Automated review]

element.style.setProperty(property, `${-size}px`);
const guess = placeOf(next, rtl)[axis];
const guess = placeOf(checked[0], rtl)[axis];
const correction = (target[axis] - guess) / perPx;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit, non-blocking: perPx (line 1665) still follows isRow, while property and axis now follow edge. An inline-edge exit in a block container, or a block-start exit in a flex row, under a non-uniformly scaled ancestor divides this correction by the other axis's scale, misses the landing check and lands at once. const perPx = isInline ? scale.x : scale.y; matches what holdExtent already does.

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 1, 2026
@cixzhang
cixzhang marked this pull request as ready for review November 1, 2026 07:16
@cixzhang
cixzhang merged commit 5909b93 into meta:main Nov 1, 2026
89 checks passed
@vjeux
vjeux deleted the uikit/f1-glide-on-morph branch November 1, 2026 07:39

This branch was successfully deployed

1 active deployment
Preview — f20b0961 Deployed Nov 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants