flet-box 1.0.7 → 1.0.9
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.
- package/README.md +3 -0
- package/bin/cli.js +13 -0
- package/bin/commands/checkApi.js +13 -1
- package/bin/commands/checkUi.js +1369 -0
- package/bin/commands/create.js +49 -5
- package/bin/commands/createBundle.js +21 -0
- package/bin/commands/runServer.js +11 -2
- package/bin/utils/pwaShell.js +137 -0
- package/bin/utils/templates.js +1398 -443
- package/docs/README.md +4 -0
- package/docs/cli/README.md +63 -2
- package/docs/guides/README.md +2 -0
- package/docs/guides/clean-ui.md +211 -0
- package/docs/guides/minification-and-protection.md +1 -1
- package/docs/guides/mobile-and-platforms.md +31 -0
- package/docs/guides/online-updates.md +48 -0
- package/docs/tools/README.md +1 -1
- package/docs/tools/animation.md +1 -1
- package/docs/tools/markdown.md +3 -3
- package/docs/tools/theme.md +42 -1
- package/docs/widget/Accordion.md +3 -0
- package/docs/widget/AdMob.md +4 -1
- package/docs/widget/AdSense.md +3 -0
- package/docs/widget/AnimatedBox.md +6 -3
- package/docs/widget/AnimatedText.md +3 -0
- package/docs/widget/Audio.md +3 -0
- package/docs/widget/Avatar.md +3 -0
- package/docs/widget/Badge.md +3 -0
- package/docs/widget/Button.md +3 -0
- package/docs/widget/COMMON_PROPS.md +52 -0
- package/docs/widget/Card.md +3 -0
- package/docs/widget/Carousel.md +3 -0
- package/docs/widget/Chart.md +3 -0
- package/docs/widget/Checkbox.md +3 -0
- package/docs/widget/Chip.md +3 -0
- package/docs/widget/CircularBar.md +3 -0
- package/docs/widget/CircularChart.md +3 -0
- package/docs/widget/CodeViewer.md +3 -0
- package/docs/widget/Column.md +3 -0
- package/docs/widget/Container.md +3 -0
- package/docs/widget/DataTable.md +3 -0
- package/docs/widget/Divider.md +3 -0
- package/docs/widget/DraggBox.md +3 -0
- package/docs/widget/Dropdown.md +3 -0
- package/docs/widget/DroppBox.md +3 -0
- package/docs/widget/FloatingActionButton.md +3 -0
- package/docs/widget/GridView.md +3 -0
- package/docs/widget/Icon.md +3 -0
- package/docs/widget/Image.md +3 -0
- package/docs/widget/Input.md +3 -0
- package/docs/widget/Inspector.md +2 -2
- package/docs/widget/InstallButton.md +3 -0
- package/docs/widget/ListTile.md +3 -0
- package/docs/widget/ListView.md +3 -0
- package/docs/widget/Maps.md +3 -0
- package/docs/widget/Markdown.md +3 -0
- package/docs/widget/MatrixRain.md +3 -0
- package/docs/widget/Pagination.md +3 -0
- package/docs/widget/ParallaxBox.md +3 -0
- package/docs/widget/ProgressBar.md +3 -0
- package/docs/widget/QRCode.md +3 -0
- package/docs/widget/README.md +2 -0
- package/docs/widget/Radio.md +4 -1
- package/docs/widget/Rating.md +3 -0
- package/docs/widget/Row.md +3 -0
- package/docs/widget/Skeleton.md +3 -0
- package/docs/widget/Slider.md +3 -0
- package/docs/widget/Stack.md +3 -0
- package/docs/widget/Stepper.md +3 -0
- package/docs/widget/Switch.md +3 -0
- package/docs/widget/Tabs.md +5 -5
- package/docs/widget/Text.md +3 -0
- package/docs/widget/Tooltip.md +3 -0
- package/docs/widget/TreeView.md +3 -0
- package/docs/widget/Video.md +3 -0
- package/package.json +2 -2
- package/src/animations/AnimatedBox.js +4 -1
- package/src/animations/MatrixRain.js +3 -3
- package/src/index.d.ts +64 -8
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +7 -12
- package/src/navigations/BottomNavigation.js +8 -13
- package/src/navigations/CollapsibleSideBar.js +30 -8
- package/src/navigations/Drawer.js +16 -14
- package/src/navigations/DrawerItem.js +3 -1
- package/src/navigations/Scaffold.js +26 -12
- package/src/navigations/Tabs.js +104 -59
- package/src/tools/color.js +54 -0
- package/src/tools/index.js +9 -1
- package/src/tools/scale.js +41 -0
- package/src/tools/shadow.js +20 -0
- package/src/utils/index.js +1 -0
- package/src/utils/readableInk.js +19 -0
- package/src/utils/syntaxHighlight.js +78 -10
- package/src/utils/themes.js +265 -58
- package/src/utils/units.js +33 -11
- package/src/widget-factory/effects.js +56 -5
- package/src/widget-factory/processProps.js +10 -1
- package/src/widget-factory/remTool.js +15 -29
- package/src/widget-factory/stackPosition.js +18 -3
- package/src/widget-factory/translateProps.js +28 -0
- package/src/widgets/Accordion.js +20 -23
- package/src/widgets/AdMob.js +7 -2
- package/src/widgets/Avatar.js +18 -12
- package/src/widgets/Badge.js +3 -2
- package/src/widgets/BottomSheet.js +14 -13
- package/src/widgets/Button.js +69 -30
- package/src/widgets/Card.js +6 -2
- package/src/widgets/Carousel.js +12 -10
- package/src/widgets/Chart.js +28 -5
- package/src/widgets/Checkbox.js +76 -18
- package/src/widgets/Chip.js +31 -10
- package/src/widgets/CircularBar.js +7 -7
- package/src/widgets/CircularChart.js +26 -8
- package/src/widgets/CodeViewer.js +93 -20
- package/src/widgets/DataTable.js +17 -11
- package/src/widgets/DraggBox.js +2 -1
- package/src/widgets/Dropdown.js +26 -13
- package/src/widgets/DroppBox.js +4 -2
- package/src/widgets/FloatingActionButton.js +13 -7
- package/src/widgets/Icon.js +3 -1
- package/src/widgets/Input.js +30 -23
- package/src/widgets/Inspector.js +54 -1
- package/src/widgets/InstallButton.js +6 -4
- package/src/widgets/ListTile.js +4 -3
- package/src/widgets/ListView.js +30 -12
- package/src/widgets/Markdown.js +37 -26
- package/src/widgets/Modal.js +14 -11
- package/src/widgets/Pagination.js +16 -11
- package/src/widgets/ProgressBar.js +6 -4
- package/src/widgets/Radio.js +73 -15
- package/src/widgets/Rating.js +24 -6
- package/src/widgets/Skeleton.js +31 -31
- package/src/widgets/Slider.js +29 -18
- package/src/widgets/SnackBar.js +35 -39
- package/src/widgets/Stepper.js +13 -9
- package/src/widgets/Switch.js +59 -18
- package/src/widgets/Tooltip.js +12 -10
- package/src/widgets/TreeView.js +27 -15
package/bin/utils/templates.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
// bin/utils/templates.js
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The plate behind everything a reader sees before the app paints: the PWA
|
|
5
|
+
* splash (`manifest.json`), the Android and iOS launch screens, the adaptive
|
|
6
|
+
* icon background, and the static `<meta name="theme-color">`.
|
|
7
|
+
*
|
|
8
|
+
* It is the dark palette's `surface` — the zinc scale the 2026-10 design rebuild
|
|
9
|
+
* moved to, replacing the indigo shell color that stayed in the scaffold and kept
|
|
10
|
+
* showing on installed PWAs long after the app itself had changed color. One
|
|
11
|
+
* value because a native splash cannot follow a theme; `applyPalette()` writes
|
|
12
|
+
* the live palette onto `<meta name="theme-color">` at runtime, so the bars do.
|
|
13
|
+
*/
|
|
14
|
+
export const SHELL_BRAND = "#18181b";
|
|
15
|
+
|
|
16
|
+
/** `SHELL_BRAND` as the channels the icon and splash scripts composite with. */
|
|
17
|
+
export const SHELL_BRAND_RGB = { r: 24, g: 24, b: 27 };
|
|
18
|
+
|
|
3
19
|
export const indexHtml = (appName = "FletBox App") => `<!DOCTYPE html>
|
|
4
20
|
<html lang="en">
|
|
5
21
|
<head>
|
|
@@ -19,7 +35,7 @@ export const indexHtml = (appName = "FletBox App") => `<!DOCTYPE html>
|
|
|
19
35
|
<meta property="og:description" content="${appName} built with Flet-Box.">
|
|
20
36
|
<meta property="og:type" content="website">
|
|
21
37
|
|
|
22
|
-
<meta name="theme-color" content="
|
|
38
|
+
<meta name="theme-color" content="${SHELL_BRAND}">
|
|
23
39
|
|
|
24
40
|
<!-- ============================================================
|
|
25
41
|
GOOGLE ADSENSE (optional — disabled by default)
|
|
@@ -64,112 +80,11 @@ export const indexHtml = (appName = "FletBox App") => `<!DOCTYPE html>
|
|
|
64
80
|
</body>
|
|
65
81
|
</html>`;
|
|
66
82
|
|
|
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
83
|
|
|
172
84
|
export const rootScreenJs = () => `// screens/RootScreen.js
|
|
85
|
+
// This screen is a reference implementation of the app's own design contract:
|
|
86
|
+
// colors only from the tokens, spacing/radius/type only from the scale, one
|
|
87
|
+
// filled action per screen. The gate that enforces it is 'npm run ui'.
|
|
173
88
|
import { Container, Column, Text, Button, Icon, colors, goTo } from 'flet-box';
|
|
174
89
|
|
|
175
90
|
export const RootScreen = () => {
|
|
@@ -177,41 +92,60 @@ export const RootScreen = () => {
|
|
|
177
92
|
flex: 1,
|
|
178
93
|
justifyContent: 'center',
|
|
179
94
|
alignItems: 'center',
|
|
180
|
-
padding:
|
|
95
|
+
padding: 24,
|
|
181
96
|
backgroundColor: colors.background,
|
|
182
97
|
child: Column({
|
|
183
|
-
gap:
|
|
98
|
+
gap: 24,
|
|
184
99
|
alignItems: 'center',
|
|
185
100
|
children: [
|
|
186
101
|
Container({
|
|
187
|
-
width:
|
|
188
|
-
height:
|
|
189
|
-
borderRadius:
|
|
190
|
-
backgroundColor: colors.
|
|
102
|
+
width: 96,
|
|
103
|
+
height: 96,
|
|
104
|
+
borderRadius: 16,
|
|
105
|
+
backgroundColor: colors.primaryBg,
|
|
191
106
|
justifyContent: 'center',
|
|
192
107
|
alignItems: 'center',
|
|
193
108
|
child: Icon({ name: 'rocket_launch', size: 48, color: colors.primary })
|
|
194
109
|
}),
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
110
|
+
Column({
|
|
111
|
+
gap: 8,
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
children: [
|
|
114
|
+
Text({ text: 'FletBox', size: 40, weight: 'bold', color: colors.text }),
|
|
115
|
+
// A lead is a sentence, not a paragraph: at desktop width this
|
|
116
|
+
// line would sit on 120 characters and no eye can track it.
|
|
117
|
+
Text({
|
|
118
|
+
text: 'A screen, in plain JavaScript.',
|
|
119
|
+
size: 16,
|
|
120
|
+
color: colors.textSecondary,
|
|
121
|
+
align: 'center'
|
|
122
|
+
})
|
|
123
|
+
]
|
|
207
124
|
}),
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
125
|
+
Column({
|
|
126
|
+
gap: 12,
|
|
127
|
+
alignItems: 'stretch',
|
|
128
|
+
maxWidth: 280,
|
|
129
|
+
children: [
|
|
130
|
+
// A filled Button defaults to colors.surface, not to the brand:
|
|
131
|
+
// the primary action has to name the fill it wants.
|
|
132
|
+
Button({
|
|
133
|
+
text: 'See the design system',
|
|
134
|
+
iconRight: 'arrow_forward',
|
|
135
|
+
bgColor: colors.primary,
|
|
136
|
+
color: colors.onPrimary,
|
|
137
|
+
fullWidth: true,
|
|
138
|
+
onPress: () => goTo('/home')
|
|
139
|
+
}),
|
|
140
|
+
// One accent per screen: everything that is not the ask is quiet.
|
|
141
|
+
Button({
|
|
142
|
+
text: 'Preferences',
|
|
143
|
+
variant: 'text',
|
|
144
|
+
color: colors.text,
|
|
145
|
+
fullWidth: true,
|
|
146
|
+
onPress: () => goTo('/about')
|
|
147
|
+
})
|
|
148
|
+
]
|
|
215
149
|
})
|
|
216
150
|
]
|
|
217
151
|
})
|
|
@@ -222,7 +156,10 @@ export default RootScreen;
|
|
|
222
156
|
`;
|
|
223
157
|
|
|
224
158
|
export const homeScreenJs = () => `// screens/HomeScreen.js
|
|
225
|
-
|
|
159
|
+
// The screen that shows what the app is built on. Every number on this page is a
|
|
160
|
+
// member of the framework's own scale, so the page documents the system it uses:
|
|
161
|
+
// change one to an off-scale value and 'npm run ui' reports it.
|
|
162
|
+
import { Container, Column, Row, Text, Icon, Chip, colors } from 'flet-box';
|
|
226
163
|
|
|
227
164
|
// ========== GOOGLE ADMOB (optional — disabled by default) ==========
|
|
228
165
|
// AdMob shows native ads in Android/iOS builds. To enable it:
|
|
@@ -238,70 +175,137 @@ import { Container, Column, Text, Button, Icon, colors, goTo } from 'flet-box';
|
|
|
238
175
|
// import { AdMob } from 'flet-box';
|
|
239
176
|
// =====================================================================
|
|
240
177
|
|
|
178
|
+
const SPACING_STEPS = [4, 8, 12, 16, 24, 32, 48, 64];
|
|
179
|
+
|
|
180
|
+
const TYPE_STEPS = [
|
|
181
|
+
{ size: 12, label: 'Caption' },
|
|
182
|
+
{ size: 14, label: 'Body' },
|
|
183
|
+
{ size: 18, label: 'Title' },
|
|
184
|
+
{ size: 24, label: 'Headline' },
|
|
185
|
+
{ size: 32, label: 'Display' }
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
// Three roles, and each one says when it is allowed on the page. A fourth would
|
|
189
|
+
// be decoration looking for a reason.
|
|
190
|
+
const COLOR_ROLES = [
|
|
191
|
+
{ token: 'primary', text: 'Brand', note: 'the one action this screen asks for' },
|
|
192
|
+
{ token: 'successStrong', text: 'Feedback', note: 'status, never ornament' },
|
|
193
|
+
{ token: 'dangerStrong', text: 'Danger', note: 'the red that carries words' }
|
|
194
|
+
];
|
|
195
|
+
|
|
241
196
|
export const HomeScreen = () => {
|
|
242
197
|
return Container({
|
|
243
198
|
flex: 1,
|
|
244
|
-
justifyContent: 'center',
|
|
245
|
-
alignItems: 'center',
|
|
246
199
|
padding: 24,
|
|
200
|
+
overflow: 'auto',
|
|
247
201
|
backgroundColor: colors.background,
|
|
248
202
|
child: Column({
|
|
249
|
-
gap:
|
|
250
|
-
|
|
203
|
+
gap: 24,
|
|
204
|
+
// A reading column, not a full-width one: at 1400px a line of body
|
|
205
|
+
// text becomes unreadable, so the measure is capped and centred.
|
|
206
|
+
maxWidth: 640,
|
|
207
|
+
margin: '0 auto',
|
|
251
208
|
children: [
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
209
|
+
Column({
|
|
210
|
+
gap: 8,
|
|
211
|
+
children: [
|
|
212
|
+
Text({ text: 'The system this app is built on', size: 32, weight: 'bold', color: colors.text }),
|
|
213
|
+
Text({
|
|
214
|
+
text: 'Three roles, one rhythm, one type scale. Nothing here is typed by hand.',
|
|
215
|
+
size: 16,
|
|
216
|
+
color: colors.textSecondary
|
|
217
|
+
})
|
|
218
|
+
]
|
|
260
219
|
}),
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
220
|
+
Column({
|
|
221
|
+
gap: 12,
|
|
222
|
+
children: [
|
|
223
|
+
Text({ text: 'Spacing', size: 18, weight: 'bold', color: colors.text }),
|
|
224
|
+
Row({
|
|
225
|
+
gap: 12,
|
|
226
|
+
alignItems: 'end',
|
|
227
|
+
// Eight squares plus their labels need ~300 units. Without
|
|
228
|
+
// wrapping they overflow a narrow column — a sidebar on a
|
|
229
|
+
// small window, or this same screen beside a desktop nav.
|
|
230
|
+
wrap: true,
|
|
231
|
+
children: SPACING_STEPS.map((step) =>
|
|
232
|
+
Column({
|
|
233
|
+
// Column is a 100%-wide block by default — the
|
|
234
|
+
// right answer for a page, the wrong one for eight
|
|
235
|
+
// cells in a row, where it makes them share the
|
|
236
|
+
// width out and the squares drift apart.
|
|
237
|
+
width: 'auto',
|
|
238
|
+
gap: 8,
|
|
239
|
+
alignItems: 'center',
|
|
240
|
+
children: [
|
|
241
|
+
Container({
|
|
242
|
+
width: step,
|
|
243
|
+
height: step,
|
|
244
|
+
borderRadius: 4,
|
|
245
|
+
backgroundColor: colors.primaryBg,
|
|
246
|
+
border: '1px solid ' + colors.border
|
|
247
|
+
}),
|
|
248
|
+
Text({ text: String(step), size: 12, color: colors.textSecondary })
|
|
249
|
+
]
|
|
250
|
+
})
|
|
251
|
+
)
|
|
252
|
+
}),
|
|
253
|
+
Text({
|
|
254
|
+
text: 'These are framework units, not pixels: raise the browser font size and every square, gap and corner grows with the text.',
|
|
255
|
+
size: 14,
|
|
256
|
+
color: colors.textSecondary
|
|
257
|
+
})
|
|
258
|
+
]
|
|
267
259
|
}),
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
260
|
+
Column({
|
|
261
|
+
gap: 12,
|
|
262
|
+
children: [
|
|
263
|
+
Text({ text: 'Type', size: 18, weight: 'bold', color: colors.text }),
|
|
264
|
+
Column({
|
|
265
|
+
gap: 8,
|
|
266
|
+
children: TYPE_STEPS.map((step) =>
|
|
267
|
+
Row({
|
|
268
|
+
gap: 12,
|
|
269
|
+
alignItems: 'center',
|
|
270
|
+
children: [
|
|
271
|
+
Text({ text: step.label, size: step.size, weight: 'bold', color: colors.text }),
|
|
272
|
+
Text({ text: String(step.size), size: 12, color: colors.textSecondary })
|
|
273
|
+
]
|
|
274
|
+
})
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
]
|
|
273
278
|
}),
|
|
274
279
|
Column({
|
|
275
280
|
gap: 12,
|
|
276
|
-
alignItems: 'stretch',
|
|
277
|
-
style: { width: '100%', maxWidth: 280 },
|
|
278
281
|
children: [
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
282
|
+
Text({ text: 'Color roles', size: 18, weight: 'bold', color: colors.text }),
|
|
283
|
+
Column({
|
|
284
|
+
gap: 8,
|
|
285
|
+
children: COLOR_ROLES.map((role) =>
|
|
286
|
+
Row({
|
|
287
|
+
gap: 12,
|
|
288
|
+
alignItems: 'center',
|
|
289
|
+
padding: 12,
|
|
290
|
+
borderRadius: 12,
|
|
291
|
+
backgroundColor: colors.surface,
|
|
292
|
+
children: [
|
|
293
|
+
Icon({ name: 'palette', size: 24, color: colors[role.token] }),
|
|
294
|
+
Column({
|
|
295
|
+
gap: 4,
|
|
296
|
+
flex: 1,
|
|
297
|
+
children: [
|
|
298
|
+
Text({ text: role.text, size: 16, weight: 'bold', color: colors.text }),
|
|
299
|
+
Text({ text: role.note, size: 14, color: colors.textSecondary })
|
|
300
|
+
]
|
|
301
|
+
}),
|
|
302
|
+
// The token name is the lesson; a Chip is how a
|
|
303
|
+
// framework value gets shown without becoming a
|
|
304
|
+
// colour of its own.
|
|
305
|
+
Chip({ label: role.token, variant: 'outlined', color: colors.textSecondary, size: 12 })
|
|
306
|
+
]
|
|
307
|
+
})
|
|
308
|
+
)
|
|
305
309
|
})
|
|
306
310
|
]
|
|
307
311
|
})
|
|
@@ -323,84 +327,191 @@ export default HomeScreen;
|
|
|
323
327
|
`;
|
|
324
328
|
|
|
325
329
|
export const aboutScreenJs = () => `// screens/AboutScreen.js
|
|
326
|
-
|
|
330
|
+
// Preferences, written the way the rest of the app should be written: state that
|
|
331
|
+
// survives a re-render, a control that names itself, validation that speaks
|
|
332
|
+
// instead of disabling, and one filled button per screen.
|
|
333
|
+
import {
|
|
334
|
+
Container,
|
|
335
|
+
Column,
|
|
336
|
+
Row,
|
|
337
|
+
Text,
|
|
338
|
+
Button,
|
|
339
|
+
Input,
|
|
340
|
+
Dropdown,
|
|
341
|
+
Switch,
|
|
342
|
+
Slider,
|
|
343
|
+
Divider,
|
|
344
|
+
Tooltip,
|
|
345
|
+
Icon,
|
|
346
|
+
SnackBar,
|
|
347
|
+
AlertDialog,
|
|
348
|
+
Storage,
|
|
349
|
+
colors,
|
|
350
|
+
useState,
|
|
351
|
+
setTheme,
|
|
352
|
+
getTheme,
|
|
353
|
+
setBaseFontSize,
|
|
354
|
+
getBaseFontSize
|
|
355
|
+
} from 'flet-box';
|
|
356
|
+
|
|
357
|
+
// Key-based RAM keeps a value across re-renders and hot reloads; localStorage is
|
|
358
|
+
// what keeps a choice across sessions. A preferences screen that writes neither
|
|
359
|
+
// is a form that lies, so the size is read back at boot — before the first
|
|
360
|
+
// render, and before the draft below seeds itself from the live value.
|
|
361
|
+
const storedSize = Storage.getData('baseFontSize');
|
|
362
|
+
if (storedSize) setBaseFontSize(storedSize);
|
|
363
|
+
|
|
364
|
+
const [draftSize, setDraftSize] = useState('prefFontSize', getBaseFontSize());
|
|
365
|
+
|
|
366
|
+
// The destructive action is built once, at module scope. The dialog puts Cancel
|
|
367
|
+
// first on its own: the way out is the first thing the eye lands on.
|
|
368
|
+
const resetDialog = AlertDialog({
|
|
369
|
+
title: 'Reset the text size?',
|
|
370
|
+
message: 'It goes back to 16, the size this browser asks for.',
|
|
371
|
+
variant: 'danger',
|
|
372
|
+
confirmText: 'Reset',
|
|
373
|
+
cancelText: 'Keep it',
|
|
374
|
+
onConfirm: () => {
|
|
375
|
+
setDraftSize(16);
|
|
376
|
+
setBaseFontSize(16);
|
|
377
|
+
// Resetting is the absence of a preference, not a new one: the key goes
|
|
378
|
+
// away, so the next boot answers to the browser again.
|
|
379
|
+
Storage.deleteData('baseFontSize');
|
|
380
|
+
SnackBar({ message: 'Text size reset', type: 'success' }).show();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Validation runs before the action, so the error is a message and never a
|
|
385
|
+
// disabled button the user has to discover.
|
|
386
|
+
const save = (name, region) => {
|
|
387
|
+
if (name.length === 0) {
|
|
388
|
+
return SnackBar({ message: 'Give the profile a name first', type: 'error' }).show();
|
|
389
|
+
}
|
|
390
|
+
const size = draftSize.valueOf();
|
|
391
|
+
setBaseFontSize(size);
|
|
392
|
+
Storage.saveData('baseFontSize', size);
|
|
393
|
+
Storage.saveData('displayName', name);
|
|
394
|
+
Storage.saveData('region', region.valueOf());
|
|
395
|
+
SnackBar({ message: 'Preferences saved', type: 'success' }).show();
|
|
396
|
+
};
|
|
327
397
|
|
|
328
398
|
export const AboutScreen = () => {
|
|
399
|
+
const [name, setName] = useState('prefName', Storage.getData('displayName') || '');
|
|
400
|
+
const [region, setRegion] = useState('prefRegion', Storage.getData('region') || '');
|
|
401
|
+
const isDark = getTheme() === 'dark';
|
|
402
|
+
const size = draftSize.valueOf();
|
|
403
|
+
|
|
329
404
|
return Container({
|
|
330
405
|
flex: 1,
|
|
331
|
-
justifyContent: 'center',
|
|
332
|
-
alignItems: 'center',
|
|
333
406
|
padding: 24,
|
|
407
|
+
overflow: 'auto',
|
|
334
408
|
backgroundColor: colors.background,
|
|
335
409
|
child: Column({
|
|
336
|
-
gap:
|
|
337
|
-
|
|
410
|
+
gap: 24,
|
|
411
|
+
maxWidth: 480,
|
|
412
|
+
margin: '0 auto',
|
|
338
413
|
children: [
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
414
|
+
Column({
|
|
415
|
+
gap: 8,
|
|
416
|
+
children: [
|
|
417
|
+
Text({ text: 'Preferences', size: 32, weight: 'bold', color: colors.text }),
|
|
418
|
+
Text({
|
|
419
|
+
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.',
|
|
420
|
+
size: 16,
|
|
421
|
+
color: colors.textSecondary
|
|
422
|
+
})
|
|
423
|
+
]
|
|
347
424
|
}),
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
425
|
+
Input({
|
|
426
|
+
label: 'Display name',
|
|
427
|
+
placeholder: 'How the app should greet you',
|
|
428
|
+
value: name.valueOf(),
|
|
429
|
+
required: true,
|
|
430
|
+
onChange: (value) => setName(value)
|
|
354
431
|
}),
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
432
|
+
Dropdown({
|
|
433
|
+
label: 'Region',
|
|
434
|
+
options: [
|
|
435
|
+
{ value: 'cl', label: 'Chile' },
|
|
436
|
+
{ value: 'es', label: 'Spain' },
|
|
437
|
+
{ value: 'mx', label: 'Mexico' }
|
|
438
|
+
],
|
|
439
|
+
placeholder: 'Where you are',
|
|
440
|
+
value: region.valueOf(),
|
|
441
|
+
onChange: (value) => setRegion(value)
|
|
361
442
|
}),
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
})
|
|
443
|
+
Divider(),
|
|
444
|
+
// A toggle that never says what it switches is a mystery control: the
|
|
445
|
+
// label is part of the Switch, and the row is the tap target.
|
|
446
|
+
Switch({
|
|
447
|
+
label: 'Dark theme',
|
|
448
|
+
value: isDark,
|
|
449
|
+
activeColor: colors.primary,
|
|
450
|
+
onPress: (value) => setTheme(value ? 'dark' : 'light')
|
|
396
451
|
}),
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
452
|
+
Column({
|
|
453
|
+
gap: 8,
|
|
454
|
+
children: [
|
|
455
|
+
Row({
|
|
456
|
+
gap: 12,
|
|
457
|
+
alignItems: 'center',
|
|
458
|
+
children: [
|
|
459
|
+
Text({ text: 'Text size', size: 16, weight: 'bold', color: colors.text }),
|
|
460
|
+
Text({ text: size + 'px', size: 14, color: colors.textSecondary })
|
|
461
|
+
]
|
|
462
|
+
}),
|
|
463
|
+
Slider({
|
|
464
|
+
min: 14,
|
|
465
|
+
max: 22,
|
|
466
|
+
step: 1,
|
|
467
|
+
value: size,
|
|
468
|
+
color: colors.primary,
|
|
469
|
+
onPress: (value) => setDraftSize(value)
|
|
470
|
+
}),
|
|
471
|
+
Text({
|
|
472
|
+
text: '16 is what the browser asks for. Anything larger is the reader asking to be readable.',
|
|
473
|
+
size: 14,
|
|
474
|
+
color: colors.textSecondary
|
|
475
|
+
})
|
|
476
|
+
]
|
|
477
|
+
}),
|
|
478
|
+
Divider(),
|
|
479
|
+
Column({
|
|
480
|
+
gap: 12,
|
|
481
|
+
children: [
|
|
482
|
+
// One filled button per screen: it is the thing this screen asks
|
|
483
|
+
// for, and it is the only control on the page that stretches —
|
|
484
|
+
// a full-width target is a decision, not a layout default.
|
|
485
|
+
Button({
|
|
486
|
+
text: 'Save preferences',
|
|
487
|
+
iconLeft: 'check',
|
|
488
|
+
bgColor: colors.primary,
|
|
489
|
+
color: colors.onPrimary,
|
|
490
|
+
fullWidth: true,
|
|
491
|
+
onPress: () => save(name.valueOf(), region.valueOf())
|
|
492
|
+
}),
|
|
493
|
+
Row({
|
|
494
|
+
gap: 12,
|
|
495
|
+
alignItems: 'center',
|
|
496
|
+
children: [
|
|
497
|
+
// A second, quieter action: text variant, danger token,
|
|
498
|
+
// and it asks before it destroys.
|
|
499
|
+
Button({
|
|
500
|
+
text: 'Reset the size',
|
|
501
|
+
iconLeft: 'restart_alt',
|
|
502
|
+
variant: 'text',
|
|
503
|
+
color: colors.dangerStrong,
|
|
504
|
+
onPress: () => resetDialog.open()
|
|
505
|
+
}),
|
|
506
|
+
// An icon alone says nothing; the Tooltip is its name.
|
|
507
|
+
Tooltip({
|
|
508
|
+
text: 'Resetting the size is not undoable from this screen',
|
|
509
|
+
position: 'right',
|
|
510
|
+
child: Icon({ name: 'help_outline', size: 24, color: colors.textSecondary })
|
|
511
|
+
})
|
|
512
|
+
]
|
|
513
|
+
})
|
|
514
|
+
]
|
|
404
515
|
})
|
|
405
516
|
]
|
|
406
517
|
})
|
|
@@ -416,8 +527,9 @@ import { Drawer, DrawerItem, Container, Column, Text, Icon, colors, closeDrawer
|
|
|
416
527
|
export const DrawerMenu = () => {
|
|
417
528
|
return Drawer({
|
|
418
529
|
header: Container({
|
|
419
|
-
padding:
|
|
420
|
-
backgroundColor: colors.
|
|
530
|
+
padding: 24,
|
|
531
|
+
backgroundColor: colors.surface,
|
|
532
|
+
borderBottom: \`1px solid \${colors.border}\`,
|
|
421
533
|
child: Column({
|
|
422
534
|
gap: 12,
|
|
423
535
|
alignItems: 'center',
|
|
@@ -425,39 +537,39 @@ export const DrawerMenu = () => {
|
|
|
425
537
|
Container({
|
|
426
538
|
width: 60,
|
|
427
539
|
height: 60,
|
|
428
|
-
borderRadius:
|
|
429
|
-
backgroundColor:
|
|
540
|
+
borderRadius: 999,
|
|
541
|
+
backgroundColor: colors.primaryBg,
|
|
430
542
|
justifyContent: 'center',
|
|
431
543
|
alignItems: 'center',
|
|
432
544
|
child: Icon({ name: 'rocket_launch', size: 32, color: colors.primary })
|
|
433
545
|
}),
|
|
434
|
-
Text({ text: 'FletBox', size: 18, weight: 'bold', color:
|
|
435
|
-
Text({ text: '
|
|
546
|
+
Text({ text: 'FletBox', size: 18, weight: 'bold', color: colors.text }),
|
|
547
|
+
Text({ text: 'Starter app', size: 12, color: colors.textSecondary })
|
|
436
548
|
]
|
|
437
549
|
})
|
|
438
550
|
}),
|
|
439
551
|
body: [
|
|
440
552
|
DrawerItem({
|
|
441
|
-
icon: '
|
|
442
|
-
label: '
|
|
553
|
+
icon: 'palette',
|
|
554
|
+
label: 'Design system',
|
|
443
555
|
route: '/home',
|
|
444
556
|
onPress: () => closeDrawer()
|
|
445
557
|
}),
|
|
446
558
|
DrawerItem({
|
|
447
|
-
icon: '
|
|
448
|
-
label: '
|
|
559
|
+
icon: 'tune',
|
|
560
|
+
label: 'Preferences',
|
|
449
561
|
route: '/about',
|
|
450
562
|
onPress: () => closeDrawer()
|
|
451
563
|
}),
|
|
452
564
|
DrawerItem({
|
|
453
565
|
icon: 'home',
|
|
454
|
-
label: '
|
|
566
|
+
label: 'Start here',
|
|
455
567
|
route: '/',
|
|
456
568
|
onPress: () => closeDrawer()
|
|
457
569
|
})
|
|
458
570
|
],
|
|
459
571
|
footer: Container({
|
|
460
|
-
padding:
|
|
572
|
+
padding: 16,
|
|
461
573
|
borderTop: \`1px solid \${colors.border}\`,
|
|
462
574
|
child: Text({
|
|
463
575
|
text: 'Version 1.0.0',
|
|
@@ -473,12 +585,39 @@ export default DrawerMenu;
|
|
|
473
585
|
`;
|
|
474
586
|
|
|
475
587
|
export const themesJs = () => `// database/themes.js
|
|
476
|
-
|
|
477
|
-
|
|
588
|
+
// The app's whole design system lives here. Every value is measured with the
|
|
589
|
+
// framework's own \`color().contrastRatio()\` against the surface it is written on:
|
|
590
|
+
// the number sits next to the pair that needs it, because a token that cannot
|
|
591
|
+
// carry AA text is decoration, not a color.
|
|
592
|
+
import { registerPalette } from 'flet-box';
|
|
593
|
+
|
|
594
|
+
export const lightColors = {
|
|
595
|
+
// #6366f1 put white on 4.47:1 — a hair under AA. Two units darker measures
|
|
596
|
+
// 4.55:1, the same indigo, and every brand button passes.
|
|
597
|
+
primary: '#6165ef',
|
|
478
598
|
secondary: '#8b5cf6',
|
|
479
599
|
success: '#10b981',
|
|
480
600
|
warning: '#f59e0b',
|
|
601
|
+
// The error signal: a badge, a border, an icon. 3.76:1 as words on this page,
|
|
602
|
+
// so it is never the label of a button.
|
|
481
603
|
danger: '#ef4444',
|
|
604
|
+
// The readable red, both ways: 6.47:1 as words on white, 6.47:1 with white
|
|
605
|
+
// written on it, 5.42:1 inside \`errorBg\`. Use it for the words and the plate.
|
|
606
|
+
dangerStrong: '#b91c1c',
|
|
607
|
+
// The readable green and amber: 5.48:1 and 5.02:1, in both directions, so
|
|
608
|
+
// they may be a label, an icon that carries meaning, or a plate.
|
|
609
|
+
successStrong: '#047857',
|
|
610
|
+
warningStrong: '#b45309',
|
|
611
|
+
// \`*Plate\` is the fill white words sit on. On this page it is the same tone
|
|
612
|
+
// as the matching Strong, and it is a separate name because on a dark page the
|
|
613
|
+
// two part ways: the readable red goes light, the plate goes dark.
|
|
614
|
+
dangerPlate: '#b91c1c',
|
|
615
|
+
successPlate: '#047857',
|
|
616
|
+
warningPlate: '#b45309',
|
|
617
|
+
// \`info\` (#3b82f6) carries white at 3.68:1, so a filled chip or a tooltip in
|
|
618
|
+
// the info hue needs this one: 6.70:1 in both directions.
|
|
619
|
+
infoStrong: '#1d4ed8',
|
|
620
|
+
infoPlate: '#1d4ed8',
|
|
482
621
|
info: '#3b82f6',
|
|
483
622
|
background: '#ffffff',
|
|
484
623
|
surface: '#f8fafc',
|
|
@@ -486,10 +625,23 @@ export let colors = {
|
|
|
486
625
|
textSecondary: '#64748b',
|
|
487
626
|
textDisabled: '#94a3b8',
|
|
488
627
|
border: '#e2e8f0',
|
|
489
|
-
white: '#ffffff',
|
|
490
|
-
black: '#000000',
|
|
491
628
|
overlay: 'rgba(0, 0, 0, 0.5)',
|
|
492
|
-
shadow: 'rgba(0, 0, 0, 0.1)'
|
|
629
|
+
shadow: 'rgba(0, 0, 0, 0.1)',
|
|
630
|
+
// What to write on a brand fill, and the soft tints that replace
|
|
631
|
+
// \`colors.primary + '15'\`.
|
|
632
|
+
onPrimary: '#ffffff',
|
|
633
|
+
primaryBg: '#eef2ff',
|
|
634
|
+
// The translucent surface for a card with \`backdropFilter\`. A token, so the
|
|
635
|
+
// app never hand-writes an \`rgba()\` the clean-UI gate would reject.
|
|
636
|
+
glass: 'rgba(255, 255, 255, 0.66)',
|
|
637
|
+
glassBorder: 'rgba(255, 255, 255, 0.75)',
|
|
638
|
+
error: '#ff3b30',
|
|
639
|
+
errorBg: '#ffe5e5',
|
|
640
|
+
successBg: '#e5f5e5',
|
|
641
|
+
warningBg: '#fff5e5',
|
|
642
|
+
infoBg: '#e5f5ff',
|
|
643
|
+
divider: '#eeeeee',
|
|
644
|
+
card: '#ffffff'
|
|
493
645
|
};
|
|
494
646
|
|
|
495
647
|
export const darkColors = {
|
|
@@ -497,7 +649,23 @@ export const darkColors = {
|
|
|
497
649
|
secondary: '#a78bfa',
|
|
498
650
|
success: '#34d399',
|
|
499
651
|
warning: '#fbbf24',
|
|
652
|
+
// On a dark page the light red is the readable one: 6.45:1 against the
|
|
653
|
+
// background. The plate a person presses goes the other way.
|
|
500
654
|
danger: '#f87171',
|
|
655
|
+
// \`*Strong\` is the answer to "what may I write words in?" and on this page
|
|
656
|
+
// that is the light end of the ladder: 9.41:1, 11.71:1, 12.38:1 on the
|
|
657
|
+
// background — all AA, and a step clearer than the signal tone above.
|
|
658
|
+
dangerStrong: '#fca5a5',
|
|
659
|
+
successStrong: '#6ee7b7',
|
|
660
|
+
warningStrong: '#fcd34d',
|
|
661
|
+
// \`*Plate\` is the fill with white words on it, and that has to go dark here:
|
|
662
|
+
// 10.02:1, 9.11:1, 9.07:1. These are the tones that used to hide inside
|
|
663
|
+
// \`*Strong\`, which is why a delete written in \`dangerStrong\` was invisible.
|
|
664
|
+
dangerPlate: '#7f1d1d',
|
|
665
|
+
successPlate: '#14532d',
|
|
666
|
+
warningPlate: '#78350f',
|
|
667
|
+
infoStrong: '#93c5fd',
|
|
668
|
+
infoPlate: '#1e40af',
|
|
501
669
|
info: '#60a5fa',
|
|
502
670
|
background: '#0f172a',
|
|
503
671
|
surface: '#1e293b',
|
|
@@ -505,46 +673,28 @@ export const darkColors = {
|
|
|
505
673
|
textSecondary: '#94a3b8',
|
|
506
674
|
textDisabled: '#64748b',
|
|
507
675
|
border: '#334155',
|
|
508
|
-
white: '#ffffff',
|
|
509
|
-
black: '#000000',
|
|
510
676
|
overlay: 'rgba(0, 0, 0, 0.6)',
|
|
511
|
-
shadow: 'rgba(0, 0, 0, 0.3)'
|
|
677
|
+
shadow: 'rgba(0, 0, 0, 0.3)',
|
|
678
|
+
// #818cf8 is a light fill: white on it is 2.98:1, the page's own ink is 5.98:1.
|
|
679
|
+
onPrimary: '#0f172a',
|
|
680
|
+
primaryBg: '#1e1b4b',
|
|
681
|
+
glass: 'rgba(30, 41, 59, 0.66)',
|
|
682
|
+
glassBorder: 'rgba(226, 232, 240, 0.22)',
|
|
683
|
+
error: '#f87171',
|
|
684
|
+
errorBg: '#3b1113',
|
|
685
|
+
successBg: '#0c2f22',
|
|
686
|
+
warningBg: '#33230a',
|
|
687
|
+
infoBg: '#12263c',
|
|
688
|
+
divider: '#1e293b',
|
|
689
|
+
card: '#1e293b'
|
|
512
690
|
};
|
|
513
691
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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); };
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
const initTheme = () => {
|
|
544
|
-
const root = document.documentElement;
|
|
545
|
-
Object.keys(colors).forEach(key => root.style.setProperty(\`--color-\${key}\`, colors[key]));
|
|
546
|
-
};
|
|
547
|
-
initTheme();
|
|
692
|
+
// Declaring a palette paints nothing by itself: every widget reads the
|
|
693
|
+
// framework's live \`colors\`, so the tokens have to be registered into it.
|
|
694
|
+
// Registration deliberately does not pick a theme — whether the app is dark is
|
|
695
|
+
// the reader's answer (\`setTheme\`, \`toggleTheme\`), not the designer's.
|
|
696
|
+
registerPalette('light', lightColors);
|
|
697
|
+
registerPalette('dark', darkColors);
|
|
548
698
|
`;
|
|
549
699
|
|
|
550
700
|
export const runSh = () => `#!/bin/bash
|
|
@@ -686,17 +836,40 @@ self.addEventListener('fetch', event => {
|
|
|
686
836
|
const hit = await cache.match(req);
|
|
687
837
|
if (hit) return hit;
|
|
688
838
|
}
|
|
839
|
+
|
|
840
|
+
// A page load that neither the host nor the cache can answer must still
|
|
841
|
+
// boot the app: the router takes the URL from there. Asset requests keep
|
|
842
|
+
// their own failure, because replacing a missing image with a document is
|
|
843
|
+
// how a broken site becomes an undebuggable one.
|
|
844
|
+
const shell = async () => {
|
|
845
|
+
if (req.mode !== 'navigate') return null;
|
|
846
|
+
const cached = await cache.match(BASE + 'index.html');
|
|
847
|
+
if (cached) return cached;
|
|
848
|
+
try {
|
|
849
|
+
const fresh = await fetch(BASE + 'index.html', { cache: 'no-store' });
|
|
850
|
+
return fresh.ok ? fresh : null;
|
|
851
|
+
} catch (err) {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
689
856
|
try {
|
|
690
857
|
const fresh = await fetch(req, { cache: 'no-store' });
|
|
691
|
-
if (fresh.ok)
|
|
692
|
-
|
|
858
|
+
if (fresh.ok) {
|
|
859
|
+
cache.put(req, fresh.clone());
|
|
860
|
+
return fresh;
|
|
861
|
+
}
|
|
862
|
+
// A host without SPA rewrites (GitHub Pages, a plain bucket) answers an
|
|
863
|
+
// unknown path with its own 404 page. That is a response, not an error,
|
|
864
|
+
// so a fallback living in the catch below never runs and every reload of
|
|
865
|
+
// a deep link shows "Page not found" while the app is perfectly healthy.
|
|
866
|
+
const fallback = await shell();
|
|
867
|
+
return fallback || fresh;
|
|
693
868
|
} catch (err) {
|
|
694
869
|
const cached = await cache.match(req);
|
|
695
870
|
if (cached) return cached;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if (shell) return shell;
|
|
699
|
-
}
|
|
871
|
+
const fallback = await shell();
|
|
872
|
+
if (fallback) return fallback;
|
|
700
873
|
return Response.error();
|
|
701
874
|
}
|
|
702
875
|
})()
|
|
@@ -711,6 +884,7 @@ export const packageJson = (name) => `{
|
|
|
711
884
|
"scripts": {
|
|
712
885
|
"dev": "bash run.sh",
|
|
713
886
|
"check": "node scripts/check-app.mjs",
|
|
887
|
+
"ui": "node scripts/check-ui.mjs",
|
|
714
888
|
"fix": "flet-box audit-fix",
|
|
715
889
|
"api": "flet-box unpack --all --md",
|
|
716
890
|
"build": "flet-box createBundle www",
|
|
@@ -746,7 +920,7 @@ export const capacitorConfig = (appName, appId) => `${JSON.stringify(
|
|
|
746
920
|
appId,
|
|
747
921
|
appName,
|
|
748
922
|
webDir: "www",
|
|
749
|
-
backgroundColor:
|
|
923
|
+
backgroundColor: SHELL_BRAND,
|
|
750
924
|
server: { androidScheme: "https", iosScheme: "https" },
|
|
751
925
|
},
|
|
752
926
|
null,
|
|
@@ -802,7 +976,7 @@ const densities = [
|
|
|
802
976
|
["xxhdpi", 144],
|
|
803
977
|
["xxxhdpi", 192],
|
|
804
978
|
];
|
|
805
|
-
const background = { r:
|
|
979
|
+
const background = { r: ${SHELL_BRAND_RGB.r}, g: ${SHELL_BRAND_RGB.g}, b: ${SHELL_BRAND_RGB.b} };
|
|
806
980
|
|
|
807
981
|
for (const [density, size] of densities) {
|
|
808
982
|
const directory = path.join(androidRes, \`mipmap-\${density}\`);
|
|
@@ -840,7 +1014,7 @@ const backgroundColor = /(<color name="ic_launcher_background">)#(?:[A-Fa-f0-9]{
|
|
|
840
1014
|
if (!backgroundColor.test(xml)) {
|
|
841
1015
|
throw new Error(\`Invalid adaptive icon background resource: \${backgroundPath}\`);
|
|
842
1016
|
}
|
|
843
|
-
await writeFile(backgroundPath, xml.replace(backgroundColor, "$1
|
|
1017
|
+
await writeFile(backgroundPath, xml.replace(backgroundColor, "$1${SHELL_BRAND}$2"));
|
|
844
1018
|
|
|
845
1019
|
// SPLASH SCREENS
|
|
846
1020
|
// The launch theme (AppTheme.NoActionBarLaunch) shows @drawable/splash while the
|
|
@@ -908,8 +1082,8 @@ const infoPlistPath = path.join(nativeAppDir, "Info.plist");
|
|
|
908
1082
|
const launchScreenPath = path.join(nativeAppDir, "Base.lproj", "LaunchScreen.storyboard");
|
|
909
1083
|
const logoPath = path.join(projectRoot, "assets", "logo.png");
|
|
910
1084
|
|
|
911
|
-
// Brand background, the same
|
|
912
|
-
const background = { r:
|
|
1085
|
+
// Brand background, the same value as theme-color in index.html and manifest.json.
|
|
1086
|
+
const background = { r: ${SHELL_BRAND_RGB.r}, g: ${SHELL_BRAND_RGB.g}, b: ${SHELL_BRAND_RGB.b} };
|
|
913
1087
|
const backgroundComponent = (value) => Number((value / 255).toFixed(6));
|
|
914
1088
|
|
|
915
1089
|
const requireExists = async (target) => {
|
|
@@ -1083,12 +1257,12 @@ export const manifest = (appName = "FletBox App") => `{
|
|
|
1083
1257
|
"name": ${JSON.stringify(appName)},
|
|
1084
1258
|
"short_name": ${JSON.stringify(appName.slice(0, 12))},
|
|
1085
1259
|
"description": ${JSON.stringify(`${appName} built with Flet-Box`)},
|
|
1086
|
-
"start_url": "
|
|
1087
|
-
"scope": "
|
|
1260
|
+
"start_url": "./",
|
|
1261
|
+
"scope": "./",
|
|
1088
1262
|
"display": "standalone",
|
|
1089
1263
|
"display_override": ["window-controls-overlay"],
|
|
1090
|
-
"theme_color": "
|
|
1091
|
-
"background_color": "
|
|
1264
|
+
"theme_color": "${SHELL_BRAND}",
|
|
1265
|
+
"background_color": "${SHELL_BRAND}",
|
|
1092
1266
|
"orientation": "any",
|
|
1093
1267
|
"icons": [
|
|
1094
1268
|
{
|
|
@@ -1130,13 +1304,45 @@ export const manifest = (appName = "FletBox App") => `{
|
|
|
1130
1304
|
// that only runs when started by hand.
|
|
1131
1305
|
// NOTE: every GitHub `${{ }}` expression is escaped as `\${{ }}` because this is
|
|
1132
1306
|
// a JS template literal — an unescaped `${` would be parsed as interpolation.
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1307
|
+
//
|
|
1308
|
+
// `siteUrl` is the Pages address `flet-box create` derived from `--repo`. It is
|
|
1309
|
+
// written into the file as text, at the top, the way the docs repo states its
|
|
1310
|
+
// own: whoever opens build.yml on GitHub reads where this app lives before
|
|
1311
|
+
// reading a single step. Anything that is not an http(s) URL is refused, so a
|
|
1312
|
+
// caller that passes the wrong thing gets the placeholder rather than a lie —
|
|
1313
|
+
// and the placeholder keeps `TU-USUARIO` visible, because that is the one string
|
|
1314
|
+
// the developer must edit.
|
|
1315
|
+
export const githubWorkflow = (appName = "FletBox App", siteUrl = null) => {
|
|
1316
|
+
const slug = String(appName)
|
|
1317
|
+
.toLowerCase()
|
|
1318
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
1319
|
+
.replace(/^-+|-+$/g, "") || "fletbox-app";
|
|
1320
|
+
const site = /^https?:\/\//i.test(String(siteUrl || ""))
|
|
1321
|
+
? String(siteUrl).replace(/\/?$/, "/")
|
|
1322
|
+
: `https://TU-USUARIO.github.io/${slug}/`;
|
|
1323
|
+
// Only when the owner is still a guess: the address lives in two files, and a
|
|
1324
|
+
// developer who edits one and not the other ships an app that polls a site
|
|
1325
|
+
// that is not where the bundle is.
|
|
1326
|
+
const todo = site.includes("TU-USUARIO")
|
|
1327
|
+
? `# The owner above is a placeholder: this project was created without \`--repo\`,
|
|
1328
|
+
# so the folder name is only a guess at the repo name. Write the real address in
|
|
1329
|
+
# both files that state it — this header, and fletbox.config.json (update.url),
|
|
1330
|
+
# which is the URL an installed app polls for an update.
|
|
1331
|
+
`
|
|
1332
|
+
: "";
|
|
1333
|
+
return `name: Deploy ${appName}
|
|
1334
|
+
|
|
1335
|
+
# This app goes live at:
|
|
1336
|
+
# ${site}
|
|
1337
|
+
${todo}#
|
|
1338
|
+
# Web: AUTOMATIC. Every push to main (and every manual run) builds a fresh site;
|
|
1339
|
+
# only the deploy job can touch Pages, so a broken bundle never replaces the
|
|
1340
|
+
# live one. Enable the destination once: Settings -> Pages -> Build and
|
|
1341
|
+
# deployment -> Source: GitHub Actions. Until you do, runs are green and no site
|
|
1342
|
+
# exists — the "Where this lives" step below says so in the log.
|
|
1343
|
+
#
|
|
1344
|
+
# Android and iOS are NOT here: they live in build-native.yml and run only when
|
|
1345
|
+
# you start them, because a macOS runner is not free.
|
|
1140
1346
|
#
|
|
1141
1347
|
# One consequence to know about: this Pages site is also what an installed app
|
|
1142
1348
|
# polls for an OTA update (www/config.json), and the app compares the bundle
|
|
@@ -1218,11 +1424,15 @@ jobs:
|
|
|
1218
1424
|
# Printed before the deploy, because this is the address the whole workflow
|
|
1219
1425
|
# is about, and because a missing Pages setting otherwise shows up as a
|
|
1220
1426
|
# green run with no site: Pages lower-cases the owner, hence the tr.
|
|
1427
|
+
# The file header names one address too; this is the authority, because it
|
|
1428
|
+
# reads the repo the run actually belongs to — they disagree when the repo
|
|
1429
|
+
# was created with a different name than the folder, or never pushed.
|
|
1221
1430
|
- name: Where this lives
|
|
1222
1431
|
run: |
|
|
1223
1432
|
owner=$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')
|
|
1224
1433
|
repo=\${GITHUB_REPOSITORY#*/}
|
|
1225
1434
|
echo " site: https://$owner.github.io/$repo/"
|
|
1435
|
+
echo " header: ${site}"
|
|
1226
1436
|
echo " version: $(node -p "require('./package.json').version")"
|
|
1227
1437
|
echo " If a green run leaves that 404, the source is wrong:"
|
|
1228
1438
|
echo " Settings -> Pages -> Build and deployment -> Source: GitHub Actions"
|
|
@@ -1273,6 +1483,7 @@ jobs:
|
|
|
1273
1483
|
echo " live at: \${{ steps.deployment.outputs.page_url }}"
|
|
1274
1484
|
echo "::notice title=Site published to GitHub Pages::\${{ steps.deployment.outputs.page_url }}"
|
|
1275
1485
|
`;
|
|
1486
|
+
};
|
|
1276
1487
|
|
|
1277
1488
|
// Native builds: the same two jobs, but they run ONLY when started by hand from
|
|
1278
1489
|
// Actions -> Run workflow, with a choice of platform. A push never pays for a
|
|
@@ -1438,7 +1649,9 @@ package-lock.json
|
|
|
1438
1649
|
|
|
1439
1650
|
export const readme = (name, _template, appId) => `# ${name}
|
|
1440
1651
|
|
|
1441
|
-
FletBox app with 3 screens: Root, Home
|
|
1652
|
+
FletBox app with 3 screens: Root, Home (the design system) and About
|
|
1653
|
+
(preferences), plus Drawer menu. Read \`src/screens/HomeScreen.js\` first: it is
|
|
1654
|
+
the contract this app is built on, and \`npm run ui\` enforces it.
|
|
1442
1655
|
The generated Capacitor configuration uses package id \`${appId}\`.
|
|
1443
1656
|
|
|
1444
1657
|
## Quick Start
|
|
@@ -1456,6 +1669,10 @@ framework from scratch:
|
|
|
1456
1669
|
|
|
1457
1670
|
- \`AGENTS.md\` — the rules: FletBox syntax only, real widgets and props only, and the
|
|
1458
1671
|
verification loop it must run after every edit.
|
|
1672
|
+
- \`.claude/skills/fletbox-clean-ui/SKILL.md\` — the design rules themselves, numbered
|
|
1673
|
+
00 to 20 and measured: which token, which step of the scale, which contrast. The
|
|
1674
|
+
same text is at \`.qoder/skills/fletbox-clean-ui/SKILL.md\` and as a Cursor rule in
|
|
1675
|
+
\`.cursor/rules/fletbox-clean-ui.mdc\`.
|
|
1459
1676
|
- \`.claude/CLAUDE.md\`, \`.cursor/rules/fletbox.mdc\` — short pointers to \`AGENTS.md\` for
|
|
1460
1677
|
Claude Code and Cursor.
|
|
1461
1678
|
- \`.fletbox/api/cheatsheet.md\` — every widget and its real props, generated from the
|
|
@@ -1465,11 +1682,16 @@ framework from scratch:
|
|
|
1465
1682
|
- \`.fletbox/memory/\` — notes the agent keeps between sessions, so it does not
|
|
1466
1683
|
rediscover the same thing twice.
|
|
1467
1684
|
- \`scripts/check-app.mjs\` — the gate that catches hallucinated APIs.
|
|
1685
|
+
- \`scripts/check-ui.mjs\` — the gate that catches an unclean screen.
|
|
1468
1686
|
|
|
1469
1687
|
\`\`\`bash
|
|
1470
1688
|
npm run check # scan src/ for invented widgets, props, imports
|
|
1471
1689
|
npm run check -- --props Button # print the real props of one widget
|
|
1472
1690
|
npm run check -- --list # print every public export
|
|
1691
|
+
npm run ui # clean-UI gate: tokens, scale, tap targets, labels,
|
|
1692
|
+
# contrast — each finding cites the rule it broke
|
|
1693
|
+
npm run ui -- --tokens # measured contrast of every token, on both surfaces
|
|
1694
|
+
npm run ui -- --strict # warnings (a one-off tint, an image with no alt) fail too
|
|
1473
1695
|
npm run fix # rewrite typos it can prove (imports, prop keys)
|
|
1474
1696
|
npm run api # regenerate .fletbox/api/api.json and api.md
|
|
1475
1697
|
flet-box unpack Button # each prop, where it really goes, and what is dead
|
|
@@ -1477,9 +1699,9 @@ flet-box unpack Button --example # a paste-ready call using only verified prop
|
|
|
1477
1699
|
flet-box unpack Modal --diff # where types, engine, docs and code disagree
|
|
1478
1700
|
\`\`\`
|
|
1479
1701
|
|
|
1480
|
-
\`npm run check\`
|
|
1481
|
-
\`flet-box\`, regenerate the cheatsheet with \`flet-box check-api --emit-api\`
|
|
1482
|
-
machine-readable surface with \`npm run api\`.
|
|
1702
|
+
\`npm run check\` and \`npm run ui\` exit non-zero on a real error and also run in CI. If
|
|
1703
|
+
you upgrade \`flet-box\`, regenerate the cheatsheet with \`flet-box check-api --emit-api\`
|
|
1704
|
+
and the machine-readable surface with \`npm run api\`.
|
|
1483
1705
|
|
|
1484
1706
|
## Android
|
|
1485
1707
|
|
|
@@ -1533,7 +1755,7 @@ run \`npm run ios:open\`.
|
|
|
1533
1755
|
|
|
1534
1756
|
Replace \`assets/logo.png\` with a 1024×1024 logo before distributing; the icon,
|
|
1535
1757
|
the launch images and the launch background are all generated from it. The
|
|
1536
|
-
launch background is the app brand color
|
|
1758
|
+
launch background is the app brand color \`${SHELL_BRAND}\`, which lives in
|
|
1537
1759
|
\`scripts/sync-ios-assets.mjs\`.
|
|
1538
1760
|
|
|
1539
1761
|
Capacitor uses a local HTTPS origin (\`https://localhost\`) in the Android and
|
|
@@ -1545,10 +1767,12 @@ CORS configuration and use HTTPS for the remote service.
|
|
|
1545
1767
|
Two workflow files ship with the project, and they answer to different triggers:
|
|
1546
1768
|
|
|
1547
1769
|
- \`.github/workflows/build.yml\` — **the web, automatic**. Every push to \`main\`
|
|
1548
|
-
(and
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1770
|
+
(and a manual run) builds \`www/\` and publishes it to **GitHub Pages**, in two
|
|
1771
|
+
jobs: **Build site** checks the app, bundles it and uploads the result; **Deploy
|
|
1772
|
+
to Pages** is the only job that can replace the live site, so a bundle that
|
|
1773
|
+
fails to build leaves the current one running. A \`v*\` tag is deliberately not
|
|
1774
|
+
a trigger: the Pages environment refuses a run that starts from a tag, so the
|
|
1775
|
+
release is deployed by running this workflow again after tagging.
|
|
1552
1776
|
- \`.github/workflows/build-native.yml\` — **Android and iOS, manual only**. Nothing
|
|
1553
1777
|
runs on a push. Open **Actions → "… — native (manual)" → Run workflow** and pick
|
|
1554
1778
|
\`android\`, \`ios\` or \`both\`. Each job uploads its build as a run artifact and
|
|
@@ -1564,8 +1788,12 @@ Two workflow files ship with the project, and they answer to different triggers:
|
|
|
1564
1788
|
and the **Published** step prints it again as a run annotation, so you do not
|
|
1565
1789
|
have to hunt for the environment box under the job.
|
|
1566
1790
|
|
|
1567
|
-
The
|
|
1568
|
-
|
|
1791
|
+
The address this project publishes to is written at the top of
|
|
1792
|
+
\`.github/workflows/build.yml\`. \`flet-box create <name> --repo <user>/<repo>\`
|
|
1793
|
+
fills it in from the repo; without \`--repo\` it stays
|
|
1794
|
+
\`https://TU-USUARIO.github.io/<name>/\`, and the header says which two files to
|
|
1795
|
+
edit. That is a sub-path, so \`index.html\` uses relative asset paths and works
|
|
1796
|
+
without changes.
|
|
1569
1797
|
|
|
1570
1798
|
**Why a push does not update anyone's app:** this same address is the OTA feed
|
|
1571
1799
|
installed apps poll, and the update check compares bundle **hashes**, not version
|
|
@@ -1678,9 +1906,13 @@ updates:
|
|
|
1678
1906
|
|
|
1679
1907
|
## Features
|
|
1680
1908
|
|
|
1681
|
-
-
|
|
1682
|
-
-
|
|
1683
|
-
|
|
1909
|
+
- Root screen: one filled action, one quiet one
|
|
1910
|
+
- Home screen: the spacing rhythm, the type scale and the three color roles,
|
|
1911
|
+
drawn from the framework's own scale
|
|
1912
|
+
- About screen: \`useState\`, an \`Input\`, a \`Dropdown\`, a labelled \`Switch\`,
|
|
1913
|
+
a \`Slider\` that moves the base font size, a \`SnackBar\` reply and an
|
|
1914
|
+
\`AlertDialog\` before the destructive action
|
|
1915
|
+
- Neutral app bar with a theme toggle that carries a \`Tooltip\` name
|
|
1684
1916
|
- Drawer menu for navigation
|
|
1685
1917
|
- PWA icons and Capacitor Android launcher/splash assets
|
|
1686
1918
|
- Capacitor iOS project, icon and launch screen assets
|
|
@@ -1715,22 +1947,91 @@ MIT
|
|
|
1715
1947
|
|
|
1716
1948
|
// Additional screen for adaptive template
|
|
1717
1949
|
export const profileScreenJs = () => `// screens/ProfileScreen.js
|
|
1718
|
-
import { Container, Column, Text, Icon, colors } from 'flet-box';
|
|
1950
|
+
import { Container, Column, Row, Text, Icon, Divider, colors, Storage, goTo } from 'flet-box';
|
|
1951
|
+
|
|
1952
|
+
// Two words, from whatever the reader typed on the Preferences screen.
|
|
1953
|
+
const initials = (name) =>
|
|
1954
|
+
name.trim().split(/\\s+/).slice(0, 2).map((word) => word[0]).join('').toUpperCase();
|
|
1955
|
+
|
|
1956
|
+
const linkRow = (icon, label, note, route) => Container({
|
|
1957
|
+
padding: 16,
|
|
1958
|
+
cursor: 'pointer',
|
|
1959
|
+
child: Row({
|
|
1960
|
+
gap: 12,
|
|
1961
|
+
alignItems: 'center',
|
|
1962
|
+
children: [
|
|
1963
|
+
Icon({ name: icon, size: 20, color: colors.textSecondary }),
|
|
1964
|
+
Column({
|
|
1965
|
+
flex: 1,
|
|
1966
|
+
gap: 4,
|
|
1967
|
+
alignItems: 'stretch',
|
|
1968
|
+
children: [
|
|
1969
|
+
Text({ text: label, size: 16, color: colors.text }),
|
|
1970
|
+
Text({ text: note, size: 12, color: colors.textSecondary })
|
|
1971
|
+
]
|
|
1972
|
+
}),
|
|
1973
|
+
Icon({ name: 'chevron_right', size: 20, color: colors.textSecondary })
|
|
1974
|
+
]
|
|
1975
|
+
}),
|
|
1976
|
+
onclick: () => goTo(route)
|
|
1977
|
+
});
|
|
1719
1978
|
|
|
1720
1979
|
export const ProfileScreen = () => {
|
|
1980
|
+
const name = Storage.getData('displayName') || '';
|
|
1981
|
+
|
|
1721
1982
|
return Container({
|
|
1722
1983
|
flex: 1,
|
|
1723
|
-
justifyContent: 'center',
|
|
1724
|
-
alignItems: 'center',
|
|
1725
1984
|
padding: 24,
|
|
1726
1985
|
backgroundColor: colors.background,
|
|
1727
1986
|
child: Column({
|
|
1728
|
-
gap:
|
|
1729
|
-
alignItems: '
|
|
1987
|
+
gap: 24,
|
|
1988
|
+
alignItems: 'stretch',
|
|
1989
|
+
maxWidth: 480,
|
|
1990
|
+
margin: '0 auto',
|
|
1730
1991
|
children: [
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1992
|
+
Row({
|
|
1993
|
+
gap: 16,
|
|
1994
|
+
alignItems: 'center',
|
|
1995
|
+
children: [
|
|
1996
|
+
Container({
|
|
1997
|
+
width: 64,
|
|
1998
|
+
height: 64,
|
|
1999
|
+
borderRadius: 999,
|
|
2000
|
+
backgroundColor: colors.primaryBg,
|
|
2001
|
+
display: 'flex',
|
|
2002
|
+
alignItems: 'center',
|
|
2003
|
+
justifyContent: 'center',
|
|
2004
|
+
child: Text({
|
|
2005
|
+
text: initials(name) || 'P',
|
|
2006
|
+
size: 24,
|
|
2007
|
+
weight: 'bold',
|
|
2008
|
+
color: colors.primary
|
|
2009
|
+
})
|
|
2010
|
+
}),
|
|
2011
|
+
Column({
|
|
2012
|
+
flex: 1,
|
|
2013
|
+
gap: 4,
|
|
2014
|
+
alignItems: 'stretch',
|
|
2015
|
+
children: [
|
|
2016
|
+
Text({ text: name || 'No name yet', size: 18, weight: 'bold', color: colors.text }),
|
|
2017
|
+
Text({
|
|
2018
|
+
text: name ? 'Saved on this device' : 'Add one under Preferences',
|
|
2019
|
+
size: 14,
|
|
2020
|
+
color: colors.textSecondary
|
|
2021
|
+
})
|
|
2022
|
+
]
|
|
2023
|
+
})
|
|
2024
|
+
]
|
|
2025
|
+
}),
|
|
2026
|
+
Divider(),
|
|
2027
|
+
Column({
|
|
2028
|
+
gap: 4,
|
|
2029
|
+
alignItems: 'stretch',
|
|
2030
|
+
children: [
|
|
2031
|
+
linkRow('tune', 'Preferences', 'name, region, theme and text size', '/about'),
|
|
2032
|
+
linkRow('palette', 'Design system', 'the spacing, type and color scales', '/home')
|
|
2033
|
+
]
|
|
2034
|
+
})
|
|
1734
2035
|
]
|
|
1735
2036
|
})
|
|
1736
2037
|
});
|
|
@@ -1751,38 +2052,70 @@ export { AboutScreen } from './AboutScreen.js';${
|
|
|
1751
2052
|
|
|
1752
2053
|
// Layout components
|
|
1753
2054
|
export const appBarComponentJs = () => `// components/layouts/AppBarComponent.js
|
|
1754
|
-
import { AppBar, Icon, colors, openDrawer } from 'flet-box';
|
|
2055
|
+
import { AppBar, Icon, Tooltip, colors, openDrawer, toggleTheme, getTheme } from 'flet-box';
|
|
1755
2056
|
|
|
2057
|
+
// The bar is neutral — the surface token, not the brand — because a coloured strip
|
|
2058
|
+
// on every screen competes with the one action the screen is asking for. One
|
|
2059
|
+
// elevation step is enough to keep it off the page.
|
|
2060
|
+
//
|
|
2061
|
+
// It reads the theme instead of subscribing to it: setTheme() re-renders the app,
|
|
2062
|
+
// so a component that subscribed would add a listener on every render and never
|
|
2063
|
+
// remove it.
|
|
1756
2064
|
export const AppBarComponent = ({ title, isMobile = false, showBack = false, onBack }) => {
|
|
2065
|
+
const isDark = getTheme() === 'dark';
|
|
2066
|
+
|
|
1757
2067
|
return AppBar({
|
|
1758
2068
|
title: title,
|
|
1759
|
-
backgroundColor: colors.
|
|
1760
|
-
titleColor:
|
|
1761
|
-
|
|
2069
|
+
backgroundColor: colors.surface,
|
|
2070
|
+
titleColor: colors.text,
|
|
2071
|
+
iconColor: colors.text,
|
|
2072
|
+
centerTitle: false,
|
|
1762
2073
|
showBackButton: showBack,
|
|
1763
|
-
leading: isMobile ?
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
2074
|
+
leading: isMobile ? Tooltip({
|
|
2075
|
+
text: 'Open the menu',
|
|
2076
|
+
position: 'bottom',
|
|
2077
|
+
child: Icon({
|
|
2078
|
+
name: 'menu',
|
|
2079
|
+
size: 24,
|
|
2080
|
+
height: 40,
|
|
2081
|
+
color: colors.text,
|
|
2082
|
+
onclick: () => openDrawer()
|
|
2083
|
+
})
|
|
1768
2084
|
}) : null,
|
|
2085
|
+
// An icon-only control still needs a name: the Tooltip is what says so.
|
|
2086
|
+
actions: [
|
|
2087
|
+
Tooltip({
|
|
2088
|
+
text: isDark ? 'Switch to light' : 'Switch to dark',
|
|
2089
|
+
position: 'bottom',
|
|
2090
|
+
child: Icon({
|
|
2091
|
+
name: isDark ? 'light_mode' : 'dark_mode',
|
|
2092
|
+
size: 24,
|
|
2093
|
+
height: 40,
|
|
2094
|
+
color: colors.text,
|
|
2095
|
+
onclick: () => toggleTheme()
|
|
2096
|
+
})
|
|
2097
|
+
})
|
|
2098
|
+
],
|
|
1769
2099
|
onBackPress: onBack,
|
|
1770
|
-
elevation:
|
|
2100
|
+
elevation: 1
|
|
1771
2101
|
});
|
|
1772
2102
|
};
|
|
1773
2103
|
`;
|
|
1774
2104
|
|
|
1775
2105
|
export const bottomNavJs = () => `// components/layouts/BottomNav.js
|
|
1776
|
-
import { BottomNavigation, colors, goTo } from 'flet-box';
|
|
2106
|
+
import { BottomNavigation, colors, goTo, getCurrentPath } from 'flet-box';
|
|
1777
2107
|
|
|
1778
|
-
|
|
2108
|
+
// Scaffold re-runs this on every route change, which is why the current path is
|
|
2109
|
+
// read here rather than passed in by the app.
|
|
2110
|
+
export const BottomNav = ({ items = [] } = {}) => {
|
|
1779
2111
|
const defaultItems = [
|
|
1780
|
-
{ icon: 'home', label: '
|
|
1781
|
-
{ icon: '
|
|
2112
|
+
{ icon: 'home', label: 'Start', route: '/' },
|
|
2113
|
+
{ icon: 'palette', label: 'System', route: '/home' },
|
|
2114
|
+
{ icon: 'tune', label: 'Preferences', route: '/about' }
|
|
1782
2115
|
];
|
|
1783
2116
|
const navItems = items.length ? items : defaultItems;
|
|
1784
2117
|
// BottomNavigation selects a tab by index, not by route.
|
|
1785
|
-
const currentIndex = Math.max(0, navItems.findIndex((item) => item.route ===
|
|
2118
|
+
const currentIndex = Math.max(0, navItems.findIndex((item) => item.route === getCurrentPath()));
|
|
1786
2119
|
return BottomNavigation({
|
|
1787
2120
|
items: navItems,
|
|
1788
2121
|
currentIndex,
|
|
@@ -1794,53 +2127,54 @@ export const BottomNav = ({ items = [], currentRoute } = {}) => {
|
|
|
1794
2127
|
`;
|
|
1795
2128
|
|
|
1796
2129
|
export const sidebarJs = () => `// components/layouts/Sidebar.js
|
|
1797
|
-
import { Container, Column, Row, Text, Icon, colors, goTo } from 'flet-box';
|
|
2130
|
+
import { Container, Column, Row, Text, Icon, colors, goTo, getCurrentPath } from 'flet-box';
|
|
1798
2131
|
|
|
1799
|
-
|
|
2132
|
+
// Scaffold re-runs this on every route change, so the active item can read the
|
|
2133
|
+
// route itself instead of being rebuilt by the app.
|
|
2134
|
+
export const Sidebar = ({ items = [] } = {}) => {
|
|
1800
2135
|
const defaultItems = [
|
|
1801
|
-
{ icon: '
|
|
1802
|
-
{ icon: '
|
|
1803
|
-
{ icon: '
|
|
2136
|
+
{ icon: 'home', label: 'Start here', route: '/' },
|
|
2137
|
+
{ icon: 'palette', label: 'Design system', route: '/home' },
|
|
2138
|
+
{ icon: 'tune', label: 'Preferences', route: '/about' }
|
|
1804
2139
|
];
|
|
1805
2140
|
const menuItems = items.length ? items : defaultItems;
|
|
1806
|
-
|
|
1807
|
-
|
|
2141
|
+
const currentRoute = getCurrentPath();
|
|
2142
|
+
// Scaffold puts this in a flex row *below* the app bar and sizes it with
|
|
2143
|
+
// leftNavBarWidth, so the sidebar has to stay in flow. A position: fixed
|
|
2144
|
+
// panel escapes that box: its header ends up hidden behind the bar and the
|
|
2145
|
+
// width prop stops meaning anything.
|
|
2146
|
+
return Column({
|
|
2147
|
+
height: '100%',
|
|
1808
2148
|
backgroundColor: colors.surface,
|
|
1809
2149
|
borderRight: \`1px solid \${colors.border}\`,
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
2150
|
+
children: [
|
|
2151
|
+
Container({
|
|
2152
|
+
padding: 24,
|
|
2153
|
+
borderBottom: \`1px solid \${colors.border}\`,
|
|
2154
|
+
child: Column({
|
|
2155
|
+
alignItems: 'center',
|
|
2156
|
+
children: [
|
|
2157
|
+
Icon({ name: 'rocket_launch', size: 40, color: colors.primary }),
|
|
2158
|
+
Text({ text: 'FletBox', size: 18, weight: 'bold', color: colors.text, marginTop: 12 })
|
|
2159
|
+
]
|
|
2160
|
+
})
|
|
2161
|
+
}),
|
|
2162
|
+
...menuItems.map(item => Container({
|
|
2163
|
+
padding: '12px 16px',
|
|
2164
|
+
cursor: 'pointer',
|
|
2165
|
+
backgroundColor: currentRoute === item.route ? colors.primaryBg : 'transparent',
|
|
2166
|
+
borderLeft: currentRoute === item.route ? \`3px solid \${colors.primary}\` : 'none',
|
|
2167
|
+
child: Row({
|
|
2168
|
+
gap: 12,
|
|
2169
|
+
alignItems: 'center',
|
|
2170
|
+
children: [
|
|
2171
|
+
Icon({ name: item.icon, size: 20, color: currentRoute === item.route ? colors.primary : colors.textSecondary }),
|
|
2172
|
+
Text({ text: item.label, size: 14, weight: currentRoute === item.route ? 'bold' : 'normal', color: colors.text })
|
|
2173
|
+
]
|
|
1826
2174
|
}),
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
backgroundColor: currentRoute === item.route ? \`\${colors.primary}15\` : 'transparent',
|
|
1831
|
-
borderLeft: currentRoute === item.route ? \`3px solid \${colors.primary}\` : 'none',
|
|
1832
|
-
child: Row({
|
|
1833
|
-
gap: 12,
|
|
1834
|
-
alignItems: 'center',
|
|
1835
|
-
children: [
|
|
1836
|
-
Icon({ name: item.icon, size: 20, color: currentRoute === item.route ? colors.primary : colors.textSecondary }),
|
|
1837
|
-
Text({ text: item.label, size: 14, color: currentRoute === item.route ? colors.primary : colors.text })
|
|
1838
|
-
]
|
|
1839
|
-
}),
|
|
1840
|
-
onclick: () => goTo(item.route)
|
|
1841
|
-
}))
|
|
1842
|
-
]
|
|
1843
|
-
})
|
|
2175
|
+
onclick: () => goTo(item.route)
|
|
2176
|
+
}))
|
|
2177
|
+
]
|
|
1844
2178
|
});
|
|
1845
2179
|
};
|
|
1846
2180
|
`;
|
|
@@ -1851,21 +2185,22 @@ import { Drawer, DrawerItem, Container, Column, Text, Icon, colors, closeDrawer
|
|
|
1851
2185
|
|
|
1852
2186
|
export const DrawerMenu = ({ items = [] } = {}) => {
|
|
1853
2187
|
const defaultItems = [
|
|
1854
|
-
{ icon: '
|
|
1855
|
-
{ icon: '
|
|
1856
|
-
{ icon: '
|
|
2188
|
+
{ icon: 'home', label: 'Start here', route: '/' },
|
|
2189
|
+
{ icon: 'palette', label: 'Design system', route: '/home' },
|
|
2190
|
+
{ icon: 'tune', label: 'Preferences', route: '/about' }
|
|
1857
2191
|
];
|
|
1858
2192
|
const menuItems = items.length ? items : defaultItems;
|
|
1859
2193
|
return Drawer({
|
|
1860
2194
|
header: Container({
|
|
1861
|
-
padding:
|
|
1862
|
-
backgroundColor: colors.
|
|
2195
|
+
padding: 24,
|
|
2196
|
+
backgroundColor: colors.surface,
|
|
2197
|
+
borderBottom: \`1px solid \${colors.border}\`,
|
|
1863
2198
|
child: Column({
|
|
1864
2199
|
alignItems: 'center',
|
|
1865
2200
|
children: [
|
|
1866
|
-
Icon({ name: 'rocket_launch', size:
|
|
1867
|
-
Text({ text: 'FletBox', size: 18, weight: 'bold', color:
|
|
1868
|
-
Text({ text: '
|
|
2201
|
+
Icon({ name: 'rocket_launch', size: 40, color: colors.primary }),
|
|
2202
|
+
Text({ text: 'FletBox', size: 18, weight: 'bold', color: colors.text, marginTop: 12 }),
|
|
2203
|
+
Text({ text: 'Starter app', size: 12, color: colors.textSecondary })
|
|
1869
2204
|
]
|
|
1870
2205
|
})
|
|
1871
2206
|
}),
|
|
@@ -1876,7 +2211,7 @@ export const DrawerMenu = ({ items = [] } = {}) => {
|
|
|
1876
2211
|
onPress: () => closeDrawer()
|
|
1877
2212
|
})),
|
|
1878
2213
|
footer: Container({
|
|
1879
|
-
padding:
|
|
2214
|
+
padding: 16,
|
|
1880
2215
|
borderTop: \`1px solid \${colors.border}\`,
|
|
1881
2216
|
child: Text({ text: 'Version 1.0.0', size: 12, color: colors.textSecondary, align: 'center' })
|
|
1882
2217
|
})
|
|
@@ -1887,6 +2222,10 @@ export const DrawerMenu = ({ items = [] } = {}) => {
|
|
|
1887
2222
|
// App templates (blank, basic, full, sidebar, adaptive)
|
|
1888
2223
|
export const blankAppJs = () => `// app.js - Blank template
|
|
1889
2224
|
import { runApp, Container, Text, colors } from 'flet-box';
|
|
2225
|
+
// Registers this app's design tokens. Without it every screen paints
|
|
2226
|
+
// with the framework's own palette, and the file that looks like the design
|
|
2227
|
+
// system is decoration.
|
|
2228
|
+
import './database/themes.js';
|
|
1890
2229
|
|
|
1891
2230
|
const App = () => Container({
|
|
1892
2231
|
flex: 1,
|
|
@@ -1900,7 +2239,11 @@ runApp(App, 'root');
|
|
|
1900
2239
|
`;
|
|
1901
2240
|
|
|
1902
2241
|
export const basicAppJs = () => `// app.js - Basic template (AppBar + Drawer)
|
|
1903
|
-
import { runApp, Scaffold, colors } from 'flet-box';
|
|
2242
|
+
import { runApp, Scaffold, colors, useState, dimensions } from 'flet-box';
|
|
2243
|
+
// Registers this app's design tokens. Without it every screen paints
|
|
2244
|
+
// with the framework's own palette, and the file that looks like the design
|
|
2245
|
+
// system is decoration.
|
|
2246
|
+
import './database/themes.js';
|
|
1904
2247
|
import { AppBarComponent } from './components/layouts/AppBarComponent.js';
|
|
1905
2248
|
import { DrawerMenu } from './components/layouts/DrawerMenu.js';
|
|
1906
2249
|
|
|
@@ -1915,12 +2258,21 @@ const routes = {
|
|
|
1915
2258
|
'/about': AboutScreen
|
|
1916
2259
|
};
|
|
1917
2260
|
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2261
|
+
// The drawer is only reachable through the AppBar's menu button, so the shell has
|
|
2262
|
+
// to know whether it is on a phone. Key-based state keeps the width across
|
|
2263
|
+
// re-renders; the resize listener is registered once, at module scope.
|
|
2264
|
+
const [viewportWidth, setViewportWidth] = useState('viewportWidth', dimensions.getWidth());
|
|
2265
|
+
dimensions.addListener(({ width }) => setViewportWidth(width));
|
|
2266
|
+
|
|
2267
|
+
const MyApp = () => {
|
|
2268
|
+
const isMobile = viewportWidth.valueOf() < 768;
|
|
2269
|
+
return Scaffold({
|
|
2270
|
+
routes,
|
|
2271
|
+
appBar: AppBarComponent({ title: 'FletBox', isMobile }),
|
|
2272
|
+
drawer: DrawerMenu(),
|
|
2273
|
+
backgroundColor: colors.background
|
|
2274
|
+
});
|
|
2275
|
+
};
|
|
1924
2276
|
|
|
1925
2277
|
runApp(MyApp, 'root');
|
|
1926
2278
|
`;
|
|
@@ -1928,6 +2280,10 @@ runApp(MyApp, 'root');
|
|
|
1928
2280
|
export const fullAppJs =
|
|
1929
2281
|
() => `// app.js - Full template (AppBar + Drawer + BottomNav)
|
|
1930
2282
|
import { runApp, Scaffold, colors, useState, dimensions } from 'flet-box';
|
|
2283
|
+
// Registers this app's design tokens. Without it every screen paints
|
|
2284
|
+
// with the framework's own palette, and the file that looks like the design
|
|
2285
|
+
// system is decoration.
|
|
2286
|
+
import './database/themes.js';
|
|
1931
2287
|
import { AppBarComponent } from './components/layouts/AppBarComponent.js';
|
|
1932
2288
|
import { DrawerMenu } from './components/layouts/DrawerMenu.js';
|
|
1933
2289
|
import { BottomNav } from './components/layouts/BottomNav.js';
|
|
@@ -1956,7 +2312,9 @@ const MyApp = () => {
|
|
|
1956
2312
|
routes,
|
|
1957
2313
|
appBar: AppBarComponent({ title: 'FletBox', isMobile }),
|
|
1958
2314
|
drawer: DrawerMenu(),
|
|
1959
|
-
|
|
2315
|
+
// A function, not an element: Scaffold re-runs it on each route change,
|
|
2316
|
+
// which is what keeps the active tab lit.
|
|
2317
|
+
bottomBar: isMobile ? () => BottomNav() : null,
|
|
1960
2318
|
backgroundColor: colors.background
|
|
1961
2319
|
});
|
|
1962
2320
|
};
|
|
@@ -1966,6 +2324,10 @@ runApp(MyApp, 'root');
|
|
|
1966
2324
|
|
|
1967
2325
|
export const sidebarAppJs = () => `// app.js - Sidebar template (desktop only)
|
|
1968
2326
|
import { runApp, Scaffold, colors } from 'flet-box';
|
|
2327
|
+
// Registers this app's design tokens. Without it every screen paints
|
|
2328
|
+
// with the framework's own palette, and the file that looks like the design
|
|
2329
|
+
// system is decoration.
|
|
2330
|
+
import './database/themes.js';
|
|
1969
2331
|
import { AppBarComponent } from './components/layouts/AppBarComponent.js';
|
|
1970
2332
|
import { Sidebar } from './components/layouts/Sidebar.js';
|
|
1971
2333
|
import { RootScreen, HomeScreen, AboutScreen } from './screens/index.js';
|
|
@@ -1979,7 +2341,9 @@ const routes = {
|
|
|
1979
2341
|
const MyApp = () => Scaffold({
|
|
1980
2342
|
routes,
|
|
1981
2343
|
appBar: AppBarComponent({ title: 'FletBox', isMobile: false }),
|
|
1982
|
-
|
|
2344
|
+
// A function, not an element: Scaffold re-runs it on each route change,
|
|
2345
|
+
// which is what keeps the active row lit.
|
|
2346
|
+
leftNavBar: () => Sidebar(),
|
|
1983
2347
|
backgroundColor: colors.background
|
|
1984
2348
|
});
|
|
1985
2349
|
|
|
@@ -1989,6 +2353,10 @@ runApp(MyApp, 'root');
|
|
|
1989
2353
|
export const adaptiveAppJs =
|
|
1990
2354
|
() => `// app.js - Adaptive template (mobile + desktop)
|
|
1991
2355
|
import { runApp, Scaffold, colors, useState, dimensions } from 'flet-box';
|
|
2356
|
+
// Registers this app's design tokens. Without it every screen paints
|
|
2357
|
+
// with the framework's own palette, and the file that looks like the design
|
|
2358
|
+
// system is decoration.
|
|
2359
|
+
import './database/themes.js';
|
|
1992
2360
|
import { AppBarComponent } from './components/layouts/AppBarComponent.js';
|
|
1993
2361
|
import { DrawerMenu } from './components/layouts/DrawerMenu.js';
|
|
1994
2362
|
import { BottomNav } from './components/layouts/BottomNav.js';
|
|
@@ -2013,6 +2381,16 @@ const routes = {
|
|
|
2013
2381
|
const [viewportWidth, setViewportWidth] = useState('viewportWidth', dimensions.getWidth());
|
|
2014
2382
|
dimensions.addListener(({ width }) => setViewportWidth(width));
|
|
2015
2383
|
|
|
2384
|
+
// One menu, three surfaces: the drawer and the bottom bar on a phone, the
|
|
2385
|
+
// sidebar on a desktop. The labels stay short because the bottom bar has to fit
|
|
2386
|
+
// all four across a 360px screen.
|
|
2387
|
+
const menu = [
|
|
2388
|
+
{ icon: 'home', label: 'Start', route: '/' },
|
|
2389
|
+
{ icon: 'palette', label: 'System', route: '/home' },
|
|
2390
|
+
{ icon: 'tune', label: 'Preferences', route: '/about' },
|
|
2391
|
+
{ icon: 'person', label: 'Profile', route: '/profile' }
|
|
2392
|
+
];
|
|
2393
|
+
|
|
2016
2394
|
const MyApp = () => {
|
|
2017
2395
|
// Structural read of the keyed viewport state, so crossing the breakpoint
|
|
2018
2396
|
// re-renders the shell (drawer + bottom bar <-> sidebar).
|
|
@@ -2020,9 +2398,11 @@ const MyApp = () => {
|
|
|
2020
2398
|
return Scaffold({
|
|
2021
2399
|
routes,
|
|
2022
2400
|
appBar: AppBarComponent({ title: 'FletBox', isMobile }),
|
|
2023
|
-
drawer: isMobile ? DrawerMenu() : null,
|
|
2024
|
-
|
|
2025
|
-
|
|
2401
|
+
drawer: isMobile ? DrawerMenu({ items: menu }) : null,
|
|
2402
|
+
// Functions, not elements: Scaffold re-runs them on each route change,
|
|
2403
|
+
// which is what keeps the active item lit.
|
|
2404
|
+
bottomBar: isMobile ? () => BottomNav({ items: menu }) : null,
|
|
2405
|
+
leftNavBar: !isMobile ? () => Sidebar({ items: menu }) : null,
|
|
2026
2406
|
backgroundColor: colors.background
|
|
2027
2407
|
});
|
|
2028
2408
|
};
|
|
@@ -2129,8 +2509,10 @@ export const agentsMd = (appName, template, version) =>
|
|
|
2129
2509
|
' routes,',
|
|
2130
2510
|
" appBar: AppBarComponent({ title: 'FletBox', isMobile }),",
|
|
2131
2511
|
' drawer: isMobile ? DrawerMenu() : null,',
|
|
2132
|
-
'
|
|
2133
|
-
'
|
|
2512
|
+
' // Functions, not elements: Scaffold re-runs a bar on every route change,',
|
|
2513
|
+
' // which is how the active item learns where the reader is.',
|
|
2514
|
+
' bottomBar: isMobile ? () => BottomNav() : null,',
|
|
2515
|
+
' leftNavBar: !isMobile ? () => Sidebar() : null, // NOT `sidebar:`',
|
|
2134
2516
|
' leftNavBarWidth: 240,',
|
|
2135
2517
|
" fab: isMobile ? ActionButton({ icon: 'add', onPress: open }) : null,",
|
|
2136
2518
|
' body: isMobile ? null : HomeScreen(),',
|
|
@@ -2180,7 +2562,7 @@ export const agentsMd = (appName, template, version) =>
|
|
|
2180
2562
|
'| --- | --- | --- |',
|
|
2181
2563
|
'| Brand | `colors.primary` | the one action the screen asks for: primary button, active tab, focus ring, links |',
|
|
2182
2564
|
'| Emphasis | `colors.secondary` | a deliberate second voice: gradients, badges, a highlighted stat |',
|
|
2183
|
-
'| Feedback | `colors.success` / `colors.warning` / `colors.danger` | status only — never as decoration |',
|
|
2565
|
+
'| 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 |',
|
|
2184
2566
|
'',
|
|
2185
2567
|
'Everything else is neutral: `colors.background`, `colors.surface`,',
|
|
2186
2568
|
'`colors.card`, `colors.text`, `colors.textSecondary`, `colors.textDisabled`,',
|
|
@@ -2190,10 +2572,18 @@ export const agentsMd = (appName, template, version) =>
|
|
|
2190
2572
|
' means one of them is wrong.',
|
|
2191
2573
|
'- Never type a hex or `rgb()` literal. If a token is missing, add it to a',
|
|
2192
2574
|
' `theme.js` in this app that re-exports `colors` — do not scatter the value.',
|
|
2193
|
-
'-
|
|
2194
|
-
'
|
|
2195
|
-
'
|
|
2196
|
-
'
|
|
2575
|
+
'- A frosted surface is `colors.glass` with `colors.glassBorder` plus a',
|
|
2576
|
+
' `backdropFilter`. It reads through to whatever is behind it, so there',
|
|
2577
|
+
' should be imagery or a gradient there, never body copy.',
|
|
2578
|
+
'- Text on a dark or brand fill is white, and it stays white: hierarchy comes',
|
|
2579
|
+
' from size and weight, not from fading the ink to `rgba(255,255,255,.72)` —',
|
|
2580
|
+
' faded white on `colors.primary` measures 3.16:1 and stops being body text.',
|
|
2581
|
+
'- `error` ≡ `danger`, and `*Bg` variants exist for alert fills. As **words**,',
|
|
2582
|
+
' `colors.danger` is 3.76:1 on white and fails AA: write `colors.dangerStrong`',
|
|
2583
|
+
' for the label of a delete or an inline error (6.47:1 on white, 5.42:1 inside',
|
|
2584
|
+
' `colors.errorBg`), and keep `colors.danger` for the dot, the border, the icon.',
|
|
2585
|
+
' Same reason `colors.warning` and `colors.success` are plates, never sentences.',
|
|
2586
|
+
' Measure any pair before arguing about it: `npm run ui -- --tokens`.',
|
|
2197
2587
|
'',
|
|
2198
2588
|
'**Space and type: an 8-based scale.** Use these numbers and nothing between',
|
|
2199
2589
|
'them. They are plain props (`padding`, `gap`, `margin`, `width`, `height`,',
|
|
@@ -2205,11 +2595,14 @@ export const agentsMd = (appName, template, version) =>
|
|
|
2205
2595
|
' 12 · control padding (input/button height − text)',
|
|
2206
2596
|
' 16 · default card padding, section gap, screen gutter',
|
|
2207
2597
|
' 18 · card-to-card rhythm on a dense grid',
|
|
2208
|
-
' 24 · between sections',
|
|
2598
|
+
' 24 · between sections, page gutter',
|
|
2209
2599
|
' 32 · between page blocks',
|
|
2600
|
+
' 40 · the smallest tap target, a roomy control',
|
|
2210
2601
|
' 48 · hero spacing',
|
|
2602
|
+
' 64 / 96 · a full-bleed section, a landing hero',
|
|
2211
2603
|
'radius 4 / 8 / 12 / 16 / 999 (pill)',
|
|
2212
|
-
'type 12 caption · 14 body-small · 16 body · 18 subtitle · 24 title
|
|
2604
|
+
'type 12 caption · 14 body-small · 16 body · 18 subtitle · 24 title',
|
|
2605
|
+
' 32 headline · 40 / 48 display (a hero, a big number)',
|
|
2213
2606
|
'weight 400 body · 500 label · 600 title · 700 headline',
|
|
2214
2607
|
'line-height 1.5 body · 1.2 heading',
|
|
2215
2608
|
'```',
|
|
@@ -2230,13 +2623,21 @@ export const agentsMd = (appName, template, version) =>
|
|
|
2230
2623
|
'npm run check # static gate: invented widgets/props, positional args,',
|
|
2231
2624
|
' # foreign imports, and broken imports between your own',
|
|
2232
2625
|
' # modules (a name a file never exports)',
|
|
2626
|
+
'npm run ui # clean-UI gate: the tokens and the scale above, 40px',
|
|
2627
|
+
' # targets, labels, contrast — every finding cites the',
|
|
2628
|
+
' # rule number it broke',
|
|
2233
2629
|
'npm run dev # serve on :8000 — check the UI actually renders',
|
|
2234
2630
|
'```',
|
|
2235
2631
|
'',
|
|
2236
|
-
'
|
|
2237
|
-
'
|
|
2632
|
+
'Both gates read the installed package, so they are always in sync with what',
|
|
2633
|
+
'runs. They exit non-zero on a real error: fix the code, not the checker. A',
|
|
2238
2634
|
'warning is allowed to stay only when you can justify it in one sentence.',
|
|
2239
2635
|
'',
|
|
2636
|
+
'`npm run ui` proves what is *written*; the rules it cites are the numbered ones',
|
|
2637
|
+
'in [.claude/skills/fletbox-clean-ui/SKILL.md](.claude/skills/fletbox-clean-ui/SKILL.md),',
|
|
2638
|
+
'and that file is the part a checker cannot do — hierarchy, copy, whether the',
|
|
2639
|
+
'screen solves the problem. Read it before writing a screen, not after.',
|
|
2640
|
+
'',
|
|
2240
2641
|
'When the error is a mistyped name, `npm run fix` rewrites it for you — a',
|
|
2241
2642
|
'relative path that does not resolve, a name the target module never exports,',
|
|
2242
2643
|
'an identifier used without its `import`, and a prop key the API does not know',
|
|
@@ -2285,8 +2686,7 @@ export const claudeMd = () =>
|
|
|
2285
2686
|
'# CLAUDE.md',
|
|
2286
2687
|
'',
|
|
2287
2688
|
'The rules for this project live in [AGENTS.md](./AGENTS.md). Read it first and',
|
|
2288
|
-
'follow it exactly — FletBox syntax only, real props only
|
|
2289
|
-
'every edit.',
|
|
2689
|
+
'follow it exactly — FletBox syntax only, real props only.',
|
|
2290
2690
|
'',
|
|
2291
2691
|
'Two shortcuts replace guessing:',
|
|
2292
2692
|
'',
|
|
@@ -2294,9 +2694,16 @@ export const claudeMd = () =>
|
|
|
2294
2694
|
' its type, default and engine target. `--example` gives a call you can paste,',
|
|
2295
2695
|
' `--diff` lists the props that are documented but dead or used but undeclared.',
|
|
2296
2696
|
' `.fletbox/api/api.json` is the same surface for all widgets, as data.',
|
|
2297
|
-
'- Design is
|
|
2298
|
-
'
|
|
2299
|
-
'
|
|
2697
|
+
'- Design is not improvised. The rules are a file: read',
|
|
2698
|
+
' [skills/fletbox-clean-ui/SKILL.md](skills/fletbox-clean-ui/SKILL.md) before',
|
|
2699
|
+
' writing a screen. `npm run ui` proves the part a checker can prove (tokens,',
|
|
2700
|
+
' the scale, 40px targets, labels, contrast) and cites the rule number it',
|
|
2701
|
+
' broke; AGENTS.md §4 has the tables. Scale: 4 · 8 · 12 · 16 · 18 · 24 · 32 ·',
|
|
2702
|
+
' 40 · 48 · 64 · 96 spacing, radii 4/8/12/16/999, type 12 · 14 · 16 · 18 · 24',
|
|
2703
|
+
' · 32 · 40 · 48.',
|
|
2704
|
+
'',
|
|
2705
|
+
'After every edit: `npm run check` (does it exist), `npm run ui` (is it clean),',
|
|
2706
|
+
'then look at the screen — the checker has no eyes.',
|
|
2300
2707
|
'',
|
|
2301
2708
|
'Memory: read `.fletbox/memory/MEMORY.md` before working, and write new entries',
|
|
2302
2709
|
'there when you learn something the next session should not rediscover.',
|
|
@@ -2320,11 +2727,15 @@ export const cursorRules = () =>
|
|
|
2320
2727
|
' `.fletbox/api/cheatsheet.md`, look it up: `flet-box unpack <Widget>` (or',
|
|
2321
2728
|
' `npm run check -- --props <Widget>`). If it is not listed, it does not exist —',
|
|
2322
2729
|
' do not invent it. `unpack --example` prints a call that is safe to paste.',
|
|
2323
|
-
'- Design rules:
|
|
2324
|
-
'
|
|
2325
|
-
'
|
|
2326
|
-
' 12
|
|
2327
|
-
'
|
|
2730
|
+
'- Design rules: read [fletbox-clean-ui.mdc](./fletbox-clean-ui.mdc) in this',
|
|
2731
|
+
' folder — the numbered clean-UI rules the agent is expected to follow. One',
|
|
2732
|
+
' brand color per screen, taken from the `colors` tokens — never a hex literal.',
|
|
2733
|
+
' Sizes come from the scale only: 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64',
|
|
2734
|
+
' · 96 spacing, radii 4/8/12/16/999, type 12 · 14 · 16 · 18 · 24 · 32 · 40 · 48.',
|
|
2735
|
+
' See AGENTS.md §4.',
|
|
2736
|
+
'- Run `npm run check` after every change (does this API exist) and `npm run ui`',
|
|
2737
|
+
' (is this screen clean: tokens, scale, 40px targets, labels, contrast). Fix',
|
|
2738
|
+
' what they report; a warning stays only with a one-sentence reason.',
|
|
2328
2739
|
'- Check `.fletbox/memory/MEMORY.md` at session start; record new non-obvious',
|
|
2329
2740
|
' findings there instead of re-deriving them.',
|
|
2330
2741
|
'',
|
|
@@ -2408,3 +2819,547 @@ export const checkAppScript = (appName) =>
|
|
|
2408
2819
|
'process.exit(exitCode);',
|
|
2409
2820
|
'',
|
|
2410
2821
|
].join('\n');
|
|
2822
|
+
|
|
2823
|
+
// ---------------------------------------------------------------------------
|
|
2824
|
+
// Clean-UI skill
|
|
2825
|
+
// ---------------------------------------------------------------------------
|
|
2826
|
+
// The design-system contract, merged from two sources and then measured against
|
|
2827
|
+
// this framework: Angelene Wallis, "No bullshit guide to UX" (2022), and "The UI/
|
|
2828
|
+
// UX Playbook" (uxpeak, 144 pages). Every number is either one the books state or
|
|
2829
|
+
// one read off the installed palette; every prop named here exists in the API the
|
|
2830
|
+
// app is generated with. The rules are numbered so `flet-box check-ui` can cite
|
|
2831
|
+
// them, and so the agent can quote one back when it breaks one on purpose.
|
|
2832
|
+
//
|
|
2833
|
+
// Two halves, always kept in agreement: what the checker proves statically, and
|
|
2834
|
+
// what only a person can see. The skill names both so the agent never claims the
|
|
2835
|
+
// checker covers more than it does.
|
|
2836
|
+
export const cleanUiSkill = () =>
|
|
2837
|
+
[
|
|
2838
|
+
"---",
|
|
2839
|
+
"name: fletbox-clean-ui",
|
|
2840
|
+
"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.",
|
|
2841
|
+
"---",
|
|
2842
|
+
"",
|
|
2843
|
+
"# Clean UI — the system rules",
|
|
2844
|
+
"",
|
|
2845
|
+
"You are not decorating screens. You are extending a system that every later",
|
|
2846
|
+
"screen will copy. A decision that cannot be restated as a rule is a defect, not",
|
|
2847
|
+
"a style.",
|
|
2848
|
+
"",
|
|
2849
|
+
"When two goals conflict, take them in this order:",
|
|
2850
|
+
"",
|
|
2851
|
+
"1 Consistency · 2 Clarity · 3 Accessibility · 4 Usability · 5 Scalability",
|
|
2852
|
+
"· 6 Maintainability · 7 Conversion",
|
|
2853
|
+
"",
|
|
2854
|
+
"Never optimize for aesthetics alone. Beauty is what is left when the system is",
|
|
2855
|
+
"correct; it is an outcome, never a target you can hit directly.",
|
|
2856
|
+
"",
|
|
2857
|
+
"## 00 The two gates",
|
|
2858
|
+
"",
|
|
2859
|
+
"```bash",
|
|
2860
|
+
"npm run check # the API is real: widgets, props, imports",
|
|
2861
|
+
"npm run ui # the screen follows these rules (codes map to rule numbers)",
|
|
2862
|
+
"npm run ui -- --tokens # measured contrast of every token, on both surfaces",
|
|
2863
|
+
"npm run dev # then look at it: the checker has no eyes",
|
|
2864
|
+
"```",
|
|
2865
|
+
"",
|
|
2866
|
+
"A finding you keep needs one sentence of justification, in the commit or in",
|
|
2867
|
+
"`.fletbox/memory/`. If that sentence is \"the design wants it\", the design is",
|
|
2868
|
+
"what needs writing down, because the next screen will copy it.",
|
|
2869
|
+
"",
|
|
2870
|
+
"Sources, cited as `NBG p.N` (No bullshit guide to UX) and `Playbook p.N`",
|
|
2871
|
+
"(The UI/UX Playbook; the PDF page — the printed folio is one lower). Every",
|
|
2872
|
+
"number below is either stated by one of them or measured on this palette.",
|
|
2873
|
+
"Every prop in every call below exists in `.fletbox/api/api.json`.",
|
|
2874
|
+
"",
|
|
2875
|
+
"## 01 System before component",
|
|
2876
|
+
"",
|
|
2877
|
+
"Define the token, then the component, then the screen. In that order, always.",
|
|
2878
|
+
"Components are outputs; the system is the source of truth.",
|
|
2879
|
+
"",
|
|
2880
|
+
"- A screen that needs a value the system does not have adds a token to",
|
|
2881
|
+
" `src/database/themes.js`. It does not write the value inline.",
|
|
2882
|
+
"- A component that needs a special exception: challenge the exception first, and",
|
|
2883
|
+
" if it survives, change its siblings too. Two components solving one problem",
|
|
2884
|
+
" differently get unified, not documented.",
|
|
2885
|
+
"- If you cannot say which rule a decision follows, do not make it.",
|
|
2886
|
+
"",
|
|
2887
|
+
"## 02 Consistency is sacred",
|
|
2888
|
+
"",
|
|
2889
|
+
"No arbitrary differences. If a person learns one pattern here, they must not",
|
|
2890
|
+
"relearn it anywhere else (Playbook p.038).",
|
|
2891
|
+
"",
|
|
2892
|
+
"- One button scheme for the whole app: the primary is `variant: \"filled\"`, the",
|
|
2893
|
+
" secondary `variant: \"outlined\"`, the tertiary `variant: \"text\"`. Never a",
|
|
2894
|
+
" per-card button color chosen to match the picture — people then wonder whether",
|
|
2895
|
+
" each button does something different (p.040-041).",
|
|
2896
|
+
" A filled `Button` defaults to `bgColor: colors.surface` with `colors.text` on",
|
|
2897
|
+
" it, so the primary action must name the brand pair: `bgColor: colors.primary`",
|
|
2898
|
+
" and `color: colors.onPrimary`. An outlined one must name its border (`color:`),",
|
|
2899
|
+
" or it inherits the surface it sits on and disappears.",
|
|
2900
|
+
"- One radius language per screen. A rounded picture next to a square button reads",
|
|
2901
|
+
" as two products (p.039).",
|
|
2902
|
+
"- One icon family, one weight, one size per screen. The one allowed mix is state:",
|
|
2903
|
+
" a selected tab filled, unselected tabs outlined (p.045-046).",
|
|
2904
|
+
"- Cards in the same row share a height. When the copy is uncontrollable, fix the",
|
|
2905
|
+
" height and bottom-align the action; the empty space goes in the middle",
|
|
2906
|
+
" (p.043-044).",
|
|
2907
|
+
"- Same purpose, same shape: identical inputs get the same `height`,",
|
|
2908
|
+
" `borderRadius`, states and validation behavior (rule 13).",
|
|
2909
|
+
"- Images inside a repeated card sit in the same slot, same size, same crop",
|
|
2910
|
+
" (`fit`, `shape`), whatever the picture is (p.042).",
|
|
2911
|
+
"",
|
|
2912
|
+
"## 03 Design tokens only",
|
|
2913
|
+
"",
|
|
2914
|
+
"No arbitrary values, no hex, no `px` strings. Every value comes from the scale.",
|
|
2915
|
+
"",
|
|
2916
|
+
"```",
|
|
2917
|
+
"space 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96",
|
|
2918
|
+
"radius 4 · 8 · 12 · 16 · 999 (pill)",
|
|
2919
|
+
"type 12 caption · 14 body-small · 16 body · 18 subtitle · 24 title",
|
|
2920
|
+
" 32 headline · 40 display-sm · 48 display",
|
|
2921
|
+
"weight 400 body · 500 label · 600 title · 700 headline",
|
|
2922
|
+
"line 1.5-1.6 body · 1.2-1.3 heading",
|
|
2923
|
+
"depth elevation 0 · 1 · 2 · 3 · 4 · 5",
|
|
2924
|
+
"```",
|
|
2925
|
+
"",
|
|
2926
|
+
"- The base unit is 4 and everything is a multiple of it (Playbook p.028). A value",
|
|
2927
|
+
" like 13, 19, 21 or 31 is not a design decision, it is drift.",
|
|
2928
|
+
"- Never type a hex or `rgb()` outside `src/database/themes.js`",
|
|
2929
|
+
" (`RAW_COLOR`). If a token is missing, add it there.",
|
|
2930
|
+
"- Never build a color by appending alpha to a token — `colors.primary + \"15\"` is",
|
|
2931
|
+
" a one-off shade the rest of the app cannot reuse (`HAND_MIXED_COLOR`). The",
|
|
2932
|
+
" tints exist: `colors.primaryBg`, `colors.errorBg`, `colors.successBg`,",
|
|
2933
|
+
" `colors.warningBg`, `colors.infoBg`.",
|
|
2934
|
+
"- A frosted surface is `colors.glass` with `colors.glassBorder` and a",
|
|
2935
|
+
" `backdropFilter`; it is translucent on purpose, so put it over imagery,",
|
|
2936
|
+
" not over body text. A hand-written `rgba()` is a `RAW_COLOR` like any other.",
|
|
2937
|
+
"- Text on a brand fill is `colors.onPrimary`, never a literal white.",
|
|
2938
|
+
"- Tap targets are at least 40 on a side (`SMALL_TAP_TARGET`). Reach it with",
|
|
2939
|
+
" `height`, not with padding. The books argue this qualitatively (Fitts, p.099);",
|
|
2940
|
+
" 40 is the floor this system enforces and 44 is what a phone primary action",
|
|
2941
|
+
" gets.",
|
|
2942
|
+
"- `size: \"small\"` on a `Button` is for dense secondary actions, never for the one",
|
|
2943
|
+
" thing a phone screen asks for.",
|
|
2944
|
+
"",
|
|
2945
|
+
"## 04 Hierarchy must be obvious in three seconds",
|
|
2946
|
+
"",
|
|
2947
|
+
"A person must answer three questions without reading: what is this, what",
|
|
2948
|
+
"matters, what happens next. There are exactly four levers — size, color, weight,",
|
|
2949
|
+
"position (Playbook p.007). If everything is emphasized, nothing is.",
|
|
2950
|
+
"",
|
|
2951
|
+
"- One primary action per screen. One secondary. Everything else is tertiary.",
|
|
2952
|
+
" Never two filled buttons on one view (`MULTIPLE_PRIMARY_ACTIONS`).",
|
|
2953
|
+
"- Actions rank by emphasis, information ranks by size: the secondary button keeps",
|
|
2954
|
+
" the same `height` as the primary and loses color (p.008); the data gets the",
|
|
2955
|
+
" size ladder (p.088). That is how p.008 and p.088 stop contradicting each other.",
|
|
2956
|
+
"- In a stat card the value is the largest and darkest text and the label is",
|
|
2957
|
+
" smaller and muted. Never the inverse (p.010).",
|
|
2958
|
+
"- Darkness means importance: heading darker than body, body muted. Never let a",
|
|
2959
|
+
" thumbnail outsize the price (p.009, p.011).",
|
|
2960
|
+
"- Never render a detail panel as uniform label:value rows — it has no hierarchy",
|
|
2961
|
+
" and cannot be scanned. Promote the one value that matters, demote the rest,",
|
|
2962
|
+
" add an icon per row (p.012).",
|
|
2963
|
+
"- Before building, list the elements and rank them by user importance; then map",
|
|
2964
|
+
" size, color, weight and position onto that ranking (p.010).",
|
|
2965
|
+
"",
|
|
2966
|
+
"## 05 One purpose per screen",
|
|
2967
|
+
"",
|
|
2968
|
+
"Write the sentence first: \"this screen lets <who> do <what>\". If it needs an",
|
|
2969
|
+
"\"and\", split the screen (NBG pp.40-43).",
|
|
2970
|
+
"",
|
|
2971
|
+
"- Conflicting goals reduce usability. A task screen carries nothing that does not",
|
|
2972
|
+
" serve the task: no promo panel, no second call to action (Playbook p.127).",
|
|
2973
|
+
"- Decorative imagery next to a form is allowed only when it is action-free — no",
|
|
2974
|
+
" text overlay, no button — and it still has to be about this product, not merely",
|
|
2975
|
+
" pretty (p.128).",
|
|
2976
|
+
"- Navigation exposes what the job needs and nothing else. Every extra link is a",
|
|
2977
|
+
" decision taken before the user made theirs.",
|
|
2978
|
+
"",
|
|
2979
|
+
"## 06 Reduce cognitive load",
|
|
2980
|
+
"",
|
|
2981
|
+
"People recognize; they must never memorize, guess or interpret.",
|
|
2982
|
+
"",
|
|
2983
|
+
"- Chunk to about four items per group and cap a visible group at 7; past that,",
|
|
2984
|
+
" truncate and expose one action — \"Show all 21 amenities\" (Playbook p.101).",
|
|
2985
|
+
" The 7±2 figure is a myth in its loosest form; plan on four and the group still",
|
|
2986
|
+
" holds when someone adds a fifth.",
|
|
2987
|
+
"- Recognition over recall: common icons, common words, state visible on screen",
|
|
2988
|
+
" rather than in memory (p.104). \"Continue watching\" beats \"remember where you",
|
|
2989
|
+
" stopped\".",
|
|
2990
|
+
"- Do not hide choices behind a control that must be opened to be seen: variants",
|
|
2991
|
+
" as swatches, quantity as a stepper, not a nested menu (p.107-108).",
|
|
2992
|
+
"- Never oversimplify into vagueness either: a switch with an icon and no words is",
|
|
2993
|
+
" not clean, it is unreadable (p.014).",
|
|
2994
|
+
"- If no user has shown they need a feature, remove it rather than park it behind",
|
|
2995
|
+
" a toggle (NBG p.136).",
|
|
2996
|
+
"",
|
|
2997
|
+
"## 07 Spacing communicates the relationship",
|
|
2998
|
+
"",
|
|
2999
|
+
"Whitespace is functional, not decorative. Related things sit closer than",
|
|
3000
|
+
"unrelated things, and that difference is the whole message (Playbook p.013, p.029).",
|
|
3001
|
+
"",
|
|
3002
|
+
"- Field pattern: label to its own input `12`, that field to the next label `32`.",
|
|
3003
|
+
" Equal spacing at both levels makes label ownership ambiguous (p.013).",
|
|
3004
|
+
"- Card padding is `16`; the rhythm between cards is `16` or `24`; sections",
|
|
3005
|
+
" separate at `24` or `32`; page blocks at `48`. Never mix two rhythms in one list.",
|
|
3006
|
+
"- Start a layout with more room than you think you need and tighten it; clutter",
|
|
3007
|
+
" is easier to see than cramp is to undo (p.025-027).",
|
|
3008
|
+
"- Do not stretch a form or a checkout to the viewport. 1440 is a canvas size, not",
|
|
3009
|
+
" a target; give the card the width it needs and let the margin carry the rest",
|
|
3010
|
+
" (p.129-130). `Container({ maxWidth: 480 })` around a form, `fullWidth: true`",
|
|
3011
|
+
" only on the button inside it.",
|
|
3012
|
+
"- Layout is `useState` bindings, `flex`, `gap` and `wrap`. An absolute offset is",
|
|
3013
|
+
" for overlays, and then through `stackPosition`, not raw coordinates.",
|
|
3014
|
+
"",
|
|
3015
|
+
"## 08 Typography is structure",
|
|
3016
|
+
"",
|
|
3017
|
+
"- Body is `size: 16` (Playbook p.090). Nothing that must be read goes below 12;",
|
|
3018
|
+
" the book contradicts itself between 12 (p.090) and 10 (p.096) — 12 is the floor",
|
|
3019
|
+
" here.",
|
|
3020
|
+
"- Heading: bigger, heavier, darker, tighter. Body: smaller, regular, muted,",
|
|
3021
|
+
" looser. Never the same weight for both, never extra-light for body (p.084-087).",
|
|
3022
|
+
"- Line-height: 1.5-1.6 on body, 1.2-1.3 on headings; a 32px heading sits at about",
|
|
3023
|
+
" 1.25 (p.091-092). A heading never gets more air than a paragraph.",
|
|
3024
|
+
"- Measure: 45-75 characters per line on desktop, 30-40 on mobile. A paragraph",
|
|
3025
|
+
" that runs the full width is unreadable at 1440 (p.076-078).",
|
|
3026
|
+
"- One typeface; two at the absolute maximum, and then headings against body",
|
|
3027
|
+
" (p.075-076). Declare it once in the theme, never per `Text`.",
|
|
3028
|
+
"- `align: \"left\"` for every run of text and every list. `right` for numbers and",
|
|
3029
|
+
" prices, so magnitudes compare. `center` only for a short title or a lone CTA,",
|
|
3030
|
+
" never for a paragraph (p.018, p.094).",
|
|
3031
|
+
"- Long copy becomes scannable copy: a short lead, a heading, then one item per",
|
|
3032
|
+
" feature with an icon, a bold name and two lines of body; four or more items go",
|
|
3033
|
+
" in a two-column grid (p.123-125).",
|
|
3034
|
+
"",
|
|
3035
|
+
"## 09 Color carries meaning",
|
|
3036
|
+
"",
|
|
3037
|
+
"- One brand color per screen, and it is the color of the one action the screen",
|
|
3038
|
+
" asks for. A second accent on the same view means one of them is wrong.",
|
|
3039
|
+
"- Reserve the brand for things you can click. Headers, labels and icons stay",
|
|
3040
|
+
" neutral — brand color on a decorative surface teaches people that color means",
|
|
3041
|
+
" nothing (Playbook p.066-067).",
|
|
3042
|
+
"- Backgrounds are neutral (`colors.background`, `colors.surface`, `colors.card`);",
|
|
3043
|
+
" color belongs in the action, not the wall (p.067-068). A dark theme is equally",
|
|
3044
|
+
" valid; offer both, because dark mode is expected, not a bonus (p.070).",
|
|
3045
|
+
"- Feedback colors are fixed and never redefined: `colors.success`,",
|
|
3046
|
+
" `colors.warning`, `colors.danger`, `colors.info`. Red is error, green is",
|
|
3047
|
+
" success, amber is warning; never pink for success, never green for delete.",
|
|
3048
|
+
"- The signal red and the readable red are different tokens. `colors.danger`",
|
|
3049
|
+
" (#ef4444) is for a badge, a border, an icon: as words on a white page it",
|
|
3050
|
+
" measures 3.76:1 and that is not AA. `colors.dangerStrong` (#b91c1c) is the red",
|
|
3051
|
+
" you may read and press — 6.47:1 as words on white, 6.47:1 with white written on",
|
|
3052
|
+
" it, 5.42:1 inside `colors.errorBg`. Same for the others: `colors.warning`",
|
|
3053
|
+
" (2.15:1 with white on it) and `colors.success` (2.54:1) are plates and dots,",
|
|
3054
|
+
" never a sentence — `colors.warningStrong` (5.02:1) and `colors.successStrong`",
|
|
3055
|
+
" (5.48:1) are the readable ones, in either direction. Measure any pair you are",
|
|
3056
|
+
" about to ship: `npm run ui -- --tokens`.",
|
|
3057
|
+
"- Never carry meaning in color alone — a color-blind person and a phone in",
|
|
3058
|
+
" sunlight both lose it. Pair the color with an icon and a word (p.070): a status",
|
|
3059
|
+
" chip is `Badge` plus text, a field error is `error` plus `showValidationMessage`.",
|
|
3060
|
+
"- Hierarchy inside a fill comes from opacity, not from a second hue.",
|
|
3061
|
+
"",
|
|
3062
|
+
"## 10 Depth is a scale",
|
|
3063
|
+
"",
|
|
3064
|
+
"- `elevation: 0-5` is the shadow scale the framework ships (`SHADOW_LEVELS`), and",
|
|
3065
|
+
" these widgets read it: `Accordion`, `AppBar`, `BottomNavigation`, `Button`,",
|
|
3066
|
+
" `Card`, `Chip`, `Drawer`, `FloatingActionButton`, `ListTile`, `Modal`, `SnackBar`.",
|
|
3067
|
+
" Elsewhere — `Container` and `ListView` included — the number reaches",
|
|
3068
|
+
" `style.boxShadow` as a bare number, CSS drops it, and the box stays flat",
|
|
3069
|
+
" without saying so: pass a `shadow: \"0 12px 32px rgba(15,23,42,0.12)\"` string",
|
|
3070
|
+
" there instead (`DEAD_ELEVATION`).",
|
|
3071
|
+
"- Anything that floats gets depth: a dropdown, a menu, a modal, a card on a map.",
|
|
3072
|
+
" A shadowless layer merges with the page and the user cannot tell what is on top",
|
|
3073
|
+
" (p.050-051).",
|
|
3074
|
+
"- Soft, not harsh: depth comes from a wide blur at low opacity, and the shadow",
|
|
3075
|
+
" is tinted toward the background hue, never pure grey or black (p.052-054).",
|
|
3076
|
+
"- Do not put a white card on a white page — the interface goes flat. Page",
|
|
3077
|
+
" `colors.background`, card `colors.card` (p.056-057).",
|
|
3078
|
+
"- Borders are thin and rare. Separation comes from space and surface, not from a",
|
|
3079
|
+
" rule under every row; a bordered list of everything reads as busy and dated",
|
|
3080
|
+
" (p.059-061).",
|
|
3081
|
+
"- Glass and blur are a brand decision, and they never survive a light background:",
|
|
3082
|
+
" text on frosted glass over white loses the contrast war (p.063-065).",
|
|
3083
|
+
"",
|
|
3084
|
+
"## 11 Accessibility is a floor, not a feature",
|
|
3085
|
+
"",
|
|
3086
|
+
"When accessibility and aesthetics conflict, accessibility wins. Every time.",
|
|
3087
|
+
"",
|
|
3088
|
+
"- Text needs 4.5:1 against what it sits on; large text (24px, or 18px bold) and",
|
|
3089
|
+
" meaningful graphics need 3:1. Disabled text is exempt. The book agrees: its",
|
|
3090
|
+
" passing examples measure 4.8-6.54:1 and its failures 1.81-2.84:1 (p.021), and",
|
|
3091
|
+
" #626262 is the lightest grey that survives on white (p.086).",
|
|
3092
|
+
"- Measure instead of arguing: `npm run ui -- --tokens`. On this palette `text` is",
|
|
3093
|
+
" 17.85:1, `textSecondary` 4.76:1, `textDisabled` 2.56:1 (decoration only), and",
|
|
3094
|
+
" the brand pair `onPrimary` on `primary` is 4.55:1 — which is why `primary` may",
|
|
3095
|
+
" carry a button label and not a paragraph. By hand:",
|
|
3096
|
+
" `color(\"#0f172a\").contrastRatio(\"#ffffff\")` answers 17.85.",
|
|
3097
|
+
"- Text over a photograph needs a scrim: a dark overlay or a blurred plate under",
|
|
3098
|
+
" the words. Light text over one dark photo proves nothing about the next photo",
|
|
3099
|
+
" (p.022).",
|
|
3100
|
+
"- Every field has a visible `label`; a placeholder is a format hint, never a",
|
|
3101
|
+
" substitute (p.115). Every switch and checkbox has one too (`UNLABELED_TOGGLE`).",
|
|
3102
|
+
"- Every control that is only an icon gets a name: wrap it in",
|
|
3103
|
+
" `Tooltip({ text, child })` or give it a visible label (`ICON_ONLY_CONTROL`).",
|
|
3104
|
+
"- Every `Image` that carries information has `alt`; `alt: \"\"` is the correct",
|
|
3105
|
+
" answer for a decoration (`IMAGE_NO_ALT`).",
|
|
3106
|
+
"- Focus must be visible, keyboard must reach everything, and `Esc` must close",
|
|
3107
|
+
" whatever opened (`Modal({ closeOnEsc: true, showCloseButton: true })`).",
|
|
3108
|
+
"- Motion is optional content: respect a reduced-motion preference; never",
|
|
3109
|
+
" auto-play a carousel or a banner.",
|
|
3110
|
+
"",
|
|
3111
|
+
"## 12 Every interaction has four states",
|
|
3112
|
+
"",
|
|
3113
|
+
"A control that handles only the happy path is unfinished (NBG pp.94-97). Write",
|
|
3114
|
+
"all four, in the same component, before the screen counts as done.",
|
|
3115
|
+
"",
|
|
3116
|
+
"```js",
|
|
3117
|
+
"Column({ gap: 16, children: [",
|
|
3118
|
+
" loading ? Skeleton({ width: \"100%\", height: 48, count: 3 }) : null,",
|
|
3119
|
+
" !loading && rows.length === 0",
|
|
3120
|
+
" ? EmptyState() // headline + illustration + tip + CTA",
|
|
3121
|
+
" : null,",
|
|
3122
|
+
" failed",
|
|
3123
|
+
" ? SnackBar({ message: \"Could not load the list.\", action: \"Retry\", onAction: retry })",
|
|
3124
|
+
" : null,",
|
|
3125
|
+
" !loading && rows.length > 0 ? ListView({ children: rows }) : null,",
|
|
3126
|
+
"]})",
|
|
3127
|
+
"```",
|
|
3128
|
+
"",
|
|
3129
|
+
"- Empty has four parts: a solution-oriented headline, an illustration, two",
|
|
3130
|
+
" concrete tips, one primary action. \"You have no projects\" is a dead end, not a",
|
|
3131
|
+
" state (Playbook p.135-136).",
|
|
3132
|
+
"- Loading appears before the wait, not after it: a `Skeleton` at once beats a",
|
|
3133
|
+
" spinner two seconds later (NBG p.134, Doherty).",
|
|
3134
|
+
"- Error does exactly three things: says what went wrong in the user's words,",
|
|
3135
|
+
" gives the way back, keeps the visual style of the app. Show the code, keep the",
|
|
3136
|
+
" navigation, offer links (p.137-138). Never an error code alone, never a shrug.",
|
|
3137
|
+
"- Success confirms in place — a `SnackBar`, not a new screen — and offers the",
|
|
3138
|
+
" next step while it is there.",
|
|
3139
|
+
"- Progress is honest. Show real progress or show a `Skeleton`; a bar that lies",
|
|
3140
|
+
" buys one visit and costs the next one (NBG defends inflating them; do not).",
|
|
3141
|
+
"",
|
|
3142
|
+
"## 13 Forms: prevent the error, do not report it",
|
|
3143
|
+
"",
|
|
3144
|
+
"- Ask for 3 to 7 things at once. More than that is more than one step, and a step",
|
|
3145
|
+
" the user can see: `Stepper({ steps, activeStep, onFinish })` (NBG pp.40-43).",
|
|
3146
|
+
"- Width fits the data: CVC is 3 or 4 characters, so the field is short; card",
|
|
3147
|
+
" number and name run full width; pair Expiry with CVC on one row (p.115-116).",
|
|
3148
|
+
"- A column of three or more fields lives inside a cap — `Container({ maxWidth:",
|
|
3149
|
+
" 480 })`. A field as wide as the window answers \"type anything\" and the eye",
|
|
3150
|
+
" loses the next line (`UNCONSTRAINED_FORM_WIDTH`).",
|
|
3151
|
+
"- A fixed-length code gets one box per character in enlarged type, not one long",
|
|
3152
|
+
" field (p.114).",
|
|
3153
|
+
"- `Input({ label, placeholder, required, maxLength, validation, type })`:",
|
|
3154
|
+
" placeholder carries the format (\"MM/YY\", \"12345\"), `maxLength` and `type`",
|
|
3155
|
+
" constrain the input, and validation runs inline as they type, not on submit",
|
|
3156
|
+
" (p.119).",
|
|
3157
|
+
"- No \"Confirm password\" field. It adds cost, repeats the same typo twice, and",
|
|
3158
|
+
" buys a false sense of safety. One field, `passwordToggle: true`, and a strength",
|
|
3159
|
+
" line under it (p.117-119).",
|
|
3160
|
+
"- Good defaults and autofill remove keystrokes: prefill the country, the saved",
|
|
3161
|
+
" address, the last card (p.105).",
|
|
3162
|
+
"- Required fields are marked, and the label of the submit button names the",
|
|
3163
|
+
" outcome — \"Pay $65\", not \"Submit\" (p.115).",
|
|
3164
|
+
"- Never clear a form because one field failed. Keep what they typed.",
|
|
3165
|
+
"",
|
|
3166
|
+
"## 14 Destructive actions are designed last, and carefully",
|
|
3167
|
+
"",
|
|
3168
|
+
"- The button carries the verb: \"Delete 3 files\", never \"OK\" or \"Yes\"",
|
|
3169
|
+
" (`GENERIC_ACTION_LABEL`) (Playbook p.121).",
|
|
3170
|
+
"- Destructive is red — `colors.dangerStrong` for the words, `colors.danger` for",
|
|
3171
|
+
" the dot beside them — because green reads safe and a person presses it twice",
|
|
3172
|
+
" (`DESTRUCTIVE_COLOR`) (p.120). `colors.danger` alone is 3.76:1 as a label.",
|
|
3173
|
+
"- Order in a two-button row: Cancel on the left, destructive on the right. Left",
|
|
3174
|
+
" is what a reading eye hits first (`CONFIRM_ORDER`) (p.121-122).",
|
|
3175
|
+
"- The dialog states reversibility: \"This action cannot be undone.\" Say it even",
|
|
3176
|
+
" when it can, and then offer the way back.",
|
|
3177
|
+
"- `AlertDialog({ title, message, confirmText, cancelText, showCancel, onCancel, onConfirm, onClose })`",
|
|
3178
|
+
" always leaves an escape: a Cancel, and an X on any layer that can be opened.",
|
|
3179
|
+
"- Undo is cheaper than a warning. If the action is reversible, let people undo it",
|
|
3180
|
+
" instead of interrupting them to confirm it (NBG pp.116-118).",
|
|
3181
|
+
"- On a phone, a destructive action is not in the thumb zone; the thing you do",
|
|
3182
|
+
" often is (p.133-135).",
|
|
3183
|
+
"",
|
|
3184
|
+
"## 15 Reduce interaction cost",
|
|
3185
|
+
"",
|
|
3186
|
+
"Interaction cost is cognitive effort + physical effort + time (Playbook p.098).",
|
|
3187
|
+
"Count it, then cut it: reading, scrolling, searching, deciding, clicking, typing,",
|
|
3188
|
+
"waiting, remembering (p.099).",
|
|
3189
|
+
"",
|
|
3190
|
+
"- Count the steps in the main flow and delete every one that does not change the",
|
|
3191
|
+
" outcome (p.105). Three is a target; one is better.",
|
|
3192
|
+
"- Put the action next to the thing it acts on: the CTA beside the quantity",
|
|
3193
|
+
" stepper, the row action inside the row (p.108, Fitts).",
|
|
3194
|
+
"- Keep the control that runs all the time in a fixed, reachable place — a player",
|
|
3195
|
+
" bar at the bottom, a primary tab at the thumb (p.100, p.133-135).",
|
|
3196
|
+
"- Show the content instead of a door to the content: three real cards beat a",
|
|
3197
|
+
" banner that says \"Explore now\" (p.111-113).",
|
|
3198
|
+
"- One click where a confirmation is not needed. Two where it is.",
|
|
3199
|
+
"",
|
|
3200
|
+
"## 16 Respect the platform",
|
|
3201
|
+
"",
|
|
3202
|
+
"- Use the words, icons and gestures people already brought with them (NBG p.131,",
|
|
3203
|
+
" Jakob's law). An invented gesture is a bug report waiting to be written.",
|
|
3204
|
+
"- iOS, Android and web each have conventions; meet them instead of overriding",
|
|
3205
|
+
" them with a custom pattern. A back that lands where the user expects, a sheet",
|
|
3206
|
+
" that dismisses down, a form that advances on Enter.",
|
|
3207
|
+
"- Break a convention only when the measurement says the convention is wrong here,",
|
|
3208
|
+
" and write that measurement down.",
|
|
3209
|
+
"",
|
|
3210
|
+
"## 17 The review",
|
|
3211
|
+
"",
|
|
3212
|
+
"When asked to review a screen, answer in this order, and say why every time:",
|
|
3213
|
+
"",
|
|
3214
|
+
"1 Goal — what the screen is for, and whether it is one goal.",
|
|
3215
|
+
"2 UX problems — cost, states, recall, flow.",
|
|
3216
|
+
"3 UI problems — hierarchy, spacing, type, color, depth.",
|
|
3217
|
+
"4 System violations — the values that are not tokens, the component that is an",
|
|
3218
|
+
" exception.",
|
|
3219
|
+
"5 Accessibility violations — measured ratios, targets, labels, focus.",
|
|
3220
|
+
"6 Consistency violations — what this screen does differently for no reason.",
|
|
3221
|
+
"7 Scalability risk — what breaks when this screen has eleven siblings.",
|
|
3222
|
+
"8 Fix — the call, not the mood. Then the rule it establishes.",
|
|
3223
|
+
"9 Score — see below.",
|
|
3224
|
+
"",
|
|
3225
|
+
"Score the seven axes 0-100, but only against evidence, never against taste:",
|
|
3226
|
+
"an axis starts at 100 and loses 10 for every open finding of its own codes, so",
|
|
3227
|
+
"the number is a count and not an opinion. Below 85 on any axis means the fix is",
|
|
3228
|
+
"a rule change, not a pixel move. An axis with no findings and no evidence is",
|
|
3229
|
+
"unmeasured, not perfect — say so.",
|
|
3230
|
+
"",
|
|
3231
|
+
"| Axis | Its codes (from `npm run ui`) |",
|
|
3232
|
+
"| --- | --- |",
|
|
3233
|
+
"| Consistency | OFF_SCALE_SPACING · OFF_SCALE_RADIUS · OFF_SCALE_TYPE · RAW_COLOR · HAND_MIXED_COLOR · DEAD_ELEVATION |",
|
|
3234
|
+
"| Accessibility | LOW_CONTRAST · LOW_CONTRAST_UI · SMALL_TAP_TARGET · UNLABELED_FIELD · UNLABELED_TOGGLE · ICON_ONLY_CONTROL · IMAGE_NO_ALT |",
|
|
3235
|
+
"| Usability | CROWDED_FORM · LIST_WITHOUT_STATE · UNCONSTRAINED_FORM_WIDTH |",
|
|
3236
|
+
"| Hierarchy | MULTIPLE_PRIMARY_ACTIONS · CENTERED_BODY_TEXT · GENERIC_ACTION_LABEL · DESTRUCTIVE_COLOR · CONFIRM_ORDER |",
|
|
3237
|
+
"| Scalability | every token that had to be added to make the screen pass |",
|
|
3238
|
+
"| Conversion | the label of the primary action, counted in words and in steps |",
|
|
3239
|
+
"| Visual quality | measured only by the axes above; state what you looked at |",
|
|
3240
|
+
"",
|
|
3241
|
+
"## 18 Where these books are wrong",
|
|
3242
|
+
"",
|
|
3243
|
+
"Both are worth more read than copied. The parts that do not survive measurement:",
|
|
3244
|
+
"",
|
|
3245
|
+
"- The Playbook bans pure black on white and pure white on dark (p.083). This",
|
|
3246
|
+
" system puts white text on deep brand fills, and the ratio is what decides —",
|
|
3247
|
+
" 4.55:1 there, not the rule of thumb.",
|
|
3248
|
+
"- p.008 says a secondary button keeps the primary's size; p.088 says size is",
|
|
3249
|
+
" the hierarchy lever. Both are right about different things: emphasis ranks",
|
|
3250
|
+
" actions, size ranks information.",
|
|
3251
|
+
"- p.079 marks center-aligned body as good; p.094 says center is for titles only.",
|
|
3252
|
+
" The later rule is the readable one.",
|
|
3253
|
+
"- p.103 bans pop-ups as distractions; p.120 requires one to confirm a deletion.",
|
|
3254
|
+
" Reconciled: never unsolicited, always on destructive.",
|
|
3255
|
+
"- p.019 (gutter 70, margin 79, overlay 17%) and p.052-054 (Y:12, blur:48,",
|
|
3256
|
+
" #E9E9E9) are one designer's Figma fields in a screenshot, not a scale.",
|
|
3257
|
+
" Do not harvest them as thresholds.",
|
|
3258
|
+
"- The Playbook's own sign-up example (p.127) is white on a pale blue fill that",
|
|
3259
|
+
" fails its own contrast chapter. Copy the rule, never the screenshot.",
|
|
3260
|
+
"- \"7 ± 2\" (p.101) is Miller's number quoted loosely; chunk size is a design",
|
|
3261
|
+
" choice, and four is the one that survives a fifth item.",
|
|
3262
|
+
"- NBG defends inflating progress bars (its \"little lie\"). Do not.",
|
|
3263
|
+
"- Neither book states a tap-target size in px. Fitts gives the direction; 40 is",
|
|
3264
|
+
" this system's floor, and the number to defend if someone asks.",
|
|
3265
|
+
"",
|
|
3266
|
+
"## 19 What no checker can see",
|
|
3267
|
+
"",
|
|
3268
|
+
"`npm run ui` cannot tell you whether the screen is understood. For that:",
|
|
3269
|
+
"",
|
|
3270
|
+
"- Ask one person to do one task and stay quiet while they do it. Their first three",
|
|
3271
|
+
" sentences are the specification.",
|
|
3272
|
+
"- Watch where the pointer hesitates and where it misclicks: that is where the",
|
|
3273
|
+
" layout is lying about what is tappable.",
|
|
3274
|
+
"- Five people find most of what is broken. Two rounds of five beat one round of",
|
|
3275
|
+
" ten.",
|
|
3276
|
+
"- Write what you saw into `.fletbox/memory/`, or the next session designs this",
|
|
3277
|
+
" screen again from taste.",
|
|
3278
|
+
"",
|
|
3279
|
+
"## 20 The loop",
|
|
3280
|
+
"",
|
|
3281
|
+
"```bash",
|
|
3282
|
+
"npm run check",
|
|
3283
|
+
"npm run ui",
|
|
3284
|
+
"npm run dev # look at the screen, at 375px and at 1440px",
|
|
3285
|
+
"```",
|
|
3286
|
+
"",
|
|
3287
|
+
"Fix the code, not the checker. Then make the rule reusable: if the fix was right",
|
|
3288
|
+
"for this screen, it is a rule for every screen, and it belongs in this file.",
|
|
3289
|
+
"",
|
|
3290
|
+
].join('\n');
|
|
3291
|
+
|
|
3292
|
+
// Cursor has no skills folder, so the same rules ship as one always-apply rule
|
|
3293
|
+
// that loads the skill file instead of duplicating it.
|
|
3294
|
+
export const cleanUiCursorRule = () =>
|
|
3295
|
+
[
|
|
3296
|
+
'---',
|
|
3297
|
+
'description: Clean-UI rules for every screen - tokens, the 8-based scale, tap targets, states',
|
|
3298
|
+
'globs: "src/**/*.js"',
|
|
3299
|
+
'alwaysApply: true',
|
|
3300
|
+
'---',
|
|
3301
|
+
'',
|
|
3302
|
+
'Design a screen, do not decorate one. The full rules are in',
|
|
3303
|
+
'[.claude/skills/fletbox-clean-ui/SKILL.md](../../.claude/skills/fletbox-clean-ui/SKILL.md)',
|
|
3304
|
+
'- read it before writing a screen, form, list or dialog.',
|
|
3305
|
+
'',
|
|
3306
|
+
'- One screen, one job, one primary action; secondaries are `Button({ variant: "text" })`.',
|
|
3307
|
+
'- Colors come from `colors.*` only. Never a hex literal outside `src/database/themes.js`.',
|
|
3308
|
+
' `colors.onPrimary` writes on a brand fill; `colors.primaryBg` is the brand tint.',
|
|
3309
|
+
'- Sizes come from the scale: 4 - 8 - 12 - 16 - 18 - 24 - 32 - 48 space, 4 / 8 / 12 / 16 /',
|
|
3310
|
+
' 999 radius, 12 / 14 / 16 / 18 / 24 / 32 type.',
|
|
3311
|
+
'- Anything pressable is at least 40px on a side, and 44 is the number to aim at.',
|
|
3312
|
+
'- Every interaction shows loading, done, failed and empty; errors say the problem and',
|
|
3313
|
+
' the way out in the user\'s own words.',
|
|
3314
|
+
'- Verify with `npm run check` then `npm run ui` (add `-- --tokens` to read the measured',
|
|
3315
|
+
' contrast table), and then look at the screen - the checker has no eyes.',
|
|
3316
|
+
'',
|
|
3317
|
+
].join('\n');
|
|
3318
|
+
|
|
3319
|
+
/**
|
|
3320
|
+
* The app-side twin of scripts/check-app.mjs for the clean-UI gate. Same
|
|
3321
|
+
* resolution dance, because the package exports map only publishes "." and
|
|
3322
|
+
* "./package.json".
|
|
3323
|
+
*
|
|
3324
|
+
* @param {string} appName
|
|
3325
|
+
* @returns {string}
|
|
3326
|
+
*/
|
|
3327
|
+
export const checkUiScript = (appName) =>
|
|
3328
|
+
[
|
|
3329
|
+
'// scripts/check-ui.mjs',
|
|
3330
|
+
`// Clean-UI gate for ${appName}: tokens, the 8-based scale, tap targets, field`,
|
|
3331
|
+
'// labels and measured WCAG contrast, checked by the installed flet-box.',
|
|
3332
|
+
'//',
|
|
3333
|
+
'// npm run ui scan src/ and fail on a real finding',
|
|
3334
|
+
'// npm run ui -- --tokens print the measured contrast of every token',
|
|
3335
|
+
'// npm run ui -- --strict contrast warnings fail the run too',
|
|
3336
|
+
'// npm run ui -- --json machine-readable findings',
|
|
3337
|
+
'',
|
|
3338
|
+
"import path from 'node:path';",
|
|
3339
|
+
"import { createRequire } from 'node:module';",
|
|
3340
|
+
"import { pathToFileURL } from 'node:url';",
|
|
3341
|
+
'',
|
|
3342
|
+
"const require = createRequire(import.meta.url);",
|
|
3343
|
+
'',
|
|
3344
|
+
'function resolvePackage() {',
|
|
3345
|
+
" const candidates = [path.resolve(process.cwd(), 'node_modules/flet-box')];",
|
|
3346
|
+
' try {',
|
|
3347
|
+
" candidates.push(path.dirname(require.resolve('flet-box/package.json')));",
|
|
3348
|
+
' } catch {',
|
|
3349
|
+
' /* not installed yet */',
|
|
3350
|
+
' }',
|
|
3351
|
+
" const fs = require('fs');",
|
|
3352
|
+
' for (const dir of candidates) {',
|
|
3353
|
+
" if (fs.existsSync(path.join(dir, 'bin/commands/checkUi.js'))) return dir;",
|
|
3354
|
+
' }',
|
|
3355
|
+
" console.error('flet-box package not found. Run: npm install');",
|
|
3356
|
+
' process.exit(1);',
|
|
3357
|
+
'}',
|
|
3358
|
+
'',
|
|
3359
|
+
"const checker = path.join(resolvePackage(), 'bin', 'commands', 'checkUi.js');",
|
|
3360
|
+
"const { checkUi } = await import(pathToFileURL(checker).href);",
|
|
3361
|
+
'',
|
|
3362
|
+
"const { exitCode } = await checkUi(process.argv.slice(2));",
|
|
3363
|
+
'process.exit(exitCode);',
|
|
3364
|
+
'',
|
|
3365
|
+
].join('\n');
|