diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 4f010bfbb59ae..31e4af0d9ff64 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -184,9 +184,9 @@ those contracts. keeps one copy per page however many components use them. There is no install call, no public entry, and no public installed check; tests reset the modules through an internal hook only. A component's own touch logic is - small glue in its own build over these modules. Where the primary pointer - is fine, no recognizer attaches and no touch listener is registered - (`spec:AST-094` FR19, FR24). The modules are not public exports and are not + small glue in its own build over these modules. The gesture host attaches for + every pointer, with no touch-only query; under a fine pointer only a component that + uses a gesture listens (`spec:AST-094` FR19, FR24, FR43). The modules are not public exports and are not documented for consumers; Core and Lab components reach them through `@astryxdesign/core/internal/touch`, a subpath reserved for Astryx's own packages: undocumented, outside the compatibility promise, versioned in @@ -340,16 +340,16 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | -| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | -| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | -| INV12 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | -| INV13 | Source boundary check plus bundle analysis of a page with several touch components, and real-browser tests on a throttled phone profile, under a fine pointer, and on a hybrid device | A second copy of a touch module on one page, a component bundling its own copy of a module's primitive, an `import()` of a module in a component, a public export or doc entry for the modules, an install call or installed check in the public API, a touch listener under a fine primary pointer, or an import of `@astryxdesign/core/internal/*` outside Core and Lab fails | -| INV15 | Unit tests of the animator's value mode and real-browser proofs of its adopters | A value off its motion role, a count past the component's swap size, a count or cross-fade under reduced motion with a coarse or a fine pointer, an intermediate value announced, or a public export fails | +| Invariant | Evidence | Failure signal | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | +| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | +| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | +| INV12 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | +| INV13 | Source boundary check plus bundle analysis of a page with several touch components, and real-browser tests on a throttled phone profile, under a fine pointer, and on a hybrid device | A second copy of a touch module on one page, a component bundling its own copy of a module's primitive, an `import()` of a module in a component, a public export or doc entry for the modules, an install call or installed check in the public API, a gesture listener in a component that uses no gesture, or an idle document listener outside a press other than the one shared press controller and the one stray-click swallower, or an import of `@astryxdesign/core/internal/*` outside Core and Lab fails | +| INV15 | Unit tests of the animator's value mode and real-browser proofs of its adopters | A value off its motion role, a count past the component's swap size, a count or cross-fade under reduced motion with a coarse or a fine pointer, an intermediate value announced, or a public export fails | diff --git a/docs/design/reduced-motion.md b/docs/design/reduced-motion.md index 509499aac7beb..00f3edeaaac81 100644 --- a/docs/design/reduced-motion.md +++ b/docs/design/reduced-motion.md @@ -87,8 +87,9 @@ Content that moves with the person's own pointer or finger during a drag follows it as direct manipulation and is not a DR1 transition, and neither is the momentum that continues a throw (DR2). A transition a component starts when a touch drag is released, such as a swiped row sliding -out or returning (`spec:AST-057` FR5), cross-fades as DR2 says, because a -finger started it. Any other transition, such as one sheet handing off to +out or returning after a finger's swipe (`spec:AST-057` FR5), cross-fades as +DR2 says, because a finger started it; one a trackpad started follows DR2 on +a coarse primary pointer and DR3 otherwise. Any other transition, such as one sheet handing off to another (`component:BottomSheetSwitcher` FR3), follows DR2 on a coarse primary pointer and DR3 otherwise. @@ -113,7 +114,7 @@ No normative assets are included. ## Component contract links -- `spec:AST-057` FR5 implements DR2 for a swiped row's slide out, +- `spec:AST-057` FR5 implements DR2 and DR3 for a swiped row's slide out, spring back, and hold-out. - `component:BottomSheetSwitcher` FR3 and ORD1 implement DR2 and DR3 for a handoff between sheets. diff --git a/docs/design/user-states.md b/docs/design/user-states.md index 1f2b156049225..191373429df7d 100644 --- a/docs/design/user-states.md +++ b/docs/design/user-states.md @@ -52,12 +52,12 @@ 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. Under a finger, the overlay's timing follows `spec:AST-094` FR11's press profiles. + or drag ends. The overlay's timing follows `spec:AST-094` FR11's press profiles under every pointer. - **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. Under a finger the item keeps that surface, and its press timing follows + receives the pressed overlay. Under every pointer 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 6ef0a725e986e..e325abba8083d 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; 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 | +| 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; press feedback follows `spec:AST-094` FR11's control profile under a finger and its fine-pointer profile under a mouse or a pen (not implemented: a mouse press still paints `:active`) | 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-055-caller-rendered-trigger/spec.md b/docs/specs/AST-055-caller-rendered-trigger/spec.md index bbe7d6938e2a1..6d931d9dd0b37 100644 --- a/docs/specs/AST-055-caller-rendered-trigger/spec.md +++ b/docs/specs/AST-055-caller-rendered-trigger/spec.md @@ -157,7 +157,7 @@ INV2), so the decision lives here and those records cite it. | FR3 | A caller-supplied control MUST NOT be taken as a `ReactNode` slot that the component renders inside an interactive element of its own. Callers routinely pass an interactive component, and that shape nests one interactive element inside another (AR2). A content slot rendered inside a component-owned control remains valid for non-interactive content and is unchanged. | DEC-1; AR2 | Accepted; jsdom evidence pending | | FR4 | The caller's control MUST own a box. A component MUST NOT render it inside a wrapper with no box of its own (`display: contents`), and MUST NOT depend on such a wrapper where it paints or offsets a focus indicator on the trigger, or positions the overlay against the trigger's rect: there is no rect to anchor to and no box on which to paint. FR1 removes the need for any wrapper. | DEC-2; `architecture:layer-runtime` anchor contract | Accepted; real-browser evidence required | | FR5 | Hover, pressed, and every other pointer paint on the caller's control MUST remain the caller's own CSS. The component MUST NOT write paint onto an element it does not render. The component MUST publish the overlay's open state on the handed-back props as `aria-expanded`, so a caller styles the open state from the rendered attribute and no additional public API is added for it. | DEC-3; `spec:AST-002` FR1, FR2 | Accepted; jsdom evidence pending | -| FR6 | The prop's consumer documentation MUST show styling the open state from `aria-expanded` on the caller's control, and MUST state that a pressed look keyed to `:active` is not a substitute, because `:active` does not behave the same under a coarse pointer — `module:DropdownMenu/useMenuPress` FR6 drops coarse-pointer `:active` paint on menu rows for that reason. | DEC-3; no current core component styles off `aria-expanded`, so callers have no example to copy | Accepted; documentation evidence pending | +| FR6 | The prop's consumer documentation MUST show styling the open state from `aria-expanded` on the caller's control, and MUST state that a pressed look keyed to `:active` is not a substitute, because `:active` does not behave the same under a finger, and press paint follows `spec:AST-094` FR11's profiles instead. | DEC-3; no current core component styles off `aria-expanded`, so callers have no example to copy | Accepted; documentation evidence pending | | FR7 | Where a component offers both a built-in control and this prop, exactly one control MUST render. The caller's control MUST win, identically in every presentation the component supports, and a development warning MUST fire; the component's own chrome for the replaced control MUST NOT render. Silently ignoring a supplied prop, or rendering both, is rejected. | `spec:AST-002` FR15 (never silently render a broken state); `architecture:public-component-api` INV3 | Accepted; jsdom evidence pending | | FR8 | A hook that returns props for a trigger — `triggerProps` and `getTriggerProps` on `useLightbox`, `usePopover`, `useMenuHover`, and `useHoverCard` — follows hook conventions and is unchanged by this record. Those return-value names are NOT precedent for a component prop's name or shape. This record does not require a hook to grow a render prop, nor a component to republish its trigger props as a hook return. | DEC-4 | Settled by DEC-4; no code change | @@ -287,9 +287,9 @@ reaches CSS through the rendered attribute instead of through new public API. Because no current core component styles off `aria-expanded` — every current use only emits the attribute — the route has no in-repo example to copy, so FR6 requires it to be documented on the prop. The same documentation states that -`:active` is not a substitute: it does not behave the same under a coarse -pointer, which is why `module:DropdownMenu/useMenuPress` FR6 drops coarse-pointer -`:active` paint on menu rows. +`:active` is not a substitute: it does not behave the same under a finger, +which is why press paint follows `spec:AST-094` FR11's profiles instead of +`:active`. ### DEC-4 — Hook trigger props are a different convention diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index e889602ad6c4d..2b3f6c57495e1 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -44,15 +44,15 @@ review_triggers: [public-api, accessibility, behavior] ## Contract at a glance -| Area | Contract | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | `Item` carries touch swipe actions. `swipeActions` declares, per side, the verbs a sideways drag uncovers as `ItemSwipeAction[]` — `label`, `icon`, `onActivate`, `variant`, `hasRemoval` — and `Item` renders the panel from them (DEC-1). `swipeBehavior` is `reveal` (rest open) or `commit` (fire on release) (DEC-2). The capability is `Item`'s, available on any row whose role permits interactive descendants — a `listitem`, a role-less row — and out of scope where it does not (`option`, `menuitem*`, a radio) (DEC-3). No hover reveal (DEC-4). No new DOM: the row's root translates and its panels counter-translate (DEC-5). | -| Behavior | On a coarse pointer a sideways drag moves the row and uncovers the side's panel. Under `reveal` (default) the row rests open so each action is tappable and a long drag fires the outermost; under `commit` the row slides out and the outermost fires; nothing rests. After an action fires the row springs back, or holds out when the action has `hasRemoval` — one bit the component cannot infer, independent of the model. The component waits for nothing afterwards. A short release springs back under both. A mouse never starts the drag; a mostly vertical drag stays the scroller's; a pointer anywhere outside a resting row closes it. | -| End-user impact | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. Nothing changes for anyone else: a mouse, a keyboard, or a screen reader meets the row exactly as today. | -| Builder impact | One prop on `Item` (and so on `ListItem`), `swipeActions={{trailing: [{label: 'Archive', icon: , onActivate: archive, hasRemoval: true}]}}`; optionally `swipeBehavior`. Every field is known before the gesture begins, so the component paints the panel from data. The group around the rows clips in the inline axis — `List` does it; any other host does it once. The consumer documentation states that a verb reachable only by swipe is unreachable by keyboard. | -| Compatibility/readiness | Additive: both props are absent by default; every current row keeps its DOM and paint exactly. Authority: `draft`; `approved_by` is `null`. No owner question is open. | -| Review checks | Reject a hover-revealed panel; swipe actions on a row whose role forbids interactive descendants; a model restricted by host; `commit` with several entries on a side and no warning; a panel exposed to the tree while closed; any element added to `Item` for the panels; `overflow: hidden` on the group where `clip` is meant; a row exit or enter animation introduced under this record; `hasRemoval` read as an obligation, a warning, or a timing rule; a second adaptive media query. | -| Governing rules | [`architecture:interaction-modality`](../../architecture/interaction-modality.md) INV4, INV6, INV7; [`spec:AST-002`](../AST-002/spec.md) FR1, FR4, FR15, FR16, `DEC-1`; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV2, INV3, INV5. | +| Area | Contract | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `Item` carries swipe actions for a finger, a pen, and horizontal wheel input. `swipeActions` declares, per side, the verbs a sideways drag uncovers as `ItemSwipeAction[]` — `label`, `icon`, `onActivate`, `variant`, `hasRemoval` — and `Item` renders the panel from them (DEC-1). `swipeBehavior` is `reveal` (rest open) or `commit` (fire on release) (DEC-2). The capability is `Item`'s, available on any row whose role permits interactive descendants — a `listitem`, a role-less row — and out of scope where it does not (`option`, `menuitem*`, a radio) (DEC-3). No hover reveal and no mouse-drag swipe (DEC-4); a trackpad's horizontal scroll swipes as a finger does (DEC-7). No new DOM: the row's root translates and its panels counter-translate (DEC-5). | +| Behavior | A sideways drag by a finger or a pen, or a horizontal wheel gesture, moves the row and uncovers the side's panel. Under `reveal` (default) the row rests open so each action is tappable and a long drag fires the outermost; under `commit` the row slides out and the outermost fires; nothing rests. After an action fires the row springs back, or holds out when the action has `hasRemoval` — one bit the component cannot infer, independent of the model. The component waits for nothing afterwards. A short release springs back under both. A mouse never starts the drag; a mostly vertical drag stays the scroller's; a pointer anywhere outside a resting row closes it. | +| End-user impact | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. A pen, or a trackpad's horizontal scroll, swipes the row the same way; a mouse drag, a keyboard, or a screen reader meets the row exactly as today. | +| Builder impact | One prop on `Item` (and so on `ListItem`), `swipeActions={{trailing: [{label: 'Archive', icon: , onActivate: archive, hasRemoval: true}]}}`; optionally `swipeBehavior`. Every field is known before the gesture begins, so the component paints the panel from data. The group around the rows clips in the inline axis — `List` does it; any other host does it once. The consumer documentation states that a verb reachable only by swipe is unreachable by keyboard. | +| Compatibility/readiness | Additive: both props are absent by default; every current row keeps its DOM and paint exactly. Authority: `draft`; `approved_by` is `null`. No owner question is open. | +| Review checks | Reject a hover-revealed panel or a mouse drag that reveals one; swipe actions on a row whose role forbids interactive descendants; a model restricted by host; `commit` with several entries on a side and no warning; a panel exposed to the tree while closed; any element added to `Item` for the panels; `overflow: hidden` on the group where `clip` is meant; a row exit or enter animation introduced under this record; `hasRemoval` read as an obligation, a warning, or a timing rule; a second adaptive media query. | +| Governing rules | [`architecture:interaction-modality`](../../architecture/interaction-modality.md) INV4, INV6, INV7; [`spec:AST-002`](../AST-002/spec.md) FR1, FR4, FR15, FR16, `DEC-1`; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV2, INV3, INV5. | This table is a review projection; the body below is authoritative. @@ -67,8 +67,9 @@ and not on one whose role forbids them. That sorts the list row and the standalone row in, and the option, menu-item and radio rows out, without naming a component; a row type nobody has written yet sorts itself. -The gesture is touch-only and is an accelerator: it adds a faster path to a -verb on a coarse pointer and changes nothing on any other input. Each side's +The gesture is an accelerator for a finger, a pen, and a trackpad: it adds a +faster path to a verb under a finger, a pen, or a trackpad's horizontal scroll, and changes +nothing for a mouse drag, hover, or the keyboard. Each side's verbs are declared as data — a label that names the state the verb reaches, an icon, a handler, how it looks, and whether the row holds out afterwards — so the component knows everything it needs before the finger moves: what to @@ -84,14 +85,13 @@ and its panels stay put, so no element is added to any row. - The rule for where swipe actions are available: the row's role. - The two models, `reveal` and `commit`, meaning the same thing wherever swipe actions are available. -- The coarse-pointer behavior: rest or fire, the accelerator, where the row +- The finger and trackpad behavior: rest or fire, the accelerator, where the row rests after an action, one open row at a time, and how a row closes. - The anatomy the gesture uses: two out-of-flow positions on `Item`'s existing root, the root's translation and the panels' counter-translation, and the group's inline clip. - The accessibility contract of the gesture itself: what the panel exposes - while resting and while closed, and that the gesture touches nothing on a - pointer that can hover. + while resting and while closed, and that a mouse drag and hover touch nothing. **Why no existing record can hold it** @@ -135,7 +135,7 @@ and its panels stay put, so no element is added to any row. 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). -- **Vertical swipes**, two-finger trackpad swipes, and a swipe whose only +- **Vertical 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 panel positions are internal, rendered from `swipeActions`. A second @@ -217,7 +217,7 @@ and its panels stay put, so no element is added to any row. | the drag's travel | a private custom property on the row root | Written by the gesture during a drag. The root's `transform` reads it; each panel's `transform` reads its negation, so the row moves and the panel appears fixed in the space the row vacates. | `0px` | `spec:AST-057` | proposed | | the group's clip | `overflow-inline: clip` on the element containing the rows | A moving row paints past its group's inline edge unless the group clips. `List` sets it; a host that renders `Item`s outside `List` sets it once. `Item` cannot clip itself: it is the thing moving. | — | `component:List` / the host | proposed | | 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 | +| the modality split | internal | A mouse drag never starts the swipe; a finger's or a pen's pointer event or a horizontal wheel gesture does, read from the input 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 @@ -227,17 +227,17 @@ constants. ### Behavioral contract -| ID | Invariant | Basis | Verification state | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | -| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | -| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | -| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | -| FR4 | A sideways drag on a coarse pointer MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | -| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2), because the swipe's own touch pointer started it. `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | -| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | -| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | -| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL. | `architecture:public-component-api` INV2 (logical direction) | Proposed | -| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host. | DEC-5; `List` root styles | Proposed | +| ID | Invariant | Basis | Verification state | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | +| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | +| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | +| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | +| FR4 | A sideways drag by a finger or a pen, or a horizontal wheel gesture, MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | +| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2) when a finger started the swipe, or, when a pen or a trackpad did, cross-fades on a coarse primary pointer (DR2) and arrives at once otherwise (DR3). `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | +| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | +| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | +| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL. | `architecture:public-component-api` INV2 (logical direction) | Proposed | +| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host. | DEC-5; `List` root styles | Proposed | ### Accessibility contract @@ -247,14 +247,17 @@ constants. activate it; while closed the panel MUST be out of the accessibility tree and the tab order. Under `commit` the panel MUST be presentational (`aria-hidden`) at all times, because nothing in it is ever tappable. -- **AR2 — The gesture touches nothing on a pointer that can hover.** - `swipeActions` MUST have no effect on the tab order, the accessible tree, - or the row's paint on such a pointer. Hover MUST NOT uncover the panel. +- **AR2 — A mouse drag never swipes a row.** A mouse drag and hover MUST NOT + uncover the panel, and on a pointer that can hover a closed row's + `swipeActions` MUST have no effect on the tab order, the accessible tree, or + the row's paint. A pen's drag (`spec:AST-094` FR43), a trackpad's horizontal scroll swipe, or a mouse's + horizontal wheel, which sends the same events, MAY uncover the panel (DEC-7), and a row it leaves resting open follows AR1. - **AR3 — The row's primary stays one tab stop.** `swipeActions` MUST NOT change the primary element's role, name, or activation, and MUST add no stop while closed. - **Stated in documentation, not enforced:** a swipe is a touch accelerator, - so a verb reachable only by swipe is unreachable by keyboard and by mouse. + so a verb reachable only by swipe is unreachable by keyboard and by a mouse + drag. The consumer documentation of `swipeActions` MUST say so and MUST show the verb also reachable through the row's own content. The component does not inspect what else the row offers. @@ -262,9 +265,13 @@ constants. ### Platform support - Supported feature/engine floor: every supported renderer and browser. - Pointer type is read from the pointer event. -- Unsupported behavior: a device that never produces a coarse pointer gets - no swipe and nothing else changes. + A finger's or a pen's swipe is read from its pointer event; a + trackpad's from a horizontal `wheel` gesture, which has no pointer type. A + mouse's horizontal wheel sends the same events and swipes the same way, + since the page cannot tell the two apart; a mouse drag never swipes. +- Unsupported behavior: a device with no touch screen, no pen, and no + horizontal wheel input gets no swipe, and a mouse drag never swipes; nothing else + changes. - Browser evidence: FR2 and FR4–FR9 are paint and gesture claims. jsdom has neither; real-Chromium evidence with dispatched touch is required, and FR5/FR6 additionally need a physical touch device, because Chromium evidence @@ -294,15 +301,15 @@ constants. ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1, FR3 | `Item` and `ListItem` prop-surface suites plus exported-type checks | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails. | -| FR2 | `Item` DOM snapshot suite plus real-Chromium evidence | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails. | -| FR4–FR6, FR8 | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. | -| FR7 | `Item` suite with two rows under one parent | Open one, drag the other; outside tap; Escape; entry fired; unmount | Two rows resting open, or a row that stays open after any closing event, fails. | -| FR9 | `List` style assertion plus real-Chromium evidence of a row dragged past the edge | A row at the list's inline edge mid-drag; the list scrolled vertically | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails. | -| AR1–AR3 | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, or any effect on a fine pointer fails. | -| Documentation | Consumer-doc check on `swipeActions` | The prop's documentation | A doc that does not state the keyboard consequence or the group clip fails. | +| Contract | Verification | Representative states | Mutation or failure expectation | +| ------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR3 | `Item` and `ListItem` prop-surface suites plus exported-type checks | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails. | +| FR2 | `Item` DOM snapshot suite plus real-Chromium evidence | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails. | +| FR4–FR6, FR8 | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL; a trackpad's horizontal scroll and a mouse's horizontal wheel; a mouse drag, which must not swipe; a pen drag | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. | +| FR7 | `Item` suite with two rows under one parent | Open one, drag the other; outside tap; Escape; entry fired; unmount | Two rows resting open, or a row that stays open after any closing event, fails. | +| FR9 | `List` style assertion plus real-Chromium evidence of a row dragged past the edge | A row at the list's inline edge mid-drag; the list scrolled vertically | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails. | +| AR1–AR3 | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer; a trackpad horizontal scroll that opens a row; a mouse drag | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, a panel a mouse drag or hover uncovers, or any effect on a closed row under a hovering pointer fails. | +| Documentation | Consumer-doc check on `swipeActions` | The prop's documentation | A doc that does not state the keyboard consequence or the group clip fails. | Known verification gap: none of the suites above exist on `main`, and no component implements the contract. This record is `draft`, does not govern @@ -419,8 +426,9 @@ owns the one coordination case. **Reference:** `spec:AST-057/DEC-4` **Decider:** `cixzhang`, `2026-10-02` -On a pointer that can hover, `swipeActions` does nothing. The gesture is a -touch accelerator and changes nothing on any other input; what a row offers +A mouse drag and hover do nothing with `swipeActions`; horizontal wheel +input, a trackpad's scroll or a mouse's horizontal wheel, and a pen are the +non-finger inputs that swipe a row (DEC-7); what a row offers a mouse or a keyboard is the row's own content, which this record does not govern. @@ -493,6 +501,19 @@ decorative colours (`blue`, `cyan`, …) that a verb slot must not admit. Rejected: the menu row's two-value `'default' | 'destructive'` — it has no word for the one action a panel exists to offer. +### DEC-7 — A trackpad swipes a row; a mouse drag does not + +**Reference:** `spec:AST-057/DEC-7` +**Decider:** `cixzhang`, `2026-10-26` + +A trackpad's two-finger horizontal scroll is the same motion as a finger's +swipe and takes nothing else from the page, so it reveals and fires row +actions as a finger does (`spec:AST-094` FR43). A mouse drag would take the +browser's text selection and native drag, so a mouse drag never swipes a row. A mouse's horizontal wheel sends the +same events as a trackpad and swipes the same way. + +Rejected: keeping swipe actions touch-only. + ## Open questions None. diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 097fa20af2587..2bdaf010cac3b 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -42,8 +42,9 @@ 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`). +- Pointer (desktop) presentations' timing and easing, which stay with the + theme's duration and easing tokens (`architecture:theme-tokens`). Gestures + and press tracking themselves apply to every pointer (FR43). - Platform-matched look and feel: glass versus material surfaces, glyphs and chrome. This record governs behavior, and touch sizing only as FR42 states. - WebKit embedded in a native app and the native Swift package. This record @@ -181,13 +182,17 @@ a measured, sourced difference exists. `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 +- **FR11 — Three press profiles.** Press feedback MUST follow one of three 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, except that a toggle under `spec:AST-020` FR9's slide-off exception (Switch, Checkbox, CheckboxListItem) may also complete on a lift after the finger slid off. + Under a fine pointer a control uses a third profile with no delay: its + press paint starts at once on `pointerdown`, through the same press + controller and release fade as a finger's, in place of CSS `:active`; the + scroll brake stays touch-only (DEC-21). A cell (a table or collection cell) highlights 150 ms after touch-down inside a scroll view, drops the highlight when the scroll wins (FR20), and activates on a lift inside it with no scroll taking the touch (FR20). A control is inside a scroll view when it sits in a scrollable container, @@ -199,9 +204,9 @@ a measured, sourced difference exists. 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. A menu whose + presentations, the press treatment of `family:buttons`, and + `module:DropdownMenu/useMenuPress` FR6: under a finger, menu rows take the + cell profile. A menu whose rows fit (`module:DropdownMenu/useMenuPress` FR4), so it cannot scroll, gives its rows no 150 ms delay: they highlight on touch-down. A list that can scroll keeps the delay. A press in a list that no scroll can claim stays @@ -262,11 +267,8 @@ a measured, sourced difference exists. screen-edge pan is attached to an element that lies 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. -- **FR19 — Claims are made in time and only by an owner.** Recognizers attach only where the primary pointer - is coarse (FR13, DEC-6). Where the primary pointer is fine, no recognizer - attaches and no touch listener is registered (FR24), so a finger on a - touchscreen whose primary pointer is fine gets the browser's defaults for - this record's behaviors (`spec:AST-017` FR59, DEC-25). This rule governs +- **FR19 — Claims are made in time and only by an owner.** Recognizers attach for every pointer, with FR43's + exceptions. This rule governs this record's behaviors only; `spec:AST-057` keeps its per-event pointer read for the row swipe, as its record states. A recognizer that begins MUST claim its touches by cancelling `touchmove` through a non-passive listener on its element; a watcher, @@ -318,14 +320,13 @@ a measured, sourced difference exists. FR26–FR28 and 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 and the primary pointer is coarse (FR19); where the primary pointer -is fine, a finger in a menu keeps today's behavior (`spec:AST-017` FR59, -DEC-25), and menus make no per-gesture pointer read; FR29 applies to DropdownMenu's trigger under the same +and Selector's listbox, whose options count as rows) under every pointer +(FR43); FR29 applies to DropdownMenu's trigger under the same condition; FR34 applies to menus; FR31 applies to anchored menus and to Popover under a finger, and FR35 to -the entrances it lists; FR32 and -FR33 apply to menus only. A mouse keeps today's behavior. +the entrances it lists; FR32 and FR33 apply to menus only. A mouse keeps +`module:DropdownMenu/useMenuPress` FR1–FR3 and DEC-3, so a mouse released +outside a menu closes it. - **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 @@ -444,8 +445,10 @@ FR33 apply to menus only. A mouse keeps today's behavior. element's existing always-on suppression stays as it is. On a label or plain text whose hold does nothing, such as a checkbox row's label, a long press selects natively, with the browser's handles; any other element follows its - own record. Keyboard and assistive-technology selection are unchanged, and a - mouse keeps its released behavior (`spec:AST-017` FR59; DEC-9). + own record. Keyboard and assistive-technology selection are unchanged. A pen's hold + acts as a finger's; a mouse makes the context request by a right click, not + a long press (FR43), and otherwise keeps its released behavior + (`spec:AST-017` FR59; DEC-9). Guidance: a caller whose menu element carries text a person may want offers a Copy action in its menu. @@ -461,11 +464,15 @@ FR33 apply to menus only. A mouse keeps today's behavior. ### Resources and compatibility -- **FR24 — Touch work costs nothing where it is not used.** Where the - primary pointer is fine (FR19), and while a touch presentation is - disabled, no touch-only listener, observer or timer is active, except the - released `module:DropdownMenu/useMenuPress` behavior a finger keeps there - (its FR9). The number of `document`- or `window`-level +- **FR24 — Gesture work costs nothing where it is not used.** A component + that uses no gesture registers no gesture listener, observer or timer, and + while a touch presentation is disabled none is active. Under a fine pointer + a component that uses a gesture listens only on its own element and adds + document listeners only during a press, removing them at the release (FR43); + the shared press controller and the menu press model's stray-click + swallower (`module:DropdownMenu/useMenuPress` PR2), each installed once per + page, count once and not per component, and a claiming recognizer's `touchmove` listener for a finger + (FR19) counts outside this budget. The number of `document`- or `window`-level touch listeners MUST NOT grow with the number of component instances, a repeated row or cell adds no touch observer of its own, and nothing touch-related remains after the last instance unmounts @@ -497,11 +504,31 @@ FR33 apply to menus only. A mouse keeps today's behavior. half the gap, or skipped, so each element stays reachable; an expansion a component or module record sets on purpose, such as `component:Thumbnail` AR4's, is unaffected. Mobile size increases belong to theme adaptations (DEC-20). +- **FR43 — Gestures work on the web.** Gestures attach for every pointer, a + mouse, a pen, and a finger, not only a coarse primary pointer. These stay + off: row swipe actions under a mouse drag, which would take the browser's text + selection and native drag, while a trackpad's horizontal scroll swipes a + row (`spec:AST-057` AR2, DEC-7); a + pinch under a pen; the screen-edge pan under a mouse, a trackpad, or a pen; + and the 44 px coarse-pointer touch target, which stays coarse-only and never overlaps a + neighbor's target. On the web, a pan also + takes wheel and trackpad scrolling; a pinch takes a ctrl-wheel; one shared + context request answers a finger's or a pen's long press, a right click, and + Shift+F10; the + press model has a fine-pointer profile with no delay (FR11); and with a + mouse, a trackpad, or a pen where the primary pointer is fine a reorder + starts on a drag from its handle without a hold, while a pen where the + primary pointer is coarse keeps the finger's hold (`spec:AST-103` FR3). Under a fine pointer only a + component that uses a gesture listens (FR24), and under reduced motion a + release from a mouse, a trackpad, or a pen follows `design:reduced-motion` + DR2 on a coarse primary pointer and DR3 otherwise; and gestures add no + stylesheet bytes (DEC-21). ### Platform support - Supported engines: Astryx's supported browsers (`spec:AST-013`) with touch - input, on iOS and Android. UIKit on the reference release is the parity + input, on iOS and Android, and, for FR43, desktop browsers with a mouse, a + trackpad, or a pen. UIKit on the reference release is the parity reference, not a support floor; an older iOS Safari gets the same behavior wherever its engine supports it, and Android Chrome gets the same behavior. This amends `spec:AST-013` FR3 with an explicit Android Chrome support row @@ -543,10 +570,16 @@ FR33 apply to menus only. A mouse keeps today's behavior. finger; `presentation="popover"` keeps the released behavior (FR25). - `module:DropdownMenu/useMenuPress`: the released 500 ms `longPressDelayMs` default gives way to FR29's timing; a caller that passes it keeps it (FR25). -- `module:DropdownMenu/useMenuPress`: its finger rules (FR1–FR3, FR6, FR7 - and FR9 under a finger) are stated here (FR26–FR30); FR6's last sentence - gives way to the cell press profile (FR11); its mouse rules (FR8 and the - mouse halves of FR1–FR3) and its non-gesture rules stay in the module. +- `module:DropdownMenu/useMenuPress`: its finger and pen rules (FR1–FR3, FR6, + FR7, FR9, FR11) follow FR26–FR30, FR33, and FR11's profiles under every + primary pointer; its mouse rules (the mouse halves of FR1–FR3) and its + non-gesture rules stay in the module. +- `spec:AST-057`: a trackpad's horizontal scroll swipes a row (DEC-7); a + mouse drag never does. +- `architecture:react-component-runtime` INV13: gestures attach for every + pointer, within FR24's listener budget. +- `design:user-states` DR1, DR3 and `family:buttons`: press timing follows + FR11's profiles under every pointer, the fine-pointer profile included. - Every component under a coarse pointer: no box grows for touch, and a touch target reaches 44 px only through an invisible hit area clamped by its neighbors, unless a component or module record sets its own (FR42). @@ -591,15 +624,16 @@ FR33 apply to menus only. A mouse keeps today's behavior. | FR15–FR17 | Recognizer unit tests on synthetic streams with explicit timestamps | exclusive, simultaneous, require-to-fail, nested; a watcher beside a claiming winner; a blocked main thread; a pan at 10 pt per axis | a wrong winner, a late deadline, or a translation that keeps the hysteresis, or a watcher failed or cancelled by a claiming winner fails | | FR18–FR22 | Browser tests with real touch input (`spec:AST-013` FR10) | per-axis pans beside native scrolling, page zoom, cancelled touches, clicks after taps, keyboard activation, a modal scroll lock, a control that keeps its press through a drag; a still finger with sub-pixel jitter over a scroll view; a finger moved 9 px and 10 px straight, and (7, 7) and (8, 8) diagonally; a recognizer on an element whose author `touch-action` is narrower, including one without `pinch-zoom`; a screen-edge pan landing at 40 px and at 41 px from the edge, at 14 px and 15 px of travel, and a horizontal scroll that starts outside the band; a screen-edge pan on an element within the band, and on one that reaches beyond it; a sheet's pull from a list at its top, and from a list scrolled away from its top | a ghost click, a scroll or zoom blocked other than by the author's `touch-action`, or a cancelled unowned touchmove outside FR19's three exceptions, a tap cancelled by sub-pixel movement or by under 10 px of travel, a scroll that does not take the touch at 10 px, or a derived `touch-action` wider than the author's, a screen-edge pan that claims outside its band or before 15 px on an element within the band, or has not claimed at 15 px, or an author scroll outside the band that is blocked, a band claim of `pan-x` on an element that reaches beyond the band, or a 15 px start there, or a scroll-edge pan that cancels a move a scroller could scroll, or begins before 10 pt, fails | | FR23 | Haptics tests on the recorded moments | tap, drag, lift | a haptic outside a native moment fails | -| FR24 | Resource tests counting listeners, observers and timers | fine primary pointer; coarse pointer enabled and disabled; many instances; unmount | a touch listener on a fine pointer, a per-instance `document` or `window` listener, or a leak fails | +| FR24 | Resource tests counting listeners, observers and timers | fine primary pointer; coarse pointer enabled and disabled; many instances; unmount | a gesture listener in a component that uses no gesture, an idle document listener outside a press other than the one shared press controller and the one stray-click swallower, a second press controller, a per-instance `document` or `window` listener, or a leak fails | | FR25 | Component tests of each moved default and its `presentation` value | default; `presentation` set to the released surface | a moved default that `presentation` does not restore, or a public threshold, fails | | FR26–FR28, FR30 | Menu press tests on synthetic touch streams and a real-Chromium touch test | a slide from one row to another; a lift outside; a cancel; a second finger; edge autoscroll | an act on the press-start row, two acts for one gesture, a lift outside that closes, or a lit row after a cancel fails | | FR29 | Trigger tests with explicit timestamps | a press in a fixed bar; a press in scrolling content held 149 and 150 ms; a quick tap in scrolling content; a scroll taking the touch before 150 ms; a drift of 12 and 14 pt; moves before and after the handoff; `presentation="popover"` | a fixed-bar press that waits, a held finger's scroll-view open before 150 ms or after a scroll, a quick tap that does not open at its lift, an open past the drift allowance, a lift that acts before the handoff, or a `popover` trigger that opens on press fails | | FR31–FR33 | Browser tests and storyboards under a finger | a menu, a Popover and a ContextMenu lift: open, close, a pan outside, a tap outside, a pinch over the surface; a mouse | a surface that does not grow from and shrink into its trigger, a pan outside that leaves a menu open, a Popover whose dismissal changed, a pinch blocked other than by the author's `touch-action`, a tap outside that reaches the page, or a changed mouse path fails | | FR34, FR35 | Browser tests and storyboards under a finger | a sub-menu drilling in and its Back row returning; a tap and a drag during a popover's and a menu's growth; a pinch during growth; a mouse; a tap and a drag during an alert's, a sheet's, and a dialog's entrance; a key and a screen reader during an entrance; a pinch during an alert's and a sheet's entrance; a tap and a scroll on the page beside an entering non-modal sheet, which pass | a drill-in that leaves the parent's rows in place, a change between levels that does not play on its roles, a tap during growth that closes, scrolls or moves focus, a pinch blocked other than by the author's `touch-action`, or a changed mouse path, a held key or assistive-technology activation, a hidden content tree, or a tap or drag during an entrance that closes the presentation, scrolls a held page, moves focus or reaches anything beneath, fails | | FR36–FR40 | Release tests on synthetic touch streams with explicit timestamps, per use | below and past each commit point; a flick that projects past it; a slow drag past it; a release moving back; Reduce Motion; keyboard and mouse paths | a decision that changes mid-settle, a release moving back that commits, a settle that does not start at the release velocity, or an action with no keyboard or mouse path fails | -| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails | +| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection; a pen long press; a mouse long press, which makes no context request | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails | | FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails | +| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, an idle listener in a component with no gesture, or added stylesheet bytes, fails | | all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect | ## Decision log @@ -753,6 +787,21 @@ iOS 26.2 simulator measures it. Rejected: the 150 ms delay on every menu row. +### DEC-21 — Gestures work on the web, not only under a finger + +**Reference:** `spec:AST-094/DEC-21` +**Decider:** `cixzhang`, `2026-10-26` + +Most of these gestures work as well with a mouse, a trackpad, or a pen, so +they stay on for every pointer, with web inputs mapped in: wheel and trackpad +scrolling for a pan, a ctrl-wheel for a pinch, and right click or Shift+F10 +for a long press. What would fight the browser is off: a mouse drag's swipe +actions, a pen's pinch, and the screen-edge pan under a mouse, a trackpad, or a pen. The 44 px target is +touch-only, and none of this adds stylesheet bytes. A mouse press uses the press model with no delay instead of the +browser's `:active`. The press controller tracks every pointer. + +Rejected: keeping gestures and press tracking touch-only. + ### DEC-20 — The default look grows nothing for touch **Reference:** `spec:AST-094/DEC-20` diff --git a/docs/specs/AST-103-reorderable/spec.md b/docs/specs/AST-103-reorderable/spec.md index 40e9dd855b932..6d5db5cddd1a5 100644 --- a/docs/specs/AST-103-reorderable/spec.md +++ b/docs/specs/AST-103-reorderable/spec.md @@ -136,7 +136,8 @@ announcements are those of FR3 and FR4, and the tree adds no option. for it; a cancelled drag calls nothing; - under Reduce Motion the moving preview still follows the finger or pointer, and the other rows and the drop take their places without travel - (`design:reduced-motion` DR1–DR3; a mouse drop follows DR3). + (`design:reduced-motion` DR1–DR3; a mouse drop follows DR2 on a coarse primary pointer and DR3 + otherwise). - **FR4 — Reorder mode is the one path without a drag.** Each reorderable row's handle is a toggle button named "Reorder" and the row's label, with `aria-pressed`. Activating it, by click, Enter, or Space, enters reorder diff --git a/packages/core/src/Button/Button.spec.md b/packages/core/src/Button/Button.spec.md index d74820befb840..c3bc2b625d548 100644 --- a/packages/core/src/Button/Button.spec.md +++ b/packages/core/src/Button/Button.spec.md @@ -37,15 +37,15 @@ system_specs: [spec:AST-005, spec:AST-085, spec:AST-086] ## Contract at a glance -| Area | Contract | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. This record states the shipped Button surface: `label`, variant, size, elevation, content mode, loading and `clickAction`, `isInterruptible`, disabled, tooltip, width, form attributes, and link mode. | -| Behavior | A named native `