Skip to content

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

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

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

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Batch: B7 Lightbox
Stack: #8147 → #8148 → #8197 → #8215, #9810 (both on #8197)

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

Rebased with the stack on #8197's dc3a360 (main f10bc91). Since 6a6505e, the head Cindy passed card 40 on:

At 67076bd on box 6: pnpm install --frozen-lockfile 0; core tsc 0; strict ESLint on every changed file 0; CI=true pnpm lint 0 errors; the Lightbox, motion, gestures and hooks unit files 2,673/2,673; pnpm check:knowledge and pnpm check:changesets pass; a root build and a Storybook build of this head, then Chromium Lightbox a11y specs 25/25, Chromium Lightbox interaction specs 96/96 (4 WebKit-only skips), WebKit 91/91 (9 Chromium-only skips).

  • the record's FR8 cites spec:AST-094 FR4 and FR45 for the dismissal's and the cancel's measured springs, by the spec owners' ruling;
  • main's test(browser): keep every Playwright browser context off remote hosts #9811 makes @playwright/test a lint error in browser specs, so the Dismiss and DismissCancel specs import test and expect from @astryxdesign/a11y-spec/playwright, as main's do;
  • a new unit pins touch as it was passed: after a drag to close springs back, the first tap on the strip below the stage only clears the drag's flag, and the next closes. It passes with 6a6505e's Lightbox.tsx and fails with a draft that set the flag only for a zoomed drag.

At de1e56f: pnpm install --frozen-lockfile exits 0; core tsc is clean, and so are strict ESLint and CI=true pnpm lint on the changed files; the Lightbox, usePager, motion/uikit and gestures unit files pass 1325/1325; pnpm check:knowledge and pnpm check:changesets pass; on a root and a Storybook build of it, Chromium Lightbox a11y specs 25/25, Chromium Lightbox and hook interaction specs 118/118 (6 WebKit-only skips) and WebKit 112/112 (12 Chromium-only skips).

Earlier: rebased on #8197's 2b36122 (main 5a373df). Since 9896c40, the head that went up for the iPhone check, this pull request's own lines change only with the stack below it:

  • the dismissal reads useZoomStage's options directly, as feat(Lightbox): touch zoom like the iOS Photos viewer #8148 now does, instead of a ref an Effect kept copied;
  • the record keeps FR11's approved text: its Current-state list still names FR7's drag paging and FR8's drag to close as not built for a mouse, a pen or a trackpad, since this pull request's drag to close stays the touch presentation's;
  • useZoomStage's header names the two options this pull request adds (the current item's key and the drag to close).

Against 11abd78:

  • the controls' backing from feat(Lightbox): touch zoom like the iOS Photos viewer #8148 sits inside the chrome layers, so each backing fades with its layer while a photo is dragged down;
  • the zoom stage keeps feat(Lightbox): swipe between gallery photos like Photos #8197's caption check on its pinch, read from the pinch's first touch, beside the drag to dismiss;
  • the restore after a cancelled dismissal runs from a layout effect and a stable callback, so render writes no ref;
  • the record's FR8 line names no pull request;
  • the busy proof's screen-signal helper and its unit test land here, where the dismissal's proof imports them.
  • the drag to close is part of the touch presentation, which a coarse primary pointer brings (spec:AST-094 FR13), as the spec owners ruled. Where the primary pointer is fine, a mouse's, a pen's or a trackpad's drag leaves the photo in place, as on main; FR11 keeps its approved text, and the record's Current-state list names the drag to close as not built for a fine pointer;
  • a photo dragged down draws above its caption and below the controls, and in a gallery it is no longer clipped by its page's photo box;
  • the release reads no layout: the exit distance comes from the stage's origin, measured when the drag began;
  • the viewer's restore after a dismissal runs in the open branch of the open/close effect, not in an effect of its own.

Size

This pull request's own commit is about 2,500 authored lines: about 1,750 are tests, about 700 source, and the rest stories, changeset, measurements and the record line. The drag and its release are one gesture: the follow, the shrink and the fades only make sense with the release that closes or springs back, and both releases share one decision and one set of measured springs. Most of the size is the proof against Photos' measured curves for the follow and both releases, a blocked main thread, Reduce Motion and every pointer.

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).

Record change

component:Lightbox's Current-state impact list: FR8 leaves the touch-presentation line, which now names only the pinch to close, the tap to hide the controls and the thumbnail transition. A new line names FR8's implementation gap: "Drag to close (FR8). Implementation gap: the close is reported when the photo has left the screen, about 0.3–0.5 s after the lift, until the shared exit hold lets the viewer stay up after a reported close." The fine-pointer line ("A mouse drag on a fitted photo does not close the viewer") goes, since a mouse's drag now closes it. No requirement's text changes. The record needs a spec owner's approval.

Writing rubric results

Run over docs/specs/components/Lightbox/Lightbox.spec.md:

  • R1 narration: none; the Current-state lines state what the shipped component does.
  • R2 research: none in the record.
  • R3 evidence: none.
  • R4 rejected alternatives: one, in DEC-2, one line (unchanged).
  • R5 change references: none; the FR8 line names no pull request.
  • R6 open questions: none open.

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

Fine pointer, before and after (desktop Chromium, a mouse, "With zoom"; at rest, 120 px into a drag down, and 120 ms after the release): the drag to close is touch only, so a mouse drag leaves the photo in place and the viewer open on both.

main: main

this pull request: this pull request

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.

Testing

At 6a6505e on #8197's 2b36122 (main 5a373df):

  • pnpm install --frozen-lockfile exits 0; core tsc is clean, and so is strict ESLint on every changed file; INV29 finds no long sentence;
  • the Lightbox, usePager, motion/uikit and gestures unit files pass 1312/1312; pnpm check:knowledge and pnpm check:changesets pass;
  • a root build and a Storybook build of this tree, then: Chromium Lightbox a11y specs 18/18; Chromium Lightbox interaction specs 94/94 (4 WebKit-only skips); WebKit 89/89 (9 Chromium-only skips);
  • cases red on the previous head's build first: "a photo dragged to dismiss draws above its caption" (the covered caption pixels differ there); "a mouse drag down leaves a fitted photo and the viewer in place, as on main", "a pen drag down leaves…" and "a trackpad's scroll down leaves…" (the photo moved and the viewer closed); "a photo without zoom leaves its touches to the page where the primary pointer is fine, as on main" (its touch-action was pinch-zoom). "A photo dragged to dismiss draws past the box it was fitted in" covers the single photo and the gallery;
  • the dismissal's busy proof ("the dismissal keeps changing the screen every frame while the main thread is blocked") failed 4 of 10 runs in WebKit on a loaded machine. Each red run shows the same cause: once the photo leaves, the watched region fades to one even colour in whole 8-bit steps, and in the fade's tail a frame repeats a level 3 to 4 levels from the end. It was not this pull request's stacking change: on one machine, order-balanced, the head failed 1 of 12 and a copy with that change reverted failed 2 of 12. The proof now stops judging where the fade's step between the closest two frames shown falls below one level, computed from the fade's spring (about 4 levels from the end), and a fade blocked on the main thread still fails, since its judged part shows no change. After: WebKit 20/20 and Chromium 6/6;
  • lint, typecheck and the full unit suites run before this leaves draft.

@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 Nov 4, 2026 1:36pm 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
🔄 In progress (for maintainers only) Nov 4, 2026, 1:27 PM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@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 a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…s on a touch screen, and the zoom fades the backdrop without its blur

sourceFor moves under the experimental options (experimental.sourceFor,
stability experimental, Tokenizer's and Drawer's pattern) while the owner
question on its API is open. The release note says the transition runs on a
touch screen. The opening and the close fade the backdrop without its blur
(meta#8215's switch), so a 2-core runner keeps the busy opening's frames; the
blur is back once the photo lands. Two browser specs pin it; both failed on
the merge alone. The zoom transition spec's non-null assertion becomes a
guard.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…ring-back brings it back

The pinch close fades the backdrop through the same helper as the drag to
dismiss, so meta#8215's switch reaches it: the blur comes off while the pinch
moves the backdrop and returns once a spring-back rests. The spec failed
with the switch on the drag alone (blur(2px) mid-pinch, both engines).

@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 it 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 only its own changes rather than 44,239 authored lines.
  • Record the stage's top when the pan begins instead of reading getBoundingClientRect() at the lift (useZoomStage.ts:582, spec:AST-094 FR9).
  • Update the description, which still says the backdrop stops blurring under every pointer, while this head keeps the blur at rest.

[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 reads layout at the lift: getBoundingClientRect() forces a synchronous style and layout just before the exit spring starts, and spec:AST-094 FR9 asks for "no layout read after the gesture begins". The stage's top can be taken when the pan begins, where measure() already reads layout, and kept with dismissRef for dismissExitY.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
Comment on lines +791 to +793
// 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.

The current spec:AST-094 FR19 says "Recognizers attach for every pointer, with FR43's exceptions", and FR43 lists no Lightbox exception, so this comment cites FR19 for a rule it no longer states. Whether the drag stays touch-only (an FR43 exception) or attaches for every pointer is an engineering owner's decision; either way this comment and the jsdom suite titled after FR19 should cite that decision.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
targetRef is not read in this PR's zoom stage (the drag to dismiss, in
meta#8215, is its first reader); its unused destructure was a lint warning.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
Resolution: the zoom stage keeps targetRef in its destructure (meta#8197 drops
it as unused; the drag to dismiss here is its first reader).
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
Resolution: this PR's open/close effect (the zoom transition's) replaces
the plain one; it takes main's meta#8741 change: with no item there is no
dialog, so the effect waits for one (`hasItem` in its guard and its
dependencies) and a viewer opened before its first item arrives opens when
it does. meta#8741's test failed on this effect without 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 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 only its own changes rather than 44,239 authored lines.
  • Keep a dragged photo above its caption: in a single-item viewer the caption is painted across the photo while it is dragged (Lightbox.tsx L1244–1250).
  • Record the stage's top when the pan begins instead of reading getBoundingClientRect() at the lift (useZoomStage.ts L582, spec:AST-094 FR9).

[Automated review]

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
Comment on lines +796 to +798
// 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 the drag to dismiss touch-only and cites FR19, but the current spec:AST-094 FR19 reads "Recognizers attach for every pointer, with FR43's exceptions", and FR43's exceptions do not name Lightbox (with a mouse a pan also takes wheel and trackpad scrolling), while spec:AST-017 FR59 leaves a new fine-pointer feel to an owner decision. Whether the drag stays touch-only or attaches for every pointer is an engineering owner's call; either way the comment should cite the rule that keeps it.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
Comment on lines +1244 to +1247
{!isGallery && currentItem.caption && (
<div
data-lightbox-caption=""
{...stylex.props(styles.caption)}>

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 a drag moves the photo, this caption is painted on top of it: once the drag writes its opacity it becomes its own layer after the photo in paint order, and the photo now draws outside its box. At 50 px of finger travel the caption is still at 0.88 opacity across the photo's lower edge (Chromium, Pixel 7). Could the stage sit above the caption while data-zoom-dismissing is set, below the control layers so Close stays reachable?

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() here runs at the lift, a layout read after the gesture began, which spec:AST-094 FR9 rules out and the zoom core's own comment (useZoomCore.ts L175) says never happens. measure() already reads the stage's rect when the pan begins (L541), so the stage's top could be kept with the drag's geometry and used here.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…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.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 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 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]

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 thread packages/core/src/Lightbox/Lightbox.tsx Outdated
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.

@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 thread packages/core/src/Lightbox/Lightbox.tsx Outdated
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.

Comment thread .changeset/lightbox-every-pointer.md Outdated
'@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.

@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: the stack rewrites the current component:Lightbox record (FR11 so a mouse wheel's notches no longer pan a zoomed photo, FR5 with Motion roles for the zoom settle, and a new AR9 that puts a zoomable viewer's controls on dark backings, a new desktop look that spec:AST-017 FR59 says needs its own owner decision). An engineering owner records it by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; otherwise the owner names the requirement to change. These still need changes after that decision:

  • The diff is 10,529 authored lines because it carries #8147, #8148 and #8197; land them, rebase, and show why the drag's own 2,558-line commit cannot be split further.
  • Under a mouse, a pen or a trackpad the committed drag animates the photo away before closing, while FR11 and DEC-2 say the viewer closes at once under a fine pointer (useZoomStage.ts:641–653).
  • The four dismissal springs are played as literals instead of named motion roles, against FR5 and spec:AST-094 FR4 (zoomMedia.ts:218–219).
  • During the drag the caption is painted across the moving photo, because it becomes a stacking context after the transformed photo (Lightbox.tsx:1413–1420).
  • The committed release calls getBoundingClientRect(), a layout read after the gesture began, against spec:AST-094 FR9 (useZoomStage.ts:636).
  • The restore on open runs in its own Effect (Lightbox.tsx:946–950) when the existing open branch (Lightbox.tsx:650–654) can do it.
  • The description shows no fine-pointer evidence before and after, which spec:AST-017 FR59 and Lightbox's Compatibility section require.

[Automated review]

Comment on lines +641 to +648
const handle = animator.animate(
{x: shown.x, y},
{
spring: zoomMedia.dismiss.dismissPositionSpring,
velocity: {x: velocity.x, y: velocity.y},
},
{crossfadeAs: 'exit', touchRelease: fingerRef.current.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.

Under a fine primary pointer this still plays the exit spring, and onOpenChange(false) waits for it. component:Lightbox FR11 says "the viewer opens and closes at once", and its verification row says "a fine-pointer close that animates fails". Where the release is not a finger's and the primary pointer is fine, close at the lift with no exit spring, as the reduced-motion path does.

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 release is a layout read after the gesture began (spec:AST-094 FR9). measure() already records the stage's origin when the pan begins (useZoomCore.ts:207–210); compute the exit distance from that.

Comment on lines +218 to +219
registerMotionTable('zoomMedia', zoomMedia, {
roles: ['settle.baseMs', 'settle.lnRatioMs', 'settle.maxMs'],

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 settle lengths become roles. The four dismiss.* springs are then played as literals (useZoomStage.ts:644, :660, :667; Lightbox.tsx:895–897), against Lightbox FR5 ("dismiss" is a named role) and spec:AST-094 FR4. Register them here and read them through resolveMotion, then add them to the record's Motion roles table.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
Comment on lines +946 to +950
useIsomorphicLayoutEffect(() => {
if (isOpen) {
restoreAfterDismissal();
}
}, [isOpen, restoreAfterDismissal]);

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 is a second Effect for the open event: the existing open/close Effect's open branch (:650–654) already handles it and clears --_lightbox-backdrop-filter there, and this Effect's comment names no external system (architecture:react-component-runtime INV3). Call restoreAfterDismissal() in that branch and drop this Effect.

Comment on lines +1413 to 1420
{!isGallery && isRenderable(currentItem.caption) && (
<div
ref={captionRef}
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 the caption's opacity, the caption becomes a stacking context painted after the transformed photo, so its text lies across the moving photo. Keep the stage above the caption, and below the control layers, while data-zoom-dismissing is set.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
Comment on lines +181 to +185
// The backdrop dims and blurs the page as on main: the theme's overlay
// colour over a 2 px blur. While a drag to dismiss and its release fade
// it, the blur comes off through the dialog's
// --_lightbox-backdrop-filter. On a 2-core limit, a full-viewport blur
// repainted every frame of the fade drops frames.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): "as on main" narrates history. Suggest: "The backdrop is the theme's overlay colour over a 2 px blur. A drag to dismiss and its release remove the blur through --_lightbox-backdrop-filter. A full-viewport blur repainted every frame of the fade drops frames."

@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. The stack rewrites the current component:Lightbox record: FR11 so a mouse wheel's notches no longer pan a zoomed photo, FR5 with Motion roles for the zoom settle, and a new AR9 that puts a zoomable viewer's controls on dark backings, a new desktop look that spec:AST-017 FR59 says needs its own owner decision. 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; otherwise the owner names the requirement to change. These still need changes after that decision:

  • The diff is 10,399 authored lines because it carries #8147, #8148 and #8197; land them, rebase, and show why the drag's own 2,556-line commit cannot be split further.
  • Under a mouse, a pen or a trackpad the committed drag animates the photo away and closes the viewer about 0.4 s after the release, while FR11 and DEC-2 say the viewer closes at once under a fine pointer (useZoomStage.ts:640–653).
  • In a gallery the dragged photo stays clipped by its page's photo box, because pageMedia keeps overflow: hidden while only the single-item stage lets the photo draw outside (Lightbox.tsx:316).
  • The four dismissal springs are played as literals instead of named motion roles, against FR5 and spec:AST-094 FR4 (zoomMedia.ts:218–219).
  • During the drag the caption is painted across the moving photo, because it becomes a stacking context after the transformed photo (Lightbox.tsx:1358–1364).
  • The committed release calls getBoundingClientRect(), a layout read after the gesture began, against spec:AST-094 FR9 (useZoomStage.ts:636).
  • The restore on open runs in its own Effect (Lightbox.tsx:939–943) when the existing open branch (Lightbox.tsx:630–634) can do it.
  • The description shows no fine-pointer evidence before and after, which spec:AST-017 FR59 and Lightbox's Compatibility section require.

[Automated review]

spring: zoomMedia.dismiss.dismissPositionSpring,
velocity: {x: velocity.x, y: velocity.y},
},
{crossfadeAs: 'exit', touchRelease: fingerRef.current.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.

Under a fine primary pointer this still plays the exit spring, and onOpenChange(false) waits for it: probed with a mouse, the photo travels from 162 px to 801 px and the dialog closes 419 ms after the release. component:Lightbox FR11 says "the viewer opens and closes at once", and its verification row says "a fine-pointer close that animates fails". Close at the lift with no exit spring when the release is not a finger's and the primary pointer is fine, as the reduced-motion path already does.

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 release is a layout read after the gesture began (spec:AST-094 FR9). measure() already records the stage's origin when the pan begins (useZoomCore.ts:207–210); compute the screen's bottom from that.

*/
export function registerZoomMediaTable(): void {
registerMotionTable('zoomMedia', zoomMedia, {
roles: ['settle.baseMs', 'settle.lnRatioMs', 'settle.maxMs'],

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 settle lengths become roles. The four dismiss.* springs are then played as literals (useZoomStage.ts:644, :660, :667; Lightbox.tsx:889–890), against Lightbox FR5 ("dismiss" is a named role) and spec:AST-094 FR4. Register them here and read them through resolveMotion.

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.

New motion roles are barred by the owners' rule, so these four stay unregistered. The drag to close is a gesture exit: its springs will read the existing gesture roles once #9706 lands, and the record's Motion roles table will name only those.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In a gallery the stage is this page box, so data-zoom-dismissing lands here, but the box keeps overflow: hidden: 140 px into a mouse drag the photo reaches 866 px while the box ends at 768 px, so its bottom is cut off. Give it the same ':is([data-zoom-dismissing])': 'visible' override as imageWrapper, and run the clipping test on the gallery story.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
animator.set({opacity: 1});
}
}, [setBackdropMoving]);
useIsomorphicLayoutEffect(() => {

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 is a second Effect for the open event: the open/close Effect's open branch (:630–634) already handles it and clears --_lightbox-backdrop-filter there, and this Effect's comment names no external system (architecture:react-component-runtime INV3). Call restoreAfterDismissal() in that branch and delete this Effect.

<div {...stylex.props(styles.caption)}>
{!isGallery && isRenderable(currentItem.caption) && (
<div
ref={captionRef}

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 the caption's opacity, the caption becomes a stacking context painted after the transformed photo, so its text lies across the moving photo. Keep the stage above the caption, and below the control layers, while data-zoom-dismissing is set.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
backgroundColor: 'transparent',
overflow: 'hidden',
outline: 'none',
// The backdrop dims and blurs the page as on main: the theme's overlay

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): "as on main" narrates history. Suggest: "The backdrop is the theme's overlay colour over a 2 px blur. A drag to dismiss and its release remove the blur through --_lightbox-backdrop-filter. A full-viewport blur repainted every frame of the fade drops frames."

@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. This head rewrites the current component:Lightbox record (FR11 so a mouse wheel's notches no longer pan a zoomed photo, FR5 with the zoom settle's length roles, and a new AR9 that puts a zoomable viewer's controls on dark backings, a new desktop look that spec:AST-017 FR59 says needs its own owner decision). It also ships behavior no current clause settles: the four drag-to-close springs stay literals while FR5 and spec:AST-094 FR4 require motion roles, the drag fades the controls, caption and neighbouring photos and drops the backdrop's blur where FR8 names only the backdrop's fade, and arrow-key panning now stops at the photo's edges. 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; otherwise the owner names the requirement or behavior to change.

These still need changes after that decision:

  • Desktop behavior change needs an owner decision: with default props, a mouse or pen drag down a fitted photo, or a trackpad scroll, now closes the viewer, and a mouse drag or sideways trackpad scroll pages a gallery (Lightbox.tsx:914, Lightbox.tsx:1012-1016, useZoomStage.ts:342-348); gate these to touch input or cite the owner decision that covers them.
  • The diff is 10,603 authored lines because it carries #8147, #8148 and #8197, and no size exemption applies; give all four descriptions the same Stack: line with #8147's head under #8148's, or land the three and rebase, since the branch also no longer merges cleanly with main.

The earlier animated desktop close, gallery clipping, caption stacking and release layout read are fixed.

[Automated review]

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
dismiss: {
// Every pointer's, like the zoom's. A video keeps its own controls'
// drags.
isEnabled: isOpen && !isVideo,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Desktop behavior change needs an owner decision: this attaches the drag to close for every pointer with default props, so a mouse or pen drag down a fitted photo, or a trackpad scroll, closes the viewer where main does nothing (the pager's panOptions at line 1013 does the same for paging). Gate it to touch input, or cite the owner decision that covers this desktop behavior.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
}
}, []);

// Opening again after a dismissal: the photo, the backdrop and the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (comment style): "Only then: a write promotes the layers" is a fragment. Suggest: "Restore the photo, the backdrop and the controls only when the viewer opens after a dismissal. Each write promotes a layer, and releasing it later repaints during a gesture."

@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 6a6505e

vjeux added 4 commits November 4, 2026 01:30
The UIKit constants table gains zoomMedia, measured on iOS 26.2 in a
UIKit lab app and in Photos: the give past the zoom limits, the eased
settle and its length law, and Photos' double tap and maximum zoom. The
settle's three lengths are internal motion roles. component:Lightbox
names them in a new Motion roles table, and the generated motion role
table carries them.

Lightbox/zoomStage.ts is the pure geometry of a zooming scroll view in a
photo viewer, checked against those numbers. A double tap zooms to the
larger of filling the stage and 2x, about the tapped point, as
component:Lightbox FR6 asks. Nothing renders the stage yet: Lightbox's
touch zoom plays it in the next change.
Lightbox's zoom moves to the gesture recognizers and the compositor
animator. A pinch zooms about the fingers with the pan riding along, and
it gives past the zoom limits and eases back about the last centroid. A
finger's double tap zooms to the larger of filling the viewer and 2x,
about the tapped point, over the theme's move easing and medium
duration. A mouse's or a pen's double-click zooms 2x about the clicked
point. In the touch presentation a zoomed pan has UIScrollView's rubber
band, coast and edge bounce. A mouse's or a pen's drag pans 1:1 and
stops at the edges and at the release, as before.

The zoom takes every pointer: a ctrl-wheel or a trackpad pinch zooms,
and a trackpad's scroll pans a zoomed photo; a mouse wheel's notches
never pan. The photo's stage fills the viewer and takes the pinch; the
controls and caption keep the page's pinch-zoom.
Since a zoomed photo can now lie under Close, Previous, Next and the
counter, a zoomable Lightbox sets them on its own translucent backing
and keeps their icons and text on the on-dark colour, so they keep
3:1, and 4.5:1 for the counter's text, over any photo in every theme.
The keyboard keeps its 2x zoom about the centre over the released 200 ms
ease-out, and keyboard panning stops at the photo's edges.

The double tap reads its tokens through the shared theme-timing
reader in motion/themeTiming.ts. A theme that leaves --duration-medium
unset gets Core's default duration there, not a zoom that lands at once.
A Lightbox gallery moved only through its two arrow buttons. A finger's
drag or flick now pages between items with UIKit's paging release: one
page at most, with Photos' measured 40 pt gap, settled on the
compositor. The drag paging is part of the touch presentation, which a
coarse primary pointer brings (spec:AST-094 FR13); under a fine pointer
the buttons and the arrow keys page, as before. A zoomed photo keeps
the drag until its edge, as nested scroll views do, and a pinch never
pages.

A video pages from its frame. Its native controls are taken as a 48 px
band at its bottom edge, since browsers expose no control geometry, so
a drag that starts there stays the video's (component:Lightbox FR7).

Only the current page takes focus and is in the accessibility tree.
Next, Previous and any other change of `index` slide the row under the
touch presentation; under a fine pointer the row moves at once, so a
button the owner refuses shows nothing. A drag the owner refuses
springs back. Under Reduce Motion they fade. On a
zoomable gallery the photo rows take the stage's touches while the
track allows the page's pinch-zoom, so a pinch on a caption still
zooms the page.

The pan and the pinch read where their touches began from the shared
recognizers, and without zoom a tap beside a gallery's photo closes
the viewer through a shared tap recognizer.
A Lightbox photo closed only through its Close button or a tap beside
it. Dragged down, a fitted photo now follows the drag, shrinks, and the
backdrop fades with it. The release closes the viewer when the drag's
projected end lies more than 10 pt below its start, so a short flick
closes and a drag brought back springs back. The release animates on
the compositor. The drag to close is part of the touch presentation,
which a coarse primary pointer brings (spec:AST-094 FR13). Where the
primary pointer is fine, a mouse's, a pen's or a trackpad's drag
leaves the photo in place, as before.

The backdrop's blur comes off while the photo moves and returns at
rest, since a full-screen blur every frame drops frames on low-end
phones. Under Reduce Motion the photo fades out.

The close is reported when the photo has left the screen, about
0.3-0.5 s after the lift; component:Lightbox names that gap until the
shared exit hold lands.
@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 67076bd

This branch was successfully deployed

1 active deployment
Preview — 67076bd0 Deployed Nov 4, 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