From 6fd240b59decfc8209309641439e9fb747f67707 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 22:56:20 -0700 Subject: [PATCH 1/4] spec(AST-094): touch presentations feel native by default Touch presentations get one native-feeling behavior by default on every touch device: measured values, compositor motion a finger can catch, recognizer-tracked gestures, two press profiles, a Reduce Motion cross-fade (design:reduced-motion), menus and popovers under a finger (FR26-FR35), and one release decision for every drag that commits (FR36-FR40). Touch motion is themeable through purpose-named roles; thresholds stay internal. useMenuPress, DropdownMenu, ContextMenu and Popover point to the new requirements. Retries #8316, whose review rounds were spent, carrying its last revision plus the review fixes: the record is current with its deciders named, FR34 follows DropdownMenu's drill-in (a parent-visible sub-menu is OQ10), the role list covers row swipes, Popover and sub-menus with purpose-led names, FR2 admits an owner decision as a source, and the quick-tap and longPressDelayMs wording is settled. Co-authored-by: vjeux <197597+vjeux@users.noreply.github.com> --- .../specs/AST-094-touch-presentations/spec.md | 531 ++++++++++++++++++ .../core/src/ContextMenu/ContextMenu.spec.md | 22 +- .../src/DropdownMenu/DropdownMenu.spec.md | 4 +- .../DropdownMenu/modules/useMenuPress.spec.md | 36 +- packages/core/src/Popover/Popover.spec.md | 2 + 5 files changed, 564 insertions(+), 31 deletions(-) create mode 100644 docs/specs/AST-094-touch-presentations/spec.md diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md new file mode 100644 index 0000000000000..a06401b2f16e8 --- /dev/null +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -0,0 +1,531 @@ +--- +schema_version: 4 +template_version: 2 +kind: system-spec +id: spec:AST-094 +authority: current +archive_reason: null +superseded_by: null +approved_by: cixzhang +approved_at: 2026-10-24 +phase: accepted +owners: [vjeux] +affects_architecture: + [ + architecture:interaction-modality, + architecture:theme-tokens, + architecture:theme-authoring-contract, + ] +affects_families: [family:buttons] +affects_contributing: [] +affects_consumer_docs: [] +--- + +# Touch presentations feel native system spec + +## Intent + +A person using an Astryx product on a phone or tablet gets controls, sheets, +menus, lists and navigation with native-feeling touch behavior: measured +physics, timings and gesture thresholds instead of desktop motion stretched to +a finger. This behavior is the default on every touch device, iOS and Android +alike, with no opt-in, and its motion keeps running at display rate while the +page's JavaScript is busy. + +The behavior comes from one measured table. Its values are measured from UIKit +on a named reference release, UIKit on iOS 26.2 today; each number names the +release it was taken from, moving the reference to a newer release re-measures +the table, and differences on older releases are noted, not targeted. Android +touch devices get the same behavior. How touch behavior differs between the +platforms is being explored, and the table records a separate value only where +a measured, sourced difference exists. + +## Non-goals + +- Pointer (desktop) presentations. Their timing and easing stay with the + theme's duration and easing tokens (`architecture:theme-tokens`). +- Platform-matched look and feel: glass versus material surfaces, glyphs and + chrome. This record governs behavior only. +- WebKit embedded in a native app and the native Swift package. This record + governs the web components themselves. +- Scroll physics. Scrolling stays native on every platform: on iOS an overflow + area or the page is a UIKit scroll view already. +- Which components get a touch presentation, and when. Each component's own + record owns its presentation. +- Equivalent internal implementations remain valid when they satisfy this + contract. The modules, files and algorithms of the motion and gesture + foundations belong to their architecture records. + +## Requirements + +### Physics + +- **FR1 — Measured values, the same everywhere.** Every spring, duration, + deceleration rate, rubber-band coefficient, gesture threshold and haptic + moment a touch presentation uses MUST default to the measured native value + for that use (FR2), and every component that plays the same motion or + gesture MUST use the same value; a theme MAY override a motion role (FR4). + No component uses a value of its own where a measured value exists. The same + values apply on every touch platform; a platform gets a value of its own only + where a measured, sourced difference exists (FR2). +- **FR2 — Every value has a source.** Each measured value MUST name its source next to it: Apple's documentation, a WWDC session, WebKit's own + source where WebKit states it matches UIKit, or a measurement of UIKit on + the iOS simulator naming the app, the iOS version, the method and the date. + A value without one of these MUST be labelled a guess and name the + measurement that will replace it. A decompilation of UIKit MAY aim a + measurement and MUST NOT be a source. An owner decision MAY set a value + that departs from a measurement; it names its date and the measurement it + departs from. + + The values this record names for menus under a finger (FR29): + + | Value | Default | Source | + | ---------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | Open delay | none in a fixed bar; 150 ms with no scroll inside a scroll view (the content-touch delay, FR11) | owner decision, 2026-10-24, departing from UIKit on the iOS 26.2 simulator (iPhone 17 Pro), 2026-10-23, which opened a held finger's menu 150–153 ms after touch-down in a fixed position and about 303 ms inside a scroll view | + | Handoff distance | 10 pt (CSS px), straight-line, from the first position the open menu sees | measured: same setup; moves of (0, 9) and (7, 7) picked nothing, and (0, 10), (10, 0) and (9, 9) picked | + | Drift allowance | 13 pt (CSS px) of movement on the trigger before the open | unmeasured, needs a measurement or justification: a 12 pt move still opened the menu and a 14 pt move did not (OQ5) | + +- **FR3 — Native physics.** Springs MUST follow UIKit's spring model + (response and damping ratio, mass, stiffness and damping, and the release + velocity), with overdamping clamped to critical damping wherever UIKit + clamps it. A momentum coast MUST decay like a UIKit scroll view (0.998 per + millisecond, 0.99 for the fast rate), a drag past an edge MUST resist with + UIKit's rubber band `(1 − 1 ÷ (x·c ÷ d + 1))·d` with the coefficient its use + measures (0.55 for a scroll view and a sheet), and a release MUST pick its + rest point by projecting its velocity the way its UIKit counterpart does: + with a deceleration rate, or with a measured projection time. +- **FR4 — Touch motion is themeable.** Every spring, duration and curve a + touch presentation plays MUST be one of the theme motion roles below, each + named for its use (`architecture:theme-tokens` INV3). The role's default value is + its measured value (FR1, FR2), and a theme MAY override it through `motion`, at its root or + in an adaptation rule. A component MUST read touch motion through these roles + and MUST NOT play a literal. A theme value MUST reach the motion at its + start, without changing how the motion runs (FR5, FR6). Gesture thresholds, the timings that decide who gets a touch (the + content-touch delay, the scroll brake, momentum memory) and haptics are not + motion: they stay internal measured values and are not public concepts (`spec:AST-002` FR3). This adds touch motion roles to the motion + configuration of `architecture:theme-authoring-contract` and to + `architecture:theme-tokens` beside the existing duration and easing tokens, whose rules are unchanged. It + amends `spec:AST-057` for touch presentations: Item's swipe springs and + durations become theme motion roles instead of constants in source, and its + swipe travel is a transform the recognizer writes (FR9) instead of a custom + property on the root (its FR2). It departs from the draft `design:motion` DR3 + wherever the native motion settles with an overshoot or stretches with a + rubber band. The motion and gesture machinery behind these roles is internal: + it publishes no public API and no module-level `/experimental` subpath + (`spec:AST-017` FR21). + + | Kind | Roles | + | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | Spring | `springs.sheetPresent`, `springs.sheetDismiss`, `springs.sheetDismissSwipe`, `springs.sheetDetent`, `springs.sheetReleaseFast`, `springs.actionSheetPresent`, `springs.actionSheetDismiss`, `springs.alertPresent`, `springs.alertDismiss`, `springs.popoverPresent`, `springs.popoverDismiss`, `springs.contextMenuLift`, `springs.contextMenuDismiss`, `springs.menuOpen`, `springs.menuClose`, `springs.subMenuOpen`, `springs.subMenuBack`, `springs.rowSwipeOpen`, `springs.rowSwipeReturn`, `springs.rowSwipeCommit`, `springs.banner`, `springs.keyboard`, `springs.navigationPush`, `springs.navigationPop`, `springs.navigationInteractivePopFinish`, `springs.navigationBarElements`, `springs.segmentedThumb`, `springs.switchThumbMove`, `springs.switchThumbStretch`, `springs.interactive`, `springs.bouncy`, `springs.smooth`, `springs.snappy`, `springs.standard`, `springs.timed`, `springs.stateChange`, `springs.responsive`, `scroll.paging.snap` | + | Duration | `timing.press.highlightInMs`, `timing.press.highlightOutMs`, `timing.press.buttonReleaseMs`, `timing.press.flashMs`, `timing.implicitAnimationMs`, `timing.keyboardNotificationMs`, `timing.navigationBarHideShowMs`, `navigation.transitionMs` | + | Curve | `timing.curves.standard`, `timing.curves.easeIn`, `timing.curves.easeOut`, `timing.curves.easeInOut` | + +### Motion + +- **FR5 — Motion runs on the compositor.** Every release and every + programmatic animation of a touch presentation MUST animate `transform`, the + individual `translate`, `scale` and `rotate` properties, or `opacity` + (a dialog's `::backdrop` included) through Web Animations that the browser + runs off the main thread, + and MUST keep advancing at display rate through a main-thread block. It MUST + NOT animate layout properties, and MUST NOT step a frame from script when the + compositor can run the curve. +- **FR6 — Motion is interruptible.** A finger MUST be able to catch any + animation mid-flight at the value on screen. That value and its velocity + MUST come from the curve, not from a layout read. A new target MUST keep the + current velocity, and a release MUST hand its velocity to the spring that + follows. +- **FR7 — Exits animate as the native counterpart's do.** A touch + presentation MUST play the exit its UIKit counterpart plays, on every touch + platform. FR8 governs their reduced-motion form. Each overlay's own record states its + exit, since members own their animation (`family:overlay-dismissal` AV6). +- **FR8 — Reduce Motion.** Under reduced motion, touch presentations follow + `design:reduced-motion` DR2 and DR4. + +### Gestures + +- **FR9 — A finger is tracked off the render path.** A drag MUST follow the + finger with one visual update per move, with no component render per move + and no layout read after the gesture begins. + Native scrolling MUST be used only where the component is a scroll view + (lists, scroll areas, the page). A component MAY track a finger with native + scroll snapping only with simulator evidence that it tracks through a + main-thread block, ends on the native release curve, ends correctly when the + finger lifts during a block, and lets a finger catch the content mid-flight. +- **FR10 — A second finger never breaks the first.** A second finger MUST NOT + cancel or redirect a drag a recognizer has claimed. Page pinch-zoom MUST stay + the browser's whenever no pinch recognizer claims the fingers and no drag is + claimed (WCAG 1.4.4). A menu's press-tracking slide keeps + `module:DropdownMenu/useMenuPress` FR4: a second pointer or `pointercancel` + ends its gesture with nothing acting, which keeps the page zoomable over an + open menu. +- **FR11 — Two press profiles.** Press feedback MUST follow one of two native + profiles. A control (UIControl) highlights on touch-down outside scroll + views, keeps tracking within its expanded bounds (70 pt), turns its highlight + off and on as the finger drags out and back, and activates on a lift inside. + A cell (a table or collection cell) highlights 150 ms after touch-down inside + a scroll view, drops the highlight when the scroll wins, and activates on a + lift inside it with no scroll. A control is inside a scroll view when it sits in a scrollable container, + or in scrolling page content with no fixed ancestor; a control in a fixed + bar (a top bar, a tab bar, a toolbar) is not. A control inside a scroll view + takes the same 150 ms delay before its highlight and then follows its + control profile; the 150 ms delay applies only inside scroll views. A control whose own record keeps its press through a drag + keeps it: Slider's overlay stays on the dragged thumb (`design:user-states` + DR4). On every touch platform this amends + `design:user-states` DR1 (the pressed overlay during a hold) and DR3 (the + selected SegmentedControl item's held surface) for touch presentations, the press treatment of `family:buttons`, and the last sentence of + `module:DropdownMenu/useMenuPress` FR6 (menu rows paint `:active` only under + `(hover: hover)`): under a finger, menu rows take the cell profile. FR6's + rule that at most one row of a menu is lit still holds, so a finger that + slides from one row to another lights only the row under it. +- **FR12 — Long press and rubber band are per use.** A long press MUST use the + thresholds its UIKit counterpart uses: 0.5 s and 10 pt for a plain long + press, the measured values for context menus and menus. A rubber band MUST + use the coefficient its use measures. +- **FR13 — One rule for the touch presentation.** A touch presentation + applies under a coarse primary pointer, and only after hydration: a server + render and the hydrating render produce the pointer presentation + (`spec:AST-013` FR9). Where a presentation also depends on size, compact is + governed by `spec:AST-012` FR9 and `architecture:public-component-api` + INV17. A presentation asks for exactly the conditions its own record names + (the date and time sheet asks for the pointer only, `spec:AST-043` FR1). +- **FR14 — Nested scrolling follows the native rule.** Who gets a drag that + starts inside a scroll area MUST be decided once, when the drag begins. If + the area sits at its edge and the drag moves toward that edge, the + surrounding surface (a sheet) takes the drag; otherwise the area keeps it for + the whole gesture and rubber-bands at its edge. The drag MUST NOT change + hands midway, in either direction. This amends `spec:AST-025` FR14 (scroll + chaining by default) for touch presentations on every touch platform. +- **FR15 — Recognizers arbitrate like UIKit's.** Recognizers that see the + same touch sequence MUST be exclusive by default: the first to recognize + wins and the others fail, unless both allow simultaneous recognition. A + recognizer that requires another to fail MUST hold its recognition until + that one fails, and MUST fail if that one recognizes. When several recognize + on the same event, the innermost element's recognizer MUST win. +- **FR16 — Gestures keep the event clock.** Recognizers MUST read time from the + input events' timestamps. Durations (a long press, the interval between + taps) MUST be deadlines on that clock, so a blocked main thread delays the + report and never the moment reported. +- **FR17 — Native readings.** A recognizer's thresholds MUST follow UIKit's + measured values (FR1, FR2). A pan begins at 10 pt of travel on either axis, + and its translation excludes that travel. Release velocity is governed by + `spec:AST-100`. +- **FR18 — `touch-action` leaves the browser the rest.** An element with touch + recognizers MUST get a `touch-action` that leaves the browser exactly what + its recognizers do not use: the other axis for an axis-limited pan or swipe, + `manipulation` for taps and long presses, everything for a one-way pan or a + pan that shares its axis with native scrolling. `pinch-zoom` MUST stay + unless a pinch or rotation recognizer is attached. +- **FR19 — Claims are made in time and only by an owner.** Recognizers attach + only under a coarse pointer; on a fine pointer no recognizer attaches and no + touch listener is registered (FR24). A recognizer that begins MUST claim its + touches by cancelling `touchmove` through a non-passive listener on its + element. A `touchmove` of a touch no recognizer owns MUST NOT be cancelled. A + recognizer that competes with native scrolling MUST decide within 10 px of + travel, before the browser commits to the scroll. Only an element with a + recognizer that must claim mid-gesture registers that blocking listener; an + element whose recognizers decide only at the lift keeps its `touchmove` + listener passive, and its `touchend` listener stays cancelable so the + gesture can cancel the click (FR21). Known limit: a native scroll that starts on an element with a + blocking listener waits for a free main thread in iOS Safari and Chromium. + That is never worse than UIKit, whose scrolling stops on a busy main thread, + and a passive listener, or one added at `touchstart`, would hand diagonal + drags to the scroll. +- **FR20 — The browser can take a touch.** `pointercancel`, an uncancelable + `touchmove`, or a native scroll under the touch MUST count as the browser + taking the touch: possible recognizers that share it fail, active ones are + cancelled, and the view's press is cancelled without a release. +- **FR21 — Clicks follow the gesture.** When a gesture takes a touch, its + `touchend` MUST be cancelled, and no click or compatibility mouse event + reaches the page. A click held for a recognizer that may still recognize (a + tap that a double tap may claim) MUST be replayed on its target if that + recognizer fails. Activation with `detail === 0` (keyboard, assistive + technology) MUST always pass. A tap MUST NOT wait for the browser's + double-tap delay. +- **FR22 — The platform keeps its edge gestures.** iOS Safari's back swipe + from the screen edge and Android's system back gesture MUST stay the + browser's and the system's; a touch presentation never overrides them. + +### Menus and popovers under a finger + +FR26–FR30 and FR34 apply to a menu that carries the press model of +`module:DropdownMenu/useMenuPress` (DropdownMenu, ContextMenu and their +sub-menus) when a finger or pen drives it; FR31–FR33 and FR35 apply to anchored +menus and to Popover under a finger. A mouse keeps today's behavior. + +- **FR26 — The lift decides.** A menu row MUST act when the finger lifts over + it, never because the press began on it; a press that never left its row is + a tap and acts on that row. One gesture acts at most once: the browser's + click for that gesture acts on nothing. +- **FR27 — The highlight follows the finger.** While a finger is held in an + open menu, the lit row MUST be the enabled row under it, and nothing is lit + over a divider, a heading, a disabled row or outside the menu. At most one + row is lit; the row the press began on keeps no pressed look once the + finger leaves it (FR11). +- **FR28 — A lift outside keeps the menu open.** A finger that began inside + a menu and lifts outside it MUST act on nothing and leave the menu open. A + cancelled touch, a second finger, or the window losing focus ends the + gesture: nothing acts and the lit row clears. +- **FR29 — Opening on press, and the opening finger's handoff.** Under a + finger, a menu's trigger in a fixed bar MUST open its menu as the finger + presses. A trigger inside a scroll view (FR11) MUST open it once the 150 ms + content-touch delay has passed with no scroll, or at the lift of a tap that + ends sooner; a scroll that starts first takes the touch and nothing opens. Before the open, the finger may move no + more than the drift allowance (FR2). After an open under a held finger, + nothing is lit and a lift acts on nothing until the finger has travelled + the handoff distance from the first position the open menu sees; from then + on FR26–FR28 apply. `presentation="popover"` keeps today's open on a tap; + the default is unset (`architecture:public-component-api` INV16). The delay and distances are internal values and gain no new props + (`spec:AST-002` FR3); `module:DropdownMenu/useMenuPress`'s released `longPressDelayMs` sets + the scroll-view wait for that hook's callers, and keeps its own lifecycle + (`spec:AST-017` FR3). +- **FR30 — Edge autoscroll under a finger.** While a finger is held in a menu + that scrolls, resting near its top or bottom edge MUST scroll the menu + toward that edge, and the lit row MUST follow the row now under the finger. + The scrolling stops when the finger leaves the edge, the menu reaches its + end, or the gesture ends. +- **FR31 — Menus and popovers grow from their trigger and shrink back.** + Under a finger, on every touch device, an anchored menu or a Popover MUST + open by growing out of its trigger and close by shrinking back into it, on + its theme motion roles (FR4; a menu on `springs.menuOpen` and + `springs.menuClose`). The shrink plays in full after the surface has + closed. This is its exit under FR7 (`family:overlay-dismissal` AV6); under + reduced motion FR8 applies. A menu or popover presented as a bottom sheet + follows the sheet instead. +- **FR32 — A pan outside dismisses.** Under a finger, an open anchored menu, or a Popover whose outside + dismissal is on, MUST close as a touch outside it begins to pan, and at the + lift of a tap or hold outside it. A Popover whose caller turned outside + dismissal off stays open. A pinch stays available over it: its surface + never takes `touch-action: none`, so the page keeps zooming (FR10, FR18; + WCAG 2.2 SC 1.4.4). Listening for that outside touch costs nothing once it + ends (FR24). +- **FR33 — A ContextMenu under a finger.** With a finger or pen, the long + press that opens a ContextMenu MUST NOT close it at the lift, and a press + outside it follows FR32. On every touch device, a ContextMenu that a held + finger opens on a block trigger MUST lift the trigger above the page and + show the menu beside it, with no row lit. A tap outside closes the menu and + reaches nothing beneath it, and closing returns the trigger to its place. + Under reduced motion FR8 applies. An inline trigger keeps today's menu, and + a menu presented as a bottom sheet follows the sheet. +- **FR34 — A sub-menu drills in under a finger.** Under a finger, a sub-menu + that drills in follows `component:DropdownMenu` FR11 and FR20: its rows and + a "Back to " row replace the menu's rows in the same surface. The + change between levels plays on the theme motion roles `springs.subMenuOpen` + and `springs.subMenuBack` (FR4); under reduced motion FR8 applies. +- **FR35 — A presentation takes no input while it grows.** Under a finger, a + tap or a drag that lands while a Popover or an anchored menu is still + growing out of its trigger MUST NOT close it, scroll the page, move focus, + or reach anything beneath it; the presentation finishes and stays open. A + pinch still zooms the page (FR10). A mouse keeps today's behavior. + +### Release decisions + +- **FR36 — One decision, at the release.** A drag that can commit an action + MUST decide once, when the finger lifts: if its projected travel (the + travel so far plus the release velocity times the use's projection, FR3) + reaches the use's commit point, the action commits; otherwise the surface + springs back. The decision MUST NOT change while the surface settles. The + same rule backs dismissing a BottomSheet, a Toast or a Lightbox, popping a + NavigationStack, completing a row swipe, and dismissing the keyboard; each + use's commit point and projection are measured values (FR1, FR2). +- **FR37 — Moving back cancels.** A release whose velocity points back + toward the start MUST cancel, whatever the travel so far. +- **FR38 — The settle continues the finger.** The commit or the spring-back + MUST start at the release velocity (FR6). Under reduced motion it follows + `design:reduced-motion` DR2. +- **FR39 — The action never needs a drag.** Every action a drag commits MUST + also be reachable without a drag, by keyboard and by mouse + (`architecture:interaction-modality` INV4). +- **FR40 — Release velocity.** The velocity FR36–FR38 read is governed by + `spec:AST-100`. + +### Haptics + +- **FR23 — Haptics where the native counterpart plays them.** A touch + presentation MUST play a haptic only at the moments its UIKit counterpart + does. In iOS Safari the only haptic a page can produce is a light impact from + a real tap landing on a `