Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3dd581e
feat(core): one progress module for the motion foundation
vjeux Nov 3, 2026
3cfb3b1
refactor(animatedLayout): take the settle spring's progress from prog…
cixzhang Nov 3, 2026
273da1e
feat(core): the rect morph's paths, the rect a grow-out presentation …
vjeux Nov 3, 2026
f1149cd
Merge remote-tracking branch 'origin/main' into motion/morph-path
cixzhang Nov 3, 2026
131ca14
feat(core): one rect morph for touch presentations that grow out of a…
vjeux Nov 3, 2026
f19df27
fix(rectMorph): a handed-in animator stays on the element through a c…
cixzhang Nov 3, 2026
e3a43d0
fix(motion): one timed curve keeps a retargeted velocity, for the pag…
cixzhang Nov 4, 2026
c99210a
fix(rectMorph): a timed motion from rest plays its easing as given
cixzhang Nov 4, 2026
3272828
docs(rectMorph): the timing follows the component's motion record
cixzhang Nov 5, 2026
df642e0
Merge main into motion/morph-path
vjeux Nov 8, 2026
8c8f726
Merge motion/morph-path into motion/rect-morph
vjeux Nov 8, 2026
3fdca8f
fix(rectMorph): AST-094 links the morph; the proof imports a safe test
vjeux Nov 8, 2026
d4903e2
test(SnapPaging): the pager's bundle lists the progress module
vjeux Nov 8, 2026
1b794af
test(motion): the popover's proofs read Popover's live geometry
vjeux Nov 8, 2026
5dd6a6a
Merge motion/morph-path into motion/rect-morph
vjeux Nov 8, 2026
3bfa524
refactor(motion): one eased progress, with a retargeted velocity
vjeux Nov 8, 2026
ad7f8fb
test(motion): the popover's turn-around reads Popover's live curves
vjeux Nov 8, 2026
99d352d
Merge motion/morph-path into motion/rect-morph
vjeux Nov 8, 2026
33a3ee8
docs(changeset): the rect morph's note names the eased-progress move
vjeux Nov 8, 2026
876eee1
feat(lift): one opt-in lift-from-trigger presentation
vjeux Oct 26, 2026
2b23b70
docs(lift): the lift's springs are the caller's, not motion roles
vjeux Oct 26, 2026
a1c82b7
refactor(lift): the lift holds input through the layer runtime's inpu…
vjeux Oct 26, 2026
bc2d667
refactor(liftFromTrigger): the lift-from-trigger module takes its rec…
vjeux Oct 26, 2026
365fca0
test(liftFromTrigger): a lifted list item leaves its list valid and t…
vjeux Oct 27, 2026
cc48c18
fix(liftFromTrigger): an li holds a list item's spot, and takes no nu…
vjeux Oct 27, 2026
a3d820d
test(liftFromTrigger): the zoom's copy of a source, through the lift …
vjeux Oct 27, 2026
40ee8c4
feat(liftFromTrigger): liftCopy, the zoom's copy of a source through …
vjeux Oct 27, 2026
f7df475
test(liftFromTrigger): browser proofs of the zoom's copy
vjeux Oct 27, 2026
ceb3da9
docs(layer-runtime): the lift-from-trigger module exists
vjeux Oct 27, 2026
e9a26c8
fix(liftFromTrigger): each spring reaches the rect morph as a motion
vjeux Nov 8, 2026
5bc1866
refactor(liftFromTrigger): the caller names the lift's marks
vjeux Nov 8, 2026
991b361
docs(liftFromTrigger): comments say why, not which record
vjeux Nov 8, 2026
073520e
test(liftFromTrigger): a finger taps the growing lift, from the harness
vjeux Nov 8, 2026
1e1e3b5
docs(liftFromTrigger): comment sentences keep to 25 words
vjeux Nov 8, 2026
33630ee
test(liftFromTrigger): a tall source fits between the gutters
vjeux Nov 8, 2026
5747593
fix(liftFromTrigger): the lift fits between the gutters on both axes
vjeux Nov 8, 2026
7b7918c
test(liftFromTrigger): a pinch on the lifted backdrop zooms the page
vjeux Nov 8, 2026
b5ac7b9
fix(liftFromTrigger): the lift layer keeps the page's pinch-zoom
vjeux Nov 8, 2026
fdf5f74
test(liftFromTrigger): a drop mid-rise turns the tint with its velocity
vjeux Nov 8, 2026
a966b6c
fix(liftFromTrigger): the tint fades on the animator's backdrop channel
vjeux Nov 8, 2026
34c1812
refactor(liftFromTrigger): the shadow rides a rect morph of its own
vjeux Nov 8, 2026
1300256
test(liftFromTrigger): a dropped row's margin returns with no transition
vjeux Nov 8, 2026
106ec92
fix(liftFromTrigger): the restore flushes style with a non-geometry read
vjeux Nov 8, 2026
9e24365
fix(liftFromTrigger): the lifted trigger casts no shadow
vjeux Nov 10, 2026
eb7e753
test(liftFromTrigger): the growth sample reads its animations at 30 ms
vjeux Nov 10, 2026
4aa36aa
fix(liftFromTrigger): a source lifts at its layout width
vjeux Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lift-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@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; no component uses it yet.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/motion-morph-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: the rect morph's paths, the geometry touch presentations share when they grow out of a source rect and shrink back into it. `lerpPath` (a straight line between two rects) and `edgeGrowPath` (UIKit's popover, growing out of a point on its edge) name the rect an element shows at each progress, and say how an interrupted motion continues in their own law; `morphCurves` turns a path into compositor curves (`translate`, `scale` and a companion `opacity`). Not exported from the package entry point; no component uses them yet.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/motion-rect-morph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: the rect morph, one way for a touch presentation to grow out of a source rect and shrink back into it. `createRectMorph` presents an element along a path (`lerpPath`, `edgeGrowPath`) and dismisses it back on a copy or on itself, each continuing the other from the rect on screen. Each play takes the timing the component's motion record names (AST-094 FR45): a duration and easing, or a spring, starting from the velocity it is given. Reduced Motion is left to the animator. `easedProgress` moves to the progress helpers and keeps a retargeted velocity; `timedCurve` joins them. Not exported from the package entry point; no component uses it yet.

@vjeux
3 changes: 2 additions & 1 deletion docs/architecture/layer-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,8 @@ be updated only as that work ships.
INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them. The
runtime owns the shared exit copy (INV13, `placeLayerExitCopy`) and the shared
presentation hold (`Layer/presentationHold.ts`), which Popover uses for its
sheet and anchored presentations; no runtime backdrop tint or lift module
sheet and anchored presentations, and the one lift-from-trigger module exists
(INV15, `packages/core/src/liftFromTrigger/`); no runtime backdrop tint
Comment on lines +666 to +667

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional: "the one lift-from-trigger module exists" is build status inside a gaps paragraph. Stating INV15's gap as its observable mismatch keeps the record free of temporary status, for example "INV15 gap: ContextMenu and Lightbox do not use the lift-from-trigger module yet".

exists, and Dialog, Drawer, BottomSheet, Lightbox, MobileNav, ToastViewport,
and Selector each build their own top-layer mounting, exit, backdrop, or
presentation hold.
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/react-component-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ those contracts.
change and moves shifted content with a compositor-only translate played
through the shared rectangle morph (`architecture:layer-runtime` INV15;
`spec:AST-094` FR5) once Core has that morph; until then, the module plays
its own compositor translate. It never animates height. Its walk visits only
its own compositor translate. (Implementation gap: Core has the shared
rectangle morph, but the size-change glide still plays its own compositor
translate.) It never animates height. Its walk visits only
the changed element's ancestors up to the area and the element siblings on
either side at each of their levels, through `parentElement`,
`nextElementSibling` and `previousElementSibling`, descending only into an
Expand Down
1 change: 1 addition & 0 deletions docs/specs/AST-094-touch-presentations/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ verified_by:
"packages/core/src/Layer/holdEntranceInput.ts",
"packages/core/src/Layer/inputHold.ts",
"packages/core/src/hooks/usePager.ts",
"packages/core/src/motion/rectMorph.ts",
"packages/core/src/motion/uikit/segmented.ts",
"packages/core/src/motion/uikit/slider.ts"
]
Expand Down
25 changes: 16 additions & 9 deletions packages/core/src/Popover/popoverSurfaceMotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,8 @@ import {
type MotionHandle,
} from '../motion/animator';
import {holdInput} from '../Layer/inputHold';
import {mapProgress, mapRange} from '../motion/progress';
import {
easedProgress,
readThemeTiming,
type ThemeTimingTokens,
} from '../motion/themeTiming';
import {easedProgress, mapProgress, mapRange} from '../motion/progress';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regression: main has since added import {ENTRANCE_OPACITY_FLOOR} from '../motion/opacityFloor'; to this import block, so this move conflicts and GitHub cannot merge #9769 or #8485. Keeping both imports is the whole resolution: Core's typecheck passes on the merged tree.

import {readThemeTiming, type ThemeTimingTokens} from '../motion/themeTiming';
import {placePopoverExitCopy} from '../privateAttributes/popoverExit.stylex';
import {
frameOf,
Expand All @@ -56,8 +52,11 @@ export type {
/**
* The curves from `from` to `to` as `progress` moves from 0 to 1. The scale
* and the length move together, so the scale along the axis is their product.
*
* @internal Read by the rect morph's popover proofs; not exported from the
* package.
*/
function surfaceCurves(
export function surfaceCurves(
from: SurfaceState,
to: SurfaceState,
axis: 'x' | 'y',
Expand All @@ -82,8 +81,16 @@ function timingOn(element: Element, tokens: ThemeTimingTokens): MotionCurve {
return readThemeTiming(element, tokens) ?? easedProgress(0, x => x);
}

/** What `animator` shows now, split back into scale and length. */
function readState(animator: Animator, axis: 'x' | 'y'): SurfaceState {
/**
* What `animator` shows now, split back into scale and length.
*
* @internal Read by the rect morph's popover proofs; not exported from the
* package.
*/
export function readState(
animator: Pick<Animator, 'get'>,
axis: 'x' | 'y',
): SurfaceState {
const across = animator.get(axis === 'x' ? 'scaleY' : 'scaleX');
const along = animator.get(axis === 'x' ? 'scaleX' : 'scaleY');
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
import {observeResize} from '../utils/sharedResizeObserver';
import {titleOfSegment} from '../privateAttributes/segmentTitle.stylex';
import {mapProgress, mapRange} from '../motion/progress';
import {easedProgress, readThemeTiming} from '../motion/themeTiming';
import {easedProgress} from '../motion/progress';
import {readThemeTiming} from '../motion/themeTiming';
import {useSegmentedTouchTracking} from './useSegmentedTouchTracking';

/** The segments, in DOM order. */
Expand Down
73 changes: 73 additions & 0 deletions packages/core/src/liftFromTrigger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# LiftFromTrigger

The one lift-from-trigger presentation, internal to core and opt-in. A
surface that chooses it (ContextMenu's touch invocation) lifts its trigger
out of the page above a tinted backdrop, grows a surface (a menu) out of the
press beside it, shrinks that surface back into the press when it closes, and
returns the trigger to its place. A viewer's zoom from a thumbnail lifts a
copy of the thumbnail's picture instead (below). Nothing else gets it by
default, and any surface that lifts uses this module, never a copy of it.
Nothing here is exported from the package entry point; components import it
by path.

Layer stays generic. The top layer, dismissal, the input hold
(`Layer/inputHold.ts`) and the shared exit copy
(`Layer/layerExitCopy.ts`) belong to Layer; the lift only uses them. Its
motion is the rect morph's (`motion/rectMorph.ts`), and its springs, scale
rule and gap are the caller's own.

## What a lift adds

The trigger itself lifts, not a copy: it becomes a manual popover in the top
layer, laid out at its own size where it stood, so no clipping, transformed
or stacking ancestor cuts or covers it, and an empty placeholder after it
holds its spot, so the page does not move. In a list the placeholder is an
`li` that is not a list item, so the list stays valid and keeps its numbers.
The trigger takes no hits while lifted: a tap on it is a tap on the backdrop.
While lifted the page has three
elements more, whatever the trigger holds: the lift layer (its `::backdrop`
is the tint); an empty box at the lifted rect for the surface to be placed
against (anchor positioning follows transforms, so a box inside the trigger
would move with it); and the placeholder. The lifted trigger casts no
shadow. The input hold's shield joins while the lift grows and while it
closes: never more than four (`spec:AST-017` FR60, DEC-29). The
exit copy the surface shrinks on is the layer runtime's transient copy. At
rest there is nothing.

## The zoom's copy

A zoom from a thumbnail into a viewer (UIKit's `.zoom`) needs the
thumbnail's picture between the viewer's backdrop and its photo, which a
trigger in the top layer cannot be: it would cover the whole viewer, or sit
under its backdrop. So `liftCopy(source, {host, src})` lifts a copy, the one
transient clone `architecture:layer-runtime` INV15 allows: one image of the
source's picture (aspect fill, its corners) in the viewer's host at the
source's box, inert and hidden from assistive technology, while the source
hides where it stands and keeps its layout (no placeholder). The viewer moves
the copy on the rect morph with its own paths and springs; a close during the
opening turns the same copy around from the rect on screen, with its
velocity. `drop()` at the landing, or on unmount, takes the copy away and
shows the source as it was, once the last copy of it drops. While a copy is
up the page has one element more.

## Motion

Every motion is `translate`, `scale` or `opacity` on the compositor
(`spec:AST-094` FR5): the trigger and the surface on the rect morph,
which continues an interrupted motion with the velocity on screen (FR6); the
tint on the animator, as the opacity of the layer's `::backdrop`, so a drop
while it rises turns it with its velocity too. A
tap or a drag that lands while the lift and the surface grow reaches nothing
(FR35). The close holds the layer until the trigger is back, the surface's
exit is done and every press on the layer has lifted. Its timers and
document listeners end with it, and an unmount ends everything at once
(FR24). Reduce Motion is the animator's (`design:reduced-motion`).

## Files

| File | What it is |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `liftFromTrigger.ts` | `createLiftFromTrigger`: lift a trigger, grow and close a surface beside it, drop, end. `liftCopy`: the zoom's copy. |
| `useLiftFromTrigger.ts` | A component's one lift, ended when it unmounts. |
| `liftFromTriggerGeometry.ts` | How far the trigger grows, where it lands inside the layer runtime's gutter, which side the surface takes. |
| `__tests__/liftFromTrigger.interaction.browser.spec.ts` | Browser proofs (Chromium and WebKit): the top layer, the placeholder, the properties that move, counts, leaks, the zoom's copy. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file liftFromTrigger.fixture.ts
* @input Nothing: bundled by esbuild and injected into a blank page
* @output A page with a list of rows in a clipping, transformed panel, and a
* menu popover beside it. The first row is rounded by `:first-child`, and
* the second holds twelve elements. `window.__lift` lifts a row, grows and
* closes the menu, drops and ends, and logs taps and clicks. A thumbnail
* and a hidden viewer serve the zoom's copy through `window.__zoom`
* @position Browser fixture of liftFromTrigger.interaction.browser.spec.ts; test-only
*/

import {createRectMorph, lerpPath} from '../../motion/rectMorph';
import {
createLiftFromTrigger,
liftCopy,
type LiftFromTriggerOptions,
type LiftFromTriggerResult,
} from '../liftFromTrigger';

interface LiftLog {
backdropTaps: number;
clicks: string[];
}

/** The page's handle on its lift. */
export interface LiftFixture {
log: LiftLog;
lift: (id: string, springResponse?: number) => LiftFromTriggerResult | null;
presentMenu: (press: {x: number; y: number} | null) => void;
dismissMenu: () => void;
closeMenu: () => void;
showMenu: () => void;
drop: () => void;
dispose: () => void;
isLifted: () => boolean;
}

/** What a zoom proof drives: the copy, and the rect morph that moves it. */
export interface ZoomFixture {
liftCopy: typeof liftCopy;
createRectMorph: typeof createRectMorph;
lerpPath: typeof lerpPath;
}

declare global {
interface Window {
__lift: LiftFixture;
__zoom: ZoomFixture;
}
}

document.documentElement.style.cssText =
'--color-overlay: rgba(1, 18, 40, 0.4); --spacing-4: 16px;';
document.body.style.margin = '0';
document.body.style.font = '16px sans-serif';
const deep = Array.from(
{length: 3},
(_, i) =>
`<div><span>${i * 3 + 1}</span><span>${i * 3 + 2}</span><span>${i * 3 + 3}</span></div>`,
).join('');
document.body.innerHTML = `
<style>
.panel { position: absolute; left: 20px; top: 120px; width: 335px; height: 250px; overflow: hidden; transform: translateX(0.5px); border-radius: 12px; }
ul { list-style: none; margin: 0; padding: 0; }
li { box-sizing: border-box; height: 60px; padding: 0 16px; display: flex; align-items: center; gap: 8px; background: rgb(255, 255, 255); border-bottom: 1px solid rgb(221, 221, 221); color: rgb(17, 17, 17); }
li:first-child { border-radius: 12px 12px 0 0; }
#menu { position: fixed; inset: auto; margin: 0; padding: 0; border: 0; left: 40px; top: 420px; width: 250px; height: 180px; background: rgb(238, 238, 238); border-radius: 16px; }
</style>
<div class="panel">
<ul id="list">
<li id="row1">Row 1</li>
<li id="row2" style="color: rgb(0, 0, 200)">${deep}</li>
<li id="row3">Row 3</li>
<li id="row4">Row 4</li>
</ul>
</div>
<button id="page" style="position: absolute; left: 20px; top: 640px; width: 335px; height: 120px">page</button>
<div id="chip" style="position: absolute; left: 20px; top: 40px; width: 100.4px; line-height: 20px; background: rgb(255, 255, 255)"><span id="chip-a" style="display: inline-block; width: 50.2px; height: 20px"></span><span id="chip-b" style="display: inline-block; width: 50.2px; height: 20px"></span></div>
<div id="menu" popover="manual"><div role="menu" aria-label="Actions">Menu</div></div>
<button id="thumb" type="button" style="position: absolute; left: 280px; top: 20px; width: 80px; height: 80px; padding: 0; border: 0; border-radius: 10px; overflow: hidden"><img alt="A photo" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120'%3E%3Crect width='160' height='120' fill='rgb(0,140,0)'/%3E%3C/svg%3E" style="display: block; width: 100%; height: 100%; object-fit: cover"></button>
<div id="viewer" style="display: none; position: fixed; inset: 0; isolation: isolate; background: rgba(0, 0, 0, 0.8)">
<div id="photo" style="position: absolute; left: 20px; top: 260px; width: 335px; height: 251px; background: rgb(200, 0, 0)"></div>
</div>`;

function element(id: string): HTMLElement {
const found = document.getElementById(id);
if (found == null) {
throw new Error(`missing #${id}`);
}
return found;
}

const log: LiftLog = {backdropTaps: 0, clicks: []};
document.addEventListener(
'click',
event => {
const target = event.target;
if (target instanceof Element) {
log.clicks.push(target.id || target.tagName.toLowerCase());
}
},
true,
);

const lift = createLiftFromTrigger();

function options(response: number): LiftFromTriggerOptions {
const spring = {response, dampingRatio: 0.9};
return {
motion: {
lift: spring,
drop: spring,
clear: spring,
surfaceIn: spring,
surfaceOut: spring,
},
scale: {maxScale: 1.15, scaleUpPoints: 26},
surfaceStartWidth: 44,
attribute: 'data-lift',
onBackdropTap: () => {
log.backdropTaps += 1;
},
};
}

window.__zoom = {liftCopy, createRectMorph, lerpPath};

window.__lift = {
log,
lift: (id, springResponse = 0.3) =>
lift.lift(element(id), options(springResponse)),
showMenu: () => element('menu').showPopover(),
presentMenu: press => {
element('menu').showPopover();
lift.presentSurface(
element('menu').querySelector<HTMLElement>('[role="menu"]') ??
element('menu'),
press,
);
},
dismissMenu: () => lift.dismissSurface(element('menu')),
closeMenu: () => element('menu').hidePopover(),
drop: () => lift.drop(),
dispose: () => lift.dispose(),
isLifted: () => lift.isLifted(),
};
Loading