Skip to content

feat(core): the animator plays each table spring and curve through its motion role - #8258

Closed
vjeux wants to merge 514 commits into
meta:mainfrom
vjeux:uikit/f1-theme-motion-animator
Closed

vjeux wants to merge 514 commits into
meta:mainfrom
vjeux:uikit/f1-theme-motion-animator

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8067 (#8257 is merged). Review its own commits; the merges bring them forward.

Why

Each touch motion names its spring or curve at the call site. Any later decision about how a kind of motion should feel, such as a theme's tempo and character (#8677), would have to change every one of those call sites.

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-094 FR4 names the motion roles those components play, and #8257 registers exactly those roles by their table paths.

Options and tradeoffs

  • Resolve roles in each component: no animator change, but every component must remember to, and a theme knob would need every call site.
  • Resolve roles in the animator: one place every touch motion passes through, at the cost of a table lookup when a motion starts.
  • Read roles from CSS custom properties: themable directly, but a style read at every motion start, and roles stay internal for now (FR4, DEC-11).

Why this choice

The animator is the one place every touch motion passes through, so resolving there changes nothing at call sites; #8677 adds the theme's knobs at that same place (spec:AST-108 FR3).

What changes

  • resolveMotion(element, role) returns a role's value: its table value while touch motion stays internal. A value that is no role, such as a spring written inline, comes back unchanged.
  • The animator resolves every spring and curve it is handed, the Reduce Motion fade's curve and a coast's edge spring; call sites keep passing table entries.
  • A table registers its roles when it is first used, so a component's bundle carries only the tables it imports.

How to check it

  1. Open a BottomSheet story on a touch screen before and after this change: it presents and dismisses exactly the same, because every role resolves to its table value.
  2. Set a role's custom property on an element around the sheet: the sheet's spring still plays as measured, since roles read no CSS.
  3. Run pnpm exec vitest run packages/core/src/motion/resolveMotion.test.ts: springs, curves and durations play their table values whatever a scope sets.

Evidence

  • resolveMotion.test.ts; its style stub answers custom properties, so a reintroduced style read fails it.
  • The browser proof shows the measured spring in Chromium and WebKit with a role's variable set.

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.

@vercel

vercel Bot commented Oct 24, 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 Oct 27, 2026 1:14pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 27, 2026, 2:02 PM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@vjeux
vjeux force-pushed the uikit/f1-theme-motion-animator branch from 48c0c4c to 65fb64b Compare October 24, 2026 16:40

@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: draft spec:AST-094 FR4 and DEC-1 say touch presentations read their motion from the UIKit table on every theme, while this pull request lets defineTheme({motion}) retune that motion and makes the animator play the theme's value. An engineering owner (the author may record it, as one) either amends FR4 and DEC-1 to admit motion roles and records approval by commenting /approve-spec <full head SHA> on the amended head, or keeps FR4, in which case the motion roles and the animator's theme resolution come out of this stack. Whatever the decision, please also:

  • Rebase once the earlier pull requests in the stack merge, so this diff is only its own commit; it currently carries about 14,500 authored lines.
  • Drop the new platform adaptation condition, or amend spec:AST-012 FR2 first, since that spec keeps the condition list closed.
  • Hold back the astryx docs motion touch-motion section, the template text and the [feat] changeset until a component actually reads a role.
  • Make defineTheme reject wrong-kind and unknown role ids without relying on the motion tables having been imported first.

[Automated review]

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-012 FR2 keeps the when vocabulary closed (width, pointer, contrast, motion) and says future fields need an explicit addition to it. Neither AST-012 nor the draft AST-094 adds platform, so this field needs an AST-012 amendment first, or it should come out of this stack.

`${location}: a motion role is named by its table path, such as "sheet.present" or "springs.navigationPush".`,
);
}
const registered = motionRole(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

motionRole(id) is undefined unless a motion table module was imported earlier, and @astryxdesign/core/theme loads none. So in a real defineTheme call, durations: {'springs.sheetPresent': 300} and a misspelled id are both written without error, and the same input throws once a table is loaded. Loading the tables (or a static role manifest) here, and rejecting unknown ids, would make validation deterministic.

watch(doc);
const scope = element.closest('[data-astryx-theme]') ?? doc.documentElement;
let cached = scopes.get(scope);
if (cached == null || cached.epoch !== epoch) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

epoch moves only when a MutationObserver callback runs, a microtask after the attribute change, so a theme switch followed by a motion in the same task still reads the old scope values. Calling observer.takeRecords() here, and bumping the epoch when it returns records, would close that window.

Comment thread packages/cli/assets/docs/motion.doc.mjs Outdated
},
{
type: 'prose',
text: 'A theme writes one CSS variable per role it sets, `--motion-` followed by the path in kebab case: a spring as its duration and bounce (`0.6s 0.1`), a duration in milliseconds, a curve as `cubic-bezier()`. Components read the variable when a motion starts and fall back to the default when it is empty. Gesture thresholds, the delays that decide whether a touch scrolls or presses, and haptics are not motion roles. The `platform` condition matches iOS and iPadOS browsers (`ios`) or every other browser (`other`).',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No component reads these variables at this head: nothing outside motion/ imports the animator or resolveMotion, and utils/pressGesture.ts keeps its own press timings. A theme author following this section sees no change. Could the section wait for the pull request that wires the first component?

cixzhang and others added 24 commits October 26, 2026 14:40
…g running

Landing a waiting Reduce Motion move on the node just detached rebuilt
every channel the landing touched there, so an axis still moving (a coast
on y while x waited in a dip) was baked onto the detached node again and
promoted it, leaving an animation, timers and will-change behind. After the
landing, the unmount now cancels what runs, clears the retire timers and
demotes, before the target goes back to none. The test fails on the
previous head.
The README's busy-main-thread paragraph and busyWindowProof's JSDoc said an
undelivered frame is never a miss; when fewer than minJudgedFrames frames
were delivered in the judged part, its span counts every frame. One clause
in each says so.
…aves the detached node idle)) as a dependency
…ose finds it

The unmount kept the node of the first attach(null) in the task, so after
attach(null), attach(next), attach(null) it landed a waiting Reduce Motion
move on the node already replaced, and next stayed at its dip's opacity.
A dispose right after attach(null) landed on no element, leaving the
detached node at opacity 0. The latest detached node is now kept while its
unmount is pending; the unmount and dispose both land there. Two tests
fail on the previous head.
…ile it ran

A page opened after startCompositorTrace and closed before stop() was still
credited: its frames are in the browser-wide record, and neither check saw
it. The record now watches for new pages from start to stop, in the page's
own context and through the browser's target discovery in every context,
and stop() throws if one opened. Negative controls: a page opened and
closed mid-record, and one in another context, both make stop() refuse;
alone, it records.
…the latest detached node)) as a dependency
Inside a trace, waitForAnimation and watch.started() read the element through
handles, which find no element rather than wait for one, so a wait on an
element the page removed rejected with "no animation started on the
element" instead of "the element is not in the document", unlike the
untraced path. At its deadline, a traced wait with no element now rejects
as missing. Traced twin of the missing-element self-test; it fails on the
previous code.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…rred unmount runs

It read will-change right after attach(null), before the unmount's
microtask, so it passed while that microtask could still promote the
element and leave timers behind. It now awaits the microtask, then checks
will-change and that no timer is left. It fails on meta#8521's earlier head
(97436b2), before the unmount cleared what the landing rebuilt.
… awaits its microtask)) as a dependency
meta#8056 landed squashed; main's motion README, animator and its tests equal
meta#8056's final head (2b264d3), so this PR's versions, built on that head,
stand.
…tor's browser proofs

The motion README's Files table is the union: main's rows, with meta#8663's
resolveSnapPoint.ts and selectPage.ts, plus this branch's, in main's
order.

# Conflicts:
#	packages/core/src/motion/README.md
…e animator

The README's Animator example and useAnimator's example comment keep this
branch's wording: a component hands the animator a table spring, which it
plays as that spring's motion role.

@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 are needed before this can be approved: resolveMotion returns each role's table value, as spec:AST-094 FR4 and DEC-11 require, but the diff still carries the unmerged stack beneath it, 5,740 authored lines against this pull request's own 384. Every carried copy matches its pull request's current head, so what remains is ordering: let #8067 and the pull requests beneath it land, then update this branch from main so the diff holds only its own commits. Please also add a changeset naming @astryxdesign/core for this pull request's own changes, since the changeset coverage check passes today only through the carried #8522 and #8067 changesets and fails over the own commits alone. The description below its update line still describes the withdrawn style read and an own size of 534 added lines; trimming it would help the next reader.

[Automated review]

The changeset coverage check passed only through the changesets this
branch carries from the pull requests beneath it. This one names
@astryxdesign/core for this pull request's own change: the animator
plays each spring and curve, and a coast its edge spring, through the
motion role the value plays, which is its table value while touch motion
stays internal.

@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 are needed before this can be approved: resolveMotion returns each role's table value, as spec:AST-094 FR4 and DEC-11 require, and the new changeset covers this pull request's own changes, but the diff still carries the unmerged stack beneath it, 5,747 authored lines against this pull request's own 391. Every carried copy matches its pull request's current head, so what remains is ordering: let #8067 and the pull requests beneath it land, then update this branch from main so the diff holds only its own commits.

[Automated review]

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Closed as carried by #8360: resolveMotion, the motion roles and the coast's edge spring are identical there, its animator resolves each role it plays, and the Reduce Motion proof reads the dip while it runs (fe7f5f9). The remaining review item was ordering. Branch kept.

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants