Skip to content

fix(core): BottomSheet releases a drag the way an iOS sheet does - #8105

Draft
vjeux wants to merge 1814 commits into
meta:mainfrom
vjeux:uikit/c17-sheet-release-wiring
Draft

vjeux wants to merge 1814 commits into
meta:mainfrom
vjeux:uikit/c17-sheet-release-wiring

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8068, #8360 and #8423. Review only this PR's change: https://github.com/vjeux/astryx/compare/4166bc935bae01ab785dd366c083ebb7b73287f9..0ca61da23fe827e1358eb0178d50f1f0736e5094

Primary intent

A BottomSheet a finger lets go of settles on iOS's release spring at the finger's speed, and a finger can catch it mid-settle.

User or maintainer impact

On a phone, a released sheet started from a standstill on the theme's curve, so a quick flick lost its speed, and a busy page made the settle stutter. Now, under a finger:

  • the settle starts at the finger's speed and follows UIKit's spring;
  • it runs on the compositor, so a long task no longer stalls it;
  • a finger on a settling sheet stops it in place;
  • under Reduce Motion, a release fades in place.

A mouse keeps its release, except that a throw held still before letting go no longer flicks.

Problem and solution fit

#8068 decides where a release lands; this PR decides how the sheet gets there (spec:AST-094 FR36–FR40). Options:

  1. A faster CSS transition: still starts from rest, still stalls.
  2. A spring stepped by script each frame: matches iOS but stalls with the main thread and repaints every frame.
  3. UIKit's spring sampled into a Web Animation (chosen): exact curve, starting speed kept, no per-frame layout. It costs 16 KB gzip and a WebKit fix: WebKit hit-tests a compositor-animated element where its animation started, so a touch now maps through the drawn sheet.

Authority

spec:AST-094 FR5, FR6, FR24, FR36–FR40; spec:AST-100 FR2, FR4; design:reduced-motion; spec:AST-107 FR7 with INV13's gaps (#8751).

Change and evidence

iOS simulator (red dot: the finger), then Android emulator:

iOS releases iOS states Android flick

The WebKit fix, tapping a settling sheet's drawn top (before, after): Before After

Scope

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

Testing

How to check, in the Snap points story on a phone: drag and let go slowly, then flick (the sheet keeps your speed and springs to a detent); touch it while it settles (it stops); turn on Reduce Motion and release (it fades in place).

What ran: the whole core package passes. In Chromium and WebKit with real touch, the settle stays within 0.5 px of UIKit's spring at three speeds, even through a 700 ms busy main thread. On a touchscreen laptop a finger still pulls the sheet. Mouse throws land where they did on main, except the two held still before the lift. Two failures also fail on main: one CI shard (#8671) and one accessibility check (#8647).

@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 30, 2026 3:17am 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
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 3:42 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026

@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 is on hold for an engineering owner's decision: the new release behavior rests on draft spec:AST-094 (FR3–FR8, FR16–FR17), and its FR4 narrows the current architecture:theme-tokens INV3 to pointer presentations, so an owner needs to approve or amend that direction, update theme-tokens INV3 if FR4 stands, and say whether mouse drags should also take the new stop rule, which AST-094 leaves out of scope. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Independently of that decision:

  • Land the stacked pull requests first and rebase, so this one shows only its BottomSheet change (about 2,000 changed lines instead of 25,000).
  • BottomSheet.doc.mjs and the changeset describe the new stop rule as touch-only, but handleRelease applies it to mouse drags too; say so, or keep the previous rule for fine pointers.

[Automated review]

type: 'ReadonlyArray<number | string>',
description:
"Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim.",
"Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim. On a touch screen a released drag settles as an iOS sheet does: on the stop nearest where its speed carries it, dismissal included, with iOS's spring.",

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 scopes the new stop rule to a touch screen, but handleRelease calls resolveSheetRelease for mouse drags too; only the spring and its velocity are touch-only. A mouse release at 1,000 px/s now projects 114 px instead of about 499 px, a 30 px nudge is no longer exempt, and the at-rest dismissal line moves from 40 % to 50 % of the shortest detent. Say the stop rule applies to every drag, or keep the previous rule for fine pointers.

);
// Gone: the top of the sheet at the bottom of the screen.
const dismissedOffset = maxOffset + shortestDetentHeight;
const release = resolveSheetRelease({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

resolveSheetRelease runs for every pointer, so mouse drags get the UIKit stop rule as well (0.114 s projection, off screen as a stop, halfway at rest), while draft AST-094 leaves pointer presentations out of scope and the docs and changeset describe the rule as touch-only. If mouse drags should keep the previous rule, choose the rule by releaseMotionRef.current.kind; otherwise update the docs and changeset.

@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

Modified Components

MultiSelector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 273B 273B 0B
Lines of Code 2642 2642 -
Complexity Very High Very High (344) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 57.0KB 57.3KB +292B 12.4KB 12.4KB 0B
core/AppShell 58.0KB 58.3KB +239B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 40.1KB 40.3KB +304B 6.4KB 6.4KB 0B
core/Banner 48.0KB 48.2KB +290B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.5KB 54.7KB +242B 9.9KB 9.9KB 0B
core/BottomSheet 33.9KB 48.8KB +14.8KB ⚠️ 7.9KB 7.9KB 0B
core/BottomSheetSwitcher 14.8KB 14.8KB 0B 3.3KB 3.3KB 0B
core/BreadcrumbItem 66.5KB 66.8KB +297B 11.0KB 11.0KB 0B
core/Button 43.4KB 43.7KB +267B 9.2KB 9.2KB 0B
core/Calendar 55.1KB 55.3KB +283B 10.0KB 10.0KB 0B
core/Carousel 50.4KB 50.7KB +343B 10.2KB 10.2KB 0B
core/ChatComposer 60.8KB 61.0KB +276B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.9KB 56.2KB +266B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.9KB 56.2KB +265B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.6KB 46.9KB +266B 9.5KB 9.5KB 0B
core/ChatLayout 49.0KB 49.2KB +272B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.5KB 46.8KB +255B 9.6KB 9.6KB 0B
core/ChatSendButton 46.0KB 46.2KB +263B 9.2KB 9.2KB 0B
core/CheckboxInput 50.1KB 50.3KB +268B 9.3KB 9.3KB 0B
core/CheckboxListItem 54.2KB 54.5KB +256B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 13.1KB +289B 6.9KB 6.9KB 0B
core/CodeBlock 60.7KB 60.9KB +273B 10.7KB 10.7KB 0B
core/Collapsible 11.7KB 12.0KB +291B 4.2KB 4.2KB 0B
core/CommandPalette 51.0KB 51.3KB +274B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 8.7KB +280B 3.5KB 3.5KB 0B
core/ComplexSelector 56.0KB 56.3KB +279B 10.6KB 10.6KB 0B
core/ContextMenu 74.7KB 75.0KB +255B 14.6KB 14.6KB 0B
core/ContextMenuItem 53.0KB 53.3KB +336B 9.6KB 9.6KB 0B
core/DateInput 99.7KB 115.1KB +15.4KB ⚠️ 14.8KB 14.8KB 0B
core/DateRangeInput 69.5KB 70.0KB +459B 11.2KB 11.2KB 0B
core/DateTimeInput 109.8KB 125.4KB +15.6KB ⚠️ 15.2KB 15.2KB 0B
core/DialogHeader 49.0KB 49.2KB +272B 9.8KB 9.8KB 0B
core/DrawerHeader 49.1KB 49.3KB +248B 11.2KB 11.2KB 0B
core/DropdownMenu 72.8KB 73.5KB +667B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.4KB +284B 5.6KB 5.6KB 0B
core/DropdownMenuItem 15.4KB 15.7KB +287B 5.2KB 5.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.5KB +275B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.2KB 58.5KB +267B 9.5KB 9.5KB 0B
core/FileInput 54.3KB 54.5KB +262B 10.3KB 10.3KB 0B
core/IconButton 43.5KB 43.7KB +272B 9.2KB 9.2KB 0B
core/Item 11.4KB 11.6KB +273B 4.7KB 4.7KB 0B
core/LayerProvider 63.4KB 63.7KB +285B 10.1KB 10.1KB 0B
core/Lightbox 51.3KB 51.6KB +254B 9.8KB 9.8KB 0B
core/Link 43.7KB 43.9KB +281B 7.6KB 7.6KB 0B
core/ListItem 53.7KB 53.9KB +261B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 144.5KB +49B 17.4KB 17.4KB 0B
core/MobileNav 51.2KB 51.4KB +261B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.4KB 46.6KB +259B 9.4KB 9.4KB 0B
core/MoreMenu 73.2KB 73.7KB +535B 14.7KB 14.7KB 0B
core/MultiSelector 73.1KB 73.3KB +211B 14.6KB 14.6KB 0B
core/NumberInput 55.9KB 56.1KB +290B 10.7KB 10.7KB 0B
core/Outline 28.8KB 29.0KB +285B 4.0KB 4.0KB 0B
core/Pagination 84.9KB 85.4KB +506B 15.3KB 15.3KB 0B
core/Popover 50.0KB 50.2KB +200B 12.1KB 12.1KB 0B
core/PowerSearch 158.2KB 172.8KB +14.6KB ⚠️ 19.9KB 19.9KB 0B
core/RadioListItem 52.4KB 52.6KB +264B 9.7KB 9.7KB 0B
core/SegmentedControlItem 8.6KB 8.9KB +285B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 13.1KB +294B 7.1KB 7.1KB 0B
core/Selector 76.5KB 76.4KB -64B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 14.8KB +251B 5.1KB 5.1KB 0B
core/SideNav 58.1KB 58.4KB +277B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.8KB 47.0KB +248B 9.5KB 9.5KB 0B
core/SideNavHeading 54.9KB 55.1KB +248B 10.1KB 10.1KB 0B
core/SideNavItem 54.6KB 54.9KB +270B 10.1KB 10.1KB 0B
core/Step 32.6KB 32.8KB +279B 5.3KB 5.3KB 0B
core/Stepper 47.5KB 47.8KB +270B 9.6KB 9.6KB 0B
core/Switch 48.5KB 48.8KB +279B 9.3KB 9.3KB 0B
core/Tab 9.7KB 10.0KB +294B 4.1KB 4.1KB 0B
core/TabMenu 51.8KB 52.0KB +276B 10.0KB 10.0KB 0B
core/Table 83.9KB 84.1KB +143B 15.1KB 15.1KB 0B
core/TableCell 75.8KB 76.1KB +325B 14.8KB 14.8KB 0B
core/TableHeaderCell 75.5KB 75.8KB +339B 14.8KB 14.8KB 0B
core/TableSelectionToolbar 52.5KB 52.8KB +278B 11.2KB 11.2KB 0B
core/TextInput 53.7KB 54.0KB +274B 10.5KB 10.5KB 0B
core/Thumbnail 50.8KB 51.1KB +272B 11.0KB 11.0KB 0B
core/TimeInput 86.9KB 102.0KB +15.1KB ⚠️ 13.8KB 13.8KB 0B
core/Timestamp 27.7KB 27.7KB +1B 9.5KB 9.5KB 0B
core/Toast 60.0KB 60.2KB +258B 9.7KB 9.7KB 0B
core/ToggleButton 44.3KB 44.5KB +245B 9.3KB 9.3KB 0B
core/Token 29.7KB 30.0KB +289B 4.5KB 4.5KB 0B
core/Tokenizer 68.8KB 69.2KB +511B 16.5KB 16.5KB 0B
core/TopNav 52.0KB 52.3KB +274B 10.4KB 10.4KB 0B
core/TopNavHeading 54.3KB 54.5KB +223B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.3KB +283B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.3KB 54.6KB +273B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.4KB +281B 4.1KB 4.1KB 0B
core/TopNavMenu 53.7KB 54.0KB +258B 10.1KB 10.1KB 0B
core/TreeList 32.7KB 33.0KB +279B 4.7KB 4.7KB 0B
core/Typeahead 61.6KB 61.9KB +269B 11.3KB 11.3KB 0B
lab/ChatEmojiPicker N/A 65.1KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 65.7KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 94.4KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 47.8KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 216.2KB N/A N/A 2.4KB N/A
lab/Comments N/A 220.6KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 46.0KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 63.3KB N/A N/A 722B N/A
lab/ListInput N/A 75.3KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 172.6KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 106.7KB N/A N/A 775B N/A
lab/Schedule N/A 66.3KB N/A N/A 3.9KB N/A
lab/TourStep N/A 55.6KB N/A N/A 590B N/A
lab/TransferList N/A 67.0KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 72.7KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.1KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 212.9KB N/A N/A 454B N/A
richtext/RichTextView N/A 251.6KB N/A N/A 2.3KB N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/BottomSheet +14.8KB ⚠️ 0B
core/DateInput +15.4KB ⚠️ 0B
core/DateTimeInput +15.6KB ⚠️ 0B
core/PowerSearch +14.6KB ⚠️ 0B
core/TimeInput +15.1KB ⚠️ 0B

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 34.6KB 34.6KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 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 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
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 35 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.

Approval is on hold for an engineering owner's decision: the new release behavior rests on draft spec:AST-094 (FR3–FR8, FR16–FR17), whose FR4 narrows the current architecture:theme-tokens INV3 to pointer presentations, so an owner needs to approve or amend that direction, update theme-tokens INV3 if FR4 stands, and say whether mouse drags should also take the new stop rule and velocity reading, which AST-094 leaves out of scope. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Independently of that decision:

  • Land the stacked pull requests first and rebase, so this one shows only its BottomSheet change (about 2,000 changed lines instead of 26,000).
  • BottomSheet.doc.mjs and the changeset describe the new release rule as touch-only, but mouse drags take it too: a slow drag released at rest between 40 % and 50 % of the sheet no longer dismisses, and a quick pull held still before release now does; say so, or keep the previous rule for fine pointers.
  • In useSheetGestures.ts, name the external system the changed reopen effect synchronizes, and stop the animator when the sheet unmounts so its completion timers do not outlive it.

[Automated review]

if (state.tracker.lastSample?.y !== event.clientY) {
state.tracker.add(0, event.clientY, event.timeStamp);
}
const velocity = state.tracker.velocity().y;

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 velocity is read for every pointer, and VelocityTracker never decays through a pause, so a mouse drag held still before release keeps its last speed: on the Text only story a quick 140 px pull held 0.42 s now dismisses the sheet, where the 100 ms window read zero and kept it open. Draft AST-094 FR17 scopes this reading to touch. If mouse drags should keep the previous behavior, pick the window velocity by releaseMotionRef.current.kind; otherwise document it beside the stop rule.

setSettlingLayoutOffset(null);
gestureExitRef.current = null;
travelHandleRef.current = null;
animator.set({y: 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.

This changed Effect now also resets the animator's translate channel, but neither its comment nor its set-state-in-effect reasons name the external system it synchronizes, which architecture:react-component-runtime INV3 asks of a changed Effect. Naming the sheet's animator here, and what the state resets derive from, would cover it.

[recordSheetHeight],
[animator, recordSheetHeight],
);
useEffect(() => () => observerRef.current?.disconnect(), []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the observer is released on unmount. The animator is released through sheetRef(null) → attach(null), which cancels its Web Animations but not the per-handle completion timer, so a settle cut short by unmount still resolves finished as true and calls completeScrollAreaSettle on the unmounted hook (architecture:react-component-runtime INV5). Calling animator.stop() in this cleanup settles the handles as unfinished and clears their timers.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c17-sheet-release-wiring branch from 558b8d6 to bd89b28 Compare October 25, 2026 00:33
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Pushed in bd89b28 (head):

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026

@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 is on hold for an engineering owner's decision: the new touch-release behavior rests on draft spec:AST-094 (FR3, FR5–FR7 and FR16–FR17), and its FR4 says touch physics are not themable while this pull request adds theme motion roles for them, so an owner needs to approve or amend that direction and choose between FR4 and the motion roles. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently of that decision:

  • Land the stacked pull requests and rebase; the BottomSheet change alone is still about 3,200 changed lines, so split it or show why it cannot be split.
  • The new platform adaptation condition is outside spec:AST-012 FR2's closed list of conditions, so remove it or have FR2 amended first.
  • AST-094 FR8 says a throw keeps its motion under Reduce Motion, which contradicts design:reduced-motion DR2 and this pull request's own code; align the record.
  • Under Reduce Motion a finger's release on a fine-pointer screen lands at once instead of cross-fading, because releaseAnimates() reads the panel's transition: none as a zero duration; let touch releases reach the animator and cover it with a test that renders BottomSheet.
  • Keep the sheet element in a ref rather than React state in useSheetGestures.ts, and describe the rule as following the primary pointer, not "under a finger", in the docs and changeset.

[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 when to a closed vocabulary ("A when object may contain width, height, pointer, contrast, and motion … Future environment fields require explicit additions to this closed vocabulary"), so platform needs that record amended first. No component in this pull request reads it; removing it here, with its template, guide and changeset text, would keep the theme contract as it stands.

const gestureExitRef = useRef<SheetGestureExit | null>(null);
// The mounted sheet, as state: the dialog-level touch listeners (see
// `drawnSheetRect`) attach once it is in the DOM.
const [sheetNode, setSheetNode] = useState<HTMLElement | null>(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV1 says DOM nodes must not be stored in React state. The dialog listener effect only needs to know the sheet is mounted: keeping the node in sheetElRef and keying the effect on a boolean (or attaching the dialog listeners from the callback ref with a cleanup) avoids the extra render on each mount and replacement.

// theme's timing not when it is zero (transitions turned off).
const releaseAnimates = useCallback(() => {
const motion = releaseMotionRef.current;
return motion.kind === 'uikit' || motion.durationMs > 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.

Under Reduce Motion the panel renders transition: none, so on a fine-primary-pointer screen readReleaseMotion reads a 0 ms theme duration and this returns false. The settle is then skipped and springTravel never runs, so a finger's release (dragTouchRef) lands at once instead of the 200 ms cross-fade design:reduced-motion DR2 requires for "the release of a touch drag". Returning true for a touch release under Reduce Motion would let the animator's touchRelease fade play; the hook-only test at useSheetGestures.test.ts:2350 cannot see this because its element never gets the panel's inline transition.

cross-fades over 0.5 s on the ease-in-out curve; a sheet, a full-screen
modal or an alert fades on its own presentation spring and drops its travel
and scale; menus open and close on UIKit's Reduce Motion spring. Motion that
follows a finger, such as a throw, MUST keep its motion. **Proposed; pending

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 contradicts current design:reduced-motion DR2 (a transition started by "the release of a touch drag … MUST change opacity only and MUST finish in 200 ms or less") and DR4 (no opt-out), and the code in this pull request now cross-fades a touch release. Conforming FR8 to DR2 would keep the record and the code saying the same thing.

rest point by projecting its velocity the way its UIKit counterpart does:
with a deceleration rate, or with a measured projection time.
- **FR4 — Physics are not themable in touch presentations.** A touch
presentation MUST read its motion from the table on every theme. Themes keep

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 same diff lets themes retune these springs through motion roles (defineTheme({motion: {springs}}), resolved per element by the animator), which this sentence forbids. One of the two has to change once the owners decide whether touch physics are themable.

type: 'ReadonlyArray<number | string>',
description:
"Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim.",
"Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim. Under a finger, a released drag settles as an iOS sheet does: on the stop nearest where its speed carries it (a quick flick goes at least to the next stop), dismissal included, with iOS's spring; elsewhere it settles as before, on the theme's 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.

The rule is chosen by the device's primary pointer (readTouchPresentation().isTouch), not by the pointer that drags: a finger on a touchscreen laptop keeps the previous rule, and a mouse on a screen whose primary pointer stays coarse takes the iOS one. "On a screen whose primary pointer is coarse (a phone or tablet)" would describe it exactly; the changeset's "on every touch device" has the same gap.

vjeux added 15 commits October 29, 2026 10:27
…eta#8548 and meta#8549) into meta#8105

Two carried harness hunks met meta#8360's text, in the harness README and the
header of the harness's own spec: meta#8360's side holds the offer's sentences
plus its animation-recorder paragraph, so both keep meta#8360's text as it is.
…ings

meta#8360's foundation carries the animator's current rules, which reach this
PR's release tests (red on this merge); meta#8341 already followed them, and the
same test changes now sit here, where the animator arrives:
- The end-state hold is 60 s: specConstants.END_HOLD_MS follows it
  (5000 -> 60_000). Red: its guard, "match the animator's end-state hold",
  and the reduced-motion cross-fade spec in Chromium and WebKit, which
  judged each curve by a 5 s hold.
- The animator compiles a curve into keyframes on a 30 Hz grid, each chord
  within a quarter of a pixel of the curve: the spring tests check every
  keyframe against the closed-form curve at 0.05 px, and that one falls in
  every 30 Hz interval, instead of reading between two keyframes, which is
  the browser's interpolation. Red: two tests 0.06 and 0.10 px off.
- The inline style holds the value on screen while a curve runs, and the end
  is written when it retires, so no frame shows where it is going: the
  release tests read the start right after the release, the end in the last
  keyframe, and the end written once the motion retires. Red: the
  drag-dismissal test read 400 px where the element still held 150.
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
…erting it

The settle test asserted today's settle, the theme's 300 ms --ease-standard
transition, frame by frame. That curve starts a release at rest at about
1,400 px/s, where spec:AST-094 FR38 starts a BottomSheet's release at the
finger's speed, so the test pinned a curve the spec rules out. It now records
the curve, its easing, its duration and its frame-by-frame fit, in the
evidence and an annotation, as it already did for the velocity hand-off, and
keeps asserting where the settle lands: the half detent.
…into meta#8105

BottomSheetDetent's settle test and header: this branch's, where the settle is
UIKit's release spring, asserted frame by frame from the finger's velocity
(spec:AST-094 FR38), which takes over the test meta#8423 now only records.
…against a compositor-only control

On a two-core runner the software compositor and the screencast share the core the block spins on, so a compositor animation presents fewer frames there than a display-rate bound allows, though it moves on every frame the compositor presents: pinned to two cores, Lightbox's double-tap zoom proof failed its bound in 7 of 10 runs. startBusyControl shows a small box (a manual popover, in the top layer) whose transform moves it for as long as the page runs; tracked from the same screencast and passed to busyWindowProof as control, it is what the compositor presented in that window. The component passes when it moved on every frame the control moved and stalled no longer than the control; a window where the control moved on fewer than four frames fails. A main-thread animation still fails.

No tolerance: both are read from the same screencast frames, and over 140 proofs of a correct compositor animation (Chromium, Chromium pinned to two cores and WebKit, at full and 1-in-6 screencast rates) the component missed none of the control's frames and never stalled longer, while the rAF mutant moved on none of the 39 to 47 frames the control moved in a full window.

Red first: five unit cases (a stall the compositor made for both, which the verdict without the control fails; a frozen component; one the control outpaces; one that misses a single control frame; a control that did not move) fail on the old busy.ts.
…ver pooled runs

A fade that lasts a fraction of a second leaves one blocked window only a few frames: in WebKit, or on a loaded machine, the control can present fewer than the four one window needs, and the proof fails as too little to judge, saying nothing about the animation. pooledBusyProof(runs) judges several runs of the same short animation as one long window is judged against the control: the control must move on at least four frames over all the runs, the component on every one of them, and in each run its longest stall may be no longer than that run's control's, since a stall belongs to its run.

Red first: a 30 ms judged window holds at most three frames and one run fails as too little to judge. Pooled over six runs, a compositor animation passes (no miss in 70 pools, in Chromium, Chromium pinned to two cores and WebKit) and a main-thread one fails (it moved on at most 55% of the control's frames); a pool whose component misses a single control frame fails.
…tep a frame from the change that ends it

Against the control, a frame with no new component position was a miss. On an easing tail the component moves under one cast step a frame, which the screencast cannot show on every frame: Lightbox's dismiss-cancel proof in WebKit missed one of the control's 12 to 14 frames in 7 of 10 runs, each time at its spring's last plateau (210, 210, then 208 on WebKit's 2 px grid). Such a run of unchanged frames now counts as moved when the change that ends it is a single cast step. A freeze ends in a catch-up of more than one step and stays a miss, as does a run that no change ends, and the component must still show a new position on at least minJudgedFrames of the control's frames, so a frame-counted animation that stands still through the block and resumes one step a frame fails too. The cast step is read from the tracked value's changes in the judged part rather than from its positions: the photo's position once the block ended (207) sat off WebKit's 2 px grid and made the step read as 1 px.

Red first: the tail, pooled tail, off-grid commit and frame-counted freeze cases fail on the previous busy.ts; the single-miss, slow freeze and stop-for-good cases still fail. Lightbox's five busy proofs with this judgment: 30 of 30 in Chromium, 39 of 40 in WebKit (the other presented 3 frames in its judged part, under the 4 a window needs). The harness's own proofs still pass a compositor animation with no miss and fail the rAF one (0 of 39 to 47 control frames) in both engines.
…ame window

Both 'a dip held before its first frame lands once the hold is …' proofs
waited for no running animation, then one frame and a timeout, assuming
the next step starts within a frame. The dip lands on a timer due on the
call's own clock, though, and the proofs passed only because the evaluate
round trips covered the gap: in WebKit on a loaded box the window closed
first and the proof read the element mid-dip (opacity 0, translate none;
reproduced 1 in 4 with the round trips removed). They now wait on the
call's own finished, then on the browser's own animations still running
or waiting for their first frame, and read nothing from the animator.
…ment at once and settles then

A Reduce Motion dip whose landing runs more than its whole fade back in
late (a hold released long after the dip was due) played a 1 ms opacity
animation, which WebKit keeps pending until its next frame, and settled
the call on a 1 ms timer: the call's finished could resolve while the
element still showed opacity 0. The animator's notes say the element
appears at once there. Fails now: the landing plays one opacity animation
and the call has not settled.
…element at once and settles

A Reduce Motion landing that runs at least the whole fade back in late
had nothing left of the cross-fade, but still played a 1 ms opacity
animation and settled the call 1 ms later. WebKit keeps that animation
pending until its next frame, so the call's finished could resolve with
the element still at opacity 0. Such a landing now writes the end opacity
at once (handing a borrowed one back to the stylesheet), plays nothing,
and settles the call then, as the animator's notes say.
…ng curve only when it is at most a frame longer than the run before it

astracat found that the sub-step credit let pooledBusyProof pass a rAF loop that steps a fixed 1 or 2 px a frame: in each 70 ms block the cast still shows the loop's last steps, then it stands still and goes on one step a frame without catching up, so its run ended in a single step. With the previous busy.ts the self-test's frame-counted loop passed the pooled proof in 5 of 5 Chromium runs.

A slowing curve lengthens its runs a frame at a time, so a run now counts as moved only when it is at most one frame longer than the component's run before it; a stall is longer than the runs before it. A judged part with no change reads the finest cast step, 0.5 px, so a larger jump never passes for one step.

The self-test adds the frame-counted loop, at 1 and 2 px a frame, to the pooled proof's mutants: it fails every pool (Chromium 10 of 10, moved on 2 to 8 of 7 to 12 control frames; WebKit 10 of 10, 0 or 1 of 11 or 12). The timed rAF mutant can read as moved on one control frame, presented as the block starts, so its assertion takes 0 or 1 and also requires its stall to exceed the control's (Chromium showed 1 of 40 once).

Red first: the frame-counted stall, the pooled frame-counted loop and the finest-step cases fail on the previous busy.ts. Lightbox's five busy proofs with this judgment: 30 of 30 in Chromium; 33 of 40 in WebKit with no miss, the other 7 presenting too few control frames to judge.
…ter a stalled dispatch, and a proof can check the pacing the page received

WebKit's touch command takes no timestamp, so the page times each event by its arrival. Under box contention a dispatch stalls 27 to 31 ms, and the driver, which aimed every event at the gesture's start, then sent the overdue moves together, 1 to 2 ms apart: C17 measured BottomSheet's flick at 2000 px/s reaching the page as 3,627 to 13,200 px/s.

playSteps now sends each event at its scripted time and never sooner after the one before it than the script spaces them, so a stall stretches one gap instead of bunching the rest. With `checkPacing`, WebKit's play reads the timeStamps the page received (a passive capture listener on touch events) and throws TouchPacingError, naming both gap lists, when an event is missing or a gap is more than TOUCH_PACING_TOLERANCE_MS off the script. retakeOnTouchPacing(testInfo, attempt) runs a proof's attempt once more on a TouchPacingError only: two attempts in all, a product failure is never re-taken, and each re-take is listed as a `touch-retake` annotation.

The tolerance is measured: on one worker of a loaded machine, 440 gaps over 40 WebKit flicks moved at most 4 ms (99th percentile 3 ms; the page's stamps are whole ms); under eight workers 4% of flicks had a gap further off, up to 21 ms.

Red first: the stalled-dispatch case fails on the previous scheduling; checkTouchPacing and retakeOnTouchPacing are new, with the three re-take cases (both attempts off pace stays red, a product failure after good input is not re-taken, off pace then good passes and is listed). In the browser a 40 ms driver stall makes WebKit's play throw TouchPacingError, and Chromium, whose page sees the scripted stamps, plays on.
…a click, and SCRIPTED_CLICK_SKIP names the skip where it does not

Playwright's WebKit synthesises a click only from its tap gesture (`driver.tap`), never from touches sent one by one, so a proof that needs the click of a scripted press and lift has to know. `driver.synthesizesClicks` says it (Chromium true, WebKit false), and such a proof skips with `test.skip(!driver.synthesizesClicks, SCRIPTED_CLICK_SKIP)`, as PARTIAL_LIFT_SKIP does for a partial lift.

The self-test plays a scripted tap and then driver.tap on one button and reads the order of touchstart, touchend and click: driver.tap's click comes last and a scripted tap's click, if any, before driver.tap's touchstart, so it waits on that order rather than on the clock. This came from the web-input drivers helper line, which no longer carries it.

@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 is on hold for an engineering owner's decision. spec:AST-094 governs this release, but three of its deltas fall outside what it states:

  • the 1,008 px/s flick floor, which dismisses releases whose 0.1 s projection stops short of FR36's commit point;
  • the three sheet springs played as constants, where FR4 and DEC-5 expect motion roles that component:BottomSheet measures;
  • the drag dismissal's spring exit, which FR7 leaves to that record to state.

An engineering owner settles this in one of three ways: amending those claims, recording the springs, the exit and the flick rule in component:BottomSheet, or asking the pull request to conform. They record the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the decision, please also:

  • Let detail === 0 activation through after a finger catches the sheet, and cancel the caught touch's touchend instead of using a 700 ms click window (AST-094 FR21).
  • Time the scrim's fade from the sheet's spring when the primary pointer is coarse (spec:AST-108 FR4a).
  • Land the stack beneath this pull request and rebase, then split or justify the 5,375-line BottomSheet area.
  • Name the new mouse catch of a settling sheet and show mouse before-and-after evidence (spec:AST-017 FR59), and add real-device iOS Safari and Android Chrome evidence for the catch and the selection lock.
  • Describe the release as applying where the primary pointer is coarse, and reuse resolveSnapPoint and the shared selection lock instead of copying them.

[Automated review]

Comment on lines +1894 to +1900
const onClick = (event: MouseEvent) => {
if (performance.now() <= suppressClickUntilRef.current) {
suppressClickUntilRef.current = -Infinity;
event.stopPropagation();
event.preventDefault();
}
};

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 guard cancels the first click in the window by time alone. After a catch that drags the sheet, no click of its own comes, so a tap on a control in the sheet within 700 ms of the lift does nothing. Enter or Space while the finger holds the sheet (Infinity from line 1847) is cancelled too. spec:AST-094 FR21 says "Activation with detail === 0 … MUST always pass" and asks for the taken touch's touchend to be cancelled, but that listener is passive (line 1912). Cancelling the caught touch's touchend and skipping detail === 0 clicks would fix both.

const baseLayoutOffset = settledLayoutOffsetRef.current;
let baseOffset = settledOffset;
let baseLayoutOffset = settledLayoutOffsetRef.current;
if (travelHandleRef.current != null) {

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 catch runs for every pointer, so a mouse press on a settling sheet now stops it mid-flight; on the base the drag started from the target detent. spec:AST-017 FR59 allows a fine-pointer change from touch work only when the pull request names it and shows mouse before-and-after evidence. Name it with that evidence, or keep the catch to touch.

Comment on lines +1054 to +1058
: animator.animate(
{y: to},
{spring, velocity: velocity == null ? undefined : {y: velocity}},
{crossfadeAs, touchRelease},
);

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 coarse pointer the sheet now settles on this spring, but the scrim's ::backdrop still fades on --duration-medium (BottomSheet.tsx:210-211). spec:AST-108 FR4a wants "BottomSheet's scrim, whose fade lasts the sheet's computed settle time". Setting --_sheet-scrim-duration from the spring's settle time when a release springs would keep them on one clock.

Comment on lines +145 to +163
const isFast = Math.abs(velocity) >= sheet.release.fastSpeed;
let index = choice.index;
if (isFast) {
// A flick goes at least to the next position beyond where it is.
const tolerance = sheet.release.detentTolerance;
let next = -1;
if (velocity > 0) {
next = positions.findIndex(p => p > position + tolerance);
} else {
for (let i = positions.length - 1; i >= 0 && next === -1; i -= 1) {
if (positions[i] < position - tolerance) {
next = i;
}
}
}
if (next !== -1 && (velocity > 0 ? next > index : next < index)) {
index = next;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Awaiting an engineering owner's decision (the open question): this flick branch dismisses a 20 px drag at 1,100 px/s from the 300 px detent (projected 430 px, short of the 470 px halfway line), while a 60 px drag at 900 px/s, projected farther to 450 px, returns. spec:AST-094 FR36 commits only when the projected travel reaches the commit point. Either FR36 is amended to this measured rule, or the dismissal follows FR36.

Comment on lines +70 to +75
/** The deceleration rate whose projection reaches `seconds` of the velocity. */
function rateForProjection(seconds: number): number {
// WWDC18's projection coasts (v / 1000) · r / (1 − r): r / (1 − r) = 1000·τ.
const ms = seconds * 1000;
return ms / (1 + ms);
}

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 helper, and the flick and cancel branches below, re-implement motion/resolveSnapPoint.ts, whose header says it "is the one 'where does a release land' helper, so a second one should not be added beside it". architecture:react-component-runtime INV13 also has core touch behavior come from one shared set of modules. Calling resolveSnapPoint with projection: {seconds: 0.1, minSpeed: 250}, flickThreshold: 1008, tolerance: 1 and start, and keeping only the sheet's dismissal mapping, would leave one implementation.

type: 'ReadonlyArray<number | string>',
description:
"Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim.",
"Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim. Under a finger, a released drag settles as an iOS sheet does: on the stop nearest where its speed carries it (a quick flick goes at least to the next stop), dismissal included, with iOS's spring; elsewhere it settles as before, on the theme's 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.

The code keys on the primary pointer (releaseMotion.ts:104, spec:AST-094 FR13), not on the pointer that drags: a touchscreen laptop's finger gets the theme rule, and a mouse on a coarse-primary tablet gets the iOS one. "Where the primary pointer is coarse" would describe it accurately, here and in .changeset/bottom-sheet-uikit-release.md.

vjeux added 13 commits October 29, 2026 17:00
…d off its pacing, once

In WebKit a touch carries its real dispatch time, so a dispatch that stalls
under load reaches the page as a burst of moves 1-2 ms apart, which the
sheet reads as a much faster flick and correctly dismisses. The three proofs
whose outcome follows a velocity (the downward flick, the moderate and fast
throws, and Android's flick) now play with `checkPacing`, and only on a
proven `TouchPacingError` re-take the gesture once, on a fresh page. A
product assertion is never re-taken, and a second pacing error fails with
its cause.
…se reads it, its last two move intervals and its lift

C17 adopted checkPacing on BottomSheet's flick detent proofs and every WebKit attempt failed, even on an idle worker: WebKit delivers a gesture's first moves with the page's own touchstart handling time (gaps of 4, 11, 3 ms against 8, 8, 8) and its lift about a frame late (8 to 11 ms against 2.1). Neither feeds a release velocity: Core's tracker reads the last three moved samples, two intervals, and a release reads as still only 40 ms after the last move. Before the check the same flicks reached the page at 2,000 px/s.

checkPacing now judges what decides a release: an event missing, either of the last two move intervals more than TOUCH_PACING_TOLERANCE_MS (4 ms) off the script, or the lift TOUCH_LIFT_STILLNESS_MS (40 ms, UIKit's measured stillness, spec:AST-100 FR2) or more after the last move. checkTouchPacing takes the played steps, so it knows which events are moves.

Red first: C17's received gaps fail the previous check and pass this one; a stall and its burst in the last two intervals still fails, and so does a lift 45 ms late, while the same stall earlier in the gesture passes. In the browser a 40 ms driver stall before the last move throws in WebKit, and Chromium plays on.
…il cancelled, and its compositor run is blocked only once on the compositor

C23 saw the control self-test fail under load at eight workers: "660 ms judged at 16.6 ms/frame: 28 frames presented, 0/28 moved". Its animations ran once, for 1,600 ms from before the screencast started, so on a loaded runner, where starting the cast takes long, the compositor's animation had ended before the block and nothing moved in the judged part.

Both animations now go back and forth until cancelled, as the pooled self-test's do, and the compositor's block uses `during`, so it starts only once that animation runs on the compositor.

Red first: with the setup stretched to 1.8 s, as a loaded runner takes, the previous test fails with exactly that read (0/39 moved) and this one passes. Normal runs at eight workers: 10 of 10 in Chromium and 10 of 10 in WebKit.
… judges its first frame on that frame's time

'a spring started right before a block plays in full after it' waited a
fixed 300 ms of wall clock after the block, then read the screencast. In
the full interaction run on a loaded box no moving frame had reached the
screencast by then, and the proof failed on an empty read. It now waits
until the page shows the box a quarter of the way along and three more
frames reach the screencast. Its bound changes form, not strength: the
curve's own start (the handle's started) must come after the block, and
the first moving frame may be no further along than a curve started a
frame before the block ended, at that frame's own time, which is the old
30 px for a frame right after the block and still holds when a loaded
machine sends frames sparsely. A curve dated at the call is 107 px in.
…itor run against the control, since a loaded runner's compositor skips frames

At 16 workers on a loaded box (load 92), the self-test failed 6 of 32 Chromium runs on its compositor animation. Every presented frame moved (32/32 to 38/38), but the compositor presented none for 63 to 109 ms inside the block, past the display-rate bound. Its compositor run is now judged against startBusyControl's control in the same window, with the bound's own verdict recorded beside it. The rAF mutant stays the negative and fails both: it moved on 0 or 1 of the control's frames with a stall past the control's, and the bound alone fails its longest stall. The run follows the README's order: the cast, then the animation, then the block once the animation runs on the compositor.

At 16 workers (load 115 to 164) it passed 48 of 48 in Chromium, where the control's own longest stall in the rAF runs reached 33.5 ms against the rAF's 660 ms. WebKit at 8 workers: 8 of 8.
… animations wrap instead of turning

astracat found that the control self-test's back-and-forth animation turns at 1,600 ms. When a loaded runner starts the cast late enough that the block holds the turnaround, two cast frames straddling it evenly read one position, and the control verdict fails the correct compositor animation: "moved on 6 of the 7 frames the control moved". Both self-tests' compositor animations and their timed rAF loops now wrap from 560 px back to 0, a change on every frame. The pooled case's counted mutants still bounce: they are negatives, and a turn changes their position by a full step.

BusyProof.stepPx's note now describes the step it reads from the judged part's changes, and the file header names pooledBusyProof.

Red first: with the setup stretched so the block holds the turnaround, the previous test fails 7 of 12 Chromium runs ("moved on 5 of the 6" and "6 of the 7 frames the control moved"), and this one passes 12 of 12.
…rol inside an open dialog, leaving its backdrop on the compositor

C17 found that in WebKit the control's top-layer popover, shown above an open modal dialog, takes the dialog's `::backdrop` animation off the compositor. BottomSheet's dim held its value for 155 to 225 ms into the block, then jumped to rest, while the control moved on every frame. `startBusyControl(page, {within})` now mounts the box inside the given element as a plain fixed box, with no popover and no top-layer entry. Its frames still count presented frames, and `raise()` leaves such a box where it is.

The self-test opens a modal dialog whose backdrop dims through a block, and judges the dim's brightness at a point against the control.

Red first: in WebKit at 8 workers, with the top-layer control the dim stalled in 5 of 24 runs, from 43 to 78 ms at the block's start, while the control moved ("17/20 moved, longest stall 76.5 ms at +20 ms"); with `within` it passed 24 of 24. Chromium passed either way.

@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 is on hold for an engineering owner's decision: spec:AST-094 governs this release, but three deltas fall outside what it states, namely the 1,008 px/s flick floor that dismisses releases whose 0.1 s projection stops short of FR36's commit point, the three sheet springs played as constants where FR4 and DEC-5 expect motion roles that component:BottomSheet measures, and the drag dismissal's spring exit that FR7 leaves to that record to state. An engineering owner settles them by amending those claims, by recording the springs, the exit and the flick rule in component:BottomSheet, or by asking the pull request to conform, and records the decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Whatever the decision, please also:

  • Land the stack beneath this pull request (#8068, #8360, #8423, #8906 and #8910) and rebase, then split or justify the 5,406-line BottomSheet area.
  • Arm the shared armClickGuard at a caught touch's lift instead of the 700 ms click window, and cancel that touch's touchend, so detail === 0 activation always passes (AST-094 FR21).
  • Time the scrim's fade from the release spring's settle time when the primary pointer is coarse (spec:AST-108 FR4a).
  • Name the new mouse catch of a settling sheet with mouse before-and-after evidence (spec:AST-017 FR59), and add real-device iOS Safari and Android Chrome evidence for the catch and the selection lock.
  • Describe the release as applying where the primary pointer is coarse, and reuse resolveSnapPoint and lockSelection instead of copying them.

[Automated review]

Comment on lines +1894 to +1899
const onClick = (event: MouseEvent) => {
if (performance.now() <= suppressClickUntilRef.current) {
suppressClickUntilRef.current = -Infinity;
event.stopPropagation();
event.preventDefault();
}

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 guard cancels the first click in the window by time alone. After a catch that drags the sheet no click of its own comes, so a tap on a control in the sheet within 700 ms of the lift does nothing, and Enter or Space while the finger holds the sheet (Infinity from line 1847) is cancelled too. spec:AST-094 FR21 says "Activation with detail === 0 … MUST always pass" and asks for the taken touch's touchend to be cancelled, but that listener is passive (line 1912). The shared armClickGuard in utils/clickGuard.ts already lets detail === 0 through and disarms at the next pointerdown; arming it at the caught touch's lift, and cancelling that touch's touchend, would meet FR21.

const baseLayoutOffset = settledLayoutOffsetRef.current;
let baseOffset = settledOffset;
let baseLayoutOffset = settledLayoutOffsetRef.current;
if (travelHandleRef.current != null) {

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 catch runs for every pointer, so a mouse press on a settling sheet now stops it mid-flight; on the base the drag started from the target detent. spec:AST-017 FR59 allows a fine-pointer change from touch work only when the pull request names it and shows mouse before-and-after evidence. Name it with that evidence, or keep the catch to touch.

releaseAnimates(),
);
if (settlingLayoutOffsetRef.current != null) {
springTravel(target, layoutOffset, release.spring, velocity);

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 coarse pointer the sheet now settles on this spring, but the scrim's ::backdrop still fades on --duration-medium (BottomSheet.tsx:210-211). spec:AST-108 FR4a wants "BottomSheet's scrim, whose fade lasts the sheet's computed settle time". Setting --_sheet-scrim-duration from the spring's settle time whenever a release springs would keep them on one clock, the drag dismissal included.

Comment on lines +160 to +162
if (next !== -1 && (velocity > 0 ? next > index : next < index)) {
index = next;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Awaiting an engineering owner's decision: this flick branch dismisses a 20 px drag at 1,100 px/s from the 300 px detent (projected 430 px, short of the 470 px halfway line), while a 60 px drag at 900 px/s, projected farther to 450 px, returns. spec:AST-094 FR36 commits only when the projected travel reaches the commit point. Either FR36 is amended to this measured rule, or the dismissal follows FR36.

Comment on lines +70 to +75
/** The deceleration rate whose projection reaches `seconds` of the velocity. */
function rateForProjection(seconds: number): number {
// WWDC18's projection coasts (v / 1000) · r / (1 − r): r / (1 − r) = 1000·τ.
const ms = seconds * 1000;
return ms / (1 + ms);
}

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 helper, and the flick and cancel branches below, re-implement motion/resolveSnapPoint.ts, whose header says it "is the one 'where does a release land' helper, so a second one should not be added beside it". Calling resolveSnapPoint with projection: {seconds: 0.1, minSpeed: 250}, flickThreshold: 1008, tolerance: 1 and start, and keeping only the sheet's dismissal mapping, would leave one release policy in core (architecture:react-component-runtime INV13).

type: 'ReadonlyArray<number | string>',
description:
"Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim.",
"Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim. Under a finger, a released drag settles as an iOS sheet does: on the stop nearest where its speed carries it (a quick flick goes at least to the next stop), dismissal included, with iOS's spring; elsewhere it settles as before, on the theme's 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.

The code keys on the primary pointer (releaseMotion.ts:104, spec:AST-094 FR13), not on the pointer that drags: a touchscreen laptop's finger gets the theme rule, and a mouse on a coarse-primary tablet gets the iOS one. "Where the primary pointer is coarse" would describe it accurately, here and in .changeset/bottom-sheet-uikit-release.md.

This branch was successfully deployed

1 active deployment
Preview — 7b0b5235 Deployed Oct 30, 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants