Skip to content

feat(Lightbox): drag a photo down to close, like Photos - #8215

Draft
vjeux wants to merge 2237 commits into
meta:mainfrom
vjeux:uikit/c24-dismiss
Draft

vjeux wants to merge 2237 commits into
meta:mainfrom
vjeux:uikit/c24-dismiss

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8197, and on #8665 and #8676 (gestures for every pointer), which this stack carries. Review only this PR's change: vjeux/astryx@2a1354c...11abd78

Why

On a phone, the only ways out of a Lightbox photo are a small Close button or a tap beside it. In Photos, people drag the photo down: it follows the finger, shrinks, the background fades, and letting go closes it, or brings it back if the drag returns.

What led to it

The UIKit-parity work for Astryx (spec:AST-094), after zoom (#8148) and paging (#8197): Photos' dismissal, with its shrink and fade measured in Photos.

Options and tradeoffs

  • Close past a fixed distance: simple, but a short quick flick would not close, and in Photos it does.
  • Project the release (this PR): the drag closes when where it would coast ends more than 10 pt below its start, so a short flick closes and a drag brought back cancels. The release animates on the compositor.
  • The backdrop's blur costs a full-screen blur every frame while it moves, which drops frames on low-end phones, so it comes off during the drag and returns at rest.

Why this choice

It behaves like Photos and keeps moving when the page is busy. The cost is 1.6 KB of gzip JavaScript (95,206 → 96,776 bytes).

How to check it

In the "Gallery with zoom" story, on a phone:

  1. Drag the photo down slowly: it follows, shrinks, and the background fades.
  2. Let go: the viewer closes.
  3. Drag down, back up to the start, and let go: the photo springs back.
  4. Flick down quickly: the viewer closes.
  5. With Reduce Motion on, drag down and let go: the photo fades out.
  6. With a mouse or a pen, drag the photo down, or scroll down on a trackpad: it follows and closes the same way.

Evidence

Safari on the iOS 26.2 simulator:

main this PR
main: nothing happens it shrinks and leaves
main: nothing happens it springs back

Android 15 emulator, Chromium: dismiss

The cancel's busy proof judged too short a window in WebKit: 5 to 6 frames of a compositor-only control animation, and fewer on a busy machine. It now pulls farther and blocks inside the page, for 13 to 14 frames; the dismissal's proof holds 8 to 10.

@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 28, 2026 7:15am UTC

Request Review

@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 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, 5:33 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

Modified Components

Lightbox (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 394B 394B 0B
Lines of Code 804 2816 -
Complexity Very High Very High (164) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 57.0KB 57.6KB +604B 12.4KB 12.4KB 0B
core/AppShell 58.0KB 58.6KB +547B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 40.1KB 40.7KB +631B 6.4KB 6.4KB 0B
core/Banner 48.0KB 48.5KB +592B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.5KB 55.0KB +554B 9.9KB 9.9KB 0B
core/BottomSheet 33.9KB 33.9KB 0B 7.9KB 7.9KB 0B
core/BottomSheetSwitcher 14.8KB 14.8KB 0B 3.3KB 3.3KB 0B
core/BreadcrumbItem 66.5KB 67.3KB +784B 11.0KB 11.0KB 0B
core/Button 43.4KB 44.0KB +602B 9.2KB 9.2KB 0B
core/Calendar 55.1KB 55.6KB +600B 10.0KB 10.0KB 0B
core/Carousel 50.4KB 50.9KB +586B 10.2KB 10.2KB 0B
core/ChatComposer 60.8KB 61.4KB +593B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.9KB 56.5KB +570B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.9KB 56.5KB +570B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.6KB 47.2KB +585B 9.5KB 9.5KB 0B
core/ChatLayout 49.0KB 49.6KB +610B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.5KB 47.1KB +592B 9.6KB 9.6KB 0B
core/ChatSendButton 46.0KB 46.5KB +575B 9.2KB 9.2KB 0B
core/CheckboxInput 50.1KB 50.6KB +560B 9.3KB 9.3KB 0B
core/CheckboxListItem 54.2KB 54.8KB +546B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 13.4KB +626B 6.9KB 6.9KB 0B
core/CodeBlock 60.7KB 61.2KB +584B 10.7KB 10.7KB 0B
core/Collapsible 17.0KB 17.6KB +594B 4.2KB 4.2KB 0B
core/CommandPalette 51.0KB 51.6KB +618B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 9.0KB +596B 3.5KB 3.5KB 0B
core/ComplexSelector 56.0KB 56.5KB +580B 10.6KB 10.6KB 0B
core/ContextMenu 74.7KB 75.5KB +798B 14.6KB 14.6KB 0B
core/ContextMenuItem 53.0KB 53.6KB +561B 9.6KB 9.6KB 0B
core/DateInput 99.7KB 100.3KB +608B 14.8KB 14.8KB 0B
core/DateRangeInput 69.5KB 70.1KB +617B 11.2KB 11.2KB 0B
core/DateTimeInput 109.8KB 110.4KB +613B 15.2KB 15.2KB 0B
core/DialogHeader 49.0KB 49.5KB +580B 9.8KB 9.8KB 0B
core/DrawerHeader 49.1KB 49.6KB +552B 11.2KB 11.2KB 0B
core/DropdownMenu 72.8KB 73.6KB +759B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.7KB +617B 5.6KB 5.6KB 0B
core/DropdownMenuItem 15.4KB 16.0KB +611B 5.2KB 5.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.8KB +607B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.2KB 58.9KB +726B 9.5KB 9.5KB 0B
core/FileInput 54.3KB 54.8KB +546B 10.3KB 10.3KB 0B
core/IconButton 43.5KB 44.1KB +602B 9.2KB 9.2KB 0B
core/Item 11.4KB 11.9KB +606B 4.7KB 4.7KB 0B
core/LayerProvider 63.4KB 64.0KB +600B 10.1KB 10.1KB 0B
core/Lightbox 51.4KB 94.5KB +43.2KB ⚠️ 9.8KB 10.1KB +329B
core/Link 43.7KB 44.2KB +590B 7.6KB 7.6KB 0B
core/ListItem 53.7KB 54.2KB +554B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 145.3KB +760B 17.4KB 17.4KB 0B
core/MobileNav 51.2KB 51.7KB +555B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.4KB 46.9KB +572B 9.4KB 9.4KB 0B
core/MoreMenu 73.2KB 73.9KB +745B 14.7KB 14.7KB 0B
core/MultiSelector 73.1KB 73.7KB +630B 14.6KB 14.6KB 0B
core/NumberInput 55.9KB 56.4KB +598B 10.7KB 10.7KB 0B
core/Outline 28.8KB 29.3KB +589B 4.0KB 4.0KB 0B
core/Pagination 84.9KB 85.7KB +775B 15.3KB 15.3KB 0B
core/Popover 50.0KB 50.6KB +604B 12.1KB 12.1KB 0B
core/PowerSearch 164.5KB 165.2KB +803B 19.9KB 19.9KB 0B
core/RadioListItem 52.4KB 52.9KB +571B 9.7KB 9.7KB 0B
core/SegmentedControlItem 8.6KB 9.2KB +597B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 13.4KB +623B 7.1KB 7.1KB 0B
core/Selector 76.5KB 77.2KB +779B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 15.1KB +601B 5.1KB 5.1KB 0B
core/SideNav 58.1KB 58.7KB +588B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.8KB 47.3KB +573B 9.5KB 9.5KB 0B
core/SideNavHeading 54.9KB 55.4KB +564B 10.1KB 10.1KB 0B
core/SideNavItem 54.6KB 55.2KB +551B 10.1KB 10.1KB 0B
core/Step 32.6KB 33.1KB +582B 5.3KB 5.3KB 0B
core/Stepper 47.5KB 48.1KB +567B 9.6KB 9.6KB 0B
core/Switch 48.5KB 49.1KB +579B 9.3KB 9.3KB 0B
core/Tab 9.7KB 10.3KB +606B 4.1KB 4.1KB 0B
core/TabMenu 51.8KB 52.3KB +579B 10.0KB 10.0KB 0B
core/Table 84.0KB 84.8KB +800B 15.2KB 15.2KB 0B
core/TableCell 75.8KB 76.6KB +801B 14.9KB 14.9KB 0B
core/TableHeaderCell 75.5KB 76.3KB +822B 14.9KB 14.9KB 0B
core/TableSelectionToolbar 52.5KB 53.1KB +601B 11.2KB 11.2KB 0B
core/TextInput 53.7KB 54.3KB +579B 10.5KB 10.5KB 0B
core/Thumbnail 50.8KB 51.4KB +598B 11.0KB 11.0KB 0B
core/TimeInput 86.9KB 87.5KB +533B 13.8KB 13.8KB 0B
core/Timestamp 27.7KB 27.7KB +1B 9.5KB 9.5KB 0B
core/Toast 60.0KB 60.6KB +580B 9.7KB 9.7KB 0B
core/ToggleButton 44.3KB 44.8KB +563B 9.3KB 9.3KB 0B
core/Token 29.7KB 30.3KB +600B 4.5KB 4.5KB 0B
core/Tokenizer 68.8KB 69.3KB +611B 16.5KB 16.5KB 0B
core/TopNav 52.0KB 52.5KB +545B 10.4KB 10.4KB 0B
core/TopNavHeading 54.3KB 54.8KB +557B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.6KB +605B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.3KB 54.9KB +600B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.7KB +603B 4.1KB 4.1KB 0B
core/TopNavMenu 53.7KB 54.3KB +580B 10.1KB 10.1KB 0B
core/TreeList 38.4KB 39.0KB +581B 4.7KB 4.7KB 0B
core/Typeahead 61.6KB 62.2KB +591B 11.3KB 11.3KB 0B
lab/ChatEmojiPicker N/A 65.8KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 66.4KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 94.7KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 48.1KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 259.3KB N/A N/A 2.4KB N/A
lab/Comments N/A 263.9KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 46.3KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 63.6KB N/A N/A 722B N/A
lab/ListInput N/A 75.8KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 147.2KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 92.8KB N/A N/A 809B N/A
lab/Schedule N/A 60.5KB N/A N/A 3.9KB N/A
lab/TourStep N/A 56.0KB N/A N/A 590B N/A
lab/TransferList N/A 67.4KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 73.0KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.4KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 213.3KB N/A N/A 454B N/A
richtext/RichTextView N/A 251.9KB 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/Lightbox +43.2KB ⚠️ +329B

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.9KB 35.1KB +145B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

57 of 504 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 37 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 waits on an engineering owner's decision: drag to dismiss is new Lightbox behavior with no current spec, and it relies on the draft spec:AST-094 this stack adds, whose FR4, FR11 and FR14 also amend current records. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (you may do so yourself as an engineering owner), or says which requirement to change. Independently of that decision, these need fixing before approval:

  • Land the earlier PRs of the stack and rebase, so this PR carries only its own commit rather than 32,471 authored lines.
  • Restore every page the dismissal faded when the viewer reopens, since reopening a gallery at the item next to the dismissed one currently leaves the current page at opacity: 0 (Lightbox.tsx L684–688).
  • Restore the viewer when the caller keeps isOpen true after a drag dismissal, instead of leaving an open, invisible dialog.
  • Give the data-lightbox-chrome layers a zIndex so Close and Previous stay above a gallery's pages while the layers fade.

[Automated review]

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
Comment on lines +684 to +688
fadedRef.current = false;
const {backdrop, chrome} = dismissFades();
for (const animator of backdrop == null ? chrome : [backdrop, ...chrome]) {
animator.set({opacity: 1});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reopening at a neighbour of the dismissed item: dismissFades() runs the same [data-lightbox-page]:not([data-current]) query again, so the page that was a faded neighbour at the dismissal and is now current keeps the opacity: 0 the release animated it to, and the viewer opens blank (the Gallery story's thumbnails reach this). Restoring exactly the animators the release faded, or every [data-lightbox-page], fixes it; a proof that dismisses at index 1 and reopens at index 2 would guard it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, a page faded as a neighbour stayed at opacity 0 once it became current. Fixed in e1a3389: the viewer restores exactly the animators the release faded. LightboxDismiss dismisses at the second item and reopens at the third: opacity 0 before the fix, 1 after.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
animator.animate({opacity}, {spring}, {reducedMotion: 'keep'});
}
},
onDismissed: handleClose,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If the caller keeps isOpen true after this onOpenChange(false) (the overlay-dismissal family leaves that to the caller), the photo stays below the stage, the backdrop and controls stay at opacity 0 and new drags are refused, while the modal dialog stays open over an inert page. Only a reopen restores it. Restoring the viewer when it is still open after the callback avoids the invisible state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, a caller that keeps the viewer open got an invisible dialog. Fixed in e1a3389: a frame after onOpenChange(false), a viewer still open puts the photo, the backdrop and the controls back. The new ConfirmClose story (it asks before closing) and LightboxDismiss cover it.

Comment on lines +326 to +329
chromeLayer: {
position: 'absolute',
inset: 0,
pointerEvents: 'none',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

While the animator writes opacity and will-change: opacity here, this layer becomes a stacking context, so the buttons' zIndex: 1 no longer lifts them above a gallery's positioned pages, which come later in tree order. For the drag, its cancel spring plus 500 ms, and 500 ms after a reopen, taps on Close and Previous land on the page. zIndex: 1 on this layer keeps both layers above the stage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, a fading layer became a stacking context under the pages. Fixed in e1a3389: the control layers carry zIndex: 1. LightboxDismiss hit-tests the Close button mid-drag in a gallery: a page before the fix, Close after.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision: drag to dismiss is new Lightbox behavior with no current spec, and it relies on the draft spec:AST-094 this stack adds, whose FR4, FR11 and FR14 also amend current records. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or names the requirement to change. Independently of that decision, these need fixing before approval:

  • Land the earlier PRs of the stack and rebase, so this PR carries only its own commit rather than 32,892 authored lines; the rebase also clears pr-a11y, which fails only because a spec it runs exists on main but not on this branch.
  • Restore every page and caption the dismissal faded when the viewer reopens, since reopening a gallery at the item next to the dismissed one leaves the current page at opacity: 0 (Lightbox.tsx L684–688).
  • Restore the viewer when the caller keeps isOpen true after a drag dismissal, instead of leaving an open, invisible dialog.
  • Give the data-lightbox-chrome layers a zIndex, so Close and Previous stay above a gallery's pages while the layers fade and for the half second after.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision: drag to dismiss is new Lightbox behavior with no current spec, and it relies on the draft spec:AST-094 this stack adds, whose FR4, FR11 and FR14 also amend current records (and FR4, as written, rules out the theme-retunable touch springs the stack ships). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or names the requirement to change. Independently of that decision, these need fixing before approval:

  • Land the earlier PRs of the stack and rebase, so this PR carries only its own commit rather than 37,202 authored lines.
  • Restore every page and caption the dismissal faded when the viewer reopens, since reopening a gallery at the item next to the dismissed one leaves the current page at opacity: 0 (Lightbox.tsx L681–694).
  • Restore the viewer when the caller keeps isOpen true after a drag dismissal, instead of leaving an open, invisible dialog.
  • Give the data-lightbox-chrome layers a zIndex, so Close and Previous stay above a gallery's pages while the layers fade and for the half second after.
  • Regenerate packages/cli/test/__golden__/no-icon-set/docs-topics.json, whose motion topic skeleton no longer matches the motion docs, so test-node (1/4) passes again.

[Automated review]

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

  • Reduced motion. core/src/Lightbox/Lightbox.tsx:669 handles reduced motion locally. Per design:reduced-motion (DR1–DR4), the animator is the one place that resolves it (touch cross-fades in 200 ms or less with no travel, a fine pointer changes at once), and core has no opt-out, so reducedMotion: 'keep' should be removed.
  • Commit or spring back on release. core/src/Lightbox/zoomStage.ts:550 decides on its own whether a release finishes or springs back. A shared release rule is being specified (AST-057 FR5 covers row swipe today); avoid a component-specific threshold model.

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
vjeux force-pushed the uikit/c24-dismiss branch from 13266ce to e1a3389 Compare October 25, 2026 05:48
@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 e1a3389: the dismissal's release and its cancel take the animator's default and pass touchRelease. The release rule stays as it is until the shared interactive-transition driver exists, then moves onto it.

vjeux added 10 commits October 27, 2026 16:13
…base of meta#8147

A busy-window proof can judge a component against a compositor-only control
animation recorded in the same window (startBusyControl, CONTROL_RGB,
busyWindowProof's control option), instead of a frame-rate bound that a
two-core runner's software compositor cannot meet.
…positor-only control

On a two-core runner the software compositor and the screencast share the
core the blocked main thread spins on, so every compositor animation
presents fewer frames there: the proof's frame-rate bound failed 7 runs in
10 pinned to two cores (the pr-interaction red on meta#8215). It now records
the interaction harness's control animation in the same window and passes
when the photo keeps pace with it (busyWindowProof's verdict); a
main-thread animation still fails.
…ainst a compositor-only control

The proof tracked the gallery track, which is wider than the screen, so the
tracked box stayed pinned to the screen's edge and read as settled 7 ms into
the block, though the settle ran another 275 ms: its frame-rate checks
passed on a window too short to judge. It now tracks the next photo as it
slides in, and judges it against the interaction harness's control
animation recorded in the same window, which also fails a window too short
to judge. Red: on the old marker the control verdict reads "too little to
judge"; green in both engines.
…al windows, against a compositor-only control

Both proofs now judge the screen against the interaction harness's control
animation recorded in the same window (the dismissal's region signal
through the new signalFrames, at the sensitivity it had), which also fails
a window too short to judge. The dismissal's window holds 8 to 10 control
frames in both engines. The cancel's held 5 to 6 in WebKit, and 0 to 3 on
a loaded runner: from a 150 px pull the return came within 2 px of rest
about 75 ms into a block the test started late. It now pulls 300 px and
blocks inside the page 16 ms into the return, as the dismissal does: 13 to
14 control frames in WebKit, 11 in Chromium.
…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 90% of them (one miss allowed), and in each run its longest stall may be at most one interval 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 and a main-thread one fails, in Chromium and WebKit.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision about the drag's pointer scope: this head attaches drag to dismiss only under a coarse pointer, while the current spec:AST-094 FR19 and FR43 attach gestures for every pointer (a mouse drag and wheel or trackpad scrolling included) and list no Lightbox exception, and spec:AST-017 FR59 leaves a new fine-pointer feel to an owner decision. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request to keep the drag touch-only (the author may do so as an engineering owner), or says it should attach for every pointer as FR43 describes. Independently of that decision, these need fixing before approval:

  • Land the earlier pull requests of the stack and rebase, so this pull request carries its own 2,236-line change rather than 48,916 authored lines, and show why that change cannot be split further.
  • Fix the red test-node (2/4) check: scripts/touch-support-bundle.test.mjs now finds the gesture core in Core's root entry through Lightbox.
  • Conform the stack's gesture host and pager to architecture:react-component-runtime INV13 and FR43, which attach for every pointer (GestureHost.ts L11–22, usePager.ts L1252–1254).
  • Record the stage's top when the dismiss pan begins instead of calling getBoundingClientRect() at the lift (useZoomStage.ts L582, spec:AST-094 FR9).
  • Keep a dragged photo above its caption: in a single-item viewer the fading caption is painted over the photo (Lightbox.tsx L1244–1250).
  • Register the four zoomMedia.dismiss springs as motion roles, as spec:AST-094 FR4 requires.

[Automated review]

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
dismiss: {
// Touch work, like the zoom's: nothing attaches where the primary
// pointer is fine (spec:AST-094 FR19).
isEnabled: isOpen && !isVideo && isTouch,

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 keeps drag to dismiss touch-only, and the comment cites spec:AST-094 FR19 for "nothing attaches where the primary pointer is fine". Current FR19 says the reverse: "Recognizers attach for every pointer, with FR43's exceptions", and FR43's exceptions do not name Lightbox (a pan "also takes wheel and trackpad scrolling"). Keeping it touch-only needs an engineering owner's decision (spec:AST-017 FR59 reserves a new fine-pointer feel to one); either way the comment should quote the current rule.

geometry,
shown,
stageElement != null && view != null
? view.innerHeight - stageElement.getBoundingClientRect().top

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getBoundingClientRect() at the lift is a layout read after the gesture began, which spec:AST-094 FR9 rules out ("no layout read after the gesture begins"). The measure() at the pan's began already reads layout; taking the stage's top there and using it here keeps the release free of layout.

Comment on lines +1244 to 1250
{!isGallery && currentItem.caption && (
<div
data-lightbox-caption=""
{...stylex.props(styles.caption)}>
{currentItem.caption}
</div>
)}

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 caption comes after the photo's box in tree order and has no position or z-index. Once the drag writes its opacity it becomes a stacking context painted after the transformed photo, so the caption is drawn over the dragged photo (CI's lightbox-drag-to-dismiss storyboard shows it 50–100 ms into the drag). Giving the photo's box a stacking order above the caption while data-zoom-dismissing is set would fix it.

Comment on lines +13 to +22
*
* - Recognizers attach, and the host registers listeners, only while the
* primary pointer is coarse (`spec:AST-094` FR19, FR24; coarsePointer.ts).
* Where it is fine, an attach is recorded but nothing is installed: no
* document, window or element listener, no `touch-action` or other style,
* nothing delivered, so a finger on a touchscreen laptop gets the
* browser's defaults. The host follows the query: turning coarse installs
* it and styles every registration; turning fine cancels every touch in
* flight and removes it all. A development warning names the first attach
* under a fine pointer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current architecture:react-component-runtime INV13 says "The gesture host attaches for every pointer, with no touch-only query", and spec:AST-094 FR19/FR43 and DEC-21 ("Rejected: keeping gestures and press tracking touch-only") agree, so this coarse-pointer-only install contradicts current spec; it predates DEC-21 and belongs to the earlier pull request that adds the host.

*/
pageGap: 40,
},
dismiss: {

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-094 FR4: "Every spring, duration and curve a touch presentation plays MUST be a motion role named for its use". This table never calls registerMotionTable, so the four dismiss springs the release and cancel play are not roles and no theme motion knob can reach them; registering them, as springs.ts does for the shared springs, would conform.

vjeux added 17 commits October 27, 2026 20:29
…ery pointer, wheel and trackpad scrolling; the harness's pooled busy proof) into meta#8147
…every pointer, wheel and trackpad scrolling; the harness's pooled busy proof) into meta#8148
…ckpad (spec:AST-094 FR43)

The zoom stage's pan, double tap, tap and catch attach for every pointer;
the pinch stays a finger's, and a ctrl-wheel reaches it through the
gesture core (meta#8676). A double-click is the stage's double tap (the zoom
core's own mouse path goes), a wheel pans a zoomed photo, a zoomed drag
rubber-bands at the edges and a released one coasts. The stage fills the
viewer for every pointer. A release follows the pointer that began it:
under reduced motion a finger's cross-fades (DR2) and a mouse's, a
trackpad's or a pen's completes at once on a fine primary pointer (DR3).

Tests: LightboxEveryPointer's zoom proofs (a ctrl-wheel zooms, a wheel
pans a zoomed photo, a released drag coasts); the fine-pointer and zoom
mode proofs that asserted the old mouse zoom flip to FR43's; the unit
tests compare a mouse's double-click with a finger's double tap. Docs in
English and Chinese; a patch changeset, and the touch zoom changeset no
longer says a mouse keeps the old zoom.

Red: under a mouse the double-click zoomed 2x about the centre, a ctrl-wheel did not zoom, a wheel did not pan a zoomed photo, a released drag did not coast and the stage did not fill the viewer; green after.
… of meta#8197

meta#8148 now carries the gesture core of meta#8676 and meta#8665, which attaches
recognizers for every pointer and reads the selection lock from the
pointer alone. Two adaptations follow: usePager calls locksSelectionFor
with the pointer only, and the bare pager's fine-pointer proof expects a
mouse drag to page (spec:AST-094 FR43).
… pointer) into meta#8197

The zoom stage keeps this PR's pan and catch conditions on meta#8148's
pointer lists, and the zoom core its surface and edge checks while its
mouse path goes. The stage's options and layout take meta#8148's ungated
forms; the touch presentation stays for the gallery's buttons and arrow
keys. The zoom target's fine-pointer proof lists the stage's claimed
touches.
…croll (spec:AST-094 FR43)

usePager attaches its pan and catch for every pointer instead of only
under a coarse primary pointer, and its pan takes a trackpad's or a
wheel's horizontal scroll through the gesture core. Lightbox's gallery
lets the mouse page too. A click beside a gallery photo with zoom on is
the stage's tap for every pointer, after the double-tap interval.

Tests: LightboxEveryPointer's paging proofs (a mouse drag sideways and a
trackpad's horizontal scroll page a gallery); the paging mode and
fine-pointer proofs and usePager's React test that asserted no input
under a fine pointer flip to FR43's. Docs in English and Chinese; the
every-pointer changeset gains paging, and the paging changeset no longer
says a mouse pages only with the buttons.

Red: under a mouse a sideways drag and a trackpad's horizontal scroll did not page, the pager attached nothing on a fine pointer and the gallery claimed no touches; green after.
meta#8197 brings the zoom's and the pager's gestures for every pointer. The
drag to dismiss keeps its finger and pen pointer list (the zoom's shared
list is now every pointer's) and its touch-presentation gating until its
own change; its imports drop meta#8197's coarse-pointer read, and the
fine-pointer proofs keep meta#8197's gallery proof beside this PR's.
…ose (spec:AST-094 FR43)

The drag to dismiss attaches for every pointer (a video keeps its own
controls' drags), so a mouse or a pen drag down, or a wheel or trackpad
scroll that moves a fitted photo down, closes the viewer. Its release
follows the pointer that began it: the stage tells the owner whether a
finger released it, and under reduced motion a finger's release
cross-fades (DR2) while a mouse's, a trackpad's or a pen's completes at
once on a fine primary pointer (DR3), for the photo, the backdrop and the
controls alike.

Tests: LightboxEveryPointer's dismiss proofs (a mouse drag down closes,
one brought back springs back, a pen drag down closes, a wheel notch over
a fitted photo closes, a mouse's release under Reduce Motion closes at
once); the recognizer unit test and the fine-pointer proof that asserted
no drag to dismiss under a fine pointer flip to FR43's. Docs in English
and Chinese; the every-pointer changeset gains the drag down, and the
drag-to-dismiss changeset no longer says the mouse is unchanged.

Red: under a mouse, a pen or a wheel the drag down did not move or close the photo, nothing attached on a fine pointer and a fitted photo claimed no one-finger drags; green after.
…er (spec:AST-094 FR43)

The zoom stage's header, its enabled flag and the drag geometry still
spoke of fingers and pens only, of a mouse whose limits do not give, and
of a double-click on the released zoom; the hasZoom JSDoc still limited
the pinch and the double tap to a coarse pointer; the stage's styles
still said the touch layer ran on a touch screen only (FR19); and the
fine-pointer proofs' header still claimed touch work leaves that zoom
alone. They now say what the code does: every pointer reaches the
recognizers (the pinch is the fingers' and a ctrl-wheel's), the released
zoom is the keyboard's, and under Reduce Motion a finger's release
cross-fades while the others complete at once on a fine pointer.
Comments only.
…estures take every pointer) into meta#8197

The zoom stage's header keeps this PR's track and pager wording with
meta#8148's pointers, and the fine-pointer proofs' header adds this PR's
gallery to meta#8148's.
…very pointer (spec:AST-094 FR43)

The track's style still said the stage's touches went to it under the
touch layer only, the zoom stage's catch still spoke of a touch, and the
gallery's navigation comment tied the pager's slide to a finger, where
it follows the touch presentation. They now say what the code does.
Comments only.
…every pointer) into meta#8215

The zoom stage's header keeps this PR's dismissal after meta#8197's catch,
and the fine-pointer proofs' header adds this PR's photo without zoom to
meta#8197's.
…nter (spec:AST-094 FR43)

The zoom stage's header still described a one-finger pan the photo
follows, its dismiss flag still said an image on a touch screen, the
photo-without-zoom style still said the touch layer took its drags, and
the dismissal geometry spoke of a finger. They now say what the code
does: a finger's, a mouse's or a pen's drag, or a scroll, drags a photo
down to close. Comments only.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision about the new architecture:layer-runtime INV13 ("One exit copy per closing layer") and its Exit copy section, which this head writes into a current architecture record that main does not state; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or asks for it to be settled in #8186 instead. Independently of that decision, these need fixing before approval:

  • Land the earlier pull requests of the stack and rebase, so this pull request carries its own 2,435-line change rather than 54,838 authored lines, and show why that change cannot be split further.
  • Fix the red test-node (2/4) check: scripts/touch-support-bundle.test.mjs now finds the gesture core in Core's root entry through Lightbox.
  • Register the four zoomMedia.dismiss springs as motion roles, as spec:AST-094 FR4 requires.
  • Record the stage's position when the dismiss pan begins instead of calling getBoundingClientRect() at the release (useZoomStage.ts L609, spec:AST-094 FR9).
  • Say in the changesets and the onOpenChange docs that a wheel or trackpad scroll that moves a fitted photo down closes the viewer.
  • Keep a dragged photo above its caption: in a single-item viewer the fading caption is painted over it (Lightbox.tsx L1232–1238).
  • Add fine-pointer evidence before and after (a mouse drag and a wheel notch over a fitted photo, on main and on this head), as spec:AST-017 FR59 asks.

[Automated review]

geometry,
shown,
stageElement != null && view != null
? view.innerHeight - stageElement.getBoundingClientRect().top

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 getBoundingClientRect() runs at the release, after the gesture began, which spec:AST-094 FR9 rules out ("no layout read after the gesture begins"), and it forces a synchronous layout at the lift. The began branch already calls measure() (L568); recording the stage's top or the exit distance there would remove this read.

* (`CASpringAnimation` mass 1, stiffness 918.09, damping 60.60),
* Oct 24 2026
*/
dismissPositionSpring: {mass: 1, stiffness: 918.09, damping: 60.6},

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 four dismiss springs are played directly (useZoomStage.ts L617, L633, L640; Lightbox.tsx L809–811), but this table never calls registerMotionTable, so none of them is a motion role as spec:AST-094 FR4 requires, and a theme's motion knobs never reach the drag's settle.

Comment on lines +1232 to 1238
{!isGallery && currentItem.caption && (
<div
data-lightbox-caption=""
{...stylex.props(styles.caption)}>
{currentItem.caption}
</div>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Once the drag writes this caption's opacity, it becomes a stacking context painted after the transformed photo, so its text lies across the moving photo; CI's storyboard of this head shows it 50 ms into a touch drag on the Default story. Keeping the dragged stage above the caption while data-zoom-dismissing is set would fix it.

'@astryxdesign/core': patch
---

[feat] Lightbox: a mouse, a pen and a trackpad now use the same gestures as a finger. Drag a photo down to close the viewer; drag sideways, or scroll a trackpad sideways, to move through a gallery; ctrl-wheel to zoom; drag or scroll to pan a zoomed photo, which coasts on when released moving. A double-click zooms in at the click, as a double tap does; the keyboard keeps its 2× zoom about the centre.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One wheel notch or trackpad scroll that moves a fitted photo down also closes the viewer (the LightboxEveryPointer proof "a wheel notch that moves a fitted photo down closes the viewer"), but this note lists scrolling only for paging a gallery and panning a zoomed photo. Worth saying so here and in the onOpenChange docs.

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants