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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fletbox-motion
3
- description: When a FletBox screen may move and with which API - AnimatedBox, AnimatedText, ParallaxBox, animate/fadeIn/pulse, the frequency gate, durations that match the interaction, the two easing vocabularies the framework actually has, and what must never animate. Use when adding any transition, entrance, loading pulse, hover lift, parallax or hero motion.
3
+ description: When a FletBox screen may move and with which API - the named transitions (slideIn, slideOut, popIn, shake), AnimatedBox, AnimatedText, ParallaxBox, animate/fadeIn/pulse, springs and the easing vocabulary, reduced motion with setMotionMode, the frequency gate, and what must never animate. Use when adding any transition, entrance, error feedback, loading pulse, hover lift, parallax or hero motion.
4
4
  ---
5
5
 
6
6
  # Motion — the only animation contract this app has
@@ -23,27 +23,61 @@ A screen the user works in all day gets *less* motion than a page they visit onc
23
23
  If you cannot say which row of this table the interaction is in, it gets no
24
24
  animation.
25
25
 
26
- ## M1 The API is four things
27
-
26
+ ## M1 Pick the shortest API that says it
27
+
28
+ - **The named transitions** — the three gestures a product repeats, with the
29
+ distance and the duration already chosen:
30
+ `slideIn(widget, { direction, distance, duration, easing, fade })`,
31
+ `slideOut(…)` (the mirror, and the unmount follows it),
32
+ `popIn(widget, { from, to, duration, easing })`,
33
+ `shake(widget, { axis, distance, cycles, duration })`. Each returns a cancel
34
+ function; each has an `…Async` form that resolves with the widget. Use these
35
+ before reaching for `animate`: they are one call instead of three, and they
36
+ already obey M7.
37
+ - **The imperative helpers** — `animate(widget, property, from, to, duration, easing)`
38
+ for one property, `animateAsync` when the next step has to wait, `fadeIn` /
39
+ `fadeOut` / `pulse` (+ the `…Async` forms), `applyPulse`, and
40
+ `stopAnimation(widget, property)` for the cleanup you did not keep a handle for.
41
+ `animateSpring(widget, property, from, to, { stiffness, damping })` when the
42
+ movement should settle like an object instead of stopping like a timer.
28
43
  - **`AnimatedBox`** — declarative keyframes on one child:
29
44
  `AnimatedBox({ child, animations: [{ effect, from, to, duration, reverse, loop, delay }], timing, delay, fillMode })`.
45
+ Each step carries its own `duration`, `delay` and `loop` and each one is read off
46
+ that step: a 600 ms `scale` and a 400 ms `opacity` in the same entrance are one
47
+ rule that ends at 600 ms with the fade holding from 400 ms on. Times are
48
+ milliseconds as a number (`duration: 400`) or a CSS time as a string
49
+ (`delay: "0.4s"`); the widget-level `delay` is the default for the steps that do
50
+ not name one. A step that loops and a step that plays once cannot share a rule —
51
+ repeating the rule repeats the entrance too — so they become two `@keyframes`, and
52
+ two rules cannot compose one `transform`: keep the transform steps in one group.
30
53
  - **`AnimatedText`** — the same steps across the characters of a text, with
31
- `delayBetween` (a stagger in ms) and `sameTime: true` to fire it at once.
54
+ `delayBetween` in **seconds** (`0.05` is a 50 ms stagger; `40` is forty seconds)
55
+ and `sameTime: true` to fire it at once.
32
56
  - **`ParallaxBox`** — offset driven by input: `type: 'scroll' | 'mouse' | 'hover'`,
33
57
  `speed`, `direction: 'vertical' | 'horizontal' | 'both'`, `maxOffset`, `reverse`,
34
58
  `disabled`, `onParallaxMove`.
35
- - **The imperative helpers** — `animate(widget, property, from, to, duration, easing)`
36
- which returns a cancel function, `animateAsync` which returns a Promise,
37
- `fadeIn` / `fadeOut` / `pulse` (+ the `…Async` forms), `applyPulse`,
38
- `stopAnimation(widget, property)`.
39
59
 
40
60
  Anything else — a hand-written `transition: all`, a CSS library, a `requestAnimationFrame`
41
- loop in a widget — is a second animation system, and this app already has one.
42
-
43
- **Effects `AnimatedBox` knows:** `scale`, `rotate`, `rotateX`, `rotateY`,
44
- `translateX`, `translateY`, `opacity`, `color`, `backgroundColor`, `borderColor`,
45
- `borderWidth`, `borderRadius`, `width`, `height`, `boxShadow`, `background`,
46
- `textGradient`. Anything outside that list does nothing, silently.
61
+ loop in a widget — is a second animation system, and this app already has one. A
62
+ hand-rolled loop is also the one way to ship motion that ignores reduced motion,
63
+ because the engine is what reads the preference.
64
+
65
+ **Effects `AnimatedBox` knows:** `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`,
66
+ `rotateY`, `rotateZ`, `skewX`, `skewY`, `translateX`, `translateY`, `translateZ`,
67
+ `opacity`, `color`, `backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`,
68
+ `width`, `height`, `boxShadow`, `background`, `textGradient`. Anything outside that list
69
+ does nothing, silently. The spacing effects (margin and padding, per side) work as
70
+ well — they are routed by prefix, so the gate cannot see them by name.
71
+
72
+ **Properties `animate()` moves:** the same names, plus any real CSS property
73
+ (`width`, `opacity`, `borderRadius`, `color`…). A bare number is a **framework unit**,
74
+ exactly as it is in a widget: `translateY: 20` is `20 / base-font-size` rem, so the
75
+ movement scales with the reader's font instead of freezing at 20 pixels. A unit
76
+ string keeps its unit (`"16px"`), a color blends whether you write `#rrggbb`,
77
+ `rgb(…)` or `rgba(…)`, and a pair the framework cannot blend (`"red"` → `"blue"`)
78
+ holds the start and lands on the end rather than writing `NaN`. `translateX`,
79
+ `translateY` and `scale` are separate loops whose outputs are merged into one
80
+ `transform` string, so an element can slide and grow at the same time.
47
81
 
48
82
  ## M2 Duration follows the distance, and the interaction
49
83
 
@@ -58,31 +92,38 @@ what slow feels like after the third repetition:
58
92
  | Menu, dropdown, sheet | 150–250 | `opacity` + `translateY` 8 → 0 |
59
93
  | Modal, drawer | 200–400 | `translateX`/`translateY`, never scale |
60
94
  | Screen entrance, hero | 300–500 | `opacity` + `translateY` 16 → 0 |
95
+ | Rejected input | 300–360 | `shake`, never a color flash alone |
61
96
  | Loading | loop | `Skeleton({ animation: 'wave' })` |
62
97
 
63
98
  Nothing in the UI exceeds 500ms. A 300ms number on every element is not a design
64
99
  decision — it is the default.
65
100
 
66
101
  ```js
67
- // A sheet entering: 220ms, distance 24, nothing else.
68
- AnimatedBox({
69
- child: panel,
70
- animations: [
71
- { effect: 'opacity', from: 0, to: 1, duration: 220 },
72
- { effect: 'translateY', from: 24, to: 0, duration: 220 },
73
- ],
74
- });
102
+ // A sheet entering: the whole gesture is one call.
103
+ slideIn(panel, { direction: 'up', distance: 24, duration: 220 });
104
+
105
+ // …and leaving is the same sentence in the other direction, then the unmount.
106
+ await slideOutAsync(panel, { direction: 'down' });
107
+ panel.remove();
75
108
  ```
76
109
 
77
110
  ## M3 Two easing vocabularies — do not mix them
78
111
 
79
112
  This is a real trap, and it fails silently:
80
113
 
81
- - **`animate(..., easing)`** takes a *framework name*: `'linear' | 'easeIn' | 'easeOut' |
82
- 'easeInOut'`. An unrecognized string falls back to `linear`.
114
+ - **`animate(..., easing)`** takes a *framework name* — one of `EASING_NAMES` — or
115
+ a curve (`cubicBezier(0.2, 0, 0, 1)`, `spring().easing`). An unrecognized string
116
+ warns and falls back to `linear`.
83
117
  - **`AnimatedBox({ timing })` and `ParallaxBox({ easing })`** are passed straight to
84
118
  CSS: `'ease' | 'ease-out' | 'ease-in' | 'linear' | 'cubic-bezier(…)'`. `easeIn` in
85
- a `timing` string is not a curve CSS knows, and the step runs linear.
119
+ a `timing` string is not a curve CSS knows, and the step runs linear. `ease-out`
120
+ in `animate()` is not a framework name, and it runs linear too.
121
+
122
+ The names the engine knows: `linear`, `easeIn`, `easeOut`, `easeInOut`, the powers
123
+ (`easeOutCubic`, `easeInOutQuart`, `easeOutExpo`, `easeOutCirc` and their `In` /
124
+ `InOut` siblings), the overshoot family (`easeOutBack`, `easeInOutBack`,
125
+ `easeOutElastic`, `easeOutBounce`), and the Material shapes (`standard`,
126
+ `emphasized`, `emphasizedDecelerate`, `emphasizedAccelerate`).
86
127
 
87
128
  Rules that hold for both:
88
129
 
@@ -91,13 +132,23 @@ Rules that hold for both:
91
132
  - **Never** `easeIn` on an interface element: it starts slow, so the first 100ms of
92
133
  a press feels broken. Save it for something leaving the screen at speed.
93
134
  - A loop that has to feel like a machine uses `linear`.
135
+ - Use the overshoot family for one thing per screen at most. `easeOutElastic` on a
136
+ modal is a toy; on a badge that just arrived it is a highlight.
94
137
  - Never `transition: all`, and never animate `width`, `height`, `top`, `left` or
95
138
  `boxShadow` for something the user presses repeatedly: they reflow or repaint the
96
139
  page. `AnimatedBox` *can* animate them, which is exactly why this rule needs to be
97
140
  written down. Transform and opacity are the two properties that cost nothing:
98
141
  `scale`, `translateX/Y`, `rotate`, `opacity`.
99
142
  - Nothing enters from `scale: 0`, and nothing from full scale on a big element: use
100
- **0.9–0.97**. From zero, the element is a dot that pops.
143
+ **0.9–0.97**. From zero, the element is a dot that pops. That is `popIn`'s default.
144
+
145
+ ```js
146
+ // The curve a stylesheet would use, on the imperative side of the same screen.
147
+ animate(card, 'translateY', 24, 0, 240, cubicBezier(0.2, 0, 0, 1));
148
+
149
+ // Something the pointer just flicked finishes like an object, not like a timer.
150
+ animateSpring(button, 'scale', 0.96, 1, { stiffness: 260, damping: 20 });
151
+ ```
101
152
 
102
153
  ## M4 Press feedback, done right
103
154
 
@@ -112,16 +163,29 @@ onPress: (widget) => animate(widget, 'scale', 1, 0.97, 140, 'easeOut');
112
163
  of that is three simultaneous answers to one question. Pick one — and if the color
113
164
  change is enough, which it usually is, ship it and move on.
114
165
 
166
+ `shake` is not press feedback. It is the answer to "the server said no": 320ms,
167
+ three decaying swings, and it ends at rest so the field does not stay off its
168
+ column. Call it once per rejection, not on every keystroke.
169
+
170
+ ```js
171
+ if (!result.ok) {
172
+ shake(loginForm, { axis: 'x', distance: 6, cycles: 3 });
173
+ }
174
+ ```
175
+
115
176
  ## M5 Entrance and stagger
116
177
 
117
178
  Motion on first paint is the one place a longer budget is fair, with two limits:
118
179
 
119
180
  - Stagger **30–80ms** between siblings, and cap the total. `AnimatedText` gives this
120
- for free with `delayBetween: 40`; a hand-built list needs `delay` per step, and a
121
- list of 20 rows at 60ms each is a 1.2s wait before the content is readable.
122
- Animate the first 5–8 items and let the rest appear.
181
+ for free with `delayBetween: 0.05` — that prop is in **seconds**, the unit the
182
+ framework writes into its `animation-delay`, so `40` would not be a fast stagger
183
+ but forty seconds. A hand-built list uses `stagger(index)` —
184
+ `stagger(3, { step: 60, max: 8 })` stops growing after 8 items, which is the
185
+ difference between a screen arriving and a screen loading: a list of 20 rows at
186
+ 60ms each is a 1.2s wait before the content is readable.
123
187
  - One direction per screen. Elements arriving from the left *and* the bottom *and*
124
- scaled is three ideas. A short `translateY` (8–24) plus `opacity` reads as one.
188
+ scaled is three ideas. `slideIn` is one direction plus opacity, on purpose.
125
189
 
126
190
  ## M6 Loops, ambient motion and `ParallaxBox`
127
191
 
@@ -134,28 +198,59 @@ Motion on first paint is the one place a longer budget is fair, with two limits:
134
198
  small (8–24). A background that moves 120px is not depth, it is a screensaver.
135
199
  - Ambient motion gets its own budget: one per screen, at most.
136
200
 
137
- ## M7 What must never move
201
+ ## M7 Reduced motion is decided once, in the engine
202
+
203
+ The user can ask for no movement, and FletBox listens:
204
+
205
+ - `prefersReducedMotion()` — `true` when they have.
206
+ - `setMotionMode('auto' | 'reduce' | 'full')` — the app's own switch, wired to a
207
+ settings control; `'auto'` (the default) follows the operating system.
208
+ - `getMotionMode()`, and `onMotionModeChange(reduced => …)` for a widget holding a
209
+ live loop — it returns the unsubscribe, so release it in `onUnmount`.
210
+
211
+ What it does to each surface, and why none of it needs a per-screen `if`:
212
+
213
+ - `animate()` and the named transitions run one frame that **writes the end value**,
214
+ so the state still lands and an `await` still resolves. Only the movement goes.
215
+ - `AnimatedBox` plays its keyframes once at `0.01ms` with no delay, and `fillMode:
216
+ 'forwards'` leaves the element on its `to` values — content never stays hidden
217
+ waiting for an animation that was not allowed to run. A `loop` stops looping.
218
+ The reader can flip the OS setting *while the app is open*: the engine injects one
219
+ `@media (prefers-reduced-motion: reduce)` rule that overrides every element it
220
+ animates, so a loop already running stops without a rebuild and without a listener
221
+ to leak. The factory marks any element it gives an `infinite` animation — a
222
+ `Skeleton`, a striped `ProgressBar`, a `ListView` spinner — so a loop you write
223
+ yourself through the `animation` prop is covered too. Nothing in the app has to do
224
+ anything about it.
225
+ - `ParallaxBox` parks its layer at the origin. `MatrixRain` paints one static field
226
+ and releases its `requestAnimationFrame`. `applyPulse` does nothing.
138
227
 
139
- - Text the user is reading at length; body copy that animates is copy they re-read.
140
- - Anything under a pointer or a finger while they aim at it — a control that shifts
141
- while being targeted is a control you cannot hit (this is why press feedback scales
142
- the element instead of moving it).
143
- - A field while typing, a list while scrolling, a number while it is being compared.
144
- - Two things at once in the same region: the last animation started is the one that
145
- should win, and `stopAnimation(widget, property)` exists for exactly that.
228
+ ```js
229
+ const chooseMotion = (on) => {
230
+ if (on) return setMotionMode('reduce');
231
+ return setMotionMode('auto');
232
+ };
233
+
234
+ // A settings screen hands the decision to the reader, and the engine applies it.
235
+ Switch({ label: 'Reduce motion', value: getMotionMode() === 'reduce', onToggle: chooseMotion });
236
+ ```
146
237
 
147
- FletBox has no reduced-motion switch. That is not an excuse: build the screen so it
148
- reads with zero animation, then add motion as a decision you can point at. If the
149
- motion is only decoration, the honest answer to "respect reduced motion" is to not
150
- add it.
238
+ Build the screen so it reads with zero animation anyway. Motion is a decision you
239
+ can point at, and if it is only decoration the honest answer is to not add it —
240
+ reduced motion is not a second design to maintain, it is the same design with the
241
+ movable parts still.
151
242
 
152
243
  ## M8 Clean up
153
244
 
154
- An animation is a resource. `animate()` returns a cancel function — hold it and call
155
- it when the widget unmounts, or call `stopAnimation(widget, property)`. A loop that
156
- outlives its widget keeps a `requestAnimationFrame` alive and animates a detached
157
- node forever. A widget that registers timers, listeners or observers releases them in
158
- its `onUnmount` disposer.
245
+ An animation is a resource. `animate()` and the presets return a cancel function —
246
+ hold it and call it when the widget unmounts, or call `stopAnimation(widget, property)`.
247
+ A loop that outlives its widget keeps a `requestAnimationFrame` alive and animates a
248
+ detached node forever. A widget that registers timers, listeners or observers releases
249
+ them in its `onUnmount` disposer.
250
+
251
+ Starting a second animation on the same widget and property replaces the first — that
252
+ is the engine, not a bug to work around. Two gestures on one element (`translateY` plus
253
+ `scale`) are two loops and are merged into one `transform`, so they do not fight.
159
254
 
160
255
  ## M9 The check
161
256
 
@@ -166,3 +261,6 @@ its `onUnmount` disposer.
166
261
  animation, not to tune it.
167
262
  4. Check the frame, not the curve: press a control while something else moves. If
168
263
  the press feels delayed, the motion is the cause.
264
+ 5. Turn the mode to `'reduce'` and walk the screen. Everything must still arrive,
265
+ still be readable, still be operable — with nothing moving. That is the test
266
+ reduced motion asks for, and it is five minutes.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fletbox-rules
3
- description: The working contract for a FletBox app - FletBox syntax only, never a widget or prop from React/Vue/Svelte/Flutter, look an API up before using it, the rem unit contract, prop aliases, key-based state, resource cleanup and the two gates that prove the work. Read first, before any other skill, and before writing or editing a single file in src/.
3
+ description: The working contract for a FletBox app - FletBox syntax only, never a widget or prop from React/Vue/Svelte/Flutter, look an API up before using it, the rem unit contract, prop aliases, key-based state, resource cleanup, one home for every shared helper, and the two gates that prove the work. Read first, before any other skill, and before writing or editing a single file in src/.
4
4
  ---
5
5
 
6
6
  # Rules — how to work inside this framework
@@ -152,3 +152,142 @@ learns something and does not write it is a session that has to be repeated.
152
152
  They cover the same screen from three angles and do not repeat each other. When
153
153
  they disagree, this file wins about the API, `fletbox-design` wins about the
154
154
  screen, and the checker wins about a value.
155
+
156
+ ## R12 The tools are the short form — use one before you hand-write it
157
+
158
+ `flet-box` exports 123 helpers alongside the widgets. They exist because the code
159
+ they replace is longer, and because the longer version is the one every model
160
+ writes from memory. The rule is not "wrap everything in a helper": it is that a
161
+ hand-written replacement has to be **shorter or clearer** than the tool, and for
162
+ the list below it is not.
163
+
164
+ | What you were about to write | Use instead |
165
+ | --- | --- |
166
+ | `Array.from({ length: n }, (_, i) => …)`, and a second branch for an object | `mapList(source, fn)` — takes an array, a number or an object |
167
+ | a mock list built field by field | `createList(row, 12)` with `row = { name: () => random.fullName(), price: () => random.number(5, 40) }` |
168
+ | `list.map` + `sort` + `[...new Set(…)]` chains | `sort(rows, 'price', 'desc')`, `unique(xs)`, `shuffle`, `chunk`, `reverse` |
169
+ | a `for (let i = 0; …)` that only builds children | `range(0, 10)`, `repeat(3, Divider({}))` |
170
+ | `'0px 4px 12px 0px rgba(…)'` typed out | `shadow(0, 4, 12, 0, rgba(0, 0, 0, 0.12))` |
171
+ | `` `rgba(${r},${g},${b},${a})` `` and `` `linear-gradient(…)` `` | `rgba(37, 99, 235, 0.2)`, `gradient('linear', [a, b], 135)` |
172
+ | a `transform` string you have to keep in sync by hand | `transform({ translate: [10, 20], rotate: 45, scale: 1.5 })` |
173
+ | `'all 0.3s ease 0s'`, `'blur(5px) brightness(0.8)'` | `transition({ property: 'opacity', duration: 0.3 })`, `filter({ blur: 5, brightness: 0.8 })` |
174
+ | a hex you re-lighten by eye, or hand-picked tints | `color(colors.primary).darken(12).hex()` |
175
+ | `Math.floor(Math.random() * …)` and `Math.random().toString(36)` | `random.number(a, b)`, `random.choice(list)`, `shortId()`, `uuid()`, `timestampId()` |
176
+ | `new Date(d).toLocaleDateString()` chains, a hand-written `"… ago"` | `formatDate(d, 'YYYY-MM-DD')`, `formatDateLong(d)`, `formatTimeAmPm(d)`, `relativeTime(iso)`, `timeAgoLocale(d, 'es')` |
177
+ | `d.getTime() + 7 * 86400000` and a `(b - a) / 86400000` you have to re-derive | `addDays(d, 7)`, `subtractHours(d, 3)`, `dateDiff(a, b, 'days')`, and `days(7)` where you need the milliseconds |
178
+ | `new Date().getDay() === 0`, a weekday name built from an array | `dayOfWeek(d)`, `monthName(d)`, `isWeekend(d)`, `isToday(d)` |
179
+ | `new Promise(r => setTimeout(r, ms))`, a retry loop written twice, a poll loop written twice | `delay(ms)`, `withMinDelay(p, 600)` (a spinner that never flashes), `retry(fn, 3, 500)`, `waitUntil(() => ready, seconds(30))` |
180
+ | a timer loop plus a handle you have to remember to clear | `everySecond(fn)`, `every(minutes(5), fn)` — and per R6, release the id it hands back on unmount |
181
+ | a `const cache = {}` keyed by `JSON.stringify(args)` | `memo(fn)`, `memoWithKey(fn, keyFn)` |
182
+ | `obj[k] !== undefined ? obj[k] : d`, `Object.entries(o).map(…)` | `dict(obj).get(k, d)`, `.items()`, `.has(k)`, `fromJSON(text)` |
183
+ | `console.log('[' + label + '] ' + msg)` | `print('synced', 'success', { label: 'cart' })` |
184
+ | `let el = null` plus `ref: (v) => (el = v)` | `const display = ref()`, then `Text({ ref: display })` and `display.update({ text: '…' })` |
185
+ | `window.innerWidth >= 768`, and a resize listener that fires per pixel | `atLeast('md')`, `pick({ xs: 16, md: 24 })`, `onBreakpointChange(cb)` |
186
+ | `navigator.clipboard.writeText(x)` (which throws without a gesture fallback) | `clipboard.copy(x)` |
187
+ | injecting a `<link>` for a webfont | `loadFont('Inter', { weights: [400, 600] })`, then `fontsReady()` before a canvas paint |
188
+ | `String(arr).split(sep)`, `s.trim().startsWith(x)` written as three calls | `split(s, sep)`, `join(arr, sep)`, `trim(s)`, `startsWith(s, x)`, `endsWith(s, x)`, `includes(s, x)` |
189
+ | a `theme` object rebuilt per screen from hex literals | `colors.*`, `setTheme`, `subscribeTheme` |
190
+
191
+ Four traps, because the rule is only honest with them:
192
+
193
+ 1. **A routed prop already *is* the short form.** `Container({ padding: 16 })` is
194
+ shorter and more correct than `padding: padding(16)` — the number reaches the
195
+ rem contract; the helper returns a **string** of pixels. Reach for
196
+ `padding(...)`/`margin(...)` only when you are writing a CSS value the props
197
+ cannot express (a `styles` object, a computed shorthand). Asymmetric sides have
198
+ props: `px: 20, py: 12`, `pt`, `pr`, `pb`, `pl`.
199
+ 2. **`filter` is the CSS filter, not a list filter.** `mapList`, `sort`, `unique`
200
+ and `createList` do list work; `filter({ blur: 5 })` builds a string.
201
+ 3. **`color()` is a chain, not a value.** `color(x).lighten(20)` is a `Color`; it
202
+ only becomes CSS at `.hex()`, `.rgb()` or `.rgba()`.
203
+ 4. **`months()` and `years()` are approximations, and a day is not 24 hours.**
204
+ `months(1)` is 30 days and `years(1)` is 365 — fine for a cache window, wrong
205
+ for a birthday. Whole-day steps (`addDays`, `subtractDays`) count on the
206
+ calendar, so they hold across a DST night; `addHours` and below count in real
207
+ elapsed time, which is what a countdown needs and what a "same time tomorrow"
208
+ must not use.
209
+
210
+ R2 still outranks this one: when the exact signature is not in memory,
211
+ `npm run check -- --list` and `flet-box unpack` say what is real. A confident wrong
212
+ call is worse than the plain JavaScript it replaced — and `npm run check` will
213
+ catch it, which is the point of running it.
214
+
215
+ ## R13 The keyboard is part of the screen, not an extra
216
+
217
+ The engine already carries the whole of it, and an app only has to not break it:
218
+
219
+ - **A handler given as a prop is a keyboard control.** `onPress` (or `onClick`)
220
+ on any widget makes the engine add `role="button"`, put it on the Tab path and
221
+ wire Enter and Space. A handler attached to the element *after* the widget was
222
+ built is a mouse-only control — the pass already ran. Express the control with
223
+ a prop.
224
+ - **Never write `outline: none`**, and never add a global `*:focus` rule that
225
+ removes it. The ring is one rule the framework injects, coloured by the
226
+ `colors.focusRing` token; an inline `outline` outranks it and leaves a screen
227
+ where nobody can see where they are. Change the token with
228
+ `registerPalette`, or override with a class — the rule is zero-specificity for
229
+ exactly that.
230
+ - **A control needs a name.** An icon with no `aria-label` and no visible text is
231
+ read as "button". An unnamed `Icon` is `aria-hidden` decoration by design, so
232
+ pressable icons take a label.
233
+ - **Fields keep two jobs apart:** the border is validation, the ring is focus.
234
+ `label` names the field, the widget's own `validate` method announces the
235
+ verdict, and the message is already a live region with `aria-invalid` on the
236
+ input.
237
+ - **Overlays trap focus and give it back.** `Modal`, `BottomSheet` and `Drawer`
238
+ do it; do not build a full-screen scrim with its own Escape listener beside
239
+ them.
240
+
241
+ Prove it the way the framework does: press Tab through the screen, then Enter and
242
+ Space on each control, then open an overlay and Tab past its last control.
243
+ `npm run ui` covers names, labels, tap targets and contrast, and no checker has a
244
+ keyboard — the last step is yours.
245
+
246
+ ## R14 The second copy is a module
247
+
248
+ The first time you write a helper, it is yours. The second time the same code
249
+ appears — the same formatter, the same validation, the same escaped string, the
250
+ same card — it stops being a line you happen to share and becomes one module both
251
+ files import. A screen-specific variant is a prop on the shared component, never a
252
+ second component that starts as a copy.
253
+
254
+ **Why:** two identical copies pass every test on the day they are written. They
255
+ diverge on the day one of them is fixed — the fix lands in the copy that was in
256
+ front of you, the other keeps the bug, and nothing tells you which is which.
257
+ This is not hypothetical in this framework: the HTML escaper existed three times,
258
+ `margin()` and `padding()` were byte-identical files apart from one word, and a
259
+ keyframe injector lived in two modules, so the same animation name could be
260
+ appended twice and neither dedupe set knew about the other.
261
+
262
+ - Put a pure helper in **`src/helpers/`** — one file per behaviour, and it
263
+ **imports nothing at all**. That is the property that makes it reusable: a
264
+ widget can import it without dragging a theme, the parser or another widget
265
+ into its graph. If the helper needs another module it is a service, not a
266
+ helper, and it belongs in `src/utils/`. Anything that returns widgets goes in
267
+ `src/components/`. Name it for the behaviour, not for its first caller.
268
+ - Import it from its own path. Re-exporting through a barrel is fine; copying the
269
+ body "to avoid a dependency" is not — if the import drags in too much, move the
270
+ helper down into a leaf module instead, which is exactly why the framework's
271
+ escaper imports nothing at all.
272
+ - A helper that writes CSS state (`@keyframes`, an injected `<style>`) is owned by
273
+ one module for the whole document. A second injector is a second document-level
274
+ cache, and the same name is then defined twice.
275
+
276
+ ## R15 Essential code only
277
+
278
+ Write the code the screen needs, in the smallest shape that says it. A feature
279
+ nobody asked for is not a head start — it is code that has to be read, tested and
280
+ maintained before it has a single caller, and it is the code most likely to be
281
+ wrong because nothing uses it yet.
282
+
283
+ - No option "for later", no prop nobody passes, no abstraction with one caller.
284
+ The second caller is when the helper appears (R14), not before.
285
+ - A wrapper that only forwards its arguments is code to delete, not to keep: call
286
+ the function it forwards to.
287
+ - Prefer removing a branch to adding a flag that selects between two branches.
288
+ - Before writing a helper, look for the one that already does it: `flet-box`
289
+ ships 123 (R12), and the framework's own three are `escapeHtml`, `boxEdges`
290
+ and `injectKeyframes`.
291
+
292
+
293
+