Skip to content

chore(core): UIKit's spring, decay, rubber-band and projection math - #8054

Merged
cixzhang merged 29 commits into
meta:mainfrom
vjeux:wip/uikit-pr-m2
Oct 26, 2026
Merged

cixzhang merged 29 commits into
meta:mainfrom
vjeux:wip/uikit-pr-m2

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8053 (and its predecessors); review the last two commits. Until #8053 merges at its approved head, this branch also carries two table commits for it, press.highlightOutCurve (M-PR1) and the velocity note (spec:AST-100 FR2), and its merge with main.

User need

Touch presentations need UIKit's motion math: a sheet released at 1,200 pt/s must land on the detent UIKit would pick, with UIKit's spring and the finger's velocity, and a scroll-like surface must coast and resist like a scroll view.

Problem and solution fit

Pure functions for spec:AST-094 FR3:

  • spring.ts: the damped spring in closed form for all three regimes; conversions between SwiftUI's duration and bounce, response and damping ratio, Core Animation's mass, stiffness and damping, and UIKit's legacy usingSpringWithDamping (from UIKit's measured grid); Core Animation's settling duration; a spring chasing a target moving at constant speed. Overdamping clamps to critical, as Core Animation does.
  • decay.ts, rubberBand.ts, projection.ts: UIScrollView's coast, its resistance past an edge (and inverse), and the release target nearest the projected rest, paging included.
  • uikit/values.test.ts: plays the constants table through this math.

One intent at 2,050 lines: each module ships with the tests that pin it to independent references.

Current specification

spec:AST-094 FR3.

Public delta and caller burden

None: internal, not exported, and no component uses it yet. The changeset is [chore].

Design and alternatives

  • toSpring normalizes a description kept in its source's vocabulary.
  • The decay is the continuous exponential UIScrollView keeps (ln r per millisecond); the projection is WWDC18's geometric series, the target UIKit reports.
  • Rejected: numeric integration; interruption needs exact value and velocity at any time.

Evidence

  • Spring against an independent fourth-order Runge-Kutta integration over a sweep of damping ratios, responses and velocities, a port of WebKit's spring solver, and the WWDC23 conversion equations.
  • Against UIKit on the iOS 26.2 simulator: Core Animation's settlingDuration grid (underdamped to the last digit, critical within 0.02 %), the durations UIKit gives its spring APIs, and the legacy grid from ζ 0.1 to 1.
  • Decay against a per-millisecond simulation; rubber band against UIScrollView's measured overscroll (within 0.5 pt).

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.

Testing

  • pnpm exec vitest run --project ui packages/core/src/motion: 7 files, 316 tests passed.
  • pnpm build, pnpm typecheck, ui and node suites, pnpm check:repo: clean.
  • pnpm -F @astryxdesign/core typecheck: clean.
  • ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/motion: clean.
  • Prettier, pnpm check:changesets, pnpm check:repo (pre-commit): clean.
  • Size: 11 files, 2,066 lines added, 7 removed.

Stack, in merge order: #8053, #8058, #8059, #8060, #8061, #8054, #8257, #8055, #8056, #8067, #8258, #8071, #8072, #8073, #8074, #8076, #8340; #8067 also follows #8043, #8044, #8348, #8045 and #8446. #8062 is on main.

@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 26, 2026 2:54am 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 26, 2026, 3:15 AM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 33.8KB 33.8KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

48 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 556,900
RichTextEditor Markdown parity: side by side probe light 538,816
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 28 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@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 looks ready to merge: the spring, coast, rubber-band and projection math matches current spec:AST-094 FR3, and its tests pin it to an independent integration and to values read from UIKit, failing whenever a formula or constant changes. The diff is 2,107 lines, over the usual 2,000-line limit, and an engineering owner has waived the split for this math. Two non-blocking follow-ups: update the description, since #8053 merged without press.highlightOutCurve and the velocity note and its size and “review the last two commits” lines no longer match the diff, and give the legacy spring’s remaining guesses in spring.ts an open measurement task, because MEASUREMENTS.md lists M-K1 as done.

[Automated review]

* read) and below 0.1 (extrapolated as c ∝ 1 ÷ ζ). A non-zero initial
* velocity shifts UIKit's root slightly, and at low ratios makes its solver
* jump to a far smaller one (ζ 0.1, velocity 1 → ω₀·D 9.74); neither is
* modeled. Measurement task M-K1.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: MEASUREMENTS.md lists M-K1 as done (“ratios 0.1–1 in 0.05 steps”), so these guesses (off-grid ratios, ζ under 0.1, the velocity-dependent root) have no open task that can replace them. An open row there, cited here, would give them one.

One conflict: main's uikit/gestures.ts is now one export per group (meta#8468).
It takes main's file, with this branch's AST-100 note on the velocity blend
placed in UIKIT_VELOCITY.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
… the press curve pinned) into the animator
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…he animator

# Conflicts:
#	packages/core/src/motion/uikit/scroll.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.

This looks ready to merge: the spring, coast, rubber-band and projection math matches current spec:AST-094 FR3, and its tests pin it to an independent integration and to values read from UIKit, failing whenever a formula or constant changes. The diff is 2,107 lines, over the usual 2,000-line limit, and an engineering owner has waived the split for this math. A few non-blocking follow-ups:

  • The test "breaks an exact tie toward the direction of travel" (projection.test.ts:96) projects 1e-7 px off the midpoint, so it never reaches the tie-break; a release at 49 px moving 1,000 px/s with rate 0.5 lands exactly on it.
  • Two guesses lack an open measurement task: selectPage's at-most-one-page limit, which M-PG1 still asks, and the legacy spring's off-grid ratios, whose M-K1 is listed as done.
  • The description still says the branch carries press.highlightOutCurve and the velocity note until #8053 merges; #8053 merged without them, so they land here, and the size and "review the last two commits" lines no longer match.

[Automated review]

Comment on lines +96 to +105
it('breaks an exact tie toward the direction of travel', () => {
expect(
selectSnapPoint([0, 100], {position: 50, velocity: 0.0001, rate: 0.5})
.value,
).toBe(100);
expect(
selectSnapPoint([0, 100], {position: 50, velocity: -0.0001, rate: 0.5})
.value,
).toBe(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.

Non-blocking: these releases project to 50 ± 1e-7, so one end is nearer by 2e-7, outside the 1e-9 tie window in selectSnapPoint, and the tie-break never runs; inverting it (isTie && !isAhead) still passes the suite. A release that lands exactly on the midpoint exercises it:

expect(selectSnapPoint([0, 100], {position: 49, velocity: 1000, rate: 0.5}).value).toBe(100);
expect(selectSnapPoint([0, 100], {position: 51, velocity: -1000, rate: 0.5}).value).toBe(0);

Comment on lines +117 to +119
* UIScrollView paging (`isPagingEnabled`): the page a release settles on, at
* most one page from `currentPage`, chosen by projecting the release with
* the paging projection rate (uikit/scroll.ts; a GUESS until measured).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: only the rate is marked a guess here, but MEASUREMENTS.md still lists "at most one page?" as open under M-PG1. Labelling the one-page limit (maxSteps: 1) a GUESS under M-PG1 as well would keep its first caller from inheriting an unmarked guess.

cixzhang pushed a commit that referenced this pull request Oct 26, 2026
…rena (#8058)

Depends on #8053 (and its predecessors); review the last commit.

<!-- Template: other.md (internal infrastructure: nothing is exported, so new-feature.md does not fit). -->

## Primary intent

Add the core of an internal gesture-recognizer system for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch sequence. Recognizers, the DOM host and component migrations follow in their own pull requests; nothing is exported from the package.

## User or maintainer impact

Touch components each carry their own pointer handling (sheet drags, swipe actions, toast dismissal, long press, press feedback), with their own answer to "who owns this touch". Two can act on one touch, and a second finger can break a drag. With one arena every component gets UIKit's answers: exclusive recognition by default, `requireToFail`, simultaneous recognition only on request, a deterministic resolution order, and the view's touches cancelled (no click, no pressed state) once a gesture takes them.

## Problem and solution fit

- Why does it happen? Ownership of a touch is decided separately in each component.
- Why does that hurt? Ghost clicks, double actions, and rules that differ from component to component.
- Why does it matter? Touch presentations aim at UIKit parity, and UIKit settles this in one place.

Deltas:

- `GestureRecognizer`:
  - UIKit's states (possible, began, changed, ended, cancelled, failed) and allowed transitions;
  - discrete and continuous recognizers;
  - `requireToFail`, `recognizeSimultaneouslyWith`, `shouldBegin`, `shouldReceiveTouch`;
  - `cancelsTouchesInView`, `delaysTouchesBegan` and `delaysTouchesEnded`, `allowedPointerTypes`.
- `GestureArena`, with no DOM:
  - participants per touch sequence, resolution and claims;
  - delivery to the view;
  - click dispositions: allow, suppress, or defer and replay if the recognizer fails;
  - timers as deadlines on the event clock.

## Authority

`spec:AST-094` FR15 (arbitration) and FR16 (the event clock), proposed; pending owner review.

## Change and evidence

The arena's rules are tested with scripted recognizers, test doubles that begin, fail or wait on command. The rules hold independently of any real recognizer:

- exclusivity and simultaneity;
- require-to-fail: waiting, failing, delegate requirements, idle recognizers;
- delegate hooks and innermost resolution;
- independent touches;
- delivery to the view, deferred and replayed clicks, the browser taking a touch;
- cancellation;
- a deadline that runs at its own time before a late input.

Size: 2,198 lines, about 400 of them tests. This is one indivisible intent: the recognizer base and the arena call each other, so neither builds or tests alone, and the doubles-only tests are the smallest proof of the rules.

## Scope

- [x] This PR has one primary intent.
- [x] Independently removable deltas were removed or split.
- [x] Public text and artifacts contain no internal Meta context.

## Testing

- `pnpm exec vitest run --project ui packages/core/src/gestures packages/core/src/motion`: 159 tests passed.
- `tsc --project packages/core/tsconfig.json --noEmit`: clean.
- `eslint packages/core/src/gestures`: clean.
- `pnpm check:changesets`, `node scripts/release/changeset-coverage.mjs pr --base origin/main`: clean.
- Size: 6 files, 2,205 lines added.


Stack (merge in this order): #8052 spec → #8053 constants table → #8058 gesture core → #8059 tap and long press → #8060 pan → #8061 other recognizers → #8054 math → #8055 animator → #8056 animator curves; the gesture DOM host, press tracking and browser proof follow the animator.
@cixzhang
cixzhang merged commit b58ca63 into meta:main Oct 26, 2026
62 checks passed
@vjeux
vjeux deleted the wip/uikit-pr-m2 branch October 26, 2026 03:36
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
… plan

MEASUREMENTS.md and the motion README take this branch's lines where they
conflict: its M-RM1 row (the played 200 ms fade) and the animator rows and
section that meta#8054 does not carry.
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
No conflicts. Brings main with meta#8058 landed and meta#8054 merged, plus meta#8060's
per-group pan and velocity imports and its FR19 decide-by-10-px fix.
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
The touch foundation's fold, mergeable against main cd4aca6: her chain
on main (meta#8058, meta#8054, meta#8059, meta#8060) and meta#8258's rework, which keeps touch
motion internal (no theme surface): the generated role table, its script and
the role kinds file are gone, and a table registers only the roles it lists.
The motion doc and its golden take meta#8076's side; the role kinds file stays
deleted.
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…cation export reverted; overlap and borrow fixes)

Conflicts in animator.ts and the motion README: meta#8056's Reduce Motion plan
wins, which already carries a waiting move to the next call's invisible
moment and keeps a running dip's borrow. meta#8055's new tests run against the
plan, the re-timing one advancing the clock to the landing and the
preference change written through the media stub.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8076's head is F1's fold on the maintainer's chain now on main (meta#8058,
meta#8054, meta#8059, meta#8060): touch motion is internal (the tables register their
values as internal motion roles, with no theme surface), and the
`isRtlElement` resolver.

Conflicts, resolved:
- The theme-roles changeset and the motion guide take meta#8076's side; their
  sentence on ChatLayout's Reduce Motion cross-fade goes with the theme
  surface.
- The CLI goldens for the empty icon set take meta#8076's side and regenerate.
- keyboardModel.test.ts reads the internal role registry (`motionRoleIn`)
  now that the generated role-kind table is gone, and no longer names a
  theme.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026

This branch was successfully deployed

1 active deployment
Preview — 3dca43f6 Deployed Oct 26, 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