Skip to content

feat(Lightbox): a gallery's photos share one caption row - #9810

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

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

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

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

Stacked on #8197 (and #8148, #8147 below it), at its dc3a360 on main f10bc91. Review this pull request's one commit: vjeux/astryx@dc3a360...5ec3393

At 5ec3393 on box 6: pnpm install --frozen-lockfile 0; core tsc 0; strict ESLint on every changed file 0; CI=true pnpm lint 0 errors; the Lightbox, motion, gestures and hooks unit files 2,652/2,652; pnpm check:knowledge and pnpm check:changesets pass; a root build and a Storybook build of this head, then Chromium Lightbox a11y specs 24/24, Chromium Lightbox interaction specs 69/69 (3 WebKit-only skips), WebKit 65/65 (7 Chromium-only skips).

Since 86d0dd1, rebased with the stack onto main f10bc91. #8197 now judges a mouse's or a pen's click by main's media group, so this pull request's band rule moves there: for a gallery page with a caption row, the media is the photo's rows, as wide as main's group, and the caption's own box, so the band between them still closes. While zoomed, the photo's shown box counts as media too, as #8148 rules. A finger's tap keeps this pull request's rule. The band unit lays its page out through the shared test helper.

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

Since 20b897a, two fixes the review asked for whatever the design decision:

  • A click or a tap in the band between a photo and the caption row closes the viewer, as on main. With the shared row the caption no longer touches its photo. So a point counts as the media only inside the caption's own box, or in the photo's rows across the width of both. A click beside a photo narrower than its caption, within the caption's width, still leaves the viewer open.
  • The caption row reserves height for a caption React renders. A 0 caption now keeps its row, and a true one no longer reserves an empty box.
  • Red first on a NOT-FOR-LANDING arm with the previous head's Lightbox.tsx (231cc80): the units "closes on a mouse click in the band between a photo and its caption row" and "reserves the caption row for a 0 caption beyond the neighbours, and none for true". At this head, the Lightbox and usePager unit files pass 254/254; Chromium Lightbox a11y specs 17/17; Chromium Lightbox interaction specs 67/67 (3 WebKit-only skips); WebKit 63/63 (7 Chromium-only skips).

Held as draft for the design owner's decision on the shared caption row (card 46). Split out of #8197 at the spec owners' request; #8197 keeps each page's caption under its own photo, as main does.

User impact

In a Lightbox gallery, items with captions of different lengths showed their photos in different boxes, so a neighbour with a longer caption slid in with a smaller photo. With this change every page shares two rows, the photo's and the caption's, and the caption row is as tall as the gallery's tallest caption. Every photo centres on the same box, the next photo slides in level with the current one, and paging never resizes it, under every pointer.

Visual authority

  • Existing component/module DD row reused or amended.
  • Durable component-local intent added to the optional Design decisions table by a team design owner.
  • Correction already settled by current family, design, theme, or objective accessibility authority.
  • New shared or cross-component direction settled in docs/design/.

None yet: the design owner decides on card 46. The open point is how the row keeps its height: today the current page lays out, unseen, the captions no rendered page shows (one hidden copy of each), which architecture:component-style-authoring INV7 rules out ("Content renders once"). The other option on the card keeps each caption with its photo, as #8197 does.

Before and after

"Gallery with captions" on a phone-sized Chromium, first and third photos at rest (the first has a three-line caption, the third none).

after: the photos share one box; the caption row is as tall as the longest caption

before (#8197, each page's caption under its own photo): the first photo is smaller, its caption right under it; the third photo is larger

State matrix

Scope

  • Appearance is the primary intent.
  • Behavior, API, interaction model, and unrelated layout changes were removed or split.
  • The evidence shows the changed pixels actively running, not only source or computed styles.
  • Public text and artifacts contain no internal Meta context.

Testing

At f53416e on #8197's b7c9690 (main 2188aa9):

  • core tsc is clean, and so is strict ESLint on every changed file; check:changesets passes;
  • the Lightbox unit files pass 123/123;
  • a root build and a Storybook build of this tree, then: Chromium Lightbox a11y specs 17/17; Chromium Lightbox interaction specs 63/63 (2 WebKit-only skips); WebKit Lightbox interaction specs 58/58 (7 Chromium-only skips), including the two same-box cases and "with a mouse a click on the caption row under a photo with none closes the gallery".

@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 4, 2026 1:17pm UTC

Request Review

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

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 4, 2026, 1:40 PM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 3, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

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


Generated by PR Enrichment workflow | View current CI run

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This pull request needs an owner decision before it can be approved, and it has independent fixes to make either way.

Owner decision: no Lightbox requirement yet covers gallery pages sharing one caption row, and this change pursues that direction while the design decision is open. The head also adds new requirements to the Lightbox record: AR9, the controls' backing and contrast with zoom on; and the zoom-settle formula with its motion roles. Approval needs a spec-gate owner to settle these: either approve the shared caption row and how it reserves its height (which today conflicts with the style-authoring rule that content renders once), or keep each caption under its own photo. An engineering owner records the decision by commenting /approve-spec <full head SHA>, and the author may do so as an engineering owner.

Blocking findings:

  • The caption row renders hidden copies of the unmounted pages' captions to reserve its height, which the component style-authoring architecture's INV7 ("Content renders once") rules out unless an owner amends it.
  • Against main the diff is 8,108 authored lines, because the three stacked pull requests ride along with this 250-line change, so land them first or retarget so this pull request carries only its own commit.
  • In a gallery with hasZoom, a click or tap on the reserved caption band under a photo without a caption no longer closes the viewer, while main and a gallery without zoom close it (useZoomStage.ts, the stage's tap only counts points inside the stage).
  • .changeset/lightbox-gallery-paging.md still says each caption sits "right under its photo", but it now sits at the bottom of the stage, 252 px below a landscape photo on a phone.
  • The Lightbox record's FR5 requires the page settle to play named motion roles, and the head records it as playing the pager's own values instead, so register those values as roles or record an owner exception.

[Automated review]

Comment on lines +492 to +498
const onStage =
point.x >= 0 &&
point.x <= geometry.width &&
point.y >= 0 &&
point.y <= geometry.height;
if (onStage && !onPhoto) {
optionsRef.current.onTapOutside?.();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In a gallery with hasZoom, the caption row reserved for the tallest caption lies outside the stage, so a tap or click on that dark band under a captionless photo matches neither branch and the viewer stays open. main closes on it, and so does a gallery without zoom (Lightbox.tsx:885-887). Close for any point that is neither on the photo nor on a [data-lightbox-caption], as the no-zoom recognizer does.

Comment thread .changeset/lightbox-gallery-paging.md Outdated

[feat] A Lightbox gallery now pages the way the iOS Photos viewer does. On a touch screen, drag or flick between items: the row follows your finger, and the release lands on the next or previous item, one at most. A gap of 40 px separates the items while they move. A zoomed photo pans with your finger until it reaches its edge; a new drag from that edge moves to the next item. A photo you leave zoomed returns to its fitted size once the gallery rests. Under a finger, the previous and next buttons and the arrow keys slide to the item instead of swapping it in place.

Each item's caption moves with its item, right under its photo. `onIndexChange` also reports a swipe.

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 shared caption row the caption no longer sits right under its photo. It sits at the bottom of the stage: 252 px below a landscape photo on a 390 × 844 viewport. Suggested: "Each item's caption moves with its item, in a caption row the gallery's photos share."

Comment on lines +1047 to +1051
const unrenderedCaptions = isGallery
? mediaArray.flatMap((item, i) =>
item.caption && !pageIndexes.includes(i) ? [i] : [],
)
: [];

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 captions render again, hidden, only to reserve the row's height. The style-authoring architecture's INV7 rules this out ("Content renders once: no hidden twin to measure or reserve space"). It needs an owner decision or a different way to reserve the height.

@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 needs an owner decision before it can be approved, and it has independent fixes to make either way.

Owner decision: no Lightbox requirement covers gallery pages sharing one caption row, and the head also adds new requirements to the Lightbox record: AR9 (the controls' backing and contrast with zoom on) and the zoom-settle formula with its motion roles. A spec-gate owner needs to settle these: approve the shared caption row and how it reserves its height, which today conflicts with the style-authoring rule that content renders once, or keep each caption under its own photo. An engineering owner records the decision by commenting /approve-spec <full head SHA>, and the author may do so as an engineering owner.

Blocking findings:

  • The caption row renders hidden copies of the unmounted pages' captions to reserve its height (Lightbox.tsx, the caption sizer), which the component style-authoring architecture's INV7 ("Content renders once") rules out unless an owner amends it.
  • Desktop behavior change needs an owner decision: a mouse drag or a trackpad's sideways scroll now pages every gallery, with default props (Lightbox.tsx, the pager's allowedPointerTypes), so gate it to touch input or cite the decision that covers it.
  • Against main the diff is 8,175 authored lines, because the three pull requests below ride along with this 294-line change, and their bodies carry a different stack line, so land them first or make the stack declaration identical in all four.
  • The Lightbox record's FR5 requires the page settle to play named motion roles, and the head records it as playing the pager's own values instead, so register those values as roles or record an owner exception.

[Automated review]

</div>
)}
<div
aria-hidden

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These captions render a second time, hidden and inert, only to reserve the caption row's height. The component style-authoring architecture's INV7 rules this out ("Content renders once: no hidden twin to measure or reserve space"), and the open dialog grows from 27–28 to 37–43 elements in a four-item captioned gallery. Either an owner amends INV7, or the row reserves its height without copying content, or each caption stays with its photo.

// Every pointer pages, a mouse's drag and a trackpad's scroll included.
// A drag that starts on a video's controls stays the video's.
panOptions: {
allowedPointerTypes: ['touch', 'pen', 'mouse'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Desktop behavior change needs an owner decision: with 'mouse' here and no touch gate, a mouse drag or a trackpad's sideways scroll pages every gallery, with default props (a drag from x 900 to 300 at 1280 × 800 moves from 1 / 4 to 2 / 4; main stays on 1 / 4). Gate this paging to touch input, or cite the decision that covers 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.

The owner decision is the current record: component:Lightbox FR11 on main says "A mouse, a pen, and a trackpad also use FR7's drag paging…". This paging comes from #8197, where the engineering owner stamped it.

thumbnail transition.
- **Touch presentation (FR8–FR10, FR12).** On a touch screen there is no drag
or pinch to close, tap to hide the controls, or thumbnail transition.
- **Paging motion roles (FR5).** The page settle and the buttons' slide play

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR5 requires the page settle to be "a named motion role ... read through the motion role table, never a literal". This gap records the shipped page settle and button slide as playing the pager's own values instead. Register them as roles, as zoomMedia.ts does for the zoom settle, or record an owner exception.

@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 needs a spec owner's decision before it can be approved, and it has independent fixes to make either way.

Spec decision: no Lightbox requirement covers gallery pages sharing one caption row, and the head adds new requirements to the Lightbox record: AR9 (the controls' backing and contrast with zoom on), the zoom-settle formula, and its motion roles. A spec-gate owner needs to approve the shared caption row and how it reserves its height, which today conflicts with the style-authoring rule that content renders once, or keep each caption under its own photo, and approve or reject the new record text. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner.

Blocking findings:

  • The caption row renders hidden, inert copies of the unrendered pages' captions to reserve its height (Lightbox.tsx, the caption sizer), which the component style-authoring architecture's INV7 ("Content renders once") rules out unless an owner amends it.
  • Desktop behavior change needs an owner decision: with default props a mouse drag or a trackpad's sideways scroll now pages every gallery (Lightbox.tsx, the pager's allowedPointerTypes), and with zoom a ctrl-wheel zooms the photo and a released mouse drag coasts (useZoomStage.ts), so gate these to touch input or cite the decision that covers them.
  • Against main the diff is 8,172 authored lines, because the three pull requests below ride along with this 294-line change and the stack line is not identical in every listed pull request, so land them first or make the stack declaration hold.
  • The Lightbox record's FR5 requires the page settle to play named motion roles, and the head records it as playing the pager's own values instead, so register those values as roles or record an owner exception.

[Automated review]

</div>
)}
<div
aria-hidden

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These captions render a second time, hidden and inert, only to reserve the caption row's height. The component style-authoring architecture's INV7 rules this out ("Content renders once: no hidden twin to measure or reserve space"), and the open dialog grows from 27–28 to 37–40 elements in a captioned gallery. The copies also move to the new current page on every page change, so a caption's own content mounts twice and remounts each time. Either an owner amends INV7, or the row reserves its height without copying content.

// Every pointer pages, a mouse's drag and a trackpad's scroll included.
// A drag that starts on a video's controls stays the video's.
panOptions: {
allowedPointerTypes: ['touch', 'pen', 'mouse'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Desktop behavior change needs an owner decision: with 'mouse' here and no touch gate, a mouse drag or a trackpad's sideways scroll pages every gallery with default props (a drag from x 900 to 300 at 1280 × 800 moves from 2 / 5 to 3 / 5; main stays on 2 / 5). FR11 is current, but the record lists these fine-pointer gestures as not built yet, so this is new desktop behavior rather than a fix. Gate this paging to touch input, or cite the decision that covers it.

thumbnail transition.
- **Touch presentation (FR8–FR10, FR12).** On a touch screen there is no drag
or pinch to close, tap to hide the controls, or thumbnail transition.
- **Paging motion roles (FR5).** The page settle and the buttons' slide play

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR5 requires the page settle to be "a named motion role ... read through the motion role table, never a literal". This gap records the page settle and button slide as playing the pager's own values instead. Register them as roles named by this record, as zoomMedia.ts does for the zoom settle, or record an owner exception.

@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 needs a spec owner's decision before it can be approved, and it has independent fixes to make either way.

Spec decision: no Lightbox requirement covers gallery pages sharing one caption row, and the head adds new requirements to the Lightbox record: AR9 (the controls' backing and contrast with zoom on), the zoom-settle formula and its motion roles. A spec-gate owner needs to approve the shared caption row and how it reserves its height, which today conflicts with the style-authoring rule that content renders once, or keep each caption under its own photo, and approve or reject the new record text. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner.

Blocking findings:

  • The caption row renders hidden, inert copies of the unrendered pages' captions only to reserve its height (Lightbox.tsx, the caption sizer), which the component style-authoring architecture's INV7 ("Content renders once") rules out unless an owner amends it.
  • The Lightbox record's FR5 requires the page settle to play named motion roles, and the head records it as playing the pager's own values instead, so register those values as roles or record an owner exception.
  • Against main the diff is 8,442 authored lines, because the three pull requests below ride along with this 294-line change and #8215 does not contain #8197's head, so land the lower pull requests first or make every listed head contain the one below it.
  • Regression: the Chinese hasZoom description still says every zoomed drag pans with momentum, while a mouse drag now stops at the release, so narrow it as the English description was narrowed.

Drag paging is now limited to touch input, so a mouse and a trackpad page a gallery as they do on main.

[Automated review]

type: 'boolean',
description: '启用双击缩放(仅图片)。缩放后可拖动平移。',
description:
'启用缩放(仅图片):双指捏合(或按住 Ctrl 滚动滚轮),或双点按、双击在该处放大并还原;Enter/Space/+/- 键以中心放大 2 倍并还原。缩放后可拖动或在触控板上滚动平移(带惯性),或用方向键平移。',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Regression: this still says every zoomed drag pans with momentum (带惯性), but at this head a mouse drag pans 1:1 and stops at the release; only a finger in the touch presentation and a trackpad's scroll coast, as the English description at line 101 now says. Suggested: 缩放后可拖动或在触控板上滚动平移,或用方向键平移;在手机或平板上以及使用触控板滚动时,平移带惯性。

@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 needs a spec owner's decision before it can be approved, and it has independent fixes to make either way.

Spec decision: no Lightbox requirement covers gallery pages sharing one caption row, and the head adds new requirements to the Lightbox record: AR9 (the controls' backing and contrast with zoom on), the zoom-settle formula and its motion roles, beside unrecorded video and index behaviours. A spec-gate owner needs to approve the shared caption row and how it reserves its height, which today conflicts with the style-authoring rule that content renders once, or keep each caption under its own photo, and approve or reject the new record text. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner.

Blocking findings:

  • In a captioned gallery without zoom, a tap or a mouse click on the dark band between a photo and its caption, which now sits in the bottom row, leaves the viewer open, while main and a zoomable gallery close on it, because the hit test in Lightbox.tsx treats one box spanning photo and caption as the media.
  • The caption row renders hidden, inert copies of the unrendered pages' captions only to reserve its height, which the component style-authoring architecture's INV7 ("Content renders once") rules out unless an owner amends it.
  • The Lightbox record's FR5 requires the page settle to play named motion roles, and the head records it as playing the pager's own values instead, so register those values as roles or record an owner exception.
  • Against main the diff is 8,622 authored lines, because the three pull requests below ride along and #8215 does not contain #8197's head, so land the lower pull requests first or declare a stack line that every listed head satisfies.
  • Miss: the caption sizer picks captions with a truthiness test (item.caption && …) instead of isRenderable, so a 0 caption reserves no height and a true caption reserves an empty box.

The Chinese hasZoom description now matches the English one.

[Automated review]

// and its caption, as one box. The photo takes no pointer events, so a
// press on it lands on its box. An element with no size yet (a photo still
// loading) adds nothing to the box.
const isOnCurrentMedia = (x: number, y: number) => {

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 shared caption row the caption no longer touches its photo, so this one box spanning photo and caption takes in the dark band between them. In a captioned gallery without zoom, a tap or a mouse click there now leaves the viewer open (274 px of band on a 390 × 844 phone), while main and the hasZoom path close on it. Counting a point as media only inside the photo's rows or the caption's own box would restore that.

Comment thread packages/core/src/Lightbox/Lightbox.tsx Outdated
// the photo row.
const unrenderedCaptions = isGallery
? mediaArray.flatMap((item, i) =>
item.caption && !pageIndexes.includes(i) ? [i] : [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Miss: this presence test uses truthiness rather than isRenderable, so a 0 caption beyond the neighbours reserves no height and a true caption reserves an empty box. Suggested: isRenderable(item.caption) && !pageIndexes.includes(i).

</div>
)}
<div
aria-hidden

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These captions render again, hidden and inert, only to reserve the caption row's height. The component style-authoring architecture's INV7 rules this out ("Content renders once: no hidden twin to measure or reserve space"), so it needs an owner decision or a different way to reserve the height.

thumbnail transition.
- **Touch presentation (FR8–FR10, FR12).** On a touch screen there is no drag
or pinch to close, tap to hide the controls, or thumbnail transition.
- **Paging motion roles (FR5).** The page settle and the buttons' slide play

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR5 requires the page settle to be "a named motion role ... read through the motion role table, never a literal". This gap records the page settle and button slide as playing the pager's own values instead. Register them as roles named by this record, as zoomMedia.ts does for the zoom settle, or record an owner exception.

@vjeux

vjeux commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

The Stack: finding no longer holds: every listed head now contains the one below it (#8148 33e0568 → #8197 2b36122 → #9810 86d0dd1 and #8215 6a6505e).

@astracat-bot

astracat-bot Bot commented Nov 4, 2026

Copy link
Copy Markdown

In reply to @vjeux

You're right — that resolves the Stack: finding as stated in the latest review.

The PR body now declares #8215 and #9810 as siblings, both on #8197, rather than a linear chain running through #8215. On that branched reading, #9810 no longer needs to contain #8215, and with #8197 at 2b36122cdf as #9810's stated base at 86d0dd1ece, the containment you list holds for this PR's line.

I can't change the published verdict from here; the next full review will re-evaluate the stack finding (and the related scope point, which still turns on the lower PRs in the diff against main). The other findings stand as written.

@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 needs a spec owner's decision before it can be approved, and it has independent fixes to make either way.

Spec decision: no Lightbox requirement covers gallery pages sharing one caption row, and the head adds new requirements to the Lightbox record: AR9 (the controls' backing and contrast with zoom on), the zoom-settle formula and its motion roles, beside unrecorded video and index behaviours. A spec-gate owner needs to approve the shared caption row and how it reserves its height, or keep each caption under its own photo, and approve or reject the new record text; an engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

Blocking findings:

  • Miss: in a captioned gallery, a tap or a mouse click in the empty strip under a shorter caption leaves the viewer open while main closes on the same point, because both caption elements stretch to the shared row's height and the close tests in Lightbox.tsx count that whole box as the caption (alignSelf: 'start' on the captions would fix it).
  • The caption row renders hidden, inert copies of the unrendered pages' captions only to reserve its height, which the component style-authoring architecture's INV7 ("Content renders once") rules out unless an owner amends it.
  • The Lightbox record's FR5 requires the page settle to play named motion roles, and the head records it as playing the pager's own values instead, so register those values as roles or record an owner exception.
  • Against main the diff is 8,702 authored lines, because the three pull requests below ride along and #8215 does not contain #8197's head, so land the lower pull requests first or declare a stack line that every listed head satisfies.

A press in the band between a photo and its caption row now closes the viewer, and the row reserves height for a 0 caption.

[Automated review]

justifyContent: 'center',
overflow: 'hidden',
},
pageCaption: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Miss: neither caption in the shared row sets alignSelf, so each stretches to the row's height (the gallery's tallest caption). The close tests (isOnCurrentMedia and onTapOutside) then count the empty strip under a shorter caption as the caption: on a 390 × 844 phone a tap 41 px under "Golden hour" leaves the viewer open, where main closes. Suggested: alignSelf: 'start' here and on the stacked caption, plus a test that taps under a short caption.

</div>
)}
<div
aria-hidden

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These captions render again, hidden and inert, only to reserve the caption row's height. The component style-authoring architecture's INV7 rules this out ("Content renders once: no hidden twin to measure or reserve space"), and the open dialog grows from 28 to 37 elements. It needs an owner amendment of INV7 or a different way to reserve the height.

thumbnail transition.
- **Touch presentation (FR8–FR10, FR12).** On a touch screen there is no drag
or pinch to close, tap to hide the controls, or thumbnail transition.
- **Paging motion roles (FR5).** The page settle and the buttons' slide play

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR5 requires the page settle to be "a named motion role ... read through the motion role table, never a literal". This gap records the page settle and button slide as playing the pager's own values instead. Register them as roles named by this record, as zoomMedia.ts does for the zoom settle, or record an owner exception.

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

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

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

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

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

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

The pan and the pinch read where their touches began from the shared
recognizers, and without zoom a tap beside a gallery's photo closes
the viewer through a shared tap recognizer.
Each page of a Lightbox gallery laid out its own photo and caption, so
a neighbour with a longer caption arrived with a smaller photo. The
pages now share two rows, the photo's and the caption's, and the
caption row is as tall as the gallery's tallest caption: the current
page lays out, unseen, the captions no rendered page shows. So every
photo centres on the same box, the next photo slides in level with the
current one, and paging never resizes it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This pull request needs a spec owner's decision before it can be approved, and it has independent fixes to make either way.

Spec decision: no Lightbox requirement covers gallery pages sharing one caption row, and the head changes the Lightbox record: FR5 now has a released pinch settle on a timed curve, which the touch-presentation spec's FR45 rules out until the curve arm proposed in #9915 lands, and AR9 (the controls' backing with zoom on) and the zoom motion roles are new, beside unrecorded video and index behaviours. A spec-gate owner needs to approve the shared caption row and how it reserves its height, or keep each caption under its own photo, and approve or reject the new record text; an engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

Blocking findings:

  • In a captioned gallery, a tap or a mouse click in the empty strip under a shorter caption leaves the viewer open while main closes on the same point, because both caption elements stretch to the shared row's height and the close tests in Lightbox.tsx count that whole box as the caption (alignSelf: 'start' on the captions would fix it).
  • The caption row renders hidden, inert copies of the unrendered pages' captions only to reserve its height, which the component style-authoring architecture's INV7 ("Content renders once") rules out unless an owner amends it.
  • The Lightbox record's FR5 requires the page settle to play named motion roles, and the head records it as playing the pager's own values instead, so register those values as roles or record an owner exception.
  • Against main the diff is 9,519 authored lines because the three pull requests below ride along and #8215 does not contain #8197's rebased head, so land the lower pull requests first or rebase #8215 so the stack line holds.

[Automated review]

justifyContent: 'center',
overflow: 'hidden',
},
pageCaption: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Neither this caption nor the stacked one in the caption row sets alignSelf, so each stretches to the shared row's height. The close tests (isClickOutsideMedia, isOnCurrentMedia, onTapOutside) then count the empty strip under a shorter caption as the caption: on a 390 × 844 phone a tap 33 px under "Golden hour" leaves the viewer open, and so does a mouse click 13 px under it at 1280 × 1100, where main closes. Suggested: alignSelf: 'start' on both captions, plus a test that taps under a short caption.

// unseen, in the caption row. So the row stays as tall as the gallery's
// tallest caption whichever pages are rendered, and paging never resizes
// the photo row.
const unrenderedCaptions = isGallery

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 captions render again, hidden and inert, only to reserve the caption row's height. The component style-authoring architecture's INV7 rules this out ("Content renders once: no hidden twin to measure or reserve space"), and the open dialog grows from 28 to 37 elements. It needs an owner amendment or a different way to reserve the height.

thumbnail transition.
- **Touch presentation (FR8–FR10, FR12).** On a touch screen there is no drag
or pinch to close, tap to hide the controls, or thumbnail transition.
- **Paging motion roles (FR5).** The page settle plays the pager's own UIKit

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR5 requires the page settle to be "a named motion role read through the motion role table, never a literal". This gap records the page settle as playing the pager's own values instead. Register them as roles named by this record, as zoomMedia.ts does for the zoom settle, or record an owner exception.

@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 needs a spec owner's decision before it can be approved, and it has fixes to make either way.

Spec decision: no Lightbox requirement covers gallery pages sharing one caption row, and reserving that row with hidden copies of the other pages' captions conflicts with the style-authoring rule that content renders once. The head also changes the Lightbox record: FR5 now has a pinch's settle ease back on a timed curve, which the touch-presentation spec's FR45 rules out until a measured-curve arm like the one proposed in #9915 lands, and AR9 (the controls' backing with zoom on) and the zoom motion roles are new, beside unrecorded video and index behaviours. A spec-gate owner needs to approve the shared caption row and how it reserves its height, or keep each caption under its own photo, and approve or reject the new record text; an engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

Blocking findings:

  • In a captioned gallery, with or without zoom, a tap or a mouse click in the empty strip under a shorter caption leaves the viewer open while main closes on the same point, because both caption elements stretch to the shared row's height and the close tests in Lightbox.tsx count that whole box as the caption (alignSelf: 'start' on both captions, plus a test that presses under a short caption, would fix it).
  • Against main the diff is 9,743 authored lines because #8147, #8148 and #8197 ride along with this 406-line change, and the stack exemption does not apply while #8215 has not been rebased onto #8197's current head.

Smaller notes: the record's pager-motion gap line could also name the Previous/Next slide under a finger, and the useZoomStage doc comment could keep only its constraints.

[Automated review]

justifyContent: 'center',
overflow: 'hidden',
},
pageCaption: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Neither this caption nor the stacked one in the caption row sets alignSelf, so each stretches to the shared row's height. The close tests (isClickOutsideMedia, isOnCurrentMedia, onTapOutside) then count the empty strip under a shorter caption as the caption: with default props, a tap 33 px under "Golden hour" on a 390 × 844 phone and a mouse click 13 px under it at 1280 × 1100 leave the viewer open, where main closes. Suggested: alignSelf: 'start' on both captions, plus a test that presses under a short caption.

// unseen, in the caption row. So the row stays as tall as the gallery's
// tallest caption whichever pages are rendered, and paging never resizes
// the photo row.
const unrenderedCaptions = isGallery

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 captions render again, hidden and inert, only to reserve the caption row's height, and the open dialog grows from 28 to 37 elements. The component style-authoring architecture's INV7 rules this out ("Content renders once: no hidden twin to measure or reserve space"), as the description notes; it waits on the owner's decision to amend INV7, reserve the height another way, or keep each caption with its photo.

thumbnail transition.
- **Touch presentation (FR8–FR10, FR12).** On a touch screen there is no drag
or pinch to close, tap to hide the controls, or thumbnail transition.
- **Paging motion roles (FR5).** The page settle plays the pager's own UIKit

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: under a finger, Previous, Next and the arrow keys also slide on the pager's 300 ms sinusoidal scroll rather than the theme's tokens FR5 asks of tap-started motion. The touch-presentation spec's FR4 already records that pager gap, so naming it here too ("and the Previous/Next slide") keeps this list complete.

pan.hysteresis = px;
}

/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): this 43-line comment narrates the recognizer wiring the code states. Keep the constraints: "The pan begins only when the photo can move that way. A finger's tap waits for its double tap to fail. A mouse's or a pen's double-click is the browser's dblclick."

This branch was successfully deployed

1 active deployment
Preview — 5ec33930 Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant