Skip to content

feat(Lightbox): zoom from a thumbnail and back, like Photos - #8231

Draft
vjeux wants to merge 2283 commits into
meta:mainfrom
vjeux:uikit/c24-zoom-transition
Draft

vjeux wants to merge 2283 commits into
meta:mainfrom
vjeux:uikit/c24-zoom-transition

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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

Why

In Photos, a photo opens by growing out of its thumbnail and closes back into it, so you keep your place in the grid. A Lightbox photo appears and disappears at once, with no link to the thumbnail you tapped.

What led to it

The UIKit-parity work for Astryx (spec:AST-094): UIKit's zoom transition, on the dismissal (#8215), so a photo dragged down lands in its thumbnail.

Options and tradeoffs

  • The browser's View Transitions: built in, but a finger cannot catch one mid-flight, and not every supported browser has them.
  • A copy of the thumbnail animated on the compositor (this PR): works everywhere, a close can start mid-opening, and it stays smooth on a busy page. The cost is 6 KB of gzip JavaScript (96,776 → 102,742 bytes), mostly modules other components share.
  • Finding the thumbnail: the caller names it (experimental.sourceFor), or else the element that opened the viewer, when it shows a picture.

Why this choice

Only a compositor copy can be reversed mid-flight. It all runs inside experimental: without it, a Lightbox keeps its stable behavior (spec:AST-017 FR21). A drag-down close reports onOpenChange(false) when the pointer lifts, as every other close does, and the landing follows the pointer that released it. INV13 in the layer-runtime record belongs to #8186 (the layer runtime's exit copy), which this stack carries: this PR doesn't add it, and the zoom uses its own lifted copy.

Owner question: experimental.sourceFor. It lets a caller name the element a photo grows out of and lands back in, such as one thumbnail in its own grid. Without it, only the element that opened the viewer can be the source, which misses when one button opens a whole gallery. The alternative is to drop the option until a spec names it. My pick: keep it inside experimental.

Owner question: the held close. After a close reports onOpenChange(false), the viewer stays until the photo lands in its thumbnail (on touch, and for a drag down under any pointer), as Photos does, so the photo returns to its place instead of vanishing mid-flight. The alternative is to remove the viewer at once and skip the landing. My pick: keep it, inside experimental.

How to check it

In the "Gallery" story, which passes experimental, on a phone:

  1. Tap a thumbnail: the photo grows out of it.
  2. Tap Close: it shrinks back into its thumbnail.
  3. Drag the photo down and let go: it lands in its thumbnail.
  4. With a mouse, the viewer opens and closes at once, as before, and a drag down lands in the thumbnail too.
  5. With Reduce Motion on, the photo fades in place.

Evidence

Safari on the iOS 26.2 simulator:

main this PR
main: at once grows from the thumbnail

Android 15 emulator, Chromium: open and close

The opening's busy proof judged a window too short to judge: it held no frames of a compositor-only control animation. It now blocks inside the page for 300 ms and holds 17 to 18 control frames in both engines.

@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:54am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 5:31 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@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 3635 -
Complexity Very High Very High (244) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 57.0KB 57.6KB +603B 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 +584B 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 +557B 9.6KB 9.6KB 0B
core/DateInput 99.7KB 100.3KB +610B 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 100.3KB +49.0KB ⚠️ 9.8KB 10.1KB +329B
core/Link 43.7KB 44.2KB +590B 7.6KB 7.6KB 0B
core/ListItem 53.7KB 54.2KB +553B 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 +602B 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 +578B 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 265.4KB N/A N/A 2.4KB N/A
lab/Comments N/A 269.8KB 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 +49.0KB ⚠️ +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.

This pull request can't be approved yet: sourceFor and the zoom transition are marked pending owner review, and the draft spec:AST-094 requirements they rely on (FR5, FR8 and the rest of FR1–FR23) have no owner decision. An engineering owner records that decision by commenting /approve-spec 70b9c9ce8afd7cab6dd08969809560382cce7d10 on this pull request, and the author can do so as an engineering owner; the decision should settle whether sourceFor ships as a stable prop or under experimental, whether a close may keep the dialog open until the photo lands, and whether the focused-opener fallback may change the default for existing callers. Independently of that decision, these changes are needed:

  • Land #8215 and its predecessors first and retarget, so this pull request carries only the zoom-transition commit instead of 34,074 changed lines.
  • Closing the viewer while the photo is still opening leaves the thumbnail hidden for good, because zoomFromSource saves the opening's hidden as the value to restore (zoomFromSource.ts:168-174).
  • Keep the viewer in the dismissal stack while its dialog is still open (Lightbox.tsx:552), and give the open/close effect a cleanup for its timer and pending close.

[Automated review]

for (const animator of fades) {
animator.play({opacity}, {reducedMotion: 'keep'});
}
const visibility = source.style.visibility;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When a close interrupts the opening, this runs while the opening still holds the source at hidden (its finally is a promise callback that has not run yet), so the close saves hidden and restores it when it lands: the thumbnail stays hidden for good, and every later opening of that item keeps it hidden. Recording the source's resting visibility once, while any transition holds it, and letting the last transition restore it would avoid this.

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, the close saved the opening's hidden as the value to restore. Fixed in 5586095: a source is hidden by holders and shows again when the last transition holding it ends; under Reduce Motion it never hides. LightboxZoomTransition covers both (both failed before).

return null;
}
const geometry = {source: sourceBox, detail, align: 'center' as const};
const close = zoomCloseProgress();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

zoomCloseProgress() here starts every close from progress 1 with no velocity, so a close during the opening snaps the backdrop, the controls and the photo to opaque before they fade. zoomCloseProgress({from, velocity}) already supports continuing from the shown progress; the driver never passes 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 close during the opening snapped everything opaque first. Fixed in 5586095: the close turns the opening around from its shown progress and velocity, on the opening's geometry. LightboxZoomTransition samples the backdrop every frame after an Escape mid-opening, and it only falls.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
);

const {shouldDismissOnCloseRequest} = useLayerDismissal({
isActive: isOpen,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With the zoom-back close, the dialog stays open after onOpenChange(false) until the photo lands, but isActive: isOpen drops the viewer from the dismissal stack at once, so an Escape in that window dismisses the layer behind the still-visible Lightbox (overlay-dismissal FR1: a member joins the stack “while present”). The hook's docs suggest passing true while the dialog is open, here with presses blocked until the photo lands.

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, the viewer left the dismissal stack while its dialog was still open. Fixed in 5586095: it stays active until the photo lands, with presses ignored meanwhile. The new InsideADialog story and LightboxZoomTransition press Escape twice, and the dialog behind stays.

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

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 effect starts a decode timer and a pending close continuation but returns no cleanup, so after an unmount mid-transition they still run against the detached dialog and finish() focuses the stored opener. A cleanup that bumps both tokens and clears the timer would release them (react-component-runtime INV3, INV5).

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, the timer and the continuation outlived an unmount. Fixed in 5586095: an unmount cleanup bumps both tokens, clears the timer, aborts the transition in flight and lets go of a landed thumbnail. The new MountedWhileOpen story covers a caller that renders the viewer only while it is open.

next > 1
? t('@astryx.lightbox.zoomedIn')
: t('@astryx.lightbox.zoomedOut'),
triggerElementRef.current = document.activeElement;

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 opener fallback depends on this document.activeElement. An <img> without tabindex never becomes the active element, and Safari does not focus a clicked button or link, so the documented “an image, or a button or link around one” zooms only where the browser focused the control. No test covers the fallback today.

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, the fallback only worked where the browser focused the control. Fixed in 5586095: the opener is the click's target (or the button or link around it), else the focused element. LightboxZoomTransition opens the Gallery story from a plain image, and it zooms.

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

This pull request can't be approved yet: sourceFor, the close that keeps the viewer open until the photo lands, and the opener fallback are marked pending owner review, and the draft spec:AST-094 requirements they rest on (FR1–FR23, including FR4, FR11 and FR14's amendments to current records) have no owner decision. An engineering owner records that decision by commenting /approve-spec 849642377981655849e0800f4c86c0354fe82b1a on this pull request, and the author may do so as an engineering owner; it should settle whether sourceFor ships as a stable prop or under experimental, whether a close may hold the dialog open, and whether the fallback may change the default for existing callers. These changes are needed independently of that decision:

  • Land #8215 and its predecessors first and retarget, so this pull request carries only the zoom-transition commit instead of 34,495 changed lines.
  • A close during the opening leaves the thumbnail hidden for good, because the close saves the opening's hidden as the value to restore (zoomFromSource.ts:168-174).
  • Keep the viewer in the dismissal stack while its dialog is still open (Lightbox.tsx:552), and give the open/close effect a cleanup for its timer and pending close.
  • The opener fallback reads document.activeElement (Lightbox.tsx:801), so it never sees a plain image and misses a clicked button in Safari; take the opener from the activating event or narrow the docs.

[Automated review]

cixzhang pushed a commit that referenced this pull request Oct 24, 2026
## Maintainer impact

When a pull request that adds a pr-a11y contract step merges, pr-a11y breaks on every open pull request that branched before it. #8130 added "Run picker wheel pinch-zoom contract", and #8231's next run failed that step with `Error: No tests found.` Each open pull request hits it on its next push until it rebases, and again on the next such merge.

## Intended invariant

A pr-a11y contract runs whenever its spec file exists in the checked-out PR head. Main's steps name only existing spec files. pr-a11y keeps checking out the exact PR head.

## Change

Why: a pull request's run takes its workflow from the test merge with main, so it has main's newest steps, but pr-a11y checks out `pull_request.head.sha` (pinned by the provenance test). A contract step runs `pnpm exec playwright test <spec> …`, and a spec added on main after the branch point is not in that checkout. `pr-scope-touches.sh` is loaded from the base for the same reason.

- `.github/scripts/skip-absent-specs.sh` defines a bash `pnpm` function. For `pnpm exec playwright test`, it drops spec files absent from the checkout and emits a `::notice` naming each one ("…is not in this pull request's head; rebase onto main to run it"). A step left with no spec passes without running Playwright. Every other pnpm command runs unchanged, and Playwright's exit status still decides the step.
- A new pr-a11y step, "Skip contract specs the PR head predates", loads the script from `github.sha`, the test merge commit the steps themselves come from (an older head lacks it, and `pull_request.base.sha` can lag main), and exports it as `BASH_ENV`, so every later bash step gets the wrapper.
  - The ~58 contract steps are unchanged, leaving open ci.yml edits nothing to conflict with.
  - If the merge commit cannot be read, the step warns and the contract steps run as before.
- Pull requests only; a release check's exact head has every spec.

Not covered: a stale head that skips a new contract can merge without running it, as before the contract existed. A contract that main renames or moves after the branch point is skipped the same way.

## Evidence

- Failure before the change: #8231's pr-a11y run, "Run picker wheel pinch-zoom contract": `Error: No tests found.`, exit 1.
- Success after the change: the same step script, run on main just before #8130 (20bde84) with the wrapper as `BASH_ENV`, prints one notice and "nothing to run", and exits 0.
- Product/runtime behavior verified unchanged: CI-only. With every spec present, the command reaches pnpm unchanged (unit test).

## Scope

- [x] No intended public API, product behavior, visual direction, or policy change.
- [x] Product changes discovered during the work were removed or split.
- [x] Public text and artifacts contain no internal Meta context.

## Testing

- `pnpm exec vitest run --project node .github/scripts`: 69 files, 1220 tests passed. New or changed:
  - `skip-absent-specs.test.mjs` (new) runs step scripts with `bash -e` and the wrapper in `BASH_ENV`, against a stand-in pnpm. It covers five cases:
    - every spec present: the command is unchanged;
    - one spec absent: it is dropped with a notice;
    - every spec absent: the step passes without Playwright;
    - Playwright fails: the step still fails;
    - other pnpm commands: untouched.
  - `ci-test-routing.test.mjs`:
    - every spec a pr-a11y step names exists, so main cannot point at a missing file, and a pull request that deletes a spec its steps still name fails here;
    - the wrapper step comes before every Playwright step, loads from `github.sha` (not the PR base) and sets `BASH_ENV`;
    - Playwright steps use bash and do not bypass the function.
  - `release-check-workflow.test.mjs`: the wrapper step runs on pull requests, not on release checks.
- Failure injection: a typo'd spec path, the wrapper step moved after the contract steps, and a wrapper that keeps absent files each fail the matching test.
- prettier, shellcheck, `bash -n`, `check-executable-bits`.

_Maintainer note: two commits on top (c3a9cb4, cff15ae) fix the review's findings: the heading-links upload no longer expects evidence from a skipped spec, and the wrapper loads from the merge commit._
@vjeux
vjeux force-pushed the uikit/c24-zoom-transition branch from 8496423 to 5288d6f Compare October 24, 2026 19:41

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

This pull request can't be approved yet: sourceFor, the close that keeps the viewer open until the photo lands, and the opener fallback are marked pending owner review, and the draft spec:AST-094 requirements they rest on (FR1–FR23, including FR4, FR11 and FR14's changes to current records) have no owner decision. An engineering owner records that decision by commenting /approve-spec 5288d6f6f0751e32091d42329804c36b46a85749 on this pull request, and the author may do so as an engineering owner; it should settle whether sourceFor ships as a stable prop or under experimental, whether a close may hold the dialog open, whether the fallback may change the default for existing callers, and whether FR4 or the stack's theme motion roles stand. These changes are needed independently of that decision:

  • Land #8215 and its predecessors first and retarget, so this pull request carries only the zoom-transition commit instead of 39,248 changed lines.
  • A close during the opening leaves the thumbnail hidden for good, because the close saves the opening's hidden as the value to restore (zoomFromSource.ts:168-174).
  • Keep the viewer in the dismissal stack while its dialog is still open (Lightbox.tsx:552-555), and give the open/close effect a cleanup for its timer and pending close.
  • The opener fallback reads document.activeElement (Lightbox.tsx:806), so a plain image never zooms and a button clicked in Safari does not either; take the opener from the activating event or narrow the docs.
  • The zoomMedia.transition springs listed as theme roles are solved straight from the constants or never played (zoomTransition.ts:179, :188-189), so a theme override changes nothing beyond the open spring's Reduce Motion fade; resolve them through resolveMotion or keep them out of the role table.

[Automated review]

velocity?: number;
}): {frame: MotionCurve; fade: MotionCurve} {
return {
frame: zoomProgress(zoomMedia.transition.closeSpring, 0, options),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

closeSpring and closeFadeSpring (and openSpring above, outside its Reduce Motion cross-fade) are solved here straight from the constants and reach Animator.play as finished curves, which it plays without resolving any role against the theme. So the zoomMedia.transition.* roles this commit adds to astryx docs motion have no effect on the transition's travel, and dragFollowSpring / dragTrackingSpring have no caller at all. Resolving the springs through resolveMotion against the viewer before solving them, or leaving them out of the registered table, would keep the docs honest.

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, the roles were listed but never read. Fixed in 5586095: the open, close and close-fade springs are resolved against the viewer with resolveMotion before they are solved (zoomTransition.test.ts), and the two drag springs no code read left the table; their measurement is in MEASUREMENTS.md.

@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/zoomFromSource.ts:107 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.

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 commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Pushed in 5586095: zoomFromSource's own reduced-motion check and its cross-fade timing are gone; the transition takes the animator's default.

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

This pull request can't be approved yet. Four choices have no governing spec on main: experimental.sourceFor, the touch close that keeps the viewer open after onOpenChange(false) until the photo lands, the opener fallback that changes the touch default for callers who omit experimental, and the new architecture:layer-runtime INV13. An engineering owner can provide or identify the governing spec, record approval by commenting /approve-spec followed by the full head SHA (the author may do this as an engineering owner), or say which of the four should change. These changes are needed whatever the decision:

  • Make the gesture host attach for every pointer, as spec:AST-094 FR19 and FR43 and architecture:react-component-runtime INV13 require.
  • Make scripts/touch-support-bundle.test.mjs pass again: its root-entry check fails on this head and passes on main.
  • Land the stack first and rebase, so this pull request carries only the zoom transition instead of 51,984 authored lines; the unrelated spec:AST-057 rewording leaves with it.

[Automated review]

* @position Browser adapter of packages/core/src/gestures: turns real events
* into GestureArena input, and carries out what the arena decides
*
* ONLY WHERE THE PRIMARY POINTER IS COARSE

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 coarse-only attach contradicts spec:AST-094 FR19 ("Recognizers attach for every pointer, with FR43's exceptions"), FR43 and DEC-21, and architecture:react-component-runtime INV13 ("The gesture host attaches for every pointer, with no touch-only query") on main. Under a fine primary pointer the host installs nothing beyond the per-event and action-group carve-outs, so a mouse, trackpad or pen gets none of these gestures. Please attach for every pointer with FR43's exceptions (wheel and trackpad for a pan, ctrl-wheel for a pinch), or have an owner amend those claims.

opener instanceof HTMLElement &&
opener !== opener.ownerDocument.body &&
(opener instanceof HTMLImageElement ||
opener.querySelector('img') != null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This fallback gives callers who omit experimental the zoom, a hidden opener and the held close on touch whenever the opener shows a picture. spec:AST-017 FR21 says omitting experimental "preserves the component's stable defaults and behavior", so this needs an engineering owner's decision: keep it as a stable default with FR21 or AST-094 FR25 amended, or move it inside experimental.

correct, and the runtime polls nothing: an anchor that a scrolled
container hides is followed again when an intersection observer reports it
back in view.
- **INV13 — One exit copy per closing layer.** A layer's close plays on at most

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

INV13 is a new normative invariant in a current record, and the zoom transition does not use placeLayerExitCopy. It needs an engineering owner's approval here, or a spec change of its own.

Comment thread docs/specs/AST-057-row-actions/spec.md Outdated
- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity,
resistance past the panel: behavior constants in source, not public API and
not design tokens (the same ruling the touch press model received for its
clocks). The row's slide and spring motion plays on theme motion roles

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 rewording of the row swipe's motion is unrelated to Lightbox's zoom. Please move it to its own change, with the AST-094 alignment it comes from.

vjeux added 23 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.
…eta#8231

The straight-down dismissal keeps this PR's landing hand-off and takes
meta#8215's release that follows the pointer; the release callback carries
whether a finger released it. The dense docs keep this PR's experimental
line beside meta#8148's zoom wording.
…er released it (spec:AST-094 FR43)

A drag down that lands the photo in its thumbnail, and the thumbnail's
picture that fades in over it, follow the pointer that began the drag, as
the straight-down dismissal does: under reduced motion a finger's release
cross-fades (DR2) and a mouse's, a trackpad's or a pen's lands at once on
a fine primary pointer (DR3). The touch presentation's readers are named
where it is read.

Tests: the zoom transition's proof that a mouse's drag down under Reduce
Motion lands in the thumbnail at once.

Red: under Reduce Motion a mouse's drag down into the thumbnail cross-faded for 300 ms before the viewer closed; green after.
…nothing calls

The zoom transition plays on the stage's rect morph, so the stage's own
play (and firstValues, which only it used) and the zoom transition's
element curves (zoomElementCurves, with mapCurve) have no caller left.
They go with their test and mock entry, and the comments that named them
say what they mean without them.
…erimental keeps the stable viewer (spec:AST-017 FR21)

Without experimental.sourceFor, the element that opened the viewer was
the opened item's source whenever it showed a picture, so callers who
omit experimental got the zoom transition, the hidden opener and the
held close on a touch screen. That fallback now lives inside
experimental: without the object no item has a source and the viewer
opens and closes plainly, and the opener record holds the shared press
controller only then. The prop and component docs, the zoom transition
changeset (now experimental) and the opener record's header say so; the
zoom transition's plain-image proof and the listener test pass
experimental where they rely on the fallback, or prove the stable
opening without it.

Red: without experimental a plain image opener still zoomed the photo out of it on a touch screen; 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.
…nto its thumbnail when it has one

Since this PR a dismissed photo shrinks into its thumbnail when the
stage's target gives one, but the header still said there was no
thumbnail to return to yet. Comment 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.

This pull request can't be approved yet: experimental.sourceFor with its opener fallback, the close that keeps the viewer open after onOpenChange(false) until the photo lands (on touch, and for a drag-down under any pointer), and the new architecture:layer-runtime INV13 have no governing spec on main. An engineering owner can provide or identify that spec, approve these choices by commenting /approve-spec followed by the full head SHA (the author may do so as an engineering owner), or say which of them should change. These changes are needed whatever that decision is:

  • Make scripts/touch-support-bundle.test.mjs pass again; its root-entry case fails on this head and passes on the base branch.
  • Land the stack this branch carries and rebase, so the pull request holds only the zoom transition, and bring that change under 2,000 lines or explain why it can't be split.
  • Correct the changeset and Lightbox.doc.mjs, which say a mouse closes the viewer at once, though a mouse drag-down lands in the thumbnail.

[Automated review]

Comment thread .changeset/lightbox-zoom-transition.md Outdated

Closing works the same way: the Close button, Escape, a tap beside the photo, or a swipe to another photo and then a close each send the photo back into the thumbnail of the photo showing, and the viewer stays open until it lands. A photo dragged down shrinks into its thumbnail. Under Reduce Motion the photo cross-fades in place.

With a mouse the viewer opens and closes at once, as before.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With experimental.sourceFor, a mouse drag-down flies the photo into its thumbnail while the dialog stays open (about 1.4 s in Chromium), so not every mouse close is instant. Could this say which closes are instant with a mouse (Close, Escape, a backdrop click) and that a drag-down lands in the thumbnail?

// link around one); a text button opens and closes plainly. Both are
// experimental: without `experimental` no item has a source, so the
// viewer keeps its stable opening and closing (spec:AST-017 FR21).
const isExperimental = experimental != null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any experimental object, even {}, turns on the opener fallback here, and no option names it. That belongs to the specification decision spec:AST-017 FR21 asks for an experimental Core API.

correct, and the runtime polls nothing: an anchor that a scrolled
container hides is followed again when an intersection observer reports it
back in view.
- **INV13 — One exit copy per closing layer.** A layer's close plays on at most

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

INV13 adds a normative rule to a current record that main does not state, and Lightbox's zoom never calls placeLayerExitCopy. It needs an engineering owner's /approve-spec on this head, or it can move with the exit-copy work to that work's own pull request.

// Blocked inside the page 140 ms into the opening, once the photo is
// opaque, and for 300 ms: a block started from the test lands later in the
// opening, by a varying amount, and 150 ms left too little to judge.
const block = await blockMainThread(page, 300, {

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's verification row for FR5 asks for an animation that keeps advancing through a 1 s main-thread block; this proof blocks for 300 ms.

… thumbnail

The changeset and the experimental option's docs said a mouse opens and
closes the viewer at once. It does, except that a photo dragged down lands
in its thumbnail under any pointer since meta#8231's every-pointer landing; the
English and Chinese docs and the changeset now say so.
@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews. The new head, a140e96, answers them:

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

This pull request can't be approved yet: experimental.sourceFor with its opener fallback, the close that keeps the viewer open after onOpenChange(false) until the photo lands (on touch, and for a drag-down under any pointer), and the new architecture:layer-runtime INV13 have no governing spec on main. An engineering owner can provide or identify that spec, record a decision on these choices by commenting /approve-spec followed by the full head SHA (the author may do so as an engineering owner), or say which of them should change. Whatever that decision is, these changes are needed:

  • Make scripts/touch-support-bundle.test.mjs pass again; its root-entry case fails on this head and passes on main.
  • Land the stack this branch carries and rebase, so the pull request holds only the zoom transition, and bring that change under 2,000 lines or explain why it can't be split.

Thanks for bringing the release note and docs in line with the mouse drag-down.

[Automated review]

correct, and the runtime polls nothing: an anchor that a scrolled
container hides is followed again when an intersection observer reports it
back in view.
- **INV13 — One exit copy per closing layer.** A layer's close plays on at most

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

INV13 adds a normative rule to a current record that main does not state (INV14 only names "the one shared exit copy"), and the zoom transition never calls placeLayerExitCopy. It needs an engineering owner's /approve-spec on this head, or it can land with the exit-copy work it belongs to.

*/

import {useMemo, useRef, useState, type RefObject} from 'react';
import {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These static gesture imports are part of how the gesture modules reach Core's root entry: the "is absent from the root entry" case of scripts/touch-support-bundle.test.mjs fails on this head (16 modules, 67,201 minified bytes) and passes on main. Please keep them out of the root entry's graph, or change that case to the boundary INV13 states with an engineering owner's agreement.

if (sourceFor != null) {
return sourceFor(i);
}
if (!isExperimental) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any experimental object, even {}, turns the opener fallback on here, and no option names it. That is part of the specification decision spec:AST-017 FR21 asks for an experimental Core API.

This branch was successfully deployed

1 active deployment
Preview — a140e96b 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