flet-box 1.1.0 → 1.1.2

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.
Files changed (135) hide show
  1. package/README.md +16 -12
  2. package/bin/commands/checkUi.js +276 -45
  3. package/bin/commands/create.js +8 -1
  4. package/bin/commands/createBundle.js +18 -6
  5. package/bin/commands/createScreen.js +1 -1
  6. package/bin/commands/runBundle.js +1 -1
  7. package/bin/utils/bundleConfig.js +47 -3
  8. package/bin/utils/colors.js +0 -8
  9. package/bin/utils/helpers.js +0 -5
  10. package/bin/utils/images/jpeg.js +2 -3
  11. package/bin/utils/templates.js +101 -85
  12. package/docs/README.md +24 -1
  13. package/docs/cli/README.md +51 -10
  14. package/docs/guides/README.md +5 -2
  15. package/docs/guides/accessibility.md +282 -0
  16. package/docs/guides/clean-ui.md +53 -8
  17. package/docs/guides/first-app.md +6 -7
  18. package/docs/guides/minification-and-protection.md +1 -1
  19. package/docs/guides/mobile-and-platforms.md +51 -0
  20. package/docs/guides/online-updates.md +18 -6
  21. package/docs/guides/utilities.md +116 -4
  22. package/docs/guides/widget-structure.md +30 -0
  23. package/docs/tools/README.md +1 -1
  24. package/docs/tools/animation.md +134 -11
  25. package/docs/tools/device.md +75 -3
  26. package/docs/tools/text-and-time.md +116 -6
  27. package/docs/widget/Accordion.md +2 -0
  28. package/docs/widget/AnimatedBox.md +46 -20
  29. package/docs/widget/AnimatedText.md +13 -7
  30. package/docs/widget/Badge.md +1 -0
  31. package/docs/widget/COMMON_PROPS.md +26 -3
  32. package/docs/widget/Chart.md +1 -0
  33. package/docs/widget/Checkbox.md +7 -1
  34. package/docs/widget/CircularChart.md +2 -0
  35. package/docs/widget/Drawer.md +1 -0
  36. package/docs/widget/GridView.md +4 -1
  37. package/docs/widget/Input.md +2 -0
  38. package/docs/widget/InstallButton.md +1 -0
  39. package/docs/widget/Markdown.md +4 -0
  40. package/docs/widget/Rating.md +2 -0
  41. package/docs/widget/Slider.md +2 -0
  42. package/docs/widget/Stack.md +6 -5
  43. package/docs/widget/Stepper.md +2 -0
  44. package/docs/widget/Tooltip.md +3 -1
  45. package/package.json +3 -2
  46. package/skills/fletbox-design/SKILL.md +390 -14
  47. package/skills/fletbox-motion/SKILL.md +145 -47
  48. package/skills/fletbox-rules/SKILL.md +140 -1
  49. package/src/animations/AnimatedBox.js +321 -226
  50. package/src/animations/MatrixRain.js +7 -1
  51. package/src/animations/ParallaxBox.js +13 -10
  52. package/src/animations/animate.js +269 -106
  53. package/src/animations/color.js +91 -0
  54. package/src/animations/easings.js +275 -0
  55. package/src/animations/index.js +21 -0
  56. package/src/animations/motion.js +204 -0
  57. package/src/animations/transitions.js +327 -0
  58. package/src/core/runApp.js +0 -6
  59. package/src/helpers/boxEdges.js +69 -0
  60. package/src/helpers/html.js +39 -0
  61. package/src/index.d.ts +377 -25
  62. package/src/index.js +77 -0
  63. package/src/navigations/AdaptiveScaffold.js +6 -2
  64. package/src/navigations/AppBar.js +16 -7
  65. package/src/navigations/BottomNavigation.js +24 -1
  66. package/src/navigations/CollapsibleSideBar.js +0 -1
  67. package/src/navigations/Drawer.js +32 -1
  68. package/src/navigations/DrawerItem.js +1 -1
  69. package/src/navigations/Router.js +0 -1
  70. package/src/navigations/Scaffold.js +9 -3
  71. package/src/navigations/Tabs.js +104 -31
  72. package/src/services/RamStore.js +1 -2
  73. package/src/tools/breakpoints.js +113 -0
  74. package/src/tools/createList.js +0 -2
  75. package/src/tools/fonts.js +99 -0
  76. package/src/tools/index.js +74 -1
  77. package/src/tools/margin.js +5 -63
  78. package/src/tools/padding.js +5 -63
  79. package/src/tools/print.js +0 -8
  80. package/src/tools/scale.js +9 -2
  81. package/src/tools/time.js +56 -15
  82. package/src/tools/useState.js +1 -54
  83. package/src/utils/fieldFont.js +77 -0
  84. package/src/utils/focusRing.js +73 -0
  85. package/src/utils/focusScope.js +113 -0
  86. package/src/utils/markdownParser.js +7 -9
  87. package/src/utils/readableInk.js +7 -1
  88. package/src/utils/stackLayout.js +55 -0
  89. package/src/utils/styleInjector.js +25 -0
  90. package/src/utils/syntaxHighlight.js +1 -17
  91. package/src/utils/themes.js +15 -0
  92. package/src/utils/visualEffects.js +49 -47
  93. package/src/widget-factory/createWidget.js +16 -3
  94. package/src/widget-factory/effects.js +76 -3
  95. package/src/widget-factory/reactiveValue.js +0 -7
  96. package/src/widget-factory/tools.js +26 -143
  97. package/src/widget-factory/translateProps.js +5 -0
  98. package/src/widget-factory/widgetFactory.js +1 -1
  99. package/src/widgets/Accordion.js +70 -12
  100. package/src/widgets/AdMob.js +1 -1
  101. package/src/widgets/AdSense.js +1 -1
  102. package/src/widgets/AlertDialog.js +11 -9
  103. package/src/widgets/Avatar.js +10 -1
  104. package/src/widgets/Badge.js +7 -1
  105. package/src/widgets/BottomSheet.js +39 -5
  106. package/src/widgets/Chart.js +41 -9
  107. package/src/widgets/Checkbox.js +59 -9
  108. package/src/widgets/Chip.js +13 -1
  109. package/src/widgets/CircularBar.js +17 -5
  110. package/src/widgets/CircularChart.js +24 -2
  111. package/src/widgets/CodeViewer.js +5 -3
  112. package/src/widgets/Dropdown.js +223 -20
  113. package/src/widgets/GridView.js +17 -1
  114. package/src/widgets/Icon.js +21 -0
  115. package/src/widgets/Input.js +75 -44
  116. package/src/widgets/InstallButton.js +8 -2
  117. package/src/widgets/ListTile.js +6 -5
  118. package/src/widgets/ListView.js +21 -7
  119. package/src/widgets/Markdown.js +25 -44
  120. package/src/widgets/Modal.js +43 -6
  121. package/src/widgets/Pagination.js +35 -3
  122. package/src/widgets/ProgressBar.js +18 -0
  123. package/src/widgets/Radio.js +7 -0
  124. package/src/widgets/Rating.js +104 -16
  125. package/src/widgets/Skeleton.js +9 -5
  126. package/src/widgets/Slider.js +87 -7
  127. package/src/widgets/SnackBar.js +30 -1
  128. package/src/widgets/Stack.js +25 -13
  129. package/src/widgets/Stepper.js +112 -64
  130. package/src/widgets/Switch.js +10 -1
  131. package/src/widgets/Tooltip.js +93 -10
  132. package/src/widgets/TreeView.js +5 -5
  133. package/src/create-index.sh +0 -237
  134. package/src/widget-factory/pixTool.js +0 -40
  135. package/src/widget-factory/stringTool.js +0 -26
@@ -12,6 +12,8 @@
12
12
  * @module animations/MatrixRain
13
13
  */
14
14
 
15
+ import { prefersReducedMotion } from "./motion.js";
16
+
15
17
  /**
16
18
  * Creates and starts a Matrix-rain animation on a `<canvas>` element.
17
19
  *
@@ -203,7 +205,11 @@ export const MatrixRain = (props = {}) => {
203
205
  drops[i] += speed + Math.random() * speed;
204
206
  }
205
207
 
206
- animationId = requestAnimationFrame(draw);
208
+ // Reduced motion stops the rain rather than slowing it: the field is painted
209
+ // once and left, because a decorative texture that scrolls forever is the
210
+ // exact pattern the preference was invented for. The check sits on the
211
+ // re-arm, so the OS setting flipping mid-session stops it within a frame.
212
+ animationId = prefersReducedMotion() ? null : requestAnimationFrame(draw);
207
213
  };
208
214
 
209
215
  // Initialize canvas and start animation
@@ -14,7 +14,7 @@
14
14
  * @module animations/ParallaxBox
15
15
  */
16
16
  import { WidgetFactory } from "../widget-factory/index.js";
17
- import { AnimatedBox } from "../animations/AnimatedBox.js";
17
+ import { prefersReducedMotion } from "./motion.js";
18
18
 
19
19
  /**
20
20
  * Creates a parallax-effect container.
@@ -65,10 +65,6 @@ export const ParallaxBox = (props = {}) => {
65
65
 
66
66
  let containerRef = null;
67
67
  let animationFrame = null;
68
- let currentX = 0;
69
- let currentY = 0;
70
- let targetX = 0;
71
- let targetY = 0;
72
68
  let animatedContent = null;
73
69
  let initialTimer = null;
74
70
  const transitionTimers = new Set();
@@ -100,6 +96,18 @@ export const ParallaxBox = (props = {}) => {
100
96
  const applyTransform = (x, y, animate = true) => {
101
97
  if (disabled) return;
102
98
 
99
+ // A layer that shifts under the reader's own scroll is the motion
100
+ // `prefers-reduced-motion` exists to stop, and the check belongs here rather
101
+ // than in the handlers: the OS setting can flip while the screen is open, and
102
+ // the next scroll frame is the moment that has to notice.
103
+ if (prefersReducedMotion()) {
104
+ if (animatedContent && animatedContent.style.transform) {
105
+ animatedContent.style.transform = "translateX(0) translateY(0)";
106
+ animatedContent.style.transition = "none";
107
+ }
108
+ return;
109
+ }
110
+
103
111
  let moveX = x;
104
112
  let moveY = y;
105
113
 
@@ -114,9 +122,6 @@ export const ParallaxBox = (props = {}) => {
114
122
  moveX = Math.max(-maxOffset, Math.min(maxOffset, moveX));
115
123
  moveY = Math.max(-maxOffset, Math.min(maxOffset, moveY));
116
124
 
117
- targetX = moveX;
118
- targetY = moveY;
119
-
120
125
  if (onParallaxMove) onParallaxMove({ x: moveX, y: moveY });
121
126
 
122
127
  if (animatedContent) {
@@ -144,8 +149,6 @@ export const ParallaxBox = (props = {}) => {
144
149
  * @returns {void}
145
150
  */
146
151
  const resetTransform = (animate = true) => {
147
- targetX = 0;
148
- targetY = 0;
149
152
  if (animatedContent) {
150
153
  animatedContent.style.transform = "translateX(0) translateY(0)";
151
154
  if (animate) {
@@ -19,14 +19,28 @@
19
19
  * @module animations/animate
20
20
  */
21
21
 
22
+ import { toREM } from "../utils/units.js";
23
+ import { isColor, mixColor } from "./color.js";
24
+ import { motionDuration } from "./motion.js";
25
+ import { resolveEasing, spring as springCurve } from "./easings.js";
26
+
22
27
  // ========== INTERPOLATION HELPERS ==========
23
28
 
24
29
  /**
25
30
  * Interpolates between two values at a given progress point.
26
- * Handles three value types:
31
+ * Handles four value shapes:
27
32
  * - `number → number`: straight numeric lerp.
28
- * - `"#rrggbb" → string`: delegates to {@link interpolateColor}.
29
- * - `"16px" → string`: parses the numeric part, lerps it, re-attaches the unit.
33
+ * - `#rrggbb` / `rgb()` / `rgba()`: the shared mixer in `color.js`.
34
+ * - `"16px" → anything numeric`: parses the numeric part, lerps it, re-attaches
35
+ * the starting value's unit.
36
+ * - anything else (a named color, `hidden`/`visible`, a font stack): a cut at the
37
+ * end of the loop.
38
+ *
39
+ * The last case is the one worth naming: the unit branch used to run `parseFloat`
40
+ * on values with no number in them and write `"NaNpx"` on every frame. The browser
41
+ * drops a declaration it cannot parse, so the animation looked like it ran and
42
+ * changed nothing at all — the same silent failure the transform registry fixed
43
+ * for `translateY`.
30
44
  *
31
45
  * @param {number|string} from - Starting value.
32
46
  * @param {number|string} to - Ending value.
@@ -38,103 +52,202 @@ const interpolate = (from, to, progress) => {
38
52
  if (typeof from === "number" && typeof to === "number") {
39
53
  return from + (to - from) * progress;
40
54
  }
41
- // Color to color (hex → rgb)
42
- if (typeof from === "string" && from.startsWith("#")) {
43
- return interpolateColor(from, to, progress);
55
+ // Color to color — hex, rgb() and rgba() all blend
56
+ if (isColor(from) && isColor(to)) {
57
+ return mixColor(from, to, progress);
44
58
  }
45
59
  // String with units (e.g., "16px", "2rem")
46
60
  if (typeof from === "string") {
47
61
  const fromNum = parseFloat(from);
48
62
  const toNum = parseFloat(to);
49
- const unit = from.replace(fromNum.toString(), "");
50
- return fromNum + (toNum - fromNum) * progress + unit;
63
+ if (Number.isFinite(fromNum) && Number.isFinite(toNum)) {
64
+ const unit = from.replace(fromNum.toString(), "");
65
+ return fromNum + (toNum - fromNum) * progress + unit;
66
+ }
51
67
  }
52
- return to;
68
+ // Not a number, not a length, not a color: arrive at the end, don't pretend.
69
+ if (from === to) return from;
70
+ return progress >= 1 ? to : from;
53
71
  };
54
72
 
73
+ // ========== EASING FUNCTIONS ==========
74
+
55
75
  /**
56
- * Linearly interpolates between two hex color values, returning an `rgb()` string.
76
+ * Applies a named easing curve to a linear progress value.
77
+ *
78
+ * The curves live in `easings.js` so the imperative engine, the presets and a
79
+ * `spring()` all read the same table. A function is accepted as the curve, which
80
+ * is how a caller passes one that is not in the vocabulary.
57
81
  *
58
- * @param {string} color1 - Starting color as a hex string (e.g. `"#ff0000"`).
59
- * @param {string} color2 - Ending color as a hex string.
60
- * @param {number} progress - Interpolation factor in [0, 1].
61
- * @returns {string} Interpolated color as `"rgb(r, g, b)"`.
82
+ * @param {string|((t: number) => number)} type - Easing name or curve.
83
+ * @param {number} t - Raw linear progress in [0, 1].
84
+ * @returns {number} Eased progress; outside `[0, 1]` for the overshoot curves.
62
85
  */
63
- const interpolateColor = (color1, color2, progress) => {
64
- const rgb1 = hexToRgb(color1);
65
- const rgb2 = hexToRgb(color2);
66
- const r = Math.round(rgb1.r + (rgb2.r - rgb1.r) * progress);
67
- const g = Math.round(rgb1.g + (rgb2.g - rgb1.g) * progress);
68
- const b = Math.round(rgb1.b + (rgb2.b - rgb1.b) * progress);
69
- return `rgb(${r}, ${g}, ${b})`;
70
- };
86
+ const getEasing = (type, t) => resolveEasing(type)(t);
71
87
 
72
88
  /**
73
- * Parses a hex color string into its red, green, and blue components.
89
+ * Transform functions the engine can animate, with the kind of value each one
90
+ * takes and the value that means "no effect" — the value an animation with no
91
+ * explicit `from` starts from.
74
92
  *
75
- * @param {string} hex - Hex color string (with or without leading `#`).
76
- * @returns {{ r: number, g: number, b: number }} RGB components in [0, 255].
77
- * Returns `{ r: 0, g: 0, b: 0 }` if the string cannot be parsed.
93
+ * `translateX`/`rotate`/`skewY` are not properties of `CSSStyleDeclaration`, so
94
+ * the generic write path used to land them on the widget object as a harmless JS
95
+ * field: `animate(row, "translateY", 24, 0)` ran a full animation loop and moved
96
+ * nothing, which is why the skill had to tell you to reach for `scale` even for a
97
+ * slide. They are now composed into one `transform` string.
98
+ *
99
+ * A `length` function takes framework units, not pixels: the bare `24` a caller
100
+ * writes becomes `1.5rem`, the same way every routed prop does, so a slide grows
101
+ * with the reader's font instead of staying a pixel while the row it belongs to
102
+ * got taller. `angle` functions take degrees and `number` functions are scalars.
103
+ *
104
+ * @type {Object<string, { kind: "length"|"angle"|"number", neutral: number }>}
78
105
  */
79
- const hexToRgb = (hex) => {
80
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
81
- return result
82
- ? {
83
- r: parseInt(result[1], 16),
84
- g: parseInt(result[2], 16),
85
- b: parseInt(result[3], 16),
86
- }
87
- : { r: 0, g: 0, b: 0 };
106
+ const TRANSFORM_FUNCTIONS = {
107
+ translateX: { kind: "length", neutral: 0 },
108
+ translateY: { kind: "length", neutral: 0 },
109
+ translateZ: { kind: "length", neutral: 0 },
110
+ rotate: { kind: "angle", neutral: 0 },
111
+ rotateX: { kind: "angle", neutral: 0 },
112
+ rotateY: { kind: "angle", neutral: 0 },
113
+ rotateZ: { kind: "angle", neutral: 0 },
114
+ skewX: { kind: "angle", neutral: 0 },
115
+ skewY: { kind: "angle", neutral: 0 },
116
+ scale: { kind: "number", neutral: 1 },
117
+ scaleX: { kind: "number", neutral: 1 },
118
+ scaleY: { kind: "number", neutral: 1 },
88
119
  };
89
120
 
90
- // ========== EASING FUNCTIONS ==========
121
+ /**
122
+ * The order the parts are written in. CSS transforms apply left to right, so
123
+ * translate first, then rotate, then scale keeps a rotation spinning about the
124
+ * element's own centre instead of the page's.
125
+ * @type {string[]}
126
+ */
127
+ const TRANSFORM_ORDER = Object.keys(TRANSFORM_FUNCTIONS);
91
128
 
92
129
  /**
93
- * Applies a named easing curve to a linear progress value.
94
- * Falls back to linear if `type` is not recognized.
130
+ * Live transform parts per widget, so two loops on one element (a slide and a
131
+ * scale) each own a function rather than overwriting the whole string.
132
+ * @type {WeakMap<object, Map<string, number|string>>}
133
+ */
134
+ const transformParts = new WeakMap();
135
+
136
+ /**
137
+ * Merges one transform function into the widget's `transform` string.
95
138
  *
96
- * @param {"linear"|"easeIn"|"easeOut"|"easeInOut"} type - Easing curve name.
97
- * @param {number} t - Raw linear progress in [0, 1].
98
- * @returns {number} Eased progress value in [0, 1].
139
+ * @param {HTMLElement} widget - The element to write on.
140
+ * @param {string} property - A key of {@link TRANSFORM_FUNCTIONS}.
141
+ * @param {number|string} value - A bare number gets the unit that function needs.
142
+ * @returns {void}
99
143
  */
100
- const getEasing = (type, t) => {
101
- const easings = {
102
- linear: (t) => t,
103
- easeIn: (t) => t * t,
104
- easeOut: (t) => t * (2 - t),
105
- easeInOut: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
106
- };
107
- return easings[type]?.(t) ?? t;
144
+ const writeTransformPart = (widget, property, value) => {
145
+ let parts = transformParts.get(widget);
146
+ if (!parts) {
147
+ parts = new Map();
148
+ transformParts.set(widget, parts);
149
+ }
150
+ parts.set(property, formatTransformValue(property, value));
151
+ widget.style.transform = TRANSFORM_ORDER.filter((name) => parts.has(name))
152
+ .map((name) => `${name}(${parts.get(name)})`)
153
+ .join(" ");
154
+ };
155
+
156
+ /**
157
+ * The CSS value of one transform function.
158
+ *
159
+ * A string already carries its unit and is passed through; a bare number is a
160
+ * framework unit for a `length`, degrees for an `angle`, and a plain scalar for a
161
+ * `number`.
162
+ *
163
+ * @param {string} property - A key of {@link TRANSFORM_FUNCTIONS}.
164
+ * @param {number|string} value
165
+ * @returns {string}
166
+ */
167
+ const formatTransformValue = (property, value) => {
168
+ if (typeof value !== "number") return String(value);
169
+ const { kind } = TRANSFORM_FUNCTIONS[property];
170
+ if (kind === "length") return toREM(value);
171
+ if (kind === "angle") return `${value}deg`;
172
+ return String(value);
108
173
  };
109
174
 
110
175
  /**
111
176
  * Reads the current animated value of a property from a widget.
112
- * For `"scale"`, returns `1` as a safe default since scale is stored
113
- * inside a `transform` string rather than as a standalone property.
177
+ * Transform functions live inside a `transform` string rather than as
178
+ * standalone properties, so a transform with no part stored yet reads neutral
179
+ * (`1` for `scale`, `0` for the rest).
114
180
  *
115
181
  * @param {HTMLElement|object} widget - The target widget or DOM element.
116
182
  * @param {string} property - The property to read.
117
- * @returns {number|string} The current value, or `1` for `"scale"`.
183
+ * @returns {number|string} The current value, or the property's neutral value.
118
184
  */
119
185
  const readAnimatedValue = (widget, property) => {
120
- if (property === "scale") return 1;
186
+ if (TRANSFORM_FUNCTIONS[property]) {
187
+ const stored = transformParts.get(widget)?.get(property);
188
+ if (stored !== undefined) return stored;
189
+ return TRANSFORM_FUNCTIONS[property].neutral;
190
+ }
121
191
  if (widget?.style && property in widget.style) {
122
192
  const inline = widget.style[property];
123
193
  // An inline-unset property reads as "" and `interpolate` turns that into the
124
194
  // string "NaN", which the browser silently drops — so `animate(el, "opacity",
125
195
  // undefined, 1, 300)` (the documented API) wrote "NaN" every frame and never
126
- // animated anything. Fall back to the property's neutral value.
196
+ // animated anything. What the element actually looks like is the next place to
197
+ // ask, because a widget styled through a class or a stylesheet has an empty
198
+ // inline value and a real one: `fadeOut` of a card the theme set to 0.8 must
199
+ // start at 0.8, not at the neutral guess below.
127
200
  if (inline !== "" && inline !== null && inline !== undefined) return inline;
201
+ const computed = readComputedStyle(widget, property);
202
+ if (computed !== undefined) return computed;
128
203
  return property === "opacity" ? 1 : 0;
129
204
  }
130
205
  return widget?.[property];
131
206
  };
132
207
 
208
+ /**
209
+ * The value a real element shows right now, from its computed style.
210
+ *
211
+ * Read once when a loop starts, never per frame. A plain object passed by a caller
212
+ * (a widget-shaped mock) has no computed style, and asking is what a `null` answer
213
+ * means here.
214
+ *
215
+ * @param {HTMLElement|object} widget
216
+ * @param {string} property
217
+ * @returns {string|undefined}
218
+ */
219
+ const readComputedStyle = (widget, property) => {
220
+ if (typeof getComputedStyle !== "function") return undefined;
221
+ if (!(widget instanceof Element)) return undefined;
222
+ const value = getComputedStyle(widget)[property];
223
+ return value === "" || value === undefined ? undefined : value;
224
+ };
225
+
226
+ /**
227
+ * Style properties a bare number must NOT get a length unit on.
228
+ *
229
+ * The same exemptions the factory's `toCSSValue` keeps, listed here because the
230
+ * animation engine writes styles directly and cannot ask the prop registry.
231
+ * @type {Set<string>}
232
+ */
233
+ const UNITLESS_STYLE_PROPS = new Set([
234
+ "opacity",
235
+ "zIndex",
236
+ "order",
237
+ "flex",
238
+ "flexGrow",
239
+ "flexShrink",
240
+ "fontWeight",
241
+ "lineHeight",
242
+ "aspectRatio",
243
+ ]);
244
+
133
245
  /**
134
246
  * Writes an animated value to a widget property.
135
- * - `"scale"` is written as `transform: scale(value)`.
136
- * - Numeric values for style properties that require a unit get `"px"` appended
137
- * (unitless properties such as `opacity`, `zIndex`, etc. are exempt).
247
+ * - Transform functions merge into `transform` (see {@link writeTransformPart}).
248
+ * - A bare number on any other length is a framework unit and becomes rem, the way
249
+ * a routed prop does — `animate(el, "width", 100, 300)` grows with the reader's
250
+ * font instead of staying a pixel while the card around it got taller.
138
251
  * - Falls back to setting the property directly on the widget object for
139
252
  * non-style properties.
140
253
  *
@@ -145,22 +258,15 @@ const readAnimatedValue = (widget, property) => {
145
258
  */
146
259
  const writeAnimatedValue = (widget, property, value) => {
147
260
  if (!widget) return;
148
- if (property === "scale") {
149
- widget.style.transform = `scale(${value})`;
261
+ if (TRANSFORM_FUNCTIONS[property]) {
262
+ if (!widget.style) return;
263
+ writeTransformPart(widget, property, value);
150
264
  return;
151
265
  }
152
266
  if (widget.style && property in widget.style) {
153
- const unitlessProperties = new Set([
154
- "opacity",
155
- "zIndex",
156
- "fontWeight",
157
- "lineHeight",
158
- "flexGrow",
159
- "flexShrink",
160
- ]);
161
267
  widget.style[property] =
162
- typeof value === "number" && !unitlessProperties.has(property)
163
- ? `${value}px`
268
+ typeof value === "number" && !UNITLESS_STYLE_PROPS.has(property)
269
+ ? toREM(value)
164
270
  : value;
165
271
  return;
166
272
  }
@@ -240,16 +346,13 @@ export const stopAnimation = (widget, property) => {
240
346
  * @param {number} duration
241
347
  * @param {string} easing
242
348
  * @param {(progress: number) => number|string} [valueAt] - Custom value curve,
243
- * receiving eased progress in `[0, 1]`. Used by `pulse` to keep the whole
244
- * up-and-down gesture inside a single cancelable loop.
245
- * @param {(progress: number) => number|string} [valueAt] - Custom value curve,
246
- * receiving eased progress in `[0, 1]`. Used by `pulse` to keep the whole
247
- * up-and-down gesture inside a single cancelable loop.
349
+ * receiving eased progress in `[0, 1]`. Used by `pulse` and the named
350
+ * transitions to keep a multi-phase gesture inside a single cancelable loop.
248
351
  * @param {(widget: HTMLElement|object) => void} [onDone] - Called when the loop
249
352
  * ends — also when it is cancelled or replaced, so awaiting code never hangs.
250
353
  * @returns {() => boolean} Cancel function; true when it stopped a live loop.
251
354
  */
252
- const runAnimation = (
355
+ export const runAnimation = (
253
356
  widget,
254
357
  property,
255
358
  from,
@@ -263,7 +366,9 @@ const runAnimation = (
263
366
  // never reaches 1 and the loop re-arms forever (and `animateAsync` never
264
367
  // resolves). Clamping the progress is not enough — it just sits at 0 and
265
368
  // `0 < 1` stays true — so sanitise the duration itself. `0` collapses to one frame.
266
- const safeDuration = Math.max(0, Number(duration) || 0);
369
+ // Reduced motion goes through the same door: one frame that writes the end
370
+ // value, so the state still lands and an `await` still resolves.
371
+ const safeDuration = motionDuration(duration);
267
372
  const startValue = from !== undefined ? from : readAnimatedValue(widget, property);
268
373
 
269
374
  // Replace the loop already animating this property instead of racing it.
@@ -325,7 +430,8 @@ const runAnimation = (
325
430
  * value is read from the widget via {@link readAnimatedValue}.
326
431
  * @param {number|string} to - Ending value.
327
432
  * @param {number} [duration=300] - Animation duration in milliseconds.
328
- * @param {string} [easing="linear"] - Easing curve name (see {@link getEasing}).
433
+ * @param {string|((t: number) => number)} [easing="linear"] - Easing curve name
434
+ * from `EASING_NAMES`, or a curve (`spring().easing`, `cubicBezier(…)`).
329
435
  * @returns {() => boolean} Cancel function — true if it stopped a loop still running.
330
436
  * The animation also stops on its own when a new animation takes the same property,
331
437
  * or via {@link stopAnimation}.
@@ -352,7 +458,8 @@ export const animate = (
352
458
  * value is read from the widget via {@link readAnimatedValue}.
353
459
  * @param {number|string} to - Ending value.
354
460
  * @param {number} [duration=300] - Animation duration in milliseconds.
355
- * @param {string} [easing="linear"] - Easing curve name (see {@link getEasing}).
461
+ * @param {string|((t: number) => number)} [easing="linear"] - Easing curve name
462
+ * from `EASING_NAMES`, or a curve (`spring().easing`, `cubicBezier(…)`).
356
463
  * @returns {Promise<HTMLElement|object>} Resolves with `widget` when the animation
357
464
  * finishes — or as soon as it is cancelled or replaced, so `await` never hangs.
358
465
  */
@@ -370,22 +477,75 @@ export const animateAsync = (
370
477
  runAnimation(widget, property, from, to, duration, easing, null, resolve);
371
478
  });
372
479
 
480
+ /**
481
+ * Animates a property the way a physical object settles: overshoot, then stop.
482
+ *
483
+ * There is no duration to choose — a spring runs until its envelope has decayed
484
+ * inside `epsilon`, and `spring()` works that number out. Which is why this is a
485
+ * separate call instead of another easing name: passing `spring().duration` by
486
+ * hand is the same result, this only saves the arithmetic.
487
+ *
488
+ * @param {HTMLElement|object} widget - The target widget or DOM element.
489
+ * @param {string} property - The CSS property (or `"scale"`) to animate.
490
+ * @param {number|string} from - Starting value; reads the widget when `undefined`.
491
+ * @param {number|string} to - Ending value.
492
+ * @param {object} [options] - Spring parameters (`stiffness`,
493
+ * `damping`, `mass`, `initialVelocity`, `epsilon`, `maxDuration`); see {@link spring}.
494
+ * @returns {() => boolean} Cancel function (see {@link animate}).
495
+ */
496
+ export const animateSpring = (widget, property, from, to, options = {}) => {
497
+ const { duration, easing } = springCurve(options);
498
+ return animate(widget, property, from, to, duration, easing ?? "linear");
499
+ };
500
+
501
+ /**
502
+ * The awaitable form of {@link animateSpring}.
503
+ *
504
+ * @param {HTMLElement|object} widget - The target widget or DOM element.
505
+ * @param {string} property - The CSS property (or `"scale"`) to animate.
506
+ * @param {number|string} from - Starting value.
507
+ * @param {number|string} to - Ending value.
508
+ * @param {object} [options] - Spring parameters (see {@link animateSpring}).
509
+ * @returns {Promise<HTMLElement|object>} Resolves with `widget` when the spring
510
+ * settles — or as soon as it is cancelled or replaced.
511
+ */
512
+ export const animateSpringAsync = (widget, property, from, to, options = {}) => {
513
+ const { duration, easing } = springCurve(options);
514
+ return animateAsync(widget, property, from, to, duration, easing ?? "linear");
515
+ };
516
+
373
517
  // ========== HELPER FUNCTIONS (Fire and Forget) ==========
374
518
 
375
519
  /**
376
- * Fades a widget out by animating its `opacity` from `1` to `0`.
377
- * Fire-and-forget — does not return a Promise.
520
+ * The scale a pulse is at, given eased progress.
521
+ *
522
+ * Up for the first half of the gesture and back down for the second, inside one
523
+ * loop: a pulse is one cancelable movement, not an animation plus a timer that
524
+ * starts the second half on a widget someone already unmounted.
525
+ *
526
+ * @param {number} progress - Progress through the pulse in `[0, 1]`.
527
+ * @returns {number} The scale to write.
528
+ */
529
+ const pulseScale = (progress) =>
530
+ interpolate(1, 1.1, progress < 0.5 ? progress * 2 : 2 - progress * 2);
531
+
532
+ /**
533
+ * Fades a widget out by animating its `opacity` to `0`.
534
+ *
535
+ * It starts from wherever the widget actually is — the element's own value, then
536
+ * its computed style — so a card the theme set to `0.8` fades from `0.8` instead
537
+ * of first jumping to fully opaque.
378
538
  *
379
539
  * @param {HTMLElement|object} widget - The target widget.
380
540
  * @param {number} [duration=300] - Fade duration in milliseconds.
381
541
  * @returns {() => boolean} Cancel function (see {@link animate}).
382
542
  */
383
543
  export const fadeOut = (widget, duration = 300) => {
384
- return animate(widget, "opacity", 1, 0, duration);
544
+ return animate(widget, "opacity", undefined, 0, duration);
385
545
  };
386
546
 
387
547
  /**
388
- * Fades a widget in by animating its `opacity` from `0` to `1`.
548
+ * Fades a widget in by animating its `opacity` to `1`.
389
549
  * Fire-and-forget — does not return a Promise.
390
550
  *
391
551
  * @param {HTMLElement|object} widget - The target widget.
@@ -393,7 +553,7 @@ export const fadeOut = (widget, duration = 300) => {
393
553
  * @returns {() => boolean} Cancel function (see {@link animate}).
394
554
  */
395
555
  export const fadeIn = (widget, duration = 300) => {
396
- return animate(widget, "opacity", 0, 1, duration);
556
+ return animate(widget, "opacity", undefined, 1, duration);
397
557
  };
398
558
 
399
559
  /**
@@ -407,23 +567,12 @@ export const fadeIn = (widget, duration = 300) => {
407
567
  * @returns {() => boolean} Cancel function (see {@link animate}).
408
568
  */
409
569
  export const pulse = (widget, duration = 300) =>
410
- runAnimation(
411
- widget,
412
- "scale",
413
- 1,
414
- 1,
415
- duration,
416
- "linear",
417
- // One loop with a triangular curve instead of an animation plus a
418
- // `setTimeout`: the timer used to start phase two on a widget that had
419
- // already been removed from the DOM, and nothing could cancel it.
420
- (progress) => interpolate(1, 1.1, progress < 0.5 ? progress * 2 : 2 - progress * 2),
421
- );
570
+ runAnimation(widget, "scale", 1, 1, duration, "linear", pulseScale);
422
571
 
423
572
  // ========== ASYNC HELPER FUNCTIONS (Returns Promise) ==========
424
573
 
425
574
  /**
426
- * Fades a widget out by animating its `opacity` from `1` to `0`.
575
+ * Fades a widget out by animating its `opacity` to `0`.
427
576
  * Returns a Promise that resolves when the fade completes.
428
577
  *
429
578
  * @param {HTMLElement|object} widget - The target widget.
@@ -431,11 +580,11 @@ export const pulse = (widget, duration = 300) =>
431
580
  * @returns {Promise<HTMLElement|object>} Resolves with `widget` on completion.
432
581
  */
433
582
  export const fadeOutAsync = (widget, duration = 300) => {
434
- return animateAsync(widget, "opacity", 1, 0, duration);
583
+ return animateAsync(widget, "opacity", undefined, 0, duration);
435
584
  };
436
585
 
437
586
  /**
438
- * Fades a widget in by animating its `opacity` from `0` to `1`.
587
+ * Fades a widget in by animating its `opacity` to `1`.
439
588
  * Returns a Promise that resolves when the fade completes.
440
589
  *
441
590
  * @param {HTMLElement|object} widget - The target widget.
@@ -443,19 +592,33 @@ export const fadeOutAsync = (widget, duration = 300) => {
443
592
  * @returns {Promise<HTMLElement|object>} Resolves with `widget` on completion.
444
593
  */
445
594
  export const fadeInAsync = (widget, duration = 300) => {
446
- return animateAsync(widget, "opacity", 0, 1, duration);
595
+ return animateAsync(widget, "opacity", undefined, 1, duration);
447
596
  };
448
597
 
449
598
  /**
450
- * Scales a widget up then back to its original size, awaiting each phase.
451
- * Use this when subsequent code must run only after the full pulse finishes.
599
+ * The awaitable form of {@link pulse}.
600
+ *
601
+ * The same single loop, not an up-animation chained to a down-animation: the two
602
+ * halves were twice the frames of the gesture they described, stopped on the
603
+ * `setTimeout` that separated them, and resolved with nothing while every other
604
+ * `Async` helper resolves with the widget it just moved.
452
605
  *
453
606
  * @param {HTMLElement|object} widget - The target widget.
454
607
  * @param {number} [duration=300] - Total pulse duration in milliseconds
455
608
  * (split evenly between scale-up and scale-down).
456
- * @returns {Promise<void>} Resolves when both phases of the pulse have completed.
609
+ * @returns {Promise<HTMLElement|object>} Resolves with `widget` when the pulse
610
+ * finishes — or as soon as it is cancelled or replaced.
457
611
  */
458
- export const pulseAsync = async (widget, duration = 300) => {
459
- await animateAsync(widget, "scale", 1, 1.1, duration / 2);
460
- await animateAsync(widget, "scale", 1.1, 1, duration / 2);
461
- };
612
+ export const pulseAsync = (widget, duration = 300) =>
613
+ new Promise((resolve) => {
614
+ runAnimation(
615
+ widget,
616
+ "scale",
617
+ 1,
618
+ 1,
619
+ duration,
620
+ "linear",
621
+ pulseScale,
622
+ resolve,
623
+ );
624
+ });