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,25 +12,18 @@
12
12
  * - boxShadow (offsetX, offsetY, blur, spread, color)
13
13
  * - background (gradients: linear, radial, conic)
14
14
  *
15
+ * Each entry is `{ effect, from, to }` plus the optional `duration`, `delay`,
16
+ * `loop` and `reverse`, and each one is honoured: a step's own duration is how
17
+ * long *that* step takes, not the duration of whichever step came first.
18
+ *
15
19
  * ⭐ Numbers in length properties (width, height, translate, etc.)
16
20
  * are automatically converted to rem (consistent with widget-factory behavior)
17
21
  */
18
22
 
19
- let animationCounter = 0;
20
- const injectedKeyframes = new Set();
21
-
22
- /**
23
- * Converts a numeric pixel value to a rem string.
24
- * Passes through non-numeric values unchanged.
25
- *
26
- * @param {number|string|undefined|null} value - The value to convert.
27
- * @returns {string|undefined} The value expressed in rem, or undefined if input was nullish.
28
- */
29
- const toREM = (value) => {
30
- if (value === undefined || value === null) return undefined;
31
- if (typeof value === "number") return `${value / 16}rem`;
32
- return value;
33
- };
23
+ import { toREM } from "../utils/units.js";
24
+ import { injectKeyframes, animationName } from "../utils/styleInjector.js";
25
+ import { mixColor } from "./color.js";
26
+ import { prefersReducedMotion, markMotionLoop } from "./motion.js";
34
27
 
35
28
  /**
36
29
  * Effect names whose values represent CSS lengths and must be expressed in rem.
@@ -41,6 +34,7 @@ const lengthEffects = new Set([
41
34
  "height",
42
35
  "translateX",
43
36
  "translateY",
37
+ "translateZ",
44
38
  "marginTop",
45
39
  "marginRight",
46
40
  "marginBottom",
@@ -59,15 +53,45 @@ const lengthEffects = new Set([
59
53
  */
60
54
  const noUnitEffects = new Set([
61
55
  "scale",
56
+ "scaleX",
57
+ "scaleY",
62
58
  "rotate",
63
59
  "rotateX",
64
60
  "rotateY",
61
+ "rotateZ",
62
+ "skewX",
63
+ "skewY",
65
64
  "opacity",
66
65
  ]);
67
66
 
67
+ /**
68
+ * The effects that land inside the single `transform` declaration.
69
+ *
70
+ * Two CSS animations that both write `transform` do not compose: the one listed
71
+ * last wins and the other's movement disappears. So these are always generated
72
+ * into one rule, and the rule is the reason a group exists at all.
73
+ * @type {Set<string>}
74
+ */
75
+ const transformEffects = new Set([
76
+ "scale",
77
+ "scaleX",
78
+ "scaleY",
79
+ "rotate",
80
+ "rotateX",
81
+ "rotateY",
82
+ "rotateZ",
83
+ "skewX",
84
+ "skewY",
85
+ "translateX",
86
+ "translateY",
87
+ "translateZ",
88
+ ]);
89
+
68
90
  /**
69
91
  * Maps shorthand effect keys (used in the animations API) to their canonical
70
- * CSS property names used during keyframe generation.
92
+ * CSS property names used during keyframe generation. The right-hand names are
93
+ * what the property is written as; the left-hand ones are the spellings the
94
+ * examples and the snippet library use.
71
95
  * @type {Object.<string, string>}
72
96
  */
73
97
  const effectMap = {
@@ -77,11 +101,17 @@ const effectMap = {
77
101
  rounded: "borderRadius",
78
102
  // Transform
79
103
  scale: "scale",
104
+ scaleX: "scaleX",
105
+ scaleY: "scaleY",
80
106
  rotate: "rotate",
81
107
  rotateY: "rotateY",
82
108
  rotateX: "rotateX",
109
+ rotateZ: "rotateZ",
110
+ skewX: "skewX",
111
+ skewY: "skewY",
83
112
  translateX: "translateX",
84
113
  translateY: "translateY",
114
+ translateZ: "translateZ",
85
115
  opacity: "opacity",
86
116
  // Dimensions and spacing
87
117
  width: "width",
@@ -105,80 +135,24 @@ const effectMap = {
105
135
  backgroundGradient: "background",
106
136
  };
107
137
 
108
- /**
109
- * Linearly interpolates between two CSS color values (hex, rgb, or rgba).
110
- *
111
- * @param {string} color1 - Starting color (hex, rgb, or rgba string).
112
- * @param {string} color2 - Ending color (hex, rgb, or rgba string).
113
- * @param {number} t - Interpolation factor in [0, 1].
114
- * @returns {string} Interpolated color as an rgba() string.
115
- */
116
- const interpolateColor = (color1, color2, t) => {
117
- if (color1 === color2) return color1;
118
-
119
- const parseRgb = (color) => {
120
- if (color.startsWith("#")) {
121
- const r = parseInt(color.slice(1, 3), 16);
122
- const g = parseInt(color.slice(3, 5), 16);
123
- const b = parseInt(color.slice(5, 7), 16);
124
- return [r, g, b, 1];
125
- }
126
- const match = color.match(
127
- /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/,
128
- );
129
- if (match) {
130
- const a = match[4] !== undefined ? parseFloat(match[4]) : 1;
131
- return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), a];
132
- }
133
- return [0, 0, 0, 1];
134
- };
135
-
136
- const rgbToRgba = (r, g, b, a = 1) => `rgba(${r}, ${g}, ${b}, ${a})`;
137
-
138
- const [r1, g1, b1, a1] = parseRgb(color1);
139
- const [r2, g2, b2, a2] = parseRgb(color2);
140
-
141
- const r = Math.round(r1 + (r2 - r1) * t);
142
- const g = Math.round(g1 + (g2 - g1) * t);
143
- const b = Math.round(b1 + (b2 - b1) * t);
144
- const a = a1 + (a2 - a1) * t;
145
-
146
- return rgbToRgba(r, g, b, a);
147
- };
148
-
149
138
  /**
150
139
  * Interpolates between two numeric values that may carry CSS unit suffixes.
151
- * When `useREM` is true the raw interpolated number is converted to rem.
140
+ * A length that needs rem is converted by its own branch in
141
+ * {@link generateRule}, through the framework's shared `toREM`.
152
142
  *
153
143
  * @param {number|string} from - Starting value (number or string with optional unit).
154
144
  * @param {number|string} to - Ending value (number or string with optional unit).
155
145
  * @param {number} t - Interpolation factor in [0, 1].
156
- * @param {boolean} [useREM=false] - When true, result is expressed in rem (÷16).
157
- * @returns {string|number} Interpolated value, with unit appended when applicable.
146
+ * @returns {string} Interpolated value with the starting value's unit re-attached.
158
147
  */
159
- const interpolateNumber = (from, to, t, useREM = false) => {
160
- let fromNum, toNum, fromUnit, toUnit;
161
-
162
- if (typeof from === "number" && typeof to === "number") {
163
- fromNum = from;
164
- toNum = to;
165
- fromUnit = "";
166
- toUnit = "";
167
- } else {
168
- fromNum = parseFloat(from);
169
- toNum = parseFloat(to);
170
- fromUnit = from.toString().replace(fromNum.toString(), "");
171
- toUnit = to.toString().replace(toNum.toString(), "");
172
- }
173
-
148
+ const interpolateNumber = (from, to, t) => {
149
+ const fromNum = parseFloat(from);
150
+ const toNum = parseFloat(to);
151
+ const fromUnit = from.toString().replace(fromNum.toString(), "");
152
+ const toUnit = to.toString().replace(toNum.toString(), "");
174
153
  const value = fromNum + (toNum - fromNum) * t;
175
- if (useREM) {
176
- // Express the interpolated pixel value in rem
177
- return `${value / 16}rem`;
178
- } else {
179
- const unit = fromUnit || toUnit || "px";
180
- return `${value}${unit}`;
181
- }
154
+ const unit = fromUnit || toUnit || "px";
155
+ return `${value}${unit}`;
182
156
  };
183
157
 
184
158
  /**
@@ -208,13 +182,12 @@ const interpolateBoxShadow = (shadow1, shadow2, t) => {
208
182
  const s1 = parseShadow(shadow1);
209
183
  const s2 = parseShadow(shadow2);
210
184
  // box-shadow lengths keep their original units (not converted to rem)
211
- const interpolateLength = (len1, len2, t) =>
212
- interpolateNumber(len1, len2, t, false);
185
+ const interpolateLength = (len1, len2, t) => interpolateNumber(len1, len2, t);
213
186
  const newOffsetX = interpolateLength(s1.offsetX, s2.offsetX, t);
214
187
  const newOffsetY = interpolateLength(s1.offsetY, s2.offsetY, t);
215
188
  const newBlur = interpolateLength(s1.blur, s2.blur, t);
216
189
  const newSpread = interpolateLength(s1.spread, s2.spread, t);
217
- const newColor = interpolateColor(s1.color, s2.color, t);
190
+ const newColor = mixColor(s1.color, s2.color, t);
218
191
  return `${newOffsetX} ${newOffsetY} ${newBlur} ${newSpread} ${newColor}`;
219
192
  };
220
193
 
@@ -299,7 +272,7 @@ const interpolateGradient = (gradient1, gradient2, t) => {
299
272
  for (let i = 0; i < maxStops; i++) {
300
273
  const stop1 = p1.stops[i] || p1.stops[p1.stops.length - 1];
301
274
  const stop2 = p2.stops[i] || p2.stops[p2.stops.length - 1];
302
- const color = interpolateColor(stop1.color, stop2.color, t);
275
+ const color = mixColor(stop1.color, stop2.color, t);
303
276
  let position = "";
304
277
  if (stop1.position && stop2.position) {
305
278
  const pos1 = parseFloat(stop1.position);
@@ -325,75 +298,164 @@ const interpolateGradient = (gradient1, gradient2, t) => {
325
298
  return gradient1;
326
299
  };
327
300
 
301
+ /** The duration a step that does not name one gets. */
302
+ const DEFAULT_DURATION = 500;
303
+
328
304
  /**
329
- * Injects a named `@keyframes` rule into the document `<head>`.
330
- * Skips injection if a rule with the same name has already been added
331
- * (tracked via the module-level `injectedKeyframes` Set).
305
+ * Reads a step's time as milliseconds.
332
306
  *
333
- * @param {string} name - The keyframe animation name.
334
- * @param {string} keyframes - The keyframe body (everything inside `@keyframes name { … }`).
335
- * @returns {void}
307
+ * A bare number is milliseconds and a unit string is parsed, because that is what
308
+ * the docs promise (`delay` is "in milliseconds") and what `AnimatedText` writes
309
+ * (`"0.1s"`). Handing the `animation` shorthand an unqualified number — `delay:
310
+ * 200` — makes the whole declaration invalid, and the element does not animate at
311
+ * all, which reads as the widget being broken rather than as a missing unit.
312
+ *
313
+ * @param {number|string|undefined|null} value
314
+ * @param {number} fallback - Milliseconds to use when the value is absent or nonsense.
315
+ * @returns {number} A finite, non-negative number of milliseconds.
336
316
  */
317
+ const cssMs = (value, fallback) => {
318
+ const num = (text) => {
319
+ const parsed = parseFloat(text);
320
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
321
+ // "0.5s" is seconds, "500ms" and a bare "500" are milliseconds. The unit is
322
+ // read from the END of the string: a `\bs\b` test never matches "0.1s", because
323
+ // the digit and the `s` are both word characters, and the delay would be
324
+ // one thousandth of what the caller wrote.
325
+ if (/(?:^|[^a-z])ms\s*$/i.test(text)) return parsed;
326
+ if (/(?:^|[^a-z])s\s*$/i.test(text)) return parsed * 1000;
327
+ return parsed;
328
+ };
329
+ if (typeof value === "number")
330
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
331
+ if (typeof value !== "string" || value.trim() === "") return fallback;
332
+ return num(value.trim()) ?? fallback;
333
+ };
334
+
337
335
  /**
338
- * Stable hash of a keyframe body, used as the generated animation name so that
339
- * identical keyframes are injected only once.
336
+ * Formats one transform function's value the way the keyframes do.
340
337
  *
341
- * @param {string} keyframes
342
- * @returns {string}
338
+ * @param {string} effect - A canonical transform name.
339
+ * @param {number|string} value - A bare number is a framework unit, not a pixel.
340
+ * @returns {string} The value with the unit that function needs.
343
341
  */
344
- const hashKeyframes = (keyframes) => {
345
- let h1 = 0x811c9dc5;
346
- let h2 = 0x01000193;
347
- for (let i = 0; i < keyframes.length; i++) {
348
- const c = keyframes.charCodeAt(i);
349
- h1 = Math.imul(h1 ^ c, 0x01000193);
350
- h2 = Math.imul(h2 + c, 0x85ebca6b) ^ (h2 >>> 13);
351
- }
352
- return (h1 >>> 0).toString(36) + (h2 >>> 0).toString(36);
342
+ const transformValue = (effect, value) => {
343
+ if (typeof value === "string") return value;
344
+ if (effect.startsWith("rotate") || effect.startsWith("skew")) return `${value}deg`;
345
+ if (effect.startsWith("translate")) return toREM(value);
346
+ return String(value);
353
347
  };
354
348
 
355
- const injectKeyframes = (name, keyframes) => {
356
- if (injectedKeyframes.has(name)) return;
357
- const style = document.createElement("style");
358
- style.textContent = `@keyframes ${name} { ${keyframes} }`;
359
- document.head.appendChild(style);
360
- injectedKeyframes.add(name);
349
+ /**
350
+ * The order the parts are written in, so the start state and the keyframes agree
351
+ * with each other and with `animate()` — CSS applies transforms left to right, and
352
+ * translating first is what keeps a rotation about the element's own centre.
353
+ * @type {string[]}
354
+ */
355
+ const TRANSFORM_ORDER = [
356
+ "translateX",
357
+ "translateY",
358
+ "translateZ",
359
+ "rotate",
360
+ "rotateX",
361
+ "rotateY",
362
+ "rotateZ",
363
+ "skewX",
364
+ "skewY",
365
+ "scale",
366
+ "scaleX",
367
+ "scaleY",
368
+ ];
369
+
370
+ /**
371
+ * Writes each step's `from` on the element before its rule starts playing.
372
+ *
373
+ * @param {HTMLElement} child - The element about to be animated.
374
+ * @param {Array<object>} steps - The resolved descriptors.
375
+ * @returns {void}
376
+ */
377
+ const writeStartValues = (child, steps) => {
378
+ const transforms = new Map();
379
+ steps.forEach((anim) => {
380
+ const effect = effectMap[anim.effect] || anim.effect;
381
+ const { from } = anim;
382
+ if (transformEffects.has(effect)) {
383
+ transforms.set(effect, from);
384
+ return;
385
+ }
386
+ if (effect === "backgroundColor") {
387
+ child.style.backgroundColor = from;
388
+ } else if (effect === "color") {
389
+ child.style.color = from;
390
+ } else if (effect === "borderColor") {
391
+ child.style.borderColor = from;
392
+ } else if (effect === "boxShadow") {
393
+ child.style.boxShadow = from;
394
+ } else if (effect === "background") {
395
+ child.style.background = from;
396
+ } else if (effect === "opacity") {
397
+ child.style.opacity = from;
398
+ } else if (typeof from === "number" && lengthEffects.has(effect)) {
399
+ // A length written as a plain number is a framework unit
400
+ child.style[effect] = toREM(from);
401
+ } else {
402
+ child.style[effect] = from;
403
+ }
404
+ });
405
+ if (transforms.size === 0) return;
406
+ child.style.transform = TRANSFORM_ORDER.filter((name) => transforms.has(name))
407
+ .map((name) => `${name}(${transformValue(name, transforms.get(name))})`)
408
+ .join(" ");
361
409
  };
362
410
 
363
411
  /**
364
- * Generates a CSS `@keyframes` body string from an array of animation descriptors.
365
- * Each animation describes an effect, a `from` value, a `to` value, and an optional
366
- * `reverse` flag that controls whether the animation cycles back (sine wave) or plays
367
- * straight through (linear interpolation).
412
+ * True when a timing group writes the `transform` declaration.
413
+ *
414
+ * @param {{members: Array<object>}} group
415
+ * @returns {boolean}
416
+ */
417
+ const groupHasTransform = (group) =>
418
+ group.members.some((anim) =>
419
+ transformEffects.has(effectMap[anim.effect] || anim.effect),
420
+ );
421
+
422
+ /**
423
+ * Generates a CSS `@keyframes` body for one timing group.
424
+ *
425
+ * Every step of a group is written into the same rule — that is what lets a
426
+ * `translateY` and a `scale` share one `transform` declaration instead of
427
+ * overwriting each other — but each step runs on its own clock: it waits its
428
+ * `offset`, takes its own `duration`, and holds `to` for the rest of the rule.
429
+ * A group spans the longest of its steps, so a 400 ms fade inside a 600 ms
430
+ * entrance is finished and holding when the last frame is written.
368
431
  *
369
- * @param {Array<{effect: string, from: *, to: *, reverse?: boolean}>} animations
370
- * Array of animation descriptors to combine into a single keyframe sequence.
432
+ * @param {Array<{effect: string, from: *, to: *, reverse?: boolean, offset?: number, duration?: number}>} entries
433
+ * The steps of the group, already resolved to millisecond `offset`/`duration`.
434
+ * @param {number} span - The group's total length in ms; the rule's duration.
371
435
  * @returns {string} CSS keyframe body string (100 steps, 0%–100%).
372
436
  */
373
- const generateKeyframes = (animations) => {
437
+ const generateRule = (entries, span) => {
374
438
  const steps = 100;
375
439
  let frames = [];
376
440
 
377
441
  for (let i = 0; i <= steps; i++) {
378
- const t = i / steps;
442
+ // Where the group's clock is, in ms, at this frame.
443
+ const clock = (i / steps) * span;
379
444
  let transformParts = [];
380
445
  let styleParts = [];
381
446
 
382
- animations.forEach((anim) => {
447
+ entries.forEach((anim) => {
383
448
  const effect = effectMap[anim.effect] || anim.effect;
384
449
  // Straight `from → to` is the default: a one-shot entry animation must hold
385
450
  // `to`. Cycling back is opt-in, or every documented `opacity 0 → 1` faded
386
451
  // to invisible again at 100%.
387
- const { from, to, reverse = false } = anim;
388
-
389
- // A descriptor without `from`/`to` is malformed. Skip it with a warning
390
- // rather than throwing a TypeError out of the caller's entire render.
391
- if (from === undefined || to === undefined) {
392
- console.warn(
393
- `⚠️ [AnimatedBox] Skipping animation "${anim.effect}": "from" and "to" are required.`,
394
- );
395
- return;
396
- }
452
+ const { from, to, reverse = false, offset = 0, duration = span } = anim;
453
+
454
+ // The step's own progress through its own duration.
455
+ const t =
456
+ duration <= 0
457
+ ? 1
458
+ : Math.min(1, Math.max(0, (clock - offset) / duration));
397
459
 
398
460
  let value;
399
461
  let color;
@@ -411,7 +473,7 @@ const generateKeyframes = (animations) => {
411
473
  effect === "color" ||
412
474
  effect === "borderColor"
413
475
  ) {
414
- color = interpolateColor(from, to, cycleColor);
476
+ color = mixColor(from, to, cycleColor);
415
477
  } else if (effect === "boxShadow") {
416
478
  value = interpolateBoxShadow(from, to, cycleColor);
417
479
  } else if (effect === "background") {
@@ -440,7 +502,7 @@ const generateKeyframes = (animations) => {
440
502
  effect === "color" ||
441
503
  effect === "borderColor"
442
504
  ) {
443
- color = interpolateColor(from, to, t);
505
+ color = mixColor(from, to, t);
444
506
  } else if (effect === "boxShadow") {
445
507
  value = interpolateBoxShadow(from, to, t);
446
508
  } else if (effect === "background") {
@@ -457,21 +519,30 @@ const generateKeyframes = (animations) => {
457
519
  const rawValue = fromNum + (toNum - fromNum) * t;
458
520
  value = toREM(rawValue);
459
521
  } else {
460
- value = interpolateNumber(from, to, t, false);
522
+ value = interpolateNumber(from, to, t);
461
523
  }
462
524
  }
463
525
  }
464
526
 
465
- // Build transform() calls
466
- if (effect === "scale") {
467
- transformParts.push(`scale(${value})`);
468
- } else if (effect === "rotate") {
469
- transformParts.push(`rotate(${value}deg)`);
470
- } else if (effect === "rotateY") {
471
- transformParts.push(`rotateY(${value}deg)`);
472
- } else if (effect === "rotateX") {
473
- transformParts.push(`rotateX(${value}deg)`);
474
- } else if (effect === "translateX" || effect === "translateY") {
527
+ // Build transform() calls. They all land in one `transform` declaration, in
528
+ // the CSS order — travel, then turn, then size — so a rotation spins about
529
+ // the element's own centre instead of the page's.
530
+ if (effect === "scale" || effect === "scaleX" || effect === "scaleY") {
531
+ transformParts.push(`${effect}(${value})`);
532
+ } else if (
533
+ effect === "rotate" ||
534
+ effect === "rotateX" ||
535
+ effect === "rotateY" ||
536
+ effect === "rotateZ" ||
537
+ effect === "skewX" ||
538
+ effect === "skewY"
539
+ ) {
540
+ transformParts.push(`${effect}(${value}deg)`);
541
+ } else if (
542
+ effect === "translateX" ||
543
+ effect === "translateY" ||
544
+ effect === "translateZ"
545
+ ) {
475
546
  // Value is already in rem when it originated from a plain number
476
547
  transformParts.push(`${effect}(${value})`);
477
548
  }
@@ -517,16 +588,18 @@ const generateKeyframes = (animations) => {
517
588
  /**
518
589
  * Wraps an existing DOM element with a generated CSS keyframe animation.
519
590
  *
520
- * Accepts an array of animation descriptors, generates a unique `@keyframes`
521
- * rule, injects it into the document, then applies it to the child element via
522
- * `element.style.animation`. Positional overrides (`top`, `right`, `bottom`,
523
- * `left`) are forwarded to the child's `dataset` for layout use.
591
+ * Accepts an array of animation descriptors, generates one `@keyframes` rule per
592
+ * timing group, injects it into the document, then applies the rules to the child
593
+ * element through `element.style.animation`. Positional overrides (`top`, `right`,
594
+ * `bottom`, `left`) are forwarded to the child's `dataset` for layout use.
524
595
  *
525
596
  * @param {object} props
526
597
  * @param {Array<object>} [props.animations] - Array of animation descriptors. Each entry
527
- * has at minimum `{ effect, from, to }` and may include `duration`, `delay`, `loop`, `reverse`.
598
+ * has at minimum `{ effect, from, to }` and may carry its own `duration` (ms), `delay`
599
+ * (ms or a CSS time), `loop` and `reverse` — all four are read off that entry.
528
600
  * @param {string} [props.timing="ease"] - CSS `animation-timing-function`.
529
- * @param {string} [props.delay="0s"] - Default animation delay (CSS time string).
601
+ * @param {number|string} [props.delay="0s"] - Default delay for the steps that
602
+ * do not name one: a number is milliseconds, a string is handed to CSS.
530
603
  * @param {string} [props.fillMode="forwards"] - CSS `animation-fill-mode`.
531
604
  * @param {HTMLElement|null} [props.child] - The DOM element to animate.
532
605
  * @param {number|string} [props.top] - CSS top offset forwarded to child dataset.
@@ -549,91 +622,113 @@ export const AnimatedBox = ({
549
622
  }) => {
550
623
  if (!child) return null;
551
624
 
552
- // Forward position values to child dataset for use by layout systems
553
- if (top) child.dataset.top = typeof top === "number" ? `${top}px` : top;
554
- if (right)
555
- child.dataset.right = typeof right === "number" ? `${right}px` : right;
556
- if (bottom)
557
- child.dataset.bottom = typeof bottom === "number" ? `${bottom}px` : bottom;
558
- if (left) child.dataset.left = typeof left === "number" ? `${left}px` : left;
559
-
560
- if (animations && animations.length > 0) {
561
- const firstAnim = animations[0];
562
- const effect = effectMap[firstAnim.effect] || firstAnim.effect;
563
-
564
- if (firstAnim.effect === "textGradient") {
565
- // Apply gradient-clip styles to the child (typically a Text widget)
566
- child.style.color = "transparent";
567
- child.style.backgroundClip = "text";
568
- child.style.webkitBackgroundClip = "text";
569
- child.style.backgroundColor = "transparent";
570
-
571
- // Apply the starting gradient immediately so there is no flash on first render
572
- const gradientValue = firstAnim.from;
573
- if (gradientValue) {
574
- child.style.background = gradientValue;
575
- }
625
+ // Forward position values to child dataset for use by layout systems. A bare
626
+ // number is a framework unit, so it is written the way every other length in
627
+ // the framework is written: as rem.
628
+ const offset = (value) => (typeof value === "number" ? toREM(value) : value);
629
+ if (top) child.dataset.top = offset(top);
630
+ if (right) child.dataset.right = offset(right);
631
+ if (bottom) child.dataset.bottom = offset(bottom);
632
+ if (left) child.dataset.left = offset(left);
633
+
634
+ if (!animations || animations.length === 0) return child;
635
+
636
+ // Each step's own `duration` and `delay`, read off that step. Taking whichever
637
+ // descriptor came first is what made the documented
638
+ // `{ scale, duration: 600 } + { opacity, duration: 400 }` entrance run both
639
+ // steps for 600 ms, and what made `AnimatedText`'s per-letter delay the only
640
+ // delay anyone could set.
641
+ const defaultDelay = cssMs(delay, 0);
642
+ const steps = [];
643
+ for (const anim of animations) {
644
+ if (!anim || anim.from === undefined || anim.to === undefined) {
645
+ // Malformed descriptor: skip it with a warning — one, not one per keyframe
646
+ // step — rather than throwing a TypeError out of the caller's whole render.
647
+ console.warn(
648
+ `⚠️ [AnimatedBox] Skipping animation "${anim?.effect}": "from" and "to" are required.`,
649
+ );
650
+ continue;
576
651
  }
652
+ steps.push({
653
+ ...anim,
654
+ duration: cssMs(anim.duration, DEFAULT_DURATION),
655
+ delayMs: cssMs(anim.delay, defaultDelay),
656
+ loop: Boolean(anim.loop),
657
+ });
658
+ }
659
+ if (steps.length === 0) return child;
660
+
661
+ const gradient = steps.find((anim) => anim.effect === "textGradient");
662
+ if (gradient) {
663
+ // Apply gradient-clip styles to the child (typically a Text widget). The
664
+ // starting gradient arrives below, with every other step's `from`.
665
+ child.style.color = "transparent";
666
+ child.style.backgroundClip = "text";
667
+ child.style.webkitBackgroundClip = "text";
668
+ child.style.backgroundColor = "transparent";
669
+ }
577
670
 
578
- /**
579
- * Applies an initial inline style to the child, converting numeric length
580
- * values to rem before assignment.
581
- *
582
- * @param {string} prop - camelCase CSS property name.
583
- * @param {number|string} value - Value to set (numbers are rem-converted for length effects).
584
- * @returns {void}
585
- */
586
- const setInitialStyle = (prop, value) => {
587
- if (value === undefined) return;
588
- let finalValue = value;
589
- if (typeof value === "number" && lengthEffects.has(effect)) {
590
- finalValue = toREM(value);
591
- }
592
- child.style[prop] = finalValue;
593
- };
594
-
595
- // Set the starting (from) value on the child so there is no layout jump
596
- if (effect === "backgroundColor") {
597
- child.style.backgroundColor = firstAnim.from;
598
- } else if (effect === "borderRadius") {
599
- setInitialStyle("borderRadius", firstAnim.from);
600
- } else if (effect === "color") {
601
- child.style.color = firstAnim.from;
602
- } else if (effect === "width") {
603
- setInitialStyle("width", firstAnim.from);
604
- } else if (effect === "height") {
605
- setInitialStyle("height", firstAnim.from);
606
- } else if (effect.startsWith("margin")) {
607
- const prop = effect.replace(/([A-Z])/g, "-$1").toLowerCase();
608
- setInitialStyle(prop, firstAnim.from);
609
- } else if (effect.startsWith("padding")) {
610
- const prop = effect.replace(/([A-Z])/g, "-$1").toLowerCase();
611
- setInitialStyle(prop, firstAnim.from);
612
- } else if (effect === "borderWidth") {
613
- setInitialStyle("borderWidth", firstAnim.from);
614
- } else if (effect === "borderColor") {
615
- child.style.borderColor = firstAnim.from;
616
- } else if (effect === "boxShadow") {
617
- child.style.boxShadow = firstAnim.from;
618
- } else if (effect === "background") {
619
- child.style.background = firstAnim.from;
620
- }
671
+ // Every step starts where it is going to leave from, written inline, so the
672
+ // frame before the animation begins is the frame it begins with. Doing this
673
+ // only for the first step left a delayed or shorter step showing its ending
674
+ // state — a fade that flashed visible before it faded in.
675
+ writeStartValues(child, steps);
676
+
677
+ // A looping step and a one-shot step cannot live in the same rule: repeating
678
+ // the rule would repeat the one-shot too, so an entrance meant to play once
679
+ // would flicker forever. Each timing group generates its own `@keyframes`.
680
+ const groups = [];
681
+ for (const loop of [false, true]) {
682
+ const members = steps.filter((anim) => anim.loop === loop);
683
+ if (members.length) groups.push({ loop, members });
684
+ }
685
+ if (groups.length > 1 && groups.every(groupHasTransform)) {
686
+ console.warn(
687
+ '⚠️ [AnimatedBox] transform steps split between a loop and a one-shot: two rules cannot write one `transform`, so only the last rule\'s transform is applied. Keep the transform steps in one group.',
688
+ );
689
+ }
621
690
 
622
- const keyframes = generateKeyframes(animations);
691
+ const rules = groups.map(({ loop, members }) => {
692
+ // The group starts when its earliest step does; a later step waits inside
693
+ // the rule instead of buying a second `animation-delay`.
694
+ const startsAt = Math.min(...members.map((anim) => anim.delayMs));
695
+ const entries = members.map((anim) => ({
696
+ ...anim,
697
+ offset: anim.delayMs - startsAt,
698
+ }));
699
+ const span = entries.reduce(
700
+ (longest, anim) => Math.max(longest, anim.offset + anim.duration),
701
+ 0,
702
+ );
703
+ const keyframes = generateRule(entries, span);
623
704
  // The name is derived from the keyframe body so identical animations reuse a
624
705
  // single <style>. A per-call unique name (`Date.now()`) made the dedupe check
625
706
  // in `injectKeyframes` dead code: every call appended a permanent <style> to
626
707
  // document.head, and AnimatedText calls this once per letter.
627
- const name = `anim-${hashKeyframes(keyframes)}`;
708
+ const name = animationName("anim", keyframes);
628
709
  injectKeyframes(name, keyframes);
629
-
630
- const duration = animations[0]?.duration || 500;
631
- const iteration = animations[0]?.loop ? "infinite" : "1";
632
- // Per-animation delay takes precedence over the prop-level default
633
- const animationDelay = animations[0]?.delay || delay;
634
-
635
- child.style.animation = `${name} ${duration}ms ${timing} ${animationDelay} ${iteration} normal ${fillMode}`;
636
- }
710
+ return { name, span, startsAt, loop };
711
+ });
712
+ // The hook the OS preference rule matches. The JS values below cover the app's
713
+ // own mode; this is what stops a loop already running when the system setting
714
+ // flips, with nothing to rebuild and no listener to leak.
715
+ markMotionLoop(child);
716
+
717
+ // Reduced motion is the accessibility recipe, not a switch-off: a near-zero
718
+ // duration lands the element on its `to` values through `fillMode: forwards`,
719
+ // so the state the animation was carrying still arrives — only the movement
720
+ // disappears. A loop becomes a single frame, and the delay goes with it:
721
+ // content held invisible for two seconds is worse than content that moved.
722
+ const reduced = prefersReducedMotion();
723
+ child.style.animation = rules
724
+ .map((rule) =>
725
+ reduced
726
+ ? `${rule.name} 0.01ms ${timing} 0s 1 normal ${fillMode}`
727
+ : `${rule.name} ${rule.span}ms ${timing} ${rule.startsAt}ms ${
728
+ rule.loop ? "infinite" : "1"
729
+ } normal ${fillMode}`,
730
+ )
731
+ .join(", ");
637
732
 
638
733
  return child;
639
734
  };