flet-box 1.0.6 → 1.0.8

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 (162) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +90 -11
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +20 -6
  5. package/bin/commands/checkUi.js +1369 -0
  6. package/bin/commands/create.js +86 -12
  7. package/bin/commands/createBundle.js +75 -1
  8. package/bin/commands/images.js +349 -0
  9. package/bin/commands/pruneAssets.js +170 -0
  10. package/bin/commands/release.js +34 -1
  11. package/bin/commands/runCloudflare.js +47 -9
  12. package/bin/commands/runServer.js +91 -25
  13. package/bin/commands/toPng.js +346 -0
  14. package/bin/commands/unpack.js +1173 -0
  15. package/bin/ota/loader.js +90 -0
  16. package/bin/utils/appSources.js +101 -0
  17. package/bin/utils/bundleConfig.js +166 -0
  18. package/bin/utils/dtsProps.js +147 -0
  19. package/bin/utils/imageTools.js +271 -0
  20. package/bin/utils/images/bmp.js +99 -0
  21. package/bin/utils/images/gif.js +244 -0
  22. package/bin/utils/images/index.js +163 -0
  23. package/bin/utils/images/jpeg.js +643 -0
  24. package/bin/utils/images/png.js +486 -0
  25. package/bin/utils/images/unsupported.js +29 -0
  26. package/bin/utils/neutralise.js +109 -6
  27. package/bin/utils/pwaShell.js +137 -0
  28. package/bin/utils/templates.js +1653 -471
  29. package/docs/README.md +2 -0
  30. package/docs/cli/README.md +144 -1
  31. package/docs/guides/README.md +2 -0
  32. package/docs/guides/clean-ui.md +211 -0
  33. package/docs/guides/minification-and-protection.md +1 -1
  34. package/docs/guides/online-updates.md +105 -9
  35. package/docs/tools/README.md +1 -1
  36. package/docs/tools/animation.md +1 -1
  37. package/docs/tools/markdown.md +3 -3
  38. package/docs/tools/theme.md +41 -1
  39. package/docs/widget/Accordion.md +4 -0
  40. package/docs/widget/AdMob.md +5 -1
  41. package/docs/widget/AdSense.md +4 -0
  42. package/docs/widget/AnimatedBox.md +7 -3
  43. package/docs/widget/AnimatedText.md +4 -0
  44. package/docs/widget/Audio.md +5 -1
  45. package/docs/widget/Avatar.md +15 -3
  46. package/docs/widget/Badge.md +4 -0
  47. package/docs/widget/Button.md +4 -0
  48. package/docs/widget/COMMON_PROPS.md +52 -0
  49. package/docs/widget/Card.md +4 -0
  50. package/docs/widget/Carousel.md +4 -0
  51. package/docs/widget/Chart.md +4 -0
  52. package/docs/widget/Checkbox.md +4 -0
  53. package/docs/widget/Chip.md +4 -0
  54. package/docs/widget/CircularBar.md +4 -0
  55. package/docs/widget/CircularChart.md +4 -0
  56. package/docs/widget/CodeViewer.md +4 -0
  57. package/docs/widget/Column.md +4 -0
  58. package/docs/widget/Container.md +4 -0
  59. package/docs/widget/DataTable.md +4 -0
  60. package/docs/widget/Divider.md +4 -0
  61. package/docs/widget/DraggBox.md +6 -2
  62. package/docs/widget/Dropdown.md +6 -0
  63. package/docs/widget/DroppBox.md +5 -1
  64. package/docs/widget/FloatingActionButton.md +4 -0
  65. package/docs/widget/GridView.md +4 -0
  66. package/docs/widget/Icon.md +4 -0
  67. package/docs/widget/Image.md +14 -3
  68. package/docs/widget/Input.md +4 -0
  69. package/docs/widget/Inspector.md +2 -2
  70. package/docs/widget/InstallButton.md +4 -0
  71. package/docs/widget/ListTile.md +4 -0
  72. package/docs/widget/ListView.md +4 -0
  73. package/docs/widget/Maps.md +191 -0
  74. package/docs/widget/Markdown.md +4 -0
  75. package/docs/widget/MatrixRain.md +4 -0
  76. package/docs/widget/Pagination.md +4 -0
  77. package/docs/widget/ParallaxBox.md +4 -0
  78. package/docs/widget/ProgressBar.md +4 -0
  79. package/docs/widget/QRCode.md +4 -0
  80. package/docs/widget/README.md +12 -10
  81. package/docs/widget/Radio.md +5 -1
  82. package/docs/widget/Rating.md +4 -0
  83. package/docs/widget/Row.md +4 -0
  84. package/docs/widget/Skeleton.md +4 -0
  85. package/docs/widget/Slider.md +4 -0
  86. package/docs/widget/Stack.md +4 -0
  87. package/docs/widget/Stepper.md +4 -0
  88. package/docs/widget/Switch.md +4 -0
  89. package/docs/widget/Tabs.md +5 -5
  90. package/docs/widget/Text.md +4 -0
  91. package/docs/widget/Tooltip.md +14 -13
  92. package/docs/widget/TreeView.md +4 -0
  93. package/docs/widget/Video.md +6 -2
  94. package/package.json +2 -2
  95. package/src/animations/AnimatedBox.js +4 -1
  96. package/src/animations/MatrixRain.js +3 -3
  97. package/src/core/hmr-client.js +129 -34
  98. package/src/index.d.ts +195 -7
  99. package/src/index.js +2 -0
  100. package/src/navigations/AppBar.js +22 -16
  101. package/src/navigations/BottomNavigation.js +8 -13
  102. package/src/navigations/CollapsibleSideBar.js +30 -8
  103. package/src/navigations/Drawer.js +25 -15
  104. package/src/navigations/DrawerItem.js +3 -1
  105. package/src/navigations/Scaffold.js +26 -12
  106. package/src/navigations/Tabs.js +104 -59
  107. package/src/tools/color.js +54 -0
  108. package/src/tools/index.js +9 -1
  109. package/src/tools/scale.js +41 -0
  110. package/src/tools/shadow.js +20 -0
  111. package/src/utils/index.js +1 -0
  112. package/src/utils/readableInk.js +19 -0
  113. package/src/utils/syntaxHighlight.js +78 -10
  114. package/src/utils/themes.js +217 -54
  115. package/src/utils/units.js +33 -11
  116. package/src/widget-factory/effects.js +56 -5
  117. package/src/widget-factory/processProps.js +10 -1
  118. package/src/widget-factory/remTool.js +15 -29
  119. package/src/widget-factory/stackPosition.js +18 -3
  120. package/src/widget-factory/translateProps.js +60 -1
  121. package/src/widgets/Accordion.js +20 -23
  122. package/src/widgets/AdMob.js +7 -2
  123. package/src/widgets/Avatar.js +236 -133
  124. package/src/widgets/Badge.js +3 -2
  125. package/src/widgets/BottomSheet.js +14 -13
  126. package/src/widgets/Button.js +69 -30
  127. package/src/widgets/Card.js +6 -2
  128. package/src/widgets/Carousel.js +12 -10
  129. package/src/widgets/Chart.js +28 -5
  130. package/src/widgets/Checkbox.js +76 -18
  131. package/src/widgets/Chip.js +31 -10
  132. package/src/widgets/CircularBar.js +7 -7
  133. package/src/widgets/CircularChart.js +26 -8
  134. package/src/widgets/CodeViewer.js +93 -20
  135. package/src/widgets/Container.js +13 -0
  136. package/src/widgets/DataTable.js +17 -11
  137. package/src/widgets/DraggBox.js +2 -1
  138. package/src/widgets/Dropdown.js +59 -14
  139. package/src/widgets/DroppBox.js +4 -2
  140. package/src/widgets/FloatingActionButton.js +13 -7
  141. package/src/widgets/Icon.js +3 -1
  142. package/src/widgets/Image.js +102 -3
  143. package/src/widgets/Input.js +30 -23
  144. package/src/widgets/Inspector.js +54 -1
  145. package/src/widgets/InstallButton.js +6 -4
  146. package/src/widgets/ListTile.js +4 -3
  147. package/src/widgets/ListView.js +30 -12
  148. package/src/widgets/Maps.js +1495 -0
  149. package/src/widgets/Markdown.js +37 -26
  150. package/src/widgets/Modal.js +14 -11
  151. package/src/widgets/Pagination.js +16 -11
  152. package/src/widgets/ProgressBar.js +6 -4
  153. package/src/widgets/Radio.js +73 -15
  154. package/src/widgets/Rating.js +24 -6
  155. package/src/widgets/Skeleton.js +31 -31
  156. package/src/widgets/Slider.js +29 -18
  157. package/src/widgets/SnackBar.js +35 -39
  158. package/src/widgets/Stepper.js +13 -9
  159. package/src/widgets/Switch.js +59 -18
  160. package/src/widgets/Tooltip.js +24 -12
  161. package/src/widgets/TreeView.js +27 -15
  162. package/src/widgets/index.js +1 -0
@@ -55,7 +55,7 @@ export const indexHtml = (appName = "FletBox App") => `<!DOCTYPE html>
55
55
  <div id="root"></div>
56
56
  <script>
57
57
  if ('serviceWorker' in navigator) {
58
- navigator.serviceWorker.register('/service-worker.js')
58
+ navigator.serviceWorker.register('./service-worker.js')
59
59
  .then(reg => console.log('Service Worker registered', reg))
60
60
  .catch(err => console.log('Service Worker registration error', err));
61
61
  }
@@ -64,112 +64,11 @@ export const indexHtml = (appName = "FletBox App") => `<!DOCTYPE html>
64
64
  </body>
65
65
  </html>`;
66
66
 
67
- export const appJs = () => `// app.js
68
- import { runApp, Scaffold, AppBar, BottomNavigation, colors, Icon, goTo, openDrawer } from 'flet-box';
69
- import { RootScreen } from './screens/RootScreen.js';
70
- import { HomeScreen } from './screens/HomeScreen.js';
71
- import { AboutScreen } from './screens/AboutScreen.js';
72
- import { DrawerMenu } from './components/layouts/DrawerMenu.js';
73
-
74
- // ========== CUSTOM APPBAR (commented out) ==========
75
- /*
76
- const CustomAppBar = () => {
77
- return AppBar({
78
- title: 'My App',
79
- backgroundColor: colors.primary,
80
- titleColor: '#ffffff',
81
- centerTitle: true,
82
- showBackButton: false,
83
- leading: Icon({
84
- name: 'menu',
85
- size: 24,
86
- color: '#ffffff',
87
- onclick: () => openDrawer()
88
- }),
89
- actions: [
90
- Icon({ name: 'search', size: 22, color: '#ffffff', onclick: () => console.log('Search') }),
91
- Icon({ name: 'notifications', size: 22, color: '#ffffff', onclick: () => console.log('Notifications') })
92
- ]
93
- });
94
- };
95
- */
96
-
97
- // ========== BOTTOM NAVIGATION (commented out) ==========
98
- /*
99
- const BottomNav = () => {
100
- return BottomNavigation({
101
- items: [
102
- { icon: 'home', label: 'Home', route: '/home' },
103
- { icon: 'favorite', label: 'Favorites', route: '/favorites' },
104
- { icon: 'person', label: 'Profile', route: '/profile' }
105
- ],
106
- backgroundColor: colors.surface,
107
- selectedColor: colors.primary
108
- });
109
- };
110
- */
111
-
112
- // ========== APPBAR PER ROUTE ==========
113
- const HomeAppBar = () => {
114
- return AppBar({
115
- title: 'Dashboard',
116
- backgroundColor: colors.primary,
117
- titleColor: '#ffffff',
118
- centerTitle: true,
119
- showBackButton: false,
120
- leading: Icon({
121
- name: 'menu',
122
- size: 24,
123
- color: '#ffffff',
124
- onclick: () => openDrawer()
125
- })
126
- });
127
- };
128
-
129
- const AboutAppBar = () => {
130
- return AppBar({
131
- title: 'About',
132
- backgroundColor: colors.primary,
133
- titleColor: '#ffffff',
134
- centerTitle: true,
135
- showBackButton: true,
136
- leading: Icon({
137
- name: 'arrow_back',
138
- size: 24,
139
- color: '#ffffff',
140
- onclick: () => goTo('/home')
141
- })
142
- });
143
- };
144
-
145
- // ========== ROUTES ==========
146
- const routes = {
147
- '/': { body: RootScreen, appBar: false },
148
- '/home': { body: HomeScreen, appBar: HomeAppBar() },
149
- '/about': { body: AboutScreen, appBar: AboutAppBar() }
150
- // Example with BottomNavigation (uncomment)
151
- // '/favorites': { body: HomeScreen, appBar: HomeAppBar() },
152
- // '/profile': { body: AboutScreen, appBar: AboutAppBar() }
153
- };
154
-
155
- const MyApp = () => {
156
- return Scaffold({
157
- routes: routes,
158
- drawer: DrawerMenu(),
159
- // bottomBar: BottomNav(), // ← Uncomment to enable BottomNavigation
160
- routerMode: 'hash',
161
- backgroundColor: colors.background
162
- });
163
- };
164
-
165
- runApp(MyApp, 'root');
166
- `;
167
-
168
- // The rest of the templates (rootScreenJs, homeScreenJs, aboutScreenJs, drawerMenuJs, themesJs, etc.)
169
- // remain the same as in your original file (they are already in English).
170
- // For brevity, I include them below as you already have them, but ensure no Spanish comments.
171
67
 
172
68
  export const rootScreenJs = () => `// screens/RootScreen.js
69
+ // This screen is a reference implementation of the app's own design contract:
70
+ // colors only from the tokens, spacing/radius/type only from the scale, one
71
+ // filled action per screen. The gate that enforces it is 'npm run ui'.
173
72
  import { Container, Column, Text, Button, Icon, colors, goTo } from 'flet-box';
174
73
 
175
74
  export const RootScreen = () => {
@@ -177,41 +76,60 @@ export const RootScreen = () => {
177
76
  flex: 1,
178
77
  justifyContent: 'center',
179
78
  alignItems: 'center',
180
- padding: 20,
79
+ padding: 24,
181
80
  backgroundColor: colors.background,
182
81
  child: Column({
183
- gap: 28,
82
+ gap: 24,
184
83
  alignItems: 'center',
185
84
  children: [
186
85
  Container({
187
- width: 80,
188
- height: 80,
189
- borderRadius: 20,
190
- backgroundColor: colors.primary + '15',
86
+ width: 96,
87
+ height: 96,
88
+ borderRadius: 16,
89
+ backgroundColor: colors.primaryBg,
191
90
  justifyContent: 'center',
192
91
  alignItems: 'center',
193
92
  child: Icon({ name: 'rocket_launch', size: 48, color: colors.primary })
194
93
  }),
195
- Text({
196
- text: 'FletBox',
197
- size: 36,
198
- weight: 'bold',
199
- color: colors.primary,
200
- align: 'center'
201
- }),
202
- Text({
203
- text: 'Ultra-lightweight UI framework for vanilla JavaScript',
204
- size: 14,
205
- color: colors.textSecondary,
206
- align: 'center'
94
+ Column({
95
+ gap: 8,
96
+ alignItems: 'center',
97
+ children: [
98
+ Text({ text: 'FletBox', size: 40, weight: 'bold', color: colors.text }),
99
+ // A lead is a sentence, not a paragraph: at desktop width this
100
+ // line would sit on 120 characters and no eye can track it.
101
+ Text({
102
+ text: 'A screen, in plain JavaScript.',
103
+ size: 16,
104
+ color: colors.textSecondary,
105
+ align: 'center'
106
+ })
107
+ ]
207
108
  }),
208
- Button({
209
- text: 'Get Started →',
210
- iconRight: 'arrow_forward',
211
- bgColor: colors.primary,
212
- color: '#ffffff',
213
- padding: '10px 28px',
214
- onPress: () => goTo('/home')
109
+ Column({
110
+ gap: 12,
111
+ alignItems: 'stretch',
112
+ maxWidth: 280,
113
+ children: [
114
+ // A filled Button defaults to colors.surface, not to the brand:
115
+ // the primary action has to name the fill it wants.
116
+ Button({
117
+ text: 'See the design system',
118
+ iconRight: 'arrow_forward',
119
+ bgColor: colors.primary,
120
+ color: colors.onPrimary,
121
+ fullWidth: true,
122
+ onPress: () => goTo('/home')
123
+ }),
124
+ // One accent per screen: everything that is not the ask is quiet.
125
+ Button({
126
+ text: 'Preferences',
127
+ variant: 'text',
128
+ color: colors.text,
129
+ fullWidth: true,
130
+ onPress: () => goTo('/about')
131
+ })
132
+ ]
215
133
  })
216
134
  ]
217
135
  })
@@ -222,7 +140,10 @@ export default RootScreen;
222
140
  `;
223
141
 
224
142
  export const homeScreenJs = () => `// screens/HomeScreen.js
225
- import { Container, Column, Text, Button, Icon, colors, goTo } from 'flet-box';
143
+ // The screen that shows what the app is built on. Every number on this page is a
144
+ // member of the framework's own scale, so the page documents the system it uses:
145
+ // change one to an off-scale value and 'npm run ui' reports it.
146
+ import { Container, Column, Row, Text, Icon, Chip, colors } from 'flet-box';
226
147
 
227
148
  // ========== GOOGLE ADMOB (optional — disabled by default) ==========
228
149
  // AdMob shows native ads in Android/iOS builds. To enable it:
@@ -238,70 +159,137 @@ import { Container, Column, Text, Button, Icon, colors, goTo } from 'flet-box';
238
159
  // import { AdMob } from 'flet-box';
239
160
  // =====================================================================
240
161
 
162
+ const SPACING_STEPS = [4, 8, 12, 16, 24, 32, 48, 64];
163
+
164
+ const TYPE_STEPS = [
165
+ { size: 12, label: 'Caption' },
166
+ { size: 14, label: 'Body' },
167
+ { size: 18, label: 'Title' },
168
+ { size: 24, label: 'Headline' },
169
+ { size: 32, label: 'Display' }
170
+ ];
171
+
172
+ // Three roles, and each one says when it is allowed on the page. A fourth would
173
+ // be decoration looking for a reason.
174
+ const COLOR_ROLES = [
175
+ { token: 'primary', text: 'Brand', note: 'the one action this screen asks for' },
176
+ { token: 'successStrong', text: 'Feedback', note: 'status, never ornament' },
177
+ { token: 'dangerStrong', text: 'Danger', note: 'the red that carries words' }
178
+ ];
179
+
241
180
  export const HomeScreen = () => {
242
181
  return Container({
243
182
  flex: 1,
244
- justifyContent: 'center',
245
- alignItems: 'center',
246
183
  padding: 24,
184
+ overflow: 'auto',
247
185
  backgroundColor: colors.background,
248
186
  child: Column({
249
- gap: 32,
250
- alignItems: 'center',
187
+ gap: 24,
188
+ // A reading column, not a full-width one: at 1400px a line of body
189
+ // text becomes unreadable, so the measure is capped and centred.
190
+ maxWidth: 640,
191
+ margin: '0 auto',
251
192
  children: [
252
- Container({
253
- width: 96,
254
- height: 96,
255
- borderRadius: 24,
256
- backgroundColor: colors.primary + '15',
257
- justifyContent: 'center',
258
- alignItems: 'center',
259
- child: Icon({ name: 'rocket_launch', size: 56, color: colors.primary })
193
+ Column({
194
+ gap: 8,
195
+ children: [
196
+ Text({ text: 'The system this app is built on', size: 32, weight: 'bold', color: colors.text }),
197
+ Text({
198
+ text: 'Three roles, one rhythm, one type scale. Nothing here is typed by hand.',
199
+ size: 16,
200
+ color: colors.textSecondary
201
+ })
202
+ ]
260
203
  }),
261
- Text({
262
- text: 'Welcome to FletBox',
263
- size: 32,
264
- weight: 'bold',
265
- color: colors.text,
266
- align: 'center'
204
+ Column({
205
+ gap: 12,
206
+ children: [
207
+ Text({ text: 'Spacing', size: 18, weight: 'bold', color: colors.text }),
208
+ Row({
209
+ gap: 12,
210
+ alignItems: 'end',
211
+ // Eight squares plus their labels need ~300 units. Without
212
+ // wrapping they overflow a narrow column — a sidebar on a
213
+ // small window, or this same screen beside a desktop nav.
214
+ wrap: true,
215
+ children: SPACING_STEPS.map((step) =>
216
+ Column({
217
+ // Column is a 100%-wide block by default — the
218
+ // right answer for a page, the wrong one for eight
219
+ // cells in a row, where it makes them share the
220
+ // width out and the squares drift apart.
221
+ width: 'auto',
222
+ gap: 8,
223
+ alignItems: 'center',
224
+ children: [
225
+ Container({
226
+ width: step,
227
+ height: step,
228
+ borderRadius: 4,
229
+ backgroundColor: colors.primaryBg,
230
+ border: '1px solid ' + colors.border
231
+ }),
232
+ Text({ text: String(step), size: 12, color: colors.textSecondary })
233
+ ]
234
+ })
235
+ )
236
+ }),
237
+ Text({
238
+ text: 'These are framework units, not pixels: raise the browser font size and every square, gap and corner grows with the text.',
239
+ size: 14,
240
+ color: colors.textSecondary
241
+ })
242
+ ]
267
243
  }),
268
- Text({
269
- text: 'Choose a section to explore:',
270
- size: 16,
271
- color: colors.textSecondary,
272
- align: 'center'
244
+ Column({
245
+ gap: 12,
246
+ children: [
247
+ Text({ text: 'Type', size: 18, weight: 'bold', color: colors.text }),
248
+ Column({
249
+ gap: 8,
250
+ children: TYPE_STEPS.map((step) =>
251
+ Row({
252
+ gap: 12,
253
+ alignItems: 'center',
254
+ children: [
255
+ Text({ text: step.label, size: step.size, weight: 'bold', color: colors.text }),
256
+ Text({ text: String(step.size), size: 12, color: colors.textSecondary })
257
+ ]
258
+ })
259
+ )
260
+ })
261
+ ]
273
262
  }),
274
263
  Column({
275
264
  gap: 12,
276
- alignItems: 'stretch',
277
- style: { width: '100%', maxWidth: 280 },
278
265
  children: [
279
- Button({
280
- text: '🏠 Home',
281
- iconRight: 'arrow_forward',
282
- bgColor: colors.primary,
283
- color: '#ffffff',
284
- padding: '12px 20px',
285
- fullWidth: true,
286
- onPress: () => goTo('/home')
287
- }),
288
- Button({
289
- text: 'ℹ️ About',
290
- iconRight: 'arrow_forward',
291
- variant: 'outlined',
292
- color: colors.info,
293
- padding: '12px 20px',
294
- fullWidth: true,
295
- onPress: () => goTo('/about')
296
- }),
297
- Button({
298
- text: '← Back to Root',
299
- iconLeft: 'arrow_back',
300
- variant: 'text',
301
- color: colors.textSecondary,
302
- padding: '12px 20px',
303
- fullWidth: true,
304
- onPress: () => goTo('/')
266
+ Text({ text: 'Color roles', size: 18, weight: 'bold', color: colors.text }),
267
+ Column({
268
+ gap: 8,
269
+ children: COLOR_ROLES.map((role) =>
270
+ Row({
271
+ gap: 12,
272
+ alignItems: 'center',
273
+ padding: 12,
274
+ borderRadius: 12,
275
+ backgroundColor: colors.surface,
276
+ children: [
277
+ Icon({ name: 'palette', size: 24, color: colors[role.token] }),
278
+ Column({
279
+ gap: 4,
280
+ flex: 1,
281
+ children: [
282
+ Text({ text: role.text, size: 16, weight: 'bold', color: colors.text }),
283
+ Text({ text: role.note, size: 14, color: colors.textSecondary })
284
+ ]
285
+ }),
286
+ // The token name is the lesson; a Chip is how a
287
+ // framework value gets shown without becoming a
288
+ // colour of its own.
289
+ Chip({ label: role.token, variant: 'outlined', color: colors.textSecondary, size: 12 })
290
+ ]
291
+ })
292
+ )
305
293
  })
306
294
  ]
307
295
  })
@@ -323,84 +311,191 @@ export default HomeScreen;
323
311
  `;
324
312
 
325
313
  export const aboutScreenJs = () => `// screens/AboutScreen.js
326
- import { Container, Column, Row, Text, Button, Icon, colors, goTo } from 'flet-box';
314
+ // Preferences, written the way the rest of the app should be written: state that
315
+ // survives a re-render, a control that names itself, validation that speaks
316
+ // instead of disabling, and one filled button per screen.
317
+ import {
318
+ Container,
319
+ Column,
320
+ Row,
321
+ Text,
322
+ Button,
323
+ Input,
324
+ Dropdown,
325
+ Switch,
326
+ Slider,
327
+ Divider,
328
+ Tooltip,
329
+ Icon,
330
+ SnackBar,
331
+ AlertDialog,
332
+ Storage,
333
+ colors,
334
+ useState,
335
+ setTheme,
336
+ getTheme,
337
+ setBaseFontSize,
338
+ getBaseFontSize
339
+ } from 'flet-box';
340
+
341
+ // Key-based RAM keeps a value across re-renders and hot reloads; localStorage is
342
+ // what keeps a choice across sessions. A preferences screen that writes neither
343
+ // is a form that lies, so the size is read back at boot — before the first
344
+ // render, and before the draft below seeds itself from the live value.
345
+ const storedSize = Storage.getData('baseFontSize');
346
+ if (storedSize) setBaseFontSize(storedSize);
347
+
348
+ const [draftSize, setDraftSize] = useState('prefFontSize', getBaseFontSize());
349
+
350
+ // The destructive action is built once, at module scope. The dialog puts Cancel
351
+ // first on its own: the way out is the first thing the eye lands on.
352
+ const resetDialog = AlertDialog({
353
+ title: 'Reset the text size?',
354
+ message: 'It goes back to 16, the size this browser asks for.',
355
+ variant: 'danger',
356
+ confirmText: 'Reset',
357
+ cancelText: 'Keep it',
358
+ onConfirm: () => {
359
+ setDraftSize(16);
360
+ setBaseFontSize(16);
361
+ // Resetting is the absence of a preference, not a new one: the key goes
362
+ // away, so the next boot answers to the browser again.
363
+ Storage.deleteData('baseFontSize');
364
+ SnackBar({ message: 'Text size reset', type: 'success' }).show();
365
+ }
366
+ });
367
+
368
+ // Validation runs before the action, so the error is a message and never a
369
+ // disabled button the user has to discover.
370
+ const save = (name, region) => {
371
+ if (name.length === 0) {
372
+ return SnackBar({ message: 'Give the profile a name first', type: 'error' }).show();
373
+ }
374
+ const size = draftSize.valueOf();
375
+ setBaseFontSize(size);
376
+ Storage.saveData('baseFontSize', size);
377
+ Storage.saveData('displayName', name);
378
+ Storage.saveData('region', region.valueOf());
379
+ SnackBar({ message: 'Preferences saved', type: 'success' }).show();
380
+ };
327
381
 
328
382
  export const AboutScreen = () => {
383
+ const [name, setName] = useState('prefName', Storage.getData('displayName') || '');
384
+ const [region, setRegion] = useState('prefRegion', Storage.getData('region') || '');
385
+ const isDark = getTheme() === 'dark';
386
+ const size = draftSize.valueOf();
387
+
329
388
  return Container({
330
389
  flex: 1,
331
- justifyContent: 'center',
332
- alignItems: 'center',
333
390
  padding: 24,
391
+ overflow: 'auto',
334
392
  backgroundColor: colors.background,
335
393
  child: Column({
336
- gap: 32,
337
- alignItems: 'center',
394
+ gap: 24,
395
+ maxWidth: 480,
396
+ margin: '0 auto',
338
397
  children: [
339
- Container({
340
- width: 96,
341
- height: 96,
342
- borderRadius: 24,
343
- backgroundColor: colors.primary + '15',
344
- justifyContent: 'center',
345
- alignItems: 'center',
346
- child: Icon({ name: 'rocket_launch', size: 56, color: colors.primary })
398
+ Column({
399
+ gap: 8,
400
+ children: [
401
+ Text({ text: 'Preferences', size: 32, weight: 'bold', color: colors.text }),
402
+ Text({
403
+ text: 'Every control writes a real framework value: the slider moves the base font, the whole app rescales, and Save remembers it for the next visit.',
404
+ size: 16,
405
+ color: colors.textSecondary
406
+ })
407
+ ]
347
408
  }),
348
- Text({
349
- text: 'Welcome to FletBox',
350
- size: 32,
351
- weight: 'bold',
352
- color: colors.text,
353
- align: 'center'
409
+ Input({
410
+ label: 'Display name',
411
+ placeholder: 'How the app should greet you',
412
+ value: name.valueOf(),
413
+ required: true,
414
+ onChange: (value) => setName(value)
354
415
  }),
355
- Text({
356
- text: 'A simple and fast UI framework for building web apps with pure JavaScript.',
357
- size: 16,
358
- color: colors.textSecondary,
359
- align: 'center',
360
- style: { maxWidth: 400 }
416
+ Dropdown({
417
+ label: 'Region',
418
+ options: [
419
+ { value: 'cl', label: 'Chile' },
420
+ { value: 'es', label: 'Spain' },
421
+ { value: 'mx', label: 'Mexico' }
422
+ ],
423
+ placeholder: 'Where you are',
424
+ value: region.valueOf(),
425
+ onChange: (value) => setRegion(value)
361
426
  }),
362
- Container({
363
- marginTop: 16,
364
- padding: 20,
365
- borderRadius: 16,
366
- backgroundColor: colors.surface,
367
- style: { width: '100%', maxWidth: 400 },
368
- child: Column({
369
- gap: 12,
370
- children: [
371
- Text({ text: '✨ What is FletBox?', size: 18, weight: 'bold', color: colors.primary }),
372
- Text({ text: 'FletBox helps you create web apps easily. No complex tools. Just write JavaScript.', size: 14, color: colors.textSecondary }),
373
- Container({ height: 1, backgroundColor: colors.border, marginVertical: 8 }),
374
- Row({ gap: 12, alignItems: 'center', children: [
375
- Icon({ name: 'check_circle', size: 20, color: colors.success }),
376
- Text({ text: 'Zero dependencies - lightweight', size: 14, color: colors.text })
377
- ] }),
378
- Row({ gap: 12, alignItems: 'center', children: [
379
- Icon({ name: 'check_circle', size: 20, color: colors.success }),
380
- Text({ text: '40+ ready-to-use widgets', size: 14, color: colors.text })
381
- ] }),
382
- Row({ gap: 12, alignItems: 'center', children: [
383
- Icon({ name: 'check_circle', size: 20, color: colors.success }),
384
- Text({ text: 'Built-in navigation (router)', size: 14, color: colors.text })
385
- ] }),
386
- Row({ gap: 12, alignItems: 'center', children: [
387
- Icon({ name: 'check_circle', size: 20, color: colors.success }),
388
- Text({ text: 'Dark / Light theme', size: 14, color: colors.text })
389
- ] }),
390
- Row({ gap: 12, alignItems: 'center', children: [
391
- Icon({ name: 'check_circle', size: 20, color: colors.success }),
392
- Text({ text: 'Hot reload for fast development', size: 14, color: colors.text })
393
- ] })
394
- ]
395
- })
427
+ Divider(),
428
+ // A toggle that never says what it switches is a mystery control: the
429
+ // label is part of the Switch, and the row is the tap target.
430
+ Switch({
431
+ label: 'Dark theme',
432
+ value: isDark,
433
+ activeColor: colors.primary,
434
+ onPress: (value) => setTheme(value ? 'dark' : 'light')
435
+ }),
436
+ Column({
437
+ gap: 8,
438
+ children: [
439
+ Row({
440
+ gap: 12,
441
+ alignItems: 'center',
442
+ children: [
443
+ Text({ text: 'Text size', size: 16, weight: 'bold', color: colors.text }),
444
+ Text({ text: size + 'px', size: 14, color: colors.textSecondary })
445
+ ]
446
+ }),
447
+ Slider({
448
+ min: 14,
449
+ max: 22,
450
+ step: 1,
451
+ value: size,
452
+ color: colors.primary,
453
+ onPress: (value) => setDraftSize(value)
454
+ }),
455
+ Text({
456
+ text: '16 is what the browser asks for. Anything larger is the reader asking to be readable.',
457
+ size: 14,
458
+ color: colors.textSecondary
459
+ })
460
+ ]
396
461
  }),
397
- Button({
398
- text: 'Back to Root',
399
- iconLeft: 'arrow_back',
400
- variant: 'outlined',
401
- bgColor: colors.info,
402
- color: colors.info,
403
- onPress: () => goTo('/')
462
+ Divider(),
463
+ Column({
464
+ gap: 12,
465
+ children: [
466
+ // One filled button per screen: it is the thing this screen asks
467
+ // for, and it is the only control on the page that stretches —
468
+ // a full-width target is a decision, not a layout default.
469
+ Button({
470
+ text: 'Save preferences',
471
+ iconLeft: 'check',
472
+ bgColor: colors.primary,
473
+ color: colors.onPrimary,
474
+ fullWidth: true,
475
+ onPress: () => save(name.valueOf(), region.valueOf())
476
+ }),
477
+ Row({
478
+ gap: 12,
479
+ alignItems: 'center',
480
+ children: [
481
+ // A second, quieter action: text variant, danger token,
482
+ // and it asks before it destroys.
483
+ Button({
484
+ text: 'Reset the size',
485
+ iconLeft: 'restart_alt',
486
+ variant: 'text',
487
+ color: colors.dangerStrong,
488
+ onPress: () => resetDialog.open()
489
+ }),
490
+ // An icon alone says nothing; the Tooltip is its name.
491
+ Tooltip({
492
+ text: 'Resetting the size is not undoable from this screen',
493
+ position: 'right',
494
+ child: Icon({ name: 'help_outline', size: 24, color: colors.textSecondary })
495
+ })
496
+ ]
497
+ })
498
+ ]
404
499
  })
405
500
  ]
406
501
  })
@@ -416,8 +511,9 @@ import { Drawer, DrawerItem, Container, Column, Text, Icon, colors, closeDrawer
416
511
  export const DrawerMenu = () => {
417
512
  return Drawer({
418
513
  header: Container({
419
- padding: 28,
420
- backgroundColor: colors.primary,
514
+ padding: 24,
515
+ backgroundColor: colors.surface,
516
+ borderBottom: \`1px solid \${colors.border}\`,
421
517
  child: Column({
422
518
  gap: 12,
423
519
  alignItems: 'center',
@@ -425,39 +521,39 @@ export const DrawerMenu = () => {
425
521
  Container({
426
522
  width: 60,
427
523
  height: 60,
428
- borderRadius: 30,
429
- backgroundColor: '#ffffff',
524
+ borderRadius: 999,
525
+ backgroundColor: colors.primaryBg,
430
526
  justifyContent: 'center',
431
527
  alignItems: 'center',
432
528
  child: Icon({ name: 'rocket_launch', size: 32, color: colors.primary })
433
529
  }),
434
- Text({ text: 'FletBox', size: 18, weight: 'bold', color: '#ffffff' }),
435
- Text({ text: 'UI Framework', size: 12, color: 'rgba(255,255,255,0.8)' })
530
+ Text({ text: 'FletBox', size: 18, weight: 'bold', color: colors.text }),
531
+ Text({ text: 'Starter app', size: 12, color: colors.textSecondary })
436
532
  ]
437
533
  })
438
534
  }),
439
535
  body: [
440
536
  DrawerItem({
441
- icon: 'dashboard',
442
- label: 'Dashboard',
537
+ icon: 'palette',
538
+ label: 'Design system',
443
539
  route: '/home',
444
540
  onPress: () => closeDrawer()
445
541
  }),
446
542
  DrawerItem({
447
- icon: 'info',
448
- label: 'About',
543
+ icon: 'tune',
544
+ label: 'Preferences',
449
545
  route: '/about',
450
546
  onPress: () => closeDrawer()
451
547
  }),
452
548
  DrawerItem({
453
549
  icon: 'home',
454
- label: 'Root',
550
+ label: 'Start here',
455
551
  route: '/',
456
552
  onPress: () => closeDrawer()
457
553
  })
458
554
  ],
459
555
  footer: Container({
460
- padding: 20,
556
+ padding: 16,
461
557
  borderTop: \`1px solid \${colors.border}\`,
462
558
  child: Text({
463
559
  text: 'Version 1.0.0',
@@ -473,12 +569,39 @@ export default DrawerMenu;
473
569
  `;
474
570
 
475
571
  export const themesJs = () => `// database/themes.js
476
- export let colors = {
477
- primary: '#6366f1',
572
+ // The app's whole design system lives here. Every value is measured with the
573
+ // framework's own \`color().contrastRatio()\` against the surface it is written on:
574
+ // the number sits next to the pair that needs it, because a token that cannot
575
+ // carry AA text is decoration, not a color.
576
+ import { registerPalette } from 'flet-box';
577
+
578
+ export const lightColors = {
579
+ // #6366f1 put white on 4.47:1 — a hair under AA. Two units darker measures
580
+ // 4.55:1, the same indigo, and every brand button passes.
581
+ primary: '#6165ef',
478
582
  secondary: '#8b5cf6',
479
583
  success: '#10b981',
480
584
  warning: '#f59e0b',
585
+ // The error signal: a badge, a border, an icon. 3.76:1 as words on this page,
586
+ // so it is never the label of a button.
481
587
  danger: '#ef4444',
588
+ // The readable red, both ways: 6.47:1 as words on white, 6.47:1 with white
589
+ // written on it, 5.42:1 inside \`errorBg\`. Use it for the words and the plate.
590
+ dangerStrong: '#b91c1c',
591
+ // The readable green and amber: 5.48:1 and 5.02:1, in both directions, so
592
+ // they may be a label, an icon that carries meaning, or a plate.
593
+ successStrong: '#047857',
594
+ warningStrong: '#b45309',
595
+ // \`*Plate\` is the fill white words sit on. On this page it is the same tone
596
+ // as the matching Strong, and it is a separate name because on a dark page the
597
+ // two part ways: the readable red goes light, the plate goes dark.
598
+ dangerPlate: '#b91c1c',
599
+ successPlate: '#047857',
600
+ warningPlate: '#b45309',
601
+ // \`info\` (#3b82f6) carries white at 3.68:1, so a filled chip or a tooltip in
602
+ // the info hue needs this one: 6.70:1 in both directions.
603
+ infoStrong: '#1d4ed8',
604
+ infoPlate: '#1d4ed8',
482
605
  info: '#3b82f6',
483
606
  background: '#ffffff',
484
607
  surface: '#f8fafc',
@@ -486,10 +609,23 @@ export let colors = {
486
609
  textSecondary: '#64748b',
487
610
  textDisabled: '#94a3b8',
488
611
  border: '#e2e8f0',
489
- white: '#ffffff',
490
- black: '#000000',
491
612
  overlay: 'rgba(0, 0, 0, 0.5)',
492
- shadow: 'rgba(0, 0, 0, 0.1)'
613
+ shadow: 'rgba(0, 0, 0, 0.1)',
614
+ // What to write on a brand fill, and the soft tints that replace
615
+ // \`colors.primary + '15'\`.
616
+ onPrimary: '#ffffff',
617
+ primaryBg: '#eef2ff',
618
+ // The translucent surface for a card with \`backdropFilter\`. A token, so the
619
+ // app never hand-writes an \`rgba()\` the clean-UI gate would reject.
620
+ glass: 'rgba(255, 255, 255, 0.66)',
621
+ glassBorder: 'rgba(255, 255, 255, 0.75)',
622
+ error: '#ff3b30',
623
+ errorBg: '#ffe5e5',
624
+ successBg: '#e5f5e5',
625
+ warningBg: '#fff5e5',
626
+ infoBg: '#e5f5ff',
627
+ divider: '#eeeeee',
628
+ card: '#ffffff'
493
629
  };
494
630
 
495
631
  export const darkColors = {
@@ -497,7 +633,23 @@ export const darkColors = {
497
633
  secondary: '#a78bfa',
498
634
  success: '#34d399',
499
635
  warning: '#fbbf24',
636
+ // On a dark page the light red is the readable one: 6.45:1 against the
637
+ // background. The plate a person presses goes the other way.
500
638
  danger: '#f87171',
639
+ // \`*Strong\` is the answer to "what may I write words in?" and on this page
640
+ // that is the light end of the ladder: 9.41:1, 11.71:1, 12.38:1 on the
641
+ // background — all AA, and a step clearer than the signal tone above.
642
+ dangerStrong: '#fca5a5',
643
+ successStrong: '#6ee7b7',
644
+ warningStrong: '#fcd34d',
645
+ // \`*Plate\` is the fill with white words on it, and that has to go dark here:
646
+ // 10.02:1, 9.11:1, 9.07:1. These are the tones that used to hide inside
647
+ // \`*Strong\`, which is why a delete written in \`dangerStrong\` was invisible.
648
+ dangerPlate: '#7f1d1d',
649
+ successPlate: '#14532d',
650
+ warningPlate: '#78350f',
651
+ infoStrong: '#93c5fd',
652
+ infoPlate: '#1e40af',
501
653
  info: '#60a5fa',
502
654
  background: '#0f172a',
503
655
  surface: '#1e293b',
@@ -505,46 +657,28 @@ export const darkColors = {
505
657
  textSecondary: '#94a3b8',
506
658
  textDisabled: '#64748b',
507
659
  border: '#334155',
508
- white: '#ffffff',
509
- black: '#000000',
510
660
  overlay: 'rgba(0, 0, 0, 0.6)',
511
- shadow: 'rgba(0, 0, 0, 0.3)'
512
- };
513
-
514
- let currentTheme = 'light';
515
- const listeners = [];
516
-
517
- export const setTheme = (themeName) => {
518
- const theme = themeName === 'dark' ? darkColors : colors;
519
- currentTheme = themeName;
520
- Object.keys(theme).forEach(key => {
521
- if (colors.hasOwnProperty(key)) {
522
- colors[key] = theme[key];
523
- }
524
- });
525
- const root = document.documentElement;
526
- Object.keys(colors).forEach(key => {
527
- root.style.setProperty(\`--color-\${key}\`, colors[key]);
528
- });
529
- listeners.forEach(cb => cb(colors, themeName));
530
- };
531
-
532
- export const getTheme = () => currentTheme;
533
- export const toggleTheme = () => {
534
- const newTheme = currentTheme === 'light' ? 'dark' : 'light';
535
- setTheme(newTheme);
536
- return newTheme;
537
- };
538
- export const subscribeTheme = (callback) => {
539
- listeners.push(callback);
540
- return () => { const i = listeners.indexOf(callback); if(i > -1) listeners.splice(i, 1); };
661
+ shadow: 'rgba(0, 0, 0, 0.3)',
662
+ // #818cf8 is a light fill: white on it is 2.98:1, the page's own ink is 5.98:1.
663
+ onPrimary: '#0f172a',
664
+ primaryBg: '#1e1b4b',
665
+ glass: 'rgba(30, 41, 59, 0.66)',
666
+ glassBorder: 'rgba(226, 232, 240, 0.22)',
667
+ error: '#f87171',
668
+ errorBg: '#3b1113',
669
+ successBg: '#0c2f22',
670
+ warningBg: '#33230a',
671
+ infoBg: '#12263c',
672
+ divider: '#1e293b',
673
+ card: '#1e293b'
541
674
  };
542
675
 
543
- const initTheme = () => {
544
- const root = document.documentElement;
545
- Object.keys(colors).forEach(key => root.style.setProperty(\`--color-\${key}\`, colors[key]));
546
- };
547
- initTheme();
676
+ // Declaring a palette paints nothing by itself: every widget reads the
677
+ // framework's live \`colors\`, so the tokens have to be registered into it.
678
+ // Registration deliberately does not pick a theme — whether the app is dark is
679
+ // the reader's answer (\`setTheme\`, \`toggleTheme\`), not the designer's.
680
+ registerPalette('light', lightColors);
681
+ registerPalette('dark', darkColors);
548
682
  `;
549
683
 
550
684
  export const runSh = () => `#!/bin/bash
@@ -686,17 +820,40 @@ self.addEventListener('fetch', event => {
686
820
  const hit = await cache.match(req);
687
821
  if (hit) return hit;
688
822
  }
823
+
824
+ // A page load that neither the host nor the cache can answer must still
825
+ // boot the app: the router takes the URL from there. Asset requests keep
826
+ // their own failure, because replacing a missing image with a document is
827
+ // how a broken site becomes an undebuggable one.
828
+ const shell = async () => {
829
+ if (req.mode !== 'navigate') return null;
830
+ const cached = await cache.match(BASE + 'index.html');
831
+ if (cached) return cached;
832
+ try {
833
+ const fresh = await fetch(BASE + 'index.html', { cache: 'no-store' });
834
+ return fresh.ok ? fresh : null;
835
+ } catch (err) {
836
+ return null;
837
+ }
838
+ };
839
+
689
840
  try {
690
841
  const fresh = await fetch(req, { cache: 'no-store' });
691
- if (fresh.ok) cache.put(req, fresh.clone());
692
- return fresh;
842
+ if (fresh.ok) {
843
+ cache.put(req, fresh.clone());
844
+ return fresh;
845
+ }
846
+ // A host without SPA rewrites (GitHub Pages, a plain bucket) answers an
847
+ // unknown path with its own 404 page. That is a response, not an error,
848
+ // so a fallback living in the catch below never runs and every reload of
849
+ // a deep link shows "Page not found" while the app is perfectly healthy.
850
+ const fallback = await shell();
851
+ return fallback || fresh;
693
852
  } catch (err) {
694
853
  const cached = await cache.match(req);
695
854
  if (cached) return cached;
696
- if (req.mode === 'navigate') {
697
- const shell = await cache.match(BASE + 'index.html');
698
- if (shell) return shell;
699
- }
855
+ const fallback = await shell();
856
+ if (fallback) return fallback;
700
857
  return Response.error();
701
858
  }
702
859
  })()
@@ -711,6 +868,9 @@ export const packageJson = (name) => `{
711
868
  "scripts": {
712
869
  "dev": "bash run.sh",
713
870
  "check": "node scripts/check-app.mjs",
871
+ "ui": "node scripts/check-ui.mjs",
872
+ "fix": "flet-box audit-fix",
873
+ "api": "flet-box unpack --all --md",
714
874
  "build": "flet-box createBundle www",
715
875
  "start": "npm run dev",
716
876
  "android:init": "npm run build && cap add android",
@@ -1081,8 +1241,8 @@ export const manifest = (appName = "FletBox App") => `{
1081
1241
  "name": ${JSON.stringify(appName)},
1082
1242
  "short_name": ${JSON.stringify(appName.slice(0, 12))},
1083
1243
  "description": ${JSON.stringify(`${appName} built with Flet-Box`)},
1084
- "start_url": "/",
1085
- "scope": "/",
1244
+ "start_url": "./",
1245
+ "scope": "./",
1086
1246
  "display": "standalone",
1087
1247
  "display_override": ["window-controls-overlay"],
1088
1248
  "theme_color": "#1a1a2e",
@@ -1121,58 +1281,117 @@ export const manifest = (appName = "FletBox App") => `{
1121
1281
  }
1122
1282
  `;
1123
1283
 
1124
- // GitHub Actions workflow that builds web + Android + iOS in ONE run.
1125
- // Placed at .github/workflows/ (the only location GitHub reads), NOT inside www/.
1284
+ // GitHub Actions workflow that builds and publishes the WEB. Two jobs, the way
1285
+ // the docs site does it: `build` checks and bundles `www/` and only *uploads* it,
1286
+ // `deploy` is the sole job that can touch Pages — so a build that fails leaves
1287
+ // the live site alone. The native builds are a second file (build-native.yml)
1288
+ // that only runs when started by hand.
1126
1289
  // NOTE: every GitHub `${{ }}` expression is escaped as `\${{ }}` because this is
1127
1290
  // a JS template literal — an unescaped `${` would be parsed as interpolation.
1128
- export const githubWorkflow = (appName = "FletBox App", appId = "com.fletbox.app") => {
1129
- const slug = appName
1291
+ //
1292
+ // `siteUrl` is the Pages address `flet-box create` derived from `--repo`. It is
1293
+ // written into the file as text, at the top, the way the docs repo states its
1294
+ // own: whoever opens build.yml on GitHub reads where this app lives before
1295
+ // reading a single step. Anything that is not an http(s) URL is refused, so a
1296
+ // caller that passes the wrong thing gets the placeholder rather than a lie —
1297
+ // and the placeholder keeps `TU-USUARIO` visible, because that is the one string
1298
+ // the developer must edit.
1299
+ export const githubWorkflow = (appName = "FletBox App", siteUrl = null) => {
1300
+ const slug = String(appName)
1130
1301
  .toLowerCase()
1131
- .replace(/[^a-z0-9]+/g, "-")
1132
- .replace(/^-+|-+$/g, "")
1133
- .slice(0, 40) || "fletbox-app";
1134
- return `name: Build ${appName}
1135
-
1136
- # One push (or manual trigger) fans out into three parallel jobs:
1137
- # web -> bundles the PWA; publishes to GitHub Pages ONLY on release
1138
- # tags (v*), which is what triggers OTA self-updates
1139
- # android -> compiles a debug APK and uploads it as an artifact
1140
- # ios -> compiles a simulator .app and uploads it as an artifact
1141
- # All three run at the same time on separate runners.
1302
+ .replace(/[^a-z0-9._-]+/g, "-")
1303
+ .replace(/^-+|-+$/g, "") || "fletbox-app";
1304
+ const site = /^https?:\/\//i.test(String(siteUrl || ""))
1305
+ ? String(siteUrl).replace(/\/?$/, "/")
1306
+ : `https://TU-USUARIO.github.io/${slug}/`;
1307
+ // Only when the owner is still a guess: the address lives in two files, and a
1308
+ // developer who edits one and not the other ships an app that polls a site
1309
+ // that is not where the bundle is.
1310
+ const todo = site.includes("TU-USUARIO")
1311
+ ? `# The owner above is a placeholder: this project was created without \`--repo\`,
1312
+ # so the folder name is only a guess at the repo name. Write the real address in
1313
+ # both files that state it — this header, and fletbox.config.json (update.url),
1314
+ # which is the URL an installed app polls for an update.
1315
+ `
1316
+ : "";
1317
+ return `name: Deploy ${appName}
1318
+
1319
+ # This app goes live at:
1320
+ # ${site}
1321
+ ${todo}#
1322
+ # Web: AUTOMATIC. Every push to main (and every manual run) builds a fresh site;
1323
+ # only the deploy job can touch Pages, so a broken bundle never replaces the
1324
+ # live one. Enable the destination once: Settings -> Pages -> Build and
1325
+ # deployment -> Source: GitHub Actions. Until you do, runs are green and no site
1326
+ # exists — the "Where this lives" step below says so in the log.
1327
+ #
1328
+ # Android and iOS are NOT here: they live in build-native.yml and run only when
1329
+ # you start them, because a macOS runner is not free.
1330
+ #
1331
+ # One consequence to know about: this Pages site is also what an installed app
1332
+ # polls for an OTA update (www/config.json), and the app compares the bundle
1333
+ # hash, not the version. So the manifest is left out of every build whose commit
1334
+ # carries no \`v*\` tag (see "Ship the update feed only when this commit is a
1335
+ # release" below): the site is live on every push, while installed apps keep
1336
+ # running the last \`flet-box release\`.
1337
+ #
1338
+ # Publishing a release: push main, then \`git tag -a v1.2.3 && git push origin
1339
+ # v1.2.3\`, then Actions -> this workflow -> Run workflow. The manual run deploys
1340
+ # the tagged commit, and it is the run that puts config.json on the site. The tag
1341
+ # itself cannot deploy: GitHub refuses Pages deployments that start from a tag.
1342
+
1142
1343
  on:
1143
1344
  push:
1144
1345
  branches: [ main ]
1145
- tags: [ 'v*' ]
1146
- pull_request:
1147
- branches: [ main ]
1346
+ # Deliberately NO tags: trigger. The github-pages environment only accepts
1347
+ # deployments from the default branch, so a run that starts from a tag fails
1348
+ # at the deploy job with "Tag v1.0.0 is not allowed to deploy to github-pages
1349
+ # due to environment protection rules". A release is therefore made by
1350
+ # pushing main and then running this workflow again, by hand, once the tag
1351
+ # exists: that run is on main, and it sees the tag on HEAD.
1148
1352
  workflow_dispatch:
1149
1353
 
1150
- # Cancel superseded runs on the same branch to save Actions minutes.
1354
+ # The deploy job needs Pages write access; it is declared once, for the file.
1355
+ permissions:
1356
+ contents: read
1357
+ pages: write
1358
+ id-token: write
1359
+
1360
+ # Allow only one concurrent deployment, skipping runs queued between the run
1361
+ # in-progress and the latest queued. Do NOT cancel in-progress: a production
1362
+ # deployment is allowed to finish, because the site that is live while it runs is
1363
+ # the previous one, and a cancelled deploy leaves Pages holding a half-written
1364
+ # artifact.
1151
1365
  concurrency:
1152
- group: \${{ github.workflow }}-\${{ github.ref }}
1153
- cancel-in-progress: true
1366
+ group: pages
1367
+ cancel-in-progress: false
1154
1368
 
1155
- # The generated app depends on the public \`flet-box\` npm package. If you are
1156
- # developing against a local copy, publish it (or vendor it) before these jobs
1157
- # can \`npm install\` it. package-lock.json is gitignored, so we use npm install.
1369
+ # The generated app depends on the public \`flet-box\` npm package. If you
1370
+ # develop against a local copy, publish it (or vendor it) before these jobs can
1371
+ # \`npm install\` it. package-lock.json is gitignored, so we use npm install.
1158
1372
 
1159
1373
  jobs:
1160
- # ---------- WEB: bundle + GitHub Pages preview ----------
1161
- web:
1162
- name: Web (Pages)
1374
+ # ---------- BUILD: check, bundle, upload. Nothing here reaches Pages. ----------
1375
+ build:
1376
+ name: Build site
1163
1377
  runs-on: ubuntu-latest
1164
- permissions:
1165
- contents: read
1166
- pages: write
1167
- id-token: write
1168
- environment:
1169
- name: github-pages
1170
- url: \${{ steps.deployment.outputs.page_url }}
1171
1378
  steps:
1172
1379
  - uses: actions/checkout@v5
1380
+ with:
1381
+ # The release decision below reads the tags pointing at this commit, and
1382
+ # a shallow checkout of one commit does not fetch them by itself.
1383
+ fetch-tags: true
1384
+
1385
+ # Registers the Pages integration for this run and reports the site's base
1386
+ # path, which is /<repo>/ for a project site and empty on a custom domain.
1387
+ # It belongs in the build job, before the artifact is uploaded.
1388
+ - name: Setup Pages
1389
+ uses: actions/configure-pages@v5
1173
1390
 
1174
1391
  - uses: actions/setup-node@v5
1175
1392
  with:
1393
+ # 22 is the current LTS. 20 is EOL, and the runner says so on every
1394
+ # build of a generated app.
1176
1395
  node-version: 22
1177
1396
 
1178
1397
  - name: Install dependencies
@@ -1183,28 +1402,108 @@ jobs:
1183
1402
  - name: Check app API usage (npm run check)
1184
1403
  run: npm run check
1185
1404
 
1186
- - name: Bundle web app (flet-box createBundle www)
1405
+ - name: Build site (flet-box createBundle www)
1187
1406
  run: npm run build
1188
1407
 
1189
- # Pages is the OTA update source (www/config.json), so it is published
1190
- # ONLY for release tags (v*) created by \`flet-box release\`. Ordinary
1191
- # pushes still build (to catch breakage in CI) but never publish an
1192
- # update, so test builds cannot reach installed apps.
1408
+ # Printed before the deploy, because this is the address the whole workflow
1409
+ # is about, and because a missing Pages setting otherwise shows up as a
1410
+ # green run with no site: Pages lower-cases the owner, hence the tr.
1411
+ # The file header names one address too; this is the authority, because it
1412
+ # reads the repo the run actually belongs to — they disagree when the repo
1413
+ # was created with a different name than the folder, or never pushed.
1414
+ - name: Where this lives
1415
+ run: |
1416
+ owner=$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')
1417
+ repo=\${GITHUB_REPOSITORY#*/}
1418
+ echo " site: https://$owner.github.io/$repo/"
1419
+ echo " header: ${site}"
1420
+ echo " version: $(node -p "require('./package.json').version")"
1421
+ echo " If a green run leaves that 404, the source is wrong:"
1422
+ echo " Settings -> Pages -> Build and deployment -> Source: GitHub Actions"
1423
+
1424
+ # www/config.json is the OTA manifest: an installed app fetches it and
1425
+ # compares its \`hash\` with the bundle it is running, so publishing it from
1426
+ # an ordinary push would offer that unfinished build to every device that
1427
+ # checks for updates. Dropping the file makes the poll fail as "not
1428
+ # deployed yet" (see bin/ota/loader.js), which is the behaviour wanted:
1429
+ # the site is live for reading, the release feed stays on tagged builds.
1430
+ #
1431
+ # The decision reads the commit, not the ref: a tag push cannot deploy (the
1432
+ # environment refuses it), so \`github.ref\` is always a branch here. To
1433
+ # publish a release, push main, tag that commit, then run this workflow by
1434
+ # hand from Actions - the manual run is on main and sees the tag.
1435
+ - name: Ship the update feed only when this commit is a release
1436
+ run: |
1437
+ tag=$(git tag --points-at HEAD --list 'v*' | sort -V | tail -1)
1438
+ if [ -n "$tag" ]; then
1439
+ echo " ota: $tag is on this commit: config.json ships, installed apps may update"
1440
+ else
1441
+ rm -f www/config.json
1442
+ echo " ota: web-only: no v* tag on $(git rev-parse --short HEAD), manifest left out"
1443
+ fi
1444
+
1193
1445
  - name: Upload Pages artifact
1194
- if: startsWith(github.ref, 'refs/tags/v')
1195
1446
  uses: actions/upload-pages-artifact@v5
1196
1447
  with:
1197
1448
  path: www
1198
1449
 
1199
- # Enable Pages first: Settings -> Pages -> Source = "GitHub Actions".
1450
+ # ---------- DEPLOY: the only job with a path to Pages ----------
1451
+ deploy:
1452
+ name: Deploy to Pages
1453
+ needs: build
1454
+ runs-on: ubuntu-latest
1455
+ environment:
1456
+ name: github-pages
1457
+ url: \${{ steps.deployment.outputs.page_url }}
1458
+ steps:
1200
1459
  - name: Deploy to GitHub Pages
1201
1460
  id: deployment
1202
- if: startsWith(github.ref, 'refs/tags/v')
1203
1461
  uses: actions/deploy-pages@v5
1204
1462
 
1463
+ # The address in the job log and as a run annotation, so it is readable
1464
+ # from the workflow list without opening anything.
1465
+ - name: Published
1466
+ run: |
1467
+ echo " live at: \${{ steps.deployment.outputs.page_url }}"
1468
+ echo "::notice title=Site published to GitHub Pages::\${{ steps.deployment.outputs.page_url }}"
1469
+ `;
1470
+ };
1471
+
1472
+ // Native builds: the same two jobs, but they run ONLY when started by hand from
1473
+ // Actions -> Run workflow, with a choice of platform. A push never pays for a
1474
+ // macOS runner, and `flet-box build android|ios` already covers a local build.
1475
+ export const nativeWorkflow = (appName = "FletBox App") => {
1476
+ const slug = appName
1477
+ .toLowerCase()
1478
+ .replace(/[^a-z0-9]+/g, "-")
1479
+ .replace(/^-+|-+$/g, "")
1480
+ .slice(0, 40) || "fletbox-app";
1481
+ return `name: Build ${appName} — native (manual)
1482
+
1483
+ # Nothing in here runs on a push. Start it by hand: Actions -> "${appName} —
1484
+ # native (manual)" -> Run workflow, then pick the platform. Each job bundles the
1485
+ # web first, because that bundle is what Capacitor copies into the app.
1486
+
1487
+ on:
1488
+ workflow_dispatch:
1489
+ inputs:
1490
+ platform:
1491
+ description: "What to build"
1492
+ type: choice
1493
+ options:
1494
+ - android
1495
+ - ios
1496
+ - both
1497
+ default: android
1498
+
1499
+ permissions:
1500
+ contents: read
1501
+
1502
+ jobs:
1205
1503
  # ---------- ANDROID: debug APK ----------
1206
1504
  android:
1207
1505
  name: Android (APK)
1506
+ if: inputs.platform == 'android' || inputs.platform == 'both'
1208
1507
  runs-on: ubuntu-latest
1209
1508
  steps:
1210
1509
  - uses: actions/checkout@v5
@@ -1246,9 +1545,19 @@ jobs:
1246
1545
  path: android/app/build/outputs/apk/debug/app-debug.apk
1247
1546
  if-no-files-found: error
1248
1547
 
1548
+ # An artifact has no public URL: it is on the run page, and this prints
1549
+ # which run. The debug APK installs as it is; a release one would have to be
1550
+ # signed with your own keystore first.
1551
+ - name: Where the APK is
1552
+ run: |
1553
+ echo " artifact: ${slug}-android-debug"
1554
+ echo " download: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
1555
+ echo "::notice title=APK ready::${slug}-android-debug, on this run page"
1556
+
1249
1557
  # ---------- iOS: simulator build ----------
1250
1558
  ios:
1251
1559
  name: iOS (simulator)
1560
+ if: inputs.platform == 'ios' || inputs.platform == 'both'
1252
1561
  runs-on: macos-latest
1253
1562
  steps:
1254
1563
  - uses: actions/checkout@v5
@@ -1295,6 +1604,12 @@ jobs:
1295
1604
  name: ${slug}-ios-simulator
1296
1605
  path: ios/App/build/Build/Products/Debug-iphonesimulator/App.app
1297
1606
  if-no-files-found: error
1607
+
1608
+ - name: Where the build is
1609
+ run: |
1610
+ echo " artifact: ${slug}-ios-simulator"
1611
+ echo " download: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
1612
+ echo "::notice title=Simulator build ready::${slug}-ios-simulator, on this run page"
1298
1613
  `;
1299
1614
  };
1300
1615
 
@@ -1318,7 +1633,9 @@ package-lock.json
1318
1633
 
1319
1634
  export const readme = (name, _template, appId) => `# ${name}
1320
1635
 
1321
- FletBox app with 3 screens: Root, Home and About, plus Drawer menu.
1636
+ FletBox app with 3 screens: Root, Home (the design system) and About
1637
+ (preferences), plus Drawer menu. Read \`src/screens/HomeScreen.js\` first: it is
1638
+ the contract this app is built on, and \`npm run ui\` enforces it.
1322
1639
  The generated Capacitor configuration uses package id \`${appId}\`.
1323
1640
 
1324
1641
  ## Quick Start
@@ -1336,22 +1653,39 @@ framework from scratch:
1336
1653
 
1337
1654
  - \`AGENTS.md\` — the rules: FletBox syntax only, real widgets and props only, and the
1338
1655
  verification loop it must run after every edit.
1656
+ - \`.claude/skills/fletbox-clean-ui/SKILL.md\` — the design rules themselves, numbered
1657
+ 00 to 20 and measured: which token, which step of the scale, which contrast. The
1658
+ same text is at \`.qoder/skills/fletbox-clean-ui/SKILL.md\` and as a Cursor rule in
1659
+ \`.cursor/rules/fletbox-clean-ui.mdc\`.
1339
1660
  - \`.claude/CLAUDE.md\`, \`.cursor/rules/fletbox.mdc\` — short pointers to \`AGENTS.md\` for
1340
1661
  Claude Code and Cursor.
1341
1662
  - \`.fletbox/api/cheatsheet.md\` — every widget and its real props, generated from the
1342
1663
  installed package. This is the lookup an agent reads instead of the framework.
1664
+ - \`.fletbox/api/api.json\` — the same surface as data: per prop its engine target,
1665
+ default, aliases and a \`sample\` value the types prove works. Write calls from this.
1343
1666
  - \`.fletbox/memory/\` — notes the agent keeps between sessions, so it does not
1344
1667
  rediscover the same thing twice.
1345
1668
  - \`scripts/check-app.mjs\` — the gate that catches hallucinated APIs.
1669
+ - \`scripts/check-ui.mjs\` — the gate that catches an unclean screen.
1346
1670
 
1347
1671
  \`\`\`bash
1348
1672
  npm run check # scan src/ for invented widgets, props, imports
1349
1673
  npm run check -- --props Button # print the real props of one widget
1350
1674
  npm run check -- --list # print every public export
1675
+ npm run ui # clean-UI gate: tokens, scale, tap targets, labels,
1676
+ # contrast — each finding cites the rule it broke
1677
+ npm run ui -- --tokens # measured contrast of every token, on both surfaces
1678
+ npm run ui -- --strict # warnings (a one-off tint, an image with no alt) fail too
1679
+ npm run fix # rewrite typos it can prove (imports, prop keys)
1680
+ npm run api # regenerate .fletbox/api/api.json and api.md
1681
+ flet-box unpack Button # each prop, where it really goes, and what is dead
1682
+ flet-box unpack Button --example # a paste-ready call using only verified props
1683
+ flet-box unpack Modal --diff # where types, engine, docs and code disagree
1351
1684
  \`\`\`
1352
1685
 
1353
- \`npm run check\` exits non-zero on a real error and also runs in CI. If you upgrade
1354
- \`flet-box\`, regenerate the cheatsheet with \`flet-box check-api --emit-api\`.
1686
+ \`npm run check\` and \`npm run ui\` exit non-zero on a real error and also run in CI. If
1687
+ you upgrade \`flet-box\`, regenerate the cheatsheet with \`flet-box check-api --emit-api\`
1688
+ and the machine-readable surface with \`npm run api\`.
1355
1689
 
1356
1690
  ## Android
1357
1691
 
@@ -1414,29 +1748,51 @@ CORS configuration and use HTTPS for the remote service.
1414
1748
 
1415
1749
  ## Continuous Integration (GitHub Actions)
1416
1750
 
1417
- Every project ships with \`.github/workflows/build.yml\`. Once the repo is on
1418
- GitHub, each push to \`main\` (or a manual **Run workflow**) fans out into three
1419
- parallel jobs:
1751
+ Two workflow files ship with the project, and they answer to different triggers:
1420
1752
 
1421
- - **web** — bundles the app into \`www/\` and deploys it to **GitHub Pages** so you
1422
- can view it in a browser.
1423
- - **android** — compiles a debug **APK** and uploads it as a run artifact.
1424
- - **ios** — compiles an unsigned **simulator .app** and uploads it as an artifact.
1753
+ - \`.github/workflows/build.yml\` — **the web, automatic**. Every push to \`main\`
1754
+ (and a manual run) builds \`www/\` and publishes it to **GitHub Pages**, in two
1755
+ jobs: **Build site** checks the app, bundles it and uploads the result; **Deploy
1756
+ to Pages** is the only job that can replace the live site, so a bundle that
1757
+ fails to build leaves the current one running. A \`v*\` tag is deliberately not
1758
+ a trigger: the Pages environment refuses a run that starts from a tag, so the
1759
+ release is deployed by running this workflow again after tagging.
1760
+ - \`.github/workflows/build-native.yml\` — **Android and iOS, manual only**. Nothing
1761
+ runs on a push. Open **Actions → "… — native (manual)" → Run workflow** and pick
1762
+ \`android\`, \`ios\` or \`both\`. Each job uploads its build as a run artifact and
1763
+ prints the artifact name and the run URL in its own log.
1425
1764
 
1426
- ### Run it on the web (one-time setup)
1765
+ ### Publish the web (one-time setup)
1427
1766
 
1428
1767
  1. Push this project to a GitHub repository.
1429
1768
  2. Open **Settings → Pages** in that repo.
1430
- 3. Set **Source** to **GitHub Actions** (not "Deploy from a branch").
1431
- 4. Push to \`main\`, or open the **Actions** tab and click **Run workflow**.
1432
-
1433
- The site goes live at \`https://<your-user>.github.io/<repo-name>/\`. That is a
1434
- sub-path, so \`index.html\` uses relative asset paths and works without changes.
1769
+ 3. Set **Source** to **GitHub Actions** (not "Deploy from a branch"). Skip this
1770
+ and the deploy step fails on every push.
1771
+ 4. Push. The **Where this lives** step prints the address before the deploy runs,
1772
+ and the **Published** step prints it again as a run annotation, so you do not
1773
+ have to hunt for the environment box under the job.
1774
+
1775
+ The address this project publishes to is written at the top of
1776
+ \`.github/workflows/build.yml\`. \`flet-box create <name> --repo <user>/<repo>\`
1777
+ fills it in from the repo; without \`--repo\` it stays
1778
+ \`https://TU-USUARIO.github.io/<name>/\`, and the header says which two files to
1779
+ edit. That is a sub-path, so \`index.html\` uses relative asset paths and works
1780
+ without changes.
1781
+
1782
+ **Why a push does not update anyone's app:** this same address is the OTA feed
1783
+ installed apps poll, and the update check compares bundle **hashes**, not version
1784
+ numbers. So the workflow leaves \`www/config.json\` out of every artifact that is
1785
+ not a \`v*\` tag: the site is live for reading, while a device keeps running the
1786
+ last \`flet-box release\` until a tag publishes a manifest. The **Where this
1787
+ lives** step prints which of the two a given run was. Cut a release with
1788
+ \`flet-box release --patch --push\` when the app is worth updating.
1435
1789
 
1436
1790
  ### Download the mobile artifacts
1437
1791
 
1438
- Open the workflow run in the **Actions** tab; the Android APK and the iOS
1439
- \`.app\` are listed under **Artifacts** at the bottom of the run summary.
1792
+ Start it by hand — **Actions → "… — native (manual)" → Run workflow**, pick a
1793
+ platform — and open that run: the Android APK and the iOS \`.app\` are listed
1794
+ under **Artifacts** at the bottom of the run summary, and the job's own log prints
1795
+ the artifact name plus the URL of the run it belongs to.
1440
1796
 
1441
1797
  > The workflow installs dependencies with \`npm install\`, which resolves the
1442
1798
  > \`flet-box\` package from npm. If you develop against a local copy of the
@@ -1514,10 +1870,19 @@ updates:
1514
1870
  - Replace \`TU-USUARIO\` with your GitHub username and \`TU-REPO\` with the
1515
1871
  repository name, or set \`update.url\` to any HTTPS host that serves your
1516
1872
  \`config.json\` + bundle.
1517
- - Enable GitHub Pages: **Settings → Pages → Source: GitHub Actions**.
1873
+ - Enable GitHub Pages: **Settings → Pages → Source: GitHub Actions**. Without
1874
+ that the deploy step is green and no site exists.
1518
1875
  - Cut a release with \`flet-box release --push\` (bumps the version, rebuilds
1519
1876
  \`www/\`, commits and tags \`vX.Y.Z\`). CI then deploys \`www/\` and installed
1520
1877
  apps self-update the next time they open.
1878
+ - A plain \`git push\` also deploys \`www/\` — that is the preview, and the job
1879
+ prints the address. It never publishes \`www/config.json\`, because that file
1880
+ is the update feed and the app compares bundle **hashes**: without it an
1881
+ installed app finds no manifest and stays on the last release. Do not "fix"
1882
+ that gate; the release tag is the only thing that should reach users.
1883
+ - Android and iOS are not built on a push. Start
1884
+ \`.github/workflows/build-native.yml\` from **Actions → Run workflow** and pick
1885
+ \`android\`, \`ios\` or \`both\`.
1521
1886
  - The update URL is also reflected in \`www/config.json\` after \`flet-box
1522
1887
  createBundle www\`; it is baked into Android/iOS builds at \`cap sync\` time.
1523
1888
  - Only web files are updated OTA. Native changes (plugins, permissions,
@@ -1525,9 +1890,13 @@ updates:
1525
1890
 
1526
1891
  ## Features
1527
1892
 
1528
- - Welcome screen with rocket icon
1529
- - Dashboard screen with welcome message
1530
- - About screen with framework info
1893
+ - Root screen: one filled action, one quiet one
1894
+ - Home screen: the spacing rhythm, the type scale and the three color roles,
1895
+ drawn from the framework's own scale
1896
+ - About screen: \`useState\`, an \`Input\`, a \`Dropdown\`, a labelled \`Switch\`,
1897
+ a \`Slider\` that moves the base font size, a \`SnackBar\` reply and an
1898
+ \`AlertDialog\` before the destructive action
1899
+ - Neutral app bar with a theme toggle that carries a \`Tooltip\` name
1531
1900
  - Drawer menu for navigation
1532
1901
  - PWA icons and Capacitor Android launcher/splash assets
1533
1902
  - Capacitor iOS project, icon and launch screen assets
@@ -1562,22 +1931,91 @@ MIT
1562
1931
 
1563
1932
  // Additional screen for adaptive template
1564
1933
  export const profileScreenJs = () => `// screens/ProfileScreen.js
1565
- import { Container, Column, Text, Icon, colors } from 'flet-box';
1934
+ import { Container, Column, Row, Text, Icon, Divider, colors, Storage, goTo } from 'flet-box';
1935
+
1936
+ // Two words, from whatever the reader typed on the Preferences screen.
1937
+ const initials = (name) =>
1938
+ name.trim().split(/\\s+/).slice(0, 2).map((word) => word[0]).join('').toUpperCase();
1939
+
1940
+ const linkRow = (icon, label, note, route) => Container({
1941
+ padding: 16,
1942
+ cursor: 'pointer',
1943
+ child: Row({
1944
+ gap: 12,
1945
+ alignItems: 'center',
1946
+ children: [
1947
+ Icon({ name: icon, size: 20, color: colors.textSecondary }),
1948
+ Column({
1949
+ flex: 1,
1950
+ gap: 4,
1951
+ alignItems: 'stretch',
1952
+ children: [
1953
+ Text({ text: label, size: 16, color: colors.text }),
1954
+ Text({ text: note, size: 12, color: colors.textSecondary })
1955
+ ]
1956
+ }),
1957
+ Icon({ name: 'chevron_right', size: 20, color: colors.textSecondary })
1958
+ ]
1959
+ }),
1960
+ onclick: () => goTo(route)
1961
+ });
1566
1962
 
1567
1963
  export const ProfileScreen = () => {
1964
+ const name = Storage.getData('displayName') || '';
1965
+
1568
1966
  return Container({
1569
1967
  flex: 1,
1570
- justifyContent: 'center',
1571
- alignItems: 'center',
1572
1968
  padding: 24,
1573
1969
  backgroundColor: colors.background,
1574
1970
  child: Column({
1575
- gap: 20,
1576
- alignItems: 'center',
1971
+ gap: 24,
1972
+ alignItems: 'stretch',
1973
+ maxWidth: 480,
1974
+ margin: '0 auto',
1577
1975
  children: [
1578
- Icon({ name: 'person', size: 80, color: colors.primary }),
1579
- Text({ text: 'User Profile', size: 28, weight: 'bold', color: colors.text }),
1580
- Text({ text: 'This is your profile page.', size: 16, color: colors.textSecondary })
1976
+ Row({
1977
+ gap: 16,
1978
+ alignItems: 'center',
1979
+ children: [
1980
+ Container({
1981
+ width: 64,
1982
+ height: 64,
1983
+ borderRadius: 999,
1984
+ backgroundColor: colors.primaryBg,
1985
+ display: 'flex',
1986
+ alignItems: 'center',
1987
+ justifyContent: 'center',
1988
+ child: Text({
1989
+ text: initials(name) || 'P',
1990
+ size: 24,
1991
+ weight: 'bold',
1992
+ color: colors.primary
1993
+ })
1994
+ }),
1995
+ Column({
1996
+ flex: 1,
1997
+ gap: 4,
1998
+ alignItems: 'stretch',
1999
+ children: [
2000
+ Text({ text: name || 'No name yet', size: 18, weight: 'bold', color: colors.text }),
2001
+ Text({
2002
+ text: name ? 'Saved on this device' : 'Add one under Preferences',
2003
+ size: 14,
2004
+ color: colors.textSecondary
2005
+ })
2006
+ ]
2007
+ })
2008
+ ]
2009
+ }),
2010
+ Divider(),
2011
+ Column({
2012
+ gap: 4,
2013
+ alignItems: 'stretch',
2014
+ children: [
2015
+ linkRow('tune', 'Preferences', 'name, region, theme and text size', '/about'),
2016
+ linkRow('palette', 'Design system', 'the spacing, type and color scales', '/home')
2017
+ ]
2018
+ })
1581
2019
  ]
1582
2020
  })
1583
2021
  });
@@ -1598,38 +2036,70 @@ export { AboutScreen } from './AboutScreen.js';${
1598
2036
 
1599
2037
  // Layout components
1600
2038
  export const appBarComponentJs = () => `// components/layouts/AppBarComponent.js
1601
- import { AppBar, Icon, colors, openDrawer } from 'flet-box';
2039
+ import { AppBar, Icon, Tooltip, colors, openDrawer, toggleTheme, getTheme } from 'flet-box';
1602
2040
 
2041
+ // The bar is neutral — the surface token, not the brand — because a coloured strip
2042
+ // on every screen competes with the one action the screen is asking for. One
2043
+ // elevation step is enough to keep it off the page.
2044
+ //
2045
+ // It reads the theme instead of subscribing to it: setTheme() re-renders the app,
2046
+ // so a component that subscribed would add a listener on every render and never
2047
+ // remove it.
1603
2048
  export const AppBarComponent = ({ title, isMobile = false, showBack = false, onBack }) => {
2049
+ const isDark = getTheme() === 'dark';
2050
+
1604
2051
  return AppBar({
1605
2052
  title: title,
1606
- backgroundColor: colors.primary,
1607
- titleColor: '#ffffff',
1608
- centerTitle: true,
2053
+ backgroundColor: colors.surface,
2054
+ titleColor: colors.text,
2055
+ iconColor: colors.text,
2056
+ centerTitle: false,
1609
2057
  showBackButton: showBack,
1610
- leading: isMobile ? Icon({
1611
- name: 'menu',
1612
- size: 24,
1613
- color: '#ffffff',
1614
- onclick: () => openDrawer()
2058
+ leading: isMobile ? Tooltip({
2059
+ text: 'Open the menu',
2060
+ position: 'bottom',
2061
+ child: Icon({
2062
+ name: 'menu',
2063
+ size: 24,
2064
+ height: 40,
2065
+ color: colors.text,
2066
+ onclick: () => openDrawer()
2067
+ })
1615
2068
  }) : null,
2069
+ // An icon-only control still needs a name: the Tooltip is what says so.
2070
+ actions: [
2071
+ Tooltip({
2072
+ text: isDark ? 'Switch to light' : 'Switch to dark',
2073
+ position: 'bottom',
2074
+ child: Icon({
2075
+ name: isDark ? 'light_mode' : 'dark_mode',
2076
+ size: 24,
2077
+ height: 40,
2078
+ color: colors.text,
2079
+ onclick: () => toggleTheme()
2080
+ })
2081
+ })
2082
+ ],
1616
2083
  onBackPress: onBack,
1617
- elevation: 2
2084
+ elevation: 1
1618
2085
  });
1619
2086
  };
1620
2087
  `;
1621
2088
 
1622
2089
  export const bottomNavJs = () => `// components/layouts/BottomNav.js
1623
- import { BottomNavigation, colors, goTo } from 'flet-box';
2090
+ import { BottomNavigation, colors, goTo, getCurrentPath } from 'flet-box';
1624
2091
 
1625
- export const BottomNav = ({ items = [], currentRoute } = {}) => {
2092
+ // Scaffold re-runs this on every route change, which is why the current path is
2093
+ // read here rather than passed in by the app.
2094
+ export const BottomNav = ({ items = [] } = {}) => {
1626
2095
  const defaultItems = [
1627
- { icon: 'home', label: 'Home', route: '/home' },
1628
- { icon: 'info', label: 'About', route: '/about' }
2096
+ { icon: 'home', label: 'Start', route: '/' },
2097
+ { icon: 'palette', label: 'System', route: '/home' },
2098
+ { icon: 'tune', label: 'Preferences', route: '/about' }
1629
2099
  ];
1630
2100
  const navItems = items.length ? items : defaultItems;
1631
2101
  // BottomNavigation selects a tab by index, not by route.
1632
- const currentIndex = Math.max(0, navItems.findIndex((item) => item.route === currentRoute));
2102
+ const currentIndex = Math.max(0, navItems.findIndex((item) => item.route === getCurrentPath()));
1633
2103
  return BottomNavigation({
1634
2104
  items: navItems,
1635
2105
  currentIndex,
@@ -1641,53 +2111,54 @@ export const BottomNav = ({ items = [], currentRoute } = {}) => {
1641
2111
  `;
1642
2112
 
1643
2113
  export const sidebarJs = () => `// components/layouts/Sidebar.js
1644
- import { Container, Column, Row, Text, Icon, colors, goTo } from 'flet-box';
2114
+ import { Container, Column, Row, Text, Icon, colors, goTo, getCurrentPath } from 'flet-box';
1645
2115
 
1646
- export const Sidebar = ({ items = [], currentRoute } = {}) => {
2116
+ // Scaffold re-runs this on every route change, so the active item can read the
2117
+ // route itself instead of being rebuilt by the app.
2118
+ export const Sidebar = ({ items = [] } = {}) => {
1647
2119
  const defaultItems = [
1648
- { icon: 'dashboard', label: 'Dashboard', route: '/home' },
1649
- { icon: 'info', label: 'About', route: '/about' },
1650
- { icon: 'home', label: 'Root', route: '/' }
2120
+ { icon: 'home', label: 'Start here', route: '/' },
2121
+ { icon: 'palette', label: 'Design system', route: '/home' },
2122
+ { icon: 'tune', label: 'Preferences', route: '/about' }
1651
2123
  ];
1652
2124
  const menuItems = items.length ? items : defaultItems;
1653
- return Container({
1654
- width: 260,
2125
+ const currentRoute = getCurrentPath();
2126
+ // Scaffold puts this in a flex row *below* the app bar and sizes it with
2127
+ // leftNavBarWidth, so the sidebar has to stay in flow. A position: fixed
2128
+ // panel escapes that box: its header ends up hidden behind the bar and the
2129
+ // width prop stops meaning anything.
2130
+ return Column({
2131
+ height: '100%',
1655
2132
  backgroundColor: colors.surface,
1656
2133
  borderRight: \`1px solid \${colors.border}\`,
1657
- height: '100vh',
1658
- position: 'fixed',
1659
- left: 0,
1660
- top: 0,
1661
- child: Column({
1662
- children: [
1663
- Container({
1664
- padding: 24,
1665
- backgroundColor: colors.primary,
1666
- child: Column({
1667
- alignItems: 'center',
1668
- children: [
1669
- Icon({ name: 'rocket_launch', size: 40, color: '#ffffff' }),
1670
- Text({ text: 'FletBox', size: 20, weight: 'bold', color: '#ffffff', marginTop: 12 })
1671
- ]
1672
- })
2134
+ children: [
2135
+ Container({
2136
+ padding: 24,
2137
+ borderBottom: \`1px solid \${colors.border}\`,
2138
+ child: Column({
2139
+ alignItems: 'center',
2140
+ children: [
2141
+ Icon({ name: 'rocket_launch', size: 40, color: colors.primary }),
2142
+ Text({ text: 'FletBox', size: 18, weight: 'bold', color: colors.text, marginTop: 12 })
2143
+ ]
2144
+ })
2145
+ }),
2146
+ ...menuItems.map(item => Container({
2147
+ padding: '12px 16px',
2148
+ cursor: 'pointer',
2149
+ backgroundColor: currentRoute === item.route ? colors.primaryBg : 'transparent',
2150
+ borderLeft: currentRoute === item.route ? \`3px solid \${colors.primary}\` : 'none',
2151
+ child: Row({
2152
+ gap: 12,
2153
+ alignItems: 'center',
2154
+ children: [
2155
+ Icon({ name: item.icon, size: 20, color: currentRoute === item.route ? colors.primary : colors.textSecondary }),
2156
+ Text({ text: item.label, size: 14, weight: currentRoute === item.route ? 'bold' : 'normal', color: colors.text })
2157
+ ]
1673
2158
  }),
1674
- ...menuItems.map(item => Container({
1675
- padding: '12px 20px',
1676
- cursor: 'pointer',
1677
- backgroundColor: currentRoute === item.route ? \`\${colors.primary}15\` : 'transparent',
1678
- borderLeft: currentRoute === item.route ? \`3px solid \${colors.primary}\` : 'none',
1679
- child: Row({
1680
- gap: 12,
1681
- alignItems: 'center',
1682
- children: [
1683
- Icon({ name: item.icon, size: 20, color: currentRoute === item.route ? colors.primary : colors.textSecondary }),
1684
- Text({ text: item.label, size: 14, color: currentRoute === item.route ? colors.primary : colors.text })
1685
- ]
1686
- }),
1687
- onclick: () => goTo(item.route)
1688
- }))
1689
- ]
1690
- })
2159
+ onclick: () => goTo(item.route)
2160
+ }))
2161
+ ]
1691
2162
  });
1692
2163
  };
1693
2164
  `;
@@ -1698,21 +2169,22 @@ import { Drawer, DrawerItem, Container, Column, Text, Icon, colors, closeDrawer
1698
2169
 
1699
2170
  export const DrawerMenu = ({ items = [] } = {}) => {
1700
2171
  const defaultItems = [
1701
- { icon: 'dashboard', label: 'Dashboard', route: '/home' },
1702
- { icon: 'info', label: 'About', route: '/about' },
1703
- { icon: 'home', label: 'Root', route: '/' }
2172
+ { icon: 'home', label: 'Start here', route: '/' },
2173
+ { icon: 'palette', label: 'Design system', route: '/home' },
2174
+ { icon: 'tune', label: 'Preferences', route: '/about' }
1704
2175
  ];
1705
2176
  const menuItems = items.length ? items : defaultItems;
1706
2177
  return Drawer({
1707
2178
  header: Container({
1708
- padding: 28,
1709
- backgroundColor: colors.primary,
2179
+ padding: 24,
2180
+ backgroundColor: colors.surface,
2181
+ borderBottom: \`1px solid \${colors.border}\`,
1710
2182
  child: Column({
1711
2183
  alignItems: 'center',
1712
2184
  children: [
1713
- Icon({ name: 'rocket_launch', size: 48, color: '#ffffff' }),
1714
- Text({ text: 'FletBox', size: 18, weight: 'bold', color: '#ffffff', marginTop: 12 }),
1715
- Text({ text: 'UI Framework', size: 12, color: 'rgba(255,255,255,0.8)' })
2185
+ Icon({ name: 'rocket_launch', size: 40, color: colors.primary }),
2186
+ Text({ text: 'FletBox', size: 18, weight: 'bold', color: colors.text, marginTop: 12 }),
2187
+ Text({ text: 'Starter app', size: 12, color: colors.textSecondary })
1716
2188
  ]
1717
2189
  })
1718
2190
  }),
@@ -1723,7 +2195,7 @@ export const DrawerMenu = ({ items = [] } = {}) => {
1723
2195
  onPress: () => closeDrawer()
1724
2196
  })),
1725
2197
  footer: Container({
1726
- padding: 20,
2198
+ padding: 16,
1727
2199
  borderTop: \`1px solid \${colors.border}\`,
1728
2200
  child: Text({ text: 'Version 1.0.0', size: 12, color: colors.textSecondary, align: 'center' })
1729
2201
  })
@@ -1734,6 +2206,10 @@ export const DrawerMenu = ({ items = [] } = {}) => {
1734
2206
  // App templates (blank, basic, full, sidebar, adaptive)
1735
2207
  export const blankAppJs = () => `// app.js - Blank template
1736
2208
  import { runApp, Container, Text, colors } from 'flet-box';
2209
+ // Registers this app's design tokens. Without it every screen paints
2210
+ // with the framework's own palette, and the file that looks like the design
2211
+ // system is decoration.
2212
+ import './database/themes.js';
1737
2213
 
1738
2214
  const App = () => Container({
1739
2215
  flex: 1,
@@ -1747,7 +2223,11 @@ runApp(App, 'root');
1747
2223
  `;
1748
2224
 
1749
2225
  export const basicAppJs = () => `// app.js - Basic template (AppBar + Drawer)
1750
- import { runApp, Scaffold, colors } from 'flet-box';
2226
+ import { runApp, Scaffold, colors, useState, dimensions } from 'flet-box';
2227
+ // Registers this app's design tokens. Without it every screen paints
2228
+ // with the framework's own palette, and the file that looks like the design
2229
+ // system is decoration.
2230
+ import './database/themes.js';
1751
2231
  import { AppBarComponent } from './components/layouts/AppBarComponent.js';
1752
2232
  import { DrawerMenu } from './components/layouts/DrawerMenu.js';
1753
2233
 
@@ -1762,12 +2242,21 @@ const routes = {
1762
2242
  '/about': AboutScreen
1763
2243
  };
1764
2244
 
1765
- const MyApp = () => Scaffold({
1766
- routes,
1767
- appBar: AppBarComponent({ title: 'FletBox', isMobile: false }),
1768
- drawer: DrawerMenu(),
1769
- backgroundColor: colors.background
1770
- });
2245
+ // The drawer is only reachable through the AppBar's menu button, so the shell has
2246
+ // to know whether it is on a phone. Key-based state keeps the width across
2247
+ // re-renders; the resize listener is registered once, at module scope.
2248
+ const [viewportWidth, setViewportWidth] = useState('viewportWidth', dimensions.getWidth());
2249
+ dimensions.addListener(({ width }) => setViewportWidth(width));
2250
+
2251
+ const MyApp = () => {
2252
+ const isMobile = viewportWidth.valueOf() < 768;
2253
+ return Scaffold({
2254
+ routes,
2255
+ appBar: AppBarComponent({ title: 'FletBox', isMobile }),
2256
+ drawer: DrawerMenu(),
2257
+ backgroundColor: colors.background
2258
+ });
2259
+ };
1771
2260
 
1772
2261
  runApp(MyApp, 'root');
1773
2262
  `;
@@ -1775,6 +2264,10 @@ runApp(MyApp, 'root');
1775
2264
  export const fullAppJs =
1776
2265
  () => `// app.js - Full template (AppBar + Drawer + BottomNav)
1777
2266
  import { runApp, Scaffold, colors, useState, dimensions } from 'flet-box';
2267
+ // Registers this app's design tokens. Without it every screen paints
2268
+ // with the framework's own palette, and the file that looks like the design
2269
+ // system is decoration.
2270
+ import './database/themes.js';
1778
2271
  import { AppBarComponent } from './components/layouts/AppBarComponent.js';
1779
2272
  import { DrawerMenu } from './components/layouts/DrawerMenu.js';
1780
2273
  import { BottomNav } from './components/layouts/BottomNav.js';
@@ -1803,7 +2296,9 @@ const MyApp = () => {
1803
2296
  routes,
1804
2297
  appBar: AppBarComponent({ title: 'FletBox', isMobile }),
1805
2298
  drawer: DrawerMenu(),
1806
- bottomBar: isMobile ? BottomNav() : null,
2299
+ // A function, not an element: Scaffold re-runs it on each route change,
2300
+ // which is what keeps the active tab lit.
2301
+ bottomBar: isMobile ? () => BottomNav() : null,
1807
2302
  backgroundColor: colors.background
1808
2303
  });
1809
2304
  };
@@ -1813,6 +2308,10 @@ runApp(MyApp, 'root');
1813
2308
 
1814
2309
  export const sidebarAppJs = () => `// app.js - Sidebar template (desktop only)
1815
2310
  import { runApp, Scaffold, colors } from 'flet-box';
2311
+ // Registers this app's design tokens. Without it every screen paints
2312
+ // with the framework's own palette, and the file that looks like the design
2313
+ // system is decoration.
2314
+ import './database/themes.js';
1816
2315
  import { AppBarComponent } from './components/layouts/AppBarComponent.js';
1817
2316
  import { Sidebar } from './components/layouts/Sidebar.js';
1818
2317
  import { RootScreen, HomeScreen, AboutScreen } from './screens/index.js';
@@ -1826,7 +2325,9 @@ const routes = {
1826
2325
  const MyApp = () => Scaffold({
1827
2326
  routes,
1828
2327
  appBar: AppBarComponent({ title: 'FletBox', isMobile: false }),
1829
- leftNavBar: Sidebar(),
2328
+ // A function, not an element: Scaffold re-runs it on each route change,
2329
+ // which is what keeps the active row lit.
2330
+ leftNavBar: () => Sidebar(),
1830
2331
  backgroundColor: colors.background
1831
2332
  });
1832
2333
 
@@ -1836,6 +2337,10 @@ runApp(MyApp, 'root');
1836
2337
  export const adaptiveAppJs =
1837
2338
  () => `// app.js - Adaptive template (mobile + desktop)
1838
2339
  import { runApp, Scaffold, colors, useState, dimensions } from 'flet-box';
2340
+ // Registers this app's design tokens. Without it every screen paints
2341
+ // with the framework's own palette, and the file that looks like the design
2342
+ // system is decoration.
2343
+ import './database/themes.js';
1839
2344
  import { AppBarComponent } from './components/layouts/AppBarComponent.js';
1840
2345
  import { DrawerMenu } from './components/layouts/DrawerMenu.js';
1841
2346
  import { BottomNav } from './components/layouts/BottomNav.js';
@@ -1860,6 +2365,16 @@ const routes = {
1860
2365
  const [viewportWidth, setViewportWidth] = useState('viewportWidth', dimensions.getWidth());
1861
2366
  dimensions.addListener(({ width }) => setViewportWidth(width));
1862
2367
 
2368
+ // One menu, three surfaces: the drawer and the bottom bar on a phone, the
2369
+ // sidebar on a desktop. The labels stay short because the bottom bar has to fit
2370
+ // all four across a 360px screen.
2371
+ const menu = [
2372
+ { icon: 'home', label: 'Start', route: '/' },
2373
+ { icon: 'palette', label: 'System', route: '/home' },
2374
+ { icon: 'tune', label: 'Preferences', route: '/about' },
2375
+ { icon: 'person', label: 'Profile', route: '/profile' }
2376
+ ];
2377
+
1863
2378
  const MyApp = () => {
1864
2379
  // Structural read of the keyed viewport state, so crossing the breakpoint
1865
2380
  // re-renders the shell (drawer + bottom bar <-> sidebar).
@@ -1867,9 +2382,11 @@ const MyApp = () => {
1867
2382
  return Scaffold({
1868
2383
  routes,
1869
2384
  appBar: AppBarComponent({ title: 'FletBox', isMobile }),
1870
- drawer: isMobile ? DrawerMenu() : null,
1871
- bottomBar: isMobile ? BottomNav() : null,
1872
- leftNavBar: !isMobile ? Sidebar() : null,
2385
+ drawer: isMobile ? DrawerMenu({ items: menu }) : null,
2386
+ // Functions, not elements: Scaffold re-runs them on each route change,
2387
+ // which is what keeps the active item lit.
2388
+ bottomBar: isMobile ? () => BottomNav({ items: menu }) : null,
2389
+ leftNavBar: !isMobile ? () => Sidebar({ items: menu }) : null,
1873
2390
  backgroundColor: colors.background
1874
2391
  });
1875
2392
  };
@@ -1896,7 +2413,8 @@ export const agentsMd = (appName, template, version) =>
1896
2413
  '',
1897
2414
  'This file is the contract. Read it before writing any code in this project.',
1898
2415
  'You do **not** need to read the whole framework to work here — sections 1 to 3',
1899
- 'tell you where the authoritative answers live, and the checker in section 4',
2416
+ 'tell you where the authoritative answers live, the design system in section 4',
2417
+ 'fixes every size and color you may use, and the checker in section 5',
1900
2418
  'proves you right or wrong in seconds.',
1901
2419
  '',
1902
2420
  '## 1. The API is a fact, not a guess',
@@ -1907,8 +2425,11 @@ export const agentsMd = (appName, template, version) =>
1907
2425
  '| --- | --- |',
1908
2426
  '| Which widgets exist and which props each accepts | `.fletbox/api/cheatsheet.md` (generated, covers every widget) |',
1909
2427
  '| Everything for one widget | `npm run check -- --props <Widget>` |',
2428
+ '| Whether a prop actually reaches the DOM, or is typed but dead | `flet-box unpack <Widget>` |',
2429
+ '| A call that is safe to paste | `flet-box unpack <Widget> --example` |',
1910
2430
  '| Prose, types and a worked example | `node_modules/flet-box/docs/widget/<Widget>.md` |',
1911
2431
  '| Every public export of the package | `npm run check -- --list` |',
2432
+ '| The whole surface as data, to build calls from | `.fletbox/api/api.json` |',
1912
2433
  '',
1913
2434
  '**If a name is not in those files, it does not exist.** A prop that silently',
1914
2435
  'does nothing is worse than a compile error: it renders as a broken layout and',
@@ -1917,6 +2438,10 @@ export const agentsMd = (appName, template, version) =>
1917
2438
  '- Do not edit `node_modules/flet-box`. It is a dependency, not source.',
1918
2439
  '- The cheatsheet is generated output. Regenerate it with',
1919
2440
  ' `flet-box check-api --emit-api` after upgrading the package; never hand-edit it.',
2441
+ '- `.fletbox/api/api.json` and `api.md` are generated the same way:',
2442
+ ' `npm run api`. They carry one entry per prop with its engine target, default,',
2443
+ ' aliases and a `sample` value, so a call can be written from data instead of',
2444
+ ' memory.',
1920
2445
  '',
1921
2446
  '## 2. FletBox syntax only — no other framework',
1922
2447
  '',
@@ -1968,8 +2493,10 @@ export const agentsMd = (appName, template, version) =>
1968
2493
  ' routes,',
1969
2494
  " appBar: AppBarComponent({ title: 'FletBox', isMobile }),",
1970
2495
  ' drawer: isMobile ? DrawerMenu() : null,',
1971
- ' bottomBar: isMobile ? BottomNav() : null,',
1972
- ' leftNavBar: !isMobile ? Sidebar() : null, // NOT `sidebar:`',
2496
+ ' // Functions, not elements: Scaffold re-runs a bar on every route change,',
2497
+ ' // which is how the active item learns where the reader is.',
2498
+ ' bottomBar: isMobile ? () => BottomNav() : null,',
2499
+ ' leftNavBar: !isMobile ? () => Sidebar() : null, // NOT `sidebar:`',
1973
2500
  ' leftNavBarWidth: 240,',
1974
2501
  " fab: isMobile ? ActionButton({ icon: 'add', onPress: open }) : null,",
1975
2502
  ' body: isMobile ? null : HomeScreen(),',
@@ -2006,7 +2533,73 @@ export const agentsMd = (appName, template, version) =>
2006
2533
  'broken, prove it with a minimal repro and record the finding in memory** — do',
2007
2534
  'not work around it silently.',
2008
2535
  '',
2009
- '## 4. Verification loop (required)',
2536
+ '## 4. Design system — one scale, three colors',
2537
+ '',
2538
+ 'A screen is designed, not decorated. Every size and every color on this app',
2539
+ 'comes from the two lists below. A value outside them is a bug you have to',
2540
+ 'justify, and `padding: 13` or `#4f46e5` written by hand is the usual sign a',
2541
+ 'screen drifted away from the rest.',
2542
+ '',
2543
+ '**Color: three roles, no more.**',
2544
+ '',
2545
+ '| Role | Token | Where it may appear |',
2546
+ '| --- | --- | --- |',
2547
+ '| Brand | `colors.primary` | the one action the screen asks for: primary button, active tab, focus ring, links |',
2548
+ '| Emphasis | `colors.secondary` | a deliberate second voice: gradients, badges, a highlighted stat |',
2549
+ '| Feedback | `colors.success` / `colors.warning` / `colors.danger` / `colors.dangerStrong` | status only — never as decoration; the last one is the red you may write words in |',
2550
+ '',
2551
+ 'Everything else is neutral: `colors.background`, `colors.surface`,',
2552
+ '`colors.card`, `colors.text`, `colors.textSecondary`, `colors.textDisabled`,',
2553
+ '`colors.border`, `colors.divider`, plus the `gray50`…`gray900` ramp. Rules:',
2554
+ '',
2555
+ '- A screen uses **one** brand color. Two accent colors competing on one view',
2556
+ ' means one of them is wrong.',
2557
+ '- Never type a hex or `rgb()` literal. If a token is missing, add it to a',
2558
+ ' `theme.js` in this app that re-exports `colors` — do not scatter the value.',
2559
+ '- A frosted surface is `colors.glass` with `colors.glassBorder` plus a',
2560
+ ' `backdropFilter`. It reads through to whatever is behind it, so there',
2561
+ ' should be imagery or a gradient there, never body copy.',
2562
+ '- Text on a dark or brand fill is white, and it stays white: hierarchy comes',
2563
+ ' from size and weight, not from fading the ink to `rgba(255,255,255,.72)` —',
2564
+ ' faded white on `colors.primary` measures 3.16:1 and stops being body text.',
2565
+ '- `error` ≡ `danger`, and `*Bg` variants exist for alert fills. As **words**,',
2566
+ ' `colors.danger` is 3.76:1 on white and fails AA: write `colors.dangerStrong`',
2567
+ ' for the label of a delete or an inline error (6.47:1 on white, 5.42:1 inside',
2568
+ ' `colors.errorBg`), and keep `colors.danger` for the dot, the border, the icon.',
2569
+ ' Same reason `colors.warning` and `colors.success` are plates, never sentences.',
2570
+ ' Measure any pair before arguing about it: `npm run ui -- --tokens`.',
2571
+ '',
2572
+ '**Space and type: an 8-based scale.** Use these numbers and nothing between',
2573
+ 'them. They are plain props (`padding`, `gap`, `margin`, `width`, `height`,',
2574
+ '`size`, `radius`) — the factory takes the number, no unit and no `px` string.',
2575
+ '',
2576
+ '```',
2577
+ 'spacing 4 · hairline, icon inset',
2578
+ ' 8 · tight gaps, chip padding',
2579
+ ' 12 · control padding (input/button height − text)',
2580
+ ' 16 · default card padding, section gap, screen gutter',
2581
+ ' 18 · card-to-card rhythm on a dense grid',
2582
+ ' 24 · between sections, page gutter',
2583
+ ' 32 · between page blocks',
2584
+ ' 40 · the smallest tap target, a roomy control',
2585
+ ' 48 · hero spacing',
2586
+ ' 64 / 96 · a full-bleed section, a landing hero',
2587
+ 'radius 4 / 8 / 12 / 16 / 999 (pill)',
2588
+ 'type 12 caption · 14 body-small · 16 body · 18 subtitle · 24 title',
2589
+ ' 32 headline · 40 / 48 display (a hero, a big number)',
2590
+ 'weight 400 body · 500 label · 600 title · 700 headline',
2591
+ 'line-height 1.5 body · 1.2 heading',
2592
+ '```',
2593
+ '',
2594
+ '- Padding inside a card is `16`; the gap between cards is `16` or `24`. Never',
2595
+ ' mix the two on the same list.',
2596
+ '- One text size per element, one weight per role. If a label and its value are',
2597
+ ' the same size, the hierarchy is carried by color, not by a fourth size.',
2598
+ '- Tap targets are at least `40` on a side; get there with `height`, not padding.',
2599
+ '- `useState` bindings, `flex` and `gap` do the layout. Reach for an absolute',
2600
+ ' offset only for overlays, and use `stackPosition` rather than raw coordinates.',
2601
+ '',
2602
+ '## 5. Verification loop (required)',
2010
2603
  '',
2011
2604
  'After every edit, before you say a task is done:',
2012
2605
  '',
@@ -2014,14 +2607,29 @@ export const agentsMd = (appName, template, version) =>
2014
2607
  'npm run check # static gate: invented widgets/props, positional args,',
2015
2608
  ' # foreign imports, and broken imports between your own',
2016
2609
  ' # modules (a name a file never exports)',
2610
+ 'npm run ui # clean-UI gate: the tokens and the scale above, 40px',
2611
+ ' # targets, labels, contrast — every finding cites the',
2612
+ ' # rule number it broke',
2017
2613
  'npm run dev # serve on :8000 — check the UI actually renders',
2018
2614
  '```',
2019
2615
  '',
2020
- '`npm run check` exits non-zero on a real error. Fix the code, not the checker.',
2021
- 'It reads the installed package, so it is always in sync with what runs. A',
2616
+ 'Both gates read the installed package, so they are always in sync with what',
2617
+ 'runs. They exit non-zero on a real error: fix the code, not the checker. A',
2022
2618
  'warning is allowed to stay only when you can justify it in one sentence.',
2023
2619
  '',
2024
- '## 5. Memory — do not relearn the same thing',
2620
+ '`npm run ui` proves what is *written*; the rules it cites are the numbered ones',
2621
+ 'in [.claude/skills/fletbox-clean-ui/SKILL.md](.claude/skills/fletbox-clean-ui/SKILL.md),',
2622
+ 'and that file is the part a checker cannot do — hierarchy, copy, whether the',
2623
+ 'screen solves the problem. Read it before writing a screen, not after.',
2624
+ '',
2625
+ 'When the error is a mistyped name, `npm run fix` rewrites it for you — a',
2626
+ 'relative path that does not resolve, a name the target module never exports,',
2627
+ 'an identifier used without its `import`, and a prop key the API does not know',
2628
+ '(`chilldren` → `children`, `bgColr` → `bgColor`). It only rewrites when exactly',
2629
+ 'one real name is close enough; anything ambiguous is printed untouched for you',
2630
+ 'to decide. Run `npm run check` again after it, and read the diff.',
2631
+ '',
2632
+ '## 6. Memory — do not relearn the same thing',
2025
2633
  '',
2026
2634
  'At the start of every session read `.fletbox/memory/MEMORY.md` and any entry it',
2027
2635
  'points at. That is how this agent remembers the framework between chats instead',
@@ -2036,13 +2644,18 @@ export const agentsMd = (appName, template, version) =>
2036
2644
  ' to be wrong rather than appending a correction beside it.',
2037
2645
  '- Commit memory files with the code change they describe.',
2038
2646
  '',
2039
- '## 6. Commands',
2647
+ '## 7. Commands',
2040
2648
  '',
2041
2649
  '| Command | What it does |',
2042
2650
  '| --- | --- |',
2043
2651
  '| `npm run dev` | static dev server on port 8000 |',
2044
2652
  '| `npm run check` | anti-hallucination gate over `src/` |',
2653
+ '| `npm run fix` | rewrite mistyped imports and prop names it can prove |',
2045
2654
  '| `npm run check -- --props Button` | print the real props of a widget |',
2655
+ '| `flet-box unpack Button` | everything Button accepts **and that actually works** |',
2656
+ '| `flet-box unpack Button --example` | a paste-ready call using only live props |',
2657
+ '| `flet-box unpack Button --diff` | doc vs code gaps: dead, invented, undocumented |',
2658
+ '| `npm run api` | rebuild `.fletbox/api/api.json` + `api.md` for the whole surface |',
2046
2659
  '| `npm run build` | esbuild bundle into `dist/` |',
2047
2660
  '| `flet-box build android` / `ios` | Capacitor native build |',
2048
2661
  '| `flet-box run-native android` | compile and launch on device/emulator |',
@@ -2057,8 +2670,24 @@ export const claudeMd = () =>
2057
2670
  '# CLAUDE.md',
2058
2671
  '',
2059
2672
  'The rules for this project live in [AGENTS.md](./AGENTS.md). Read it first and',
2060
- 'follow it exactly — FletBox syntax only, real props only, `npm run check` after',
2061
- 'every edit.',
2673
+ 'follow it exactly — FletBox syntax only, real props only.',
2674
+ '',
2675
+ 'Two shortcuts replace guessing:',
2676
+ '',
2677
+ '- `flet-box unpack <Widget>` prints every prop the widget really honors, with',
2678
+ ' its type, default and engine target. `--example` gives a call you can paste,',
2679
+ ' `--diff` lists the props that are documented but dead or used but undeclared.',
2680
+ ' `.fletbox/api/api.json` is the same surface for all widgets, as data.',
2681
+ '- Design is not improvised. The rules are a file: read',
2682
+ ' [skills/fletbox-clean-ui/SKILL.md](skills/fletbox-clean-ui/SKILL.md) before',
2683
+ ' writing a screen. `npm run ui` proves the part a checker can prove (tokens,',
2684
+ ' the scale, 40px targets, labels, contrast) and cites the rule number it',
2685
+ ' broke; AGENTS.md §4 has the tables. Scale: 4 · 8 · 12 · 16 · 18 · 24 · 32 ·',
2686
+ ' 40 · 48 · 64 · 96 spacing, radii 4/8/12/16/999, type 12 · 14 · 16 · 18 · 24',
2687
+ ' · 32 · 40 · 48.',
2688
+ '',
2689
+ 'After every edit: `npm run check` (does it exist), `npm run ui` (is it clean),',
2690
+ 'then look at the screen — the checker has no eyes.',
2062
2691
  '',
2063
2692
  'Memory: read `.fletbox/memory/MEMORY.md` before working, and write new entries',
2064
2693
  'there when you learn something the next session should not rediscover.',
@@ -2079,9 +2708,18 @@ export const cursorRules = () =>
2079
2708
  '- Widgets are function calls returning an element: `Text({ text: "Hi", size: 18 })`.',
2080
2709
  ' No JSX, no positional args, no Flutter/Vue/Svelte/React syntax or imports.',
2081
2710
  '- Before using a widget or prop you have not just seen in',
2082
- ' `.fletbox/api/cheatsheet.md`, look it up: `npm run check -- --props <Widget>`.',
2083
- ' If it is not listed, it does not exist — do not invent it.',
2084
- '- Run `npm run check` after every change and fix what it reports.',
2711
+ ' `.fletbox/api/cheatsheet.md`, look it up: `flet-box unpack <Widget>` (or',
2712
+ ' `npm run check -- --props <Widget>`). If it is not listed, it does not exist —',
2713
+ ' do not invent it. `unpack --example` prints a call that is safe to paste.',
2714
+ '- Design rules: read [fletbox-clean-ui.mdc](./fletbox-clean-ui.mdc) in this',
2715
+ ' folder — the numbered clean-UI rules the agent is expected to follow. One',
2716
+ ' brand color per screen, taken from the `colors` tokens — never a hex literal.',
2717
+ ' Sizes come from the scale only: 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64',
2718
+ ' · 96 spacing, radii 4/8/12/16/999, type 12 · 14 · 16 · 18 · 24 · 32 · 40 · 48.',
2719
+ ' See AGENTS.md §4.',
2720
+ '- Run `npm run check` after every change (does this API exist) and `npm run ui`',
2721
+ ' (is this screen clean: tokens, scale, 40px targets, labels, contrast). Fix',
2722
+ ' what they report; a warning stays only with a one-sentence reason.',
2085
2723
  '- Check `.fletbox/memory/MEMORY.md` at session start; record new non-obvious',
2086
2724
  ' findings there instead of re-deriving them.',
2087
2725
  '',
@@ -2165,3 +2803,547 @@ export const checkAppScript = (appName) =>
2165
2803
  'process.exit(exitCode);',
2166
2804
  '',
2167
2805
  ].join('\n');
2806
+
2807
+ // ---------------------------------------------------------------------------
2808
+ // Clean-UI skill
2809
+ // ---------------------------------------------------------------------------
2810
+ // The design-system contract, merged from two sources and then measured against
2811
+ // this framework: Angelene Wallis, "No bullshit guide to UX" (2022), and "The UI/
2812
+ // UX Playbook" (uxpeak, 144 pages). Every number is either one the books state or
2813
+ // one read off the installed palette; every prop named here exists in the API the
2814
+ // app is generated with. The rules are numbered so `flet-box check-ui` can cite
2815
+ // them, and so the agent can quote one back when it breaks one on purpose.
2816
+ //
2817
+ // Two halves, always kept in agreement: what the checker proves statically, and
2818
+ // what only a person can see. The skill names both so the agent never claims the
2819
+ // checker covers more than it does.
2820
+ export const cleanUiSkill = () =>
2821
+ [
2822
+ "---",
2823
+ "name: fletbox-clean-ui",
2824
+ "description: Design-system contract for a FletBox app - tokens only, one scale, three-tier hierarchy, a state for every interaction, measured contrast, destructive actions designed last. Use when creating, editing or reviewing any screen, form, list, dialog or action control.",
2825
+ "---",
2826
+ "",
2827
+ "# Clean UI — the system rules",
2828
+ "",
2829
+ "You are not decorating screens. You are extending a system that every later",
2830
+ "screen will copy. A decision that cannot be restated as a rule is a defect, not",
2831
+ "a style.",
2832
+ "",
2833
+ "When two goals conflict, take them in this order:",
2834
+ "",
2835
+ "1 Consistency · 2 Clarity · 3 Accessibility · 4 Usability · 5 Scalability",
2836
+ "· 6 Maintainability · 7 Conversion",
2837
+ "",
2838
+ "Never optimize for aesthetics alone. Beauty is what is left when the system is",
2839
+ "correct; it is an outcome, never a target you can hit directly.",
2840
+ "",
2841
+ "## 00 The two gates",
2842
+ "",
2843
+ "```bash",
2844
+ "npm run check # the API is real: widgets, props, imports",
2845
+ "npm run ui # the screen follows these rules (codes map to rule numbers)",
2846
+ "npm run ui -- --tokens # measured contrast of every token, on both surfaces",
2847
+ "npm run dev # then look at it: the checker has no eyes",
2848
+ "```",
2849
+ "",
2850
+ "A finding you keep needs one sentence of justification, in the commit or in",
2851
+ "`.fletbox/memory/`. If that sentence is \"the design wants it\", the design is",
2852
+ "what needs writing down, because the next screen will copy it.",
2853
+ "",
2854
+ "Sources, cited as `NBG p.N` (No bullshit guide to UX) and `Playbook p.N`",
2855
+ "(The UI/UX Playbook; the PDF page — the printed folio is one lower). Every",
2856
+ "number below is either stated by one of them or measured on this palette.",
2857
+ "Every prop in every call below exists in `.fletbox/api/api.json`.",
2858
+ "",
2859
+ "## 01 System before component",
2860
+ "",
2861
+ "Define the token, then the component, then the screen. In that order, always.",
2862
+ "Components are outputs; the system is the source of truth.",
2863
+ "",
2864
+ "- A screen that needs a value the system does not have adds a token to",
2865
+ " `src/database/themes.js`. It does not write the value inline.",
2866
+ "- A component that needs a special exception: challenge the exception first, and",
2867
+ " if it survives, change its siblings too. Two components solving one problem",
2868
+ " differently get unified, not documented.",
2869
+ "- If you cannot say which rule a decision follows, do not make it.",
2870
+ "",
2871
+ "## 02 Consistency is sacred",
2872
+ "",
2873
+ "No arbitrary differences. If a person learns one pattern here, they must not",
2874
+ "relearn it anywhere else (Playbook p.038).",
2875
+ "",
2876
+ "- One button scheme for the whole app: the primary is `variant: \"filled\"`, the",
2877
+ " secondary `variant: \"outlined\"`, the tertiary `variant: \"text\"`. Never a",
2878
+ " per-card button color chosen to match the picture — people then wonder whether",
2879
+ " each button does something different (p.040-041).",
2880
+ " A filled `Button` defaults to `bgColor: colors.surface` with `colors.text` on",
2881
+ " it, so the primary action must name the brand pair: `bgColor: colors.primary`",
2882
+ " and `color: colors.onPrimary`. An outlined one must name its border (`color:`),",
2883
+ " or it inherits the surface it sits on and disappears.",
2884
+ "- One radius language per screen. A rounded picture next to a square button reads",
2885
+ " as two products (p.039).",
2886
+ "- One icon family, one weight, one size per screen. The one allowed mix is state:",
2887
+ " a selected tab filled, unselected tabs outlined (p.045-046).",
2888
+ "- Cards in the same row share a height. When the copy is uncontrollable, fix the",
2889
+ " height and bottom-align the action; the empty space goes in the middle",
2890
+ " (p.043-044).",
2891
+ "- Same purpose, same shape: identical inputs get the same `height`,",
2892
+ " `borderRadius`, states and validation behavior (rule 13).",
2893
+ "- Images inside a repeated card sit in the same slot, same size, same crop",
2894
+ " (`fit`, `shape`), whatever the picture is (p.042).",
2895
+ "",
2896
+ "## 03 Design tokens only",
2897
+ "",
2898
+ "No arbitrary values, no hex, no `px` strings. Every value comes from the scale.",
2899
+ "",
2900
+ "```",
2901
+ "space 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96",
2902
+ "radius 4 · 8 · 12 · 16 · 999 (pill)",
2903
+ "type 12 caption · 14 body-small · 16 body · 18 subtitle · 24 title",
2904
+ " 32 headline · 40 display-sm · 48 display",
2905
+ "weight 400 body · 500 label · 600 title · 700 headline",
2906
+ "line 1.5-1.6 body · 1.2-1.3 heading",
2907
+ "depth elevation 0 · 1 · 2 · 3 · 4 · 5",
2908
+ "```",
2909
+ "",
2910
+ "- The base unit is 4 and everything is a multiple of it (Playbook p.028). A value",
2911
+ " like 13, 19, 21 or 31 is not a design decision, it is drift.",
2912
+ "- Never type a hex or `rgb()` outside `src/database/themes.js`",
2913
+ " (`RAW_COLOR`). If a token is missing, add it there.",
2914
+ "- Never build a color by appending alpha to a token — `colors.primary + \"15\"` is",
2915
+ " a one-off shade the rest of the app cannot reuse (`HAND_MIXED_COLOR`). The",
2916
+ " tints exist: `colors.primaryBg`, `colors.errorBg`, `colors.successBg`,",
2917
+ " `colors.warningBg`, `colors.infoBg`.",
2918
+ "- A frosted surface is `colors.glass` with `colors.glassBorder` and a",
2919
+ " `backdropFilter`; it is translucent on purpose, so put it over imagery,",
2920
+ " not over body text. A hand-written `rgba()` is a `RAW_COLOR` like any other.",
2921
+ "- Text on a brand fill is `colors.onPrimary`, never a literal white.",
2922
+ "- Tap targets are at least 40 on a side (`SMALL_TAP_TARGET`). Reach it with",
2923
+ " `height`, not with padding. The books argue this qualitatively (Fitts, p.099);",
2924
+ " 40 is the floor this system enforces and 44 is what a phone primary action",
2925
+ " gets.",
2926
+ "- `size: \"small\"` on a `Button` is for dense secondary actions, never for the one",
2927
+ " thing a phone screen asks for.",
2928
+ "",
2929
+ "## 04 Hierarchy must be obvious in three seconds",
2930
+ "",
2931
+ "A person must answer three questions without reading: what is this, what",
2932
+ "matters, what happens next. There are exactly four levers — size, color, weight,",
2933
+ "position (Playbook p.007). If everything is emphasized, nothing is.",
2934
+ "",
2935
+ "- One primary action per screen. One secondary. Everything else is tertiary.",
2936
+ " Never two filled buttons on one view (`MULTIPLE_PRIMARY_ACTIONS`).",
2937
+ "- Actions rank by emphasis, information ranks by size: the secondary button keeps",
2938
+ " the same `height` as the primary and loses color (p.008); the data gets the",
2939
+ " size ladder (p.088). That is how p.008 and p.088 stop contradicting each other.",
2940
+ "- In a stat card the value is the largest and darkest text and the label is",
2941
+ " smaller and muted. Never the inverse (p.010).",
2942
+ "- Darkness means importance: heading darker than body, body muted. Never let a",
2943
+ " thumbnail outsize the price (p.009, p.011).",
2944
+ "- Never render a detail panel as uniform label:value rows — it has no hierarchy",
2945
+ " and cannot be scanned. Promote the one value that matters, demote the rest,",
2946
+ " add an icon per row (p.012).",
2947
+ "- Before building, list the elements and rank them by user importance; then map",
2948
+ " size, color, weight and position onto that ranking (p.010).",
2949
+ "",
2950
+ "## 05 One purpose per screen",
2951
+ "",
2952
+ "Write the sentence first: \"this screen lets <who> do <what>\". If it needs an",
2953
+ "\"and\", split the screen (NBG pp.40-43).",
2954
+ "",
2955
+ "- Conflicting goals reduce usability. A task screen carries nothing that does not",
2956
+ " serve the task: no promo panel, no second call to action (Playbook p.127).",
2957
+ "- Decorative imagery next to a form is allowed only when it is action-free — no",
2958
+ " text overlay, no button — and it still has to be about this product, not merely",
2959
+ " pretty (p.128).",
2960
+ "- Navigation exposes what the job needs and nothing else. Every extra link is a",
2961
+ " decision taken before the user made theirs.",
2962
+ "",
2963
+ "## 06 Reduce cognitive load",
2964
+ "",
2965
+ "People recognize; they must never memorize, guess or interpret.",
2966
+ "",
2967
+ "- Chunk to about four items per group and cap a visible group at 7; past that,",
2968
+ " truncate and expose one action — \"Show all 21 amenities\" (Playbook p.101).",
2969
+ " The 7±2 figure is a myth in its loosest form; plan on four and the group still",
2970
+ " holds when someone adds a fifth.",
2971
+ "- Recognition over recall: common icons, common words, state visible on screen",
2972
+ " rather than in memory (p.104). \"Continue watching\" beats \"remember where you",
2973
+ " stopped\".",
2974
+ "- Do not hide choices behind a control that must be opened to be seen: variants",
2975
+ " as swatches, quantity as a stepper, not a nested menu (p.107-108).",
2976
+ "- Never oversimplify into vagueness either: a switch with an icon and no words is",
2977
+ " not clean, it is unreadable (p.014).",
2978
+ "- If no user has shown they need a feature, remove it rather than park it behind",
2979
+ " a toggle (NBG p.136).",
2980
+ "",
2981
+ "## 07 Spacing communicates the relationship",
2982
+ "",
2983
+ "Whitespace is functional, not decorative. Related things sit closer than",
2984
+ "unrelated things, and that difference is the whole message (Playbook p.013, p.029).",
2985
+ "",
2986
+ "- Field pattern: label to its own input `12`, that field to the next label `32`.",
2987
+ " Equal spacing at both levels makes label ownership ambiguous (p.013).",
2988
+ "- Card padding is `16`; the rhythm between cards is `16` or `24`; sections",
2989
+ " separate at `24` or `32`; page blocks at `48`. Never mix two rhythms in one list.",
2990
+ "- Start a layout with more room than you think you need and tighten it; clutter",
2991
+ " is easier to see than cramp is to undo (p.025-027).",
2992
+ "- Do not stretch a form or a checkout to the viewport. 1440 is a canvas size, not",
2993
+ " a target; give the card the width it needs and let the margin carry the rest",
2994
+ " (p.129-130). `Container({ maxWidth: 480 })` around a form, `fullWidth: true`",
2995
+ " only on the button inside it.",
2996
+ "- Layout is `useState` bindings, `flex`, `gap` and `wrap`. An absolute offset is",
2997
+ " for overlays, and then through `stackPosition`, not raw coordinates.",
2998
+ "",
2999
+ "## 08 Typography is structure",
3000
+ "",
3001
+ "- Body is `size: 16` (Playbook p.090). Nothing that must be read goes below 12;",
3002
+ " the book contradicts itself between 12 (p.090) and 10 (p.096) — 12 is the floor",
3003
+ " here.",
3004
+ "- Heading: bigger, heavier, darker, tighter. Body: smaller, regular, muted,",
3005
+ " looser. Never the same weight for both, never extra-light for body (p.084-087).",
3006
+ "- Line-height: 1.5-1.6 on body, 1.2-1.3 on headings; a 32px heading sits at about",
3007
+ " 1.25 (p.091-092). A heading never gets more air than a paragraph.",
3008
+ "- Measure: 45-75 characters per line on desktop, 30-40 on mobile. A paragraph",
3009
+ " that runs the full width is unreadable at 1440 (p.076-078).",
3010
+ "- One typeface; two at the absolute maximum, and then headings against body",
3011
+ " (p.075-076). Declare it once in the theme, never per `Text`.",
3012
+ "- `align: \"left\"` for every run of text and every list. `right` for numbers and",
3013
+ " prices, so magnitudes compare. `center` only for a short title or a lone CTA,",
3014
+ " never for a paragraph (p.018, p.094).",
3015
+ "- Long copy becomes scannable copy: a short lead, a heading, then one item per",
3016
+ " feature with an icon, a bold name and two lines of body; four or more items go",
3017
+ " in a two-column grid (p.123-125).",
3018
+ "",
3019
+ "## 09 Color carries meaning",
3020
+ "",
3021
+ "- One brand color per screen, and it is the color of the one action the screen",
3022
+ " asks for. A second accent on the same view means one of them is wrong.",
3023
+ "- Reserve the brand for things you can click. Headers, labels and icons stay",
3024
+ " neutral — brand color on a decorative surface teaches people that color means",
3025
+ " nothing (Playbook p.066-067).",
3026
+ "- Backgrounds are neutral (`colors.background`, `colors.surface`, `colors.card`);",
3027
+ " color belongs in the action, not the wall (p.067-068). A dark theme is equally",
3028
+ " valid; offer both, because dark mode is expected, not a bonus (p.070).",
3029
+ "- Feedback colors are fixed and never redefined: `colors.success`,",
3030
+ " `colors.warning`, `colors.danger`, `colors.info`. Red is error, green is",
3031
+ " success, amber is warning; never pink for success, never green for delete.",
3032
+ "- The signal red and the readable red are different tokens. `colors.danger`",
3033
+ " (#ef4444) is for a badge, a border, an icon: as words on a white page it",
3034
+ " measures 3.76:1 and that is not AA. `colors.dangerStrong` (#b91c1c) is the red",
3035
+ " you may read and press — 6.47:1 as words on white, 6.47:1 with white written on",
3036
+ " it, 5.42:1 inside `colors.errorBg`. Same for the others: `colors.warning`",
3037
+ " (2.15:1 with white on it) and `colors.success` (2.54:1) are plates and dots,",
3038
+ " never a sentence — `colors.warningStrong` (5.02:1) and `colors.successStrong`",
3039
+ " (5.48:1) are the readable ones, in either direction. Measure any pair you are",
3040
+ " about to ship: `npm run ui -- --tokens`.",
3041
+ "- Never carry meaning in color alone — a color-blind person and a phone in",
3042
+ " sunlight both lose it. Pair the color with an icon and a word (p.070): a status",
3043
+ " chip is `Badge` plus text, a field error is `error` plus `showValidationMessage`.",
3044
+ "- Hierarchy inside a fill comes from opacity, not from a second hue.",
3045
+ "",
3046
+ "## 10 Depth is a scale",
3047
+ "",
3048
+ "- `elevation: 0-5` is the shadow scale the framework ships (`SHADOW_LEVELS`), and",
3049
+ " these widgets read it: `Accordion`, `AppBar`, `BottomNavigation`, `Button`,",
3050
+ " `Card`, `Chip`, `Drawer`, `FloatingActionButton`, `ListTile`, `Modal`, `SnackBar`.",
3051
+ " Elsewhere — `Container` and `ListView` included — the number reaches",
3052
+ " `style.boxShadow` as a bare number, CSS drops it, and the box stays flat",
3053
+ " without saying so: pass a `shadow: \"0 12px 32px rgba(15,23,42,0.12)\"` string",
3054
+ " there instead (`DEAD_ELEVATION`).",
3055
+ "- Anything that floats gets depth: a dropdown, a menu, a modal, a card on a map.",
3056
+ " A shadowless layer merges with the page and the user cannot tell what is on top",
3057
+ " (p.050-051).",
3058
+ "- Soft, not harsh: depth comes from a wide blur at low opacity, and the shadow",
3059
+ " is tinted toward the background hue, never pure grey or black (p.052-054).",
3060
+ "- Do not put a white card on a white page — the interface goes flat. Page",
3061
+ " `colors.background`, card `colors.card` (p.056-057).",
3062
+ "- Borders are thin and rare. Separation comes from space and surface, not from a",
3063
+ " rule under every row; a bordered list of everything reads as busy and dated",
3064
+ " (p.059-061).",
3065
+ "- Glass and blur are a brand decision, and they never survive a light background:",
3066
+ " text on frosted glass over white loses the contrast war (p.063-065).",
3067
+ "",
3068
+ "## 11 Accessibility is a floor, not a feature",
3069
+ "",
3070
+ "When accessibility and aesthetics conflict, accessibility wins. Every time.",
3071
+ "",
3072
+ "- Text needs 4.5:1 against what it sits on; large text (24px, or 18px bold) and",
3073
+ " meaningful graphics need 3:1. Disabled text is exempt. The book agrees: its",
3074
+ " passing examples measure 4.8-6.54:1 and its failures 1.81-2.84:1 (p.021), and",
3075
+ " #626262 is the lightest grey that survives on white (p.086).",
3076
+ "- Measure instead of arguing: `npm run ui -- --tokens`. On this palette `text` is",
3077
+ " 17.85:1, `textSecondary` 4.76:1, `textDisabled` 2.56:1 (decoration only), and",
3078
+ " the brand pair `onPrimary` on `primary` is 4.55:1 — which is why `primary` may",
3079
+ " carry a button label and not a paragraph. By hand:",
3080
+ " `color(\"#0f172a\").contrastRatio(\"#ffffff\")` answers 17.85.",
3081
+ "- Text over a photograph needs a scrim: a dark overlay or a blurred plate under",
3082
+ " the words. Light text over one dark photo proves nothing about the next photo",
3083
+ " (p.022).",
3084
+ "- Every field has a visible `label`; a placeholder is a format hint, never a",
3085
+ " substitute (p.115). Every switch and checkbox has one too (`UNLABELED_TOGGLE`).",
3086
+ "- Every control that is only an icon gets a name: wrap it in",
3087
+ " `Tooltip({ text, child })` or give it a visible label (`ICON_ONLY_CONTROL`).",
3088
+ "- Every `Image` that carries information has `alt`; `alt: \"\"` is the correct",
3089
+ " answer for a decoration (`IMAGE_NO_ALT`).",
3090
+ "- Focus must be visible, keyboard must reach everything, and `Esc` must close",
3091
+ " whatever opened (`Modal({ closeOnEsc: true, showCloseButton: true })`).",
3092
+ "- Motion is optional content: respect a reduced-motion preference; never",
3093
+ " auto-play a carousel or a banner.",
3094
+ "",
3095
+ "## 12 Every interaction has four states",
3096
+ "",
3097
+ "A control that handles only the happy path is unfinished (NBG pp.94-97). Write",
3098
+ "all four, in the same component, before the screen counts as done.",
3099
+ "",
3100
+ "```js",
3101
+ "Column({ gap: 16, children: [",
3102
+ " loading ? Skeleton({ width: \"100%\", height: 48, count: 3 }) : null,",
3103
+ " !loading && rows.length === 0",
3104
+ " ? EmptyState() // headline + illustration + tip + CTA",
3105
+ " : null,",
3106
+ " failed",
3107
+ " ? SnackBar({ message: \"Could not load the list.\", action: \"Retry\", onAction: retry })",
3108
+ " : null,",
3109
+ " !loading && rows.length > 0 ? ListView({ children: rows }) : null,",
3110
+ "]})",
3111
+ "```",
3112
+ "",
3113
+ "- Empty has four parts: a solution-oriented headline, an illustration, two",
3114
+ " concrete tips, one primary action. \"You have no projects\" is a dead end, not a",
3115
+ " state (Playbook p.135-136).",
3116
+ "- Loading appears before the wait, not after it: a `Skeleton` at once beats a",
3117
+ " spinner two seconds later (NBG p.134, Doherty).",
3118
+ "- Error does exactly three things: says what went wrong in the user's words,",
3119
+ " gives the way back, keeps the visual style of the app. Show the code, keep the",
3120
+ " navigation, offer links (p.137-138). Never an error code alone, never a shrug.",
3121
+ "- Success confirms in place — a `SnackBar`, not a new screen — and offers the",
3122
+ " next step while it is there.",
3123
+ "- Progress is honest. Show real progress or show a `Skeleton`; a bar that lies",
3124
+ " buys one visit and costs the next one (NBG defends inflating them; do not).",
3125
+ "",
3126
+ "## 13 Forms: prevent the error, do not report it",
3127
+ "",
3128
+ "- Ask for 3 to 7 things at once. More than that is more than one step, and a step",
3129
+ " the user can see: `Stepper({ steps, activeStep, onFinish })` (NBG pp.40-43).",
3130
+ "- Width fits the data: CVC is 3 or 4 characters, so the field is short; card",
3131
+ " number and name run full width; pair Expiry with CVC on one row (p.115-116).",
3132
+ "- A column of three or more fields lives inside a cap — `Container({ maxWidth:",
3133
+ " 480 })`. A field as wide as the window answers \"type anything\" and the eye",
3134
+ " loses the next line (`UNCONSTRAINED_FORM_WIDTH`).",
3135
+ "- A fixed-length code gets one box per character in enlarged type, not one long",
3136
+ " field (p.114).",
3137
+ "- `Input({ label, placeholder, required, maxLength, validation, type })`:",
3138
+ " placeholder carries the format (\"MM/YY\", \"12345\"), `maxLength` and `type`",
3139
+ " constrain the input, and validation runs inline as they type, not on submit",
3140
+ " (p.119).",
3141
+ "- No \"Confirm password\" field. It adds cost, repeats the same typo twice, and",
3142
+ " buys a false sense of safety. One field, `passwordToggle: true`, and a strength",
3143
+ " line under it (p.117-119).",
3144
+ "- Good defaults and autofill remove keystrokes: prefill the country, the saved",
3145
+ " address, the last card (p.105).",
3146
+ "- Required fields are marked, and the label of the submit button names the",
3147
+ " outcome — \"Pay $65\", not \"Submit\" (p.115).",
3148
+ "- Never clear a form because one field failed. Keep what they typed.",
3149
+ "",
3150
+ "## 14 Destructive actions are designed last, and carefully",
3151
+ "",
3152
+ "- The button carries the verb: \"Delete 3 files\", never \"OK\" or \"Yes\"",
3153
+ " (`GENERIC_ACTION_LABEL`) (Playbook p.121).",
3154
+ "- Destructive is red — `colors.dangerStrong` for the words, `colors.danger` for",
3155
+ " the dot beside them — because green reads safe and a person presses it twice",
3156
+ " (`DESTRUCTIVE_COLOR`) (p.120). `colors.danger` alone is 3.76:1 as a label.",
3157
+ "- Order in a two-button row: Cancel on the left, destructive on the right. Left",
3158
+ " is what a reading eye hits first (`CONFIRM_ORDER`) (p.121-122).",
3159
+ "- The dialog states reversibility: \"This action cannot be undone.\" Say it even",
3160
+ " when it can, and then offer the way back.",
3161
+ "- `AlertDialog({ title, message, confirmText, cancelText, showCancel, onCancel, onConfirm, onClose })`",
3162
+ " always leaves an escape: a Cancel, and an X on any layer that can be opened.",
3163
+ "- Undo is cheaper than a warning. If the action is reversible, let people undo it",
3164
+ " instead of interrupting them to confirm it (NBG pp.116-118).",
3165
+ "- On a phone, a destructive action is not in the thumb zone; the thing you do",
3166
+ " often is (p.133-135).",
3167
+ "",
3168
+ "## 15 Reduce interaction cost",
3169
+ "",
3170
+ "Interaction cost is cognitive effort + physical effort + time (Playbook p.098).",
3171
+ "Count it, then cut it: reading, scrolling, searching, deciding, clicking, typing,",
3172
+ "waiting, remembering (p.099).",
3173
+ "",
3174
+ "- Count the steps in the main flow and delete every one that does not change the",
3175
+ " outcome (p.105). Three is a target; one is better.",
3176
+ "- Put the action next to the thing it acts on: the CTA beside the quantity",
3177
+ " stepper, the row action inside the row (p.108, Fitts).",
3178
+ "- Keep the control that runs all the time in a fixed, reachable place — a player",
3179
+ " bar at the bottom, a primary tab at the thumb (p.100, p.133-135).",
3180
+ "- Show the content instead of a door to the content: three real cards beat a",
3181
+ " banner that says \"Explore now\" (p.111-113).",
3182
+ "- One click where a confirmation is not needed. Two where it is.",
3183
+ "",
3184
+ "## 16 Respect the platform",
3185
+ "",
3186
+ "- Use the words, icons and gestures people already brought with them (NBG p.131,",
3187
+ " Jakob's law). An invented gesture is a bug report waiting to be written.",
3188
+ "- iOS, Android and web each have conventions; meet them instead of overriding",
3189
+ " them with a custom pattern. A back that lands where the user expects, a sheet",
3190
+ " that dismisses down, a form that advances on Enter.",
3191
+ "- Break a convention only when the measurement says the convention is wrong here,",
3192
+ " and write that measurement down.",
3193
+ "",
3194
+ "## 17 The review",
3195
+ "",
3196
+ "When asked to review a screen, answer in this order, and say why every time:",
3197
+ "",
3198
+ "1 Goal — what the screen is for, and whether it is one goal.",
3199
+ "2 UX problems — cost, states, recall, flow.",
3200
+ "3 UI problems — hierarchy, spacing, type, color, depth.",
3201
+ "4 System violations — the values that are not tokens, the component that is an",
3202
+ " exception.",
3203
+ "5 Accessibility violations — measured ratios, targets, labels, focus.",
3204
+ "6 Consistency violations — what this screen does differently for no reason.",
3205
+ "7 Scalability risk — what breaks when this screen has eleven siblings.",
3206
+ "8 Fix — the call, not the mood. Then the rule it establishes.",
3207
+ "9 Score — see below.",
3208
+ "",
3209
+ "Score the seven axes 0-100, but only against evidence, never against taste:",
3210
+ "an axis starts at 100 and loses 10 for every open finding of its own codes, so",
3211
+ "the number is a count and not an opinion. Below 85 on any axis means the fix is",
3212
+ "a rule change, not a pixel move. An axis with no findings and no evidence is",
3213
+ "unmeasured, not perfect — say so.",
3214
+ "",
3215
+ "| Axis | Its codes (from `npm run ui`) |",
3216
+ "| --- | --- |",
3217
+ "| Consistency | OFF_SCALE_SPACING · OFF_SCALE_RADIUS · OFF_SCALE_TYPE · RAW_COLOR · HAND_MIXED_COLOR · DEAD_ELEVATION |",
3218
+ "| Accessibility | LOW_CONTRAST · LOW_CONTRAST_UI · SMALL_TAP_TARGET · UNLABELED_FIELD · UNLABELED_TOGGLE · ICON_ONLY_CONTROL · IMAGE_NO_ALT |",
3219
+ "| Usability | CROWDED_FORM · LIST_WITHOUT_STATE · UNCONSTRAINED_FORM_WIDTH |",
3220
+ "| Hierarchy | MULTIPLE_PRIMARY_ACTIONS · CENTERED_BODY_TEXT · GENERIC_ACTION_LABEL · DESTRUCTIVE_COLOR · CONFIRM_ORDER |",
3221
+ "| Scalability | every token that had to be added to make the screen pass |",
3222
+ "| Conversion | the label of the primary action, counted in words and in steps |",
3223
+ "| Visual quality | measured only by the axes above; state what you looked at |",
3224
+ "",
3225
+ "## 18 Where these books are wrong",
3226
+ "",
3227
+ "Both are worth more read than copied. The parts that do not survive measurement:",
3228
+ "",
3229
+ "- The Playbook bans pure black on white and pure white on dark (p.083). This",
3230
+ " system puts white text on deep brand fills, and the ratio is what decides —",
3231
+ " 4.55:1 there, not the rule of thumb.",
3232
+ "- p.008 says a secondary button keeps the primary's size; p.088 says size is",
3233
+ " the hierarchy lever. Both are right about different things: emphasis ranks",
3234
+ " actions, size ranks information.",
3235
+ "- p.079 marks center-aligned body as good; p.094 says center is for titles only.",
3236
+ " The later rule is the readable one.",
3237
+ "- p.103 bans pop-ups as distractions; p.120 requires one to confirm a deletion.",
3238
+ " Reconciled: never unsolicited, always on destructive.",
3239
+ "- p.019 (gutter 70, margin 79, overlay 17%) and p.052-054 (Y:12, blur:48,",
3240
+ " #E9E9E9) are one designer's Figma fields in a screenshot, not a scale.",
3241
+ " Do not harvest them as thresholds.",
3242
+ "- The Playbook's own sign-up example (p.127) is white on a pale blue fill that",
3243
+ " fails its own contrast chapter. Copy the rule, never the screenshot.",
3244
+ "- \"7 ± 2\" (p.101) is Miller's number quoted loosely; chunk size is a design",
3245
+ " choice, and four is the one that survives a fifth item.",
3246
+ "- NBG defends inflating progress bars (its \"little lie\"). Do not.",
3247
+ "- Neither book states a tap-target size in px. Fitts gives the direction; 40 is",
3248
+ " this system's floor, and the number to defend if someone asks.",
3249
+ "",
3250
+ "## 19 What no checker can see",
3251
+ "",
3252
+ "`npm run ui` cannot tell you whether the screen is understood. For that:",
3253
+ "",
3254
+ "- Ask one person to do one task and stay quiet while they do it. Their first three",
3255
+ " sentences are the specification.",
3256
+ "- Watch where the pointer hesitates and where it misclicks: that is where the",
3257
+ " layout is lying about what is tappable.",
3258
+ "- Five people find most of what is broken. Two rounds of five beat one round of",
3259
+ " ten.",
3260
+ "- Write what you saw into `.fletbox/memory/`, or the next session designs this",
3261
+ " screen again from taste.",
3262
+ "",
3263
+ "## 20 The loop",
3264
+ "",
3265
+ "```bash",
3266
+ "npm run check",
3267
+ "npm run ui",
3268
+ "npm run dev # look at the screen, at 375px and at 1440px",
3269
+ "```",
3270
+ "",
3271
+ "Fix the code, not the checker. Then make the rule reusable: if the fix was right",
3272
+ "for this screen, it is a rule for every screen, and it belongs in this file.",
3273
+ "",
3274
+ ].join('\n');
3275
+
3276
+ // Cursor has no skills folder, so the same rules ship as one always-apply rule
3277
+ // that loads the skill file instead of duplicating it.
3278
+ export const cleanUiCursorRule = () =>
3279
+ [
3280
+ '---',
3281
+ 'description: Clean-UI rules for every screen - tokens, the 8-based scale, tap targets, states',
3282
+ 'globs: "src/**/*.js"',
3283
+ 'alwaysApply: true',
3284
+ '---',
3285
+ '',
3286
+ 'Design a screen, do not decorate one. The full rules are in',
3287
+ '[.claude/skills/fletbox-clean-ui/SKILL.md](../../.claude/skills/fletbox-clean-ui/SKILL.md)',
3288
+ '- read it before writing a screen, form, list or dialog.',
3289
+ '',
3290
+ '- One screen, one job, one primary action; secondaries are `Button({ variant: "text" })`.',
3291
+ '- Colors come from `colors.*` only. Never a hex literal outside `src/database/themes.js`.',
3292
+ ' `colors.onPrimary` writes on a brand fill; `colors.primaryBg` is the brand tint.',
3293
+ '- Sizes come from the scale: 4 - 8 - 12 - 16 - 18 - 24 - 32 - 48 space, 4 / 8 / 12 / 16 /',
3294
+ ' 999 radius, 12 / 14 / 16 / 18 / 24 / 32 type.',
3295
+ '- Anything pressable is at least 40px on a side, and 44 is the number to aim at.',
3296
+ '- Every interaction shows loading, done, failed and empty; errors say the problem and',
3297
+ ' the way out in the user\'s own words.',
3298
+ '- Verify with `npm run check` then `npm run ui` (add `-- --tokens` to read the measured',
3299
+ ' contrast table), and then look at the screen - the checker has no eyes.',
3300
+ '',
3301
+ ].join('\n');
3302
+
3303
+ /**
3304
+ * The app-side twin of scripts/check-app.mjs for the clean-UI gate. Same
3305
+ * resolution dance, because the package exports map only publishes "." and
3306
+ * "./package.json".
3307
+ *
3308
+ * @param {string} appName
3309
+ * @returns {string}
3310
+ */
3311
+ export const checkUiScript = (appName) =>
3312
+ [
3313
+ '// scripts/check-ui.mjs',
3314
+ `// Clean-UI gate for ${appName}: tokens, the 8-based scale, tap targets, field`,
3315
+ '// labels and measured WCAG contrast, checked by the installed flet-box.',
3316
+ '//',
3317
+ '// npm run ui scan src/ and fail on a real finding',
3318
+ '// npm run ui -- --tokens print the measured contrast of every token',
3319
+ '// npm run ui -- --strict contrast warnings fail the run too',
3320
+ '// npm run ui -- --json machine-readable findings',
3321
+ '',
3322
+ "import path from 'node:path';",
3323
+ "import { createRequire } from 'node:module';",
3324
+ "import { pathToFileURL } from 'node:url';",
3325
+ '',
3326
+ "const require = createRequire(import.meta.url);",
3327
+ '',
3328
+ 'function resolvePackage() {',
3329
+ " const candidates = [path.resolve(process.cwd(), 'node_modules/flet-box')];",
3330
+ ' try {',
3331
+ " candidates.push(path.dirname(require.resolve('flet-box/package.json')));",
3332
+ ' } catch {',
3333
+ ' /* not installed yet */',
3334
+ ' }',
3335
+ " const fs = require('fs');",
3336
+ ' for (const dir of candidates) {',
3337
+ " if (fs.existsSync(path.join(dir, 'bin/commands/checkUi.js'))) return dir;",
3338
+ ' }',
3339
+ " console.error('flet-box package not found. Run: npm install');",
3340
+ ' process.exit(1);',
3341
+ '}',
3342
+ '',
3343
+ "const checker = path.join(resolvePackage(), 'bin', 'commands', 'checkUi.js');",
3344
+ "const { checkUi } = await import(pathToFileURL(checker).href);",
3345
+ '',
3346
+ "const { exitCode } = await checkUi(process.argv.slice(2));",
3347
+ 'process.exit(exitCode);',
3348
+ '',
3349
+ ].join('\n');