Repository navigation
Conversation
progressCurve, mapProgress, mapRange and presentedProgressVelocity in motion/progress.ts, internal, one velocity unit (progress per second). Rebuilt on main from the pull request's own change, without the open dependency chain it no longer needs. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…ressCurve CodeBunny on meta#9755: springProgress solved the same spring as progressCurve. It now reads progressCurve (remaining = 1 - progress), so one implementation remains. Same curve, same settle epsilon (0.001) and duration.
…shows at each progress lerpPath, edgeGrowPath, each path's restartFrom and morphCurves in motion/morphPath.ts, internal. Pure geometry over a progress curve: the caller chooses the timing. Rebuilt on the progress module from the pull request's own change, without the open chain it no longer needs. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
# Conflicts: # packages/core/src/motion/README.md
… source rect createRectMorph in motion/rectMorph.ts, internal: present along a path and dismiss back onto a copy or the element, each continuing the other from the rect on screen. Rebuilt on main from the pull request's own change and ported to main's engine (createMotionAnimator). Timing follows the owner's rule for touch motion: present and dismiss take a motion, either a theme duration and easing (a tap, a key, the program) or a spring (a gesture's release, from its velocity). timedProgressCurve joins the progress helpers. architecture:react-component-runtime INV12 records the implementation gap: the size-change glide moves onto the morph next. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…opy dismissal CodeBunny on meta#9769: a dismissal onto a copy detached the animator, and a handed-in one was never attached again, so its owner's writes stopped reaching the element. Only the morph's own animator lets go now. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…er and the morph CodeBunny on meta#9769: solveAnimatedPageScroll already played this curve and keeps the velocity of the move it retargets. timedCurve in progress.ts is now that one curve; the pager and the morph's timed motion call it, so a timed present that interrupts a dismissal keeps its velocity. The INV12 note states the system only. File header and comments trimmed. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
CodeBunny on meta#9769: with nothing in flight the morph handed timedCurve a velocity of 0, which it treats as a retarget and uses to cancel the easing's starting slope. Only a motion in flight hands its velocity on now; tests check a present from rest and a dismissal from a settled present against the easing itself. Header and comments shortened. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
The type's doc, the example and the changeset name no rule of their own about which input plays which timing: each play takes the timing the adopting component's motion record names (AST-094 FR45). Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
A finger held on the trigger opens the menu on UIKit's timeline: the hold commits at 400 ms and the menu shows at 735 ms. One outside-press watch closes the menu for every pointer, and a touch closes it as it begins to pan. It carries this pull request's own diff from 6d7fceb, replayed on main after meta#9814's squash, and folds these 12 commits (the history of 6d7fceb has each one): - chore(motion): ContextMenu's touch timeline and press curve, measured on iOS 26.2 - test(ContextMenu): a held finger opens the menu on iOS 26's timeline, and a touch outside closes it as it pans - feat(ContextMenu): open from a held finger on iOS 26's timeline, with no page lock - docs(ContextMenu): FR11 cites AST-094 FR45 for the hold's measured moments - test(ContextMenu): the long-press a11y contract follows the touch model - docs(ContextMenu): the touch changeset and docs name their condition, not every touch device - test(Layer): the outside-press watch closes a layer at the press for a mouse or a pen - feat(Layer): the outside-press watch closes a layer at the press for a mouse or a pen - refactor(ContextMenu): one outside-press watch closes the menu for every pointer, and its mousedown Effect goes - docs(ContextMenu): split long file-header sentences - test(Indicator): the ContextMenu box-hold case pins the popover menu - test(ContextMenu): the long-press spec note keeps to 20 words
The hold's timer only sets ContextMenu's state, so React mounts and shows the menu in its next scheduled task. A WebKit CI run showed it at 758 ms, past the 735 ms proof's 20 ms. The proof now also wants the menu to show inside the timer task that decides it, and that task at the deadline counted from the touch. The page's stamps record the first show only.
The hold's timer opened the menu with a plain state update, which waits for React's next scheduled task: the menu showed 9 ms after the deadline in Chromium and 17 ms in the WebKit CI container. UIKit shows the menu at its moment. The open now commits with flushSync, in the timer task, as a lift's open commits in the lift's dispatch.
The menu's first show mounts its layer, a render of 6 ms in Chromium and 17 to 21 ms in the WebKit CI container inside the task that shows the menu at 735 ms. UIKit prepares a context menu as the interaction begins and only presents it at its moment. The proofs want the menu mounted, closed and out of the accessibility tree, at the commit; the same element shown at 735 ms or at the lift; and nothing left mounted by a move past the tolerance or a cancel.
A lazy context layer gains prepare(), which mounts its content without showing it, and releasePrepared(), which unmounts that content unless the layer has shown since. Both are internal to core's layer hook. At the hold's commit, ContextMenu prepares its layer, so the show at 735 ms or at the lift only reveals it. A press that ends with no menu releases it. component:ContextMenu PR1 names the commit-time mount.
On main, ContextMenu's mouse and pen dismissal listens on the page's document. A menu rendered into a same-origin iframe or a Document Picture-in-Picture window then closes on a press in the page and stays open on one in its own window. The shared outside-press watch listens in the trigger's own document, as `family:overlay-dismissal` FR8 has every member work in the document it renders in. With main's ContextMenu.tsx, this case fails.
PR1 listed one mousedown listener and the adaptive query. While the menu is open, the outside-press watch adds pointer listeners to the trigger's document and shadow roots, plus a mousedown listener where a mouse or a pen closes it at the press. With presentation omitted, ContextMenu subscribes to the coarse-pointer query while mounted.
Under a finger, a block trigger grows from the onset, 150 ms after the finger lands, toward 1.0735 times its size on springs.contextMenuLift, and returns on springs.contextMenuDismiss. It carries this pull request's own diff from 63c7a6c, replayed on main after meta#9814's squash, and folds these 27 commits (the history of 63c7a6c has each one): - test(ContextMenu): a held finger grows the trigger on the theme's motion tokens, and the timeline's other proofs - feat(ContextMenu): a held finger grows the trigger on the theme's motion tokens - test(ContextMenu): a trigger gets its size back when its press feedback goes while the menu it opened is up - fix(ContextMenu): the press feedback attaches through useAttachment, and a departing feedback gives the trigger its size back - refactor(ContextMenu): the touch layer's recognizer carries its transition listener, and the press recognizer drops onPressStart - docs(ContextMenu): FR11's growth cites AST-094 FR45 - test(ContextMenu): the trigger returns on the theme's exit duration and easing when the menu closes - fix(ContextMenu): the trigger returns on the theme's exit duration and easing when the menu closes - docs(ContextMenu): FR11 keeps the measured basis for the hold's moments, and the tests that verify them - docs(ContextMenu): the block-trigger guidance says the trigger grows while the menu is up - test(ContextMenu): the growth starts 150 ms after the finger lands, wherever the press machine marks the press - fix(ContextMenu): the growth starts 150 ms after the finger lands, wherever the press machine marks the press - test(ContextMenu): the trigger's growth plays springs.contextMenuLift, and its returns springs.contextMenuDismiss - fix(ContextMenu): the trigger's growth plays springs.contextMenuLift, and its returns springs.contextMenuDismiss - docs(ContextMenu): FR11 names the block trigger, and the tests, story and comments say the trigger springs back - refactor(ContextMenu): the press feedback stops returning the pressable marker, and two comments say what the code does - test(ContextMenu): a right-click in a scaled container gives the anchor no scale of its own - fix(ContextMenu): the anchor cancels only the press feedback's own growth - fix(ContextMenu): the press feedback imports only its two springs - test(ContextMenu): a drift the recognizer still follows grows at onset - fix(ContextMenu): keep the growth's onset while the recognizer follows - test(ContextMenu): a finger grows at its own onset, marked or not - fix(ContextMenu): a finger arms the growth from its own landing - docs(ContextMenu): the growth starts 150 ms after the finger lands - refactor(ContextMenu): the recognizer times a finger's onset - refactor(ContextMenu): drop a comment that restates followsFingerRef - refactor(ContextMenu): the onset ref comment keeps only its constraint
main's hold now rejects a hold that names no judged motion. The curve and storyboard proofs judge the trigger's scale, so they say so.
A touch that reaches the page 90 ms after its timestamp waits for Chromium's timer hold, so the growth starts about 50 ms late, and its back-dated curve skips its first frames. The proof wants the frame before the growth at rest, the first grown frame no further along than the spring one frame in, and that frame within two frames of the onset.
Chromium holds every timer for about 100 ms after it handles a touchstart, so a touch that reached the page late grew late, and its back-dated curve skipped its first frames. The first frame at or after the onset now starts the spring from rest. The motion engine is built in the first frame after the finger lands, not in its touchstart handler. A lift, a move past the tolerance, a cancel and the menu's display stop the frame watch.
The proof read "the menu shows in the lift's task" as "within one frame of the lift". Merged with main, the menu's render in that task takes 17 ms in the WebKit CI container, so the time proxy failed while the menu still showed before any frame. A page flag now records whether the menu shows while the lift's listeners run.
Merged with main, the lazy-sheet bundle proof pins that a menu with an on-demand sheet keeps the animator on demand. ContextMenu's touch growth plays on the animator from the first touch, so the animator loads with the menu. The proof now expects that for ContextMenu, and `rideKeyboard` still loads only with the sheet.
A WebKit CI run showed the menu at 890 ms, inside the timer task that decided it, so that task itself started late. The proof now logs the page's frames. A deciding task that starts within a frame of its due time keeps the 20 ms bound. A later start that a frame gap over 50 ms covers fails with FrameStarvationError, its numbers named; any other late start fails bare. The bound does not move.
Where a coarse pointer's held finger opens the menu, the same finger slides onto an item, which lights under it, and its lift selects it. A presentation="popover" or fine-pointer long press hands nothing over. It carries this pull request's own diff from bc4efd0, replayed on main after meta#9814's squash, and folds these 11 commits (the history of bc4efd0 has each one): - test(useMenuPress): a pointer that went down elsewhere is the menu's opening press - feat(useMenuPress): a pointer that went down elsewhere is the menu's opening press - test(ContextMenu): the finger a long press opened the menu under slides onto an item and its lift selects it - feat(ContextMenu): the finger a long press opened the menu under slides onto an item, and its lift selects it - docs(ContextMenu): FR12 records the slide onto an item after a long press opens the menu - docs(ContextMenu): the slide's release note and docs say the menu opens at the finger - docs(ContextMenu): FR12's slide follows a long press that opens the menu at the finger - docs(ContextMenu): FR5 points the slide to FR13 - docs(core): useMenuPress's @output keeps main's sentence - docs(ContextMenu): the slide changeset names where the slide applies - docs(ContextMenu): the slide changeset covers adaptive popovers too
The 735 ms proof wants the menu to show inside the timer task that decides it. Under Reduce Motion the menu opens on the same timer, so its proof wants the same: the show inside that task, which starts at the deadline counted from the touch.
A lifted trigger's tint swallows a tap while the lift still grows. The long-press a11y case's outside tap now waits until no animation runs on the lift, so it lands on a settled tint and closes the menu.
The layout effect that picks the menu's side beside the lift reads the shown menu's height from the browser's layout. Its note now says so.
A late move whose coalesced samples cross 11.5 px before the menu's moment ends the hold, as the same samples as separate moves do. A finger that shifts slightly just before 735 ms, with its move on time, gets the menu at 735 ms. A late timer after a recent move still waits. The test harness's finger moves can carry coalesced samples.
The gesture arena now keeps each coalesced sample of a move at the time it came with, as the touch's inputSamples, and no other recognizer reads them. The press follows the finger through each of them: a drift past 11.5 px before the menu's moment ends the hold, and a sample from the moment on shows a waiting menu. The menu waits at its moment only when the page is behind its input: a recent move from before the moment, or the moment's timer, ran more than two frames late. The wait runs on the arena's clock, like the hold's deadlines. An on-time finger gets the menu at 735 ms.
It brings meta#8125's late-input fix: the gesture arena keeps each coalesced sample's own time, the press judges each sample at it, and the menu waits at its moment only when the page is behind.
A finger that crosses 11.5 px at 650 ms while the page is blocked past the menu's moment, until 760, 800 or 900 ms, ends the hold when its coalesced move arrives, as separate moves do. A still finger behind a block gets its menu once no move comes. Every case fakes the page's clock from 0, and the on-time browser case checks that the moment's own timer task shows the menu.
When the menu's timer runs more than a frame late, the page was blocked, and the finger's moves from before the moment may still be queued. The menu then waits up to 50 ms, counted from when the page runs again, so those moves are judged at their own times first. On an idle page the timer runs on time, and nothing waits. The record's FR16 gap line states the shift and its 50 ms bound.
It brings meta#8125's fix for a page blocked past the menu's moment: a late menu timer waits up to 50 ms for the moves queued behind the block, so they are judged at their own times first.
The press's units drive the page's time with the gesture arena's own injected clock, the test harness's manual clock, instead of faking performance.now(). A move can reach the page after its own time, and a blocked page runs the menu's timer late on that clock. A blocked main thread until 900 ms now shows the still finger's menu at 950 ms.
The press measured how late a move or its timer ran with performance.now(). It now reads the gesture arena's injected clock, whose new pageTime() returns the clock's own reading. In a browser that clock is performance.now(), so nothing changes there; the units drive it with the test harness's manual clock.
It brings meta#8125's final head: the press reads the page's time from the gesture arena's injected clock, which is performance.now() in a browser, so nothing changes there.
The menu's entrance plays as a transform on its layer. A slow first frame, such as WebKit's on a cold shader cache, can hold that transform at its start when the case first reads the menu. The case now reads the menu's laid-out position, which an anchor that moved the menu still changes and the entrance does not.
The menu's entrance plays as a transform on its layer. A slow first frame, such as WebKit's on a cold shader cache, can hold that transform at its start when the inline trigger's case first reads the menu. The case now reads the menu's laid-out position, which an anchor that moved the menu still changes and the entrance does not.
A sandbox page lists message rows with the same ContextMenu. Its ?lift=L2 switch turns the lift off for the page, through a preview-only check in the touch layer. The lift module drops its uncalled dismissSurface. Not for merge.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
This preview is not ready to merge: an engineering owner's spec decision is pending, and the changes below block it either way.
Spec decision: this head proposes ContextMenu record requirements the current specs do not state (FR12, FR13, DD2, DEC-3 and the PR1 rewrite). Approval waits on an engineering owner recording that decision with /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or on the same decision made on #8201.
Blocking:
ContextMenuTouchLayer.tsxreadsglobalThis.__astryxPreviewLiftShape, so any page that sets it to'L2'turns the lift off for every ContextMenu in its window; keep this switch out ofpackages/core.- Removing
dismissSurfaceleaves the lift module without the exit on the layer runtime's exit copy thatarchitecture:layer-runtimeINV15 requires, andliftFromTrigger.interaction.browser.spec.ts:443fails while three more cases close through a no-opdismissMenu. .changeset/lift-module.mdandliftFromTrigger/README.mdstill say the surface shrinks back on the exit copy, which this head no longer does.packages/core/src/motion/uikit/contextMenu.tsis a new internal shared module (imported byContextMenu.tsx,ContextMenuLift.ts,ContextMenuTouchLayer.tsx,contextMenuPress.tsandcontextMenuPressFeedback.ts) that no current record names: it needs an owner API decision that says why existing modules are not enough.ContextMenu.presentationdefault'popover'→ unset (ContextMenu) is outside this pull request's scope (no component scope): name it in the scope and get an owner decision, or split it into its own pull request.- Opening the lifted menu lays the page out two or three times in one task (
liftFromTrigger.ts:899, thenmenu.offsetHeightinContextMenu.tsx:849-865); let anchor positioning choose above or below instead of measuring. - At 15,435 authored lines for a 218-line preview, the scope is too broad; review the lift through #8201's stack and keep this pull request to the sandbox page.
CI has not run on this head yet.
| } | ||
|
|
||
| /** Preview only: the page's `?lift=L2` switch turns the lift off. */ | ||
| function isLiftOffForPreview(): boolean { |
There was a problem hiding this comment.
This global lives in @astryxdesign/core, so any page that sets globalThis.__astryxPreviewLiftShape = 'L2' turns the lift off for every ContextMenu in its window, against ContextMenu FR5 and AST-094 FR33. Keep the preview switch in the sandbox page, for example by rendering the L2 rows with triggerAs="span" and a block triggerXstyle, which takes the existing no-lift branch.
| ); | ||
| }, | ||
| // Preview only: the lift has no surface dismissal to call. | ||
| dismissMenu: () => {}, |
There was a problem hiding this comment.
With dismissMenu a no-op, the cases at liftFromTrigger.interaction.browser.spec.ts:389, :573 and :645 no longer exercise the close they name, and :443 fails. Keep dismissSurface, or drop those calls and the failing case with it.
| ).toEqual([]); | ||
| }); | ||
|
|
||
| test('the menu grows out of the press into its box and keeps nothing inline at rest; its close shrinks the exit copy back into the press', async ({ |
There was a problem hiding this comment.
This case waits for the exit copy that dismissSurface placed; with dismissSurface removed it fails at this head, and INV15's exit on the runtime's exit copy has no proof left.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: one opt-in lift-from-trigger presentation for touch surfaces. The trigger itself lifts into the top layer above a tinted backdrop; a surface grows out of the press beside it on the shared rect morph and shrinks back into it on the layer runtime's exit copy; the trigger returns to its place. Translate, scale and opacity only; nothing mounted at rest, three elements while lifted, and no shadow. A zoom from a thumbnail lifts a copy of the thumbnail's picture into the viewer instead, while the thumbnail hides in place. Not exported from the package entry point; ContextMenu's touch lift uses it, and nothing else does yet. |
There was a problem hiding this comment.
The head no longer shrinks a surface back on the layer runtime's exit copy (dismissSurface is removed), so this release note announces an exit that does not ship. The README says the same at lines 6, 33-35 and 70.
| * Every leaf has one or more `@source`. A GUESS value also has the `@measure` | ||
| * task that confirms or replaces it. uikit.test.ts enforces both. | ||
| */ | ||
| export const contextMenu = { |
There was a problem hiding this comment.
packages/core/src/motion/uikit/contextMenu.ts is a new internal shared module (imported by ContextMenu.tsx, ContextMenuLift.ts, ContextMenuTouchLayer.tsx, contextMenuPress.ts and contextMenuPressFeedback.ts) that no current record names: it needs an owner API decision that says why existing modules are not enough.
| isDisabled = false, | ||
| onOpenChange, | ||
| presentation = 'popover', | ||
| presentation, |
There was a problem hiding this comment.
ContextMenu.presentation default 'popover' → unset is a public default change (BREAKING by the API rules) riding in a chore(sandbox) pull request with no component scope: name ContextMenu in the scope and get an owner decision, or leave the move to #8201's stack.
| ); | ||
| // Syncs the menu's side with the browser's layout of the shown menu: its | ||
| // height picks below or above the lift, before the frame paints. | ||
| useIsomorphicLayoutEffect(() => { |
There was a problem hiding this comment.
This effect reads menu.offsetHeight after the commit writes the new placement, in the same task where presentSurface already forced a layout after the lift's writes, and reads again if the side flips. Anchor positioning can pick above or below (position-try-fallbacks: flip-block via stylex.positionTry) without measuring (INV18).
Preview only: #8201's head d096b78 plus one sandbox page,
/sandbox/pages/context-menu-lift-shapes/, for a check on a real iPhone. Do not merge; it closes after the check.The page lists message rows, each with the same ContextMenu.
?lift=L0keeps the lift as #8201 has it, without the lift module's uncalleddismissSurface, which changes nothing a person sees.?lift=L2turns the lift off for the page, so the menu opens where the finger landed, as at #8133's head. A preview-only check in the touch layer reads the page's switch.Testing
At 61de1b5: Prettier and ESLint pass on the changed files, core's source typecheck passes, and the sandbox build exports and verifies the page (845 routes).
The lift's browser spec in Chromium: 15 passed, 1 failed. The failing case, "the menu grows out of the press into its box", checks the close through
dismissSurface, which this preview removes. Three other cases call that close as one step, which is now a no-op.