Skip to content

fix(Popover): under a finger, a pan that starts outside dismisses it - #8198

Closed
vjeux wants to merge 92 commits into
meta:mainfrom
vjeux:uikit/c26-popover-pan-dismiss
Closed

vjeux wants to merge 92 commits into
meta:mainfrom
vjeux:uikit/c26-popover-pan-dismiss

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8187 (and its predecessors); review the last commit.

User impact

Under a finger, a Popover stayed open when the user started scrolling the page elsewhere: it rode along with its trigger. A drag inside it scrolled the page too (also on main).

Evidence

iOS 26.2 Safari, a lined page; a grey dot marks the finger. A pan from the lower right; before (#8187), then after:

Before After

Android 15 emulator, Chromium 158 (540×1200, every frame reviewed); before, then after:

Android before Android after

  • Before: pointercancel as the pan begins; the popover stays open and rides up with the page.
  • After: it closes on that pointercancel and shrinks into its trigger as the page scrolls. A tap or a 300 ms hold outside still closes it at the lift.
  • The emulator's software renderer drops frames while the dismissal composites over the scroll (gaps of 67–103 ms); the iOS clip has none.

A pan that starts inside the popover; before, then after:

Inside before Inside after

Problem and solution fit

Light dismiss acts when an outside tap lifts. A pan never lifts: the browser takes it for a scroll and ends it with pointercancel, which light dismiss ignores. On iOS 26.2 a pan that starts outside a popover dismisses it as it begins; a drag inside leaves the page alone.

The rule becomes a layer runtime hook, usePanOutsideDismiss, in Popover's touch module. It listens for a press's release only while a touch that went down outside is down (INV5). A still surface now takes no pan while a pinch still zooms the page (touch-action: pinch-zoom); a scrolling one keeps its own (overscroll-behavior: contain).

Expected behavior and authority

  • family:overlay-dismissal AV4 leaves outside dismissal, touch included, to the member; spec:AST-094 FR32 (spec(AST-094): touch presentations feel native by default #8378) states this dismissal and component:Popover FR9 points there.
  • Page zoom keeps working on a still surface: it never takes touch-action: none (FR32, FR10, FR18; WCAG 1.4.4).
  • hasLightDismiss={false} keeps a popover open through a pan, as through a tap.
  • A pan that starts on the trigger dismisses it too. Nested surfaces: a pan inside a parent popover but outside its child closes only the child.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm vitest run packages/core/src/Popover packages/core/src/Layer packages/core/src/motion on feat(Popover): under a finger, it grows out of its trigger and shrinks back into it #8187: 754 pass, including usePanOutsideDismiss.test.tsx (no release listeners until a touch goes down outside; a re-render between the press and the cancel keeps it) and the Popover cases: a pan outside closes it, a pan from the trigger closes it, a pan inside a parent but outside its child closes only the child, hasLightDismiss={false} keeps it, a mouse keeps it.
  • pnpm test:interaction PopoverTouchMotion.interaction.browser.spec.ts --project chromium after pnpm storybook:build: 10/10, the pan-inside proof reading touch-action: pinch-zoom on the still surface; WebKit: 5/5 (5 Chromium-only cases skip). The pan-outside, pan-from-the-trigger and pan-inside proofs fail against feat(Popover): under a finger, it grows out of its trigger and shrinks back into it #8187's build.
  • pnpm check:knowledge.
  • pnpm exec vitest run --project node packages/cli/test: 309 pass.
  • iOS 26.2 simulator in Safari and the Android 15 emulator with Chromium 158, before and after, with a page log of the touches and the scroll.
  • Core typecheck, eslint, prettier.

Stack (merge in this order): #8076 and #8378 → #8186 → #8187 → this PR → #8208 (with #8176) → #8223.

vjeux added 24 commits October 23, 2026 20:05
Adds internal/interaction-harness (@astryxdesign/interaction-harness) and
playwright.interaction.config.ts: phone-sized touch projects in Chromium and
WebKit for *.interaction.browser.spec.ts specs.

Touch scripts are data (tap, drag with hold and rest, flick with an exact
release velocity and velocity profiles, hold-then-drag, pinch, rotate, any
path, a second finger mid-gesture), played as real engine input: CDP
Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own
input command in WebKit. The harness's self-test proves the timestamps, a
native fling and multi-touch delivery; a pr-interaction CI job runs the
proofs against each PR's Storybook build, and a container script runs the
WebKit project where host libraries are missing.
Springs, scroll physics, timing curves, navigation values and gesture
thresholds for the touch presentations of core components, each with its
source (Apple documentation, a WWDC session, WebKit's UIKit-matched source,
or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess
with its measurement task. A test enforces the source rules on every domain
file, in their JSDoc and typed forms, and checks the list of simulator
measurements still owed.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
…rena

An internal gesture system for touch presentations, not exported from the
package. GestureRecognizer carries UIKit's states and allowed transitions,
discrete and continuous recognizers, require-to-fail, simultaneous
recognition on request, shouldBegin and shouldReceiveTouch,
cancelsTouchesInView and the touch delays. GestureArena settles each touch
sequence without the DOM: participants, resolution order, claims, delivery
to the view, click dispositions (allow, suppress, defer and replay) and
timers as deadlines on the event clock. Its rules are tested with scripted
recognizers, independent of any real one.
…sholds

Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt
apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on
the event clock within 10 pt, even when the main thread was blocked, and a
zero duration begins on the touch itself. The arena tests with these real
recognizers cover tap against double tap, delegate requirements, deferred
clicks and cancellation.
Touch behaviours key on a coarse primary pointer, layout behaviours on a
compact viewport, and presentations that need both (a sheet in place of a
popover) ask for both. utils/touchPresentation.ts holds the three queries,
SSR-safe hooks (useTouchPresentation; useMotionPresentation for components
with no interaction to infer from: UIKit motion under a finger, theme
tokens under a mouse) and a one-moment read. Styles write the same queries
as StyleX media keys, and a test keeps core's compact-touch keys spelled as
the shared query.

A compact viewport is narrower than 744 px or shorter than 500 px, the
closest a size gets to UIKit's compact size class: an iPad mini in portrait
keeps the anchored popover and a phone in landscape gets the sheet; a
Max-size iPhone in landscape is the known miss. The adaptive popover-or-
sheet presentation reads the shared query; its docs, stories and tests
follow.
A file that reads only defineConsts values from a .stylex.ts module lost
that import under the legacy {stylexOptions} form Storybook uses: the
babel wrapper's StyleX instance ran without treeshakeCompensation (the
unplugin applies its Vite default only to its own instance, which runs
after the wrapper), so once stylex.create was compiled the TypeScript
transform dropped the import and the defining module never reached the
build. A constant used as a media-query key failed the build with
"Invalid empty selector"; one used as a value shipped as a custom
property nothing declares. The wrapper now gets treeshakeCompensation by
default.

The layer split also substituted constants only within each layer, so a
constant defined on the library side and read on the product side stayed
a placeholder in either form; every layer now sees every constant.

A production build now warns, naming the variable and the rule that reads
it, when the StyleX stylesheet reads a generated custom property nothing
declares; undefinedVariables: 'error' fails the build instead, and this
repo's Storybook and Sandbox configs set it so CI catches the next case.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
createAnimator plays springs and UIScrollView coasts as Web Animations
keyframes on translate, rotate, scale and opacity, on one explicit clock, so
they keep running while JavaScript is busy. Presentation values and
velocities come from the curves, so a finger can catch any animation and a
new target keeps the velocity; the end state is committed inline and held by
the keyframes through a main-thread block, and a finished animation retires
when its own time reaches the curve's end, so a sampler can pause it; Reduce
Motion substitutes UIKit's cross-fade.
… Chromium

The Chromium driver sent a lift of one finger of several as a touchMove
listing the fingers still down, assuming CDP releases a point a move
omits. It does not: the lifted finger stayed down. A touchEnd that lists
the lifting fingers releases exactly those while the others keep moving,
so the driver sends that, and a self-test lifts one of two fingers and
checks the release, the other finger's moves and the touchend's
remaining touches.

Playwright's WebKit sends that touchend with no remaining touches, which
iOS never does. The harness does not work around it: drivers expose
partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it
skips; the README lists it with the other engine limits.
Pause every Web Animation, seek it to a time and read the presented
transform and opacity, then compare the curve with oracles that share no
code with the motion under test: cubic-bezier, the SwiftUI spring
(response, dampingFraction, initial velocity), UIScrollView deceleration
and UIKit's rubber band. Storyboards photograph seeked animations at
t = 0, 16.7, 33.3 ... ms or each step of a scripted touch, and write a PNG
contact sheet plus the JSON trace a spec asserts, under the gitignored
test-results/interaction-evidence. A timer gate holds a component's own
timers while a filmstrip is taken. pnpm storyboards runs the tagged tests.
…sented values

An animation that keeps its end value in a run of identical final
keyframes (held so a later interruption can read it) reported its whole
duration as the curve: storyboards filmed the hold, waitForAnimation's
interval reached past the landing, and waitForRest, which also required
every animation to leave the running state and read only transform, timed
out. The curve now ends where that run starts (HeldAnimation.curveEndMs,
waitForAnimation's to, the default storyboard times), and rest is no
animation pending or before its curve's end plus the presented transform,
translate, rotate, scale and opacity (and ::backdrop opacity) still for 3
frames, whatever the playState. sampleCurve composes translate, rotate and
scale with transform, so curves of the individual properties are sampled.
Timestamped composited frames (Chromium's screencast and WebKit's own),
a marker the frames can find, and the busy-main-thread proof: block the
page's main thread mid-flight and show the element still moving every
frame, with a requestAnimationFrame animation that freezes as the
negative control. Chromium trace counts of Layout, Paint and style work
inside an animation window, and long tasks. The exemplar spec proves the
BottomSheet drag to a detent with all of it: the drag follows the
finger, a flick dismisses, the settle matches its timing function, keeps
advancing through a blocked main thread in Chromium and WebKit, does no
Layout or Paint, and gets a drag and settle storyboard.
…ed machine

The proof asserted a stall under 40 ms and near-exact frame counts, so a
dropped screencast frame or two on a loaded runner failed it, and frames
delivered out of order read as the element stepping backwards.
busyWindowProof now returns a verdict that tolerates what load does to a
screencast and nothing a frozen animation does: a median frame interval
of at most 20 ms, no stall longer than three screencast intervals, and
at least 90% of the window's whole frame intervals presenting a new
position (one miss always allowed), with the raw numbers in a summary.
Screencast frames are sorted by presentation time, the cadence comes
from the frames around the window, and blockMainThread can block from
inside the page in the frame an animation has run, so a short settle is
still judged at length on a busy machine. The exemplar judges its settle
until it is within 2 px of rest, since the screencast rounds to whole
pixels. A unit test covers the limits; the rAF negative control still
fails every criterion.
The pan begins past 10 pt on either axis and leaves that travel out of its
translation, as UIPanGestureRecognizer does; axis and one-way directions
with axisRatio, axis lock, finger changes without jumps, scrollEdge, and an
opt-in non-UIKit scrollHandoff. VelocityTracker defaults to UIKit's filter
(0.2 x the newest event's velocity + 0.8 x the previous one's), which
reproduces every value UIKit reported on recorded streams.
…zers

The rest of UIKit's recognizer set on the gesture system: swipe recognized
during the motion, pinch measured from the starting span moved by its
hysteresis, unwrapped rotation, edge pans resolved against the writing
direction that fail at touch-down outside their band, and hover for mice
and hovering pens.
The touch-action an element gets from its recognizers (pinch-zoom stays
unless a pinch or rotation recognizer is attached), what the scrollers
under a touch could still do along the scroll chain, and UIKit's haptic
feedback kinds on the web: navigator.vibrate where it exists, the switch
tap haptic in iOS Safari.
…s takeover

One host per document routes fingers (touch events, paired with their
pointer events), mice and pens to the recognizers on the hit path. A
recognizer that owns a touch cancels its touchmove through a non-passive
listener; pointercancel, an uncancelable move or a scroll mean the browser
took the touch; a claimed touch's touchend is cancelled and its click
swallowed, a deferred click replayed when its recognizer fails.
pointerIdOf names the pointer behind a tracked contact, so a held finger
can be handed to code that follows pointers.
… and hooks

PressTracker applies UIControl's events and its 70 pt tracking margin, the
content-touch delay inside scrollers, the quick-tap flash and the scroll
brake, with two profiles: control (UIButton) and cell (a row: delayed
highlight everywhere, cancelled past the pan hysteresis, no flash).
paintPress is the one writer of data-astryx-press and never clobbers a held
row. installPressAttribute paints every marked surface with one tracker
per touch, picks the profile from the marker's value and releases on its
clock. useGestureRecognizer attaches a recognizer with a stable identity
and no render per move.
The same touch scripts run in Chromium (CDP touches with timestamps) and
WebKit (its own touch input): touch-action per element, claims over a
scroller, no ghost click, scroll edge, pinch on its element only, a second
finger, and a long press at exactly 500 ms under a blocked main thread.
The README documents the system for component authors.
…tarts at the commit

play() bakes curves given as value and velocity functions of time (a spring
chasing a moving target, a fitted UIKit curve) like a spring.
createAnimator(dialog, {pseudoElement: '::backdrop'}) drives a
pseudo-element: a static filling animation holds its end value.
animatorFor(element) shares one animator per element, so callers animating
different axes compose in one translate track; atRest() waits out every
curve on its properties, and rebase() moves them by a layout shift keeping
their springs. A call from rest gets no start time, so the browser starts it
at its commit, as Core Animation does, and handle.started reports that
start. Every animation and set() promotes its channels to a compositing
layer in the same task (will-change, or a host custom property for a
pseudo-element) until 500 ms after the last motion: Mobile Safari only runs
a script-started curve through a block on an element that already has a
layer. Under Reduce Motion the substituted cross-fade can follow a
presentation's own spring, and crossfadeAs gives a slide that changes no
opacity its direction. The header lists the iOS Safari limits measured on
the simulator.
…eraction harness

useAnimator wraps the animator for React without re-rendering per frame.
Browser proofs on the interaction harness, in Chromium and WebKit on the
phone profile: a spring keeps advancing on every screencast frame through a
1 s main-thread block and every frame sits on its analytic curve; a spring
ending inside the block keeps its end state; no Layout or Paint runs while
it animates; the engine plays the curve when seeked; a finished animation
leaves within a frame or two of the curve's end; a dialog's ::backdrop
follows the curve.
@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 25, 2026 7:50am UTC

Request Review

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

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 25, 2026, 10:24 AM UTC

vjeux added 3 commits October 24, 2026 22:23
…cription

They ran under their own Playwright config, which no CI job picks up;
spec:AST-030 FR11 asks new coverage to join its existing owner. Their
results (Chromium and WebKit, and Mobile Safari on the iOS 26.2 simulator)
stay in the pull request description, and the unit suites keep the
resolver's answers.
Nothing pinned useMenuDrillIn's theme read: a fixed default query left the
suites green. A 900 px coarse screen now flies the sub-menu out under the
default md (768) and drills in under a theme whose md is 1000, as the root
sheet does (spec:AST-012 FR9).
… reads

Its default ignored a theme that moves md; nothing calls it yet, so the
compact definition is now required and its first caller passes the
theme's (useCompactDefinition()). The changeset names ContextMenu's
sub-menus, whose drill-in changes too.
Touch presentations get one native-feeling behavior by default on every
touch device: measured values, compositor motion a finger can catch,
recognizer-tracked gestures, two press profiles, a Reduce Motion cross-fade
(design:reduced-motion), menus and popovers under a finger (FR26-FR35), and
one release decision for every drag that commits (FR36-FR40). Touch motion
is themeable through purpose-named roles; thresholds stay internal.
useMenuPress, DropdownMenu, ContextMenu and Popover point to the new
requirements.

Retries meta#8316, whose review rounds were spent, carrying its last revision
plus the review fixes: the record is current with its deciders named, FR34
follows DropdownMenu's drill-in (a parent-visible sub-menu is OQ10), the
role list covers row swipes, Popover and sub-menus with purpose-led names,
FR2 admits an owner decision as a source, and the quick-tap and
longPressDelayMs wording is settled.

Co-authored-by: vjeux <197597+vjeux@users.noreply.github.com>
cixzhang and others added 5 commits October 24, 2026 23:05
…l subpath

The package no longer exports @astryxdesign/core/motion/experimental or
@astryxdesign/core/gestures/experimental: a file path alone does not mark
an API as experimental, and a public experimental API needs explicit
marking in the API itself and its own spec decision. Core components
import motion and gestures from ../motion and ../gestures, and Lab through
internal workspace imports.

Removes the two entry modules and their tests, their package.json and
sync-exports entries, the hook docs whose import path was a removed
subpath (useAnimator, useGestureRecognizer, usePressTracker,
useTouchPresentation; already left out of search and the docsite), and
the subpaths changeset. The READMEs and the gestures barrel say the
modules are internal.
… into the touch foundation

# Conflicts:
#	packages/core/src/utils/touchPresentation.ts
The animator and reducedMotionFade() read the person's settings
(prefers-reduced-motion and a coarse primary pointer) and nothing else: the
prefersReducedMotion and isCoarsePointer options are gone, so no caller can
keep the motion or change which form it takes (design:reduced-motion DR4),
and the experimental motion surface adds no Reduce Motion override. Tests
set the settings through matchMedia.

Co-authored-by: vjeux <197597+vjeux@users.noreply.github.com>
…hangesets say internal again

The proposed ./motion/reducedMotion.stylex export goes: its constants carry
no experimental marking and had no spec decision, and Reduce Motion stays
internal (design:reduced-motion). Its Lab import test goes with it. With the
experimental subpaths gone, the touch pieces' changesets say they are
internal and not exported again, and search and the docsite no longer need
to skip experimental hook docs.
A static import graph of core's source fails when anything imports a table
as a namespace object (which keeps every table, register function and the
role registry), when shared code imports a table that belongs to one
component, or when a component reaches a component's table only through the
uikit barrel or shared code. A made-up package proves each case fails.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold until an engineering owner rules on two Popover touch rules that no current spec states: that a touch-presented Popover closes when a pan starts outside it (its trigger included), and that a surface that fits takes touch-action: pinch-zoom; the spec:AST-094 that component:Popover now points to does not state either at this head (only #8316's FR32 does). An engineering owner records the decision by commenting /approve-spec 4a984aacb0178d70a4b2fe0e3fc627b0d16ab93f on this pull request, and the author may do so as an engineering owner; otherwise the change waits for #8316. Separately, these need changes:

  • Once the predecessors land, rebase so the diff is this commit alone; as issued it carries 33,652 authored lines from #8076, #8186 and #8187.
  • Land after #8316 so that component:Popover's pointer reaches a spec:AST-094 that states the pan dismissal.
  • In the stack, reconcile spec:AST-094 FR4 and the theme-roles changeset with the theme-retunable touch springs, and build the compact query from the theme's own md point (spec:AST-012 FR9).

[Automated review]

Comment on lines +326 to +328
entrance. It also owns the dismissal of a light-dismiss popover by a pan that
starts outside it, and a surface that does not scroll takes no pan while a
pinch still zooms the page.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This names spec:AST-094 as the owner of the pan-outside dismissal and the still surface, but the spec:AST-094 this pull request adds (FR1–FR23) states neither; only #8316's FR32 does, and #8316 adds the same file. Landing after #8316 and rebasing onto its record keeps this pointer from citing a rule that is not on main.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-012 FR9 makes compact "below the effective md point", and FR1 lets a theme override md. This query is built once from the default breakpoints, so presentation="adaptive" keeps switching at 768 px under a theme with its own md (a coarse-pointer tablet 800 px wide gets the popover where FR9 gives the sheet). Building it from the theme's breakpoints would conform.

'@astryxdesign/cli': patch
---

[feat] Themes can retune touch motion roles. `defineTheme({motion})` takes `springs`, `durations` and `curves` keyed by motion role, the table path of a measured touch motion value (`springs.sheetPresent`, `navigation.transitionMs`), at the root or in an adaptation rule, and writes one `--motion-*` variable per role it sets. A role the theme leaves out keeps its measured default, an unknown role id is refused, and the duration scale (`fast`, `medium`, `ratio`) is optional when a theme sets only roles. No shipped component reads the roles yet. `astryx docs motion` lists every motion role with its CSS variable and default, and the theme authoring guide and template describe the new fields.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"No shipped component reads the roles yet" no longer holds in this stack: Popover's touch presentation registers the springs table and the animator resolves each spring through resolveMotion (popoverSurfaceMotion.ts:321-323, motion/animator.ts:1536). The draft spec:AST-094 FR4 also says touch motion is not themable, so either FR4 or the roles need to change, and this sentence with them.

vjeux and others added 2 commits October 24, 2026 23:15
Motion and gestures are internal to core, so Lab reaches them through
workspace imports of their module paths (@astryxdesign/core/motion/animator,
@astryxdesign/core/gestures, @astryxdesign/core/utils/touchPresentation),
which core's exports do not list. Vitest and Storybook already alias
@astryxdesign/core to its source; Lab's typecheck and declaration build now
map those paths to core's built types the same way.
- Selector DEC-1, AST-057, AST-025 FR14, design:user-states DR1 and DR3,
  AST-013 FR3 (an Android Chrome row for touch presentations) and the
  buttons family now point to or conform with AST-094.
- FR34 states the drill-in itself. AST-094 no longer amends AST-057 FR2's
  custom property.
- A useMenuPress caller that sets longPressDelayMs gets that wait in a fixed
  bar too, which keeps the released behavior.
- useMenuPress drops its finger-on-a-tap text; Popover FR9 is pending
  implementation.
- FR4's preset springs and curves are named by use.

Co-authored-by: vjeux <197597+vjeux@users.noreply.github.com>
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Remaining issues at 4a984aa:

vjeux added 4 commits October 24, 2026 23:59
…s close

The browser hides a popover the moment it closes (a pick, Escape, its own
light dismiss), so a touch presentation whose close stays on screen plays it
on a copy of the popover taken as it closes. DropdownMenu's touch menu keeps
a private one; Popover's touch presentation and others need the same, so the
layer runtime gets one helper, Layer/layerExitCopy.ts (placeLayerExitCopy),
with its own tests, and architecture:layer-runtime names it (the Exit copy
section and INV13). It is the one exit copy a layer may have
(architecture:component-style-authoring INV7); its owner removes it when its
spring settles. Nothing uses it yet.

- Drawn where the layer was drawn: laid out on the layer's own box (a fixed
  layer's offset box, fractions of its used size kept) and carrying the
  transform and opacity its running animations hold, so a copy taken while
  the layer is still entering holds where it was drawn.
- No ids, no form names and no form owner (a cloned checked radio would
  otherwise take the live group's selection, and a named field would
  submit), inert, aria-hidden, takes no pointer events, scrolled as the
  layer was, with no alignment against the anchor it left.
- One copy per layer: placing another for the same layer takes the previous
  one away; another layer's copy stays.
- Shown only in the top layer, as a manual popover, so a clipping,
  transformed or sticky ancestor neither cuts nor moves it; no fixed box and
  no z-index stand in for it (spec:AST-027 FR8). Where the browser refuses
  the show during the closing popover's own toggle (Android Chromium 158:
  "Invalid to show a popover during another show operation"), the copy
  waits, hidden, and shows once the toggle is over: after the script that
  closed the layer (a microtask) or, for the browser's own light dismiss, in
  the next frame before it paints. A browser without the popover API, or
  one that still refuses, gets no copy (null), and the close is immediate.

Browser proofs (Chromium, WebKit): a copy placed as a layer closes
mid-entrance is in the top layer with no z-index, its drawn box the
layer's within half a px; a light dismiss gets it too, uncut by a clipping,
transformed panel; a second copy for the same layer replaces the first
while another layer's copy stays.
…s back into it

Measured on the iOS 26.2 simulator (UIPopoverPresentationController kept a
popover with the .none adaptation): the popover grows out of the point where
it meets its source, its scale (0.01 to 1), its length away from the source
(100 pt to its own) and its opacity on one spring (mass 1, stiffness 331.901,
damping 29.149), the edge on the source held in place; it shrinks back into
the same point on the dismiss spring (283.973 / 28.648). Under Reduce Motion
UIKit fades it on the same springs with no size or scale change; here the
current design:reduced-motion record caps that fade (below).

- popoverSurfaceMotion: the scale across the source's edge and the scale
  times the length along the axis away from it, played as closed-form curves
  on the compositor; interruptible both ways from the frame on screen. The
  close plays on the layer runtime's exit copy. Its pure geometry
  (popoverSurfaceFrame.ts: where it grows from, the frame at a progress) has
  no DOM and no animator, so the browser proof imports it on its own.
- Popover: under a finger (the shared resolver's coarse primary pointer,
  one touch model on every touch device) it runs; a mouse keeps the layer's
  entrance. Its touch-only code sits behind one module, PopoverTouch.ts,
  imported once (AST-080 FR8).
- spec:AST-094 FR31 (meta#8378) and component:Popover FR9 state this; the
  close plays on the layer runtime's exit copy (INV13), which stands where
  the surface is drawn and is null where there is no top layer (the close
  is then immediate).
- motion/uikit: popover.ts (collapsed scale and length) and
  springs.popoverPresent / popoverDismiss, with their sources: theme motion
  roles (spec:AST-094 FR4), so a theme can retune them and the CLI's motion
  role table lists them; the touch presentation registers the springs
  table before its first animation, and imports its tables by path; M-PO1
  for the popover above and beside its source.

Under Reduce Motion it follows the animator's policy (design:reduced-motion,
from meta#8076): on a coarse pointer it neither grows nor springs, it lands at
its full size and cross-fades in, and out where it stands, opacity only, in
200 ms; on any other pointer it changes at once. UIKit itself fades a
popover on its presentation springs, about half a second.
On iOS 26.2 a popover presented by a finger is dismissed by a pan that
starts outside it as soon as the pan begins, and the same touch goes on to
scroll what is under it; a tap or a hold outside dismisses at the lift. The
browser's light dismiss covers the tap and the hold but ignores the
pointercancel that announces a pan, so a touch-presented Popover stayed open
while the page scrolled under it.

DropdownMenu's touch menu closes on that pointercancel with its own effect.
The rule becomes a layer runtime hook, usePanOutsideDismiss (with its own
tests), which Popover uses through its touch module (PopoverTouch.ts);
DropdownMenu can move onto it in its own change. It listens for the press's
release only while a touch that went down outside is down
(react-component-runtime INV5), and reads its options through a ref, so a
re-render between the press and the cancel keeps the press. A popover with
hasLightDismiss={false} keeps its explicit dismissal. component:Popover
points to spec:AST-094 for this dismissal.

A pan that starts inside a popover whose surface does not scroll no longer
scrolls the page behind it: the still surface takes no pan (touch-action:
pinch-zoom, so a pinch still zooms the page), and a scrolling surface keeps
its pan with overscroll-behavior: contain, as before. On main that pan
scrolled the page and the popover rode away with its trigger; behind a UIKit
popover the page stays put.

A pan that starts on the trigger counts as outside: on iOS 26.2 a pan that
starts on a UIKit popover's source dismisses it as the pan begins and
scrolls the view under it, like any outside pan (a menu whose trigger's
press-and-drag picks a row would pass the trigger as inside). Nested
surfaces: a pan inside a parent popover but outside its child closes only
the child, and a pan in a dialog outside its popover closes only the
popover.
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Both items are addressed in 84aa650:

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold until an engineering owner decides two Popover touch rules that no current spec states: that a touch-presented Popover closes when a pan starts outside it (its trigger included), and that a surface that fits takes touch-action: pinch-zoom, under a mouse too. The spec:AST-094 this pull request carries is an earlier copy of the still-open #8378, so an engineering owner either approves the records at this head by commenting /approve-spec 84aa650f91df9a1e34e337e2fb65137bc994290e (the author may do so as an engineering owner), or the change waits for #8378 to land. Separately from that decision, these need changes:

  • Once the predecessors land, rebase so the diff is this commit alone; as issued it carries 33,737 authored lines.
  • Give the stack's velocity tracker one release filter that reads 0 after a still pause, as the current interaction-modality INV10 and AST-100 FR2 require, without selectable strategies.
  • A pan outside while the popover is still growing closes it, which contradicts the FR35 that component:Popover FR9 adopts: ignore those touches, or take FR35 out of FR9.
  • Reword the exit-copy and motion-role changesets, which say nothing uses what this diff's Popover now uses.

[Automated review]

follow();
}
};
doc.addEventListener('pointerdown', handleDown, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This arms as soon as the popover opens, so a pan outside that starts while the popover is still growing closes it and scrolls the page. component:Popover FR9 in this diff adopts spec:AST-094 FR35, which says a drag that lands during the growth must not close it or scroll the page. Either ignore outside touches until the present settles, or take FR35 out of FR9 until it ships.

* header describes, `uikit` by default.
* @experimental
*/
export type VelocityStrategy = 'uikit' | 'lsq2' | 'iosFling' | 'window';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

interaction-modality INV10 and AST-100 FR2, now current on main, ask Core for one release filter that reads 0 when the contact stayed still before lifting, with no selectable alternatives. This tracker offers four strategies, and its uikit filter keeps the last moving value after a pause (lines 18–20), so a drag that rests before it lifts reports a flick velocity.

'@astryxdesign/core': patch
---

[chore] Internal: the layer runtime gains one exit copy for touch presentations that animate a popover out: a static, inert copy of the closing popover, shown only in the top layer where the popover was drawn, for the close to play on. Nothing uses it yet; no change in behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In this diff every touch close of Popover plays on the exit copy (popoverSurfaceMotion.ts:312), so "Nothing uses it yet; no change in behavior" misdescribes the release that carries both.

'@astryxdesign/cli': patch
---

[feat] Themes can retune touch motion roles. `defineTheme({motion})` takes `springs`, `durations` and `curves` keyed by motion role, the table path of a measured touch motion value (`springs.sheetPresent`, `navigation.transitionMs`), at the root or in an adaptation rule, and writes one `--motion-*` variable per role it sets. A role the theme leaves out keeps its measured default, an unknown role id is refused, and the duration scale (`fast`, `medium`, `ratio`) is optional when a theme sets only roles. No shipped component reads the roles yet. `astryx docs motion` lists every motion role with its CSS variable and default, and the theme authoring guide and template describe the new fields.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"No shipped component reads the roles yet" no longer holds here: Popover's touch presentation registers the springs table and plays springs.popoverPresent and springs.popoverDismiss through the theme (popoverSurfaceMotion.ts:213, :285, :330).

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Closing: spec:AST-094 FR32 now applies to anchored menus only and leaves a Popover's dismissal unchanged; #8208 carries the revert.

@vjeux vjeux closed this Oct 25, 2026
@vjeux
vjeux deleted the uikit/c26-popover-pan-dismiss branch October 25, 2026 19:13

This branch was successfully deployed

1 active deployment
Preview — 84aa650f Deployed Oct 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants