flet-box 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/bin/commands/checkUi.js +276 -45
  2. package/bin/commands/create.js +4 -0
  3. package/bin/commands/createScreen.js +1 -1
  4. package/bin/commands/runBundle.js +1 -1
  5. package/bin/utils/colors.js +0 -8
  6. package/bin/utils/helpers.js +0 -5
  7. package/bin/utils/images/jpeg.js +2 -3
  8. package/bin/utils/templates.js +78 -73
  9. package/docs/README.md +2 -0
  10. package/docs/cli/README.md +18 -1
  11. package/docs/guides/README.md +5 -2
  12. package/docs/guides/accessibility.md +282 -0
  13. package/docs/guides/clean-ui.md +53 -8
  14. package/docs/guides/minification-and-protection.md +1 -1
  15. package/docs/guides/mobile-and-platforms.md +51 -0
  16. package/docs/guides/utilities.md +116 -4
  17. package/docs/guides/widget-structure.md +30 -0
  18. package/docs/tools/README.md +1 -1
  19. package/docs/tools/animation.md +134 -11
  20. package/docs/tools/device.md +75 -3
  21. package/docs/tools/text-and-time.md +116 -6
  22. package/docs/widget/Accordion.md +2 -0
  23. package/docs/widget/AnimatedBox.md +46 -20
  24. package/docs/widget/AnimatedText.md +13 -7
  25. package/docs/widget/Badge.md +1 -0
  26. package/docs/widget/COMMON_PROPS.md +26 -3
  27. package/docs/widget/Chart.md +1 -0
  28. package/docs/widget/Checkbox.md +7 -1
  29. package/docs/widget/CircularChart.md +2 -0
  30. package/docs/widget/Drawer.md +1 -0
  31. package/docs/widget/GridView.md +4 -1
  32. package/docs/widget/Input.md +2 -0
  33. package/docs/widget/InstallButton.md +1 -0
  34. package/docs/widget/Markdown.md +4 -0
  35. package/docs/widget/Rating.md +2 -0
  36. package/docs/widget/Slider.md +2 -0
  37. package/docs/widget/Stack.md +6 -5
  38. package/docs/widget/Stepper.md +2 -0
  39. package/docs/widget/Tooltip.md +3 -1
  40. package/package.json +3 -2
  41. package/skills/fletbox-design/SKILL.md +390 -14
  42. package/skills/fletbox-motion/SKILL.md +145 -47
  43. package/skills/fletbox-rules/SKILL.md +140 -1
  44. package/src/animations/AnimatedBox.js +321 -226
  45. package/src/animations/MatrixRain.js +7 -1
  46. package/src/animations/ParallaxBox.js +13 -10
  47. package/src/animations/animate.js +269 -106
  48. package/src/animations/color.js +91 -0
  49. package/src/animations/easings.js +275 -0
  50. package/src/animations/index.js +21 -0
  51. package/src/animations/motion.js +204 -0
  52. package/src/animations/transitions.js +327 -0
  53. package/src/core/runApp.js +0 -6
  54. package/src/helpers/boxEdges.js +69 -0
  55. package/src/helpers/html.js +39 -0
  56. package/src/index.d.ts +377 -25
  57. package/src/index.js +77 -0
  58. package/src/navigations/AdaptiveScaffold.js +6 -2
  59. package/src/navigations/AppBar.js +16 -7
  60. package/src/navigations/BottomNavigation.js +24 -1
  61. package/src/navigations/CollapsibleSideBar.js +0 -1
  62. package/src/navigations/Drawer.js +32 -1
  63. package/src/navigations/DrawerItem.js +1 -1
  64. package/src/navigations/Router.js +0 -1
  65. package/src/navigations/Scaffold.js +9 -3
  66. package/src/navigations/Tabs.js +104 -31
  67. package/src/services/RamStore.js +1 -2
  68. package/src/tools/breakpoints.js +113 -0
  69. package/src/tools/createList.js +0 -2
  70. package/src/tools/fonts.js +99 -0
  71. package/src/tools/index.js +74 -1
  72. package/src/tools/margin.js +5 -63
  73. package/src/tools/padding.js +5 -63
  74. package/src/tools/print.js +0 -8
  75. package/src/tools/scale.js +9 -2
  76. package/src/tools/time.js +56 -15
  77. package/src/tools/useState.js +1 -54
  78. package/src/utils/fieldFont.js +77 -0
  79. package/src/utils/focusRing.js +73 -0
  80. package/src/utils/focusScope.js +113 -0
  81. package/src/utils/markdownParser.js +7 -9
  82. package/src/utils/readableInk.js +7 -1
  83. package/src/utils/stackLayout.js +55 -0
  84. package/src/utils/styleInjector.js +25 -0
  85. package/src/utils/syntaxHighlight.js +1 -17
  86. package/src/utils/themes.js +15 -0
  87. package/src/utils/visualEffects.js +49 -47
  88. package/src/widget-factory/createWidget.js +16 -3
  89. package/src/widget-factory/effects.js +76 -3
  90. package/src/widget-factory/reactiveValue.js +0 -7
  91. package/src/widget-factory/tools.js +26 -143
  92. package/src/widget-factory/translateProps.js +5 -0
  93. package/src/widget-factory/widgetFactory.js +1 -1
  94. package/src/widgets/Accordion.js +70 -12
  95. package/src/widgets/AdMob.js +1 -1
  96. package/src/widgets/AdSense.js +1 -1
  97. package/src/widgets/AlertDialog.js +11 -9
  98. package/src/widgets/Avatar.js +10 -1
  99. package/src/widgets/Badge.js +7 -1
  100. package/src/widgets/BottomSheet.js +39 -5
  101. package/src/widgets/Chart.js +41 -9
  102. package/src/widgets/Checkbox.js +59 -9
  103. package/src/widgets/Chip.js +13 -1
  104. package/src/widgets/CircularBar.js +17 -5
  105. package/src/widgets/CircularChart.js +24 -2
  106. package/src/widgets/CodeViewer.js +5 -3
  107. package/src/widgets/Dropdown.js +223 -20
  108. package/src/widgets/GridView.js +17 -1
  109. package/src/widgets/Icon.js +21 -0
  110. package/src/widgets/Input.js +75 -44
  111. package/src/widgets/InstallButton.js +8 -2
  112. package/src/widgets/ListTile.js +6 -5
  113. package/src/widgets/ListView.js +21 -7
  114. package/src/widgets/Markdown.js +25 -44
  115. package/src/widgets/Modal.js +43 -6
  116. package/src/widgets/Pagination.js +35 -3
  117. package/src/widgets/ProgressBar.js +18 -0
  118. package/src/widgets/Radio.js +7 -0
  119. package/src/widgets/Rating.js +104 -16
  120. package/src/widgets/Skeleton.js +9 -5
  121. package/src/widgets/Slider.js +87 -7
  122. package/src/widgets/SnackBar.js +30 -1
  123. package/src/widgets/Stack.js +25 -13
  124. package/src/widgets/Stepper.js +112 -64
  125. package/src/widgets/Switch.js +10 -1
  126. package/src/widgets/Tooltip.js +93 -10
  127. package/src/widgets/TreeView.js +5 -5
  128. package/src/create-index.sh +0 -237
  129. package/src/widget-factory/pixTool.js +0 -40
  130. package/src/widget-factory/stringTool.js +0 -26
@@ -1,6 +1,6 @@
1
1
  # Stack
2
2
 
3
- Overlay container that lets children occupy the same box. It is relative by default so children can be positioned, and it supports a nine-point alignment.
3
+ Overlay container: every child after the first paints in the same box, anchored by a nine-point `alignment` or by its own edges.
4
4
 
5
5
  ## When to use it
6
6
 
@@ -101,9 +101,10 @@ const stack = Stack({
101
101
 
102
102
  ## Tips
103
103
 
104
- - Stack is `position: "relative"` by default, which makes it a positioning context for absolute children.
105
- - `alignment` anchors children at one of nine points from top-left to bottom-right.
104
+ - The first child stays in the flow and gives the stack its size; every child after it floats over that box.
105
+ - `alignment` anchors the children that name no edges, at one of nine points from top-left to bottom-right. Top-left is the default.
106
106
  - Later children paint on top, so order them from back to front.
107
+ - A child that names its own `top`/`right`/`bottom`/`left` keeps those, and a child that declares `position` is left alone — that is how a child stays in the flow.
107
108
 
108
109
  ## Accessibility
109
110
 
@@ -111,8 +112,8 @@ const stack = Stack({
111
112
 
112
113
  ## Behavior
113
114
 
114
- - The root is a plain block by default and children are laid out per `position` and `alignment`.
115
- - Set `width` and `height` (or `expand`) when the stack needs a definite size for layered content.
115
+ - The root is a flex column, so `alignment` places the in-flow child too; an explicit `justifyContent` or `alignItems` wins over it.
116
+ - The layers take no space. Give the stack a `width` and `height`, or make the first child the size you want the box to be, or there is nothing for them to sit on.
116
117
 
117
118
  ## Related widgets
118
119
 
@@ -122,6 +122,8 @@ const checkout = Stepper({
122
122
 
123
123
  - Steps are clickable, so users can jump around; use onStepChange to validate a step before allowing the move.
124
124
  - variant: icons pulls each step's icon; circles and numbers ignore it.
125
+ - A step's content is a live widget and a DOM node has one parent, so build the steps array where the Stepper is built: sharing one array between two steppers empties the first slot, and the widget warns when it happens.
126
+ - orientation: vertical puts each label beside its marker; horizontal puts it underneath. showLabels: false hides it in both.
125
127
 
126
128
  ## Accessibility
127
129
 
@@ -116,11 +116,12 @@ const tip = Tooltip({
116
116
 
117
117
  - Attach it to any widget via child; the tooltip is a separate portal, so overflow hidden ancestors do not cut it off.
118
118
  - delay controls how long the pointer must rest before the tooltip appears; lower it for dense toolbars, raise it for error-prone hints.
119
- - The bubble is clamped to the viewport edges, so top/left placements can sit slightly inside the window bounds.
119
+ - The bubble keeps to the device's safe area, so a placement is a request: an edge with no room for it gets the opposite edge instead.
120
120
 
121
121
  ## Accessibility
122
122
 
123
123
  - The wrapped child keeps its focus handlers, so keyboard users tabbing onto it get the same tooltip as mouse hover.
124
+ - On a device that cannot hover the bubble stays hidden. iOS answers the first tap with a synthetic mouseenter and never sends the matching mouseleave, so a tap-shown tooltip is a bubble nothing can dismiss. Anything a tooltip explains has to be explained by the control's own label too.
124
125
 
125
126
  ## Behavior
126
127
 
@@ -128,6 +129,7 @@ const tip = Tooltip({
128
129
  - The wrapped child gains showTooltip, hideTooltip, updateContent, and updatePosition helpers for imperative control.
129
130
  - A pointer that leaves before the wait is over never shows the bubble: leaving clears the timer.
130
131
  - tooltipDelay is the old name of delay. When both are given, delay wins.
132
+ - The requested position is a preference, not a promise: when the bubble would not fit between the trigger and the safe-area edge it is placed on the opposite side, and the arrow follows it.
131
133
 
132
134
  ## Related widgets
133
135
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flet-box",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Declarative, zero-dependency UI framework in vanilla JS — ship web, PWA, Android and iOS from one codebase",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -19,7 +19,7 @@
19
19
  "dev": "node bin/cli.js run --hot",
20
20
  "build": "node bin/cli.js createBundle",
21
21
  "release": "node bin/cli.js release",
22
- "test": "node scripts/check-widgets.mjs && node scripts/check-widget-scale.mjs && node scripts/check-prop-registry.mjs && node scripts/check-docs-props.mjs && node scripts/check-api-surface.mjs && node scripts/check-api-propscan.mjs && node scripts/check-router-base.mjs && node scripts/check-clean-ui.mjs && node scripts/check-skills.mjs && node scripts/check-ota-assets.mjs && node scripts/check-pwa-shell.mjs && node scripts/check-cli-install.mjs && python3 tools/snippets-creator-flet-box/build_snippets.py --check && node scripts/check-snippets.mjs",
22
+ "test": "node scripts/check-widgets.mjs && node scripts/check-widget-scale.mjs && node scripts/check-shared-helpers.mjs && node scripts/check-a11y.mjs && node scripts/check-prop-registry.mjs && node scripts/check-docs-props.mjs && node scripts/check-api-surface.mjs && node scripts/check-api-propscan.mjs && node scripts/check-time-helpers.mjs && node scripts/check-router-base.mjs && node scripts/check-clean-ui.mjs && node scripts/check-framework-ui.mjs && node scripts/check-skills.mjs && node scripts/check-app-types.mjs && node scripts/check-ota-assets.mjs && node scripts/check-pwa-shell.mjs && node scripts/check-cli-install.mjs && python3 tools/snippets-creator-flet-box/build_snippets.py --check && node scripts/check-snippets.mjs",
23
23
  "test:browser": "node scripts/run-browser-tests.mjs"
24
24
  },
25
25
  "keywords": [
@@ -47,6 +47,7 @@
47
47
  "devDependencies": {
48
48
  "chokidar": "^4.0.3",
49
49
  "esbuild": "^0.28.0",
50
+ "typescript": "^7.0.2",
50
51
  "ws": "^8.21.0"
51
52
  },
52
53
  "files": [
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fletbox-design
3
- description: The design contract for a FletBox app - tokens only, one scale, three-tier hierarchy, a state for every interaction, measured contrast, composition that starts as a sentence, the dated visual tells that give away a template, destructive actions designed last. Use when creating, editing or reviewing any screen, form, list, dialog, hero or action control.
3
+ description: The design contract for a FletBox app - tokens only, one scale, three-tier hierarchy, a state for every interaction, measured contrast, composition that starts as a sentence, the dated visual tells that give away a template, destructive actions designed last, and Part II, the visual atlas: six style archetypes with their surface ramps, accent budget, number-as-hero typography, nested radii, photo-and-sheet composition, control shapes, data widgets and per-genre recipes. Use when creating, editing or reviewing any screen, form, list, dialog, hero or action control, or when choosing how a screen should look.
4
4
  ---
5
5
 
6
6
  # Design — the system rules for a FletBox screen
@@ -82,7 +82,7 @@ No arbitrary values, no hex, no `px` strings. Every value comes from the scale.
82
82
 
83
83
  ```
84
84
  space 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96
85
- radius 4 · 8 · 12 · 16 · 999 (pill)
85
+ radius 4 · 8 · 12 · 16 · 20 · 24 · 999 (pill)
86
86
  type 12 caption · 14 body-small · 16 body · 18 subtitle · 24 title
87
87
  32 headline · 40 display-sm · 48 display
88
88
  weight 400 body · 500 label · 600 title · 700 headline
@@ -92,9 +92,11 @@ depth elevation 0 · 1 · 2 · 3 · 4 · 5
92
92
 
93
93
  - The base unit is 4 and everything is a multiple of it (Playbook p.028). A value
94
94
  like 13, 19, 21 or 31 is not a design decision, it is drift. A radius between
95
- two steps — the 20 beside the 16 — is the same drift on the corner
96
- (`OFF_SCALE_RADIUS`), and it is what makes a rounded picture sit next to a
97
- square button like two products.
95
+ two steps — the 15 beside the 16, the 18 beside a 24 — is the same drift on the
96
+ corner (`OFF_SCALE_RADIUS`), and it is what makes a rounded picture sit next to a
97
+ square button like two products. The scale reaches 24 because `Drawer`,
98
+ `DrawerItem` and the `Tabs` slider already default there: an app matching its own
99
+ widgets cannot be off-scale.
98
100
  - Never type a hex or `rgb()` outside `src/database/themes.js`
99
101
  (`RAW_COLOR`). If a token is missing, add it there.
100
102
  - Never build a color by appending alpha to a token — `colors.primary + "15"` is
@@ -205,7 +207,10 @@ unrelated things, and that difference is the whole message (Playbook p.013, p.02
205
207
  - Measure: 45-75 characters per line on desktop, 30-40 on mobile. A paragraph
206
208
  that runs the full width is unreadable at 1440 (p.076-078).
207
209
  - One typeface; two at the absolute maximum, and then headings against body
208
- (p.075-076). Declare it once in the theme, never per `Text`.
210
+ (p.075-076). Declare it once — `loadFont("Inter", { weights: [400, 600] })` and
211
+ one `fontFamily` on the screen's root — never per `Text`. There is no font on
212
+ the theme: `fontFamily` is a routed prop, and the loader is what puts the bytes
213
+ behind the name.
209
214
  - `align: "left"` for every run of text and every list. `right` for numbers and
210
215
  prices, so magnitudes compare. `center` only for a short title or a lone CTA,
211
216
  never for a paragraph (p.018, p.094) — a centered paragraph is reported as
@@ -291,14 +296,17 @@ When accessibility and aesthetics conflict, accessibility wins. Every time.
291
296
  (p.022).
292
297
  - Every field has a visible `label`; a placeholder is a format hint, never a
293
298
  substitute (p.115). Every switch and checkbox has one too (`UNLABELED_TOGGLE`).
294
- - Every control that is only an icon gets a name: wrap it in
295
- `Tooltip({ text, child })` or give it a visible label (`ICON_ONLY_CONTROL`).
299
+ - Every control that is only an icon gets a name: `aria-label` on the control
300
+ itself, or a visible label beside it (`ICON_ONLY_CONTROL`). A `Tooltip` is a
301
+ description — it explains the control, it does not name it.
296
302
  - Every `Image` that carries information has `alt`; `alt: ""` is the correct
297
303
  answer for a decoration (`IMAGE_NO_ALT`).
298
304
  - Focus must be visible, keyboard must reach everything, and `Esc` must close
299
305
  whatever opened (`Modal({ closeOnEsc: true, showCloseButton: true })`).
300
306
  - Motion is optional content: respect a reduced-motion preference; never
301
- auto-play a carousel or a banner.
307
+ auto-play a carousel or a banner. FletBox listens (`prefersReducedMotion()`,
308
+ `setMotionMode('reduce')`) and the engine obeys it, so a hand-written
309
+ `requestAnimationFrame` is the only way to ignore it — see `fletbox-motion`.
302
310
 
303
311
  ## 12 Every interaction has four states
304
312
 
@@ -309,7 +317,13 @@ all four, in the same component, before the screen counts as done.
309
317
  Column({ gap: 16, children: [
310
318
  loading ? Skeleton({ width: "100%", height: 48, count: 3 }) : null,
311
319
  !loading && rows.length === 0
312
- ? EmptyState() // headline + illustration + tip + CTA
320
+ ? Column({ gap: 16, alignItems: "center", children: [
321
+ Icon({ name: "inbox", size: 48, color: colors.textSecondary }),
322
+ Text({ text: "No projects yet", size: 18, weight: "bold" }),
323
+ Text({ text: "Create one to see it here.", size: 14,
324
+ color: colors.textSecondary, align: "center" }),
325
+ Button({ text: "New project", onPress: create }),
326
+ ]}) // headline + illustration + tip + CTA
313
327
  : null,
314
328
  failed
315
329
  ? SnackBar({ message: "Could not load the list.", action: "Retry", onAction: retry })
@@ -439,10 +453,18 @@ broken" report, and none of them is something `npm run ui` can see:
439
453
  - **`alignItems: "center"` centers the block, not the text.** A paragraph that
440
454
  wraps inside a centered column becomes a ragged stripe: center the heading,
441
455
  left-align the copy (rule 08).
442
- - **There is no breakpoint prop, and none is needed.** Responsiveness is
443
- `flex`, `wrap: true` and `maxWidth` — plus the rem contract: a number is a
444
- fraction of the reader's font, so one build fits a phone and a desktop. A
445
- screen built from two hard-coded layouts is two screens to maintain.
456
+ - **Fluid first, breakpoint second.** `flex`, `wrap: true`, `maxWidth` and the rem
457
+ contract carry one screen from phone to desktop with no decision at all, and
458
+ that is the default. There is no breakpoint *prop* — when a shape genuinely
459
+ changes (one column into two, a rail into a drawer), the change is made in the
460
+ code that builds the tree, with the one table: `atLeast("md")` for the boolean,
461
+ `currentBreakpoint()` for the name, a per-step value function for a number that
462
+ really wants to be different at 400px and 1400px. `md` is 768, the same line
463
+ `AdaptiveScaffold` crosses, so a screen and its shell cannot disagree. Read the
464
+ width, never the user agent: `device.isMobile()` answers a platform question
465
+ (long-press, a native back gesture), not a layout one. A screen built from two
466
+ hard-coded layouts is two screens to maintain; a screen with one per-step value
467
+ where the shape truly changes is one.
446
468
  - Overlays go in a `Stack` with `stackPosition`, not in absolute coordinates on
447
469
  `Container({ pos: "absolute", top, left })`. The first survives a content
448
470
  change; the second is a pixel bomb in any language longer than English.
@@ -571,3 +593,357 @@ npm run dev # look at the screen, at 375px and at 1440px
571
593
 
572
594
  Fix the code, not the checker. Then make the rule reusable: if the fix was right
573
595
  for this screen, it is a rule for every screen, and it belongs in this file.
596
+
597
+ ---
598
+
599
+ # PART II — THE VISUAL ATLAS
600
+
601
+ Rules 01-24 say what is legal. They do not say what to choose, and a screen that
602
+ is merely legal looks like nothing. Part II is the menu: the looks that hold up
603
+ for their own reasons, each reduced to the calls that produce it in this
604
+ framework. It is read after the rules, never instead of them — every recipe below
605
+ still has to pass `npm run check`, `npm run ui` and rules 01-24.
606
+
607
+ Nothing here cites a trend, a screenshot or a mood board. Each rule states its
608
+ mechanism — what the eye is actually doing, or what the CSS engine will and will
609
+ not accept — so an edge case can be judged from the reason instead of from the
610
+ precedent. Where a number appears it is either arithmetic (30), a measured ratio
611
+ from this palette (03, 11, 35), or a size the framework ships.
612
+
613
+ ## 25 Three laws
614
+
615
+ One accent, one surface family, one hero. These three hold across mobile and web,
616
+ dark and light, and every genre; a screen that breaks any of them is not
617
+ stylistically bold, it is unreadable in a new way.
618
+
619
+ 1. **One accent.** A neutral base plus exactly one saturated hue, spent on the
620
+ action and the selected state and nowhere else (26, 28). Two accents force the
621
+ eye to rank them, and it has no basis to rank them with.
622
+ 2. **One surface family.** Either a tinted dark ramp or a tinted paper; never a
623
+ black page with white cards, never a grey page with white cards (27).
624
+ 3. **One hero.** The screen has one element that is 3-5× the rest — usually a
625
+ number, once an image, rarely a word. Everything else is at reading size (29).
626
+
627
+ Squint test for the three: if you can still tell which element is the hero, and
628
+ how many colored things there are, the screen obeys them.
629
+
630
+ ## 26 Pick an archetype, then obey it
631
+
632
+ Name the archetype in the commit or in `.fletbox/memory/` before the first
633
+ widget. A screen that mixes two of them is what rule 02 calls an exception.
634
+
635
+ | Archetype | Use it when | The tell you must get right |
636
+ | --- | --- | --- |
637
+ | Acid-on-black | finance, media, fitness, tools, anything glanceable | the accent is on one element only (28) |
638
+ | Tinted paper | commerce, food, wellness, content | the page is a wash of the accent, not grey (27) |
639
+ | Photo-bleed + sheet | travel, product detail, real estate, recipes | the sheet overlaps the photo, it does not sit under it (31) |
640
+ | Bento dashboard | dashboards, smart-home, admin | one tile is double-size and it is the hero (33) |
641
+ | Editorial luxury | watches, perfume, hotel, long-read | one serif, used for the name of the thing only (29) |
642
+ | Single-hue field | a brand moment: splash, campaign, empty state | the whole page is one hue, the text is its darker plate (27) |
643
+
644
+ The six are exhaustive of what survives review here. The look they are usually
645
+ reached for instead — violet mesh gradient, glass on every card, a 3D blob behind
646
+ the phone — is not a seventh archetype; it is in 36 as a dated tell, and it is the
647
+ one most often mistaken for a style.
648
+
649
+ ## 27 Surfaces are never the extremes
650
+
651
+ Pure black under pure white is the one pair that always loses: `#000000` against
652
+ `#ffffff` measures 21:1, and an eye moving across a list at that ratio keeps
653
+ catching on the edges of every glyph. A large bright surface glows for the same
654
+ reason. So the extremes get replaced by steps, and the steps are where the
655
+ hierarchy lives. The shipped dark palette already obeys this — `background`
656
+ `#09090b`, `card` and `surface` `#18181b`, `border` `#27272a` — a three-step ramp
657
+ of one near-black hue. Use the steps, do not invent a fourth.
658
+
659
+ - **Dark.** Depth is a lighter plate, not a shadow: a shadow on a dark page is
660
+ invisible, so the only signal available is value. Two steps maximum between the
661
+ page and the topmost card; a third step is a bug, because nobody can tell step
662
+ three from step two. A hairline `colors.border` rim replaces the shadow on an
663
+ elevated card (rule 10: `Container` ignores `elevation` — pass `shadow` or
664
+ `borderColor` there, never a bare number).
665
+ - **Paper.** The page is a low-chroma wash of the accent (`colors.primaryBg`
666
+ exists for exactly this), the card is `colors.card`, and the tile inside the
667
+ card is one step back. Grey page under white card is the wrong version of this
668
+ look and reads as a wireframe: grey is a neutral, so it separates the card from
669
+ nothing.
670
+ - **Single-hue field.** One hue carries the screen and the text is a darker or
671
+ lighter plate of the same hue — never a second hue for contrast, because a
672
+ second hue is a second accent and 25 allows one. In this framework that means a
673
+ new token in `src/database/themes.js` per theme, not an inline mix:
674
+ `colors.primary + "22"` is `HAND_MIXED_COLOR` (rule 03).
675
+ - **Never** a pure-`#fff` page under pure-`#000` text with nothing between: that
676
+ pair is the failure this section exists to prevent, and the shipped light page
677
+ is `#ffffff` only because `surface` is `#f8fafc` and does the separating.
678
+
679
+ ## 28 The accent budget
680
+
681
+ The accent is a budget, not a color. It may appear on, and only on:
682
+
683
+ 1. the one primary action;
684
+ 2. the selected state of whatever chose it (chip, tab, nav icon, swatch);
685
+ 3. the hero number, when the number is the point.
686
+
687
+ Everything else — headers, labels, icons, dividers, decorative plates — stays
688
+ neutral. This is rule 09's "reserve the brand for things you can click" with the
689
+ count added: three placements, and a fourth means one of them is not an accent.
690
+ The mechanism is learning: a color that appears on non-clickable things stops
691
+ being a cue, and the user stops reading it — so the CTA loses the very property
692
+ that made it findable.
693
+
694
+ - Secondary is the same ink, hollow: `variant: "outlined"` naming its own
695
+ `color:`, or `variant: "text"`. Never a second hue for the second button.
696
+ - A gradient on the primary is legal only as **two shades of one hue** — a violet
697
+ button running `#9D70F6 → #5520FF`, both declared as tokens, is still one
698
+ accent. Cap any gradient at two hues: three is no longer a surface, it is an
699
+ event, and it competes with the CTA for the same attention (36).
700
+ - Glow is the accent's only other permitted spend, and only on dark, because a
701
+ bloom over a light page is indistinguishable from a smudge:
702
+ `CircularBar({ glow: true, glowColor: colors.primary })`, or a tinted `shadow`
703
+ string on the CTA. One glowing element per screen.
704
+
705
+ ## 29 The number is the hero
706
+
707
+ The composition every genre that shows a quantity should reach for: one oversized
708
+ tabular numeral, its label above it in muted text, and nothing else on the screen
709
+ competing. A magnitude read as a shape is understood faster than the same
710
+ magnitude read digit by digit, which is why the number gets the size and not the
711
+ heading.
712
+
713
+ ```js
714
+ Column({ gap: 4, children: [
715
+ Text({ text: "Total balance", size: 12, weight: 500, color: colors.textSecondary, letterSpacing: 0.4 }),
716
+ Row({ alignItems: "flex-end", gap: 8, children: [
717
+ Text({ text: "26072", size: 40, weight: 700, color: colors.text, letterSpacing: -0.8, lineHeight: 1.1 }),
718
+ Text({ text: ".00", size: 18, weight: 500, color: colors.textSecondary }),
719
+ Text({ text: "+4.2%", size: 14, weight: 600, color: colors.successStrong }),
720
+ ]}),
721
+ ]})
722
+ ```
723
+
724
+ - The jump is 3-5× the body size, and the numeral is the only element at that
725
+ size on the screen. Two heroes is zero heroes (rule 04).
726
+ - Decimals and currency marks one step down and muted: the eye is meant to read
727
+ the magnitude first. `letterSpacing` goes slightly negative on big digits and
728
+ positive on small caps labels — never both the same.
729
+ - Delta text is `colors.successStrong` / `colors.dangerStrong`, never the plate
730
+ tokens (rule 09: `colors.success` is 2.54:1 with white on it and is a dot).
731
+ - Serif is a brand voice, not a font choice: one serif for the name of the thing
732
+ (product, hotel, edition), sans for everything readable, and never a serif
733
+ paragraph. Declare it once with `loadFont` and one `fontFamily` on the root,
734
+ not per `Text` (rule 08).
735
+ - Uppercase with wide tracking is for a two-word label, never for a sentence,
736
+ and never for the body: tracking destroys the word shape that a reader
737
+ recognizes, so it costs speed everywhere it is not a single glance (rule 19's
738
+ eyebrow ban still applies to the label above a heading).
739
+
740
+ ## 30 Radius, nesting and the pill
741
+
742
+ - Controls are pills: `borderRadius` = half the height. Cards are 16-24. Inner
743
+ thumbnails and tiles are 12-16, one step below their card.
744
+ - **The nesting rule**, as arithmetic: `outer = inner + padding`. A 16-radius
745
+ tile inside 8 of padding needs a 24-radius card. Equal radii at both levels is
746
+ the failure — the inner corner and the outer corner curve on the same radius,
747
+ so the gap between them reads as a second box rather than as one plate.
748
+ - A radius between two steps is `OFF_SCALE_RADIUS` (rule 03), and the reason is
749
+ perceptual, not bookkeeping: the corner is what people match by eye, so a 15
750
+ beside a 16 looks like a mistake even when nobody can say why. 20 and 24 are
751
+ steps, not neighbours — they are where the nesting rule lands, and where the
752
+ framework's own drawers and tab indicators sit.
753
+ - One radius language per screen (rule 02). A pill button inside a 16-radius card
754
+ on a 24-radius sheet is three languages; pick the card radius and let the
755
+ controls be pills, since a pill is the one shape that reads as a control at any
756
+ card radius.
757
+
758
+ ```js
759
+ Card({ borderRadius: 24, padding: 16, children:
760
+ Row({ gap: 12, alignItems: "center", children: [
761
+ Image({ src: thumb, width: 56, height: 56, borderRadius: 12, fit: "cover", alt: "" }),
762
+ Column({ gap: 2, children: [
763
+ Text({ text: title, size: 16, weight: 600, color: colors.text }),
764
+ Text({ text: subtitle, size: 12, color: colors.textSecondary }),
765
+ ]}),
766
+ Button({ text: "Add", variant: "filled", height: 36, borderRadius: 999 }),
767
+ ]}),
768
+ })
769
+ ```
770
+
771
+ ## 31 Photo, scrim, sheet
772
+
773
+ The composition of every photo-led genre — travel, product detail, real estate,
774
+ recipes — and the one place a `Stack` earns its keep:
775
+
776
+ ```js
777
+ Stack({ alignment: "bottom-left", height: 420, children: [
778
+ Image({ src: hero, fit: "cover", alt: "The room as booked", full: true,
779
+ maskImage: "linear-gradient(to top, rgba(0,0,0,0.75) 0%, transparent 55%)" }),
780
+ Card({ borderRadius: 24, padding: 20, margin: 16, child:
781
+ Text({ text: "From $128 a night", size: 18, weight: 600, color: colors.text }) }),
782
+ ]})
783
+ ```
784
+
785
+ - The sheet **overlaps** the photograph's bottom edge; a card placed under a
786
+ photo is a caption, and a caption says the photo is the subject of the screen.
787
+ - Words over a photograph need a scrim, and the scrim belongs on the image
788
+ (`maskImage`) or as its own plate (`colors.overlay`) — light text over one
789
+ unscrimmed photo is the finding rule 11 already names.
790
+ - **Color harvest**: the page tint comes from the photograph's dominant hue, so
791
+ the image and the interface are the same color event instead of two arguments.
792
+ It is a token decision, so it goes into `src/database/themes.js` as a named
793
+ tint, not an inline hex.
794
+ - **Glass is for the layer that floats over scrolling content** — the nav over a
795
+ feed, the sheet over a photo. `colors.glass` with `colors.glassBorder` and
796
+ `backdropFilter` on a solid page is a texture, not a hierarchy (rules 10, 19):
797
+ translucency only means anything when there is something moving behind it.
798
+ Translucency only means something when there is something moving behind it: a
799
+ glass panel over a photograph reads as a layer, the same panel over a gradient
800
+ reads as a smudge.
801
+
802
+ ## 32 The shape of each control
803
+
804
+ | Job | The shape | The call |
805
+ | --- | --- | --- |
806
+ | choose 1 of 3-6 | pill row, one filled, rest hollow | `Chip({ variant: "filled" \| "outlined" })` in a `Row({ gap: 8, wrap: true })` |
807
+ | switch 2 views of one object | segmented pill with a sliding plate | `Tabs({ variant: "pills", tabs, activeIndex, activeTextColor, buttonColor })` |
808
+ | choose 1 of N with a preview | selection **tile**, not a radio list | `ListTile({ selected, selectedBgColor })`, or a 2-up `Row` of `Card`s |
809
+ | pick a color/size | dot row, selected gets a ring | `Avatar({ shape: "circle", size: 28 })` row, ring via `borderColor` + `borderWidth` |
810
+ | count then buy | stepper merged into the CTA | one `Row` of `Button`s inside the add-to-cart bar, not two actions |
811
+ | navigate the app | bottom bar, active = accent + label | `BottomNavigation({ items, currentIndex, selectedColor, unselectedColor, showLabels: true })` |
812
+ | a badge on an icon | dot, never a word | `Badge({ value, size, offset, borderColor: colors.background })` |
813
+
814
+ - Selected state is **inverted**, not tinted: on dark, the selected chip goes
815
+ white-with-black-text among hollow dark chips. An inversion is seen before it
816
+ is read, and it survives a color-blind user; a slightly lighter grey is the
817
+ same hue at a value nobody can compare across a row.
818
+ - Disabled is a desaturated gray token, not the same color at 40% opacity,
819
+ because opacity fades the text into whatever is behind it and changes its
820
+ contrast per surface. `colors.textDisabled` is already a different hue rather
821
+ than a fade for exactly that reason (rule 03).
822
+ - Quantity, variant and price live in the same row as the action that spends them
823
+ (rule 15, Fitts). The same rule written as a label is cheaper still:
824
+ `Button({ text: "Add to cart · $34.21" })` — the decision and its cost are one
825
+ read instead of two.
826
+
827
+ ## 33 Data: the dial, the bento, the axis-less chart
828
+
829
+ - **Dial as hero.** One KPI gets a ring, not a chart:
830
+ `CircularBar({ value, max, size: 180, strokeWidth: 14, lineCap: "round", label, subtitle, valueSize: 32, showValue: true })`.
831
+ The number sits inside the ring, so the gauge and the hero are one object and
832
+ one read. One per screen; a second ring is a decoration.
833
+ - **Bento.** Two columns, mixed heights, uniform radius, one tile double-size and
834
+ that tile is the hero: `GridView({ columns: 2, spacing: 12 })` with tiles of
835
+ different `height`, or a `Row` + `Column` pair when the spans get awkward.
836
+ Gaps stay on the 4-scale (rule 03); dense is not the same as tight.
837
+ - **Zero-label charts.** A sparkline with one number beats an axis:
838
+ `Chart({ type: "area", data, smooth: true, areaGradient: true, showGrid: false, showLabels: false, lineColor: colors.primary })`.
839
+ Gridlines ask the reader to measure; the delta text tells them the answer, and
840
+ the shape of the line carries the trend.
841
+ - A chart's series color is the accent; its second series is a neutral, not a
842
+ second hue (rule 09). `Chart({ barColor })` and `lineColor` take tokens.
843
+ - Skeletons, not spinners, and the shape of the skeleton is the shape of the
844
+ thing: `Skeleton({ count: 3, height: 72, borderRadius: 16, gap: 12 })` under a
845
+ list of 72-tall rows (rule 12).
846
+
847
+ ## 34 Genre recipes
848
+
849
+ The moves each genre is made of. Each is a starting composition, not a
850
+ permission slip: rules 01-24 still decide what ships.
851
+
852
+ | Genre | The five moves |
853
+ | --- | --- |
854
+ | Onboarding | full-bleed image or one-hue field · one line at display size · dots or a ghost skip · one pill CTA at the bottom · nothing tappable else |
855
+ | Dashboard | greeting + avatar · one hero number or card · 2-up tinted stat tiles · one chart, no axes · `BottomNavigation` |
856
+ | Commerce list | pill search · horizontal `Chip` rail · 2-col `GridView` of image-first cards · price bold, strike-through muted · a circular add on the card |
857
+ | Product detail | photo top half · sheet overlapping it with 24 top radius · dot swatch + size pills · sticky bar with price inside the CTA |
858
+ | Wallet | dark ramp · card plate as the hero · signed rows, `+` in `successStrong`, `-` in `dangerStrong` · tabular amounts right-aligned |
859
+ | Media player | artwork blurred as the page tint · square art · thin `ProgressBar` · circular transport row · no other chrome |
860
+ | Social feed | story rail of `Avatar`s · full-bleed post cards · actions under the image, centered as a group · one accent for the like state only |
861
+ | Travel | destination photo with scrim · date/guest pill row · horizontal card carousel with a peek of the next · price chip on the image |
862
+ | Health | one or three rings · a 7-bar week strip · mint or lime accent on dark · the number inside the ring |
863
+ | Settings | grouped rows, `ListTile` · leading icon in a tinted plate · trailing chevron or `Switch` · one section cap per group, no hairline between rows |
864
+ | Web landing | transparent `AppBar` · display headline with one word in the accent · pill CTA + ghost pair · a three-number stat strip · card row below |
865
+
866
+ ## 35 Deriving a palette
867
+
868
+ A theme is five roles, not five colors:
869
+
870
+ ```
871
+ anchor the darkest surface, never #000
872
+ primary the one hue the screen spends
873
+ accent a warm counter-hue, used at most once per screen
874
+ wash the tinted paper the page sits on
875
+ ink white or near-white, and its opacity tiers
876
+ ```
877
+
878
+ - **Ramp, not swatch.** A family is 5-7 stops (base → tint → accent → shadow).
879
+ Build it as a ramp so a card, a tile and a plate can be three steps of one hue
880
+ instead of three hues — which is the only way 27's surface discipline is
881
+ possible without breaking 25's one-accent law.
882
+ - **The light/dark pair rule**: for the same hue in both palettes, lift the
883
+ lightness and drop the saturation going dark. That is why this framework's dark
884
+ `dangerStrong` is `#fca5a5` where the light one is `#b91c1c` — the readable red
885
+ is the one that measures 4.5:1 *on that page*, which is rule 09's whole point.
886
+ - **Shadows are tinted, never grey.** A real shadow is the page's own hue darkened
887
+ and made translucent, so the ambient shadow is a large blur at low alpha in that
888
+ hue (`0 5px 50px rgba(0,67,101,0.08)`). In FletBox that is a
889
+ `shadow: "0 20px 50px rgba(…)"` string on a `Container`/`Card` —
890
+ `colors.shadow` is the neutral default and is the flat version of the same idea
891
+ (rule 10).
892
+ - Add the tokens to `src/database/themes.js` in **both** palettes. The gate that
893
+ checks the two palettes expose the same token names exists because a light-only
894
+ tint once deleted `colors.errorBg` out from under a running app; a token added
895
+ to one theme is a runtime `undefined` in the other.
896
+ - Contrast is still the veto: `npm run ui -- --tokens` measures every pair. A
897
+ palette that looks right and measures 3.2:1 is a palette with a bug in it.
898
+
899
+ ## 36 The dated visual tells
900
+
901
+ Rule 19 bans the template tells of copy and hierarchy. These are the *visual*
902
+ ones, and each is named with the mechanism that dates it, so an edge case can be
903
+ judged rather than obeyed.
904
+
905
+ - **Violet mesh gradient behind the phone**, and the blurred 3D blob or sphere
906
+ floating in front of it. A decoration that carries no information reads as
907
+ stock the moment the eye finishes with it. There is no gradient page surface in
908
+ this framework anyway — `gradient` exists on `Button` and `AppBar`, which is the
909
+ system saying it is one element's surface (rule 19).
910
+ - **Neumorphism**: extruded dials and pressed-in tiles. Its shadows are the only
911
+ depth cue, so the moment the surface changes the affordance disappears —
912
+ a control whose visibility depends on a light source is not a control.
913
+ - **Glass over a solid page**, or glass on every card. See 31: translucency needs
914
+ something moving behind it, and a page of glass has no layer left to signal.
915
+ - **Gradient-filled headline text.** `Text` has no gradient, and color running
916
+ across a word destroys the letter edges the reader is using. Emphasis comes
917
+ from weight and ink (rule 17).
918
+ - **Hairline border + 6-8px radius + drop shadow on every card**, and a
919
+ `Divider` under every row: the three-piece card that separates everything and
920
+ therefore organizes nothing (rules 10, 19).
921
+ - **Skeuomorphic props** — a rendered credit card as the hero of a wallet, a
922
+ photoreal watch bezel, isometric people, emoji as feature icons. They depict an
923
+ object instead of the user's own data, which is the one thing the screen has
924
+ that no stock art can supply.
925
+ - **Default-blue SaaS**: a blue→indigo button, a grey page, white cards, no
926
+ accent discipline. It is not a color problem, it is a missing archetype
927
+ decision (26) — the palette was never tied to anything this product does.
928
+ - **Three CTAs in a hero row**, and the floating-phone-on-a-plinth presentation:
929
+ both are portfolio staging, not interface.
930
+
931
+ Deleting a tell is not a design: each one still owes its job to a rule —
932
+ atmosphere to 27, hierarchy to 29, separation to 30, depth to 31, emphasis to 28.
933
+
934
+ ## 37 Choosing, in five questions
935
+
936
+ Answer them before the first widget, in the commit message or the memory note.
937
+ If two answers are "I don't mind", the screen is not being designed, it is being
938
+ filled.
939
+
940
+ 1. Which archetype (26), and why does this product's genre point there?
941
+ 2. Which surface family, and which three steps of its ramp does this screen use (27)?
942
+ 3. Where is the accent spent — name the three placements (28)?
943
+ 4. What is the hero, and is it 3-5× everything else (25, 29)?
944
+ 5. Which tell from 36 is the one this screen is most likely to fall into, given
945
+ its genre and its imagery?
946
+
947
+ Then run the loop in 24. Part II never overrides a rule from Part I: the atlas is
948
+ the menu, the rules are the contract, and the checker has the last word on
949
+ legality and the user has it on whether the screen is understood (23).