Skip to content

feat(core): a CSS part times itself from its spring's settle at the theme's knobs - #8686

Draft
vjeux wants to merge 931 commits into
meta:mainfrom
vjeux:uikit/f1-motion-settle-clock
Draft

vjeux wants to merge 931 commits into
meta:mainfrom
vjeux:uikit/f1-motion-settle-clock

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8677. Review only this PR's change: vjeux/astryx@5e235e6...a31f1ec; the merges after take #8677's head and main.

Why

A CSS part that plays alongside a spring, such as the scrim fading under a sheet or a menu's content under its grow, has to start and land with the spring. Timed on its own clock, it drifts as soon as a theme's tempo or character changes the spring.

What led to it

This comes from the work to make Astryx's touch components behave as native iOS ones do (sheets, menus, gestures and their motion). spec:AST-108 FR4a asks paired animations to end within a frame of each other at every knob value. #8677 made the springs follow the knobs, so the CSS side now needs the same number.

Options and tradeoffs

  • Hard-code each CSS part's duration next to its spring: simple, but it silently breaks the pairing under any theme that changes the spring.
  • Animate the CSS part on the animator too: always in step, but it gives up plain CSS transitions for every scrim and fade.
  • One internal helper that returns the spring's settle time at the element's theme, for a CSS duration.

Why this choice

The helper keeps CSS parts in CSS and gives them the exact time the spring will take. It measures the settle on the spring's progress, as the layout glide does, because the animator's pixel and degree thresholds fit a moving element, not a fade.

What changes

  • resolveSettleMs(element, spring) returns the spring's settle time in milliseconds at the knobs the element inherits from its theme. A fixed role (finger tracking) or a spring that is not a role gives its own.
  • The layout glide's duration is that same number, so a glide and a CSS part timed from it land together.
  • No component uses it yet: BottomSheet's scrim, MobileNav's close and a menu's grow take it in their own pull requests.

How to check it

  1. Run pnpm exec vitest run packages/core/src/motion/themeKnobs.test.tsx -t FR4a.
  2. At Astryx's own knobs, the time equals the role's settling duration; under a theme with tempo: 1.5 it is 1.5 times as long; with character: 'expressive' it is the resolved spring's own.
  3. Under a theme that sets both knobs, the layout glide's duration equals resolveSettleMs(area, springs.settle).

Evidence

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.

cixzhang and others added 30 commits October 26, 2026 04:32
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.
…an exit removed at its end is still reported) into the frame evidence
… transparent

A call with travel (or a waiting landing) whose opacity rose from a visible
value landed its travel at once, in view, and one whose opacity fell to a
visible value landed at the end of its fade, in view. The entrance branch
now applies only from 0 and the exit branch only to 0; any other such call
dips (fades out, lands at the bottom, fades back to its opacity), as
design:reduced-motion DR1 and DR2 ask. The tests read the opacity at the
moment the travel lands.
vjeux added 25 commits October 28, 2026 19:37
…equest that reads them

Theme wrote data-astryx-motion-tempo and data-astryx-motion-character on
its wrapper and on <html> for the animator, but nothing in this pull
request reads them: the animator's reader comes with meta#8677, where the
springs follow the knobs. The attributes and their three tests move there;
the reduced-motion mark and its custom property stay. The comment beside
the mark now says what follows it, in the words of the record's known gap.
…ll request doesn't change

An earlier formatter run had rewritten untouched lines of motion.doc.mjs
(quote style, the sections' indentation). They go back to main's text;
the topic's own changes stay: the usage section's entrance and move
curves, its sample's entrance curve, and the theme-motion section.
…ut, not the vars its motion knobs derive

theme-private-vars.mjs checks what a theme's author wrote; the private
vars the compiler derives are valid output. A defined theme's tokens and
resolved adaptation rules are compiler output, and the motion knobs add
private vars to both (spec:AST-108 FR4): --_astryx-motion-tempo for a
theme with tempo, and the busy loops' pre-tempo copies for one with a
duration scale. The check read them as authored, so astryx theme build
reported them as errors. Fails now with 20 errors; a private var the
author writes is still reported once.
…les for private vars

A defined theme's tokens are resolved, with the private vars the motion
knobs derive, so the check reads its __inputTokens; a built module is
compiler output already checked when it was built. Adaptation rules are
read from their authored values (adaptations or the normalized
__adaptations) before the resolved __adaptationRules. A theme with tempo
or a duration scale now builds; a private var an author writes is still
reported.
… other rows keep main's text

The row was wider than the table's evidence and failure columns, so the
formatter realigned all thirteen rows. It now fits them, with the same
evidence and failure signal in fewer words, and the other rows are
main's again.
Takes both base heads, each folded onto main 70ea859. meta#8661's head
moves the tempo and character attributes the animator reads out of
meta#8661, to this pull request, which adds their reader; the next commit
adds them back here.
…beside its reader

Moved here from meta#8661, which no longer writes them since nothing there
reads them: a theme that sets tempo or character writes
data-astryx-motion-tempo and data-astryx-motion-character on its wrapper
and, for the root theme, on <html>, where the animator's themeKnobs.ts
reads them without importing the theme (spec:AST-108 FR3). Without
them, themeKnobs.test.tsx fails on the merge before this commit. Their
three tests move with them; the comment beside the reduced-motion mark
keeps the record's known-gap wording.
…them and the animator follows 'always'

meta#8661's docs say what it ships: the knobs drive the CSS tokens, no spring
reads them yet, and the animator doesn't follow reducedMotion: 'always'
yet. This pull request ships both, so its docs say so:

- The motion topic, the theme guide's row, the theme template, the
  motion field's JSDoc and themeMotion.ts say tempo and character reach
  the touch springs, and that the animator follows 'always'.
- The theme authoring contract's known gaps: resolveThemeMotion's role
  tables stay unshipped for server code, while the animator's springs
  follow the knobs through the theme root's attributes; the animator
  leaves the list of what doesn't follow the mark yet.
- The theme-motion-knobs changeset says the same, so the release notes
  describe the springs once.
…the --ease-standard beneath it (spec:AST-108 FR2)

At 'standard', --ease-entrance and --ease-move equal --ease-standard.
Fails now when that --ease-standard is an explicit token from beneath the
layer: a child of a base with one, or an adaptation rule of a theme with
one, that sets character 'standard' gets Astryx's default curve.
…olved --ease-standard

A layer that sets character 'standard' wrote Astryx's default curve, or
its duration scale's easing, to --ease-entrance and --ease-move, though
FR2 makes them equal --ease-standard, which can be an explicit token from
beneath the layer: its base's, or the theme's for an adaptation rule.
resolveThemeValues now writes them from the resolved --ease-standard: the
layer's own, else the seed's, else, for a rule (resolved without a seed),
its theme's, passed as standardBeneath. The case of an explicit
--ease-standard in the layer itself moves into the same rule.
…ay it in fewer lines

The press-state helper and the built-sheet check read a release clock's
base duration at tempo 1 with one pattern for its scaled and capped forms,
instead of one regular expression per form; pressFeedback.test.ts still
pins both forms in the source. The comments on the clock, its timer and
the mark's custom property keep what they say in fewer lines, to keep
this pull request under the scope limit.
Takes meta#8661's head: at 'standard', the entrance and move curves follow the
resolved --ease-standard, the authoring contract lists motionKnobs.ts, and
the press release clock's comments and test parsers are shorter.
…prings, beside the tempo (spec:AST-108 FR3)

The springs are to read the knobs from the same CSS the durations use, so
the knobs inherit into nested themes and follow adaptation rules as the
durations do. A theme or rule with a tempo already writes it as a private
custom property; one that sets a character must write that too. Fails
now: no character is written.
…tion-character

Beside --_astryx-motion-tempo, which a theme or rule with a tempo already
writes for the press release fade, the character is now a private custom
property too, so the springs can read both knobs from the CSS the
durations use (spec:AST-108 FR3). A theme that sets neither writes
neither, so existing themes generate exactly what they did (FR5).
…ations use (spec:AST-108 FR3, FR4a)

A nested theme that sets no knob inherits its enclosing theme's duration
and easing tokens, and an adaptation rule's knobs apply where its
condition holds, but the springs read only attributes Theme wrote on its
own wrapper. The springs are to read the same custom properties
(--_astryx-motion-tempo, --_astryx-motion-character), so they inherit and
follow rules as the durations do.

- jsdom: the knobs are the custom properties declared on the element;
  the role tests' element carries the ones its theme writes.
- Chromium and WebKit (themeKnobs.interaction.browser.spec.ts, themes
  from defineTheme and generateThemeCSS): a nested theme that sets no knob
  plays its enclosing theme's, one that sets character 'standard' stops
  it, a coarse-pointer rule's knobs apply on a phone and not under a
  mouse, and a spring in a nested theme plays at the enclosing tempo and
  starts on its first frame while the layout is dirty. In Chromium, Long
  Animation Frames show that reading the knobs forces no layout, where
  reading a size does.

The nested, rule and spring cases fail now.
…animator's end hold

An animation's duration includes the 5 s it holds its end state
(END_HOLD_MS), and an expressive character lengthens a spring's settle
on its own, so the check compares, less the hold, the theme that sets
only character 'standard' inside the outer tempo of 1.4 with a spring
outside every theme. The layout is dirtied across a 12,000-row tree.
…om properties, so nested themes and adaptation rules reach them

themeMotionKnobs read attributes Theme wrote on its own wrapper, so the
springs played Astryx's own knobs inside a nested theme that sets none,
whose durations inherit the enclosing theme's, and ignored an adaptation
rule's tempo and character, which its durations follow (spec:AST-108 FR3,
FR4a). It now reads --_astryx-motion-tempo and
--_astryx-motion-character in one getComputedStyle at each motion start,
custom properties only, which forces no layout. Theme no longer writes
the tempo and character attributes, and their tests go with them; the
reduced-motion mark stays. resolveMotion's test now checks that no
role's own variable is read, only the knobs, and animatedLayout's that
an area inherits the tempo its theme writes.
…cter, and the springs read the knobs' custom properties

The motion docs said exits and fades never overshoot, but a role whose
character weight is zero keeps its measured bounce, and springs.menuClose
overshoots 0.6%. They now say exits and fades keep their measured bounce
(motion topic, theme template, defineTheme and motionKnobs JSDoc,
resolveMotion). The record's note on role tables says the springs follow
the custom properties a theme or adaptation rule writes, no longer
attributes.
…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).
…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.
…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.
…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.
…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.
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.

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

Approval waits on an engineering owner's decision on this head's change to architecture:theme-authoring-contract: it replaces the accepted statement that resolveThemeMotion returns role tables for the animator and for server code with one where the animator reads the theme's custom properties and the resolver serves server code only. Any engineering owner, the author included since they are one, can record that decision by commenting /approve-spec <full head SHA> on this pull request, or by landing the amended record on main. Whatever the decision, these need changes:

  • Land the stacked pull requests and rebase, so the diff carries only resolveSettleMs and its tests (it carries 16,980 authored lines today).
  • Make reducedMotion: 'always' reach the remaining component styles and script readers, as spec:AST-108 FR6 requires, or hold the option back until it does.
  • Make resolveSettleMs follow Reduce Motion and end with the spring the animator plays (FR4, FR4a), and make the FR4a glide test set --_astryx-motion-tempo and --_astryx-motion-character instead of attributes nothing reads.

[Automated review]

it('lasts what a CSS part timed from springs.settle lasts in the same theme, so the two land together (spec:AST-108 FR4a)', async () => {
await changeOnce(
'',
'data-astryx-theme="lively" data-astryx-motion-tempo="1.2" data-astryx-motion-character="expressive"',

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 at this head reads data-astryx-motion-tempo or data-astryx-motion-character: themeMotionKnobs reads the --_astryx-motion-tempo and --_astryx-motion-character custom properties. So this case runs at Astryx's own knobs and would still pass if the glide ignored the theme. Passing '--_astryx-motion-tempo: 1.2; --_astryx-motion-character: expressive' as the style argument, as the tempo case above does, would make it test what its name says.

// 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 reducedMotion: 'always' with the person's own setting off, this rule never applies, so the loop keeps its full speed; spec:AST-108 FR6 asks every style keyed on prefers-reduced-motion to key on the theme's mark too. Skeleton's pulse and the other component styles and script readers the record now lists as a known gap behave the same way.


- An internal, pure `resolveThemeMotion(theme)` returns a theme's motion
knobs, durations, and easings for server code; it touches no DOM and has no
import-time side effects (`spec:AST-108` FR6). The animator reads the knobs

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 replaces the record's accepted statement that resolveThemeMotion returns role tables "for the animator and for server code" with a new reading: the animator reads the theme's custom properties and the resolver serves server code. An engineering owner needs to approve that, for example by commenting /approve-spec <full head SHA>.

This branch was successfully deployed

1 active deployment
Preview — 491f6ac0 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