Skip to content

feat(lab): PageControl, UIPageControl's indicators: a tap steps a page, a finger scrubs, every pointer pages - #9684

Merged
cixzhang merged 14 commits into
meta:mainfrom
vjeux:c23/l1-pagecontrol
Nov 6, 2026
Merged

cixzhang merged 14 commits into
meta:mainfrom
vjeux:c23/l1-pagecontrol

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

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.

Path Clip
P1 taps: trailing forward, leading back
P2 scrub, with the platter
P3 jog past the edge
P4 the window slides
P5 RTL scrub; RTL window
P6 Reduce Motion: cross-fade
P7 Android 15: scrub; window

Problem and solution fit

Measured on iOS 26.2 (pageControl table):

  • Look: 9.67 px dots 8 px apart, 25.67 px tall, at most 161.3 px wide; others at 48 % (UIKit's 45 %, raised to 3:1).
  • Tap: the current dot's trailing side asks for the next page, its leading side the previous, at lift; RTL mirrors.
  • Scrub: from 16 px, the page is the dot under the finger, stepping past a windowed row's edge; vertical swipes scroll the page.
  • Held: after 155 ms still, or once scrubbing, a platter shows the pressed overlay; the other dots dim to 25 %.
  • Window: ten slots, shrinking edge dots, moving only when the current page would land in one, on UIKit's spring.
  • Every pointer: a mouse or pen taps and scrubs; a cropped long row holds still until the lift; a trackpad scroll leaves the page alone.
  • Hit area: under a coarse pointer, Core's shared touch hit area grows the control toward 44 px.
  • Accessibility: one adjustable slider, "Page indicators": "Page 3 of 5".

Current specification

spec:AST-094 FR1, FR2, FR19, FR42 and FR43; design:reduced-motion DR2 and DR3. The scrub may use the internal pan recognizer, listed as a gap until the public pan hook lands (architecture:react-component-runtime INV13).

For the owner: (a) a Lab PageControl beside Pagination's variant="dots", with its one-page tap rule and slider semantics; (b) the held look, which has no design:user-states row; (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's page from 1, totalPages, onChange(page); hidesForSinglePage), with a story, a discover entry and a registry route.

Design and alternatives

  • Each dot is a 10 px box scaled to 9.67 px: Chromium paints 9.67 px boxes 9 or 10 px wide.

Supersedes #8164, #8174 and #8705.

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

  • New in this head: under Reduce Motion, a held finger's platter and the dots' dimming show at once and only their release fades, in 200 ms on a coarse pointer; the current indicator changes at once on every page change. A press colour's onset and a selection colour apply at once under design:reduced-motion DR2 (Specs decision, Nov 5 2026). Normal motion is unchanged.
    • Red first: with the previous head's touch layer, the two jsdom Reduce Motion cases fail, and the browser case fails at the key's change (a 200 ms fade where none should run).
    • Mutants: a release that changes at once too fails the jsdom case and the browser case (the platter's opacity is 0 on the first frame after the lift); a highlight that takes the short switch fails both (15.625 ms).
    • At this head, the browser Reduce Motion case: Chromium unpinned ×5, Chromium on a pinned pair of 2 cores ×5 and WebKit in the CI job's profile ×5, 5 passed each.
    • Normal motion, measured in Chromium before and after: the dots' switch and the platter's fade are 15.625 ms on (0.42, 0, 0.58, 1); the platter is opaque on the second frame of the hold; the 0.125 s and 0.25 s fade cases pass before and after.
    • The whole PageControl spec: Chromium on the pinned pair, 31 passed; WebKit ×10, 300 passed, 10 skipped. Lab tsc 0 errors; jsdom over all of Lab, Core's internal entry and the touch hit area, 1,057 passed; the full node project, 16,819 passed, 2 expected failures, 71 skipped; CI=true pnpm lint, Prettier, check:changesets, check:knowledge, lab:readiness:check and check:sync pass.
  • Earlier in this round: each Effect's note sits on it and names the system it syncs. The latest values the gestures and timers read are written at commit, in a named layout effect, so a discarded render leaves none behind (no behavior change).
  • Earlier in this round: the hit area is Core's shared one, as the stack review asked: useTouchHitArea, paused while a scrub runs, keeps the insets live, and touchHitAreaStyles.layer draws 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.
  • Earlier in this round: the props take Pagination's names (page from 1, totalPages, onChange(page); Specs decision, Nov 5 2026); every test and story counts pages from 1, while the dots' internal data-page index stays 0-based. The browser spec imports @astryxdesign/a11y-spec/playwright.
  • Earlier in this round: under Reduce Motion, the held look's fade is written by the hold and the lift themselves, before the look changes, not by an effect afterwards (no behavior change). Guarded by the jsdom case "under a finger and Reduce Motion, a held finger's platter and the dots' dimming show and hide on the policy's fade" and the browser case "under Reduce Motion nothing slides or scales, and no fade runs over 200 ms on a coarse pointer".
  • Previous rounds, each failing before its fix:
    • "after a page change made while the control is hidden, a held finger dims the previous current indicator on the switch" (before: [0.25, 0.015625]);
    • "after a page change, a held finger dims every other indicator on the switch, the previous current one included, and the lift restores them on it" (before: [0.25, 0.25]).
    • "when a tap or a key slides the window, the old indicator fades for 0.125 s or 0.25 s, not the switch" (before: 0.015625 s); beside it, the guard "when the window jumps, a dot that mounts starts at its size in the old window and grows on the slide" (it passed before too);
    • "with a mouse, a scrub on a cropped long row holds the row still until the lift, so the page stays the dot under the pointer" (before: the row moved 17.7 px with each page);
    • "under a coarse pointer, an invisible hit area grows the 25.67 px tall control toward 44 px" (before: no hit area);
    • in the browser, "a tap just above the control, inside its 44 px hit area, steps a page" and "on a cropped long row, a drag holds the row still, and the page is the dot under the pointer until the lift";
    • a pen tap and a pen scrub (jsdom).
  • Ported from feat(lab): PageControl — UIPageControl's indicators, one page per tap #8164, feat(lab): PageControl scrubs, windows and fades like UIPageControl #8174 and feat(lab): PageControl takes every pointer (spec:AST-094 FR43) #8705, where each case failed first against the code before it.
  • A shown page's request is now consumed in the touch layer's fade effect, right after it reads the request's kind, not in a second layout effect (no behavior change). It is guarded by the jsdom cases "under a finger, the old indicator fades on the table's measured 250 ms and curve" and "a page the app declines can be asked for again", and the browser cases "the old current indicator fades over 0.125 s after a tap and 0.25 s after any other change" and "rapid taps are not coalesced".
  • pnpm exec vitest run packages/lab/src/PageControl: 48 passed.
  • Browser proofs (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.
  • Local gates at the same head, on chore(core): Lab's PageControl reaches the shared animator, the Reduce Motion cross-fade and the touch hit area through Core's internal touch entry #9669: core and Lab tsc 0 errors; jsdom 1,009 passed (all of Lab, Core's internal entry and the touch hit area); node 65 passed (the touch bundle and registry tests included); strict eslint on the changed files has no warnings, and CI=true pnpm lint over 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:pre reports one finding, on that layout effect, which the pre-review check is being aligned to exempt.
  • Records checked: spec:AST-094 FR1, FR2, FR4, FR19, FR42, FR43; design:reduced-motion DR2, DR3; design:user-states DR1; architecture:react-component-runtime INV13; architecture:public-component-api INV2; architecture:knowledge-contracts INV29; and the global records that pnpm specs:for --diff lists.
  • Known limit: under Reduce Motion, the dots take their new sizes on a timer at the cross-fade's midpoint, and a busy main thread can push that late.
  • Size: 14 files, 3,889 lines added, over the 2,000-line threshold as one unit: the component, its model, table and touch layer, which merge three drafts. Tests are 2,226 of the lines.

Stack: #9669 → #9684
Merge group: #9669, #9684

@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 6, 2026 9:45am UTC

Request Review

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

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

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

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · e446b25

Storybook · Sandbox · built from this PR's head

Modified components

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

@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 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 main is 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.ts swallows a claimed tap's late mousedown, mouseup and click with its own document listeners beside Core's click guard (armClickGuard); extend the guard and arm it from the claimed tap instead.
  • SearchFieldTouch.ts builds its own ResizeObserver where Core's observeResize already owns resize observation; use the shared observer, extending it per window if a same-origin iframe must work.
  • PageControl.tsx clears requestRef in 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 reads kind.

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) => {

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

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.

This is SearchField's code from #9399, which this PR is stacked on; the fix goes to #9399.

if (field == null || Observer == null || placement === 'tab') {
return;
}
const observer = new Observer(() => {

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.

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.

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.

This is SearchField's code from #9399, which this PR is stacked on; the fix goes to #9399.


// A shown page consumes its request (the touch layer read its kind first).
const consumedPageRef = useRef(current);
useLayoutEffect(() => {

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

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 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. */

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

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 3cd5389: pageCountOf now sits above the component's JSDoc.

@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 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 main is 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.ts swallows a claimed tap's late mousedown, mouseup and click with its own document listeners beside Core's click guard (armClickGuard); extend the guard and arm it from the claimed tap instead.
  • SearchFieldTouch.ts builds its own ResizeObserver where Core's observeResize already 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]

@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 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 main is 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]

@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

(d) is decided by the owners: "#9669 and #9684's scrub may use the internal pan recognizer, listed as a gap until the public pan hook lands." It is the gap line under Current specification. (a)–(c) are pending the owner's answer.

@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 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 createMotionEngine rename, the uncalled useMotionEngine hook, 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-533 only 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);

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

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 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(() => {

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

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

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: this restates the next line. Drop it, or keep only the reason: "Clear it. A later change from the app then fades as other."

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 288574e.

}
// Under Reduce Motion every dot takes the policy's fade.
const reduced = reducedFadeOf(root);
// The table's measured values: touch motion stays internal.

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

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.

Dropped in 288574e.

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

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

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.

That changeset belongs to #9754, the base this PR is stacked on; this PR adds no changeset for those re-exports.

@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 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 createMotionEngine rename, the uncalled useMotionEngine hook, 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 writeHitInsets through @astryxdesign/core/internal/touch instead of the copy at PageControl.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]

Comment on lines +332 to +339
return watchHitArea(root, insets => {
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
root.style.setProperty(
`--_page-control-hit-${side}`,
`${insets[side]}px`,
);
}
});

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

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 9bd8205: internal/touch re-exports writeHitInsets beside watchHitArea, PageControl calls it, and the hit layer reads --astryx-hit-inset-*.

Comment on lines +479 to +485
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);
}

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

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

@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 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 createMotionEngine rename, the uncalled useMotionEngine hook, 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]

Comment on lines +493 to +502
// 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);

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

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

Comment on lines +117 to +119
// 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.

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 (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."

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

Comment on lines +291 to +293
// 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.

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 (comment style): this restates usePageControlTouch's own JSDoc; it can 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.

Removed in 9197699.

Comment on lines +372 to +373
// Under a finger the slots centre on the control; the cropped row starts inside the
// padding.

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 (comment style): two topics in one sentence, and the first restates stripStart. Suggest only: "The cropped row starts inside the padding."

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

@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 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 createMotionEngine rename, the uncalled useMotionEngine hook 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-589 and into showHeld() and finish(), which set isInteracting and 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(() => {

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

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 489ca21: the effect is gone. The hold and the lift write the Reduce Motion fade before they change the held look.

@vjeux
vjeux force-pushed the c23/l1-pagecontrol branch from 9197699 to 489ca21 Compare November 3, 2026 22:25
# Conflicts:
#	internal/shadcn-registry/routes.lock.json
#	packages/cli/assets/discover/official-packages.json
#	packages/lab/src/index.ts
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec b583753

@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: 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]

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/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.
@vjeux
vjeux marked this pull request as draft November 6, 2026 05:09
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 0c7fe4e

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

#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]

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

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec cdd8ca1

@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 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 PageControl beside Pagination's variant="dots", with one page per tap at the lift, a single role="slider", and other dots at 48 % against UIKit's measured 45 %.
  • (b) the held look, a --color-overlay-pressed platter 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]

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

@vjeux
vjeux marked this pull request as ready for review November 6, 2026 08:23
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec e446b25

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

#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 single slider and other dots at 48 % against UIKit's measured 45 %;
  • (b) the held look, a --color-overlay-pressed platter with the other dots at 25 %, which design:user-states does 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]

@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: #9669 and #9684.

#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]

@cixzhang
cixzhang merged commit 0cc5407 into meta:main Nov 6, 2026
69 checks passed
@vjeux
vjeux deleted the c23/l1-pagecontrol branch November 6, 2026 10:43

This branch was successfully deployed

1 active deployment
Preview — e446b25f 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants