diff --git a/docs/design/user-states.md b/docs/design/user-states.md index ded895ea6517c..61b450df6f351 100644 --- a/docs/design/user-states.md +++ b/docs/design/user-states.md @@ -52,12 +52,14 @@ surface. - **DR1 — Press the manipulated surface.** During pointer hold or drag, each enabled surface in the matrix below MUST paint `--color-overlay-pressed` on the named visual part. The overlay is transient and MUST clear when the press - or drag ends. + or drag ends. Under a finger, a touch presentation's press feedback follows + `spec:AST-094` FR11's press profiles instead. - **DR2 — Disabled means no response.** A disabled control in the matrix MUST remain inoperable and MUST NOT gain a pressed visual treatment. - **DR3 — Persistent selection wins.** A selected SegmentedControl item MUST keep its raised selected surface during pointer hold; only an unselected item - receives the pressed overlay. + receives the pressed overlay. Under a finger the item keeps that surface, and its press timing follows + `spec:AST-094` FR11. ## Anatomy and hierarchy diff --git a/docs/families/buttons.md b/docs/families/buttons.md index f4b666c03d821..6ef0a725e986e 100644 --- a/docs/families/buttons.md +++ b/docs/families/buttons.md @@ -93,15 +93,15 @@ Membership follows public responsibility, not an import of Button or a rendered ## Canonical concepts -| Concept | Values or states | Default semantics | Stability | -| ---------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------- | -| activation model | momentary action, navigation, persistent press | Button and IconButton activate once; ToggleButton represents retained pressed state | shipped distinction | -| content mode | visible label, custom visible content, icon-only | every control has a required accessible `label`; icon-only hides it visually | shipped family rule | -| size | `sm`, `md`, `lg` | `md`; explicit member size wins over an inherited group size | shipped family axis | -| visual state | rest, hover, focus, active, disabled, loading; pressed where applicable | states preserve control geometry and accessible purpose | shipped family rule | -| async action | absent, fire-once, interruptible persistent action | ordinary actions deduplicate while pending; persistent toggles remain reversible | shipped family distinction | -| elevation | `none`, `low`, `med`, `high` | `none`; the element painting the visible surface owns the shadow | shipped family axis; ToggleButton adoption approved | -| grouping | standalone, spaced set, connected surface | semantics and painted containment decide ownership; grouping alone does not | family rule | +| Concept | Values or states | Default semantics | Stability | +| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| activation model | momentary action, navigation, persistent press | Button and IconButton activate once; ToggleButton represents retained pressed state | shipped distinction | +| content mode | visible label, custom visible content, icon-only | every control has a required accessible `label`; icon-only hides it visually | shipped family rule | +| size | `sm`, `md`, `lg` | `md`; explicit member size wins over an inherited group size | shipped family axis | +| visual state | rest, hover, focus, active, disabled, loading; pressed where applicable | states preserve control geometry and accessible purpose; under a finger, press feedback follows `spec:AST-094` FR11's control profile | shipped family rule | +| async action | absent, fire-once, interruptible persistent action | ordinary actions deduplicate while pending; persistent toggles remain reversible | shipped family distinction | +| elevation | `none`, `low`, `med`, `high` | `none`; the element painting the visible surface owns the shadow | shipped family axis; ToggleButton adoption approved | +| grouping | standalone, spaced set, connected surface | semantics and painted containment decide ownership; grouping alone does not | family rule | ## Cross-component invariants diff --git a/docs/specs/AST-013/spec.md b/docs/specs/AST-013/spec.md index 85cfd51a175ef..e202d3159363a 100644 --- a/docs/specs/AST-013/spec.md +++ b/docs/specs/AST-013/spec.md @@ -72,10 +72,12 @@ requirements. Assistive-technology evidence remains separately owned. public [Browser Support guide](../../../packages/cli/assets/docs/browser-support.doc.mjs) and record their source and refresh date. TypeScript targets, build targets, and feature tables are not support evidence. -- **FR3 — Two explicit browser support rows are always included.** Independently +- **FR3 — Explicit browser support rows are always included.** Independently of the generated Baseline coverage, Astryx explicitly supports: - - the latest stable Chrome release on desktop; and - - Safari on the latest stable iOS release. + - the latest stable Chrome release on desktop; + - Safari on the latest stable iOS release; and + - for touch presentations, Chrome on the latest stable Android release + (`spec:AST-094`). “Latest” means the current stable release identified and refreshed from authoritative browser release data, not a handwritten version. Other browsers @@ -191,7 +193,7 @@ hydrating render can differ from the server's markup. ## Decision log -### DEC-1 — Rolling Baseline tiers plus two official browser rows +### DEC-1 — Rolling Baseline tiers plus official browser rows **Reference:** `spec:AST-013/DEC-1` **Decider:** `cixzhang`, `2026-09-01` @@ -199,7 +201,8 @@ hydrating render can differ from the server's markup. Astryx uses current Web Platform Baseline for full support, Baseline minus two years for reduced fallback, and no promise below that tier. Latest stable Chrome on desktop and Safari on latest stable iOS are always included as official support -and test rows independently of generated Baseline coverage. Generated compatibility +and test rows independently of generated Baseline coverage, and touch +presentations add Chrome on the latest stable Android (`spec:AST-094`). Generated compatibility rows and the named official test matrix remain distinct public facts. ### DEC-2 — Explicit support does not require proof on every change @@ -207,7 +210,7 @@ rows and the named official test matrix remain distinct public facts. **Reference:** `spec:AST-013/DEC-2` **Decider:** `cixzhang`, `2026-09-02` -DEC-1's two named browser rows are explicit support rows, not a required automated +DEC-1's named browser rows are explicit support rows, not a required automated or per-change test matrix. Astryx investigates and fixes defects reproduced in those browsers, but routine changes do not need to prove every supported browser before landing. Actual-browser evidence is required when the change makes a diff --git a/docs/specs/AST-025/spec.md b/docs/specs/AST-025/spec.md index d11d473df6db9..4a13741dd28e0 100644 --- a/docs/specs/AST-025/spec.md +++ b/docs/specs/AST-025/spec.md @@ -160,7 +160,8 @@ the behavior. the requested direction. A containment policy MAY stop chaining, but its `overscroll-behavior` effect MUST be active only on axes that currently overflow. This avoids dead scroll zones in engines that honor containment on non-overflowing - `overflow: auto` elements. + `overflow: auto` elements. Inside a touch presentation under a finger, who + gets a drag is decided once, when it begins (`spec:AST-094` FR14). - **FR15 — Native scrollbars are the default presentation.** The default presenter MUST preserve native scrolling and use standards-based CSS such as `scrollbar-width`, `scrollbar-color`, and `scrollbar-gutter` where supported. diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index e889602ad6c4d..0a045a42d0839 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -131,10 +131,11 @@ and its panels stay put, so no element is added to any row. - **A render prop for a custom panel.** Deferred, not foreclosed: a `render*`-shaped addition can land when a caller needs something the vocabulary cannot say (DEC-1). -- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity, slide - and spring durations, resistance past the panel: behavior constants in - source, not public API and not design tokens (the same ruling the touch - press model received for its clocks). +- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity, + resistance past the panel: behavior constants in source, not public API and + not design tokens (the same ruling the touch press model received for its + clocks). The row's slide and spring motion plays on theme motion roles + (`spec:AST-094` FR4). - **Vertical swipes**, two-finger trackpad swipes, and a swipe whose only meaning is dismissal (Toast already has that in `useToastGesture`). - **Public out-of-flow slots on `Item`.** Deliberately not admitted: the @@ -219,9 +220,10 @@ and its panels stay put, so no element is added to any row. | open state | closed, resting open | The row's own. A pointer landing anywhere outside a resting row closes it, which is also how a drag on a neighbour closes it; no group state exists. | closed | `spec:AST-057` | proposed | | the modality split | internal | A mouse never starts the drag; a coarse pointer does. Read from the pointer event, not a prop or a media query (`architecture:interaction-modality` INV6). | — | `spec:AST-057` | proposed | -Not public: axis-lock distance, commit ratio, fling velocity, durations, -resistance, panel widths, the custom property's name. They are behavior -constants. +Not public: axis-lock distance, commit ratio, fling velocity, resistance, +panel widths, the custom property's name. They are behavior constants. The +row's slide and spring motion plays on theme motion roles (`spec:AST-094` +FR4). ## Requirements 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..46403b7df6f17 --- /dev/null +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -0,0 +1,547 @@ +--- +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 a theme motion role named for its use + (`architecture:theme-tokens` INV3); the generated motion role table + (`astryx docs motion`) lists every role with its kind and default. 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. 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). + + A measured platform preset takes a use name: `springs.fingerTracking` + (SwiftUI's interactive spring), `springs.emphasis` (`.bouncy`), + `springs.settle` (`.smooth`), `springs.quickChange` (`.snappy`), + `springs.viewChange` (UIKit's default spring), `springs.timedChange` + (UIKit's duration-and-bounce spring), `springs.stateChange` (SwiftUI's + default animation), `springs.directChange` (SwiftUI's response spring), + `timing.stateChangeMs` (Core Animation's implicit transaction), and the + curves `timing.curves.viewChange` (Core Animation's default), `exit` + (ease-in), `entrance` (ease-out) and `move` (ease-in-out). + +### 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 and activates by the browser's click on a lift inside. On the web a + control's press ends when the browser takes the touch (FR20), and its + activation stays the browser's click. + 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 (a held, selected SegmentedControl item keeps its raised selected + surface; only its press timing follows the control profile) 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, with + two exceptions. While a scroll lock under a modal presentation holds the + page, it MAY cancel the moves of a one-finger touch that no recognizer owns + when that pan would scroll the page; a second finger is never cancelled + (FR10), and the page keeps pinch-zoom. Once its press is delivered, a + control that keeps its press through a drag (FR11) owns its touch and MAY + cancel that finger's moves. 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. A recognizer that + never claims (a watcher) registers only passive listeners + (`architecture:react-component-runtime` INV3, INV5). +- **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 apply to every surface that carries the press model of +`module:DropdownMenu/useMenuPress` (DropdownMenu, ContextMenu, their sub-menus, +and Selector's listbox, whose options count as rows) when a finger or pen +drives it; FR34 applies to menus; 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); A `module:DropdownMenu/useMenuPress` caller that sets the released + `longPressDelayMs` gets that wait in a fixed bar and in a scroll view alike, + which keeps the released behavior (`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 on a + compact touch screen, an `adaptive` sub-menu row MUST drill in: the + sub-menu's rows and a leading "Back to " row replace the menu's rows + in the same surface, no flyout opens, and Back returns to the row that + opened it (`component:DropdownMenu` FR11 and FR20). 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 `