@flemo/core 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,8 @@ interface CreatePartProps {
6
6
  idle: PartVariantValue;
7
7
  enter: PartVariantValue;
8
8
  exit: PartVariantValue;
9
+ dismiss?: PartVariantValue;
9
10
  options?: PartTransitionOptions;
10
11
  }
11
- export default function createPartTransition({ name, initial, idle, enter, exit, options }: CreatePartProps): PartTransition;
12
+ export default function createPartTransition({ name, initial, idle, enter, exit, dismiss, options }: CreatePartProps): PartTransition;
12
13
  export {};
@@ -0,0 +1,76 @@
1
+ import { SwipeInfo, SwipeOptions, Transition } from './typing';
2
+ /**
3
+ * The swipe a transition declares, with its defaults filled in.
4
+ *
5
+ * A transition states as little as `{ direction }` and every caller downstream
6
+ * needs a complete answer: how far to commit, how fast, where the two sides
7
+ * are. Resolving that at each call site is how the defaults drift, so it
8
+ * happens once, here, and "a direction is a complete swipe" is a fact about
9
+ * this function rather than a promise made in a doc comment.
10
+ */
11
+ export interface ResolvedSwipe {
12
+ direction: "x" | "y";
13
+ /** How far the gesture must carry the screen to navigate, in px. */
14
+ commitDistance: (span: number) => number;
15
+ /** How fast the finger must still be going to navigate regardless. */
16
+ commitVelocity: number;
17
+ /**
18
+ * Where the drag itself carries each side, when that is not where the pop
19
+ * does. `undefined` means walk the pop, which is the case for most.
20
+ */
21
+ dragTo: {
22
+ current: SwipeOptions["current"];
23
+ prev: SwipeOptions["prev"];
24
+ };
25
+ /** Where the two sides are along their travel, 0 to 1. */
26
+ progress: (info: SwipeInfo, span: number, travelled: number) => {
27
+ current: number;
28
+ prev: number;
29
+ };
30
+ onStart: SwipeOptions["onStart"] | undefined;
31
+ onMove: SwipeOptions["onMove"] | undefined;
32
+ onEnd: SwipeOptions["onEnd"] | undefined;
33
+ /**
34
+ * Whether flemo drives the screens itself.
35
+ *
36
+ * Two drivers on one transform is how a bar drifts from the screen it
37
+ * rides, so exactly one of the two owns them, and NAMING WHERE THEY GO IS
38
+ * HOW A TRANSITION CLAIMS THEM. Declaring `current` or `prev` keeps the
39
+ * screens on the scrub whatever hooks are also written, and the hook's own
40
+ * writes to them are refused while everything else it animates goes
41
+ * through.
42
+ *
43
+ * That combination is the point rather than a leniency. A gesture that
44
+ * carries a morphing element about freely needs a hook for the element and
45
+ * has no reason to give up the screens for it — and the screens are the
46
+ * expensive half, being two full-screen layers. Writing a hook without
47
+ * naming a destination still hands them over, which is what a drag that
48
+ * moves the screens themselves to arbitrary places has to do.
49
+ *
50
+ * `onStart` only answers whether the gesture may begin, so it never costs
51
+ * the screens.
52
+ */
53
+ drivesScreens: boolean;
54
+ }
55
+ /**
56
+ * The distance at which a release navigates, as a fraction of the screen's own
57
+ * span.
58
+ *
59
+ * 50px on the 390px screen it was chosen on — the number cupertino carried
60
+ * before this was shared — expressed as a fraction so a wider screen asks for
61
+ * proportionally more rather than the same 50px.
62
+ */
63
+ export declare const DEFAULT_COMMIT_FRACTION: number;
64
+ /**
65
+ * The speed at which a release navigates however little it travelled, unless
66
+ * the transition names its own.
67
+ *
68
+ * 20 is what all three presets had written for themselves, and it is their
69
+ * taste rather than a law: a consumer transition asks for 300, a fifteen times
70
+ * harder flick. Without a way to name it, a declarative drag that wanted a
71
+ * different number would have to take over `onEnd` and give up the scrub for
72
+ * it.
73
+ */
74
+ export declare const DEFAULT_COMMIT_VELOCITY = 20;
75
+ export declare const resolveSwipeOptions: (transition: Transition) => ResolvedSwipe | null;
76
+ export default resolveSwipeOptions;
@@ -33,20 +33,40 @@ export declare const MIN_REVERSAL_SECONDS = 0.28;
33
33
  *
34
34
  * So the floor scales with what is left to travel: a release may outrun the
35
35
  * authored motion, because the finger genuinely was faster, but not without
36
- * limit. At 3x, a full-width cupertino landing bottoms out near 0.21s against
37
- * its authored 0.7s brisk, continuous with the gesture, and still
38
- * unmistakably motion. Expressed against the author's own clock and span
39
- * rather than as pixels per second, so it means the same thing on any screen
40
- * and under any transition.
36
+ * limit. Expressed against the author's own clock and span rather than as
37
+ * pixels per second, so it means the same thing on any screen and under any
38
+ * transition.
41
39
  *
42
- * THE NUMBER IS A CEILING ON THE ARTIFACT, NOT ON THE FINGER. A human flick
43
- * tops out around six or seven screen-widths a second; the readings that
44
- * produced the defect were several times that, which is a coalesced pointer
45
- * pair over a 4ms gap rather than a hand. 3x leaves every honoured release in
46
- * the device-judged table above exactly where it was the fastest of them
47
- * departs at 2.8x and only clips what no finger did.
40
+ * IT WAS 3x, AND 3x WAS CHOSEN SO THAT IT WOULD NOT BIND. The argument ran: a
41
+ * human flick tops out around six or seven screen-widths a second, the
42
+ * readings that produced the vanishing screen were several times that, so a
43
+ * ceiling above the hand "only clips what no finger did". Both halves were
44
+ * true and the conclusion was backwards. A ceiling no hand can reach is not a
45
+ * ceiling: on cupertino's 390px over 0.7s the floor only starts to bind above
46
+ * 2674 px/s, so the by-speed term alone decides every release a person can
47
+ * actually make, and it lets one run at up to 2.9x the authored motion. That
48
+ * band IS the hand. Device-reported against the 3x floor itself: "조금 빠르게
49
+ * 하면 휙 그냥 씹히듯 지나간다".
50
+ *
51
+ * 1.2 WAS PICKED ON GLASS, from a ladder of builds identical but for this
52
+ * number (3 / 2 / 1.5 / 1.2, judged on a device against the same gesture). It
53
+ * is the first value that reaches the speeds a hand produces: 2 moves nothing
54
+ * below 1783 px/s and 1.5 nothing below 1337, while 1.2 binds from 1070 up. A
55
+ * full-width cupertino landing then bottoms out at 0.583s against its authored
56
+ * 0.7s, and a fast release with 30% travelled lands in 0.408s where it used to
57
+ * take 0.163s.
58
+ *
59
+ * WHAT IT DOES NOT TOUCH. A release slower than the ceiling is unchanged — the
60
+ * by-distance and by-speed terms already ask for longer than the floor, and
61
+ * that is most of them. A CANCEL is unchanged outright: a reversal contributes
62
+ * no speed, so its length is the by-distance term, which is `authored x
63
+ * remaining` and therefore above this floor by construction at any multiplier
64
+ * over 1.
65
+ *
66
+ * A RELEASE STILL OUTRUNS THE BUTTON, by a fifth. What it no longer does is
67
+ * outrun it threefold.
48
68
  */
49
- export declare const MAX_RELEASE_SPEEDUP = 3;
69
+ export declare const MAX_RELEASE_SPEEDUP = 1.2;
50
70
  export interface SwipeSettleInput {
51
71
  remainingPx: number;
52
72
  spanPx: number;
@@ -28,43 +28,182 @@ export interface SwipeInfo {
28
28
  };
29
29
  }
30
30
  export type SwipeAnimate = (target: HTMLElement, value: TransitionTarget, options?: AnimationOptions) => Promise<void>;
31
- export type TransitionOptions = {
32
- decoratorName?: DecoratorName;
33
- driver?: "native";
34
- swipeDirection: "x" | "y";
35
- onSwipeStart: (event: PointerEvent, info: SwipeInfo, options: {
31
+ /**
32
+ * One pose along a drag, and where the drag reaches it.
33
+ *
34
+ * A single destination makes every property travel at one rate, because one
35
+ * progress walks one keyframe. Some drags do not: a screen may finish fading
36
+ * a third of the way across while it is still sliding out for the rest of it.
37
+ * Naming the pose at that third is what lets the two rates coexist without
38
+ * handing the whole drag to a hook.
39
+ *
40
+ * `at` is where along the drag this pose is reached, 0 to 1, and the last stop
41
+ * is the end whether or not it says so. It is spelled `at` rather than
42
+ * `offset` because `offset` is already a CSS property and `TransitionTarget`
43
+ * carries the whole CSS vocabulary.
44
+ */
45
+ export interface SwipeStop {
46
+ at?: number;
47
+ value: TransitionTarget;
48
+ }
49
+ /**
50
+ * Everything a transition says about its swipe, in one place.
51
+ *
52
+ * WRITING NOTHING BUT A DIRECTION IS A COMPLETE SWIPE. The drag is this
53
+ * transition's own pop keyframes walked by the gesture, the release decides on
54
+ * the distance travelled and the speed at which the finger left, and the
55
+ * landing runs on the clock the controller already computes. The hooks below
56
+ * exist for the drag that is NOT that — a screen that shrinks and is carried
57
+ * about freely, say — and taking one over is what tells flemo to stand aside.
58
+ */
59
+ export interface SwipeOptions {
60
+ /**
61
+ * The axis the gesture travels. Required, and deliberately without a
62
+ * default: a stack of pages swipes on x and a sheet on y, both are ordinary,
63
+ * and a silent guess would be wrong half the time. A transition with no
64
+ * swipe leaves `swipe` off altogether.
65
+ */
66
+ direction: "x" | "y";
67
+ /**
68
+ * How far the gesture must carry the screen before the release navigates
69
+ * rather than returning it, in px. A number is that distance; a function is
70
+ * handed the screen's own span so a transition can ask for a fraction of it.
71
+ *
72
+ * The default is the iOS-derived 50px on a 390px screen, expressed as a
73
+ * fraction so it means the same thing on any width.
74
+ *
75
+ * The SPEED half of the same question is not authorable: every preset used
76
+ * the same "or the finger was still moving" floor, so it is the controller's
77
+ * and stays out of the way.
78
+ */
79
+ threshold?: number | ((span: number) => number);
80
+ /**
81
+ * Where the drag carries the screen under the finger, when that is not where
82
+ * this
83
+ * transition's pop takes it.
84
+ *
85
+ * The drag is this transition's own pop walked by the finger, and for most
86
+ * transitions that is exactly right. For some it is not: a drag may pull a
87
+ * screen down that the pop only fades, or dip an opacity the pop takes all
88
+ * the way to zero. Those differ in SHAPE rather than in rate, so `progress`
89
+ * cannot express them, and before this the only way to author one was to
90
+ * take over `onMove` and give up the scrub.
91
+ *
92
+ * Only the destination is named. The drag starts where the screen already
93
+ * is, which is the same pose the pop starts from, so it is read from the
94
+ * same table (see FROM_VARIANT).
95
+ *
96
+ * An empty target means the side does not move at all. A LIST of stops (see
97
+ * `SwipeStop`) says the drag passes through poses on the way, which is how
98
+ * two properties travel at different rates.
99
+ */
100
+ current?: TransitionTarget | readonly SwipeStop[];
101
+ /** Where the drag carries the screen underneath. Terms as `current`. */
102
+ prev?: TransitionTarget | readonly SwipeStop[];
103
+ /**
104
+ * The speed at which a release navigates however little it travelled, in the
105
+ * units of `SwipeInfo.velocity`.
106
+ *
107
+ * The distance half of the verdict asks whether the gesture went far enough;
108
+ * this asks whether it was still going when the finger left. A flick that
109
+ * covers 20px and lets go at speed reads as "go", and a slow drag parked
110
+ * short of `threshold` reads as "come back".
111
+ *
112
+ * The default is the 20 all three presets had written for themselves, which
113
+ * is their taste rather than a law: a consumer transition in this repo's own
114
+ * app asks for 300, a fifteen times harder flick. Without this a declarative
115
+ * drag has no way to ask for a different one, and taking over `onEnd` to
116
+ * carry a single number costs it the scrub.
117
+ */
118
+ velocity?: number;
119
+ /**
120
+ * Where the gesture is along its own travel, 0 to 1, per side.
121
+ *
122
+ * The default is geometric: how far the screen has been carried over its own
123
+ * width or height. A drag that resists, eases or clamps says so here, so the
124
+ * FEEL stays the author's while the keyframes stay the transition's.
125
+ *
126
+ * TWO NUMBERS, NOT ONE, because the two sides legitimately walk at different
127
+ * rates. `material` is the case that proves it: the screen being pushed away
128
+ * travels its own height, so its progress keeps growing as the rubber band
129
+ * stretches, while the screen arriving underneath travels 56px and stops
130
+ * there. One scalar cannot say both, and a drag driven by the wrong one is a
131
+ * drag that no longer resists.
132
+ *
133
+ * `current` is the screen under the finger and `prev` is the one coming out
134
+ * from under it, which are the same two the hooks are handed as
135
+ * `currentScreen` and `prevScreen`.
136
+ */
137
+ progress?: (info: SwipeInfo, span: number) => number | {
138
+ current: number;
139
+ prev: number;
140
+ };
141
+ /**
142
+ * Whether the gesture may begin at all. Returning false abandons it before
143
+ * anything moves.
144
+ */
145
+ onStart?: (event: PointerEvent, info: SwipeInfo, options: {
36
146
  animate: SwipeAnimate;
37
147
  currentScreen: HTMLDivElement;
38
148
  prevScreen: HTMLDivElement;
39
149
  onStart?: (triggered: boolean) => void;
40
150
  }) => Promise<boolean>;
41
- onSwipe: (event: PointerEvent, info: SwipeInfo, options: {
151
+ /**
152
+ * Move the screens yourself, once per follow frame.
153
+ *
154
+ * TAKING THIS OVER MEANS FLEMO DOES NOT. The screens are yours for the whole
155
+ * drag, so the two of you can never be writing one transform between you —
156
+ * which is what makes a bar drift from the screen it rides. The cost is that
157
+ * the drag is a style write per frame rather than an animation the
158
+ * compositor owns, and the release then has to commit one (see the note on
159
+ * the scrub in createSwipeController for what that measures).
160
+ */
161
+ onMove?: (event: PointerEvent, info: SwipeInfo, options: {
42
162
  animate: SwipeAnimate;
43
163
  currentScreen: HTMLDivElement;
44
164
  prevScreen: HTMLDivElement;
45
165
  /**
46
- * Report the gesture's VERDICT so the decorator and the parts can
47
- * follow it. The controller supplies their progress itself, against
48
- * the box the screen is actually dragged over, so a second argument
49
- * is accepted for source compatibility and is not read.
50
- *
51
- * It used to be. A transition's own progress is in the transition's
52
- * own unit — material's is a pull in pixels, layout's is a constant
53
- * — so what reached a decorator depended on which preset it was
54
- * paired with, and could not honour the 0-100 those hooks document.
166
+ * Report the gesture's VERDICT so the decorator and the parts can follow
167
+ * it. The controller supplies their progress itself, against the box the
168
+ * screen is actually dragged over, so a second argument is accepted for
169
+ * source compatibility and is not read.
55
170
  */
56
171
  onProgress?: (triggered: boolean, progress?: number) => void;
57
172
  }) => number;
58
- onSwipeEnd: (event: PointerEvent, info: SwipeInfo, options: {
173
+ /**
174
+ * Land what the drag was carrying. Same terms as `onMove`.
175
+ *
176
+ * WHOEVER OWNS THE SCREENS OWNS THE VERDICT. A transition that took them
177
+ * over answers the commit question here, and its answer is the navigation's.
178
+ * One written beside a declared destination is told the answer instead: it
179
+ * arrives as `triggered`, the clock every participant lands on is already
180
+ * settled, and whatever this returns is not read — so a hook that only wants
181
+ * to land an element of its own does not have to restate a rule it has no
182
+ * opinion about.
183
+ */
184
+ onEnd?: (event: PointerEvent, info: SwipeInfo, options: {
59
185
  animate: SwipeAnimate;
60
186
  currentScreen: HTMLDivElement;
61
187
  prevScreen: HTMLDivElement;
188
+ /** The verdict, when flemo owns the screens and has already decided. */
189
+ triggered?: boolean;
62
190
  onStart?: (triggered: boolean) => void;
63
- }) => Promise<boolean>;
64
- } | {
191
+ }) => Promise<boolean | void>;
192
+ }
193
+ /**
194
+ * A transition's non-keyframe options.
195
+ *
196
+ * IT USED TO BE A UNION discriminated on a flat `swipeDirection`, with the
197
+ * three hooks required alongside it. Every option added to the swipe then had
198
+ * to be declared absent on the other arm as well (`swipeDirection?: never`) or
199
+ * a caller holding the union could not read it without narrowing first, and
200
+ * the padding grew with the surface. One optional object says the same thing,
201
+ * present is a swipe and absent is not, and says it once.
202
+ */
203
+ export type TransitionOptions = {
65
204
  decoratorName?: DecoratorName;
66
205
  driver?: "native";
67
- swipeDirection?: never;
206
+ swipe?: SwipeOptions;
68
207
  };
69
208
  export interface BaseTransition {
70
209
  name: TransitionName;
@@ -6,6 +6,10 @@ export type MotionTarget = TransitionVariantValue["value"] | InitialTarget;
6
6
  export interface VariantMotion {
7
7
  from: MotionTarget;
8
8
  to: MotionTarget;
9
+ via?: readonly {
10
+ at: number;
11
+ value: MotionTarget;
12
+ }[];
9
13
  duration: number;
10
14
  delay: number;
11
15
  ease: AnimationOptions["ease"] | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flemo/core",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
4
4
  "description": "Framework-agnostic primitives for flemo: history, navigation, transitions, task manager.",
5
5
  "main": "./dist/index.mjs",
6
6
  "module": "./dist/index.mjs",
@@ -39,14 +39,14 @@
39
39
  "@flemo/eslint-config": "0.0.0",
40
40
  "@flemo/tsconfig": "0.0.0",
41
41
  "@types/node": "^24.13.1",
42
- "@vitest/coverage-v8": "^4.1.11",
42
+ "@vitest/coverage-v8": "^5.0.0",
43
43
  "csstype": "^3.2.3",
44
44
  "eslint": "^9.39.5",
45
- "jsdom": "^29.1.1",
45
+ "jsdom": "^30.0.1",
46
46
  "typescript": "^6.0.3",
47
47
  "vite": "^8.2.2",
48
- "vite-plugin-dts": "^5.0.3",
49
- "vitest": "^4.1.11"
48
+ "vite-plugin-dts": "^5.1.0",
49
+ "vitest": "^5.0.0"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"
@@ -1,3 +0,0 @@
1
- export { WARM_ATTR } from '../../dom/attributes';
2
- export declare const resetCompositorWarmForTesting: () => void;
3
- export default function holdCompositorWarm(): () => void;