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
@@ -0,0 +1,282 @@
1
+ # Accessibility: the engine's half and your screen's half
2
+
3
+ A FletBox control is a `div` that the framework teaches to behave like a
4
+ `button`. That is what makes the syntax pleasant — `Container({ onPress })`
5
+ instead of a stylesheet and three event listeners — and it is also where
6
+ accessibility is won or lost. Three things have to be true for someone with no
7
+ pointer: the control must be **reachable** (Tab), **operable** (Enter and
8
+ Space), **announced** (a role and a name), and the person must be able to see
9
+ **where they are** (a focus ring). An overlay owes a fourth: hold the keyboard
10
+ while it is open, and give it back when it closes.
11
+
12
+ The engine already does all four. This chapter is about the half it cannot do
13
+ for you, and about the two ways an app destroys the half it was given.
14
+
15
+ ## 1. The focus ring is a stylesheet, not a paint
16
+
17
+ A focus ring is not a decoration — it is a *state*, and a state can only live in
18
+ a stylesheet. `src/utils/focusRing.js` appends one `<style id="fletbox-focus-ring">`
19
+ to `<head>` the first time any widget is created, and never appends a second:
20
+
21
+ ```css
22
+ :where(a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
23
+ outline: 2px solid var(--color-focusRing, #1d4ed8);
24
+ outline-offset: 2px;
25
+ }
26
+ ```
27
+
28
+ Three deliberate choices, each worth knowing when you want something different:
29
+
30
+ - **`:focus-visible`, not `:focus`.** A click with a mouse does not ring; the
31
+ keyboard does. That is the browser's own distinction, not the framework's.
32
+ - **`:where(…)` keeps specificity at zero.** One class of yours overrides it,
33
+ and no `!important` is needed anywhere.
34
+ - **The color is a token.** `--color-focusRing` is written by `applyPalette` for
35
+ every theme, so the ring moves with the theme instead of being a literal that
36
+ only suits one background. The light palette's `#1d4ed8` is 6.4:1 on the page,
37
+ the dark one's `#93c5fd` is 11:1, and the literal after the comma in the rule
38
+ is the boot value a page shows before the first `setTheme`.
39
+
40
+ The widths are pixels on purpose, next to `SHADOW_LEVELS` and the hairline
41
+ `borderWidth`: a 1px ring is invisible and a 6px one is a blob, whatever the
42
+ reader's font size is.
43
+
44
+ ### The one thing an app must never do
45
+
46
+ ```javascript
47
+ // Never. An inline declaration outranks every stylesheet rule, including the
48
+ // engine's, so this is what "the focus ring does not work" looks like in code.
49
+ element.style.outline = "none";
50
+
51
+ // Never, for the same reason, anywhere in a global stylesheet:
52
+ // *:focus { outline: none; }
53
+ ```
54
+
55
+ If you do not like the ring's colour, change the token — or override the rule
56
+ with one class of your own, which `:where()` makes possible:
57
+
58
+ ```javascript
59
+ import { registerPalette } from "flet-box";
60
+
61
+ // The token is part of the palette, so both themes move it and nothing is
62
+ // overridden behind the framework's back. A registered palette repaints only
63
+ // when it is the one on screen.
64
+ registerPalette("light", { focusRing: "#0f766e" });
65
+ registerPalette("dark", { focusRing: "#5eead4" });
66
+ ```
67
+
68
+ ```css
69
+ /* or, for one component only: a class beats :where(...) whatever the order */
70
+ .chart:focus-visible { outline-color: var(--color-primary); }
71
+ ```
72
+
73
+ ### A field rings as a whole control
74
+
75
+ `Input` is a wrapper with a border, an icon and sometimes a reveal button, and
76
+ the native `<input>` inside it is only part of the control. A ring around the
77
+ text box alone draws a box *inside* the thing it is supposed to mark. So the
78
+ rule has a second pair:
79
+
80
+ ```css
81
+ :where([data-fb-field]):has(> input:focus-visible) {
82
+ outline: 2px solid var(--color-focusRing, #1d4ed8);
83
+ outline-offset: 2px;
84
+ }
85
+ :where([data-fb-field]) > input:focus-visible {
86
+ outline: none;
87
+ }
88
+ ```
89
+
90
+ The `outline: none` there is a *rule*, at zero specificity, which any app can
91
+ still override — that is the whole difference between silencing the inner box
92
+ and killing the ring. The marker (`data-fb-field`) is written by `Input`; if you
93
+ build your own composite field (a text box with a button inside), put the same
94
+ marker on the wrapper and the ring lands where the control is.
95
+
96
+ And keep the two jobs apart: **the border is validation, the ring is focus.**
97
+ A field that paints focus by recolouring its rim, or by adding a `box-shadow`,
98
+ puts two indicators where one belongs and loses the validation state the moment
99
+ someone types.
100
+
101
+ ## 2. A press is a press, however it arrives
102
+
103
+ Give any widget a click handler as a prop and the engine runs a keyboard pass
104
+ over it — `src/widget-factory/effects.js`:
105
+
106
+ - it sets `role="button"` **if you have not set a role**;
107
+ - it sets `tabIndex = 0` **if you have not set one** (an explicit `-1` is your
108
+ decision to keep the element off the Tab path, and it is respected);
109
+ - Enter and Space fire the same handler a pointer fires, with Space's default
110
+ (scrolling the page) cancelled and a held key counted as one press;
111
+ - a nested control keeps its own keys — Enter on a `Button` inside a pressable
112
+ `Card` presses the button and the card, not twice the card;
113
+ - native `button`/`a[href]` are left alone, and disabled controls are not made
114
+ Tab stops.
115
+
116
+ ```javascript
117
+ Container({ onPress: openDetails, children: [Text({ text: "Details" })] });
118
+ // role="button", tabIndex=0, Enter and Space → openDetails
119
+ ```
120
+
121
+ The one way an app defeats this is to attach the handler to the element after
122
+ the widget was built. The pass runs during construction; a handler assigned
123
+ afterwards leaves a control with a click, no role, no Tab stop and no keyboard:
124
+
125
+ ```javascript
126
+ const card = Container({ children: [Text({ text: "Details" })] });
127
+ card.onclick = openDetails; // mouse-only: the engine already ran
128
+ ```
129
+
130
+ Express the control with a prop instead — `onPress` (or `onClick`) on any
131
+ widget — and the engine sees it.
132
+
133
+ ### An icon is decoration until it is a control
134
+
135
+ `Icon` has two lives, and it picks one by itself:
136
+
137
+ - `Icon({ name: "search" })` — no name for a reader, so it is marked
138
+ `aria-hidden="true"` and kept off the Tab path. Correct inside a `Button`
139
+ that already has text.
140
+ - `Icon({ name: "search", onPress: focusSearch, "aria-label": "Search" })` — a
141
+ named, pressable icon keeps `role="button"` and is read as "Search, button".
142
+
143
+ An icon-only control with no `aria-label` is read as "button", which is the
144
+ worst of both worlds: reachable, operable, and anonymous. The places where the
145
+ framework wires a glyph up for you already name it — the `AppBar` back arrow,
146
+ `Input`'s `onIconPress` and reveal button, and the menu icon a `Scaffold`
147
+ connects to its drawer.
148
+
149
+ ## 3. Names, roles and states the widgets already declare
150
+
151
+ You can pass `role` and `tabIndex` as ordinary props, and any `aria-*`
152
+ attribute reaches the element verbatim (`"aria-label": "…"`). Everything below
153
+ is already in place; the table exists so you know what not to duplicate.
154
+
155
+ | Widget | Role | States it keeps in sync |
156
+ | --- | --- | --- |
157
+ | `Button`, `Container` with `onPress` | `button` | — (engine pass) |
158
+ | `Switch` | `switch` | `aria-checked` |
159
+ | `Checkbox` | `checkbox` | `aria-checked` |
160
+ | `Radio` / `Rating` | `radio` / `radiogroup` | `aria-checked` |
161
+ | `Slider` | `slider` | `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, `aria-orientation` |
162
+ | `ProgressBar`, `CircularBar` | `progressbar` | `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, `aria-busy` while indeterminate |
163
+ | `Dropdown` | `combobox` | `aria-expanded`, `aria-controls`, `aria-activedescendant`, `aria-invalid`, `aria-labelledby` |
164
+ | `Dropdown` list | `listbox` / `option` | `aria-selected` |
165
+ | `Tabs` | `tablist` / `tab` / `tabpanel` | `aria-selected`, roving `tabIndex`, `aria-controls` |
166
+ | `Accordion` | `button` / `region` | `aria-expanded`, `aria-controls` |
167
+ | `Pagination` | `navigation` | `aria-current="page"` |
168
+ | `Stepper` | — | `aria-current="step"` |
169
+ | `BottomNavigation` | — | `aria-current="page"` |
170
+ | `Modal`, `BottomSheet`, `Drawer` | `dialog` | `aria-modal="true"` |
171
+ | `SnackBar` | live region | `aria-live="polite"`, `"assertive"` for error and warning |
172
+ | `Tooltip` | `tooltip` | `aria-describedby` on the trigger while shown |
173
+ | `Maps` | `application` | — |
174
+ | `CircularChart` | `img` | give it an `aria-label`; it is a picture of data |
175
+
176
+ Composite widgets answer the keyboard the way their ARIA pattern says: `Tabs`,
177
+ `Dropdown`, `Rating` and `Slider` move with the arrow keys, take `Home` and
178
+ `End`, and keep a single Tab stop per group. The Dropdown's options are
179
+ deliberately *not* Tab stops — the selector holds focus, the arrows move the
180
+ highlight and `aria-activedescendant` names the row.
181
+
182
+ What is left for you is **naming**: a visible `label` where the widget has one
183
+ (`Input`, `Dropdown`), `text` for a button (`"Save"` is a name; `"OK"` is not —
184
+ see the clean-UI rule about generic action labels), `alt` on images, and
185
+ `aria-label` for anything that has no visible words of its own.
186
+
187
+ ## 4. An invalid field says so in two places
188
+
189
+ A red rim is the visual half. The other half is the state and the reason, and
190
+ `Input` keeps them together: while a message is showing, the field carries
191
+ `aria-invalid="true"` and an `aria-describedby` pointing at the message, which
192
+ is a `polite` live region; when the value becomes valid, all three leave
193
+ together. A described-by pointing at a `display: none` node would be a control
194
+ claiming a description it does not have.
195
+
196
+ ```javascript
197
+ const email = Input({ label: "Email", validation: "email", required: true });
198
+
199
+ // submit
200
+ const { valid, message } = email.validate(); // paints, links and announces
201
+ ```
202
+
203
+ Use `validate()` (or `setValue()`, which validates) rather than colouring a rim
204
+ by hand: `showValidationMessage` decides whether the reason is on screen, and a
205
+ ring with no message tells a reader "wrong" and nothing else.
206
+
207
+ ## 5. Overlays hold the keyboard, then give it back
208
+
209
+ `Modal`, `BottomSheet` and `Drawer` share one scope (`src/utils/focusScope.js`),
210
+ and one implementation on purpose:
211
+
212
+ - focus moves into the panel when it opens — the first control, or the panel
213
+ itself (`tabIndex="-1"`) when it has none, so an alert with two lines of text
214
+ still takes the keyboard;
215
+ - Tab and Shift+Tab cycle **inside** the panel instead of walking out behind
216
+ the scrim into a page the person can no longer see;
217
+ - Escape closes it (each overlay already listens);
218
+ - closing hands focus back to whatever was focused before, and skips that if the
219
+ element has left the page (a route change replaced it).
220
+
221
+ The trap and the ring read the same list of reachable elements — `a[href]`,
222
+ `button`, `input`, `select`, `textarea`, `summary` and anything on the Tab path
223
+ — so a stop you can Tab to is always a stop that shows a ring.
224
+
225
+ For an app, the contract is simply: open and close these through their props and
226
+ refs, and do not build a full-screen scrim by hand with its own Escape listener.
227
+
228
+ ## 6. Verify it with the keyboard
229
+
230
+ Five minutes, no screen reader needed:
231
+
232
+ 1. Press Tab from the top of the screen. Every control gets a ring; nothing is
233
+ skipped and nothing rings twice.
234
+ 2. Press Enter and Space on each control. The same thing happens as with a click.
235
+ 3. Focus a field and type. The rim stays put until there is a verdict.
236
+ 4. Submit an invalid form. The message is under the field, the field says
237
+ `aria-invalid`, and the message disappears with the fix.
238
+ 5. Open a modal. Tab to the last control and press Tab once more: focus is back
239
+ at the first, not behind the scrim.
240
+ 6. Press Escape. Focus is back on whatever opened it.
241
+ 7. Repeat 1–6 with a screen reader (VoiceOver: `Cmd+F5`; TalkBack on Android) and
242
+ listen for names: "Search, button", "Email, invalid entry, Invalid email
243
+ address".
244
+
245
+ Then let the gates do what they do:
246
+
247
+ ```bash
248
+ npm run check # the API is real: widgets, props, imports
249
+ npm run ui # names, labels, tap targets, contrast:
250
+ # UNLABELED_FIELD, UNLABELED_TOGGLE, ICON_ONLY_CONTROL,
251
+ # IMAGE_NO_ALT, SMALL_TAP_TARGET, LOW_CONTRAST*, UNREADABLE
252
+ npm run dev # then Tab through it; the checker has no keyboard
253
+ ```
254
+
255
+ In the framework repository itself, `node scripts/check-a11y.mjs` runs inside
256
+ `npm test` and holds the invariants of this chapter: nothing writes an inline
257
+ `outline`; the ring rule is injected and carries the token; both palettes define
258
+ `focusRing`; the pressable path sets role, Tab stop and Enter/Space and releases
259
+ its listener; every overlay that reads Escape also traps focus; the trap and the
260
+ ring agree on what is reachable; and no handler is attached after the factory
261
+ returned. Every one of those rules has been proven to fail on the bug it
262
+ forbids. The runtime half lives in `/tests/a11y.html`.
263
+
264
+ ## See also
265
+
266
+ - [Clean UI](clean-ui.md) — the design rules this chapter's checklist comes from.
267
+ - [Themes](../tools/theme.md) — `registerPalette`, the call that moves the ring.
268
+ - [Input](../widget/Input.md) — the props used above.
269
+ - [Modal](../widget/Modal.md) · [BottomSheet](../widget/BottomSheet.md) ·
270
+ [Drawer](../widget/Drawer.md) — the three overlays and what they return.
271
+ - [Widget structure](widget-structure.md) — where a widget's defaults and
272
+ disposers live.
273
+
274
+ ---
275
+
276
+ ## Continue reading
277
+
278
+ - **Previous:** [Clean UI](clean-ui.md) — Chapter 12
279
+ - **Next:** [Minify and protect a FletBox build](minification-and-protection.md) — Appendix A
280
+ - **Index:** [Guides index](README.md) · [The FletBox Book](../README.md)
281
+
282
+ You are reading **Chapter 13 · Accessibility**.
@@ -35,13 +35,13 @@ pointed at from one `.cursor/rules/<name>.mdc` each:
35
35
  | Skill | What it owns |
36
36
  | --- | --- |
37
37
  | `fletbox-rules` | The working contract: FletBox syntax only, never invent an API and the four ways to look one up, the rem rule, aliases, cleanup, the two gates, the memory duty. |
38
- | `fletbox-design` | The 25 numbered rules below — tokens, the scale, hierarchy, the four states, measured contrast, composition, the framework traps, the dated tells, density. |
38
+ | `fletbox-design` | The numbered rules below — tokens, the scale, hierarchy, the four states, measured contrast, composition, the framework traps, the dated tells, density (00–24), then the visual atlas of looks that hold up, each reduced to calls this framework makes (25–37). |
39
39
  | `fletbox-motion` | Whether a thing may move at all, and through which of `AnimatedBox`, `AnimatedText`, `ParallaxBox` or `animate`. |
40
40
  | `fletbox-memory` | The `.fletbox/memory/` journal that hands progress from one AI to the next. |
41
41
 
42
42
  | Home | What it holds |
43
43
  | --- | --- |
44
- | `skills/fletbox-design/SKILL.md` (framework) → `skills/…`, `.claude/skills/…`, `.qoder/skills/…` (app) | The rules as the agent reads them, in 25 numbered sections. The app's copies are generated; edit the framework file and regenerate, never the mirror. |
44
+ | `skills/fletbox-design/SKILL.md` (framework) → `skills/…`, `.claude/skills/…`, `.qoder/skills/…` (app) | The rules as the agent reads them, in 38 numbered sections across two parts. The app's copies are generated; edit the framework file and regenerate, never the mirror. |
45
45
  | `AGENTS.md` §4 of the app | The short version: one brand color per screen, no hex literals, spacing and radius only from the scale. |
46
46
  | `src/tools/scale.js` in the framework | The tables themselves — `SPACING_SCALE`, `RADIUS_SCALE`, `TYPE_SCALE`, `TAP_MINIMUM`, `PILL`, `LABEL_TEXT`. |
47
47
  | `bin/commands/checkUi.js` | The gate. It re-exports those same tables instead of keeping its own copy, and `scripts/check-skills.mjs` fails when a skill and the gate stop naming the same codes. |
@@ -55,7 +55,7 @@ components it uses do not follow. The defaults and the checker read one table.
55
55
  | Dimension | Allowed values |
56
56
  | --- | --- |
57
57
  | Spacing | `0 · 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96` |
58
- | Radius | `0 · 4 · 8 · 12 · 16 · 999` — `999` is a pill, `0` a flush edge; both are decisions |
58
+ | Radius | `0 · 4 · 8 · 12 · 16 · 20 · 24 · 999` — `999` is a pill, `0` a flush edge; both are decisions. 20 and 24 are on the scale because the nesting rule (`outer = inner + padding`) lands there and `Drawer`, `DrawerItem` and the `Tabs` indicator default there |
59
59
  | Type | `12 · 14 · 16 · 18 · 24 · 32 · 40 · 48` |
60
60
  | Tap minimum | `40` — Fitts: the smallest pressable box a finger reliably hits |
61
61
  | Label text | `14` — the reading size a control's own label may not fall under |
@@ -93,9 +93,17 @@ The framework keeps this honest with its own gate, `scripts/check-widget-scale.m
93
93
  (part of `npm test`): a length written straight to `el.style.*` that is neither
94
94
  `toREM(...)` nor measured geometry fails the build.
95
95
 
96
- ## The 25 rules
96
+ ## The rules
97
97
 
98
- When two of them conflict, take them in this order: **consistency · clarity ·
98
+ The skill numbers its rules 00–37 and splits them in two parts. **00–24 are the
99
+ enforceable contract**: each one is cited by at least one `check-ui` code, so
100
+ `npm run ui` can fail a screen for breaking it, and `scripts/check-skills.mjs`
101
+ fails if a code loses its rule. **25–37 are the visual atlas** — the archetypes,
102
+ the accent budget, the surfaces, the dated tells — judgement about what to
103
+ choose, which no static pass can prove. The atlas lives only in the skill; this
104
+ page indexes it by number and does not copy it, so the two cannot drift.
105
+
106
+ When two rules conflict, take them in this order: **consistency · clarity ·
99
107
  accessibility · usability · scalability · maintainability · conversion**.
100
108
 
101
109
  | # | Rule | What it forbids |
@@ -126,6 +134,29 @@ accessibility · usability · scalability · maintainability · conversion**.
126
134
  | 23 | What no checker can see | Taste, hierarchy in the wild, whether the screen solves the problem. |
127
135
  | 24 | The loop | Fixing the checker instead of the code, or leaving a rule you had to break undocumented. |
128
136
 
137
+ ### Part II — the visual atlas, 25–37
138
+
139
+ Read these after the table, never instead of it: every recipe still has to pass
140
+ `npm run check` and `npm run ui`. They are indexed here so a rule number in a
141
+ review comment points somewhere, and they are written out in full in
142
+ `skills/fletbox-design/SKILL.md`.
143
+
144
+ | # | Rule | What it decides |
145
+ | --- | --- | --- |
146
+ | 25 | Three laws | One accent, one surface family, one hero — across mobile, web, both themes. |
147
+ | 26 | Pick an archetype | Acid-on-black, tinted paper, photo-bleed, bento, editorial, single-hue field — name one before the first widget. |
148
+ | 27 | Surfaces are never the extremes | No pure black under pure white: the steps between them are where hierarchy lives. |
149
+ | 28 | The accent budget | How much saturated hue a screen may spend, and on what. |
150
+ | 29 | The number is the hero | The one element at 3–5× the rest, and why it is usually a number. |
151
+ | 30 | Radius, nesting and the pill | `outer = inner + padding`, and when a pill is a decision. |
152
+ | 31 | Photo, scrim, sheet | The sheet overlaps the photo, the scrim earns the text over it. |
153
+ | 32 | The shape of each control | Which control carries which affordance, from the widgets that exist. |
154
+ | 33 | Data widgets | The dial, the bento tile, the axis-less chart. |
155
+ | 34 | Genre recipes | What a finance, commerce, travel or wellness screen actually uses. |
156
+ | 35 | Deriving a palette | Building a hue family from one accent without leaving contrast behind. |
157
+ | 36 | The dated visual tells | What reads as 2021, and what to choose instead. |
158
+ | 37 | Choosing, in five questions | How to land on an archetype and a palette for a brief. |
159
+
129
160
  ## What `flet-box check-ui` proves
130
161
 
131
162
  The gate reads your `src/` and reports what a static pass can prove. Prop names
@@ -157,7 +188,7 @@ judging yours. Each finding carries a code, and the code maps to a rule above.
157
188
  | `HAND_MIXED_COLOR` | 03 | `colors.primary + "22"` — a one-off shade no other screen can reuse. |
158
189
  | `DEAD_ELEVATION` | 10 | A numeric `elevation` on a widget that never reads it: CSS drops the bare number and the box stays flat in silence. |
159
190
  | `IMAGE_NO_ALT` | 11 | An `Image` with no `alt` — `alt: ""` is a decision, a missing prop is an oversight. |
160
- | `ICON_ONLY_CONTROL` | 11 | A pressable glyph with no name; wrap it in `Tooltip({ text, child })`. |
191
+ | `ICON_ONLY_CONTROL` | 11 | A pressable glyph with no accessible name; give it `aria-label` (a `Tooltip` is a description, not a name). |
161
192
  | `CENTERED_BODY_TEXT` | 08 | A paragraph centered; a paragraph needs a left edge to find the next line. |
162
193
  | `GENERIC_ACTION_LABEL` | 14 | "OK", "Submit", "Click here" — a label that says nothing about what pressing it does. |
163
194
  | `CONFIRM_ORDER` | 14 | A destructive button placed before Cancel in the same row. |
@@ -170,7 +201,7 @@ judging yours. Each finding carries a code, and the code maps to a rule above.
170
201
  | `SMALL_PRESET` | 03 | `size: "small"` on a control that is the screen's main action (~30px). |
171
202
  | `CROWDED_FORM` | 06 | Many fields in one file: group them, or split the task. |
172
203
  | `LIST_WITHOUT_STATE` | 12 | A list with no loading, empty or failed state in this file. |
173
- | `MULTIPLE_PRIMARY_ACTIONS` | 04 | Several filled buttons on one screen. |
204
+ | `MULTIPLE_PRIMARY_ACTIONS` | 04 | More than one filled button in one view; keep one and make the rest outlined or text. |
174
205
 
175
206
  ## Running the gate
176
207
 
@@ -203,6 +234,20 @@ agent, will otherwise copy the exception as if it were the rule.
203
234
  And a rule you had to break is information about the rule: if the exception was
204
235
  right twice, it is a defect in the scale, not in the app.
205
236
 
237
+ ## The framework runs this gate on itself
238
+
239
+ A widget cannot ship a default that the gate would reject in your app, because
240
+ `npm test` runs the same checker over the framework's `src/`
241
+ (`scripts/check-framework-ui.mjs`). It found real defects the first time: a tab
242
+ badge wrote white on the signal red (2.77:1 in the dark theme), a `ListTile`
243
+ description borrowed the disabled grey (2.57:1), a dialog painted two filled
244
+ buttons at once, and eleven defaults sat off the scale.
245
+
246
+ The list of accepted findings lives in `scripts/framework-ui-baseline.json` and
247
+ is shrink-only: every entry carries the reason it is not a defect, and
248
+ `--write-baseline` exits `1` while any entry has no justification. A new finding
249
+ has to be fixed, not snapshotted.
250
+
206
251
  ## What no checker can see
207
252
 
208
253
  `check-ui` has no eyes. It cannot tell you that the screen is ugly, that the
@@ -223,7 +268,7 @@ Run the gate, then open the page.
223
268
  ## Continue reading
224
269
 
225
270
  - **Previous:** [The CLI](../cli/README.md) — Chapter 11
226
- - **Next:** [Minify and protect a FletBox build](minification-and-protection.md) — Appendix A
271
+ - **Next:** [Accessibility](accessibility.md) — Chapter 13
227
272
  - **Index:** [Guides index](README.md) · [The FletBox Book](../README.md)
228
273
 
229
274
  You are reading **Chapter 12 · Clean UI**.
@@ -4,24 +4,23 @@ This page is for **absolute beginners**. You do not need to know any trick — y
4
4
 
5
5
  ## Before you start
6
6
 
7
- FletBox is not published to the npm registry, so first you install the CLI once and link it globally:
7
+ You need Node.js 20.9 or newer and one command, because FletBox is published on npm:
8
8
 
9
9
  ```bash
10
- git clone https://github.com/kuko53348/flet-box.git
11
- cd flet-box
12
- npm link # link the flet-box CLI globally
10
+ npm install -g flet-box # the CLI, once
13
11
  ```
14
12
 
15
- Then create an empty project, link the framework into it, and start the dev server:
13
+ Then create an empty project, install the framework into it, and start the dev server:
16
14
 
17
15
  ```bash
18
- cd ..
19
16
  flet-box create my-app
20
17
  cd my-app
21
- npm link flet-box # or: npm install ../path/to/flet-box
18
+ npm install # flet-box itself, from the registry
22
19
  npm run dev
23
20
  ```
24
21
 
22
+ If you would rather not install anything globally, `npx flet-box create my-app` does the same thing.
23
+
25
24
  Open your browser at `http://localhost:8000`. You should see "Hello FletBox" (or a small welcome screen). If you do, everything is working.
26
25
 
27
26
  ## Step 1: the smallest app
@@ -255,6 +255,6 @@ Minification reduces the web payload inside the native application. It does not
255
255
 
256
256
  ## Continue reading
257
257
 
258
- - **Previous:** [Clean UI](clean-ui.md) — Chapter 12
258
+ - **Previous:** [Accessibility](accessibility.md) — Chapter 13
259
259
  - **Next:** [Build FletBox for mobile and other platforms](mobile-and-platforms.md) — Appendix B
260
260
  - **Index:** [Guides index](README.md) · [The FletBox Book](../README.md)
@@ -75,6 +75,44 @@ Your Capacitor configuration must point `webDir` to that output directory, commo
75
75
 
76
76
  The exact configuration file may be `capacitor.config.ts`, `capacitor.config.json`, or another format depending on how Capacitor was initialized.
77
77
 
78
+ ### The safe area
79
+
80
+ Every app `flet-box create` writes carries `viewport-fit=cover` in `index.html`,
81
+ which lets the web view paint behind the notch and the home indicator. That is
82
+ what a full-bleed app wants, but it only looks right if content steps back from
83
+ those edges, so the framework reads `env(safe-area-inset-*)` wherever a widget
84
+ touches a screen edge: the [AppBar](../widget/AppBar.md) adds the top inset to
85
+ its own padding, the [Drawer](../widget/Drawer.md) pads its panel and the
86
+ [Tooltip](../widget/Tooltip.md) measures the same box before it picks the side
87
+ its bubble goes on, so a header never sits under the status bar, a footer never
88
+ under the gesture bar and a tooltip never flips into either. The
89
+ value is `0` on a device with no inset, so nothing is configured per platform and
90
+ a desktop browser is untouched.
91
+
92
+ A screen that pins its own content to an edge — a hand-built tab bar, a floating
93
+ action row — has to ask for the same inset. A length prop accepts the expression
94
+ as it stands, because only a value made purely of pixels is converted to rem:
95
+
96
+ ```js
97
+ Container({
98
+ paddingBottom: "calc(16px + env(safe-area-inset-bottom, 0px))",
99
+ children: [/* tabs */],
100
+ });
101
+ ```
102
+
103
+ ### Hover does not exist here
104
+
105
+ A touch screen has no resting pointer, and iOS answers the first tap on an
106
+ element with a synthetic `mouseenter` that no `mouseleave` ever follows. So a
107
+ hover-only affordance on a phone is one that appears and then cannot be
108
+ dismissed, which is why [Tooltip](../widget/Tooltip.md) binds its hover path only
109
+ when `matchMedia("(hover: hover)")` matches and stays reachable through keyboard
110
+ focus everywhere else. The same test is the one to copy for a custom hover card,
111
+ peek row or mouse-following cursor: gate it on the media query, and give the
112
+ information a home the finger can reach — the control's own label, a
113
+ [SnackBar](../widget/SnackBar.md), or a
114
+ [BottomSheet](../widget/BottomSheet.md).
115
+
78
116
  ## Install Capacitor
79
117
 
80
118
  ```bash
@@ -225,6 +263,19 @@ Both platforms serve the app from a local HTTPS origin
225
263
  `capacitor.config.json`. A remote API has to allow that origin in its CORS
226
264
  configuration.
227
265
 
266
+ ### Text fields do not zoom the page
267
+
268
+ iOS magnifies the whole viewport when a text control whose computed font size is
269
+ under 16px receives focus, which reads as the screen growing and staying
270
+ deformed after you type. FletBox floors the field text at 16px on iOS only: a
271
+ `medium` or `small` field renders slightly larger there, while its box, label
272
+ and icons keep the design's sizes. A reader who raises the browser font still
273
+ gets a larger field, because the floor bounds the field's rem value instead of
274
+ replacing it.
275
+
276
+ Nothing has to be configured in the app, and the viewport is left alone: adding
277
+ `maximum-scale=1` would stop the zoom by also blocking pinch-to-zoom.
278
+
228
279
  ### Add iOS to the project
229
280
 
230
281
  ```bash
@@ -116,7 +116,7 @@ Every bundle ships a small manifest at `www/config.json`. This is what a running
116
116
 
117
117
  `update.url` is resolved automatically, in this order:
118
118
 
119
- 1. `update.url` in `fletbox.config.json` at your project root (explicit wins).
119
+ 1. `update.url` in `fletbox.config.json` at your project root (explicit wins) — unless it is still the scaffold placeholder, which is not a choice. See [The Pages address of a generated app](#the-pages-address-of-a-generated-app).
120
120
  2. Derived from the `repository` field of `package.json`, mapped to GitHub Pages: `https://<user>.github.io/<repo>/`.
121
121
  3. Derived from the **current project's** git remote `origin` (when it is a GitHub URL). This means `flet-box createBundle` always points at the project it is built in, never at the flet-box framework installed as a dependency.
122
122
  4. If none of these exist, OTA is **disabled** and the bundle simply runs what shipped in it.
@@ -152,10 +152,22 @@ things at once:
152
152
  - writes that address into the header of the generated `.github/workflows/build.yml`;
153
153
  - prints it in the closing summary, with the one-time setting Pages needs.
154
154
 
155
- Without `--repo` nothing is invented. `fletbox.config.json` keeps the generic
156
- placeholder `https://TU-USUARIO.github.io/TU-REPO/`, the workflow header names a
157
- guess built from the folder name, and the workflow says so in its own comment:
158
- write the real address in both files that state it.
155
+ Without `--repo` nothing is invented at scaffold time. `fletbox.config.json` keeps
156
+ the generic placeholder `https://TU-USUARIO.github.io/TU-REPO/`, the workflow
157
+ header names a guess built from the folder name, and the workflow says in its own
158
+ comment which part of that address still has to be checked by hand.
159
+
160
+ The build does not trust that placeholder. `TU-USUARIO` and `TU-REPO` mean "not
161
+ configured yet", so `flet-box createBundle` falls through to rules 2 and 3 and
162
+ writes the address it derived — and prints that it derived, because a manifest
163
+ pointing at a real site next to a file that still says `TU-REPO` is a half-filled
164
+ config you should either finish or leave alone. A project with no GitHub remote
165
+ yet gets `update.url: null`: OTA is off, the bundle runs what shipped in it, and
166
+ nothing polls a host that does not exist.
167
+
168
+ That matters because the failure used to be silent. The app compares bundle
169
+ **hashes**, and OTA is built never to break the app, so a placeholder URL that
170
+ reached a bundle produced no error, no dialog and no update — ever.
159
171
 
160
172
  **The workflow log is the authority.** The header names the address you typed at
161
173
  scaffold time; the `Where this lives` step prints the address the run *actually*
@@ -274,7 +286,7 @@ Requirements: a **public** GitHub repository, GitHub Pages enabled (Settings →
274
286
  | Symptom | Likely cause |
275
287
  | --- | --- |
276
288
  | Build prints "THIS APP DOES NOT SUPPORT OTA UPDATES" | No public GitHub repo is reachable from the project (no `origin` remote, a non-GitHub remote, or nothing configured). The app still works — it runs the bundle it shipped with. Create the repo, push it, and rebuild. |
277
- | No dialog, app runs old code | `update.url` missing/incorrect, or no `v*` tag was published — a normal push updates the site but deliberately leaves the manifest out, so there is nothing to compare against. |
289
+ | No dialog, app runs old code | `update.url` wrong — a real address that is not where the bundle is (the scaffold placeholder is not one of those: the build derives the address and prints it). Or no `v*` tag was published — a normal push updates the site but deliberately leaves the manifest out, so there is nothing to compare against. |
278
290
  | `hash mismatch` in console | The published `config.json` and bundle are from different builds. Re-run `flet-box release` so both are published together. |
279
291
  | Dialog appears but download fails | The Pages site is not reachable/CORS-blocked from the app origin. Pages serves with permissive headers; check you are not behind a proxy that strips them. |
280
292
  | Update applies but reverts on next launch | Cache Storage unavailable (private browsing). The bundle runs once but cannot persist. |