Repository navigation
feat(lab): PageControl, UIPageControl's indicators: a tap steps a page, a finger scrubs, every pointer pages - #9684
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report🔎 Preview ·
|
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| lab/PageControl · Storybook | 71.4 kB (new) · 709 B CSS (new) | 999 (new) | — · ✓ · ✗ |
| lab/NavigationScreen | 78.3 kB (+242 B) · 1.5 kB CSS (0) | — | — · — · — |
| lab/NavigationStack | 78.3 kB (+242 B) · 1.5 kB CSS (0) | — | — · — · — |
Package size:
- @astryxdesign/cli 3.39 MB packed (+9 B); no new dependencies
- @astryxdesign/core 6.71 MB packed (+243 B); no new dependencies
API changes: none in this pull request
Committed API report behind this build: cli 19 rows, core 24 rows; run pnpm api:report.
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
There was a problem hiding this comment.
Approval waits on an engineering owner's decision about Lab PageControl, and four fixes are needed either way.
Owner decision needed. No current record governs (a) a Lab PageControl beside Pagination's variant="dots", with its one-page tap rule and slider semantics; (b) its held look, which design:user-states DR1 does not list; (c) its fades, slide delay and window spring played as table values instead of spec:AST-094 FR4 motion roles; or (d) Lab scrubbing with Core's internal PanGestureRecognizer where spec:AST-107 FR7 points to usePanGesture and architecture:react-component-runtime INV13 lists no pan gap. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request or by naming the record change that settles it; the author may do so as an engineering owner.
Blocking findings:
- The diff against
mainis 10,793 authored lines, over the 8,000-line limit for an owner-directed batch, because it carries the stacked SearchField from #9399; land #9399 and #9669 first and rebase so this pull request holds PageControl alone. SearchFieldTouch.tsswallows a claimed tap's latemousedown,mouseupandclickwith its own document listeners beside Core's click guard (armClickGuard); extend the guard and arm it from the claimed tap instead.SearchFieldTouch.tsbuilds its ownResizeObserverwhere Core'sobserveResizealready owns resize observation; use the shared observer, extending it per window if a same-origin iframe must work.PageControl.tsxclearsrequestRefin a layout effect that synchronizes no external system and relies on the touch layer's effect running first; clear it in that fade effect right after it readskind.
A non-blocking note on the component's JSDoc placement is inline.
[Automated review]
| // lets go (its click, the next press, or its window), the late events are | ||
| // over, and this swallow lets go too. | ||
| const disarmRef = useRef<(() => void) | null>(null); | ||
| const armSwallow = useCallback((doc: Document) => { |
There was a problem hiding this comment.
This swallow installs its own document-capture mousedown/mouseup/click listeners beside Core's click guard (packages/core/src/utils/clickGuard.ts, armClickGuard), which owns the browser's late click after a gesture that already acted. Extending the guard to also swallow the compatibility mousedown/mouseup, and arming it here through internal/touch, would leave one path to fix.
| if (field == null || Observer == null || placement === 'tab') { | ||
| return; | ||
| } | ||
| const observer = new Observer(() => { |
There was a problem hiding this comment.
observeResize from @astryxdesign/core/utils (sharedResizeObserver.ts) already owns resize observation without per-instance observers. If this per-window constructor is for same-origin iframes, extending the shared module to keep one observer per window would serve both.
|
|
||
| // A shown page consumes its request (the touch layer read its kind first). | ||
| const consumedPageRef = useRef(current); | ||
| useLayoutEffect(() => { |
There was a problem hiding this comment.
This layout effect synchronizes no external system and relies on the touch layer's fade effect (PageControlTouch.ts:537-545) running first. Clearing requestRef in that effect, right after it reads kind, removes the cross-file ordering dependency (architecture:react-component-runtime INV3).
There was a problem hiding this comment.
Done in 3cd5389: the touch layer's fade effect clears the request right after it reads its kind, and the second layout effect is gone.
| * <PageControl count={5} value={page} onChange={setPage} /> | ||
| * ``` | ||
| */ | ||
| /** The page count: 0 for a count that is not a finite number. */ |
There was a problem hiding this comment.
Non-blocking: this helper's JSDoc sits between the component's JSDoc (lines 173-199) and PageControl, so the component block documents nothing. Moving pageCountOf above that block restores the hover docs.
There was a problem hiding this comment.
Done in 3cd5389: pageCountOf now sits above the component's JSDoc.
1cbb09e to
3cd5389
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision about Lab PageControl, and three fixes are needed either way.
Owner decision needed. No current record governs (a) a Lab PageControl beside Pagination's variant="dots", with its one-page tap rule and slider semantics; (b) its held look, which design:user-states DR1 does not list; (c) its fades, slide delay and window spring played as table values instead of spec:AST-094 FR4 motion roles; or (d) scrubbing with Core's internal PanGestureRecognizer, where spec:AST-107 FR7 points to a public pan hook that Core does not ship yet and architecture:react-component-runtime INV13 lists no pan gap. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request or by naming the record change that settles it; the author may do so as an engineering owner.
Blocking findings:
- The diff against
mainis 10,786 authored lines, over the 8,000-line limit for an owner-directed batch, because it carries the stacked SearchField from #9399; land #9399 and #9669 first and rebase so this pull request holds PageControl alone. SearchFieldTouch.tsswallows a claimed tap's latemousedown,mouseupandclickwith its own document listeners beside Core's click guard (armClickGuard); extend the guard and arm it from the claimed tap instead.SearchFieldTouch.tsbuilds its ownResizeObserverwhere Core'sobserveResizealready owns resize observation; use the shared observer, extending it per window if a same-origin iframe must work.
The request-consumer effect and the JSDoc placement from the last review are fixed.
[Automated review]
3cd5389 to
fde400d
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision about Lab PageControl, and one fix is needed either way.
Owner decision needed. No current record governs (a) a Lab PageControl beside Pagination's variant="dots", with its one-page tap rule and single slider semantics; (b) its held look, which design:user-states DR1 does not list; (c) its fades, slide delay and window spring played as table values instead of spec:AST-094 FR4 motion roles; or (d) scrubbing with Core's internal PanGestureRecognizer, where spec:AST-107 FR7 points to a public pan hook that Core does not ship yet and architecture:react-component-runtime INV13 lists no pan gap. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request or by naming the record change that settles it; the author may do so as an engineering owner.
Blocking finding:
- The diff against
mainis 10,911 authored lines, over the 8,000-line limit for an owner-directed batch, because it carries the stacked SearchField from #9399; land #9399 and #9669 first and rebase so this pull request holds PageControl alone.
SearchField now relies on Core's click guard and shared resize observer, as the earlier inline comments asked.
[Automated review]
fde400d to
b8e3a66
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision about Lab PageControl, and three fixes are needed either way.
Owner decision needed. No current record governs (a) a Lab PageControl beside Pagination's variant="dots", with its one-page-per-tap rule, single slider semantics and other dots at 48 %; (b) its held look (a pressed-overlay platter, other dots at 25 %), which design:user-states DR1 does not list; or (c) its window spring, slide delay and fades played as table values, where spec:AST-094 FR4 asks for motion roles. The owners' answer already covers the internal pan recognizer. An engineering owner records the decision for (a)–(c) by commenting /approve-spec <full head SHA> on this pull request, or names the record change that settles them; the author may do so as an engineering owner.
Required fixes:
- Land #9754 and #9669 first, or drop the
createMotionEnginerename, the uncalleduseMotionEnginehook, the unused re-exports and the changeset that says Lab components use them, because they put this diff at 3,914 authored lines outside the one-component batch exemption. - When a tap or key slides the window, the old indicator's fade runs 0.015625 s instead of 0.125 s or 0.25 s, because the window-slide effect reads computed style (
PageControlTouch.ts:464) before the fade effect writes the transitions; write the transitions first and test the fade that actually runs in a window-slide case. - The layout effect at
PageControlTouch.ts:526-533only marks rendered dots for the window-slide effect; derive the newly mounted dots there from the previous window and remove it.
[Automated review]
| if (strip == null || root == null) { | ||
| return undefined; | ||
| } | ||
| const isRtl = isRtlElement(root); |
There was a problem hiding this comment.
This getComputedStyle read runs before the fade effect below writes the dots' transitions. When the window slides, the old dot's opacity transition starts on its previous 15.625 ms switch: in Chromium the fade that runs is 0.015625 s instead of 0.125 s after a tap or 0.25 s after a key. Writing the transitions first (for example, declaring the fade effect ahead of this one) keeps the measured fade.
There was a problem hiding this comment.
Fixed in 288574e: the fade effect now runs before the window effect, so the transitions are written before its style reads. A new browser case checks the fade that runs when a tap or a key slides the window.
| }, [windowStart, pages, isTouch]); | ||
|
|
||
| // Mark rendered dots, so a later window move knows which ones are new. | ||
| useLayoutEffect(() => { |
There was a problem hiding this comment.
This Effect runs after every commit only to mark dots for the window-slide effect. That effect already keeps the previous window in shownRef, so a dot is new when its page lies outside the previous window's rendered range (windowStart ± RENDER_MARGIN); computing that there removes this Effect.
There was a problem hiding this comment.
Done in 288574e: that layout effect is gone. The window effect finds the dots that mounted from the previous window's rendered range.
| } | ||
| const kind = | ||
| requestRef.current?.page === current ? requestRef.current.kind : 'other'; | ||
| // The shown page consumes its request. |
There was a problem hiding this comment.
Optional: this restates the next line. Drop it, or keep only the reason: "Clear it. A later change from the app then fades as other."
| } | ||
| // Under Reduce Motion every dot takes the policy's fade. | ||
| const reduced = reducedFadeOf(root); | ||
| // The table's measured values: touch motion stays internal. |
There was a problem hiding this comment.
Optional: the next lines already show the values come from the table; dropping this comment also drops a policy statement that the owner question about motion roles still has to settle.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: Core's internal touch entry re-exports the motion engine, curve and gesture helpers Lab's touch components use, and the engine's factory is renamed `createMotionEngine`. Apps import nothing new. |
There was a problem hiding this comment.
No Lab component at this head imports the motion engine, cubicBezier, timing, rubberBand, UIKIT_GESTURES or TOUCH_QUERY; this changeset can move with those re-exports to #9754, or name only the re-exports PageControl uses.
There was a problem hiding this comment.
That changeset belongs to #9754, the base this PR is stacked on; this PR adds no changeset for those re-exports.
b8e3a66 to
288574e
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision about Lab PageControl, and three fixes are needed either way.
Owner decision needed. No current record governs (a) a Lab PageControl beside Pagination's variant="dots", with its one-page-per-tap rule, single slider semantics and other dots at 48 % instead of the measured 45 %; (b) its held look, a pressed-overlay platter with the other dots at 25 %, which the design:user-states table does not list; and (c) its window spring, slide delay, fades and switch played as table literals, where spec:AST-094 FR4 says a component must read touch motion through named roles. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request or by naming the record change that settles each point; the author may do so as an engineering owner. The owners' answer already covers the internal pan recognizer.
Required changes:
- Land #9754 first or drop its parts from this branch (the
createMotionEnginerename, the uncalleduseMotionEnginehook, the re-exports nothing here imports and the changeset that says Lab components use them), since they put this owner-directed batch at 4,018 authored lines with unrelated changes mixed in. - Call Core's
writeHitInsetsthrough@astryxdesign/core/internal/touchinstead of the copy atPageControl.tsx:332-339, and read--astryx-hit-inset-*in the hit layer. - Return every dot to the measured 15.625 ms switch once a page change is shown, because a held finger now dims the previously current dot over 0.125 s or 0.25 s, and a lift that keeps the page restores it as slowly, while the others switch at once.
The window-slide fade and the extra layout effect from the last round are fixed.
[Automated review]
| return watchHitArea(root, insets => { | ||
| for (const side of ['top', 'right', 'bottom', 'left'] as const) { | ||
| root.style.setProperty( | ||
| `--_page-control-hit-${side}`, | ||
| `${insets[side]}px`, | ||
| ); | ||
| } | ||
| }); |
There was a problem hiding this comment.
This loop copies Core's writeHitInsets (packages/core/src/utils/hitArea.ts), which InfoTip and Switch call as watchHitArea(el, insets => writeHitInsets(el, insets)) with a hit layer reading --astryx-hit-inset-*. Re-export writeHitInsets from internal/touch beside watchHitArea, call it here, and point the ::after layer at --astryx-hit-inset-*, so a change to the shared writer reaches PageControl too.
There was a problem hiding this comment.
Done in 9bd8205: internal/touch re-exports writeHitInsets beside watchHitArea, PageControl calls it, and the hit layer reads --astryx-hit-inset-*.
| const switchFade = fadeOf(PC.switchMs, PC.switchCurve); | ||
| for (const dot of Array.from( | ||
| stripRef.current?.children ?? [], | ||
| ) as HTMLElement[]) { | ||
| dot.style.transition = | ||
| reduced ?? (Number(dot.dataset.page) === old ? fade : switchFade); | ||
| } |
There was a problem hiding this comment.
The old dot keeps this 125 ms or 250 ms fade after the change. Nothing sets it back to the switch, so the next held finger dims that dot over 0.25 s (0.125 s after a tap) while the others switch in 0.015625 s, and a lift that keeps the page restores it as slowly. Chromium on the Default story, after End: dots 1, 2 and 4 reach 0.25 at 9 ms, dot 3 at 242 ms. The table names the switch for that change (pageControlTable.ts:162-164). Writing reduced ?? SWITCH to every dot when isInteracting changes would fix it.
There was a problem hiding this comment.
Fixed in 9bd8205, with a browser case that failed before (the previous current dot dimmed and returned over 0.25 s). Once the old dot's fade starts (transitionrun), it goes back to the switch; the running fade keeps its duration. Writing the switch on every isInteracting change would also replace a page change's fade, because a lift's step and a scrub's first page commit together with the platter change.
288574e to
9bd8205
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision about Lab PageControl, and two fixes are needed either way.
Owner decision needed. No current spec governs (a) a Lab PageControl beside Pagination's variant="dots", with its one-page-per-tap rule, single slider semantics and other dots at 48 % instead of the measured 45 %; (b) its held look, a pressed-overlay platter with the other dots at 25 %, which the design:user-states table does not list; and (c) its window spring, slide delay, fades and switch played as table literals, where spec:AST-094 FR4 says a component must read touch motion through named roles. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, or provides or identifies the record that settles each point; the author may do so as an engineering owner.
Required changes:
- Land #9754 first or drop its parts from this branch (the
createMotionEnginerename, the uncalleduseMotionEnginehook, the re-exports nothing here imports and the changeset that says Lab components use them), since they put this owner-directed batch at 4,123 authored lines with unrelated changes mixed in. - Return the old dot to the switch when its fade cannot run, because after a page change made while the control is hidden (
display: none) the first held finger dims that dot over about 0.22 s while the others switch in one frame.
The hit-inset copy and the previous dot's dimming from the last round are fixed.
[Automated review]
| // Once the fade runs, the dot's next change is a switch again. A running | ||
| // transition keeps the duration it started with. | ||
| const dot = fading; | ||
| const onRun = (event: TransitionEvent) => { | ||
| if (event.target === dot && event.propertyName === 'opacity') { | ||
| dot.style.transition = SWITCH; | ||
| dot.removeEventListener('transitionrun', onRun); | ||
| } | ||
| }; | ||
| dot.addEventListener('transitionrun', onRun); |
There was a problem hiding this comment.
This hands the old dot back to the switch only when its fade starts. Under display: none no transition runs, so transitionrun never fires: after a page change made while the control is hidden, the dot keeps the 250 ms fade, and the next held finger dims it in about 217 ms while the others take one frame (Chromium, Default story, End while hidden). Also writing SWITCH once the fade's duration has passed with no transitionrun (a timer cleared in the cleanup), plus a case that changes the page while hidden, would close it.
There was a problem hiding this comment.
Fixed in 9197699, with a case that hides the control, changes the page, then holds (before: [0.25, 0.015625]). The effect now reads the old dot's style once after writing the fade, which starts the fade with that duration, and then writes the switch at once; a running transition keeps its duration, and a hidden control starts none. No listener or timer.
| // The held look: a held or scrubbing finger's platter paints the system | ||
| // pressed overlay on the root, under `data-interacting`. It has no element | ||
| // of its own. |
There was a problem hiding this comment.
Non-blocking (comment style): platterShown follows isInteracting through StyleX, and data-interacting styles nothing. Suggest: "The held look has no element of its own. The root's ::before paints it."
| // The input (PageControlTouch.ts): the tap and the scrub for every pointer. | ||
| // Under a finger, it adds the touch presentation: the held platter, the | ||
| // window's slide and the fades. |
There was a problem hiding this comment.
Non-blocking (comment style): this restates usePageControlTouch's own JSDoc; it can go.
| // Under a finger the slots centre on the control; the cropped row starts inside the | ||
| // padding. |
There was a problem hiding this comment.
Non-blocking (comment style): two topics in one sentence, and the first restates stripStart. Suggest only: "The cropped row starts inside the padding."
9bd8205 to
9197699
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision about Lab PageControl, and three fixes are needed either way.
Owner decision needed. No current spec governs (a) a Lab PageControl beside Pagination's variant="dots", with its one-page-per-tap rule, single slider semantics and other dots at 48 % instead of the measured 45 %; (b) its held look, a pressed-overlay platter with the other dots at 25 %, which the design:user-states table does not list; and (c) its window spring, slide delay, fades and switch played as table literals, where spec:AST-094 FR4 says a component must read touch motion through named roles. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, or provides or identifies the record that settles each point; the author may do so as an engineering owner.
Required changes:
- Land #9754 first or drop its parts from this branch (the
createMotionEnginerename, the uncalleduseMotionEnginehook and the re-exports only that hook uses), since they put this owner-directed batch at 4,206 authored lines with unrelated changes mixed in. - Drop or correct
.changeset/core-internal-touch-entry-lab-animator.md, which says Lab's touch components use helpers that no Lab component imports. - Move the held look's Reduce Motion fade out of the layout effect at
PageControlTouch.ts:571-589and intoshowHeld()andfinish(), which setisInteractingand can write that fade first.
The previous dot's dimming after a change made while the control is hidden, and the three comment suggestions, are fixed.
[Automated review]
|
|
||
| // Under Reduce Motion the platter and the dots' dimming, which show and | ||
| // hide with a finger, take the policy's fade too. | ||
| useLayoutEffect(() => { |
There was a problem hiding this comment.
This Effect reacts to isInteracting, which only showHeld() and finish() (lines 267-285) change. Both can write the Reduce Motion fade to the platter's custom property and the dots before they call setInteracting, so the fade needs no Effect (architecture:react-component-runtime INV2), and the note above names no external system (INV3). One helper both handlers call, and deleting this Effect, would close it; the page-change fade effect already covers dots that mount on a slide.
There was a problem hiding this comment.
Done in 489ca21: the effect is gone. The hold and the lift write the Reduce Motion fade before they change the held look.
9197699 to
489ca21
Compare
# Conflicts: # internal/shadcn-registry/routes.lock.json # packages/cli/assets/discover/official-packages.json # packages/lab/src/index.ts
|
/approve-spec b583753 |
There was a problem hiding this comment.
Approved pending CI: the CI run on this head stopped before testing anything because CI was full, so all jobs need a re-run; nothing merges until the required checks pass.
#9669: approve. Core's internal touch entry re-exports the four helpers Lab's PageControl reads, after the NavigationStack names main added. No blocking findings.
#9684: approve. Lab PageControl with UIPageControl's taps, scrub, held look and sliding window. An engineering owner's /approve-spec for this head records the decision on its tap rule, slider semantics and 48 % dots, its held look, and its measured motion values. No blocking findings.
Whole stack: the combined change passes. A pull request lands only when it and every pull request below it are approved.
[Automated review]
|
/approve-spec 7f2b7f2 |
…e under Reduce Motion Under Reduce Motion a press colour's onset and a selection colour apply at once. So a held finger's platter and the dots' dimming show with no fade, and only their release keeps the 200 ms fade on a coarse pointer. The current indicator changes at once on every page change.
|
/approve-spec 0c7fe4e |
There was a problem hiding this comment.
#9669 is approved; #9684 waits on an engineering owner's decision for this head.
#9669: approved. Its four re-exports sit on Core's reserved internal touch entry, which the runtime record's internal-module gap list covers.
#9684: waits on an engineering owner's decision. Its Reduce Motion fix is right (a held finger's platter and dimming, and every page change, now apply at once, and only the lift fades, as design:reduced-motion DR2 asks), and no defect remains.
- Approval needs an engineering owner to settle, for this head, the PageControl points that no spec covers yet (its place beside Pagination's dots with the one-page tap rule, single slider and 48 % dots; its held look; and its motion played from measured literals rather than motion roles or theme tokens) by commenting
/approve-spec <full head SHA>, which the author may do as an engineering owner.
Stack: the whole-stack evaluation passes; a pull request lands only when it and every pull request below it are approved, and CI has not run on this draft head yet.
[Automated review]
There was a problem hiding this comment.
Approved pending CI: CI has not run on these draft heads yet and runs once they are marked ready for review.
#9669: approved, with no blocking finding.
#9684: approved, with no blocking finding; an engineering owner's /approve-spec for this head settles the PageControl design points that no spec covered.
Stack: the whole-stack evaluation passes, and a pull request lands only when it and every pull request below it are approved.
[Automated review]
|
/approve-spec cdd8ca1 |
There was a problem hiding this comment.
Approval waits on an engineering owner's decision for #9684 at this head; neither pull request has a code defect.
#9669
Ready on its own: its four re-exports on Core's internal touch entry are among the runtime record's listed internal touch gaps, and its merge beside the new useFingerScroll re-export is clean.
#9684
Waiting on an owner decision for these points, which have no governing record; the earlier /approve-spec covered only the previous head:
- (a) a Lab
PageControlbeside Pagination'svariant="dots", with one page per tap at the lift, a singlerole="slider", and other dots at 48 % against UIKit's measured 45 %. - (b) the held look, a
--color-overlay-pressedplatter with the other dots at 25 %, which the user-states record does not list. - (c) the tap-, key- and app-started motion (the 125 and 250 ms fades, the 15.625 ms switch, the window's spring and its 50 ms delay) played from Lab table literals rather than motion roles or theme motion tokens.
An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.
Stack
- The whole-stack evaluation finds no blocking issue.
- A pull request lands only when it and every pull request below it are approved.
[Automated review]
There was a problem hiding this comment.
Approved pending CI: CI has not run on these draft heads yet; it starts once they are marked ready for review.
#9669
Approved. Its four re-exports on Core's internal touch entry fill gaps the runtime record already lists, and Core's public API is unchanged.
#9684
Approved. An engineering owner's /approve-spec for this head settles PageControl's tap-and-slider design, its held look and its table-driven motion, and no code change is needed.
Stack
- The whole-stack evaluation finds no blocking issue.
- A pull request lands only when it and every pull request below it are approved.
[Automated review]
|
/approve-spec e446b25 |
There was a problem hiding this comment.
#9669 is approved; #9684 waits on an engineering owner's decision for this head, and neither pull request needs a code change.
#9669: approved. Its merge keeps main's new NavigationStack re-exports beside its four PageControl re-exports on Core's internal touch entry.
#9684: waiting on an owner decision. Since the last spec-owner approval, which counted only at the head it named, both branches only merged main; an engineering owner (the author included) records the decision for this head by commenting /approve-spec with its full SHA, for:
- (a) a Lab PageControl beside Pagination's
variant="dots", with one page per tap at the lift, a singlesliderand other dots at 48 % against UIKit's measured 45 %; - (b) the held look, a
--color-overlay-pressedplatter with the other dots at 25 %, whichdesign:user-statesdoes not list; - (c) tap-, key- and app-started motion played from the table's measured literals instead of motion roles or theme motion tokens.
Stack: no blocking issue across the stack; a pull request lands only when it and every pull request below it are approved.
[Automated review]
There was a problem hiding this comment.
#9669: approve. Core's internal touch entry re-exports animatorFor, reducedMotionFade, useTouchHitArea and touchHitAreaStyles for Lab's PageControl, within the internal entry's documented gaps; no blocking findings.
#9684: approve. An engineering owner's /approve-spec for this head records the decision on PageControl's tap-and-slider design, its held look and its Lab motion values, and the review found no defect; CI passed on this head.
Whole stack: passes; the two pull requests are consistent and leave nothing for a later one to undo. A pull request lands only when it and every pull request below it are approved.
Before promotion, tap- and key-started motion should move to the theme's motion tokens, as the description commits.
[Automated review]
Stacked on #9669. Review only this PR's change: vjeux/astryx@39d3a83...0c7fe4e
Batch: L1 · Lab: iOS chrome (TabBar, SearchField, NavigationStack, PageControl)
User need
A phone's paging view shows its page with UIPageControl's dots; Pagination's are desktop-sized.
Evidence
The iOS 26.2 simulator; P7: the Android 15 emulator. Red ring: the finger.
Problem and solution fit
Measured on iOS 26.2 (
pageControltable):slider, "Page indicators": "Page 3 of 5".Current specification
spec:AST-094FR1, FR2, FR19, FR42 and FR43;design:reduced-motionDR2 and DR3. The scrub may use the internal pan recognizer, listed as a gap until the public pan hook lands (architecture:react-component-runtimeINV13).For the owner: (a) a Lab PageControl beside Pagination's
variant="dots", with its one-page tap rule andslidersemantics; (b) the held look, which has nodesign:user-statesrow; (c) the fades, window spring and slide delay as table constants, not motion roles (FR4). Open until promotion: tap- and key-started motion will play theme motion tokens; only the scrub's will spring from velocity. Real-phone clips of P1–P7 are not captured yet.Public delta and caller burden
New Lab
PageControl(Pagination'spagefrom 1,totalPages,onChange(page);hidesForSinglePage), with a story, a discover entry and a registry route.Design and alternatives
Supersedes #8164, #8174 and #8705.
Scope
Testing
design:reduced-motionDR2 (Specs decision, Nov 5 2026). Normal motion is unchanged.CI=true pnpm lint, Prettier, check:changesets, check:knowledge, lab:readiness:check and check:sync pass.useTouchHitArea, paused while a scrub runs, keeps the insets live, andtouchHitAreaStyles.layerdraws the band, replacing PageControl's own effect and band (no behavior change). Guarded by the jsdom case "under a coarse pointer, an invisible hit area grows the 25.67 px tall control toward 44 px" and, in both engines, the browser case "a tap just above the control, inside its 44 px hit area, steps a page". This pull request no longer changes Core.pagefrom 1,totalPages,onChange(page); Specs decision, Nov 5 2026); every test and story counts pages from 1, while the dots' internaldata-pageindex stays 0-based. The browser spec imports@astryxdesign/a11y-spec/playwright.[0.25, 0.015625]);[0.25, 0.25]).pnpm exec vitest run packages/lab/src/PageControl: 48 passed.PageControl.interaction.browser.spec.ts, real touch) at 880e851, the same code as a187c7a, whose only other change is one comment: Chromium on a pinned pair of 2 cores, 4 workers: 31 passed; WebKit in the CI job's profile (podman--cpus=4,CI=1,LP_NUM_THREADS=4, 1 worker) ×10: 300 passed, 10 skipped.CI=true pnpm lintover the repository passes; Prettier passes; check:knowledge, check:changesets, check:i18n-runtime, check:i18n-catalog, sync:exports:check, check:use-client, lab:readiness:check and check:sync pass; Storybook builds; the docsite generator's registry route check passes;pnpm review:prereports one finding, on that layout effect, which the pre-review check is being aligned to exempt.spec:AST-094FR1, FR2, FR4, FR19, FR42, FR43;design:reduced-motionDR2, DR3;design:user-statesDR1;architecture:react-component-runtimeINV13;architecture:public-component-apiINV2;architecture:knowledge-contractsINV29; and the global records thatpnpm specs:for --difflists.Stack: #9669 → #9684
Merge group: #9669, #9684