Skip to content

feat(core): Carousel's hasSnap takes useSnapPaging(), iOS paging on native snap's stops - #8153

Merged
cixzhang merged 5 commits into
meta:mainfrom
vjeux:uikit/c23-carousel-paging
Nov 6, 2026
Merged

cixzhang merged 5 commits into
meta:mainfrom
vjeux:uikit/c23-carousel-paging

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Rebuilt on main after #9666 merged; the gesture host's supporting fixes landed in #10212.

User need

A photo strip or onboarding row on a phone should page like a native paging scroll view: a flick moves one view or one item, resting on an item's start edge. Native CSS snap leaves the momentum to the platform, so a fast flick can skip several items, and a trackpad swipe glides on. hasSnap cannot express that, and Carousel cannot carry the pager without every carousel bundling it.

Problem and solution fit

  • hasSnap={useSnapPaging()} pages on native snap's own stops, under a finger and on a trackpad swipe, for every pointer. A flick moves one view, or one item with useSnapPaging({pages: 'item'}), and at most one page from where the drag started.
  • A mouse wheel and a mouse drag keep native scrolling. hasSnap alone is unchanged.
  • Only a carousel given the hook's value bundles the pager: Carousel imports no pager code and only attaches and detaches the value.
  • While a flick holds the row, an edge button, scrollNext, scrollPrev, scrollTo and Shift+wheel move it through the pager's own goTo to an offset, from where it shows; a finger catches that move as it catches a settle.

Current specification

component:Carousel FR5–FR9 (merged #9666): hasSnap takes boolean | SnapPaging; FR7's stops, paging rule and trackpad swipe under every pointer; FR8's module boundary. Per component:Carousel FR9 (merged #9666), the hook has no non-React equivalent, so this PR ships useSnapPaging only. spec:AST-094 FR3, FR6 and FR43 give the paging rule, the settle and the trackpad routing.

Public delta and caller burden

  • hasSnap?: boolean | SnapPaging (was boolean); useSnapPaging(options?: {pages?: 'view' | 'item'}): SnapPaging; the @astryxdesign/core/SnapPaging entry, also on the root.
  • Before: <Carousel hasSnap>. To page: <Carousel hasSnap={useSnapPaging()}>. No codemod: true keeps today's native snapping.
  • Docs in English, Chinese and the dense form, a useSnapPaging page and a runnable example block. Four stories (one item per view, three per view, a partial last page, right-to-left), each with a mode arg for hasSnap, useSnapPaging() and {pages: 'item'}.

Design and alternatives

During a gesture the pager moves the row's children by translate from the held scroll offset, and at rest it hands the offset back to native scrolling, so the row stays a real scroll container for the scrollbar, the keyboard and the wheel. The record rejected a separate paging prop and a non-React twin.

Evidence

Chromium and WebKit proofs under a finger and a trackpad swipe, red first where behavior changed; see Testing.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

  • Red first, each failing before its change:
    • jsdom "hands its scroll area to the hook's value under every pointer, a fine one included" and "removes every listener it added to the scroll area when Carousel drops the value or unmounts" (before, with the coarse-only attach: touch-action '', not 'pan-y pinch-zoom');
    • browser "a mouse drag leaves the row where it is: only a finger, a pen or a trackpad pages" (before, without the pan's pointer kinds: the row was taken over);
    • browser "a new page size applies from the next flick: one item, then one view" (before, with a story that swapped two hook values: 205 px, one item, where one view lands at 410.6 px).
  • New in this head: while a flick holds the row, its moves stop where the row ends. The items' translate widens the row's overflow, so the moves clamp to the travel the row was taken over with, not the live scroll width. Red first: "an edge press after a flick back near the end shows no space past the last item" fails with the code before this change, 70 px of space past the last item (Chromium ×3; in WebKit it passes before and after); so does a clamp to the live width.
  • Earlier in this round: the press move is the pager's own goTo to an offset, a settle its catch stops, in place of a copy of that move and catch. A caught-and-lifted press move rests on the pager's nearest page, as a caught flick does; the previous head rested it on the nearest stop. The unit case "goTo with an offset ends there as a settle, and a still finger that catches it settles on the nearest page" fails when the offset is ignored, and so does the browser 40 ms case.
  • Red first for the moves while held:
    • "an edge press late in a settle steps on to a stop": with the code before these moves, the press rested 9 px off a stop (5/5); with a press that does nothing, the row rests at 308 and the case fails.
    • "a 150 ms touch…stops it" and "a 40 ms touch…lets it run on": the pager's catch runs both, from 85 ms.
    • "Shift+wheel during an edge press goes to a stop": with the wheel through React's passive handler, Chromium's native scroll moved the held row by 616 px (3/3); with no preventDefault(), the same 616 px; with the wheel's direction flipped, the rest misses the stop nearest its travel. The preventDefault() evidence is Chromium's: this WebKit build does not scroll the row natively on Shift+wheel.
  • New proofs: a trackpad swipe pages under a coarse and a fine primary pointer (WebKit; Chromium's CDP wheels read as a mouse's); dropping the paging value mid-settle hands the row back; hasSnap alone keeps native snapping under a trackpad swipe; the entry exports only useSnapPaging.
  • Bundle (FR8), built from source with the size report's esbuild options rather than from the built package: Carousel alone bundles no pager code; Carousel with the hook does; a positive control requires every module the hook adds over Carousel alone to be on the pager list, so the list stays complete; a server import does no DOM work. 4 passed.
  • At fff7785: the five new cases, Chromium unpinned ×10, Chromium on a pinned pair of 2 cores ×10 and WebKit in the CI job's profile (podman --cpus=4, CI=1, LP_NUM_THREADS=4, 1 worker) ×10, 50 passed each; the whole SnapPaging spec in WebKit, 29 passed; the SnapPaging and hooks specs on the pinned pair, the pager's included, 47 passed, 7 skipped (the trackpad cases); the pager's spec in WebKit, 22 passed, 3 skipped; Carousel's a11y contracts, 13 passed; core jsdom, 19,496 passed (716 files); the full node project, 16,737 passed, 2 expected failures, 71 skipped.
  • Core tsc: 0 errors. Strict eslint on the changed files and CI=true pnpm lint pass. Prettier passes, except on Carousel.doc.mjs, which keeps main's own format. The changeset, knowledge, i18n, exports, use-client, Lab readiness and sync checks pass. The docsite generator's registry route check passes. The committed API report is main's plus this pull request's rows; a fresh regeneration differs from it only by main's own drift. Storybook builds. pnpm review:pre: 1,997 authored lines, one finding, on the hook's options layout effect, which the pre-review check is being aligned to exempt.
  • Measured settle at fff7785, against d601a4c on the same box, in Chromium under a finger, from the lift to the hand-back to native scrolling: 0.34–0.38 s at both, within 1 ms per case, for an 80 px flick in 80 ms (about 1,000 px/s) and a 200 px drag in 400 ms (about 500 px/s), at one and three items per view, with either page size. Scroll right pressed late in a flick's settle: 324–343 ms from the press to the hand-back before, 326–345 ms after, resting at 513 px (the next stop past the flick's) at both.
  • Not in this pull request: on main, one Shift+wheel notch scrolls a Carousel twice (React's wheel listener is passive, so the mapping's preventDefault() does nothing); a separate Carousel fix covers it.
  • Record: component:Carousel drops its two implementation gaps for snap paging, which this pull request builds, keeps its FR7 evidence gap and its edge-button gap, lists the snap-paging unit, browser and bundle tests in verified_by, names the bundle test's method in FR8's verification row (Core's source with the size report's esbuild options and a positive control), and FR8 states that Carousel also asks the value to move a row it holds. Prettier re-pads the requirement table's other rows (whitespace only).
  • Records checked: component:Carousel FR5–FR9; spec:AST-094 FR3, FR6, FR43; architecture:public-component-api INV22; architecture:knowledge-contracts INV28, INV29; architecture:react-component-runtime INV3, INV13.

Writing rubric results

Over the component:Carousel lines this pull request changes:

  • R1 narration: none added; the two implementation gaps it builds are removed, the remaining gaps keep present-tense wording, and FR8's added clause states what Carousel calls.
  • R2 research: none.
  • R3 evidence: verified_by names the three test files, and FR8's verification row states the bundle test's actual method; no pull request or commit is cited.
  • R4 rejected alternatives: none touched.
  • R5 change references: none.
  • R6 open questions: none touched.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 6, 2026 7:07am UTC

Request Review

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

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 6, 2026, 7:29 AM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · fdfb308

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
Carousel · Storybook 59.1 kB (+823 B) · 10.1 kB CSS (0) 592 (+99) ✓ · ✓ · ✓
charts/Chart 173.7 kB (0) · 494 B CSS (0) — — · — · —
charts/ChartTooltip 151.1 kB (0) · 423 B CSS (0) — — · — · —

Package size:

  • @astryxdesign/cli 3.39 MB packed (+515 B); no new dependencies
  • @astryxdesign/core 6.71 MB packed (+13.3 kB); no new dependencies

API changes: core +6 · ~1 · −0

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

7 API changes (advisory)
Package Where Signature Usage
+ core . · Hooks · hook useSnapPaging hook useSnapPaging: (options?: SnapPagingOptions) => SnapPaging import {useSnapPaging} from '@astryxdesign/core'; useSnapPaging(…)
+ core . · Types · interface SnapPaging interface SnapPaging import type {SnapPaging} from '@astryxdesign/core'
+ core . · Types · interface SnapPagingOptions interface SnapPagingOptions import type {SnapPagingOptions} from '@astryxdesign/core'
+ core ./SnapPaging · Also exported from an earlier entry point · SnapPaging SnapPaging (.) import {SnapPaging} from '@astryxdesign/core/SnapPaging'
+ core ./SnapPaging · Also exported from an earlier entry point · SnapPagingOptions SnapPagingOptions (.) import {SnapPagingOptions} from '@astryxdesign/core/SnapPaging'
+ core ./SnapPaging · Also exported from an earlier entry point · useSnapPaging useSnapPaging (.) import {useSnapPaging} from '@astryxdesign/core/SnapPaging'
~ core . · Components · component Carousel prop hasSnap?: boolean = false → prop hasSnap?: boolean | SnapPaging = false <Carousel hasSnap />

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@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 needs an engineering owner's decision before it can be approved. No current spec covers the new hasPaging prop, the touch pager that replaces native scrolling (draft spec:AST-094 itself says scrolling stays native) or the 0.3 s slide kept under Reduce Motion, and the new adaptive bottom-sheet threshold changes the width-and-pointer query that component:Selector DEC-1 keeps. An engineering owner records the decision, or the amendments it needs, by commenting /approve-spec <full head SHA>; the author may do so as an engineering owner. Whatever that decision is, please also:

  • land or retarget the six predecessor PRs so this diff is the 1,269-line Carousel change rather than 29,083 lines;
  • run the interaction proofs through Core's existing test lane instead of the separately routed pr-interaction job (spec:AST-030 FR11);
  • get lint passing (ESLint runs out of heap) and fix the Paging stories' text contrast that fails pr-a11y;
  • keep a single-pointer alternative to swiping when hasPaging is used with hasButtons={false}.

[Automated review]

Comment thread packages/core/src/Carousel/Carousel.tsx Outdated
* `hasSnap`, `hasEdgeFade` and `padding` do not apply.
* @default false
*/
hasPaging?: boolean;

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.

hasPaging already names PageUp/PageDown focus paging in the exported useListFocus options (useListFocus.ts:95). architecture:public-component-api INV2 says names carry the same meaning across the public surface. Keeping this name is part of the owner decision the open question asks for.

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.

Left to the owner decision the description asks for (the public prop name).

Comment thread packages/core/src/Carousel/Carousel.tsx Outdated
Comment on lines +243 to +246
pagingTouchScroller: {
overflowX: 'clip',
overflowY: 'clip',
},

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.

On a coarse pointer this clip removes native scrolling, so the custom pan is the only touch path. With hasButtons={false} the edge buttons are never rendered (line 770), which leaves no single-pointer alternative to the path-based swipe (WCAG 2.2 SC 2.5.1, the spec:AST-020 FR1 baseline). Keep the buttons whenever touch paging is active, or fall back to native per-page snapping when hasButtons is false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 226a949: with hasButtons={false} the carousel snaps natively on iOS too, so the browser's own scrolling pages it and no author gesture is the only way. Listed in the description as proposed, pending owner review. Test: "without the buttons it pages natively" (failed before).

return undefined;
}
const gap = Number.parseFloat(getComputedStyle(track).columnGap) || 0;
pager.setOptions({gap});

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.

The pager gets the gap only here, at attach. This effect's dependencies omit gap, and usePager's measure() re-reads only the width. After a gap change, place() uses the live gap while the pager keeps the old stride, so page n rests n × the difference off. Re-send pager.setOptions({gap}) when the gap changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 226a949: a gap change re-reads the track's gap into the pager, and #8146 re-places the track when the stride changes. Test: "a gap change reaches the pager: the page keeps its place" (failed before).

Comment on lines +161 to +166
useIsomorphicLayoutEffect(() => {
pager.setOptions({count: Math.max(1, count), loop: loops});
if (isEnabled) {
place();
}
}, [pager, count, loops, isEnabled]);

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 Effect names no external system (architecture:react-component-runtime INV3). When count shrinks below the current page it sets page through the pager's clamp (setOptions → goTo → onIndexChange → setPage). Derive the clamped page during render, and add the comment naming the pager it synchronizes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 226a949: the page is the pager's, read through useSyncExternalStore and clamped during render; the count Effect names the pager it synchronizes and reports nothing. Test: "a smaller count clamps the page shown, and growing back keeps the pager and the controls in step" (failed before).

Comment thread packages/core/src/hooks/usePager.ts Outdated
Comment on lines +698 to +702
useEffect(() => {
if (index != null && index !== pager.index) {
pager.goTo(index, {reason: 'controlled'});
}
}, [pager, index]);

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.

A controlled index change calls the consumer's onIndexChange from this Effect (goTo → report). INV2 of architecture:react-component-runtime says the callback fires from the handler that caused the change, never from an Effect reacting to the resulting state. Skip the echo for reason: 'controlled', or name the external system this synchronizes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #8146 (b366394): a controlled move is no longer reported back through onIndexChange.

Comment thread .github/workflows/ci.yml
# them, so a change to an instrument is proven by its negative controls
# before a component spec relies on it. Chromium only on these runners;
# see the harness README for running WebKit.
pr-interaction:

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 job routes itself (pr-scope-touches.sh with its own path list) and is not part of the required test join. spec:AST-030 FR11: "New coverage inside an existing surface MUST join those owners and MUST NOT create a separately routed lane or required context." Run these proofs under Core's existing test owner, or split the lane with an owner-approved AST-030 amendment.

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.

Not this PR's code: the pr-interaction job comes from the interaction-harness foundation below this commit, and is routed to its owners.

Comment on lines +59 to +60
export const COMPACT_TOUCH_QUERY =
'(width < 744px) and (pointer: coarse), (height < 500px) and (pointer: coarse)';

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 query becomes presentation="adaptive"'s for five components. It adds a height test to the released (max-width: 768px) and (pointer: coarse), while component:Selector DEC-1 says adaptive "keeps its width-and-pointer query". Phones in landscape now get the sheet, and 744–768 px portrait tablets get the popover. This waits on the owner decision in the open question.

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.

Not this PR's code: the adaptive query belongs to the touch-presentation change below this commit, and is routed to its owner.

Comment on lines +95 to +96
{title: 'Pack', body: 'A checklist for every trip.', color: '#5b8c5a'},
{title: 'Go', body: 'Tickets and directions offline.', color: '#c46a3a'},

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.

pr-a11y fails on the three Paging stories with a serious color-contrast violation, two nodes each. The white 14 px body text measures 3.93:1 on #5b8c5a and 3.84:1 on #c46a3a, under WCAG's 4.5:1. Darken these two fills, or use a token pair that reaches 4.5:1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 7fad86a: the Paging stories' fills are darker (white text reaches 4.5:1), and pr-a11y passes.

@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 needs an engineering owner's decision before it can be approved: no current spec covers the new hasPaging prop, the touch pager that replaces native scrolling, or the 0.3 s slide kept under Reduce Motion, and the draft spec:AST-094 it relies on is unapproved (its FR8 asks for a cross-fade where the code keeps the slide). An engineering owner records that decision, or the amendments it needs, by commenting /approve-spec <full head SHA>; the author may do so as an engineering owner. Whatever is decided, please also:

  • land or retarget the six predecessor PRs so this diff is the 1,269-line Carousel change;
  • move the adaptive bottom-sheet query change, which contradicts component:Selector DEC-1, into its own PR with an amendment to that decision;
  • run the interaction proofs under Core's existing test owner instead of the separately routed pr-interaction job (spec:AST-030 FR11);
  • get the required lint check passing (ESLint runs out of heap);
  • keep a single-pointer alternative to swiping when hasPaging is used with hasButtons={false}.

[Automated review]

Comment thread packages/core/src/hooks/usePager.ts Outdated
const token = ++settleToken;
const handle = animator.play(
{x: toTrackCurve(curve, sign())},
{reducedMotion: 'keep'},

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 every pager move under Reduce Motion, including button, key, handleRef and focus moves that no finger drives. Draft spec:AST-094 FR8 in this same PR says such a move "fades through" and that only motion following a finger keeps moving. Whichever way the owner decides, make the code and FR8 agree, for example the animator's default 'crossfade' for goTo and 'keep' only for the settle after a drag.

@vjeux vjeux Oct 24, 2026 •

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.

Left to the owner decision listed in the description (Reduce Motion keeps paging motion, as UIKit does; AST-094 FR8 follows the decision).

@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 needs an engineering owner's decision before it can be approved: no current spec covers the new hasPaging prop, the iOS pager that replaces native scrolling, the 0.3 s slide it keeps under Reduce Motion, or its theme-tunable paging motion, and the draft spec:AST-094 they rely on is unapproved (its FR4 and FR8 also disagree with the code). An engineering owner records that decision, or the changes it needs, by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Whatever is decided, please also:

  • land or retarget the seven predecessor PRs so this diff is the 1,375-line Carousel change;
  • move the adaptive bottom-sheet query, the role-keyed theme motion API with its platform condition, and the experimental subpaths into their own PRs with the amendments they need (component:Selector DEC-1, architecture:theme-authoring-contract, spec:AST-017 FR21);
  • run the interaction proofs under Core's existing test owner instead of the separately routed pr-interaction job (spec:AST-030 FR11);
  • keep a single-pointer way to page when hasPaging is combined with hasButtons={false}, and keep the pager's gap in sync with the track.

[Automated review]

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This adds a platform axis to theme adaptation conditions, compiled to @supports (-webkit-touch-callout: none) in generateThemeRules.ts. architecture:theme-authoring-contract says “An adaptation change preserves the fixed condition vocabulary”, and the record is not amended here. Please move the role-keyed motion API and this condition to their own PR with that amendment.

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.

Not this PR's code: the platform adaptation condition belongs to the theme foundation below this commit, and is routed to its owner.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

@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 needs an engineering owner's decision before it can be approved: no current spec covers the new hasPaging prop or the iOS pager that replaces native scrolling, the draft spec:AST-094 they rely on is unapproved, and the pager's 0.3 s slide and travelling settle under Reduce Motion contradict the current design:reduced-motion record (an opacity-only fade of 200 ms or less) unless that record is amended. An engineering owner records the decision, or the changes it needs, by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Whatever is decided, please also:

  • land or retarget the seven predecessor PRs so this diff is the 1,501-line Carousel change;
  • build the adaptive bottom-sheet query from the theme's compact definition (spec:AST-012 FR9: below the md point or under 500 px tall) instead of 744 px, in its own PR;
  • cap the animator's Reduce Motion cross-fade at 200 ms, and move the theme motion roles with their platform condition and the experimental subpaths into their own PRs with the amendments they need;
  • run the interaction proofs under Core's existing test owner instead of the separately routed pr-interaction job (spec:AST-030 FR11), and add component:Carousel FR2 and FR3 browser cases for hasPaging.

[Automated review]

Comment thread packages/core/src/hooks/usePager.ts Outdated
return;
}
follow(
animator.play({x: toTrackCurve(curve, sign())}, {reducedMotion: 'keep'}),

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.

Under Reduce Motion this keeps the travel of the settle and of goTo (reducedMotion: 'keep', also at line 453). Current design:reduced-motion DR2 asks for an opacity-only fade of 200 ms or less on a coarse pointer and after a touch drag's release, DR4 rules out keeping a longer animation, and its DEC-1 rejected a per-component keep. Either an engineering owner amends that record for paging (the open question asks), or the settle and the moves conform.

* Cross-Fade Transitions on (opacity CABasicAnimation 0.5 s,
* (0.42, 0, 0.58, 1)), Oct 23 2026
*/
reducedMotionCrossFadeMs: 500,

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 500 ms is the default substituted fade for every animator move under Reduce Motion (animator.ts:1420). No core component plays it yet (the pager uses 'keep'), but current design:reduced-motion DR2 caps a touch fade at 200 ms, opacity only, and DR4 rules out a theme value that lengthens it, which defineTheme.ts:262 documents. Cap it at 200 ms and refuse longer theme values, or amend the record.

export const TOUCH_QUERY = '(pointer: coarse)';

/** The viewport is compact: layout behaviours. */
export const COMPACT_QUERY = '(width < 744px), (height < 500px)';

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.

Current spec:AST-012 FR9 defines compact as a width below the effective md point (768) or a height below 500, and says the web touch presentation resolver uses the theme's one definition, with no numbers of its own; main exports it as compactDefinition. With 744 here, a 744–767 px coarse-pointer screen (an iPad mini in portrait) opens the popover where FR9 gives the sheet, and a theme that moves md is ignored.

Comment thread packages/core/src/Carousel/Carousel.tsx Outdated
// pager's swipe (WCAG 2.5.1), and the browser's own scrolling needs none.
const trackRef = useRef<HTMLDivElement>(null);
const {idiom} = useTouchPresentation();
const isTouchPaging = hasPaging && idiom === 'ios' && hasButtons;

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.

hasButtons={false} now also turns the iOS pager off. architecture:public-component-api INV3 (one prop, one concept) and INV16 (a presentation value keeps the fine-pointer input mode) bear on this coupling, and draft FR9 and DEC-2 reject native snapping on iOS; it is part of the owner decision the open question asks for.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No spec:AST-090 exists at this head or on main, so the departure recorded here, and repeated under Current-state impact, cannot be weighed. Cite the record where it exists, or drop the reference until it lands.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

@vjeux
vjeux force-pushed the uikit/c23-carousel-paging branch from 226a949 to 03cb63d Compare October 25, 2026 00:26
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 8c3747a

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

Approval waits on an engineering owner's decision, and one fix is needed.

  • Decision: useSnapPaging is a new public hook with no hook doc and no runnable example block that names it (architecture:knowledge-contracts INV28); an engineering owner needs to say whether it can ship with that gap tracked as an issue or must add them in this pull request.
  • Fix: the paging stories swap two useSnapPaging() values through the mode control (apps/storybook/stories/Carousel.stories.tsx:509-511), and Carousel keeps the first value it attached (component:Carousel FR5), so switching between paging and item in the preview keeps the old page size; drive one hook's pages from mode instead.

Otherwise the change matches component:Carousel FR5-FR9: hasSnap alone stays native and bundles no pager code, and the touch, trackpad and bundle proofs pass on this head. CI has not run on this head yet.

[Automated review]

Comment on lines +509 to +511
const view = useSnapPaging();
const item = useSnapPaging({pages: 'item'});
const hasSnap = mode === 'snap' ? true : mode === 'item' ? item : view;

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.

Carousel attaches only the first SnapPaging value it gets (component:Carousel FR5), so swapping view and item here leaves the first one attached: after switching the control from item to paging, a flick still moves one item. One hook whose pages follows mode fixes it, since the hook applies a new pages from the next flick:

const paging = useSnapPaging({pages: mode === 'item' ? 'item' : 'view'});
const hasSnap = mode === 'snap' ? true : paging;

@vjeux
vjeux force-pushed the uikit/c23-carousel-paging branch from 8c3747a to 7fef456 Compare November 5, 2026 21:21
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 5, 2026
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 7fef456

@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. It runs once the pull request leaves draft and, because the branch is in a fork, a maintainer adds the ci:run label.

The change matches component:Carousel FR5–FR9: hasSnap alone stays native and bundles no pager code, the hook has its doc and a runnable example, and the touch, trackpad and bundle proofs pass on this head.

Optional wording fixes:

  • packages/core/src/Carousel/Carousel.tsx:143: the hasSnap JSDoc says the value pages "under a finger"; add the trackpad swipe, as the docs and changeset do.
  • CarouselSnapPaging.doc.mjs:10: "each flick moves one screen" reads as one card, but the default moves one view (about two cards); say "one view".
  • component:Carousel still says snap paging is not built (its Compatibility row and FR7); those gap sentences and the new SnapPaging tests in verified_by can follow.

[Automated review]

Comment thread packages/core/src/Carousel/Carousel.tsx Outdated
/**
* Enable scroll-snap on items. Each direct child snaps to the start edge.
* Snap each item to the start edge. `true` (written `hasSnap`) uses native
* CSS scroll snap. A `useSnapPaging()` value adds iOS paging under a finger

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.

Optional: a trackpad swipe pages too, under every pointer (as Carousel.doc.mjs and the changeset say). Suggest "adds iOS paging under a finger and on a trackpad swipe".

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.

Done in 6068fcd.

name: 'useSnapPaging — Onboarding Pages',
displayName: 'useSnapPaging — Onboarding Pages',
description:
"Onboarding screens in a Carousel with hasSnap={useSnapPaging()}: under a finger or a trackpad swipe, each flick moves one screen and rests on it. A switch picks one screen or one card per flick through the hook's pages option.",

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.

Optional: the cards are the onboarding screens, so "each flick moves one screen" reads as one card, while the default moves one view (about two of the 220 px cards here). Suggest "each flick moves one view" and "one view or one card per flick".

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.

Done in 6068fcd.

@vjeux
vjeux force-pushed the uikit/c23-carousel-paging branch from 7fef456 to 6068fcd Compare November 5, 2026 22:06
@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

/approve-spec 6068fcd

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

Changes requested: snap paging matches the Carousel contract, and its unit, Chromium and WebKit tests pass on this head, but one fix is needed in how Carousel attaches it.

  • Miss: Carousel.tsx lines 408–415 attach and release the snap-paging value with their own useEffect; attach it through the shared useAttachment hook instead (for example useAttachment(scrollElRef, scroller => attachedPaging == null ? () => {} : attachSnapPaging(attachedPaging, scroller), attachedPaging)), so the pager keeps one attach lifecycle that also follows a replaced scroll element.

Optional: the origin/travel comment in SnapPaging.ts could become names such as heldOffset and heldTravel.

[Automated review]

Comment thread packages/core/src/Carousel/Carousel.tsx Outdated
Comment on lines +408 to +415
// Syncs snap paging with the scroll area: it attaches to the element.
useEffect(() => {
const scroller = scrollElRef.current;
if (attachedPaging == null || scroller == null) {
return undefined;
}
return attachSnapPaging(attachedPaging, scroller);
}, [attachedPaging]);

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.

Miss (blocking): this Effect is a second attach-and-release lifecycle for the pager. useAttachment (packages/core/src/hooks/useAttachment.ts) already owns that lifecycle for the gesture hooks, the pager and useAnimator, attaches in a layout effect, and follows a replaced node. Suggested:

useAttachment(
  scrollElRef,
  scroller =>
    attachedPaging == null
      ? () => {}
      : attachSnapPaging(attachedPaging, scroller),
  attachedPaging,
);

With this change the SnapPaging, Carousel and bundle tests still pass, and useAttachment imports no pager code, so FR8 holds.

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.

Done in 079514d.

*/
function childTrack(element: HTMLElement) {
let isMoving = false;
// The scroll offset the take-over holds, and the row's travel then.

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: names such as heldOffset and heldTravel would say what this comment says, and the comment could go.

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.

Done in 079514d.

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 079514d

@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; it runs once the pull request is marked ready for review and, because the branch is in a fork, a maintainer adds the ci:run label.

hasSnap={useSnapPaging()} matches the current Carousel contract, and the unit, Chromium and WebKit tests pass on this head, with the built package keeping the pager out of a Carousel that is not given the hook's value. Carousel now attaches the value through the shared useAttachment lifecycle, which resolves the earlier request.

Optional: scripts/snap-paging-bundle.test.mjs bundles source, while the Carousel record's FR8 verification row asks for the built package, base against head; aligning one with the other would keep them in step.

[Automated review]

@vjeux

vjeux commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

FR8's verification row now states what the bundle test does: Core's source with the size report's esbuild options, output attributed by module, and a positive control. Done in 5349630.

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 5349630

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

Changes requested: snap paging for Carousel is well scoped, and every delta matches component:Carousel FR5–FR9, but one FR7 defect needs a fix first.

  • Miss: under hasSnap={useSnapPaging()}, pressing Scroll right (or calling handleRef.scrollNext()) during the settle after a flick leaves the row resting between item edges (312–342 px against stops at 308 and 411 on the three-per-view story), because the step in Carousel.tsx:479-500 reads the held scroll offset and the still-translated children, then races the hand-back's scrollTo; hand the row back at its shown position before stepping, and add a browser case that presses the button mid-settle.

The rest checks out at this head, including the bundle boundary on the built package and the hook's doc and example. CI has not run yet because the pull request is a draft.

[Automated review]

Comment thread packages/core/src/Carousel/Carousel.tsx Outdated
Comment on lines +482 to +499
const at = Math.abs(el.scrollLeft);
const stops = snapStops(
el,
at,
Math.max(0, el.scrollWidth - el.clientWidth),
);
const to = at + direction * Math.max(amount, itemWidth);
let next = stops.reduce(
(best, stop) =>
Math.abs(stop - to) < Math.abs(best - to) ? stop : best,
stops[0],
);
if (direction === 1 && next <= at + 0.5) {
next = stops.find(stop => stop > at + 0.5) ?? next;
} else if (direction === -1 && next >= at - 0.5) {
next = [...stops].reverse().find(stop => stop < at - 0.5) ?? next;
}
el.scrollTo({left: rtlSign * next, behavior});

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.

Miss (blocking): while a flick's settle runs, the row is taken over: el.scrollLeft still holds the offset the gesture started from, and the children still carry the settle's translate, so at and snapStops(el, at, …) here describe a position nobody sees. The smooth scrollTo below then races the hand-back's instant scrollTo (SnapPaging.ts:210). Pressing Scroll right 100–250 ms after a flick on the three-per-view story leaves the row at 312–342 px, between the stops at 308 and 411 (FR7), and handleRef.scrollNext() takes the same path. Hand the row back at its shown position through the value before stepping (for example a call next to attachSnapPaging that stops the settle where it shows), then read the offset and stops, and add a browser case that presses the button mid-settle.

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.

Done in d601a4c: while a flick holds the row, the edge buttons, scrollNext/scrollPrev, scrollTo and Shift+wheel move it with the paging's own animated scroll from where it shows, and native scrolling returns at rest; a browser case presses mid-settle and asserts the rest is a stop past the flick's own target.

…s a finger's pan (spec:AST-094 FR43)

On the axis that a pan's derived touch-action keeps from the browser, the
pan's own element is the pan's for a trackpad's scroll too, as it is for a
finger. A scroller inside it still takes the wheel first, and a mouse
wheel's notches stay native.

The mechanism this needs: manageStyle: false applies to its own
recognizer, so a pager's catch on the same element no longer drops the
pan's touch-action. The gesture README and the allowedScrollTypes JSDoc
state the own-scroller rule.
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec d601a4c

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

Changes requested on #8153; #10212 is approved pending CI. A pull request in this stack lands only when it and every pull request below it are approved.

#10212: approved pending CI. CI has not run on this head yet: it runs once the pull request is ready for review and, because the branch is in a fork, a maintainer adds the ci:run label. The own-scroller trackpad routing and per-recognizer manageStyle: false match spec:AST-094 FR41 and FR43, and the earlier README and attach-order requests are done.

#8153: changes requested. The press-during-settle defect from the last review is fixed: the row now rests on a stop in both directions.

  • SnapPaging.ts:345-413: moveTo re-implements the pager's goTo move and its settle catch (a window pointer watch held for paging.catchStopMs) instead of extending createPager, so give the pager an offset move that runs as its own settle, and have moveHeld make that one call.

Whole stack: it fails on that #8153 item; the net API (hasSnap?: boolean | SnapPaging, useSnapPaging, the ./SnapPaging entry) and the gesture-host change otherwise agree with component:Carousel and spec:AST-094.

[Automated review]

* A scroll call's move of the held row to `to`, as the pager's `goTo` plays.
* It replaces the settle, and hands the row back at rest.
*/
function moveTo(to: number): void {

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.

Blocking (one shared path): moveTo repeats createPager's goTo move (solveAnimatedPageScroll from the shown position with the running velocity, usePager.ts:1107-1155), and the window watch below repeats the pager's settle catch (SettleCatcher and onCatch, usePager.ts:386-403 and :1069-1105). The pager's catcher is attached to this same row and fires on the same touch, but stops nothing, because this move is not its settle. The two already differ: a hold-then-lift after a flick rests through settleFrom, after an edge press through this animated scroll.

Suggest an offset move on the pager (for example goTo taking a position) that runs as its own settle, so its catcher stops it. moveHeld then makes that one call, and this curve code and the subscribeInput watch go.

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.

Done in 2dc6f90: goTo takes an offset, so the press move is the pager's own settle and its catch stops it; moveHeld makes that one call, and the curve code and the pointer watch are gone.

…ative snap's stops

hasSnap keeps native CSS snap. hasSnap={useSnapPaging()} pages the row
under a finger and on a trackpad swipe, for every pointer: a flick moves
one view, or one item with {pages: 'item'}, landing on an item's start
edge. A mouse wheel and a mouse drag keep native scrolling. Only a
carousel given the hook's value bundles the pager, and the hook has no
non-React equivalent. The hook has its doc page and a runnable example
block, and Carousel's record no longer lists snap paging as unbuilt.
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 2dc6f90

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec d432fc9

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

Changes requested: snap paging matches the component:Carousel contract, and the held-row move now runs through the pager's own goTo, which fixes the earlier copied move and catch. One fix remains:

  • Miss: while a flick holds the row, moveHeld (SnapPaging.ts:350) and Carousel's pagingStep and wheel listener (Carousel.tsx:335, :461) clamp to the live scrollWidth - clientWidth, which the children's translate widens after a flick back, so Scroll right from item 6 carries the three-per-view row 70 px past its last item before it jumps back at the hand-back, against FR7's "no empty space shows"; clamp to the travel the row took over with (heldTravel) and add a browser case that presses after a flick back near the end.

Two small non-blocking notes are in the review: pagingStep could reuse nearestStop, and FR8's "only calls attach and detach" sentence no longer describes the moveHeldSnapPaging calls.

[Automated review]

}
// A finger on the row keeps it.
if (!pager.isDragging) {
const end = Math.max(0, element.scrollWidth - element.clientWidth);

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.

Miss (blocking): while the row is held, its children carry a translate, and a translate toward the inline end widens the scrollable overflow. After a flick back from item 6 on the three-per-view story, scrollWidth reads 986 px against 916 px at rest, so this clamp lets Scroll right carry the row to 686 px against a 616 px end: 70 px of empty space after item 9, then a jump to 616 px at the hand-back (component:Carousel FR7: "the last stops clamped where the row ends so no empty space shows"). Clamp to the travel the track took over with (heldTravel, which stops() already uses), and pass that end to Carousel's pagingStep and wheel listener, which read the same live value (Carousel.tsx:335, :461).

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.

Done in fff7785: the held moves clamp to the travel the row was taken over with (heldTravel), passed to Carousel's pagingStep and wheel listener; a browser case presses after a flick back near the end and checks no space past the last item on every frame (70 px before the change). pagingStep reuses nearestStop, and FR8 now names the move call.

vjeux added 2 commits November 5, 2026 22:17
While a flick holds the row, its children's translate widens the row's
overflow, so its live scroll width runs past its last item. The press,
Shift+wheel and scrollTo moves now clamp to the travel the row was taken
over with, so no space shows past the last item. Carousel's record states
that it also asks the value to move a row it holds.
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec fdfb308

@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 branch is in a fork, so a maintainer adds the ci:run label for CI to run.

The last review's blocker is fixed: held-row moves now clamp to the travel the row was taken over with, so Scroll right after a flick back near the end stops at the last stop with no space past the last item, and the new browser case covers it. Every delta matches the component:Carousel contract, and the FR8 wording is approved for this head.

[Automated review]

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants