Skip to content

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

Draft
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:c23/l1-pagecontrol
Draft

vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:c23/l1-pagecontrol

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #9669 (on #9754). Review only this PR's change: vjeux/astryx@1ea5d3e...489ca21

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, an invisible layer grows the control toward 44 px, via Core's hit-area writer.
  • 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 (count, value, onChange, hidesForSinglePage), with a story, discover entry and registry route; Core's internal entry also re-exports writeHitInsets.

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, 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: 47 passed.
  • Browser proofs (PageControl.interaction.browser.spec.ts, real touch) at 489ca21: Chromium on a pinned pair of 2 cores (shared with another run at the time), 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 hit-area watcher through Core's internal touch entry #9669 and chore(lab): Lab's animator hook, over Core's internal touch entry #9754: Lab and core tsc 0 errors; jsdom 63 passed (the touch entry test included); strict eslint and pnpm lint on the changed files have no warnings; Prettier passes (the registry's routes.lock.json keeps its generator's format, as on main); check:knowledge, check:changesets, check:i18n-runtime, check:i18n-catalog, check:use-client, check:sync, sync:exports:check and lab:readiness:check pass; the touch entry test 2 passed; the touch-support bundle, CLI discovery and registry tests 57 passed; the Storybook build passes.
  • 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: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,570 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 1,899 of the lines.

Stack: #9754 → #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 3, 2026 10:35pm 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

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 3, 2026, 10:50 PM UTC

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

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

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

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

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

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

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

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.

vjeux added 2 commits November 3, 2026 06:57
Internal entries land with their first user, per meta#8562. Lab's
NavigationStack, PageControl and TabBar animate with Core's animator, solve
UIKit's timing curves with Core's cubic-bézier solver, listen for taps and
pans with Core's gesture hook and pan recognizer, stretch past their ends
with Core's rubber band and read the writing direction as Core does, so
`@astryxdesign/core/internal/touch` re-exports, by name:

- the motion engine, `createMotionEngine`, for its jobs beyond the public
  `createAnimator` (architecture:react-component-runtime INV13), with its
  types `MotionEngine` and `MotionEngineOptions` (the engine's `Animator`
  and `AnimatorOptions`), `AnimateOptions`, `MotionCurve` and `MotionHandle`. The engine's factory is renamed from
  `createMotionAnimator` at its source and in every internal caller, so the
  engine and the public animator differ by more than one word;
- `cubicBezier` and UIKit's `timing` table;
- `useGestureRecognizer`, `useTouchPresentation` and `isRtlElement`;
- `PanGestureRecognizer`, `rubberBand`, UIKit's `UIKIT_GESTURES` table and
  `TOUCH_QUERY`, which more than one of those components reads.

Named re-exports of code already on main, plus that rename: no behavior changes, no docs, no
root re-export, and the `@astryx/no-core-internal-import` lint rule already
limits the entry to Core and Lab. The entry's test checks it re-exports the
same values Core imports by path, and that it carries no public factory's
name.

One Lab module that those components share comes with them:
`internal/useMotionEngine`, a stable motion engine bound to an element
through a callback ref.
…e Motion cross-fade and the hit-area watcher through Core's internal touch entry

Internal entries land with their first user. Lab's PageControl animates its
strip and dots with each element's one shared animator, where the strip's
slide and a dot's size and fade compose. It reads the Reduce Motion
cross-fade's timing, and it keeps its coarse-pointer hit area live with the
hit-area watcher. So `@astryxdesign/core/internal/touch` also re-exports, by
name:

- `animatorFor`, an element's shared animator, as Core's components reach
  it (no public API has the name);
- `reducedMotionFade`;
- `watchHitArea`.

Named re-exports only, of code already on main: no behavior change, no
docs, no root re-export, no new entry, and the
`@astryx/no-core-internal-import` lint rule already limits the entry to
Core and Lab. The entry's test checks it re-exports the same values Core
imports by path.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

…e, a finger scrubs, every pointer pages

PageControl is a row of page indicators, the current one highlighted, that
pages like UIPageControl on iOS 26 (measured on the iOS 26.2 simulator):

- What it draws: 9.67 px indicators 8 px apart, at most ten slots; with
  more pages the row is a window whose edge slots shrink and slide on
  UIKit's spring; the old indicator fades out after a change.
- How it pages: the app owns the page (`value`, `onChange`). A tap on
  either side of the current indicator asks for the next or the previous
  page (mirrored in RTL); a finger that travels 16 px scrubs to the
  indicator under it and jogs past a window's edge; a press held 155 ms
  shows the held platter and dims the others. A vertical swipe stays the
  page's scroll.
- Every pointer: with a mouse or a pen a click steps one page
  toward its side and a drag scrubs; the platter, the dimming, the window's
  slide and the fades stay the touch presentation, on a coarse pointer.
- Reduce Motion: every change fades in 200 ms, nothing travels.
- Accessibility: one adjustable `slider` ("Page 3 of 5"); the arrow keys,
  Home and End change the page. Under a coarse pointer an invisible hit area
  grows the control toward 44 px, as far as its neighbours allow.
- A mouse scrub on a cropped long row holds the row still until the lift, so
  the page stays the dot under the pointer.

Its measured values live in its own table (pageControlTable.ts): only
PageControl reads them, so they stay in Lab. It reaches Core's pan and tap
recognizers, each element's shared animator, the Reduce Motion cross-fade,
the hit-area watcher and the touch presentation through Core's internal
touch entry.
`astryx discover` lists it, and the registry has its route.

Its timing holds on a loaded runner. A jog step commits in the timer's own
task (`flushSync`): a render scheduled after it could wait behind a busy
page's other tasks past the next step, and two steps would show as one. A
jog step counts from when the previous one was due, so a timer that runs
late does not slow the run (after
a stall longer than a step, from the late step: no burst). The window's
slide starts 50 ms after the change at a start time on the document
timeline, for the strip and every dot, which a busy main thread cannot push
back as it would a timer. The held-platter proof dates each read on the
page's clock from the touch, the clock the 155 ms runs on, and the slide
proof dates the change at the lift's first event, the task the change
commits in, not by the next frame or the end of that task. The slide's
storyboard arms the harness's hold before the tap (`armHold`), so the page
holds the motion as it starts, however late the test reads it. The RTL
scrub holds the finger 6 px past the content's edge, where the jog steps
about once a second, so exactly one step comes before the lift with
hundreds of milliseconds to spare.

The unit tests' media-query stub answers from the case's pointer at every
read: Core's gesture host keeps the `(pointer: coarse)` list it asked for
when the document's host was created, so a stub that froze its answer let
an earlier mouse case's pointer reach a later touch case (that case failed
after any mouse case, and passed alone).

Core's internal touch entry also re-exports `writeHitInsets`, Core's hit-area
inset writer, beside `watchHitArea`: PageControl is its first Lab user, and its
hit layer reads the same `--astryx-hit-inset-*` properties as InfoTip and
Switch.
@vjeux
vjeux force-pushed the c23/l1-pagecontrol branch from 9197699 to 489ca21 Compare November 3, 2026 22:25
@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

.changeset/core-internal-touch-entry-lab-animator.md belongs to #9754, the base this PR is stacked on. It is reworded there in #9754's next rebase, to name only the hook and the re-exports that Lab components use.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on engineering owners' decisions about Lab PageControl, and two fixes are needed either way.

Owner decisions 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. Separately, (d) its count and 0-based value props name concepts Pagination calls totalPages and a 1-based page (architecture:public-component-api INV2 and DEC-1). An engineering owner records each decision by commenting /approve-spec <full head SHA> on this pull request, or provides or identifies the record that settles it; 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 no Lab component imports), or put one identical Stack: #9754 → #9669 → #9684 line in all three pull request bodies, since this owner-directed batch now reaches 4,254 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 the motion engine and curve helpers, though no Lab component imports them.

The held look's Reduce Motion fade now lives in the hold and lift handlers, as asked.

[Automated review]

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant