Skip to content

chore(motion): ContextMenu's touch timeline and press curve, measured on iOS 26.2 - #8065

Draft
vjeux wants to merge 827 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-timeline
Draft

vjeux wants to merge 827 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-timeline

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8076 (the UIKit constants table, its registry and the animator); review the last two commits.

Maintainer impact

Part of the UIKit-parity program: ContextMenu's touch invocation follows UIContextMenuInteraction's timeline (#8201 onward). Without measured numbers in the UIKit constants table, each component would guess its own timings and drift from iOS; the table's test refuses any value without a source.

Intended invariant

Every leaf in motion/uikit/*.ts carries an @source (measured, Apple documentation, or a GUESS with a @measure task); components never inline physics; and touch motion stays internal, so no value here is a motion role a theme retunes (spec:AST-094 FR4, DEC-11). Nothing imports the new values yet.

Change

Measured on iOS 26.2 (iPhone 17 Pro simulator) with a UIKit reference app that logs recognizer states and per-frame Core Animation values, motion/uikit/contextMenu.ts records:

  • the press feedback starts 150 ms after touch-down, and the press commits at 400 ms with the click haptic;
  • the menu shows at 735 ms for a finger still down, or at a lift after the commit;
  • the drift tolerance is per axis, between 11 and 12 pt;
  • the feedback grows a copy of the view from its center, ramping 0.16/s and then 0.10/s, chased by a spring (damping 0.6, response 0.4) and shown 38 ms late.

ContextMenu/contextMenuPressCurve.ts is that chase in closed form, so the compositor plays it with no per-frame JavaScript; stepping the spring each frame would stall whenever the main thread is busy.

Proposed values, pending owner review (each a GUESS with its measurement task): the tolerance's exact value, 11.5 (M-CM3); the commit haptic's navigator.vibrate stand-in, a medium impact (M-CM5).

Evidence

  • Before: none of these values exist; ContextMenu's long press is a 500 ms timer with no feedback.
  • After: the curve matches a step-by-step integration of the same spring within 1e-6, and UIKit's recorded frames within 0.03 pt rms on a 200 pt view.
  • Behavior unchanged: nothing imports the new module.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

What a reviewer can check:

  1. pnpm vitest run packages/core/src/motion: the table's test fails any value without a source tag or measurement ID; delete one @source and it goes red.
  2. pnpm vitest run packages/core/src/ContextMenu: the press curve is compared with the recorded UIKit frames, for a long press and for a press lifted at 0.25 s.
  3. Nothing in the app changes: no module imports the new table yet.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 27, 2026 11:20am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 27, 2026, 11:39 AM UTC

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

@astracat-bot astracat-bot Bot 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.

This needs an engineering owner's decision before it can be approved: every change here implements the draft spec:AST-094 (FR1–FR3 and FR12), which no owner has approved yet, and that record would also narrow architecture:theme-tokens and amend design:user-states DR1/DR3, family:buttons and spec:AST-025 FR14 for touch presentations. 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. Independently of that decision, a few changes are needed:

  • Land #8052–#8054 first and rebase, so this pull request carries only its 585-line ContextMenu commit instead of 4,639 lines.
  • AST-094 FR7 and its Current-state impact cite spec:AST-090, which does not exist on main, so drop that citation until the record lands.
  • Add the ## Writing rubric results to this description (or land #8052 first), give the projection tie test an exact tie so it fails without the tie rule, and bring the motion/uikit header comments and the switch line in MEASUREMENTS.md in line with the files at this head.

[Automated review]

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-090 is not on main (it is only proposed in #8003), so this citation, and the one in Current-state impact, points at nothing an owner can read. Drop it until that record lands, keeping FR7’s own requirement.

Comment on lines +96 to +105
it('breaks an exact tie toward the direction of travel', () => {
expect(
selectSnapPoint([0, 100], {position: 50, velocity: 0.0001, rate: 0.5})
.value,
).toBe(100);
expect(
selectSnapPoint([0, 100], {position: 50, velocity: -0.0001, rate: 0.5})
.value,
).toBe(0);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With ±0.0001 units/s the projection lands at 50.0000001, so 100 is simply nearer and the tie branch never runs: deleting || (isTie && isAhead) leaves all 11 projection tests passing. A velocity of ±1e-30 makes it an exact tie within the 1e-9 tolerance, so the test then depends on the rule.

Comment on lines +15 to +22
* sources.test.ts enforces both.
*
* Most interaction springs below are GUESSES today: UIKit does not document
* the springs of its own transitions, and the simulator measurements that
* will replace them (method in motion/README.md) have not run yet. Each
* guess starts from the nearest sourced spring and names it, so a component
* built on the table gets the right value the moment the measurement lands,
* without changing.

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 header predates the measurements: only segmentedThumb and banner are GUESS among the 31 entries below, and the enforcing test is uikit/uikit.test.ts, not sources.test.ts (the same note is in scroll.ts and timing.ts).

where a switch outside any scroll view is needed.

Done (iOS 26.2 simulator, Oct 23 2026; in the switch's own table,
`uikit/switch.ts`): M-SW02, M-SW03, M-SW04, M-SW05, M-SW06, M-SW09 (UISwitch

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

uikit/switch.ts does not exist at this head, so this records switch captures as landed in a table the repository does not have.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

ContextMenu (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 1.1KB 1.1KB 0B
Lines of Code 678 795 -
Complexity Very High Very High (86) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 56.9KB 57.0KB +117B 12.4KB 12.4KB 0B
core/AppShell 57.9KB 58.0KB +90B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 39.9KB 40.0KB +125B 6.4KB 6.4KB 0B
core/Banner 47.8KB 47.9KB +123B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.3KB 54.4KB +111B 9.9KB 9.9KB 0B
core/BreadcrumbItem 66.4KB 66.5KB +105B 11.0KB 11.0KB 0B
core/Button 43.3KB 43.4KB +102B 9.2KB 9.2KB 0B
core/Calendar 54.9KB 55.0KB +110B 10.0KB 10.0KB 0B
core/Carousel 50.2KB 50.3KB +82B 10.2KB 10.2KB 0B
core/ChatComposer 60.6KB 60.7KB +100B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.7KB 55.8KB +99B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.7KB 55.8KB +100B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.5KB 46.6KB +107B 9.5KB 9.5KB 0B
core/ChatLayout 48.8KB 48.9KB +104B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.4KB 46.5KB +84B 9.6KB 9.6KB 0B
core/ChatSendButton 45.8KB 45.9KB +96B 9.2KB 9.2KB 0B
core/CheckboxInput 49.9KB 50.0KB +105B 9.3KB 9.3KB 0B
core/CheckboxListItem 54.0KB 54.1KB +92B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 12.9KB +117B 6.9KB 6.9KB 0B
core/CodeBlock 60.5KB 60.6KB +106B 10.7KB 10.7KB 0B
core/Collapsible 11.7KB 11.8KB +113B 4.2KB 4.2KB 0B
core/CommandPalette 50.9KB 51.0KB +104B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 8.5KB +110B 3.5KB 3.5KB 0B
core/ComplexSelector 55.8KB 55.9KB +132B 10.6KB 10.6KB 0B
core/ContextMenu 77.7KB 77.8KB +87B 14.6KB 14.6KB 0B
core/ContextMenuItem 16.6KB 16.7KB +117B 5.2KB 5.2KB 0B
core/DateInput 99.9KB 100.0KB +88B 14.8KB 14.8KB 0B
core/DateRangeInput 69.4KB 69.5KB +103B 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 110.2KB +77B 15.2KB 15.2KB 0B
core/DialogHeader 48.8KB 48.9KB +101B 9.8KB 9.8KB 0B
core/DrawerHeader 48.9KB 49.0KB +97B 11.2KB 11.2KB 0B
core/DropdownMenu 72.7KB 72.8KB +106B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.2KB +106B 5.6KB 5.6KB 0B
core/DropdownMenuItem 15.4KB 15.5KB +101B 5.2KB 5.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.3KB +110B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.1KB 58.2KB +95B 9.5KB 9.5KB 0B
core/FileInput 54.1KB 54.2KB +102B 10.3KB 10.3KB 0B
core/IconButton 43.3KB 43.4KB +113B 9.2KB 9.2KB 0B
core/Item 11.4KB 11.5KB +98B 4.7KB 4.7KB 0B
core/LayerProvider 63.3KB 63.4KB +121B 10.1KB 10.1KB 0B
core/Lightbox 51.2KB 51.3KB +95B 9.8KB 9.8KB 0B
core/Link 43.5KB 43.6KB +112B 7.6KB 7.6KB 0B
core/ListItem 53.5KB 53.6KB +103B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 144.5KB +79B 17.4KB 17.4KB 0B
core/MobileNav 51.0KB 51.1KB +97B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.2KB 46.3KB +104B 9.4KB 9.4KB 0B
core/MoreMenu 72.9KB 73.0KB +103B 14.7KB 14.7KB 0B
core/MultiSelector 76.2KB 76.3KB +110B 14.6KB 14.6KB 0B
core/NumberInput 55.7KB 55.8KB +108B 10.7KB 10.7KB 0B
core/Outline 28.6KB 28.7KB +113B 4.0KB 4.0KB 0B
core/Pagination 87.7KB 87.8KB +105B 15.3KB 15.3KB 0B
core/Popover 49.8KB 49.9KB +84B 12.1KB 12.1KB 0B
core/PowerSearch 155.1KB 155.1KB +87B 18.6KB 18.6KB 0B
core/RadioListItem 52.1KB 52.3KB +111B 9.7KB 9.7KB 0B
core/SegmentedControlItem 8.6KB 8.7KB +116B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 12.9KB +114B 7.1KB 7.1KB 0B
core/Selector 79.1KB 79.2KB +81B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 14.6KB +97B 5.1KB 5.1KB 0B
core/SideNav 58.0KB 58.1KB +97B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.6KB 46.7KB +90B 9.5KB 9.5KB 0B
core/SideNavHeading 54.7KB 54.8KB +96B 10.1KB 10.1KB 0B
core/SideNavItem 54.5KB 54.6KB +97B 10.1KB 10.1KB 0B
core/Step 32.4KB 32.5KB +118B 5.3KB 5.3KB 0B
core/Stepper 47.4KB 47.5KB +106B 9.6KB 9.6KB 0B
core/Switch 48.4KB 48.5KB +111B 9.3KB 9.3KB 0B
core/Tab 9.7KB 9.8KB +119B 4.1KB 4.1KB 0B
core/TabMenu 51.6KB 51.7KB +83B 10.0KB 10.0KB 0B
core/Table 84.0KB 84.0KB +71B 15.1KB 15.1KB 0B
core/TableCell 78.8KB 78.9KB +89B 14.8KB 14.8KB 0B
core/TableHeaderCell 78.6KB 78.7KB +92B 14.8KB 14.8KB 0B
core/TableSelectionToolbar 52.4KB 52.5KB +98B 11.2KB 11.2KB 0B
core/TextInput 53.5KB 53.6KB +100B 10.5KB 10.5KB 0B
core/Thumbnail 50.7KB 50.8KB +103B 11.0KB 11.0KB 0B
core/TimeInput 86.8KB 86.9KB +83B 13.8KB 13.8KB 0B
core/Timestamp 27.5KB 27.5KB 0B 9.5KB 9.5KB 0B
core/Toast 59.8KB 59.9KB +105B 9.7KB 9.7KB 0B
core/ToggleButton 44.1KB 44.2KB +91B 9.3KB 9.3KB 0B
core/Token 29.6KB 29.7KB +111B 4.5KB 4.5KB 0B
core/Tokenizer 64.8KB 64.9KB +114B 11.7KB 11.7KB 0B
core/TopNav 51.9KB 52.0KB +104B 10.4KB 10.4KB 0B
core/TopNavHeading 54.2KB 54.3KB +101B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.2KB +116B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.2KB 54.3KB +121B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.2KB +119B 4.1KB 4.1KB 0B
core/TopNavMenu 53.6KB 53.7KB +97B 10.1KB 10.1KB 0B
core/TreeList 32.5KB 32.6KB +111B 4.7KB 4.7KB 0B
core/Typeahead 61.5KB 61.6KB +101B 11.3KB 11.3KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 34.6KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

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

This needs an engineering owner's decision before it can be approved: every change here rests on the draft spec:AST-094, and the new defineTheme({motion}) roles and platform adaptation condition contradict that record's own FR4 and DEC-1 (touch motion is not themable) as well as the current architecture:theme-authoring-contract ("An adaptation change preserves the fixed condition vocabulary") and architecture:theme-tokens INV1. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request once the records state the chosen direction, and the author may do so as an engineering owner. Independently of that decision, a few changes are needed:

  • Land #8045, #8053, #8054, #8257 and #8258 first and rebase, so this pull request carries only its 702-line commit instead of about 15,200 authored lines.
  • Keep the "Touch motion in a theme" docs and the [feat] changeset out until a component reads the motion roles, and make defineTheme reject unknown or wrong-kind role ids without relying on the motion tables being imported first.
  • Drop the spec:AST-090 citation from AST-094, add ## Writing rubric results to the description, make the projection tie test and the press curve's theme-spring test fail without the behavior they cover, and bring the motion/uikit headers in line with this head.

[Automated review]

`${location}: a motion role is named by its table path, such as "sheet.present" or "springs.navigationPush".`,
);
}
const registered = motionRole(id);

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 check runs only for a registered role, and nothing in defineTheme's import graph imports the motion tables, so in a consumer's theme the registry is empty: motion: {durations: {'springs.sheetPresent': 300}} is written as --motion-springs-sheet-present: 300ms, and a misspelt id writes an orphan variable. The refusal test passes only because expandMotionRoles.test.ts imports '../motion/uikit' first. Validating against a role list the theme module owns would make the documented refusal hold for consumers.

Comment thread packages/cli/assets/docs/motion.doc.mjs Outdated
content: [
{
type: 'prose',
text: 'On a touch screen, sheets, alerts, menus, navigation and scrolling play the springs, durations and curves measured from iOS. Each value is a motion role named by its table path, such as `springs.sheetPresent` or `timing.curves.easeInOut`. A theme retunes a role through `motion.springs`, `motion.durations` or `motion.curves`, at its root or in an adaptation rule; a role it leaves alone keeps its measured default.',

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 component plays these springs yet (nothing imports the animator or resolveMotion), so a theme author who follows this section sees no change. AST-094 says consumer docs wait until a component ships its touch presentation; could this section wait too, or say plainly that no component reads the roles yet?

});
});

it('takes a theme’s spring and latency; the onset, the commit and the menu’s moment are not roles', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With pressFeedbackCurve changed to play the table's spring instead of motion.spring, this test still passes: curve.at(0) === 1 and curve.at(0.3) > 1 hold for any spring. Comparing the themed curve with the table's at a fixed time (say 0.2 s) would make it fail without the behavior it names.

@vjeux
vjeux force-pushed the uikit/c20-contextmenu-timeline branch from cae55cf to c325a79 Compare October 25, 2026 04:10
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026

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

This needs an engineering owner's decision before it can be approved: nearly every change here rests on the draft spec:AST-094, and the new defineTheme({motion}) roles contradict that record's own FR4 and DEC-1 as well as the current architecture:theme-tokens INV1. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request once the records state the chosen direction, and the author may do so as an engineering owner. Independently of that decision, a few changes are needed:

  • Land #8076 and the pull requests it merges first, then rebase so this pull request carries only its 712-line ContextMenu commit instead of about 30,900 authored lines.
  • Align AST-094 FR8 with the current design:reduced-motion DR2 and DR4 (or mark it as a proposed amendment of that record), and drop the spec:AST-090 citations.
  • Make the adaptive presentation honor a theme's md as its release note says (or say 768 px), keep the motion/reducedMotion.stylex subpath behind the experimental boundary or give it a real record and release note, and drop the platform axis the theme template still documents.
  • Add ## Writing rubric results to the description, make the projection tie and press-curve theme-spring tests fail without their behavior, register the context-menu table inside pressFeedbackMotion, and run the interaction proofs inside the existing test owners instead of a separately routed job.

[Automated review]

on: a transition that travels becomes a cross-fade carrying the same change
of state. An entrance lands in place and fades in, an exit fades out in
place, and any other move fades through. A navigation push or pop
cross-fades over 0.5 s on the ease-in-out curve; a sheet, a full-screen

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A 0.5 s cross-fade here, and "UIKit's Reduce Motion spring" for menus two lines down, conflict with the current design:reduced-motion DR2 ("opacity only", "200 ms or less" on a coarse pointer) and DR4 (no springs or longer animation under reduced motion), which this paragraph does not name; the head's own Reduce Motion fade is 200 ms (motion/reducedMotion.stylex.ts:24). Could FR8 defer to DR1–DR4, or say it proposes amending that record?

Comment on lines +71 to +72
export const COMPACT_TOUCH_QUERY =
DEFAULT_COMPACT_DEFINITION.coarsePointerQuery;

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 is the default theme's definition, so a theme that overrides md (for example md: 900) still gets the 768 px edge, while the touch-presentation changeset tells consumers the sheet follows "the theme's md point" and spec:AST-012 FR9 says "the effective md point". Either build the query from the active theme's width points (compactDefinition(points) takes them), or have the release note say 768 px.

Comment thread scripts/sync-exports.js Outdated
@@ -177,6 +190,13 @@ const STATIC_EXPORTS = {
types: './dist/utils/interactionOverlay.stylex.d.ts',
default: './dist/utils/interactionOverlay.stylex.js',
},
// Proposed; pending owner review (spec:AST-094 OQ4): the Reduce Motion

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AST-094 at this head has only OQ1 and OQ2, so this justification points at nothing. Since the subpath sits outside any experimental boundary, it becomes stable API on its first release (spec:AST-017 FR21), and the [fix] changeset never names it; should it go behind the experimental boundary until an owner approves it?

element: Element | null | undefined,
): PressFeedbackMotion {
return {
spring: resolveMotion(element, contextMenu.press.feedbackSpring),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

resolveMotion returns the table's value for a role whose table is not registered, and nothing here calls registerContextMenuTable() (the test registers it itself), so a caller gets UIKit's spring whatever the theme sets. Calling the idempotent registerContextMenuTable() at the top of pressFeedbackMotion would make this function's doc hold on its own.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This job routes itself (its own path list includes packages/[^/]+/src/) and is not in the test join's needs, so a failing interaction proof on a core component never fails test. spec:AST-030 FR11 says new coverage inside an existing surface "MUST join those owners and MUST NOT create a separately routed lane", and FR7 says "Any owned lane failure MUST fail its join". Could the proofs run under the existing test owner, or the job join test?

Comment thread packages/cli/assets/theme.template.ts Outdated
Comment on lines +361 to +362
* are primary-pointer precision, contrast preference, motion preference, and
* platform (`ios` for the browsers of iPhone and iPad, `other` for the rest).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head defineTheme has no platform condition (CONDITION_AXES in packages/core/src/theme/themeAdaptations.ts is width, pointer, contrast and motion, and an unknown when key is refused), so a theme author who follows this line written by astryx init --features theme gets an error. Drop the platform clause?

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

This needs an engineering owner's decision before it can be approved: the ContextMenu motion roles and the defineTheme({motion: {springs, durations, curves}}) field they rest on have no current spec now that spec:AST-094 is no longer on this branch (it is proposed in #8378), and the field's --motion-<role> tokens contradict architecture:theme-tokens INV1. An engineering owner needs to provide the governing record on main (for example AST-094 made current, with INV1 amended to admit motion-role tokens), identify another current record that covers these additions, or direct that the roles come out; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently of that decision, a few changes are needed:

  • Land #8076 and the pull requests it merges first, then rebase so this pull request carries only its 712-line ContextMenu commit instead of about 30,200 authored lines.
  • Run the interaction proofs inside the existing UI test owner so a failing proof fails test, as spec:AST-030 FR11 and FR7 require.
  • Give the new astryxStylex undefinedVariables option a [feat] changeset, rewrite the reduced-motion changeset as an internal note, and describe the adaptive presentation behavior change in the description.
  • Register the context-menu table inside pressFeedbackMotion, start the visible curve from the themed latency, and make the press-curve theme test and the projection tie test fail without their behavior.

[Automated review]

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This job decides its own scope from its own path list (line 3850 includes packages/[^/]+/src/ and apps/storybook/) and is not in the test join's needs, so a failing interaction proof on a core component never fails test. spec:AST-030 FR11 says new coverage inside an existing surface "MUST join those owners and MUST NOT create a separately routed lane", and FR7 says "Any owned lane failure MUST fail its join". Running the proofs as steps of the existing UI test owner, scoped by check-scope, would satisfy both.

element: Element | null | undefined,
): PressFeedbackMotion {
return {
spring: resolveMotion(element, contextMenu.press.feedbackSpring),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

resolveMotion returns the table's value for a table nothing has registered, and nothing in this file calls registerContextMenuTable() (the test registers it itself), so a caller that follows this function's doc gets UIKit's spring whatever the theme sets. Since registration is idempotent, calling it here would make the documented contract hold.

}

/** When the visible curve starts, ms after the finger landed. */
export const PRESS_FEEDBACK_START_MS = feedbackOnsetMs + feedbackLatencyMs;

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 start is the table's onset plus the table's latency, and pressFeedbackCurve never reads motion.latencyMs, so a theme that retunes contextMenu.press.feedbackLatencyMs changes what pressFeedbackMotion reports but not when the copy starts moving. Deriving the start from the motion passed in would let the themed latency take effect.

Comment on lines +214 to +216
const curve = pressFeedbackCurve({motion});
expect(curve.at(0)).toBe(1);
expect(curve.at(0.3)).toBeGreaterThan(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These two assertions also hold if pressFeedbackCurve ignores motion.spring and plays the table's spring, so the test cannot fail when the theme stops driving the curve. Comparing the themed curve with pressFeedbackCurve() at a fixed time (for example 0.3 s) would make it fail without the behavior.


it('breaks an exact tie toward the direction of travel', () => {
expect(
selectSnapPoint([0, 100], {position: 50, velocity: 0.0001, rate: 0.5})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At ±0.0001 points per second the projection lands 1e-7 past 50, more than the 1e-9 tie tolerance in selectSnapPoint, so the nearer point wins and the tie branch never runs: removing || (isTie && isAhead) leaves this test green. A velocity whose offset falls inside the tolerance, such as ±1e-30, would exercise the tie.

Comment thread .changeset/reduced-motion-record.md Outdated
'@astryxdesign/core': patch
---

[fix] The experimental touch animator follows design:reduced-motion. Under Reduce Motion every `animate` and `play` (travel or opacity, on a spring or any curve) becomes an opacity-only cross-fade of 200 ms on a coarse primary pointer, or for a call marked `touchRelease`, and completes at once on any other pointer; nothing moves, scales or springs, and travel lands with a plain style write while the element is transparent, never as an animated transform. `timing.reducedMotionCrossFadeMs` is now 200 ms and is no longer a theme role. The `reducedMotion` and `crossfade` call options are removed; `crossfadeAs` stays. `animator.reducedMotion()` reports the mode for feedback with no fade of its own, `reducedMotionFade()` gives the same fade to code beside the animator, and `reducedMotion.stylex.ts` holds its constants for StyleX transitions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head the animator is internal and has never been released or marked experimental, so this [fix] would tell readers of the changelog about an "experimental touch animator" and removed reducedMotion and crossfade options that never shipped. An internal [chore] note, like the other touch changesets, would describe the change.

'@astryxdesign/build': patch
---

[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. A constant defined on the library side of the layer split and read on the product side stayed unresolved in either form. A production build now also warns, naming the variable and the rule that reads it, when its stylesheet reads a StyleX-generated custom property that nothing declares; set `undefinedVariables: 'error'` to fail the build instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Besides the defineConsts fix, this adds the undefinedVariables option, a warning every production build now runs, and the undefinedGeneratedVariables export. spec:AST-017 FR7 puts compatible capabilities under [feat], so the option and the warning would read correctly as their own [feat] changeset.

…y pointer is coarse and a recognizer is enabled

From CodeBunny on meta#8073 (spec:AST-094 FR19), fixed once here: the host
wrote its managed touch-action on every device. It now writes it only
while the primary pointer is coarse and an attached recognizer is
enabled, and restores the element's own value when either stops being
true; with the default coarse-pointer read it follows the media query's
change too. The test observes no touch-action write under a fine pointer,
the write once coarse, its removal while the recognizer is disabled and
again when the pointer turns fine.
cixzhang and others added 27 commits October 26, 2026 15:53
Inside a trace, waitForAnimation and watch.started() read the element through
handles, which find no element rather than wait for one, so a wait on an
element the page removed rejected with "no animation started on the
element" instead of "the element is not in the document", unlike the
untraced path. At its deadline, a traced wait with no element now rejects
as missing. Traced twin of the missing-element self-test; it fails on the
previous code.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…med or not

The planner's comment on the cross-fade's length said it is not a theme
role and no theme lengthens it. Under spec:AST-094 DEC-11 no motion role is
themeable: the fade's length is no role at all, and nothing lengthens it.
A promotion keeps the stylesheet's will-change reasons beside its own, and
read them with getComputedStyle as it started: in a drag's first move, that
forces a style recalculation inside the input handler. The stylesheet's
value is now read in a task of its own after the element is attached, and
again after each demotion, so a promotion uses what was read. One that
comes before that read (an element attached in this task that moves at
once) still reads it then. A test that counts style reads in the promoting
task fails on the previous code; one for the element that moves at once
passes on both.
… edges only in their edge zone, an off-zone edge pan decides at 10 px) into the experimental touch layer
…rred unmount runs

It read will-change right after attach(null), before the unmount's
microtask, so it passed while that microtask could still promote the
element and leave timers behind. It now awaits the microtask, then checks
will-change and that no timer is left. It fails on meta#8521's earlier head
(97436b2), before the unmount cleared what the landing rebuilt.
…ith one passive listener and press-time document listeners

family:action-groups FR5 (and the AST-094 FR19/FR24 exception proposed with
it): the behavior applies to every pointer, mouse and pen included, so a
group must hear a press where the primary pointer is fine, where the host
otherwise registers nothing. attach() takes { actionGroup: true }: under a
fine pointer the group element gets one passive pointerdown listener; a
press on it adds document pointermove, pointerup and pointercancel
listeners (and window blur, plus the click listener for the click the lift
owes), and they go at the release. Only the group's own recognizers and
consumer take that press. Under a coarse pointer the host's usual listeners
serve the group, as every other recognizer.

Tests (failing first): idle under a fine pointer, only the group's one
passive pointerdown and nothing for a recognizer that is not a group; a
press adds the document listeners and the release removes them; a
recognizer that is not a group on the press path takes no part; detach
removes the group listener, and a coarse pointer keeps the usual
listeners.
meta#8056 landed squashed; main's motion README, animator and its tests equal
meta#8056's final head (2b264d3), so this PR's versions, built on that head,
stand.
…ond route around ./internal/*

Lab's per-module tsconfig paths (core's motion, gestures and
touchPresentation) and the Sandbox/docsite resolver that served them opened
a route around architecture:react-component-runtime INV13's ./internal/*
subpath exports. Nothing in Lab imports those paths here; they're dropped,
and the import guard now fails on any Lab import core doesn't export and on
any Lab tsconfig alias for core.

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
 landed) into the experimental touch layer

# Conflicts:
#	packages/core/src/motion/README.md
… detaches

The deferred unmount demotes the detached node, and demote asks for a fresh
will-change read; the detached node now gets none, so no timer outlives the
element (architecture:react-component-runtime INV5).

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
… that finger until it lifts (spec:AST-094 FR14)

A recognition that claimed its touches and was then cancelled gave them
back to the browser unless another recognition owned them, so a finger
resting there could pinch-zoom the page. A gesture that cancels itself on
a finger's move, a long press dragged past its tolerance after it began,
gave back a finger that was dragging: the host stopped cancelling its moves
and the page scrolled the rest of the drag, a hand change FR14 forbids and
UIKit never makes (its scroll view never takes a touch another recognizer
had). A cancel during a finger's move now keeps that finger claimed until
it lifts; a resting finger is given back as before, and a waiting
recognition that fails still gives its claim back.

The new arena case fails on the previous code: the dragging finger reads
unclaimed right after the cancel.
…edge-zone strip decides at UIKit's 15 px

The host's arena environment had no viewSize, so under the real host a
screen-edge pan never saw its edge zone while a touch was down and decided
at 10 px, while its attach-time touch-action (read through the default
environment) had already claimed the axis (spec:AST-094 FR18).

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
…fter layout rounding

Lane 0's real-host browser spec found a strip authored at the 40.2 px edge
region lays out at 40.203 px (layout snaps to 1/64 px), so the strict
comparison read it as off the zone and the pan decided at 10 px instead of
UIKit's 15 (spec:AST-094 FR18). The zone takes half a pixel of tolerance:
the rounded strip is the zone, a view a pixel wider still is not.
Failing-first test at 40.203 px: it claims pan-x and decides at 15.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
Main's meta#8521 is its head 25b468c squashed, which this pull request already
carries: every file it touches is byte-identical to 25b468c. In the two
files that conflicted (the motion README and animator.ts), the resolution is
this side's version, main's merged meta#8521 with this pull request's later work
on top. Nothing of main is dropped.
An attach reads the element's own will-change in a task of its own. A
shared animator (animatorFor) is never detached, so its node can leave the
document with that read still waiting, and the read then forces a style
pass on a node with no style: a test run that mounts and unmounts many
pagers piled up such reads until a later wait timed out. The read now
skips a node out of the document; a promotion that needs it reads it then,
as one on a node attached in the same task does. A detach and dispose
already cancel it.

The new test fails on the previous code (one read of a removed node); the
in-task read test now puts its element in the document, where the read
happens.
…(spec:AST-094 FR18, DEC-13)

A screen-edge pan attached to a stack container, as wide as the screen,
with a touch 4 px from its edge: inside the 40.2 px band the pan decides at
UIKit's 15 px whatever the element's width, and then tracks the travel past
it. From several edges too. Both fail while a wider element decides at
FR19's 10 px.
…'s width (spec:AST-094 FR18, DEC-13)

FR18: inside the 40.2 px band a screen-edge pan decides at UIKit's 15 px
screen-edge hysteresis; outside it the author's scrolling is untouched and
FR19's 10 px governs every other claim. The pan decided at 10 px on any
element wider than the band, so NavigationStack's edge pan, attached to the
stack container, began 5 px early. The edge zone still decides only the
touch-action; a touch outside every band still fails at once. The two
cases that pinned the 10 px decision go.
… 15 px whatever the element's width) into the experimental touch exports
…er's move keeps that finger until it lifts) into the experimental touch exports
…than the band (spec:AST-094 FR18, DEC-22)

FR18 as amended in meta#8631 (DEC-22): the band's pan-x claim and 15 px start
hold only where the screen-edge pan is attached to an element within the
band; on an element that reaches beyond it, whose touch-action cannot
claim per touch, a touch in the band decides at FR19's 10 px like every
other claim. This restores the 10 px decision 3d85c30 removed and keeps
15 px on an element within the band. The cases pin DEC-22 both ways (a wide
element with a touch at x=4 begins at 10, an in-band strip at 15, and from
several edges at 10 on a wide element); the 10 px cases fail on 3d85c30.
…an the edge band decides at 10 px again, DEC-22) into the experimental touch exports
…xperimental touch exports

The motion README's Files table is the union: main's rows, with meta#8663's
resolveSnapPoint.ts and selectPage.ts, plus this branch's, in main's
order.

# Conflicts:
#	packages/core/src/motion/README.md
…imeline

Where meta#8076 dropped the theme's motion-role table (roleKinds.generated.ts)
and its theme section in the motion doc, this branch takes meta#8076's files:
touch motion stays internal, so the press constants are no theme roles.
…g internal

meta#8076 keeps touch motion internal, so a theme cannot retune the press
feedback's spring or latency (spec:AST-094 FR4, DEC-11): the curve reads the
table's values, the test checks that nothing comes from a theme, and the
changeset and the table no longer say a theme can retune them. The CLI's
motion doc no longer lists the values, so the changeset names only core.

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

Changes requested. The ContextMenu timeline and press curve are well sourced and fit UIKit's measured frames, but this pull request's diff against main still carries the open #8076 stack: 16,482 authored lines beside its own 688. Please land #8076 first (or retarget onto its branch) and rebase so only the six ContextMenu files remain; two issues in that stack should be fixed there before it lands:

  • useGestureRecognizer forwards a mid-gesture cancel to onGesture from an Effect when its element unmounts or is replaced, unlike usePressTracker, which guards the same release.
  • .changeset/gesture-press-tracking.md says every press painter writes through one writer, but actionGroupTracker still writes data-astryx-press directly.

[Automated review]

Comment on lines +229 to +243
useIsomorphicLayoutEffect(() => {
const element = ref.current;
const current = attachedRef.current;
if (
current != null &&
current.element === element &&
current.deps.length === deps.length &&
current.deps.every((dep, index) => Object.is(dep, deps[index]))
) {
return;
}
current?.detach?.();
attachedRef.current =
element == null ? null : {element, deps, detach: attach(element)};
});

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 detach runs inside a layout Effect (and again in the unmount cleanup below), and GestureHost's detach cancels a recognizer that is mid-gesture. cancelled is an action state, so the listener subscribed above forwards it to onGesture, because isSyncingRef is only set around setEnabled. A component that unmounts or swaps its node while a finger is down then gets its callback from React's commit, which architecture:react-component-runtime INV2 rules out and which usePressTracker in this file already prevents. Holding the guard around both detach calls, plus a mid-gesture unmount and node-swap test, would close it.

'@astryxdesign/core': patch
---

[chore] Internal: UIControl press tracking with control and cell profiles and the gesture hooks, with every press painter writing `data-astryx-press` through one internal writer in `utils/pressFeedback.ts`. Not exported from the package entry point.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hooks/actionGroupTracker.ts (lines 252–272 and 441–442) still sets and clears data-astryx-press directly with its own fade timer, and paintPress has no caller outside its test, so "every press painter … through one internal writer" is not true at this head. Either move the tracker onto paintPress or drop the claim here and in pressFeedback.ts's header.

}

/** When the visible curve starts, ms after the finger landed. */
export const PRESS_FEEDBACK_START_MS = feedbackOnsetMs + feedbackLatencyMs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pressFeedbackCurve reads motion.spring and motion.onsetMs but never motion.latencyMs, so the curve's start is always this table-derived constant, whatever pressFeedbackMotion returns. That is harmless while every value is the table's, but the description's "the curve takes them" holds for two of the three; deriving the start from the motion passed in, or dropping latencyMs from PressFeedbackMotion, would make the contract say what it does.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:30

This branch was successfully deployed

1 active deployment
Preview — db7e625c Deployed Oct 27, 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. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants