Skip to content

spec(Carousel): opt-in snap paging through useSnapPaging - #9666

Merged
cixzhang merged 8 commits into
mainfrom
spec/carousel-snap-paging
Nov 5, 2026
Merged

cixzhang merged 8 commits into
mainfrom
spec/carousel-snap-paging

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Exact decision needed

How a Carousel opts into touch paging, what a plain snapping Carousel ships, and what one page is on a carousel that shows several items. All three are decided: one prop, hasSnap={useSnapPaging()}, and a page is one view.

Existing authority searched

component:Carousel; spec:AST-094 FR3, FR6 and DEC-25 (the pager's paging rule); component:Lightbox FR7 (gallery paging); spec:AST-057 FR1 and FR10 (the swipe={createSwipe()} config prop); spec:AST-107 FR6 and architecture:public-component-api INV22 (hook plus create… factory); architecture:react-component-runtime INV13 (no lazy import()). Open pull requests #8146 (internal pager) and #8153 (Carousel adopting it under hasSnap) are the overlapping work: this record makes that paging opt-in. No current claim decides the opt-in, its name, or the page size.

Decision

  • One prop, hasSnap, whose type widens from boolean to boolean | SnapPaging (FR5). An additive patch: no rename, deprecation, or codemod.

    • hasSnap omitted or false: no snapping.
    • hasSnap / hasSnap={true}: native CSS scroll snap, exactly as released, with no pager code (FR6).
    • hasSnap={useSnapPaging(options?)}: snap paging on the same per-item stops (FR7). useSnapPaging has no non-React equivalent by default (FR9).
  • A flick moves one view by default, settling on the item start edge nearest one viewport on; the last page rests where the row ends. {pages: 'item'} moves one item per flick. Under right-to-left, pages run from the inline start. The item option's feel is not yet measured against UIKit's group paging (evidence gap in FR7).

  • Only the useSnapPaging module imports the pager, tree-shaken by module boundary with no lazy import(), so a Carousel without the paging config bundles none of the pager's roughly 40 KB (FR8).

  • Native snap does not depend on Chromium-only carousel CSS (::scroll-button(), ::scroll-marker, scroll-state queries, snap events); those may only be behavior-neutral @supports extras (FR6).

  • hasSnap's third state deviates from the on/off rule for is… / has… booleans in architecture:public-component-api INV3. The owner accepted it on 2026-11-02 to keep one snap prop; it is listed with that record's known deviations.

  • DEC-2 records the owner's decisions: the hook and its name, the single hasSnap prop, the rule deviation, the page size, and the useSnap<Variant> / createSnap<Variant> naming family.

  • Lightbox's gallery shares the pager's rule but not the config: it drives its own full-width row.

  • createSnapPaging is dropped per architecture:public-component-api INV22: a slot hook whose value feeds only hasSnap has no non-React equivalent by default.

  • Review fixes: FR7 pages on a trackpad swipe under every pointer, matching spec:AST-094 FR43 (a mouse wheel and hasSnap={true} keep native snap; snap paging is an implementation gap until built). FR6 states the observable rule, not a position mechanism, so shipped hasSnap stays conforming. review_triggers uses public-api. The spec:AST-107 FR6 citation and the unmeasured size figure are gone. DEC-2 and the INV3 entry are in present tense, and the INV3 entry names the INV2 union rule and the per-pointer value.

Changed records

  • component:Carousel: at-a-glance, compatibility, ownership, public concepts (hasSnap as boolean | SnapPaging, SnapPaging, SnapPagingOptions with pages), FR5–FR9, representative states, verification map (finger and iPad-style trackpad, default and {pages: 'item'}, preview stories, bundle test), DEC-2, open questions (none).
  • spec:AST-094: FR3's paging paragraph and DEC-25 name a Carousel given hasSnap={useSnapPaging()}.
  • architecture:public-component-api: a known-deviation entry for Carousel hasSnap under INV3.

Rejected alternatives

  • One hook with a variant option: every variant's code would ship with any import.
  • A separate paging prop beside hasSnap: two props would describe one choice.

Claim scope and non-goals

Snapping and snap paging on Carousel only. Looping, mouse-drag paging, autoplay, dots and slide semantics stay uncontracted.

Evidence and implementation relationship

Implementation follows in #8146 and #8153, which will move paging behind useSnapPaging and add view paging as the default.

Writing rubric results

  • R1 narration: none; the DEC dates the owner's decisions and states them in the present.
  • R2 research in the record: browser-support findings reduced to the decision's reasons in DEC-2; no tables or sources in the record.
  • R3 evidence naming a change: none.
  • R4 rejected alternatives over one line or outside a decision: none in the record.
  • R5 pull request or issue references remaining: none in the records.
  • R6 ruled open questions still listing options: none; the prop and page-size questions are answered, folded into FR5, FR7 and DEC-2, and removed.

Scope

  • This PR contains specification records only.
  • It records observable behavior and preserves equivalent internal implementations.
  • Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract.
  • Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
  • It records one intentional durable decision, not a rescue for a separable tagalong.
  • The canonical knowledge owner, affected current claims, and overlapping open work are named; any ownership collision is resolved or called out for human decision.
  • Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
  • Public text and artifacts contain no internal Meta context.

Validation

  • pnpm install --frozen-lockfile
  • pnpm check:knowledge
  • Prettier
  • git diff --check

@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 5, 2026 2:55pm 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
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 2:58 PM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This spec needs an engineering owner's decision before it can be approved, and some record fixes are needed either way.

Approval waits on an engineering owner approving the new Carousel snap contract (FR5–FR10, AR3, DEC-2, DEP-0018, CLN-0029) and the spec:AST-094 FR3 and DEC-25 edits, ruling OQ1 (is a snap-paging page one item or one view?), and deciding whether snap: boolean | SnapPaging stands as an exception to the <thing>Behavior rule in architecture:public-component-api INV2/INV3. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

Blocking:

  • Carousel FR7 turns snap paging off for a fine-pointer trackpad, but spec:AST-094 FR43, which this pull request leaves unchanged, says gestures attach for every pointer and a pager follows a trackpad swipe, so FR7 or FR43 needs to change.
  • spec:AST-107 FR4 still names hasSnap as Carousel's path and keeps everything else in the gesture and motion modules internal, so it needs amending for snap, useSnapPaging and createSnapPaging.
  • FR6 requires scrollend, IntersectionObserver and inline mandatory, which shipped Carousel does not use (it reads useScrollOverflow and sets x mandatory), so please state the observable rule instead and drop "from an effect" from FR8.
  • review_triggers lists api and bundle, which are not supported triggers and are dropped by the routing tools, so use public-api.
  • FR7 is marked proposed inside a current record; once OQ1 is ruled, mark it settled and fold OQ1 into DEC-2.

Smaller: DEC-2 narrates the decision ("On 2026-11-02 the owner decided…") and carries survey sentences and an unsourced "about 40 KB" figure, which the writing rubric moves out of the record.

[Automated review]

| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `snap` takes `boolean \| SnapPaging`. Absent or `false`: the scroll area does not snap. `true` (written `snap`): native snap, FR6. A `SnapPaging` value: snap paging, FR7. `SnapPaging` is opaque and made only by `useSnapPaging`. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: `scroll-snap-type: inline mandatory` on the scroll area, `scroll-snap-align: start` on each direct child, and `scroll-padding` matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The edge buttons scroll with `scrollBy`, and the current position comes from `scrollend` and `IntersectionObserver`. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |
| FR7 | Snap paging uses native snap's stops (FR6): each direct child's start edge, measured from the inline start and mirrored in right-to-left layout, with the last stops clamped where the row ends so no empty space shows. A gap sits between stops and is never a stop. Under a finger, and under a trackpad where the primary pointer is coarse, a release picks its rest stop by `spec:AST-094` FR3's paging rule, with one stop as one page: at most one stop from the stop the drag started on. The settle starts as `spec:AST-094` FR6's paging paragraph says. Where the primary pointer is fine, a mouse or trackpad keeps native snapping. An edge button steps about one view: the viewport width less half an item, landing on the nearest stop and moving at least one stop. Open question OQ1 asks whether one page stays one item. | DEC-2; `spec:AST-094` FR3, FR6, DEC-25 | proposed |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This turns snap paging off for a fine-pointer trackpad, but spec:AST-094 FR43 (unchanged here) says "Gestures attach for every pointer, a mouse, a pen, and a finger, not only a coarse primary pointer" and "A pager follows a trackpad swipe". Either follow FR43's trackpad pager path here, or amend FR43 with this exception and its reason. The FR7 verification row ("a fine-pointer scroll that runs the pager fails") needs the same decision.

| FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `snap` takes `boolean \| SnapPaging`. Absent or `false`: the scroll area does not snap. `true` (written `snap`): native snap, FR6. A `SnapPaging` value: snap paging, FR7. `SnapPaging` is opaque and made only by `useSnapPaging`. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: `scroll-snap-type: inline mandatory` on the scroll area, `scroll-snap-align: start` on each direct child, and `scroll-padding` matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The edge buttons scroll with `scrollBy`, and the current position comes from `scrollend` and `IntersectionObserver`. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shipped Carousel reads its edges from useScrollOverflow (a scroll listener plus the shared resize observer) and sets scrollSnapType: 'x mandatory', so pinning scrollend, IntersectionObserver and inline mandatory makes today's hasSnap non-conforming, with no gap listed. Stating the observable rule instead (edge-button state follows every scroll, momentum and programmatic included) keeps the equivalent implementation valid.

approved_at: 2026-10-22
owners: [cixzhang]
review_triggers: [behavior, accessibility]
review_triggers: [behavior, accessibility, api, bundle]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

api and bundle are not supported review triggers: pnpm specs:for drops them, and the baseline routing script rejects api. The supported name is public-api, so [public-api, behavior, accessibility] routes what this change meant to route.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 77fb438

Storybook · Sandbox · built from this PR's head

Package size: no published package changed; no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 9 rows, core 4 rows; run pnpm api:report.

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 1bd9215

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This spec is owner-approved at this head; two record fixes are still needed before it merges.

Blocking:

  • Carousel FR6 requires the position to come from scrollend and IntersectionObserver (with inline mandatory), which shipped Carousel does not use, while FR5 and the at-a-glance table say native snap is unchanged and no gap is listed, so please state the observable rule (or list the gap) so today's hasSnap stays conforming and no second position tracker is needed.
  • review_triggers lists api and bundle, which the routing tools reject or drop, so please use public-api.

Smaller: the new INV3 entry names only the third state, not the INV2 union rule or the per-device paging value; DEC-2 and that entry still narrate the decision ("On 2026-11-02 the owner decided…"); and the "about 40 KB" figure has no measurement behind it.

[Automated review]

| FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `hasSnap` takes `boolean \| SnapPaging`. Omitted or `false`: the scroll area does not snap. `true` (written `hasSnap`): native snap, FR6, exactly as released, with no gesture code. A `SnapPaging` value, made only by `useSnapPaging`: snap paging, FR7; the carousel snaps on native snap's per-item stops and the pager settles on those same stops. `true` never pages. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. The third state is a known deviation from `architecture:public-component-api` INV3, which keeps a `has…` boolean to a true on/off (DEC-2). | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: `scroll-snap-type: inline mandatory` on the scroll area, `scroll-snap-align: start` on each direct child, and `scroll-padding` matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The edge buttons scroll with `scrollBy`, and the current position comes from `scrollend` and `IntersectionObserver`. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shipped Carousel sets x mandatory and reads its edges from the shared useScrollOverflow hook (a scroll listener plus the shared resize observer, also used by TabList and Table sticky columns), so pinning inline mandatory, scrollend and IntersectionObserver here makes today's hasSnap non-conforming even though FR5 calls it "exactly as released". Stating the observable rule (edge-button availability follows every scroll, momentum and programmatic included, and every resize) keeps the shared tracker valid; otherwise list the difference as an implementation gap.

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 5, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This spec needs an engineering owner's decision before it can be approved, and three record fixes are needed either way.

Approval waits on an engineering owner approving, at the current head, the Carousel snap contract (component:Carousel FR5–FR9, AR3 and DEC-2), the spec:AST-094 FR3 and DEC-25 edits and the INV3 known-deviation entry for hasSnap; the earlier /approve-spec named a head before createSnapPaging was removed, so it no longer counts. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

Blocking:

  • Regression: Carousel FR7 keeps native snapping under a fine-pointer trackpad, but spec:AST-094 on main now says Carousel's paging presentation follows a trackpad swipe under every pointer (its current-state impact list and its FR45 table row), so FR7 and its verification row, or those two AST-094 lines, need to change so the records agree.
  • Carousel FR6 requires the position to come from scrollend and IntersectionObserver (with inline mandatory), which shipped Carousel does not use, while FR5 and the at-a-glance table say native snap is unchanged and no gap is listed, so please state the observable rule (or list the gap) so today's hasSnap stays conforming and no second position tracker is needed.
  • review_triggers lists api and bundle, which the routing tools drop, so please use public-api.

Smaller: the at-a-glance "Governing rules" still cites spec:AST-107 FR6 after the factory was removed; the INV3 entry names only the third state, not the INV2 union rule or the per-pointer value; DEC-2 and that entry narrate the decision ("On 2026-11-02 the owner decided…"); and the "about 40 KB" figure has no measurement behind it.

[Automated review]

| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `hasSnap` takes `boolean \| SnapPaging`. Omitted or `false`: the scroll area does not snap. `true` (written `hasSnap`): native snap, FR6, exactly as released, with no gesture code. A `SnapPaging` value, made only by `useSnapPaging`: snap paging, FR7; the carousel snaps on native snap's per-item stops and the pager settles on those same stops. `true` never pages. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. The third state is a known deviation from `architecture:public-component-api` INV3, which keeps a `has…` boolean to a true on/off (DEC-2). | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: `scroll-snap-type: inline mandatory` on the scroll area, `scroll-snap-align: start` on each direct child, and `scroll-padding` matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The edge buttons scroll with `scrollBy`, and the current position comes from `scrollend` and `IntersectionObserver`. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |
| FR7 | Snap paging uses native snap's stops (FR6): each direct child's start edge, measured from the inline start and mirrored in right-to-left layout, with the last stops clamped where the row ends so no empty space shows. A gap sits between stops and is never a stop. Under a finger, and under a trackpad where the primary pointer is coarse, a release picks its rest page by `spec:AST-094` FR3's paging rule: at most one page from the page the drag started on. The settle starts as `spec:AST-094` FR6's paging paragraph says. With the paging config, a flick moves one view by default: the release settles on the item start edge nearest one viewport on in the flick's direction, and the last page rests where the row ends; `{pages: 'item'}` moves one item per flick instead. Under right-to-left layout, pages run from the inline start. With one item per view both give the same stops. Where the primary pointer is fine, a mouse or trackpad keeps native snapping. An edge button steps about one view: the viewport width less half an item, landing on the nearest stop and moving at least one stop. Evidence gap: the one-item-per-flick feel is not yet measured against UIKit's group paging; measure before claiming parity. | DEC-2; `spec:AST-094` FR3, FR6, DEC-25 | settled |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This keeps native snapping "where the primary pointer is fine, a mouse or trackpad", but spec:AST-094 on main now says a trackpad swipe follows FR43's pager rule "in Carousel's paging presentation, under every pointer", and its FR45 table moves Carousel's "Paging under a trackpad swipe" to "Every pointer". Either page on a trackpad swipe under every pointer here (a mouse wheel stays native) and change the FR7 verification row, or amend those two AST-094 lines with the coarse-only exception and its reason.

| FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `hasSnap` takes `boolean \| SnapPaging`. Omitted or `false`: the scroll area does not snap. `true` (written `hasSnap`): native snap, FR6, exactly as released, with no gesture code. A `SnapPaging` value, made only by `useSnapPaging`: snap paging, FR7; the carousel snaps on native snap's per-item stops and the pager settles on those same stops. `true` never pages. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. The third state is a known deviation from `architecture:public-component-api` INV3, which keeps a `has…` boolean to a true on/off (DEC-2). | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: `scroll-snap-type: inline mandatory` on the scroll area, `scroll-snap-align: start` on each direct child, and `scroll-padding` matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The edge buttons scroll with `scrollBy`, and the current position comes from `scrollend` and `IntersectionObserver`. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shipped Carousel sets x mandatory and reads its edges from the shared useScrollOverflow hook (a scroll listener plus the shared resize observer, also used by TabList and Table sticky columns), so pinning inline mandatory, scrollend and IntersectionObserver makes today's hasSnap non-conforming even though FR5 calls it "exactly as released". Stating the observable rule (edge-button availability follows every scroll, momentum and programmatic included, and every resize) keeps the shared tracker valid; otherwise list the difference as an implementation gap.

approved_at: 2026-10-22
owners: [cixzhang]
review_triggers: [behavior, accessibility]
review_triggers: [behavior, accessibility, api, bundle]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

api and bundle are not supported review triggers, and pnpm specs:for drops them. The supported name is public-api, so [behavior, accessibility, public-api] routes what this change meant to route.

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec b407c4a

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 5, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 5, 2026
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec d72de97

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 5, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet.

The Carousel snap contract is owner-approved at this head, and the earlier blocking points are fixed: Carousel FR7 now pages a trackpad swipe under every pointer as spec:AST-094 FR43 says, FR6 states the observable native-snap rule, and review_triggers uses public-api.

Next: this branch conflicts with main in docs/architecture/public-component-api.md, where main changed the INV22 lines next to the new INV3 entry; merging main lets CI run, and that new commit needs a fresh /approve-spec <full head SHA> from an engineering owner.

Optional, non-blocking:

  • FR6's "The current page reported is the page snapped into view when scrolling settles" names a report Carousel does not have; drop it, or say what it means for paging.
  • FR8's "Carousel only calls attach and detach on the SnapPaging value from an effect" pins internals of an opaque value whose observable rule FR9 already states.

[Automated review]

| FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
| FR5 | `hasSnap` takes `boolean \| SnapPaging`. Omitted or `false`: the scroll area does not snap. `true` (written `hasSnap`): native snap, FR6, exactly as released, with no gesture code. A `SnapPaging` value, made only by `useSnapPaging`: snap paging, FR7; the carousel snaps on native snap's per-item stops and the pager settles on those same stops. `true` never pages. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. The third state is a known deviation from `architecture:public-component-api` INV3, which keeps a `has…` boolean to a true on/off (DEC-2). | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: mandatory snap along the row on the scroll area, each direct child snapping at its start edge, and scroll padding matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The current page reported is the page snapped into view when scrolling settles, and edge-button availability follows every scroll, momentum and programmatic included, and every resize. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: Carousel reports no current page today (its handle has scrollNext, scrollPrev, scrollTo, canScrollNext and canScrollPrev, and each slide's name is a static "Slide N of M"), so "The current page reported is the page snapped into view when scrolling settles" governs nothing and reads as if a page tracker were needed beside the shared overflow hook. Drop the sentence, or say what it means for paging, for example "a drag starts from the item snapped into view when scrolling settles".

| FR5 | `hasSnap` takes `boolean \| SnapPaging`. Omitted or `false`: the scroll area does not snap. `true` (written `hasSnap`): native snap, FR6, exactly as released, with no gesture code. A `SnapPaging` value, made only by `useSnapPaging`: snap paging, FR7; the carousel snaps on native snap's per-item stops and the pager settles on those same stops. `true` never pages. A new `SnapPaging` value on a later render does not restart a settle or attach the gesture again. The third state is a known deviation from `architecture:public-component-api` INV3, which keeps a `has…` boolean to a true on/off (DEC-2). | DEC-2 | settled |
| FR6 | Native snap uses CSS scroll snap only: mandatory snap along the row on the scroll area, each direct child snapping at its start edge, and scroll padding matching the carousel's padding. Every item is a stop, also when several items show at once, and the platform's own momentum picks where a swipe rests. The current page reported is the page snapped into view when scrolling settles, and edge-button availability follows every scroll, momentum and programmatic included, and every resize. Native snap adds no gesture code. It does not depend on `::scroll-button()`, `::scroll-marker`, `:target-current`, scroll-state container queries, snap events, or `interactivity: inert`; one of these MAY be added under `@supports` only where it changes no behavior, name, role, or focus order. | DEC-2 | settled |
| FR7 | Snap paging uses native snap's stops (FR6): each direct child's start edge, measured from the inline start and mirrored in right-to-left layout, with the last stops clamped where the row ends so no empty space shows. A gap sits between stops and is never a stop. Under a finger, and on a trackpad swipe under every pointer (`spec:AST-094` FR43), a release picks its rest page by `spec:AST-094` FR3's paging rule: at most one page from the page the drag started on. The settle starts as `spec:AST-094` FR6's paging paragraph says. With the paging config, a flick moves one view by default: the release settles on the item start edge nearest one viewport on in the flick's direction, and the last page rests where the row ends; `{pages: 'item'}` moves one item per flick instead. Under right-to-left layout, pages run from the inline start. With one item per view both give the same stops. A mouse wheel keeps native snapping, and `hasSnap={true}` keeps native CSS snap under every pointer. An edge button steps about one view: the viewport width less half an item, landing on the nearest stop and moving at least one stop. Evidence gap: the one-item-per-flick feel is not yet measured against UIKit's group paging; measure before claiming parity. Implementation gap: snap paging is not built, so no trackpad swipe pages yet. | DEC-2; `spec:AST-094` FR3, FR6, DEC-25 | settled |
| FR8 | Carousel's module imports no pager code: not the internal pager, its paging motion, or its gesture code. The `useSnapPaging` module imports that code statically, and Carousel only calls attach and detach on the `SnapPaging` value from an effect. The pager is tree-shaken by module boundary, with no lazy `import()` (`architecture:react-component-runtime` INV13), so a Carousel without `hasSnap={useSnapPaging()}` bundles no pager code. Importing the module does no DOM work. | DEC-2; `architecture:react-component-runtime` INV13 | settled |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: SnapPaging is opaque (Public concepts), and FR9 already states the observable rule (attached through the value, every listener removed on detach). "Carousel only calls attach and detach on the SnapPaging value from an effect" pins the value's internals and the React mechanism; dropping that wording leaves an implementer free to attach from a callback ref.

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 5, 2026
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 77fb438

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 5, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not finished on this head yet.

The Carousel snap contract is owner-approved at this head after the merge of main, and the records agree with spec:AST-094 FR3, FR43 and DEC-25.

Optional, non-blocking:

  • Regression: spec:AST-094's FR45 table, which came in from main, lists "the pager and Carousel" among spring components; writing "Carousel's paging presentation" there, as the shared-pager bullet does, keeps a plain Carousel out of it.
  • FR6's "The current page reported is the page snapped into view when scrolling settles" names a report Carousel does not have; drop it, or say what it means for paging.
  • FR8's "Carousel only calls attach and detach on the SnapPaging value from an effect" pins internals that FR9's observable rule already covers.

[Automated review]

@cixzhang
cixzhang merged commit b779b26 into main Nov 5, 2026
95 of 100 checks passed
@github-actions
github-actions Bot deleted the spec/carousel-snap-paging branch November 6, 2026 06:04

This branch was successfully deployed

1 active deployment
Preview — 77fb438e Deployed Nov 5, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant