Skip to content
Closed
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c784084
spec(AST-094): touch presentations feel native by default
cixzhang Oct 24, 2026
0c35c43
chore(core): the UIKit constants table for touch presentations
vjeux Oct 24, 2026
17c181b
chore(core): UIKit's spring, decay, rubber-band and projection math
vjeux Oct 24, 2026
6bd2851
chore(core): the constants table names only what exists, and dates ev…
vjeux Oct 24, 2026
76be493
fix(core): paging past the last page lands on a real page
vjeux Oct 24, 2026
4b3de64
Merge main into the constants table (lab typechecks with its tests)
vjeux Oct 25, 2026
530315f
Merge #8053 (main merged in) into the motion math
vjeux Oct 25, 2026
7fd8edf
chore(core): swipe actions' UIKit values and release rule
vjeux Oct 24, 2026
e7df427
perf(core): the gesture table's sources move to JSDoc, out of the bundle
vjeux Oct 25, 2026
439dcc3
docs(spec): the draft AST-094 copy leaves this stack
vjeux Oct 25, 2026
0bf13da
Merge main (the deploy typecheck gate test reads the step as written)…
vjeux Oct 25, 2026
bbbcb60
refactor(core): the platform presets are named for their use
vjeux Oct 25, 2026
7c5f71c
Merge main (67dc45f66d3) into #8054
cixzhang Oct 25, 2026
03f66c3
fix(core): the UIKit table keeps only what a touch presentation plays…
cixzhang Oct 25, 2026
1133bf9
Merge remote-tracking branch 'origin/main' into m8053
cixzhang Oct 25, 2026
eded944
style(core): MEASUREMENTS.md table widths after the dropped rows
cixzhang Oct 25, 2026
f9d0900
feat(core): the press release's curve is a press value
vjeux Oct 25, 2026
9db9315
docs(core): the velocity weight says a rested finger releases at 0
vjeux Oct 25, 2026
5d3cab6
Merge #8053 (the table keeps only what a touch presentation plays; th…
vjeux Oct 25, 2026
7e36bcd
fix(core): the table's notes point at what exists, and the press entr…
cixzhang Oct 25, 2026
c1fa660
Merge #8053's notes and press pins into the press release's curve and…
vjeux Oct 25, 2026
59a748f
Merge #8053 (the table's notes point at what exists; the press entrie…
vjeux Oct 25, 2026
6ae3390
Merge main (#8043's interaction proofs) into the UIKit constants table
vjeux Oct 25, 2026
51b3ecb
Merge #8053 (main with #8043's interaction proofs) into the motion math
vjeux Oct 25, 2026
1f751c9
docs(core): M-S2 feeds scroll.decelerationRate, where the coast readi…
cixzhang Oct 25, 2026
96f8240
Merge #8053 (M-S2 feeds scroll.decelerationRate) into the press relea…
vjeux Oct 25, 2026
1ae9200
Merge #8053 (M-S2 feeds scroll.decelerationRate) into the motion math
vjeux Oct 25, 2026
14935b0
Merge #8054 (1ae92006cf) into #8069
vjeux Oct 25, 2026
7234d44
Merge main (99ad3f6d906) into the motion math
cixzhang Oct 26, 2026
25330a3
Merge #8054 (7234d4409b, main 99ad3f6d90) into #8069
vjeux Oct 26, 2026
d3a2e41
fix(core): toSpring's clamp, FR6 for the rubber band's inverse, the p…
cixzhang Oct 26, 2026
3dca43f
Merge main (16770e30070) into the motion math
cixzhang Oct 26, 2026
3918f31
Merge #8054's final head (3dca43f62a) into #8069
vjeux Oct 26, 2026
13ecb14
Merge main (52c1b24fb8, #8054 squashed as b58ca635e6) into #8069
vjeux Oct 26, 2026
a269997
Merge main (04b0e64ac4) into #8069
vjeux Oct 26, 2026
fa6c198
Merge main (bb12cfbd60) into #8069
vjeux Oct 27, 2026
f5f7346
Merge main (ef6d25b715) into #8069
vjeux Oct 27, 2026
bbcb3f4
docs(core): the swipe values' changeset counts the releases its tests…
vjeux Oct 27, 2026
e805797
Merge main (6aea9bda01) into #8069
vjeux Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/uikit-swipe-actions-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: the UIKit values of a swipe-action row (the swipe threshold, the spring the row follows the finger and settles with, the touch claim distance the web needs) and the release rule that picks closed, open or fire (a fast release goes the way it moves, a slow one by where it is, and only a release past the commit point fires), checked against 50 releases read from UIKit. Not exported from the package entry point.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

swipeRelease.test.ts checks 44 of batch 10a's 51 releases (the table at lines 32-80, with a 45th commented out at 42-44), not 50: add the six missing releases, or say 44. This text ships in the @astryxdesign/core changelog.


@vjeux
190 changes: 190 additions & 0 deletions packages/core/src/Item/swipeRelease.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file swipeRelease.test.ts
* @input vitest; swipeRelease.ts and the UIKit constants table
* @output The release rule against the UIKit lab's releases; the commit
* point and the resistance past it; the swipe spring is the one UIKit
* reported
* @position Unit tests of packages/core/src/Item/swipeRelease.ts
*/

import {describe, expect, it} from 'vitest';
import {
dampingRatioOf,
naturalFrequency,
settlingDuration,
toSpring,
} from '../motion/spring';
import {swipeActions} from '../motion/uikit/swipeActions';
import {commitPoint, resistedTravel, resolveRelease} from './swipeRelease';

describe('resolveRelease', () => {
// The UIKit lab's releases (batch 10a: plain table, 402 pt rows, two
// trailing actions, a 156.67 pt open width; the full-swipe point far beyond
// them), as the swipe pan reported them when the finger lifted: the
// swipe's travel (past the 25 pt the pan needs to begin; negative when the
// finger came back past where it began) and its velocity in pt/s, positive
// toward open. A scripted "closing" release can still be opening when it
// lifts (the 36 pt one at +458), so the reported values are the inputs.
const OPEN_WIDTH = 156.67;
const FAR = 373;
const RELEASES: [travel: number, velocity: number, lands: string][] = [
[35, -19, 'closed'],
[65, 490, 'open'],
[125, 1506, 'open'],
[5, -492, 'closed'],
[-55, -1532, 'closed'],
[95, -16, 'open'],
[125, 478, 'open'],
[185, 1565, 'open'],
[65, -468, 'closed'],
// [115, +135]: closed. Scripted as a 1500 pt/s closing flick, it lifted
// with the pan reporting a slow opening one: the one release of the 51
// neither this rule nor projection explains.
[-67, -2545, 'closed'],
[-47.7, -1025, 'closed'],
[-3, -256, 'closed'],
[12.3, -22, 'closed'],
[30.3, 293, 'open'],
[64, 1058, 'open'],
[132.3, 1983, 'open'],
[-67, -2060, 'closed'],
[-10, -1087, 'closed'],
[35.7, 458, 'open'],
[50, -18, 'closed'],
[63, 286, 'open'],
[93, 1372, 'open'],
[170, 2031, 'open'],
[1.7, -2168, 'closed'],
[27.3, -996, 'closed'],
[69.3, -287, 'closed'],
[87.7, -5, 'open'],
[100.3, 148, 'open'],
[147.3, 1271, 'open'],
[190.3, 608, 'open'],
[39.3, -2052, 'closed'],
[65, -983, 'closed'],
[107, -308, 'closed'],
[125, -18, 'open'],
[143, 347, 'open'],
[185, 948, 'open'],
[245, 2082, 'open'],
[42.3, -1975, 'closed'],
[102.7, -991, 'closed'],
[144.3, -283, 'closed'],
[162.3, -19, 'open'],
[180.3, 271, 'open'],
[222.7, 1002, 'open'],
[282.3, 1971, 'open'],
];

it.each(RELEASES)(
'lands where UIKit did: %s pt of swipe at %s pt/s → %s',
(travel, velocity, lands) => {
expect(
resolveRelease({
behavior: 'reveal',
commitAt: FAR,
openWidth: OPEN_WIDTH,
travel: Math.max(0, travel),
velocity,
}),
).toBe(lands);
},
);

it('goes the way a release moves from about 50 pt/s, and by where it is below that (batches 38c, 38d)', () => {
// 204.67 pt open width, released at 0.7 of it: past half. The speeds
// are the pan's at its end, as UIKit read them.
const base = {
behavior: 'reveal' as const,
commitAt: 325,
openWidth: 204.67,
travel: 143,
};
for (const velocity of [-19.9, -21.2, -35.7, -40.7, 0, 56.1]) {
expect(resolveRelease({...base, velocity})).toBe('open');
}
for (const velocity of [-56.1, -56.5, -68.5, -105, -200, -308]) {
expect(resolveRelease({...base, velocity})).toBe('closed');
}
});

it('closes a release past the open width that is moving back, as UIKit did (batch 38)', () => {
// 204.67 pt open width; released about 300 pt out while coming back at
// 300 pt/s: UIKit closed the row, though it would coast to rest past half
// the open width.
expect(
resolveRelease({
behavior: 'reveal',
commitAt: 325,
openWidth: 204.67,
travel: 305,
velocity: -300,
}),
).toBe('closed');
// The same release at rest, or still opening, stays open.
for (const velocity of [0, 300]) {
expect(
resolveRelease({
behavior: 'reveal',
commitAt: 325,
openWidth: 204.67,
travel: 305,
velocity,
}),
).toBe('open');
}
});

it('fires under reveal on a release past the commit point, whatever the velocity, and never on a flick short of it', () => {
const base = {behavior: 'reveal' as const, commitAt: 192, openWidth: 144};
expect(resolveRelease({...base, travel: 192, velocity: 0})).toBe('perform');
expect(resolveRelease({...base, travel: 200, velocity: -2000})).toBe(
'perform',
);
expect(resolveRelease({...base, travel: 44, velocity: 3000})).toBe('open');
expect(resolveRelease({...base, travel: 191, velocity: 3000})).toBe('open');
});

it('fires under commit only past the commit point, and never rests', () => {
const base = {behavior: 'commit' as const, commitAt: 150, openWidth: 72};
expect(resolveRelease({...base, travel: 150, velocity: 0})).toBe('perform');
expect(resolveRelease({...base, travel: 160, velocity: -1000})).toBe(
'perform',
);
expect(resolveRelease({...base, travel: 100, velocity: 0})).toBe('closed');
expect(resolveRelease({...base, travel: 40, velocity: 3000})).toBe(
'closed',
);
});
});

describe('commitPoint and resistedTravel', () => {
it('commits past the panel, or past the fraction of the row when that is further', () => {
expect(commitPoint(72, 300)).toBe(150);
expect(commitPoint(144, 300)).toBe(192);
});

it('follows the finger up to the commit point and resists past it', () => {
expect(resistedTravel(150, 150)).toBe(150);
expect(resistedTravel(250, 150)).toBeCloseTo(
150 + 100 * swipeActions.overdragResistance,
10,
);
});
});

describe('the swipe spring', () => {
it('is the one UIKit reported: critically damped at 10 rad/s, settling in 0.9235 s', () => {
for (const description of [swipeActions.tracking, swipeActions.settle]) {
const spring = toSpring(description);
expect(dampingRatioOf(spring)).toBeCloseTo(1, 10);
expect(naturalFrequency(spring)).toBeCloseTo(10, 10);
// UIKit's settlingDuration for it, within the 0.02 % the solver's
// critically damped branch is documented to land.
expect(settlingDuration(spring)).toBeCloseTo(0.9235, 3);
}
});
});
85 changes: 85 additions & 0 deletions packages/core/src/Item/swipeRelease.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file swipeRelease.ts
* @input The UIKit constants table (motion/uikit/swipeActions.ts)
* @output Exports resolveRelease (where a swipe-action release lands: closed,
* open, or the outermost entry fires), commitPoint and resistedTravel
* @position Pure pieces of Item's swipe actions, beside useSwipeAction;
* tested by swipeRelease.test.ts against the UIKit lab's releases
*
* A UIKit list row decides a release by its speed first. A release at
* `releaseVelocity` or faster goes the way it moves: it opens the row when
* it opens and closes it when it closes, from anywhere short of the commit
* point. A slower one goes by where it is, against `openFraction` of the
* open width. So a flick opens the row even from a short drag and closes it
* from anywhere, and a flick never fires an action: only a release past the
* commit point does. Travel and velocity here are logical: measured toward
* the side being uncovered, whatever the writing direction.
*/

import {swipeActions} from '../motion/uikit/swipeActions';

/** What a swipe does (Item's `swipeBehavior`). */
export type SwipeReleaseBehavior = 'reveal' | 'commit';

/** Where a release lands. */
export type SwipeRelease = 'closed' | 'open' | 'perform';

/**
* Where a release lands. `travel` (px, ≥ 0) and `velocity` (px/s) are
* logical: positive toward the side being uncovered. A release past the
* commit point fires the outermost entry, whatever its velocity; under
* `commit` nothing else fires and nothing rests. Under `reveal` a release at
* `releaseVelocity` or faster opens the row if it is opening and closes it if
* it is closing; a slower one opens it past `openFraction` of its open width
* and closes it short of that.
*/
export function resolveRelease({
travel,
velocity,
openWidth,
commitAt,
behavior,
}: {
travel: number;
velocity: number;
openWidth: number;
commitAt: number;
behavior: SwipeReleaseBehavior;
}): SwipeRelease {
if (travel >= commitAt) {
return 'perform';
}
if (behavior === 'commit') {
return 'closed';
}
if (velocity >= swipeActions.releaseVelocity) {
return 'open';
}
if (velocity <= -swipeActions.releaseVelocity) {
return 'closed';
}
Comment on lines +54 to +62

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 reveal a fling short of the commit point opens here, and a release past the panel's width that is moving back closes; under commit a fling short of the commit point closes. Current spec:AST-057 FR5 requires a fling to fire the outermost entry and a release past the panel's width to rest open, so before useSwipeAction calls this, FR5 needs an owner's amendment or this branch needs to conform.

return travel >= swipeActions.openFraction * openWidth ? 'open' : 'closed';
}

/**
* The commit point: `commitPastPanel` past the open width, or
* `commitFraction` of the row when that is further.
*/
export function commitPoint(openWidth: number, rowWidth: number): number {
return Math.max(
openWidth + swipeActions.commitPastPanel,
rowWidth * swipeActions.commitFraction,
);
}

/**
* The row's travel for the finger's: one for one up to the commit point,
* `overdragResistance` per pixel past it.
*/
export function resistedTravel(raw: number, commitAt: number): number {
return raw <= commitAt
? raw
: commitAt + (raw - commitAt) * swipeActions.overdragResistance;
}
16 changes: 16 additions & 0 deletions packages/core/src/motion/MEASUREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,19 @@ fades in, with Prefer Cross-Fade Transitions on or off.
| M-CM4 | Reduce Motion press feedback | Probe app with Reduce Motion on: long press, then dismiss | press feedback, lift and dismiss animations under Reduce Motion | A, B |
| M-CM5 | Commit haptic stand-in | An Android device: `navigator.vibrate` patterns against the platform's HapticFeedbackConstants for the context-click moment | the pattern that matches; not a UIKit parity value (as M-G18) | H |
| M-CM6 | Press thresholds | Probe app: repeated long presses at random vsync phases | from touch receipt and from the HID timestamp, the tick before and the tick at the press observer's began and at the click commit; the intersection pins the 150 ms and 400 ms thresholds | C |

## Swipe actions (UIContextualAction)

UIKit lab host app: a plain `UITableView` (402 and 375 pt wide, 60 pt rows)
with one, two and three trailing `UIContextualAction`s (the last one
`.destructive`) and one leading `.normal` action; drags driven with known
velocity, the touch stream, the swipe handler's tracking and move calls and
every animator and `CAAnimation` logged.

| ID | Value | Gesture | Extract | Method |
| ------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------ |
| M-SA01 | `swipeActions.openFraction` | swipes released at rest at 0.3, 0.35, 0.4, 0.45, 0.5, 0.55 × the open width (past the 25 pt threshold) | open or closed per release: the fraction | C |
| M-SA02 | `swipeActions.commitPastPanel`, `.commitFraction` | slow drag out past the full-swipe point and back; releases at rest and at ±1000 pt/s around it; 1, 2, 3 actions; 375, 402, 440 pt wide | the offsets where the primary action expands and collapses; perform or not per release; `confirmationThreshold` | C, D |
| M-SA03 | `swipeActions.overdragResistance` | drag to the row width + 200 pt at 100 pt/s, with `performsFirstActionWithFullSwipe` on and off | the row's offset against the finger's, per frame; `-[UISwipeOccurrence _extraOffsetForOffset:withDirection:]` over a sweep | C, D |
| M-SA04 | `swipeActions.slideOutOvershoot` | full swipe released past the confirmation point, `.normal` and `.destructive` | the row's position per frame until the action runs and the row returns or is removed; when the handler runs | A, C |
| M-SA05 | `swipeActions.releaseVelocity` | releases past half the open width closing at 42, 44, 46, 48, 50, 52 and 54 pt/s (the pan's velocity at its end) | open or closed per release: the slowest closing speed that closes the row | C |
35 changes: 18 additions & 17 deletions packages/core/src/motion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,24 @@ so each keeps its measured value; no shipped component reads the roles yet.

## Files

| File | What it is |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `uikit/` | THE constants table: every spring, duration, deceleration rate, rubber-band constant and curve, with its source. |
| `uikit/springs.ts` | Interaction springs (navigation, sheets, alerts, menus, controls, keyboard, banners) and the SwiftUI / UIKit presets. |
| `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, paging, `delaysContentTouches`, scroll to top. |
| `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. |
| `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. |
| `uikit/gestures.ts` | Gesture recognizer thresholds, the velocity filter and haptic stand-ins, as `{value, unit}` entries (sources in JSDoc). |
| `MEASUREMENTS.md` | The UIKit reference captures each unconfirmed value still needs. |
| `spring.ts` | Closed-form damped spring, every Apple parameterization, settling duration, a spring chasing a moving target. |
| `decay.ts` | UIScrollView's momentum coast. |
| `coast.ts` | A release's momentum curves for `animator.decay`: the coast, the edge spring, a release past an edge. |
| `rubberBand.ts` | UIScrollView's resistance past an edge, and its inverse. |
| `projection.ts` | Release targets: the rest point nearest the projected coast; paging. |
| `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). |
| `animator.ts` | Compositor animator: springs, coasts and any closed-form curve compiled to Web Animations, read back analytically. |
| `reducedMotionPlan.ts` | The animator's Reduce Motion planner: what one call becomes, a cross-fade whose travel lands while transparent or at once. |
| File | What it is |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `uikit/` | THE constants table: every spring, duration, deceleration rate, rubber-band constant and curve, with its source. |
| `uikit/springs.ts` | Interaction springs (navigation, sheets, alerts, menus, controls, keyboard, banners) and the SwiftUI / UIKit presets. |
| `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, paging, `delaysContentTouches`, scroll to top. |
| `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. |
| `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. |
| `uikit/swipeActions.ts` | Swipe actions on a list row: the claim and swipe thresholds, the tracking and settle spring, the release rule. |
| `uikit/gestures.ts` | Gesture recognizer thresholds, the velocity filter and haptic stand-ins, as `{value, unit}` entries (sources in JSDoc). |
| `MEASUREMENTS.md` | The UIKit reference captures each unconfirmed value still needs. |
| `spring.ts` | Closed-form damped spring, every Apple parameterization, settling duration, a spring chasing a moving target. |
| `decay.ts` | UIScrollView's momentum coast. |
| `coast.ts` | A release's momentum curves for `animator.decay`: the coast, the edge spring, a release past an edge. |
| `rubberBand.ts` | UIScrollView's resistance past an edge, and its inverse. |
| `projection.ts` | Release targets: the rest point nearest the projected coast; paging. |
| `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). |
| `animator.ts` | Compositor animator: springs, coasts and any closed-form curve compiled to Web Animations, read back analytically. |
| `reducedMotionPlan.ts` | The animator's Reduce Motion planner: what one call becomes, a cross-fade whose travel lands while transparent or at once. |

Component-specific values (a sheet's detents, a switch's geometry, a context
menu's timeline) live in a domain file per component, `uikit/<component>.ts`,
Expand Down
Loading
Loading