Skip to content

feat(Lightbox): a tap hides the controls, like Photos - #8242

Draft
vjeux wants to merge 2320 commits into
meta:mainfrom
vjeux:uikit/c24-chrome-toggle
Draft

vjeux wants to merge 2320 commits into
meta:mainfrom
vjeux:uikit/c24-chrome-toggle

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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

Why

In Photos, a tap on the photo hides the buttons and caption and turns the background black, so the photo has the screen to itself; the next tap brings them back. On a phone, Lightbox's controls always cover part of the photo.

What led to it

The UIKit-parity work for Astryx (spec:AST-094), after zoom, paging, dismissal and the zoom transition (#8148 to #8231).

Options and tradeoffs

  • Hide the controls with a re-render: simple, but the viewer re-renders on every toggle and the fade cannot be interrupted smoothly.
  • Fade the controls' layer and the black on the compositor (this PR): no re-render, and a tap mid-fade reverses it. The black is the container's own background layer.
  • The black's colour: the theme's --color-on-light (rgb 17, 17, 17); owners may prefer a dedicated token.

Why this choice

It matches Photos and keeps the photo still. A double tap still zooms, because the tap waits out the double-tap interval, and a keyboard focus on a control brings the controls back. The cost is 1.3 KB of gzip JavaScript (102,742 → 104,082 bytes) and 0.1 KB of CSS.

How to check it

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

  1. Tap the photo: the controls fade and the background turns black. Tap again: they return.
  2. Zoom in: the controls hide; zoom back out: they return.
  3. With the controls hidden, press Tab: they return.
  4. With the controls hidden, tap beside the photo: they return, and the viewer stays.
  5. With a mouse, click the photo: the controls hide and return as with a tap; a double-click zooms in and hides them.

Evidence

Safari on the iOS 26.2 simulator:

main this PR
main: nothing changes controls fade, background turns black

Android 15 emulator, Chromium: a tap and the next

The fade's busy proof judged one blocked fade, and Photos' 0.2 s fade left a window too short to judge in WebKit: 4 to 6 frames of a compositor-only control animation, 3 on a busy machine. It now blocks inside six fades and judges them pooled: 47 control frames, 7 to 8 a run.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 28, 2026 7:15am UTC

Request Review

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

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 31, 2026, 9:41 PM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

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

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 101.6KB +50.3KB ⚠️ 9.8KB 10.2KB +463B
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 266.7KB N/A N/A 2.4KB N/A
lab/Comments N/A 271.4KB 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 +50.3KB ⚠️ +463B

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 +197B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

57 of 504 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 37 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold until an engineering owner decides the direction: the tap-to-hide behavior and the other Lightbox touch features in this stack rest on the new draft spec:AST-094 (FR1–FR23, DEC-1 and DEC-2, including FR4's narrowing of the theme-tokens architecture) and on the draft Lightbox record, and any engineering owner, including you, can record that decision by commenting /approve-spec <full head SHA> on this pull request. Independently of that decision, please:

  • land #8231 and the pull requests beneath it, then rebase onto main, so this diff is its own 941-line commit rather than 34,941 authored lines (the rebase also fixes pr-a11y, which cannot find a spec file that main added);
  • restore the controls whenever the current item is not a zoom target, because in a gallery of photos and videos the controls hidden on a photo stay invisible and untappable on the video page, and a tap beside the video closes the viewer;
  • attach the dialog's input listeners through a callback ref, because a Lightbox whose media arrive after it mounts never hides its controls;
  • paint the black layer with a background colour role instead of --color-on-light, which the theme-tokens architecture defines as a content colour.

[Automated review]

// changes), so mirror them in the polite live region, including a hint that
// arrow keys pan while zoomed. A tap on the dark area around the photo
// closes, as a click on the backdrop does.
const isZoomTarget = hasZoom && !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.

Controls hidden on a photo stay hidden when the gallery pages to a video: isZoomTarget turns false here, the stage that reports taps is disabled (line 820), and nothing clears data-chrome-hidden, so Close and the arrows stay invisible with pointer-events: none while a tap beside the video closes the viewer. Restoring the controls (restChrome() or setChrome({hidden: false, immersive: false})) whenever the current item is not a zoom target would fix it, with a mixed photo and video case in LightboxChrome.interaction.browser.spec.ts.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
dialog.removeEventListener('keydown', onKeyDown, true);
dialog.removeEventListener('focusin', onFocusIn);
};
}, [setChrome]);

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 reads dialogRef.current once and its only dependency, setChrome, is stable, so when Lightbox first renders without a current item (it returns null) the listeners never attach and a tap never hides the controls; with media set 300 ms after mount, every control stayed at opacity 1 after a tap on the photo. architecture:react-component-runtime INV6 asks the node owner to handle late attachment and replacement; a callback ref on the <dialog> would.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
position: 'absolute',
inset: 0,
zIndex: -1,
backgroundColor: colorVars['--color-on-light'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--color-on-light is a content colour (architecture:theme-tokens INV10, shape 4: "content placed on a fill"), so this background follows any theme that retints content on light fills; a background role, or one the token owners admit for this surface, fits better.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold until an engineering owner decides the direction: the tap-to-hide behavior and the other Lightbox touch features in this stack rest on the draft spec:AST-094 (FR1–FR23, DEC-1 and DEC-2, including FR4's narrowing of the theme-tokens architecture) and on the draft Lightbox record, and any engineering owner, including you, can record that decision by commenting /approve-spec <full head SHA> on this pull request, or say which part is approved. Independently of that decision, please:

  • land #8231 and the pull requests beneath it, then rebase, so this diff is its own 941-line commit rather than 35,362 authored lines;
  • restore the controls whenever the current item is not a zoom target, and keep the backdrop click from closing behind hidden controls, because in a gallery of photos and videos the controls hidden on a photo stay invisible and untappable on the video page and the next tap there closes the viewer;
  • attach the dialog's input listeners through a callback ref, because a Lightbox whose media arrive after it mounts never hides its controls;
  • paint the black layer with a background colour role instead of --color-on-light, which the theme-tokens architecture defines as a content colour.

[Automated review]

@@ -469,9 +624,24 @@ export function Lightbox({
}
if (e.target === e.currentTarget || e.target === containerRef.current) {

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 close ignores chromeHiddenRef, so it runs behind hidden controls wherever the stage does not take the tap: on a gallery's video page (the stage is off there) and, on a touchscreen whose primary pointer is fine, beside a single photo whose stage only hugs it (the stretch styles at lines 193-211 apply under pointer: coarse). Showing hidden controls here instead of closing, as onTapOutside does, would cover both.

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.

Fixed in 550d872 with the same change: with the controls hidden, the dialog's click shows them instead of closing.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold until an engineering owner decides the direction: the tap-to-hide behavior and the other Lightbox touch features in this stack rest on the draft spec:AST-094 (FR1–FR23, DEC-1 and DEC-2, including FR4's narrowing of the theme-tokens architecture) and on the draft Lightbox record, and any engineering owner, including you, can record that decision by commenting /approve-spec <full head SHA> on this pull request, or say which part is approved. Independently of that decision, please:

  • land #8231 and the pull requests beneath it, then rebase, so this diff is its own 1,096-line commit rather than 35,517 authored lines;
  • restore the controls whenever the current item is not a zoom target, and make the backdrop click show hidden controls instead of closing, because in a gallery of photos and videos the controls hidden on a photo stay invisible on the video page and the next tap there closes the viewer;
  • attach the dialog's input listeners and the zoom stage's resize observer through callback refs, because a Lightbox whose media arrive after it mounts never hides its controls and a zoomed photo two or more pages into a gallery does not re-fit when the phone rotates;
  • paint the black layer with a background colour role instead of --color-on-light, which the theme-tokens architecture defines as a content colour.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
@vjeux
vjeux force-pushed the uikit/c24-chrome-toggle branch from 1e440dc to b1448e5 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.

Approval is on hold until an engineering owner decides the direction: the tap-to-hide behavior and the other Lightbox touch features rest on the draft spec:AST-094 (FR1–FR23, DEC-1 and DEC-2) and the draft Lightbox record, and the theme motion roles and the new compact-touch query for presentation="adaptive" have no current spec; any engineering owner, including you, can record that decision by commenting /approve-spec <full head SHA> on this pull request, or say which part is approved. Independently of that decision, please:

  • land #8231 and the pull requests beneath it, then rebase, so this diff is its own 1,102-line commit rather than 40,147 authored lines;
  • restore the controls whenever the current item is not a zoom target, and make the backdrop click show hidden controls instead of closing, because in a gallery of photos and videos the controls hidden on a photo stay invisible on the video page and the next tap there closes the viewer;
  • attach the dialog's input listeners and the zoom stage's resize observer through callback refs, because a Lightbox whose media arrive after it mounts never hides its controls and a zoomed photo two pages from the opened one does not re-fit when the phone rotates;
  • reconcile the theme motion roles with FR4 and DEC-1, which reject per-theme touch motion, fix FR7's citation of a spec:AST-090 that is not in the repository, and drop the platform adaptation condition and the module-level experimental subpaths or have spec:AST-012 FR2 and spec:AST-017 FR21 amended first;
  • paint the black layer with a background colour role instead of --color-on-light, which the theme-tokens architecture defines as a content colour.

[Automated review]

// changes), so mirror them in the polite live region, including a hint that
// arrow keys pan while zoomed. A tap on the dark area around the photo
// closes, as a click on the backdrop does.
const isZoomTarget = hasZoom && !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.

On a video isZoomTarget is false, so the stage that reports taps is off (line 824), but nothing restores controls that a photo tap hid: after a swipe to a video, Close, the arrows, the counter and the caption stay invisible, and the dialog's click handler (lines 620-646) closes on the next tap beside the video. Restore the controls whenever the current item is not a zoom target, and have the click handler show hidden controls instead of closing, as onTapOutside does.

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, controls hidden on a photo stayed hidden on a video page, and a tap there closed the viewer behind them. Fixed in 550d872: an item that is not a zoom target brings the controls back, and the dialog's click shows hidden controls instead of closing. The new GalleryWithVideo story and LightboxChrome cover it.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
Comment on lines +741 to +770
useEffect(() => {
const dialog = dialogRef.current;
if (dialog == null) {
return undefined;
}
const onPointerDown = (event: PointerEvent) => {
lastInputRef.current = event.pointerType;
};
const onKeyDown = () => {
lastInputRef.current = 'keyboard';
};
const onFocusIn = (event: FocusEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (
chromeHiddenRef.current &&
lastInputRef.current === 'keyboard' &&
target?.closest('[data-lightbox-chrome]') != null
) {
setChrome({hidden: false, immersive: immersiveRef.current});
}
};
dialog.addEventListener('pointerdown', onPointerDown, true);
dialog.addEventListener('keydown', onKeyDown, true);
dialog.addEventListener('focusin', onFocusIn);
return () => {
dialog.removeEventListener('pointerdown', onPointerDown, true);
dialog.removeEventListener('keydown', onKeyDown, true);
dialog.removeEventListener('focusin', onFocusIn);
};
}, [setChrome]);

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 reads dialogRef.current once and depends only on the stable setChrome. When Lightbox first renders with no current item it returns null, so the dialog that mounts later never gets these listeners, lastInputRef stays 'mouse', and a touch tap never hides the controls. A callback ref would attach and detach with the node (architecture:react-component-runtime INV6).

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 dialog that mounted after the first render never got the listeners. Fixed in 550d872: they attach through a callback ref on the dialog. The new MediaArrivesLater story and LightboxChrome cover it.

Comment on lines +952 to +974
useEffect(() => {
const stage = options.stageRef.current;
if (stage == null || typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(() => {
const before = geometryRef.current;
if (before == null || !zoomedRef.current) {
return;
}
const after = measure();
if (after == null) {
return;
}
animator.stop();
const next = transformForResize(before, after, targetRef.current);
write(next);
setZoomed(isZoomed(after, next));
});
observer.observe(stage);
return () => observer.disconnect();
// The stage element is stable for the dialog's lifetime.
}, [animator, measure, options.stageRef, setZoomed, write]);

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 observer watches the first stage element and never re-attaches, but a gallery renders only the current page and its neighbours and moves stageRef to the current page. Two pages on, the observed node is gone and a zoomed photo no longer re-fits when the phone rotates. Observe through a callback ref instead.

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.

Fixed in #8197's commit, which this one carries (550d872): the stage watches the track, whose size every page shares.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
position: 'absolute',
inset: 0,
zIndex: -1,
backgroundColor: colorVars['--color-on-light'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--color-on-light is a content colour (architecture:theme-tokens INV10, shape 4: "content placed on a fill"), so the viewer's background follows a content role. A background role, or one the token owners admit for this surface, fits better.

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-012 FR2 closes the when vocabulary to width, pointer, contrast and motion and says "Future environment fields require explicit additions to this closed vocabulary"; this adds platform without amending AST-012.

Comment thread packages/core/package.json Outdated
Comment on lines +64 to +73
"./motion/experimental": {
"source": "./src/motion/experimental.ts",
"types": "./dist/motion/experimental.d.ts",
"default": "./dist/motion/experimental.js"
},
"./gestures/experimental": {
"source": "./src/gestures/experimental.ts",
"types": "./dist/gestures/experimental.d.ts",
"default": "./dist/gestures/experimental.js"
},

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-017 FR21 puts experimental hooks, functions and types on "the owning component's /experimental import subpath". These module subpaths have no owning component, so under FR21 they would not be outside the stable promise the changeset describes.

Comment thread packages/core/src/theme/defineTheme.ts Outdated
* ```
*/
motion?: MotionScaleConfig;
motion?: MotionConfig;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Retuning touch springs, durations and curves per theme is what the draft AST-094 FR4 forbids ("A touch presentation MUST read its motion from the table on every theme") and DEC-1 rejects; the record or this field has to change before an owner can approve both.

follows.
- **FR7 — Exits animate as UIKit's do.** A touch presentation MUST play the
exit its UIKit counterpart plays. **Proposed; pending owner review:** this
departs from the draft `spec:AST-090` FR1 (exits off by default) and FR10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No spec:AST-090 exists at this head or on main, so this departure, and the matching current-state impact line, points at a record no owner can rule on.

@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:711 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 force-pushed the uikit/c24-chrome-toggle branch from b1448e5 to 550d872 Compare October 25, 2026 05:48
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Pushed in 550d872: the controls' fades take the animator's default.

vjeux added 3 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
vjeux added 23 commits October 27, 2026 20:47
…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.
…sed it; the opener's zoom inside experimental) into meta#8242

Only the landing into the thumbnail follows the releasing pointer: the
zoom out of the thumbnail on open and back into it on close stays
touch-only, and without experimental the opener is no source.

The stage's ref for early callbacks stays beside meta#8148's zoom wording.
Under a cancelled drag the controls that return on their own fade keep
this PR's release, and the rest follow meta#8215's pointer; the docs keep
this PR's tap sentences after meta#8148's zoom wording, and the mouse and pen
unit tests keep this PR's controls proof in meta#8148's form.
… pooled, against a compositor-only control

Photos' fade lasts 0.2 s, so one blocked window held only 4 to 6 frames of
the interaction harness's control animation in WebKit, and 3 on a loaded
machine: too few to judge. The controls now hide six times, blocked inside
each fade and shown again between, and pooledBusyProof judges the Close
button's spot over the runs pooled (each run's stall against that run's
control): 7 to 8 control frames a run in WebKit, 47 pooled.
…er's zoom hides them (spec:AST-094 FR43)

The stage's tap toggles the controls for every pointer, so a mouse's or a
pen's click on the photo hides them once a double-click can no longer
follow, the next shows them, and with them hidden a click beside the
photo brings them back. A gesture's zoom hides them for every pointer, a
double-click's, a ctrl-wheel's and a pinch's alike; the keyboard's zoom
leaves them. A tap recognized after a page change belongs to the photo
it landed on and toggles nothing on the next one. Under a cancelled drag
the returning controls and the black follow the pointer that released
it. The paging layout effect reads getAnimations only where Web
Animations exist, as the motion system does.

Tests: the chrome proofs (a mouse click hides and shows the controls and
a double-click zooms in and hides them; a click followed at once by a
page change leaves the next photo its controls), the gallery's
click-beside proof, the fine-pointer double-click proof and the unit test
of a mouse's and a pen's zoom flip to FR43's. Docs in English and
Chinese; the every-pointer changeset gains the pointer's zoom hiding the
controls, and the chrome changeset no longer says a mouse click leaves
them.

Red: under a mouse a click on the photo and a double-click's zoom left the controls, and a click beside the photo closed the gallery; green after (and without the tap guard a click followed at once by a page change hid the next photo's controls).
…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.
…into its thumbnail) into meta#8242

The zoom stage's header keeps this PR's single tap after meta#8148's
pointers, and the geometry's header keeps this PR's chrome fade beside
meta#8148's released zoom.
…pinch too (spec:AST-094 FR43)

A ctrl-wheel pinches as fingers do, and this PR's controls hide as
either passes the fitted size, but the callback's doc still spoke of
fingers still down. 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.

Approval is on hold until an engineering owner decides the direction: component:Lightbox is still draft and spec:AST-094 leaves each component's touch presentation to its own record. So the tap that hides the controls, the gestures that now reach mouse and trackpad users (spec:AST-017 FR59), experimental.sourceFor (FR21) and the new architecture:layer-runtime INV13 all need that decision. Any engineering owner, including you, can record it by commenting /approve-spec <full head SHA> on this pull request, or say which part is approved. Independently of that decision, please:

  • land #8231 and the pull requests beneath it, then rebase, so this diff is its own change rather than 59,670 authored lines, and move the exit copy with INV13, the press and menu fixes and the build fix to their own pull requests;
  • fix the failing root-entry check in scripts/touch-support-bundle.test.mjs, which now finds the gesture modules in Core's root entry;
  • play the chrome fade, the black's turn and the dismiss and transition springs through motion roles (spec:AST-094 FR4);
  • size the gallery's caption row without hidden caption elements, and report element counts before and after (architecture:component-style-authoring INV7);
  • add fine-pointer before-and-after evidence for each mouse-visible change, and real-device iOS Safari and Android Chrome recordings.

[Automated review]

* The chrome's opacity as it hides or shows, from where it is: Photos'
* 0.2 s easeInEaseOut fade.
*/
export function chromeFadeCurve(from: number, isHidden: boolean): MotionCurve {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

chromeFadeCurve and chromeBackgroundCurve play zoomMedia.chrome's 0.2 s and 0.1 s literals directly, so a theme's tempo never reaches them. spec:AST-094 FR4 asks every spring, duration and curve a touch presentation plays to be a motion role named for its use, with a component's own roles joining through the generated motion role table (DEC-5). The dismiss and transition springs in zoomMedia.ts need the same.

Comment on lines +1947 to +1964
<div
aria-hidden
inert
{...stylex.props(
styles.captionStacked,
styles.captionSizer,
)}>
{unrenderedCaptions.map(j => (
<div
key={j}
{...stylex.props(
styles.caption,
styles.captionStacked,
)}>
{mediaArray[j].caption}
</div>
))}
</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 sizer mounts a hidden, inert element for every caption no rendered page shows, only to reserve the caption row's height: 97 extra elements on an interior page of a 100-photo captioned gallery. architecture:component-style-authoring INV7 rules out a hidden twin to reserve space and asks for measured element counts before and after any added element. Size the row on the element that shows the caption instead.

Comment on lines +75 to +80
import {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer';
import {PanGestureRecognizer} from '../gestures/PanGestureRecognizer';
import {PinchGestureRecognizer} from '../gestures/PinchGestureRecognizer';
import {TapGestureRecognizer} from '../gestures/TapGestureRecognizer';
import type {Point} from '../gestures/types';
import {useGestureRecognizer} from '../gestures/useGestureRecognizer';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Through these imports, and usePager.ts's, 16 gesture modules now reach Core's root entry (index.ts re-exports Lightbox). CI's scripts/touch-support-bundle.test.mjs root-entry check, which pins architecture:react-component-runtime INV13, fails as a result. Either keep the gesture code out of the root entry's bundle, or update that test to the bundle INV13 allows once a root-exported component imports the modules.

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 on a current record, so an engineering owner needs to approve it. Its only caller is createLiftFromTrigger, which no component uses yet. It would review more cleanly in its own pull request, landing with its first adopter.

This branch was successfully deployed

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