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
@@ -2,6 +2,20 @@
2
2
 
3
3
  FletBox includes small helpers for common UI and application tasks. Import only the helpers you need from `flet-box`.
4
4
 
5
+ These 123 helpers are the **short form** of code you would otherwise write by hand —
6
+ a shadow, a gradient, a mock list, a retry, a relative date. The rule that goes with
7
+ them is a comparison, not a prohibition: reach for a helper when the code it replaces
8
+ is longer or less clear, which for the examples below it is.
9
+
10
+ The one trap: **a routed prop is already the short form**. `Container({ padding: 16 })`
11
+ is shorter and more correct than `padding: padding(16)`, because the number reaches the
12
+ rem contract and the helper returns a pixel string. Use the string builders for a CSS
13
+ value a prop cannot express, not instead of a number.
14
+
15
+ Two names that read as something else: `filter({ blur: 5 })` builds a CSS filter, it
16
+ does not filter a list (that is `mapList`, `sort`, `unique`), and `color(x).lighten(20)`
17
+ is a chain that only becomes a color at `.hex()`, `.rgb()` or `.rgba()`.
18
+
5
19
  ## Layout helpers
6
20
 
7
21
  Use direct props for the simplest code:
@@ -90,20 +104,50 @@ Use random helpers for demos and sample data. Do not use generated demo values a
90
104
 
91
105
  ## Strings and dates
92
106
 
107
+ The short form of the text and calendar code you would otherwise write by hand.
108
+
93
109
  ```javascript
94
110
  import {
111
+ addDays,
95
112
  capitalizeWords,
113
+ dateDiff,
114
+ dayOfWeek,
96
115
  formatDate,
116
+ formatDateLong,
117
+ isWeekend,
118
+ parseDate,
97
119
  relativeTime,
120
+ seconds,
121
+ timeAgoLocale,
122
+ today,
123
+ trim,
98
124
  truncate,
99
125
  } from "flet-box";
100
126
 
101
- capitalizeWords("flet box");
102
- truncate("A very long sentence", 10, "...");
103
- formatDate(new Date(), "YYYY-MM-DD");
104
- relativeTime("2026-01-01");
127
+ capitalizeWords("flet box"); // "Flet Box"
128
+ truncate("A very long sentence", 10); // "A very lon..."
129
+ trim(" flet-box "); // "flet-box"
130
+
131
+ formatDate(today(), "YYYY-MM-DD"); // today(), read back as itself
132
+ formatDateLong(addDays(new Date(), 7));
133
+ dayOfWeek("2024-01-15"); // "Monday"
134
+ isWeekend("2024-01-15"); // false
135
+ dateDiff(parseDate("15/01/2024", "DD/MM/YYYY"), new Date(), "days");
136
+ relativeTime(Date.now() - seconds(90)); // "1 minute ago"
137
+ timeAgoLocale(Date.now() - seconds(90), "es"); // "hace 1 minuto"
105
138
  ```
106
139
 
140
+ Two things the helpers get right that hand-rolled `new Date(...)` chains usually
141
+ don't. A bare `"YYYY-MM-DD"` is read as a **calendar day in the reader's zone**,
142
+ so `isToday(today())` is true on a machine in any timezone; and `addDays` counts
143
+ days on the calendar rather than adding `86400000` milliseconds, so it does not
144
+ slip a day across a DST transition. `months(n)` and `years(n)` are the honest
145
+ exceptions — they approximate 30 and 365 days, which is fine for a cache window
146
+ and wrong for a birthday. For the repeating half of the same module — `every()`,
147
+ `everySecond()`, `timer()`, `waitUntil()` — see
148
+ [Text & time](../tools/text-and-time.md); whatever registers an interval releases
149
+ it in `onUnmount`.
150
+
107
151
  ## Delays and retries
108
152
 
109
153
  ```javascript
@@ -115,6 +159,9 @@ await withMinDelay(fetchData(), 800);
115
159
  ```
116
160
 
117
161
  Use these helpers for loading states, demos, and recoverable network operations.
162
+ `waitUntil(condition, timeout, interval)` and `waitForElement(selector, …)` cover
163
+ the polling loop, and both **throw** at their timeout — await them inside a
164
+ `try`, or answer them with a `.catch`.
118
165
 
119
166
  ## Device and dimensions
120
167
 
@@ -139,6 +186,62 @@ const removeListener = dimensions.addListener(({ width, height }) => {
139
186
  removeListener();
140
187
  ```
141
188
 
189
+ `device` reads a user agent; the width is the fact a layout can trust. The full
190
+ reference — the breakpoint table, the subscription and font loading — is
191
+ [Device & environment](../tools/device.md).
192
+
193
+ ## Breakpoints
194
+
195
+ One table, five steps, in CSS pixels: `xs` 0, `sm` 560, `md` 768, `lg` 1024,
196
+ `xl` 1280. `md` is the same line `AdaptiveScaffold` draws between a drawer and a
197
+ permanent sidebar, so a screen and its shell cannot disagree.
198
+
199
+ ```javascript
200
+ import { atLeast, pick, currentBreakpoint } from "flet-box";
201
+
202
+ const twoColumns = atLeast("md");
203
+ const gutter = pick({ xs: 16, md: 24, lg: 32 });
204
+ const step = currentBreakpoint(); // "sm" at 600px, "md" at 800px
205
+
206
+ // A step that is not named inherits the nearest smaller one:
207
+ // pick({ xs: 8, md: 16 }) answers 8, 8, 16, 16, 16.
208
+ ```
209
+
210
+ Rebuild only when the viewport *crosses* a step, not on every pixel of a resize.
211
+ `onBreakpointChange` returns the unsubscribe — hand it to the widget that owns the
212
+ subscription:
213
+
214
+ ```javascript
215
+ const [isWide, setIsWide] = useState("isWide", atLeast("md"));
216
+
217
+ const screen = Column({
218
+ padding: gutter,
219
+ children: [Text({ text: String(isWide) })],
220
+ });
221
+
222
+ screen.onUnmount(onBreakpointChange(() => setIsWide(atLeast("md"))));
223
+ ```
224
+
225
+ ## Fonts
226
+
227
+ `fontFamily` is a name. If the bytes of that face are not on the machine, the
228
+ browser paints its own fallback and the app ships a typeface nobody chose.
229
+
230
+ ```javascript
231
+ import { loadFont, fontsReady } from "flet-box";
232
+
233
+ // A webfont is a network request: ask, wait, then name it.
234
+ const loaded = await loadFont("Inter", { weights: [400, 600] });
235
+ if (loaded) Text({ text: "Hello", fontFamily: "Inter" });
236
+
237
+ // Before a canvas paint or an export, where reflowing text is a visible bug.
238
+ await fontsReady();
239
+ ```
240
+
241
+ `loadFont` resolves `false` when the face could not be had — it never fails a
242
+ screen. Pass `url` to load your own same-origin stylesheet instead of a
243
+ provider's, which is the answer an offline launch keeps.
244
+
142
245
  ## Themes and colors
143
246
 
144
247
  ```javascript
@@ -231,6 +334,15 @@ formatMediaProgress(30, 120);
231
334
  - Need URL changes? Read [Routing](router.md).
232
335
  - Need colors that follow the app theme? Use `colors` and `getColor`.
233
336
  - Need browser information? Use `device`, `os`, and `dimensions`.
337
+ - Need a layout that changes with the window? Use `atLeast` and `pick`, and
338
+ subscribe with `onBreakpointChange` — never a user-agent guess.
339
+ - Need a typeface that is not on the machine? Load it with `loadFont` before you
340
+ name it in `fontFamily`.
341
+ - Need a date moved, compared or labelled? Use `addDays`, `dateDiff`, `formatDate`
342
+ and `timeAgo` — not a millisecond walk you have to re-derive each time.
343
+ - Need a period, a stopwatch or a poll loop? Use `seconds(30)` and `every(…)`,
344
+ `timer(label)`, `waitUntil(…)`; the id `every` hands back is what `clearInterval`
345
+ releases in `onUnmount`.
234
346
 
235
347
  ---
236
348
 
@@ -114,6 +114,32 @@ export default MyWidget;
114
114
  pass CSS props directly (`backgroundColor`, `padding`, `flexDirection`,
115
115
  `whiteSpace`, …) with aliases resolved by the factory. `style: { ... }` is
116
116
  rejected with a warning (`processProps`), so it never silently half-applies.
117
+ 12. **A shared helper has one home.** The first time a helper is written it is
118
+ local; the second time it is a module both callers import. Two copies pass
119
+ every test on the day they are written and diverge on the day one is fixed —
120
+ so the home is named and the gate enforces it:
121
+
122
+ | Behaviour | Home |
123
+ | --- | --- |
124
+ | Escaping text for HTML | [`src/helpers/html.js`](../../src/helpers/html.js) |
125
+ | The four-side box shorthand | [`src/helpers/boxEdges.js`](../../src/helpers/boxEdges.js), behind `margin()`/`padding()` |
126
+ | Injecting `@keyframes`, naming one | [`src/utils/styleInjector.js`](../../src/utils/styleInjector.js) |
127
+ | A number that becomes a length | [`src/utils/units.js`](../../src/utils/units.js) |
128
+
129
+ **`src/helpers/` is the folder for reusable code, and a module there imports
130
+ nothing at all** — no widget, no theme, no engine service. That is what makes
131
+ it safe to import from anywhere: `html.js` has no dependencies on purpose,
132
+ because importing the Markdown parser from a widget would pull six widgets
133
+ into that widget's graph. A helper that needs another module is a service and
134
+ belongs in `utils/`. `node scripts/check-shared-helpers.mjs` fails on a second
135
+ definition, an `@keyframes` rule written outside the injector, a helper that
136
+ grew an import, or a caller that stopped importing the home.
137
+ 13. **Only the code the widget needs.** No prop nobody passes, no option kept
138
+ "for later", no abstraction with a single caller, no wrapper that only
139
+ forwards its arguments — that last one is code to delete, not to keep. The
140
+ second caller is when a helper appears (rule 12), not before. Three lines
141
+ repeated once reads better than a premature module; the same three lines
142
+ repeated three times do not.
117
143
 
118
144
  ## Controller widgets
119
145
 
@@ -158,6 +184,10 @@ Everything else returns an `HTMLElement`.
158
184
  to the baseline is never the fix.
159
185
  - `node scripts/check-widgets.mjs --write-baseline` — re-snapshot after a batch
160
186
  of fixes (only to *remove* entries in practice).
187
+ - `node scripts/check-shared-helpers.mjs` — the single-home contract from rule
188
+ 12: one definition per shared helper, the module named in the rule, and every
189
+ caller importing it. `--root <dir>` checks another checkout, which is how each
190
+ of its five rules was proven to fail when the rule is broken.
161
191
  - `/tests/widget-contract.html` — runtime contract: every widget instantiates,
162
192
  returns an element (or a documented controller), exposes `update()`, and
163
193
  reports its real `_widgetName`.
@@ -30,7 +30,7 @@ Each category is the next chapter of the reading path:
30
30
  | [Visual effects](visual-effects.md) | `applyShimmer`, `applyGlow`, `applyPulse`, `injectKeyframes` |
31
31
  | [Widget introspection](introspection.md) | `getWidgetProps`, `getWidgetProp`, `stringifyWidgetProps` |
32
32
  | [Input validation](validation.md) | `TextInputValidator` rules |
33
- | [Animation helpers](animation.md) | `animate`, `fadeIn`, `pulse` + `AnimatedBox` and friends |
33
+ | [Animation helpers](animation.md) | `slideIn`, `popIn`, `shake`, `animate`, `fadeIn`, `pulse`, springs and `EASING_NAMES`, `setMotionMode` + `AnimatedBox` and friends |
34
34
 
35
35
  ## Styling helpers (CSS in JS)
36
36
 
@@ -1,38 +1,159 @@
1
1
  # Animation helpers
2
2
 
3
3
  ## Overview
4
- Two ways to animate in FletBox: `animate`/`fadeIn`/`fadeOut`/`pulse` (and their `Async` variants) run animations on existing elements, while the widgets `AnimatedBox`, `AnimatedText`, `MatrixRain`, and `ParallaxBox` give you animated UI out of the box. Every helper-driven animation is cancelable — through the function it returns or later with `stopAnimation(widget)`.
4
+ Three ways to animate in FletBox: the **named transitions** (`slideIn`, `popIn`, `shake`, `slideOut`) for the gestures a screen keeps repeating, `animate`/`fadeIn`/`fadeOut`/`pulse` (and their `Async` variants) for one property of one element, and the widgets `AnimatedBox`, `AnimatedText`, `MatrixRain`, and `ParallaxBox` for animated UI out of the box. Every helper-driven animation is cancelable — through the function it returns or later with `stopAnimation(widget)` — and all three honour the app's [motion preference](#reduced-motion), so a screen never has to ask whether the user wants movement.
5
5
 
6
6
  ## When to use
7
- - Animate an element when something happens: fade a panel in, pulse a button, animate a value change.
7
+ - Bring a screen, sheet or card in: `slideIn(card)`, `popIn(badge)` — one call, one gesture.
8
+ - Say "that was wrong": `shake(field)` on a rejected form.
9
+ - Animate one property when something happens: fade a panel in, pulse a button, animate a value change.
8
10
  - Stop an animation the element no longer needs: `onUnmount(() => stopAnimation(el))`.
9
11
  - Add moving backgrounds (`MatrixRain`, `ParallaxBox`).
10
12
 
11
13
  ## Import
12
14
 
13
15
  ```javascript
14
- import { animate, fadeIn, fadeOut, pulse, stopAnimation, AnimatedBox } from "flet-box";
16
+ import {
17
+ animate, fadeIn, fadeOut, pulse, stopAnimation,
18
+ slideIn, slideOut, popIn, shake, animateSpring,
19
+ EASING_NAMES, cubicBezier, spring, stagger,
20
+ prefersReducedMotion, setMotionMode, getMotionMode, onMotionModeChange,
21
+ AnimatedBox,
22
+ } from "flet-box";
15
23
  ```
16
24
 
17
25
  ## Functions
18
26
 
19
27
  | Function | Signature | Returns | Description |
20
28
  | --- | --- | --- | --- |
21
- | `animate` | `animate(widget, property, from, to, duration?, easing?)` | cancel `() => boolean` | Animate one CSS `property` of a widget from `from` to `to` (both numbers or colors). `duration` is ms, `easing` defaults to `"linear"`. Calling the returned function stops the loop and returns `true` if it was still running. |
29
+ | `animate` | `animate(widget, property, from, to, duration?, easing?)` | cancel `() => boolean` | Animate one `property` of a widget from `from` to `to`. A number is a **framework unit** — the same unit every widget prop takes — so `translateY: 20` is `20 / base-font-size` rem and grows with the reader's font; a unit string keeps its unit (`"16px"`); and a color blends whichever way you write it (`#rrggbb`, `rgb(…)`, `rgba(…)`). `duration` is ms, `easing` defaults to `"linear"` — a name from [`EASING_NAMES`](#easings), a `cubicBezier()` or a `spring().easing`. Calling the returned function stops the loop and returns `true` if it was still running. |
22
30
  | `animateAsync` | `animateAsync(widget, property, from, to, duration?, easing?)` | `Promise` | Same, resolves with the widget when the animation finishes — or as soon as it is cancelled or replaced, so `await` never hangs. |
31
+ | `animateSpring` | `animateSpring(widget, property, from, to, options?)` | cancel `() => boolean` | The same loop driven by a damped spring: there is no duration to pick, the animation runs until it settles. `options` are `stiffness`, `damping`, `mass`, `initialVelocity`. |
32
+ | `animateSpringAsync` | same, `Async` suffix | `Promise` | Promise variant of `animateSpring`. |
23
33
  | `stopAnimation` | `stopAnimation(widget, property?)` | `boolean` | Stop what is animating `widget` — one `property`, or every animated property when it is omitted. Use it in `onUnmount` when you did not keep the cancel function. |
24
- | `fadeIn` | `fadeIn(widget, duration?)` | cancel `() => boolean` | Fade the widget in (default 300 ms). |
25
- | `fadeOut` | `fadeOut(widget, duration?)` | cancel `() => boolean` | Fade it out. |
26
- | `pulse` | `pulse(widget, duration?)` | cancel `() => boolean` | A quick scale pulse: up for the first half, back down for the second. |
27
- | `fadeInAsync` / `fadeOutAsync` / `pulseAsync` | same, `Async` suffix | `Promise` | Promise variants that resolve on completion. |
34
+ | `fadeIn` | `fadeIn(widget, duration?)` | cancel `() => boolean` | Fade the widget in to full opacity (default 300 ms). |
35
+ | `fadeOut` | `fadeOut(widget, duration?)` | cancel `() => boolean` | Fade it out — from wherever the element actually is, so a card the theme set to `0.8` does not first jump to fully opaque. |
36
+ | `pulse` | `pulse(widget, duration?)` | cancel `() => boolean` | A quick scale pulse: one loop that swells to `1.1` at the halfway mark and is back at `1` when it ends. |
37
+ | `fadeInAsync` / `fadeOutAsync` / `pulseAsync` | same, `Async` suffix | `Promise` | The same single loop, awaitable, resolving with the widget — the sync and awaitable forms of a preset are one gesture, not two implementations. |
28
38
 
29
39
  Starting a second animation on the same widget and property **replaces** the first one; two loops writing
30
40
  one style would otherwise fight, and the older animation would resume after the newer had finished.
41
+ Transform functions are the exception, and deliberately: `translateY` and `scale` are separate
42
+ properties in the registry and are merged into one `transform` string, so an element can slide and
43
+ grow at the same time.
44
+
45
+ ## Named transitions
46
+
47
+ The three gestures every product repeats, with the durations and the distances already chosen, so an
48
+ app does not hand-roll eleven slightly different entrances. Each returns a cancel function and has an
49
+ `Async` form that resolves with the widget. `distance` is a framework unit like every other length in
50
+ the framework — `16` is `1 rem` at the default base, not sixteen fixed pixels.
51
+
52
+ | Function | Signature | What it says |
53
+ | --- | --- | --- |
54
+ | `slideIn` | `slideIn(widget, { direction, distance, duration, easing, fade })` | "this appeared" — a short travel plus a fade. `direction` is the way the element travels (`"up"` starts below its resting place), `distance` defaults to `16`, `duration` to `240`, `easing` to `"easeOut"`. |
55
+ | `slideOut` | `slideOut(widget, { direction, distance, duration, easing, fade })` | "this is gone" — the mirror, one notch shorter (180 ms). What follows is your unmount: `await slideOutAsync(dialog).then(() => dialog.remove())`. |
56
+ | `popIn` | `popIn(widget, { from, to, duration, easing, fade })` | "this belongs here" — scale from `0.94` with a small overshoot (`"easeOutBack"`). Nothing enters from `scale: 0`. |
57
+ | `shake` | `shake(widget, { axis, distance, cycles, duration })` | "that was wrong" — one decaying sine on `translateX` (or `translateY`), 6 units, 3 swings, 320 ms, ending at rest. |
58
+
59
+ ```javascript
60
+ // A sheet that arrives, and the error that refuses it.
61
+ slideIn(panel, { direction: "up", distance: 24, duration: 220 });
62
+
63
+ if (!password.valid) {
64
+ shake(loginForm, { axis: "x" });
65
+ }
66
+ ```
67
+
68
+ ## Easings
69
+
70
+ `animate()` reads a **FletBox name**, not a CSS string: `"ease-out"` is not in this vocabulary and the
71
+ loop would run `linear` (with a warning) while `AnimatedBox({ timing })` hands the same word straight
72
+ to CSS. `EASING_NAMES` is the list the framework knows.
73
+
74
+ | Family | Names |
75
+ | --- | --- |
76
+ | The original four | `linear`, `easeIn`, `easeOut`, `easeInOut` |
77
+ | Powers | `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeInExpo`, `easeOutExpo`, `easeInOutExpo`, `easeInCirc`, `easeOutCirc`, `easeInOutCirc` |
78
+ | Overshoot | `easeInBack`, `easeOutBack`, `easeInOutBack`, `easeInElastic`, `easeOutElastic`, `easeOutBounce` |
79
+ | Material shapes | `standard`, `emphasized`, `emphasizedDecelerate`, `emphasizedAccelerate` |
80
+
81
+ An entrance decelerates — `easeOut*`, `emphasizedDecelerate` or a spring. `easeIn*` starts slow, which
82
+ is why it belongs on something leaving the screen at speed and nowhere else in an interface.
83
+
84
+ ```javascript
85
+ // Any CSS curve, on the imperative side.
86
+ animate(card, "translateY", 24, 0, 240, cubicBezier(0.2, 0, 0, 1));
87
+
88
+ // A gesture that continues into its resting place instead of stopping at it.
89
+ animateSpring(button, "scale", 0.96, 1, { stiffness: 260, damping: 20 });
90
+ ```
91
+
92
+ `stagger()` gives an entrance its choreography without the arithmetic that turns 20 rows into a 1.2 s
93
+ wait: the delay stops growing after `max` items.
94
+
95
+ ```javascript
96
+ list.forEach((row, index) => {
97
+ slideIn(row, { duration: 240 });
98
+ row.style.animationDelay = `${stagger(index, { step: 60, max: 8 })}ms`;
99
+ });
100
+ ```
101
+
102
+ ## Reduced motion
103
+
104
+ `prefers-reduced-motion` is not a style note: for readers with a vestibular disorder, parallax and
105
+ large movement are physically unpleasant. FletBox answers it in the engine, so an animation cannot
106
+ ship without the check.
107
+
108
+ | Function | Signature | Description |
109
+ | --- | --- | --- |
110
+ | `prefersReducedMotion` | `prefersReducedMotion()` | `true` when the app or the operating system asked for no movement. |
111
+ | `setMotionMode` | `setMotionMode("auto" \| "reduce" \| "full")` | The app's own switch. `"auto"` (the default) follows the OS; `"reduce"` never moves; `"full"` always animates. Wires to a settings toggle. |
112
+ | `getMotionMode` | `getMotionMode()` | The mode in effect. |
113
+ | `onMotionModeChange` | `onMotionModeChange(listener)` | Calls `listener(reduced)` when the mode — or the OS setting — changes. Returns the unsubscribe; call it from `onUnmount`. |
114
+ | `motionDuration` | `motionDuration(240)` | The duration a loop should actually run: `0` when motion is reduced. |
115
+
116
+ What each surface does with it:
117
+
118
+ - **`animate()` and the presets** collapse to a single frame that writes the end value. The state still
119
+ lands and an `await` still resolves — only the movement disappears.
120
+ - **`AnimatedBox`** runs its keyframes at `0.01 ms`, once, with no delay. `fillMode: "forwards"` leaves
121
+ the element on its `to` values, and a `loop` stops looping. An element already looping when the reader
122
+ changes the OS setting is caught by a `@media (prefers-reduced-motion: reduce)` rule the engine injects
123
+ once, which overrides every animated element — no listener to leak, nothing to rebuild.
124
+ - **Every `infinite` CSS animation the factory writes** is marked with that same hook, so the loading
125
+ loops belong to the widget author: `Skeleton`, `ProgressBar`, `Slider` (striped or glowing), the
126
+ `ListView` refresh spinner and the `Modal` spinner all stop on their own.
127
+ - **`ParallaxBox`** parks its layer at the origin and stops answering scroll or pointer input.
128
+ - **`MatrixRain`** paints one static field and releases the `requestAnimationFrame`.
129
+ - **`applyPulse`** does nothing at all.
130
+
131
+ ```javascript
132
+ // A settings screen that hands the decision to the reader.
133
+ Switch({
134
+ label: "Reduce motion",
135
+ value: getMotionMode() === "reduce",
136
+ onToggle: (on) => setMotionMode(on ? "reduce" : "auto"),
137
+ });
138
+ ```
31
139
 
32
140
  ## Widgets
33
141
 
34
- - `AnimatedBox({ animations, timing, delay, fillMode, child, top, right, bottom, left })` — animates a child widget. `animations` is an array of `{ effect, from, to, duration }` entries; each plays once and holds `to` at the end (add `reverse: true` to cycle back, and `loop: true` to repeat); effects include `backgroundColor`, `color`, `borderRadius`, `opacity`, `scale`, `translate`, `rotate`, `width`, `height`, spacing, `boxShadow`, and gradients.
35
- - `AnimatedText({ child, animations, sameTime, delayBetween, orientation })` — animates a `Text` widget (letters split per animation).
142
+ - `AnimatedBox({ animations, timing, delay, fillMode, child, top, right, bottom, left })` — animates a child widget.
143
+ `animations` is an array of `{ effect, from, to }` steps, and each step may carry its own `duration`, `delay`,
144
+ `loop` and `reverse` — every one of them is read off **that step**, so a 600 ms `scale` and a 400 ms `opacity`
145
+ run on two clocks inside one rule. Times are milliseconds as a number (`duration: 400`) or a CSS time as a
146
+ string (`delay: "0.4s"`); the widget-level `delay` is the default for the steps that do not name one. A step
147
+ plays once and holds `to` at the end (`reverse: true` swings it back to `from`, `loop: true` repeats it).
148
+ Effects are `opacity`, `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY`, `rotateZ`, `skewX`,
149
+ `skewY`, `translateX`, `translateY`, `translateZ`, `backgroundColor`, `color`, `borderColor`, `borderWidth`,
150
+ `borderRadius`, `width`, `height`, `boxShadow`, `background`, `textGradient` and the per-side margin/padding
151
+ steps. A looping step and a one-shot step generate two `@keyframes` rules — one rule per timing group — and
152
+ since two rules cannot compose one `transform`, keep the transform steps in one group. `timing` is a **CSS**
153
+ string (`"ease-out"`, `"cubic-bezier(…)"`).
154
+ - `AnimatedText({ child, animations, sameTime, delayBetween, orientation })` — animates a `Text` widget, one step
155
+ per character. `delayBetween` is in **seconds** (`0.05` is a 50 ms stagger); the letters share one generated
156
+ rule and each carries its own delay.
36
157
  - `MatrixRain({ chars, fontSize, speed, fadeAmount, resetProbability, useDynamicColor, position, zIndex })` — the falling-characters canvas effect (returns a `canvas`).
37
158
  - `ParallaxBox({ type, speed, direction, maxOffset, reverse, child })` — content that moves; `type` is `"scroll"`, `"mouse"`, or `"hover"`, `direction` is `"vertical"`, `"horizontal"`, or `"both"`.
38
159
 
@@ -67,7 +188,9 @@ AnimatedBox({
67
188
 
68
189
  ## Notes
69
190
 
70
- - Animation timing follows CSS `animation-duration` speeds (seconds/ms values depending on context — pass a number for ms in the widget props).
191
+ - Two vocabularies of time, and each is honest about itself: `duration`/`delay` on a step or a preset are
192
+ milliseconds (a number, or a CSS time string the engine parses), `stagger()` returns milliseconds for
193
+ `animationDelay`, and `AnimatedText`'s `delayBetween` is seconds.
71
194
  - The `Async` variants are handy for sequences: `await fadeInAsync(el); await fadeOutAsync(el);`
72
195
  - For CSS-string animations use the `animation()` styling helper from [Styling helpers](styling.md).
73
196
 
@@ -1,18 +1,26 @@
1
1
  # Device & environment
2
2
 
3
3
  ## Overview
4
- Detect what your app is running on: screen, orientation, operating system, and clipboard access. These helpers read the browser and stay in sync when the window changes.
4
+ Detect what your app is running on: screen, orientation, operating system, and clipboard access. These helpers read the browser and stay in sync when the window changes. The same page carries the two halves a responsive screen needs — the **breakpoint** vocabulary (`BREAKPOINTS`, `currentBreakpoint`, `atLeast`, `pick`, `onBreakpointChange`), which says *which layout this viewport wants*, and `loadFont`, which makes a `fontFamily` prop real.
5
5
 
6
6
  ## When to use
7
7
  - Layout that changes between phone and desktop (`device`, `dimensions`).
8
+ - One value per viewport (`pick({ xs: 8, md: 16 })`), or a decision that has to agree with `AdaptiveScaffold` (`atLeast("md")`).
9
+ - Rebuild a screen when the viewport *crosses a step*, not when it moves a pixel (`onBreakpointChange`).
8
10
  - Show platform-specific UI (`os`).
9
11
  - Copy and paste text with one call (`clipboard`).
10
12
  - Walk the widget tree (`addNavigation`).
13
+ - Load a webfont before you name it in `fontFamily` (`loadFont`, `fontsReady`).
11
14
 
12
15
  ## Import
13
16
 
14
17
  ```javascript
15
- import { device, os, dimensions, width, height, clipboard } from "flet-box";
18
+ import {
19
+ device, os, dimensions, width, height, clipboard,
20
+ BREAKPOINTS, BREAKPOINT_NAMES, breakpointForWidth,
21
+ currentBreakpoint, atLeast, pick, onBreakpointChange,
22
+ loadFont, fontsReady,
23
+ } from "flet-box";
16
24
  ```
17
25
 
18
26
  ## Objects
@@ -42,6 +50,67 @@ import { device, os, dimensions, width, height, clipboard } from "flet-box";
42
50
 
43
51
  `width` and `height` are also exported directly as numbers.
44
52
 
53
+ ### Breakpoints
54
+
55
+ `dimensions` answers "how wide is the window"; a screen almost always wants the
56
+ next question, "which layout does that width ask for". One table answers it, and
57
+ `md` is 768 — the same line `AdaptiveScaffold` draws between a drawer and a
58
+ permanent sidebar, so a screen and its shell cannot disagree.
59
+
60
+ | Function | Signature | Description |
61
+ | --- | --- | --- |
62
+ | `BREAKPOINTS` | `{ xs, sm, md, lg, xl }` | The widths in **CSS pixels** (0 · 560 · 768 · 1024 · 1280). A viewport is not a framework unit: it does not scale with the reader's font, and pretending otherwise puts a phone layout on a desktop. |
63
+ | `BREAKPOINT_NAMES` | `['xs' … 'xl']` | The same names in width order. |
64
+ | `currentBreakpoint()` | `() => Breakpoint` | The step the live window is in. `"xs"` where there is no window, so a Node import never throws. |
65
+ | `breakpointForWidth(px)` | `(px: number) => Breakpoint` | The pure read behind it — a test, or a width that is not the viewport (an embed's container). |
66
+ | `atLeast(name)` | `(name: Breakpoint) => boolean` | `atLeast("md")` is the sentence a two-column layout needs. |
67
+ | `pick(values, fallback?)` | `(Record<Breakpoint, T>, T) => T` | A value per step. An unnamed step inherits the nearest smaller one: `pick({ xs: 8, md: 16 })` reads 8, 8, 16, 16, 16. |
68
+ | `onBreakpointChange(listener)` | `(cb) => () => void` | Fires when the *name* changes, not on every pixel of a resize. Returns the unsubscribe; hand it to the widget's `onUnmount` so the subscription dies with the screen. |
69
+
70
+ ```javascript
71
+ // One screen, two shapes, no second codebase.
72
+ const gutter = pick({ xs: 16, md: 24, lg: 32 });
73
+
74
+ const items = atLeast("md")
75
+ ? [listPane, detailPane]
76
+ : [currentPane];
77
+
78
+ const [name, setName] = useState("bp", currentBreakpoint());
79
+ const header = Column({ padding: gutter, children: [Text({ text: name })] });
80
+ header.onUnmount(onBreakpointChange(setName));
81
+ ```
82
+
83
+ A breakpoint is a *decision*, not a device. `device.isMobile()` reads a user
84
+ agent, which a desktop in a narrow window cannot change and a tablet on desktop
85
+ mode will not admit; `currentBreakpoint()` reads the width the app actually has.
86
+ Use the width for layout and the UA only for a genuinely platform question (touch
87
+ long-press, a native back gesture).
88
+
89
+ ### Fonts
90
+
91
+ `fontFamily` is a name. If the bytes of that face are not on the machine, the
92
+ browser paints its fallback and the app ships a typeface nobody chose.
93
+
94
+ | Function | Signature | Description |
95
+ | --- | --- | --- |
96
+ | `loadFont(family, options?)` | `(string, { weights?, url?, display? }) => Promise<boolean>` | Ask for a family. `weights` is required for a Google family if you want anything but 400 — a heading needs its weight asked for by name. `url` loads your own stylesheet instead of a provider's, which is the same-origin answer an offline launch keeps. Resolves `false` when the face could not be had; the screen keeps its fallback instead of failing. |
97
+ | `fontsReady()` | `() => Promise<boolean>` | Wait for the faces already started. Use it before a canvas paint or an export, where text that reflows *after* the drawing is a visible bug. |
98
+
99
+ ```javascript
100
+ import { Text, loadFont } from "flet-box";
101
+
102
+ const body = Text({ text: "Reading", fontFamily: "Georgia, serif" });
103
+
104
+ // One request per family, whoever asks first.
105
+ loadFont("Inter", { weights: [400, 600] }).then((ok) => {
106
+ if (ok) body.style.fontFamily = "Inter, Georgia, serif";
107
+ });
108
+ ```
109
+
110
+ > A webfont is a network request. A self-hosted `@font-face` in the app's own CSS
111
+ > is free of that and is the right answer for a product; a provider URL is fine
112
+ > for a demo and is what a tunnel, a firewall or an offline launch breaks on.
113
+
45
114
  ### `os`
46
115
 
47
116
  | Method | Returns | Description |
@@ -111,7 +180,10 @@ dimensions.addListener(({ width, height }) => {
111
180
 
112
181
  ## Notes
113
182
 
114
- - `dimensions` subscribes to window resize internally; `addListener` returns an **unsubscribe function** — call it when the feature is destroyed to avoid leaks. Same for `device.onResize`/`onOrientationChange`.
183
+ - `dimensions` subscribes to window resize internally; `addListener` returns an **unsubscribe function** — call it when the feature is destroyed to avoid leaks. Same for `device.onResize`/`onOrientationChange` and `onBreakpointChange`.
184
+ - `onBreakpointChange` keeps **one** window listener for the whole app and fires only when the step name changes. Subscribing per card would still be one listener, but each callback would run on every pixel — a `Row` that re-renders on a pixel can change the scrollbar and resize the window again.
185
+ - `BREAKPOINTS` are pixels by design. Everything else about a FletBox screen is a framework unit that grows with the reader's font; the viewport is the browser's own number and the layout switch has to be in the same currency as the window.
186
+ - `loadFont` is idempotent per family and never rejects: a blocked request resolves `false`, and the app keeps its fallback. It writes a `<link>` into `document.head`, so a page with a strict CSP needs that host allowed — a self-hosted `url` avoids the question entirely.
115
187
  - `clipboard.read()` requires a user gesture in most browsers (it uses the same rules as `navigator.clipboard`).
116
188
  - `addNavigation` is idempotent — calling it twice on a widget is safe, and widgets already expose these members when they are built by the factory.
117
189
  - `os` also detects mobile/desktop families, which `device` derives for touch+screen decisions.