Skip to content

feat(core): the animator's springs follow the theme's tempo and character - #8677

Draft
vjeux wants to merge 927 commits into
meta:mainfrom
vjeux:uikit/f1-theme-motion-springs
Draft

vjeux wants to merge 927 commits into
meta:mainfrom
vjeux:uikit/f1-theme-motion-springs

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8076 and #8661. Review only this PR's change: the springs and the knob reader.

Why

A theme's tempo and character (#8661) reach CSS transitions, but not the springs of a sheet, popover or menu presented on touch, nor does reducedMotion: 'always'. spec:AST-108 FR3 derives the springs from the same knobs, wherever a theme or adaptation rule sets them.

What led to it

The work to make Astryx's touch components behave as native iOS ones do: the touch presentations play on the animator's motion roles (spec:AST-094 FR4), which knew nothing about their theme.

Options and tradeoffs

  • Attributes on Theme's wrapper: no style read, but no rule conditions and no inheritance.
  • The custom properties the theme's CSS writes: one getComputedStyle read per motion start, and CSS supplies inheritance and rule conditions.
  • How strongly character moves each role: the weights (1 for entrances, 0.5 for state changes, 0 for exits) and the 0.1 step were asked on spec(AST-108): theme motion is three knobs — tempo, character, reducedMotion #8613.

Why this choice

The springs follow what the durations follow, and the read forces no layout. Exits keep their measured bounce, so dismissals never wobble.

What changes

  • A theme or rule that sets a character writes --_astryx-motion-character beside --_astryx-motion-tempo; existing themes are unchanged (FR5).
  • resolveMotion(element, role) reads both: tempo scales spring and duration roles, never finger-following ones (FR4). The layout glide plays one spring at the area's knobs.
  • reducedMotion: 'always' counts as Reduce Motion for the animator (FR6).
  • The docs, template, changeset and record say so; exits and fades keep their measured bounce.

How to check it

  1. Wrap a BottomSheet story in a theme with motion: {tempo: 1.5} on a touch device: it presents 1.5 times slower; a drag still follows the finger.
  2. Nest a theme with no motion inside: still 1.5 times slower.
  3. Put reducedMotion: 'always' on an outer theme: the sheet fades in instead.

Evidence

  • themeKnobs.interaction.browser.spec.ts, Chromium and WebKit: nested themes inherit, 'standard' stops an outer character, a coarse-pointer rule applies only on a phone, and a nested spring plays at the outer tempo, starting on its first frame with a dirty 12,000-row layout. Long Animation Frames time the read at 0 ms of forced layout, against 5 ms for reading a size.
  • themeKnobs.test.tsx covers each knob on an entrance, a state change and an exit.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

vjeux and others added 30 commits October 26, 2026 04:18
…fine pointer); the changeset says the same

animator.test.ts keeps this layer's header (no compositor browser spec in
this layer). The changeset now says decay sends a spring back from past an
edge through the Reduce Motion policy: a cross-fade on a coarse pointer, at
once elsewhere.
…side a scroll view

The press profiles' README said a cell's highlight always waits the
content-touch delay. PressTracker waits it inside a scroll view and lights
at once outside one (spec:AST-094 FR11), as its own header says; the README
now says so too.
What one call becomes under Reduce Motion (an opacity-only cross-fade
whose travel lands while the element is transparent, or everything at
once; a dip on a borrowed stylesheet opacity) moves from createAnimator's
closure to reducedMotionPlan.ts, a pure planner that reads the animator
through a view and returns the plan; the animator turns the plan into its
segments and runs the landings. No behavior change: the animator's tests
pass unchanged, and the planner has its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…ed pull requests

This pull request keeps the animator's Reduce Motion plan (now its own
planner module), the browser-set start of a call from rest, promotion,
atRest, rebase and animatorFor. Two features it carried move up the stack,
each with its own tests and changeset:
- play(curves), baking any closed-form curve like a spring;
- driving a pseudo-element (a dialog's ::backdrop).
The changeset is split the same way.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
play(curves) bakes any curve given as value and velocity functions of
time (a spring chasing a moving target from solveSpringTowardRamp, a
fitted UIKit curve) exactly like a spring: presentation values,
velocities, stop, finished and the shared clock work the same, a later
animate starts from the curve's value and velocity, and under Reduce
Motion a curve that moves is travel the policy replaces. Tests and a
changeset of its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
createAnimator(element, {pseudoElement: '::backdrop'}) animates a
pseudo-element: with no inline style, a static filling animation holds
its value after a curve ends and after set; a custom property on its host
promotes it; a Reduce Motion dip borrows and hands back the
pseudo-element's own opacity, and its holds follow a node replacement.
animatorFor keeps one animator per element and pseudo-element. Tests and
a changeset of its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…ot the page's frames around it

"a harness read inside a trace is not the page's long task…" also asserted
that no long task started anywhere in the read's window. On CI's runner each
frame of the fixture's 1,200 running rows took 51-54 ms, the page's own long
tasks, and failed it; at 4x CPU locally they take 126-220 ms. The read
itself was set apart correctly.

The test now asserts the read's own classification, and removes the rows
before the timer, click and direct-call windows, so each holds only its own
work. It passes at 4x CPU, where the previous assertion failed.
…ot hold up a wait) into the frame evidence
…hed curve

waitForAnimation's finished branch read every recorded animation, so a
scroll- or view-timeline animation that reached its end (a scroller scrolled
to its end) came back as finished with a NaN duration and interval. The
recorded set now keeps document-timeline animations only. The scroll-timeline
self-test scrolls its scroller to the end first; it returned the NaN entry
before (Chromium, 3x repeat pass). The README, JSDoc and file header name
watchAnimations where they list what moves the wait window and what the file
exports.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…rves), pseudo-element support) as a dependency

# Conflicts:
#	packages/core/src/motion/README.md
#	packages/core/src/motion/animator.ts
… a finished curve) into the recorder stand-down
…ion plan

Main carries meta#8055 as one commit; this branch already had its head
(8fc7f2e), so the merge keeps this branch's animator, coast, test and
changeset files over it and takes main's other changes as they are.
… motion-role animator

# Conflicts:
#	packages/core/src/motion/animator.ts
…into the experimental touch layer

# Conflicts:
#	.changeset/uikit-motion-animator.md
The travel list moved to the planner with the Reduce Motion plan; nothing
in the animator reads it any more (no-unused-vars).
…deselect clock; the animator changeset drops its no-component clause

The press profiles' README gave a control UIButton's 0.25 s release.
PRESS_PROFILES releases every surface on the list's deselect clock until a
control runs its own release, as its comment says; the README now says so.
Its test list still credited VelocityTracker's tests with checking each
strategy against the true velocity, though one filter remains: it now
names what they check. The animator's changeset said no component uses
it, which goes false in the release that carries the components that do.
…removed at its end is still reported

- Arming the recorder now records first and stamps strictly after the
  newest entry: a watch taken right after a step that changed style no
  longer reports that step's transition (created by the arm's own style
  read) as started after it.
- Each entry keeps its target's ancestors as recorded, so an exit whose
  element the page removes at its end is still reported for the container
  it left, instead of a wait rejecting with nothing recorded.

A self-test for each; both failed before (Chromium, 3x repeat pass).

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
solveSpringTowardRamp went with play(curves)'s tests, and a coast test
destructured an element it never reads (no-unused-vars).
play(curves)'s tests use solveSpringTowardRamp, so this layer keeps its
import.
…he frames it credits as skipped

With a compositor record and an on-screen start, the moved criterion counted
from the first moved frame after the on-screen frame, while the gap from the
on-screen frame to it was credited as frames the screencast skipped. When the
cast skipped the frames right after the on-screen start, those credits had
no place in the denominator: four unmoved frames that fail on a full cast
("31 of 35 expected frames moved") passed as "26/30 moved and 5 skipped by
the screencast (103%)", and a clean run read 117%.

The criterion now counts the compositor's frames from the on-screen frame,
so both sides cover the same frames: the skipped cast fails like the full
one, and a clean run reads at most 100%. A busy.test.ts case pins all three.
…ts timing is a test annotation

@typescript-eslint/promise-function-async asks a function that returns a
promise to be async: knobsOf and the two page.evaluate callbacks. The
measured forced style and layout is reported as a test annotation instead
of a console line (no-console).
@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

  • Knobs from an adaptation rule or an enclosing theme: the springs now read tempo and character from the CSS the durations use. A theme or rule that sets a character writes --_astryx-motion-character beside the tempo it already wrote (a24decd fails, c23d11e writes it). themeMotionKnobs reads both in one getComputedStyle at each motion start (aafa0c4), so a nested theme that sets neither plays its enclosing theme's, and a rule's knobs apply where its condition holds. Theme's tempo and character attributes are gone.
    • Evidence: f494462 adds a Chromium and WebKit check on themes from defineTheme and generateThemeCSS. A nested theme inherits, one that sets 'standard' stops the outer character, and a coarse-pointer rule applies on a phone and not under a mouse. A spring in a nested theme plays at the outer tempo of 1.4 and starts on its first frame while a 12,000-row layout is dirty.
    • The read forces no layout: Chromium's Long Animation Frames time it at 0 ms of forced style and layout, against 5 ms for reading a size.
  • Exits and fades: e810246 says they keep their measured bounce at every character, which covers springs.menuClose's 0.6%.
  • reducedMotion: 'always': its reach into components' other Reduce Motion styles and script readers is the known gap recorded in the theme authoring contract, as asked on feat(core): a theme's motion takes tempo and character #8661. This change makes the animator honour it.
  • Landing order: feat(core): the internal touch foundation: press tracking and the animator #8076 and feat(core): a theme's motion takes tempo and character #8661 land first in the maintainer's order, and this branch merges main as they do.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
Takes meta#8677's head: the springs read tempo and character from the custom
properties a theme or adaptation rule writes, so nested themes and rules
reach them; Theme writes no tempo or character attributes; exits and
fades keep their measured bounce. It carries meta#8661's head b08596a.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
…ites, which the springs now read

meta#8677's head reads tempo and character from the custom properties a theme
or adaptation rule writes, and Theme writes no tempo or character
attributes, so this case's attributes no longer reach the spring: after
the merge it fails, the themed spring equal to the plain one. It now
declares --_astryx-motion-tempo and --_astryx-motion-character on the
element, as a browser inherits them from the theme's root.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
… name here, createMotionAnimator

This pull request names the internal motion animator createMotionAnimator,
since createAnimator is the public one from the package root, and meta#8677's
new check bundled createAnimator from motion/animator. After the merge,
core's typecheck failed (TS2305) and the check's spring case failed in
both engines. Both files now use createMotionAnimator.

@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 springs and the layout glide now follow the theme's tempo and character as spec:AST-108 FR3 describes, and reading the knobs from the theme's custom properties covers nested themes and adaptation rules. Two things block landing:

  • reducedMotion: 'always' is public, but 47 core files still key reduced motion on prefers-reduced-motion alone (Spinner, Skeleton, Dialog, Drawer, Toast and BottomSheet styles; the Carousel, MobileNav and AppShell script readers). AST-108 FR6 requires them to honour the mark too, so please finish that reach with a test of 'always' with the setting off, or keep the value out of the public type until it lands.
  • The diff still carries #8076 and #8661 (16,858 authored lines around a 941-line change), so please land those first and rebase.

Smaller: the theme authoring contract says the animator reads resolveThemeMotion, and a few motion comments still describe the earlier attribute design.

[Automated review]

* `'always'` reduces motion for everyone; `'system'` follows each person's
* Reduce Motion setting. No value loosens Reduce Motion. Default `'system'`.
*/
reducedMotion?: ThemeReducedMotion;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

'always' is public here, but 47 core files still key reduced motion on prefers-reduced-motion alone (Spinner, Skeleton, Dialog, Drawer, Toast, BottomSheet; the Carousel, MobileNav and AppShell script readers). spec:AST-108 FR6 requires them to key on the mark too. Either finish that reach with a test of 'always' with the setting off, or keep this value out of the public type until it lands.

// The loop reads the duration before a theme's tempo scales it
// (spec:AST-108 FR4).
default: `var(--_astryx-pre-tempo-duration-slow-min, ${durationVars['--duration-slow-min']})`,
'@media (prefers-reduced-motion: reduce)': '3s',

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 a theme's 'always' with the person's setting off, this loop keeps its normal turn (0.525 s in the Storybook theme) instead of the 3 s it gets under Reduce Motion: this key has no arm for [data-astryx-reduced-motion] or --_astryx-reduced-motion.

`expandMotionScale.ts` own their derived token/component values.
- `motionKnobs.ts` owns the motion knobs' types, defaults and clamping;
`themeMotion.ts` owns the tokens and pre-tempo durations they generate, and
`resolveThemeMotion`, the pure read the animator and server code use.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing outside tests calls resolveThemeMotion. The animator reads the knobs from CSS custom properties through motion/themeKnobs.ts, as this record's gap paragraph says, so "the pure read the animator and server code use" (and the matching line at 270–272) should name themeKnobs.ts for the animator.

* reaches CSS curves through its easing tokens. With Astryx's own knobs every
* role plays exactly its table value. The per-role values stay internal
* (`spec:AST-094` DEC-11, `spec:AST-108` DEC-1): no CSS variable retunes a
* role. Nothing here reads style or layout (the theme is found by DOM

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

themeMotionKnobs now reads computed style at every motion start, so "Nothing here reads style or layout (the theme is found by DOM attribute)" no longer holds. The same stale wording is at animator.ts:137 and :1944, and at README.md:24, :50 and :72.

…er code reads resolveThemeMotion

The theme authoring contract said the animator reads resolveThemeMotion;
it reads the knobs from the custom properties the theme writes
(motion/themeKnobs.ts), and resolveThemeMotion is server code's pure
read. The motion README's themeKnobs row and resolveMotion's header still
described the attribute design; they now say the knobs are inherited
custom properties, read in one style read that forces no layout.
@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@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 touch springs, the layout glide and the animator's Reduce Motion check now follow the theme's tempo, character and reducedMotion as spec:AST-108 FR3 and FR6 describe, but the pull request needs these changes before it can land.

  • Land #8076 and #8661 first and rebase, so the diff carries only this change (16,877 authored lines today around a 950-line change).
  • reducedMotion: 'always' is public while 47 core files still key reduced motion on prefers-reduced-motion alone (Spinner, Skeleton, Dialog, Drawer, Toast and BottomSheet styles; the Carousel, MobileNav and AppShell script readers), which AST-108 FR6 requires to honour the mark, so finish that reach with a test of 'always' with the setting off, keep the value out of the public type, or let #8661 land first so the gap leaves this diff.
  • The motion guide says 'crisp' decelerates without overshoot, but crisp springs still bounce (springs.emphasis keeps a bounce of 0.2), and themeMotion.ts, animator.ts, the motion README and themeKnobs.test.tsx still describe the earlier design.

[Automated review]

Comment thread packages/core/src/theme/themeMotion.ts Outdated
* @input A theme's motion configuration: the three knobs (`tempo`,
* `character`, `reducedMotion`), the existing `MotionScaleConfig`, or both
* @output The resolved knobs, the motion tokens they generate, and
* `resolveThemeMotion`, the internal resolver the animator and server code

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 header (and the docstring at line 188) says the animator reads resolveThemeMotion, but the animator reads the knobs from the inherited custom properties through motion/themeKnobs.ts, as this pull request's authoring-contract owning-code line now says; only server code is meant to read the resolver. Line 15 also says the animator reads the knobs "from the theme's root", while it reads the element's inherited properties.

Comment thread packages/cli/assets/docs/motion.doc.mjs Outdated
style: 'unordered',
items: [
"`tempo` scales every motion duration: 1 is Astryx's own, 0.75 the quickest and 1.5 the slowest (values outside are clamped).",
"`character` sets how lively motion is: `'crisp'` decelerates without overshoot, `'standard'` is Astryx's own, and `'expressive'` overshoots slightly on entrances and moves. Exits and fades keep their measured bounce at every character.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since line 153 now says the knobs drive the springs too, "'crisp' decelerates without overshoot" overstates: 'crisp' lowers a spring's bounce by 0.1 × its weight (resolveMotion.ts:170-175), so springs.emphasis goes from 0.3 to 0.2 and still overshoots about 1.5%. Saying 'crisp' uses a decelerate-only curve and gives springs less bounce would match the code (the same wording is at theme/motionKnobs.ts:32).

* through a rendered Theme: a role's spring and duration follow the knobs
* of the theme the element is in (tempo scales the duration; character
* moves the bounce by the role's weight, never for an exit), a nested theme
* that sets no knob plays Astryx's own motion, finger-following motion

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 header says a nested theme that sets no knob "plays Astryx's own motion", but at this head it plays its enclosing theme's (the browser spec asserts exactly that), and line 17 still refers to "the attributes Theme.tsx writes". themeKnobs.ts:25 also names themeKnobs.test.ts for this .tsx file.

…xts describe the custom-property knobs

The motion guide said 'crisp' decelerates without overshoot, which is its
entrance and move curves; a crisp spring keeps part of its bounce
(springs.emphasis keeps 0.2). The guide and motionKnobs' JSDoc now say
crisp gives the curves no overshoot and takes some bounce out of
springs, and expressive adds some. themeMotion.ts's header, animator.ts's
role note, the motion README and themeKnobs.test.tsx's header still
described the attribute design or a role that never follows the theme;
they now say a role plays its table value moved by the knobs, read from
the custom properties the theme writes, and that no role's own variable
retunes it (DEC-11, DEC-15). The README lists the new browser check.
@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@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 touch springs, the layout glide and the animator's Reduce Motion check follow the theme's tempo, character and reducedMotion as spec:AST-108 FR3 and FR6 describe, and the 'crisp' wording now matches the code, but two things still need to change before this can land.

  • Land #8076 and #8661 first and merge main, so the diff carries only this change (16,887 authored lines today around a 994-line change); that also takes the missing reach of reducedMotion: 'always' out of this diff (47 core files still key reduced motion on prefers-reduced-motion alone, which AST-108 FR6 requires to honour the mark), unless you finish that reach here with its test.
  • Three comments are still stale: themeMotion.ts:188-191 says the animator reads resolveThemeMotion, Theme.tsx:275-278 leaves the animator, the glide and reducedMotionFade() out of the mark's readers, and themeKnobs.ts:25 names themeKnobs.test.ts for themeKnobs.test.tsx.

[Automated review]

Comment thread packages/core/src/theme/themeMotion.ts Outdated
Comment on lines +188 to +191
/**
* A theme's motion values, for the animator and for server code (FR6): the
* knobs, and the duration and easing tokens they resolve to with the theme's
* explicit tokens applied. Pure: it reads only `theme`.

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 docstring still says resolveThemeMotion is "for the animator and for server code", while the file's header (lines 7-9) and the authoring contract's owning-code lines say server code reads it and the animator reads the knobs' custom properties through motion/themeKnobs.ts. Saying "for server code (FR6)" would match the head.

Comment on lines +275 to +278
// spec:AST-108 FR6: 'always' marks the theme's root. So far motion-keyed
// adaptation rules and the press release fade key reduced motion on the
// mark; components' other Reduce Motion styles don't yet (a known gap in
// `architecture:theme-authoring-contract`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The animator, the layout glide and reducedMotionFade() read the mark too (motion/animator.ts:785, motion/animatedLayout.ts:145, motion/reducedMotion.ts:89), as themeMotion.ts's header now says for the animator; this comment names only adaptation rules and the press release fade.

Comment thread packages/core/src/motion/themeKnobs.ts Outdated
* plain Node.
*
* SYNC: When modified, update:
* - /packages/core/src/motion/themeKnobs.test.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The test file is themeKnobs.test.tsx, not themeKnobs.test.ts.

…ry reader of the mark, and themeKnobs' SYNC names its test file

resolveThemeMotion's comment still said the animator reads it; the
animator reads the knobs' custom properties (themeKnobs.ts). Theme's FR6
comment listed only adaptation rules and the press release fade as the
mark's readers; the animator, the layout glide and reducedMotionFade()
read it too. themeKnobs.ts's SYNC list named themeKnobs.test.ts for
themeKnobs.test.tsx.
@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@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 touch springs, the layout glide and the animator's Reduce Motion check follow the theme's tempo, character and reducedMotion as spec:AST-108 FR3 and FR6 describe, and the three comments now match the code, but two things still need to change before this can land.

  • Land #8076 and #8661 first and merge main, so the diff carries only this change (16,889 authored lines today around a 1,006-line change); that also takes the missing reach of reducedMotion: 'always' out of this diff, where 47 core files still key reduced motion on prefers-reduced-motion alone although AST-108 FR6 requires them to honour the theme's mark too.
  • The 'always' gap paragraph in docs/architecture/theme-authoring-contract.md should also name the layout glide and reducedMotionFade(), which already honour the mark.

[Automated review]

Comment on lines +322 to +324
(`spec:AST-108` FR6, INV15). Motion-keyed adaptation rules, the press release
fade and the animator key reduced motion on the mark; components' other Reduce
Motion styles and other script readers of `prefers-reduced-motion` do not yet. Until they do, that part of INV15 and FR6 is current authority but not

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The animated-layout glide and reducedMotionFade() are script readers of prefers-reduced-motion that already key on the mark (packages/core/src/motion/animatedLayout.ts:143-146, packages/core/src/motion/reducedMotion.ts:89), as Theme.tsx's comment now says. Naming them here would leave the gap listing only what still ignores 'always'.

…or's layout glide and Reduce Motion fade

The record's known-gap paragraph, the motion topic, the changeset, the
theme template, defineTheme's JSDoc and themeMotion.ts listed motion-keyed
rules, the press release fade and the animator as what follows 'always'
so far; the layout glide (motion/animatedLayout.ts) and
reducedMotionFade() honour the mark too, and they now say so.
@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@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 touch springs and the layout glide now follow a theme's tempo and character as spec:AST-108 describes (the glide runs 1.5 times longer at tempo: 1.5), and the record's 'always' paragraph now names the glide and reducedMotionFade(). Please land #8076 and #8661 first and merge main, so this pull request carries only its own change (today it carries 16,892 authored lines around 1,007 of its own). Also, reducedMotion: 'always' is public while 47 core components, Spinner and Skeleton among them, still key Reduce Motion on prefers-reduced-motion alone, which AST-108 FR6 rules out: finish that reach with its test, or keep 'always' out of the public type until it lands.

[Automated review]

// The loop reads the duration before a theme's tempo scales it
// (spec:AST-108 FR4).
default: `var(--_astryx-pre-tempo-duration-slow-min, ${durationVars['--duration-slow-min']})`,
'@media (prefers-reduced-motion: reduce)': '3s',

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 a theme with reducedMotion: 'always' and the person's own setting off, this loop keeps its regular duration (525 ms in Storybook, against 3 s under Reduce Motion). spec:AST-108 FR6 asks this style to also key on the theme's mark, for example a [data-astryx-reduced-motion] arm or the --_astryx-reduced-motion property, or 'always' should stay out of the public type until that reach lands.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
Folds this pull request onto meta#8677's head, whose springs read tempo and
character from the theme's custom properties, and onto main, in place of
the old meta#8677 copy it carried. No conflicts: this pull request's own
commit touches resolveMotion and its tests.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38
cixzhang pushed a commit that referenced this pull request Nov 1, 2026
…ch the SideNav rail moves onto (#8721)

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 #9197 and #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 (#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

- [x] One capability is added (FR9's shared edge reveal), with its adopters in this PR's scope (the SideNav rail; Collapsible, TreeList and Table's row expansion keeping their form).
- [x] Unrelated fixes, cleanup, and policy changes were removed or split. Resizable's and LayoutPanel's adoption is #9197 and #9202.
- [x] Consumer docs, focused tests, and migration evidence ship with the feature.
- [x] Public text and artifacts contain no internal Meta context.

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.

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants