Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewModified ComponentsLightbox (@astryxdesign/core) · View in Storybook
Component Size
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.
|
| 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
There was a problem hiding this comment.
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.tsxL684–688). - Restore the viewer when the caller keeps
isOpentrue after a drag dismissal, instead of leaving an open, invisible dialog. - Give the
data-lightbox-chromelayers azIndexso Close and Previous stay above a gallery's pages while the layers fade.
[Automated review]
| fadedRef.current = false; | ||
| const {backdrop, chrome} = dismissFades(); | ||
| for (const animator of backdrop == null ? chrome : [backdrop, ...chrome]) { | ||
| animator.set({opacity: 1}); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| animator.animate({opacity}, {spring}, {reducedMotion: 'keep'}); | ||
| } | ||
| }, | ||
| onDismissed: handleClose, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| chromeLayer: { | ||
| position: 'absolute', | ||
| inset: 0, | ||
| pointerEvents: 'none', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
e620a7e to
2a31746
Compare
There was a problem hiding this comment.
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 onmainbut 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.tsxL684–688). - Restore the viewer when the caller keeps
isOpentrue after a drag dismissal, instead of leaving an open, invisible dialog. - Give the
data-lightbox-chromelayers azIndex, so Close and Previous stay above a gallery's pages while the layers fade and for the half second after.
[Automated review]
2a31746 to
13266ce
Compare
There was a problem hiding this comment.
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.tsxL681–694). - Restore the viewer when the caller keeps
isOpentrue after a drag dismissal, instead of leaving an open, invisible dialog. - Give the
data-lightbox-chromelayers azIndex, 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, whosemotiontopic skeleton no longer matches the motion docs, sotest-node (1/4)passes again.
[Automated review]
|
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. |
|
Agreed: one implementation.
|
13266ce to
e1a3389
Compare
|
Pushed in e1a3389: the dismissal's release and its cancel take the animator's default and pass |
e1a3389 to
c6329eb
Compare
…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.
There was a problem hiding this comment.
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.mjsnow finds the gesture core in Core's root entry through Lightbox. - Conform the stack's gesture host and pager to
architecture:react-component-runtimeINV13 and FR43, which attach for every pointer (GestureHost.tsL11–22,usePager.tsL1252–1254). - Record the stage's top when the dismiss pan begins instead of calling
getBoundingClientRect()at the lift (useZoomStage.tsL582,spec:AST-094FR9). - Keep a dragged photo above its caption: in a single-item viewer the fading caption is painted over the photo (
Lightbox.tsxL1244–1250). - Register the four
zoomMedia.dismisssprings as motion roles, asspec:AST-094FR4 requires.
[Automated review]
| dismiss: { | ||
| // Touch work, like the zoom's: nothing attaches where the primary | ||
| // pointer is fine (spec:AST-094 FR19). | ||
| isEnabled: isOpen && !isVideo && isTouch, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| {!isGallery && currentItem.caption && ( | ||
| <div | ||
| data-lightbox-caption="" | ||
| {...stylex.props(styles.caption)}> | ||
| {currentItem.caption} | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
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.
| * | ||
| * - 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. |
There was a problem hiding this comment.
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: { |
There was a problem hiding this comment.
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.
…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.
…ter) into the base of meta#8197
…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.
…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.
There was a problem hiding this comment.
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.mjsnow finds the gesture core in Core's root entry through Lightbox. - Register the four
zoomMedia.dismisssprings as motion roles, asspec:AST-094FR4 requires. - Record the stage's position when the dismiss pan begins instead of calling
getBoundingClientRect()at the release (useZoomStage.tsL609,spec:AST-094FR9). - Say in the changesets and the
onOpenChangedocs 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.tsxL1232–1238). - Add fine-pointer evidence before and after (a mouse drag and a wheel notch over a fitted photo, on
mainand on this head), asspec:AST-017FR59 asks.
[Automated review]
| geometry, | ||
| shown, | ||
| stageElement != null && view != null | ||
| ? view.innerHeight - stageElement.getBoundingClientRect().top |
There was a problem hiding this comment.
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}, |
There was a problem hiding this comment.
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.
| {!isGallery && currentItem.caption && ( | ||
| <div | ||
| data-lightbox-caption="" | ||
| {...stylex.props(styles.caption)}> | ||
| {currentItem.caption} | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
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
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:
Evidence
Safari on the iOS 26.2 simulator:
Android 15 emulator, Chromium:
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.