Repository navigation
spec(Carousel): opt-in snap paging through useSnapPaging - #9666
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
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-094FR43, 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-107FR4 still nameshasSnapas Carousel's path and keeps everything else in the gesture and motion modules internal, so it needs amending forsnap,useSnapPagingandcreateSnapPaging.- FR6 requires
scrollend,IntersectionObserverandinline mandatory, which shipped Carousel does not use (it readsuseScrollOverflowand setsx mandatory), so please state the observable rule instead and drop "from an effect" from FR8. review_triggerslistsapiandbundle, which are not supported triggers and are dropped by the routing tools, so usepublic-api.- FR7 is marked
proposedinside a current record; once OQ1 is ruled, mark itsettledand 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 | |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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.
PR Analysis Report🔎 Preview ·
|
|
/approve-spec 1bd9215 |
There was a problem hiding this comment.
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
scrollendandIntersectionObserver(withinline 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'shasSnapstays conforming and no second position tracker is needed. review_triggerslistsapiandbundle, which the routing tools reject or drop, so please usepublic-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 | |
There was a problem hiding this comment.
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.
… equivalent by default (INV22)
There was a problem hiding this comment.
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-094onmainnow 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
scrollendandIntersectionObserver(withinline 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'shasSnapstays conforming and no second position tracker is needed. review_triggerslistsapiandbundle, which the routing tools drop, so please usepublic-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 | |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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.
|
/approve-spec b407c4a |
…bservable rule; review fixes
|
/approve-spec d72de97 |
There was a problem hiding this comment.
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
SnapPagingvalue 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 | |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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.
|
/approve-spec 77fb438 |
There was a problem hiding this comment.
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 frommain, 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
SnapPagingvalue from an effect" pins internals that FR9's observable rule already covers.
[Automated review]
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-094FR3, FR6 and DEC-25 (the pager's paging rule);component:LightboxFR7 (gallery paging);spec:AST-057FR1 and FR10 (theswipe={createSwipe()}config prop);spec:AST-107FR6 andarchitecture:public-component-apiINV22 (hook pluscreate…factory);architecture:react-component-runtimeINV13 (no lazyimport()). Open pull requests #8146 (internal pager) and #8153 (Carousel adopting it underhasSnap) 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 frombooleantoboolean | SnapPaging(FR5). An additive patch: no rename, deprecation, or codemod.hasSnapomitted orfalse: 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).useSnapPaginghas 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
useSnapPagingmodule imports the pager, tree-shaken by module boundary with no lazyimport(), 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@supportsextras (FR6).hasSnap's third state deviates from the on/off rule foris…/has…booleans inarchitecture:public-component-apiINV3. 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
hasSnapprop, the rule deviation, the page size, and theuseSnap<Variant>/createSnap<Variant>naming family.Lightbox's gallery shares the pager's rule but not the config: it drives its own full-width row.
createSnapPagingis dropped perarchitecture:public-component-apiINV22: a slot hook whose value feeds onlyhasSnaphas no non-React equivalent by default.Review fixes: FR7 pages on a trackpad swipe under every pointer, matching
spec:AST-094FR43 (a mouse wheel andhasSnap={true}keep native snap; snap paging is an implementation gap until built). FR6 states the observable rule, not a position mechanism, so shippedhasSnapstays conforming.review_triggersusespublic-api. Thespec:AST-107FR6 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 (hasSnapasboolean | SnapPaging,SnapPaging,SnapPagingOptionswithpages), 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 givenhasSnap={useSnapPaging()}.architecture:public-component-api: a known-deviation entry for CarouselhasSnapunder INV3.Rejected alternatives
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
useSnapPagingand add view paging as the default.Writing rubric results
Scope
Validation
pnpm install --frozen-lockfilepnpm check:knowledgegit diff --check