@bitforgehq/ui 0.3.0

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 (281) hide show
  1. package/CHANGELOG.md +3665 -0
  2. package/LICENSE +21 -0
  3. package/README.md +175 -0
  4. package/ai/MANIFEST.index.json +383 -0
  5. package/ai/MANIFEST.json +7528 -0
  6. package/ai/REFERENCE.index.json +1461 -0
  7. package/ai/REFERENCE.json +7213 -0
  8. package/ai/UI_GUIDE.md +1159 -0
  9. package/ai/components/action-bar.md +209 -0
  10. package/ai/components/action-sheet.md +526 -0
  11. package/ai/components/alert-dialog.md +509 -0
  12. package/ai/components/alert.md +376 -0
  13. package/ai/components/avatar.md +146 -0
  14. package/ai/components/back-button.md +286 -0
  15. package/ai/components/badge.md +171 -0
  16. package/ai/components/button.md +431 -0
  17. package/ai/components/card.md +303 -0
  18. package/ai/components/checkbox.md +418 -0
  19. package/ai/components/chip-group.md +205 -0
  20. package/ai/components/chip.md +300 -0
  21. package/ai/components/content.md +474 -0
  22. package/ai/components/desktop-toast.md +420 -0
  23. package/ai/components/dialog.md +962 -0
  24. package/ai/components/empty-state.md +146 -0
  25. package/ai/components/field.md +319 -0
  26. package/ai/components/header.md +809 -0
  27. package/ai/components/icon-button.md +221 -0
  28. package/ai/components/icon.md +176 -0
  29. package/ai/components/input.md +455 -0
  30. package/ai/components/list-item.md +326 -0
  31. package/ai/components/list.md +178 -0
  32. package/ai/components/menu-button.md +377 -0
  33. package/ai/components/menu.md +661 -0
  34. package/ai/components/modal.md +613 -0
  35. package/ai/components/number-input.md +423 -0
  36. package/ai/components/page.md +299 -0
  37. package/ai/components/progress.md +215 -0
  38. package/ai/components/radio-group.md +432 -0
  39. package/ai/components/refresher.md +377 -0
  40. package/ai/components/segmented.md +517 -0
  41. package/ai/components/select.md +510 -0
  42. package/ai/components/sheet.md +845 -0
  43. package/ai/components/spinner.md +171 -0
  44. package/ai/components/stack-outlet.md +661 -0
  45. package/ai/components/tab-bar.md +427 -0
  46. package/ai/components/tab.md +295 -0
  47. package/ai/components/tabs.md +408 -0
  48. package/ai/components/textarea.md +371 -0
  49. package/ai/components/toast.md +522 -0
  50. package/ai/components/toggle.md +387 -0
  51. package/ai/reference/bf-action-directive.md +21 -0
  52. package/ai/reference/bf-action-sheet-action-role.md +27 -0
  53. package/ai/reference/bf-action-sheet-action.md +28 -0
  54. package/ai/reference/bf-action-sheet-options.md +28 -0
  55. package/ai/reference/bf-action-sheet-service.md +33 -0
  56. package/ai/reference/bf-alert-dialog-button-role.md +27 -0
  57. package/ai/reference/bf-alert-dialog-button.md +27 -0
  58. package/ai/reference/bf-alert-dialog-options.md +29 -0
  59. package/ai/reference/bf-alert-dialog-service.md +34 -0
  60. package/ai/reference/bf-alert-variant.md +28 -0
  61. package/ai/reference/bf-autocapitalize.md +30 -0
  62. package/ai/reference/bf-avatar-size.md +27 -0
  63. package/ai/reference/bf-avatar-variant.md +28 -0
  64. package/ai/reference/bf-back-at-root.md +28 -0
  65. package/ai/reference/bf-back-button-service.md +40 -0
  66. package/ai/reference/bf-badge-variant.md +29 -0
  67. package/ai/reference/bf-button-size.md +29 -0
  68. package/ai/reference/bf-button-type.md +28 -0
  69. package/ai/reference/bf-button-variant.md +29 -0
  70. package/ai/reference/bf-card-padding.md +26 -0
  71. package/ai/reference/bf-card-variant.md +28 -0
  72. package/ai/reference/bf-chip-group-mode.md +27 -0
  73. package/ai/reference/bf-chip-variant.md +26 -0
  74. package/ai/reference/bf-compare-with.md +24 -0
  75. package/ai/reference/bf-component-overlay-ref.md +33 -0
  76. package/ai/reference/bf-confirm-button-role.md +26 -0
  77. package/ai/reference/bf-confirm-button.md +27 -0
  78. package/ai/reference/bf-confirm-options.md +31 -0
  79. package/ai/reference/bf-confirm-size.md +26 -0
  80. package/ai/reference/bf-content-scroll-event.md +21 -0
  81. package/ai/reference/bf-defaults-options.md +32 -0
  82. package/ai/reference/bf-defaults.md +28 -0
  83. package/ai/reference/bf-desktop-toast-config.md +28 -0
  84. package/ai/reference/bf-desktop-toast-harness.md +33 -0
  85. package/ai/reference/bf-desktop-toast-options.md +28 -0
  86. package/ai/reference/bf-desktop-toast-position.md +28 -0
  87. package/ai/reference/bf-desktop-toast-service.md +42 -0
  88. package/ai/reference/bf-dialog-config.md +44 -0
  89. package/ai/reference/bf-dialog-harness.md +52 -0
  90. package/ai/reference/bf-dialog-ref.md +44 -0
  91. package/ai/reference/bf-dialog-role.md +26 -0
  92. package/ai/reference/bf-dialog-service.md +55 -0
  93. package/ai/reference/bf-dialog-size.md +31 -0
  94. package/ai/reference/bf-dialog-template-context.md +32 -0
  95. package/ai/reference/bf-empty-state-heading-level.md +26 -0
  96. package/ai/reference/bf-enter-key-hint.md +31 -0
  97. package/ai/reference/bf-field-control-class.md +32 -0
  98. package/ai/reference/bf-field-control-state.md +28 -0
  99. package/ai/reference/bf-field-size.md +29 -0
  100. package/ai/reference/bf-haptics-impact-style.md +27 -0
  101. package/ai/reference/bf-haptics-notification-type.md +27 -0
  102. package/ai/reference/bf-haptics-service.md +41 -0
  103. package/ai/reference/bf-harness-filters.md +29 -0
  104. package/ai/reference/bf-horizontal-scroll-directive.md +129 -0
  105. package/ai/reference/bf-icon-button-variant.md +28 -0
  106. package/ai/reference/bf-icon-size.md +27 -0
  107. package/ai/reference/bf-input-autocapitalize.md +36 -0
  108. package/ai/reference/bf-input-enter-key-hint.md +38 -0
  109. package/ai/reference/bf-input-input-mode.md +38 -0
  110. package/ai/reference/bf-input-mode.md +32 -0
  111. package/ai/reference/bf-input-type.md +35 -0
  112. package/ai/reference/bf-jsdom-shim-options.md +22 -0
  113. package/ai/reference/bf-keyboard-assist-behavior.md +26 -0
  114. package/ai/reference/bf-keyboard-assist-directive.md +185 -0
  115. package/ai/reference/bf-keyboard-phase.md +30 -0
  116. package/ai/reference/bf-keyboard-service.md +50 -0
  117. package/ai/reference/bf-labels-overrides.md +26 -0
  118. package/ai/reference/bf-labels.md +44 -0
  119. package/ai/reference/bf-menu-button-harness.md +40 -0
  120. package/ai/reference/bf-menu-button-variant.md +27 -0
  121. package/ai/reference/bf-menu-divider-directive.md +13 -0
  122. package/ai/reference/bf-menu-group-directive.md +14 -0
  123. package/ai/reference/bf-menu-harness.md +55 -0
  124. package/ai/reference/bf-menu-item-checkbox-directive.md +31 -0
  125. package/ai/reference/bf-menu-item-directive.md +31 -0
  126. package/ai/reference/bf-menu-item-harness.md +52 -0
  127. package/ai/reference/bf-menu-item-radio-directive.md +29 -0
  128. package/ai/reference/bf-menu-placement.md +29 -0
  129. package/ai/reference/bf-menu-trigger-directive.md +46 -0
  130. package/ai/reference/bf-modal-options.md +30 -0
  131. package/ai/reference/bf-modal-presentation.md +26 -0
  132. package/ai/reference/bf-modal-service.md +38 -0
  133. package/ai/reference/bf-nav-controller.md +59 -0
  134. package/ai/reference/bf-nav-direction.md +23 -0
  135. package/ai/reference/bf-nav-target.md +21 -0
  136. package/ai/reference/bf-number-input-mode.md +28 -0
  137. package/ai/reference/bf-number-input-step.md +25 -0
  138. package/ai/reference/bf-overlay-auto-focus.md +29 -0
  139. package/ai/reference/bf-overlay-controller.md +22 -0
  140. package/ai/reference/bf-overlay-host.md +36 -0
  141. package/ai/reference/bf-overlay-options.md +53 -0
  142. package/ai/reference/bf-overlay-position.md +30 -0
  143. package/ai/reference/bf-overlay-ref.md +53 -0
  144. package/ai/reference/bf-overlay-role.md +27 -0
  145. package/ai/reference/bf-overlay-service.md +53 -0
  146. package/ai/reference/bf-overlay-stack-options.md +24 -0
  147. package/ai/reference/bf-overlay-stack.md +81 -0
  148. package/ai/reference/bf-overlay-template-context.md +24 -0
  149. package/ai/reference/bf-overscroll-stretch-directive.md +163 -0
  150. package/ai/reference/bf-page-context.md +24 -0
  151. package/ai/reference/bf-platform-service.md +53 -0
  152. package/ai/reference/bf-platform.md +28 -0
  153. package/ai/reference/bf-progress-size.md +26 -0
  154. package/ai/reference/bf-progress-tone.md +28 -0
  155. package/ai/reference/bf-radio-group-harness.md +49 -0
  156. package/ai/reference/bf-radio-group-orientation.md +26 -0
  157. package/ai/reference/bf-radio-option.md +30 -0
  158. package/ai/reference/bf-refresher-refresh-event.md +22 -0
  159. package/ai/reference/bf-segment-option.md +29 -0
  160. package/ai/reference/bf-segmented-harness.md +48 -0
  161. package/ai/reference/bf-segmented-size.md +29 -0
  162. package/ai/reference/bf-select-harness.md +70 -0
  163. package/ai/reference/bf-select-option.md +29 -0
  164. package/ai/reference/bf-sheet-auto-focus.md +29 -0
  165. package/ai/reference/bf-sheet-options.md +32 -0
  166. package/ai/reference/bf-sheet-ref.md +45 -0
  167. package/ai/reference/bf-sheet-service.md +34 -0
  168. package/ai/reference/bf-shell-config-mode.md +28 -0
  169. package/ai/reference/bf-shell-config.md +38 -0
  170. package/ai/reference/bf-shell-dynamic-type-limit.md +21 -0
  171. package/ai/reference/bf-shell-dynamic-type.md +25 -0
  172. package/ai/reference/bf-shell-mode.md +28 -0
  173. package/ai/reference/bf-shell-testing-options.md +26 -0
  174. package/ai/reference/bf-size.md +45 -0
  175. package/ai/reference/bf-spinner-size.md +27 -0
  176. package/ai/reference/bf-stack-change.md +22 -0
  177. package/ai/reference/bf-stack-reuse-strategy.md +32 -0
  178. package/ai/reference/bf-stack-transition-event.md +26 -0
  179. package/ai/reference/bf-stack-transition.md +26 -0
  180. package/ai/reference/bf-stacked-overlay.md +23 -0
  181. package/ai/reference/bf-system-bars-style.md +38 -0
  182. package/ai/reference/bf-tab-bar-controller.md +51 -0
  183. package/ai/reference/bf-tab-bar-host.md +52 -0
  184. package/ai/reference/bf-tab-context.md +32 -0
  185. package/ai/reference/bf-tab-links.md +37 -0
  186. package/ai/reference/bf-testing-options.md +27 -0
  187. package/ai/reference/bf-text-scale-service.md +35 -0
  188. package/ai/reference/bf-theme.md +27 -0
  189. package/ai/reference/bf-toast-action.md +25 -0
  190. package/ai/reference/bf-toast-controller.md +17 -0
  191. package/ai/reference/bf-toast-dismiss-reason.md +29 -0
  192. package/ai/reference/bf-toast-options.md +29 -0
  193. package/ai/reference/bf-toast-position.md +26 -0
  194. package/ai/reference/bf-toast-ref.md +41 -0
  195. package/ai/reference/bf-toast-service.md +39 -0
  196. package/ai/reference/bf-toggle-label-position.md +26 -0
  197. package/ai/reference/bf-transition-kind.md +28 -0
  198. package/ai/reference/bf-validation-message.md +31 -0
  199. package/ai/reference/bf-validation-messages.md +33 -0
  200. package/ai/reference/bf-view-event.md +28 -0
  201. package/ai/reference/bf-view-lifecycle.md +41 -0
  202. package/ai/reference/bf-view-state.md +28 -0
  203. package/ai/reference/bf_default_labels.md +21 -0
  204. package/ai/reference/bf_default_values.md +21 -0
  205. package/ai/reference/bf_defaults.md +21 -0
  206. package/ai/reference/bf_desktop_toast_defaults.md +21 -0
  207. package/ai/reference/bf_dialog_data.md +17 -0
  208. package/ai/reference/bf_labels.md +21 -0
  209. package/ai/reference/bf_overlay_data.md +19 -0
  210. package/ai/reference/bf_overlay_host.md +21 -0
  211. package/ai/reference/bf_sheet_default_snap_points.md +17 -0
  212. package/ai/reference/bf_shell_config.md +21 -0
  213. package/ai/reference/bf_shell_defaults.md +21 -0
  214. package/ai/reference/bf_tab_bar.md +21 -0
  215. package/ai/reference/bf_tab_bar_controller.md +21 -0
  216. package/ai/reference/bf_tab_context.md +21 -0
  217. package/ai/reference/bf_tab_links.md +21 -0
  218. package/ai/reference/bf_theme_storage.md +19 -0
  219. package/ai/reference/create-transition.md +36 -0
  220. package/ai/reference/duration_fast.md +17 -0
  221. package/ai/reference/duration_md.md +17 -0
  222. package/ai/reference/duration_overlay.md +17 -0
  223. package/ai/reference/duration_push.md +17 -0
  224. package/ai/reference/fade-down.md +26 -0
  225. package/ai/reference/fade-in.md +25 -0
  226. package/ai/reference/fade-out.md +25 -0
  227. package/ai/reference/fade-up.md +26 -0
  228. package/ai/reference/inject-shell-mode.md +23 -0
  229. package/ai/reference/install-bf-jsdom-shims.md +31 -0
  230. package/ai/reference/ios_duration.md +17 -0
  231. package/ai/reference/ios_easing.md +17 -0
  232. package/ai/reference/is-text-entry.md +25 -0
  233. package/ai/reference/max_complete_ms.md +17 -0
  234. package/ai/reference/md-transition.md +31 -0
  235. package/ai/reference/md_duration.md +17 -0
  236. package/ai/reference/md_easing.md +17 -0
  237. package/ai/reference/overlay_duration.md +21 -0
  238. package/ai/reference/overlay_easing.md +19 -0
  239. package/ai/reference/overlay_reduced_duration.md +17 -0
  240. package/ai/reference/pop-frames-at.md +25 -0
  241. package/ai/reference/prefers-reduced-motion.md +25 -0
  242. package/ai/reference/provide-bf-defaults.md +33 -0
  243. package/ai/reference/provide-bf-desktop-toast.md +28 -0
  244. package/ai/reference/provide-bf-labels.md +38 -0
  245. package/ai/reference/provide-bf-shell-testing.md +34 -0
  246. package/ai/reference/provide-bf-shell.md +41 -0
  247. package/ai/reference/provide-bf-testing.md +33 -0
  248. package/ai/reference/reduced-transition.md +28 -0
  249. package/ai/reference/reduced_duration.md +17 -0
  250. package/ai/reference/resolve-shell-mode.md +33 -0
  251. package/ai/reference/scale-in.md +25 -0
  252. package/ai/reference/settle-duration.md +25 -0
  253. package/ai/reference/settle-pop.md +33 -0
  254. package/ai/reference/settle_easing.md +17 -0
  255. package/ai/reference/should-complete-pop.md +26 -0
  256. package/ai/reference/slide-down.md +26 -0
  257. package/ai/reference/slide-up.md +26 -0
  258. package/ai/reference/with-bf-shell-routing.md +19 -0
  259. package/bin/bf-ui.js +81 -0
  260. package/fesm2022/bitforgehq-ui-icons.mjs +230 -0
  261. package/fesm2022/bitforgehq-ui-icons.mjs.map +1 -0
  262. package/fesm2022/bitforgehq-ui-mobile.mjs +5530 -0
  263. package/fesm2022/bitforgehq-ui-mobile.mjs.map +1 -0
  264. package/fesm2022/bitforgehq-ui-number-input.mjs +680 -0
  265. package/fesm2022/bitforgehq-ui-number-input.mjs.map +1 -0
  266. package/fesm2022/bitforgehq-ui-overlay.mjs +2675 -0
  267. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -0
  268. package/fesm2022/bitforgehq-ui-testing.mjs +402 -0
  269. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -0
  270. package/fesm2022/bitforgehq-ui.mjs +7765 -0
  271. package/fesm2022/bitforgehq-ui.mjs.map +1 -0
  272. package/llms-full.txt +25394 -0
  273. package/llms.txt +329 -0
  274. package/package.json +94 -0
  275. package/theme.css +253 -0
  276. package/types/bitforgehq-ui-icons.d.ts +50 -0
  277. package/types/bitforgehq-ui-mobile.d.ts +2914 -0
  278. package/types/bitforgehq-ui-number-input.d.ts +282 -0
  279. package/types/bitforgehq-ui-overlay.d.ts +1514 -0
  280. package/types/bitforgehq-ui-testing.d.ts +495 -0
  281. package/types/bitforgehq-ui.d.ts +3499 -0
package/ai/UI_GUIDE.md ADDED
@@ -0,0 +1,1159 @@
1
+ <!-- GENERATED by tools/gen-ai-docs.ts from tools/ui-guide.template.md and the component sources. Do not edit by hand; run `bun run ai:docs`. -->
2
+
3
+ # @bitforgehq/ui — guide for implementer agents
4
+
5
+ You are working in an app that uses `@bitforgehq/ui`, Bitforge's shared Angular 22 component
6
+ library (Meadow Sage design, Tailwind CSS v4, signals-first). **Before creating any UI primitive
7
+ (button, input, card, list, badge, …), check the component index below and use the library
8
+ component.** Per-component pages with the full API, examples and dos/don'ts are in
9
+ `node_modules/@bitforgehq/ui/ai/components/<name>.md`; the machine-readable version is
10
+ `node_modules/@bitforgehq/ui/ai/MANIFEST.json`.
11
+
12
+ ## Install
13
+
14
+ ### 1. Check the prerequisites
15
+
16
+ Node 22.22.3 or later in the 22 line, 24.15 or later in the 24 line, or 26 and later (the Angular CLI 22 range `^22.22.3 || ^24.15.0 || >=26.0.0`; it refuses older versions and warns on odd releases such as 23 and 25), Bun 1.3 or later and the Angular CLI 22.
17
+
18
+ ```bash
19
+ node --version # v22.22.3+, v24.15+ or v26+
20
+ bun --version # 1.3 or later
21
+ bun add --global @angular/cli@22
22
+ ```
23
+
24
+ ### 2. Create the app
25
+
26
+ A new Angular 22 app with plain CSS styles. Skip this step in an existing app.
27
+
28
+ ```bash
29
+ ng new my-app --style css --package-manager bun
30
+ cd my-app
31
+ ```
32
+
33
+ ### 3. Add Tailwind CSS
34
+
35
+ The theme is a Tailwind CSS 4 theme. `ng add tailwindcss` installs Tailwind and its PostCSS plugin and writes `.postcssrc.json`.
36
+
37
+ ```bash
38
+ ng add tailwindcss
39
+ ```
40
+
41
+ ### 4. Add the packages
42
+
43
+ `lucide` provides the icon data for `bf-icon`; the two `@fontsource` packages provide the Manrope and JetBrains Mono fonts.
44
+
45
+ ```bash
46
+ bun add @bitforgehq/ui lucide @fontsource/manrope @fontsource/jetbrains-mono
47
+ ```
48
+
49
+ ### 5. Wire the theme into styles.css
50
+
51
+ Replace the contents of `src/styles.css`. Keep `tailwindcss` first: the theme redefines the palette its utilities read, and registers the package as a Tailwind source, so no `tailwind.config.js` is needed.
52
+
53
+ ```css
54
+ @import 'tailwindcss';
55
+ @import '@bitforgehq/ui/theme.css';
56
+ @import '@fontsource/manrope/500.css';
57
+ @import '@fontsource/manrope/600.css';
58
+ @import '@fontsource/manrope/700.css';
59
+ @import '@fontsource/manrope/800.css';
60
+ @import '@fontsource/jetbrains-mono/400.css';
61
+ ```
62
+
63
+ ### 6. Theme the page
64
+
65
+ In `src/index.html`, give `<body>` the theme background and text colour, so the page itself follows light and dark mode.
66
+
67
+ ```html
68
+ <body class="bg-bg text-text">
69
+ <app-root></app-root>
70
+ </body>
71
+ ```
72
+
73
+ ### 7. Switch to dark mode
74
+
75
+ Add the `dark` class to `<html>` (and remove it for light mode). Every token is redefined under `.dark`, and the theme sets `color-scheme`, so scrollbars and native controls follow too. Components need no `dark:` classes.
76
+
77
+ ```html
78
+ <html lang="en" class="dark">
79
+ <!-- head and body stay as they are -->
80
+ </html>
81
+ ```
82
+
83
+ ### 8. Use a first component
84
+
85
+ Import a widget from `@bitforgehq/ui` into the component that uses it. Replace `src/app/app.ts` with the code below and delete the welcome page it no longer uses, `app.html` and `app.css`. `app.spec.ts` tests that welcome page too: delete its `should render title` case (or make it check the button), so `ng test` keeps passing.
86
+
87
+ ```ts
88
+ import { Component } from '@angular/core';
89
+ import { BfButtonComponent } from '@bitforgehq/ui';
90
+
91
+ @Component({
92
+ selector: 'app-root',
93
+ imports: [BfButtonComponent],
94
+ template: `<bf-button variant="primary">Save</bf-button>`,
95
+ })
96
+ export class App {}
97
+ ```
98
+
99
+ ### 9. Check it
100
+
101
+ Run the app and open it: you should now see an indigo, pill-shaped **Save** button in Manrope on the theme background. With `class="dark"` on `<html>`, the whole page is dark.
102
+
103
+ ```bash
104
+ ng serve
105
+ ```
106
+
107
+ Required peers: `@angular/common`, `@angular/core`, `@angular/forms` and `lucide`. An app that
108
+ imports `@bitforgehq/ui/overlay` (the overlay core: no Capacitor, no router) also installs
109
+ `@angular/cdk` and `rxjs` (a CDK peer); an app that imports `@bitforgehq/ui/mobile` installs those
110
+ plus `@angular/router` and `@capacitor/core`. They are declared optional so a desktop-only
111
+ install of the widgets stays free of them.
112
+
113
+ `styles.css` is the same for web and mobile apps. Nothing else: `theme.css` registers the library
114
+ as a Tailwind source itself. Do not create a `tailwind.config.js`, and add no Tailwind plugin:
115
+ the widgets style their native controls themselves.
116
+
117
+ Optional per-app overrides go after the import. A brand colour is a set: override
118
+ `--color-primary` together with `--color-primary-text` (primary as text, ≥ 4.5:1 on `bg` and
119
+ `surface`) and the rest of the `--color-primary-*` tokens, in `@theme` and in `.dark`; the docs
120
+ site's theme configurator derives the whole contrast-checked set from one colour.
121
+ `--radius-control` (`9999px`, pills) reshapes buttons, chips, badges, segmented, the tab bar, the
122
+ action bar and toggles at once:
123
+
124
+ ```css
125
+ @theme {
126
+ --color-primary: #123456;
127
+ --color-primary-text: #123456;
128
+ --radius-control: 8px;
129
+ }
130
+ ```
131
+
132
+ ## Compatibility
133
+
134
+ `@bitforgehq/ui` 0.x is client-rendered only. SSR, hydration and prerendering of pages that render
135
+ its components are not supported or tested. Component ids come from a module-level counter and can
136
+ differ between server and client, and the shell's theme is read from `localStorage` on the client.
137
+ Render library components only in the browser, for example in a `@defer` block or a client-only
138
+ route. Support will be decided before 1.0.
139
+
140
+ The one server-side check is a render smoke, not a support test: the library repository's
141
+ `tools/ssr-smoke.ts` renders 28 fixtures with `renderApplication()` (every root widget, `bf-icon`,
142
+ `bf-number-input`, a `/mobile` page with `provideBfShell()`, `bf-page`, `bf-header` and
143
+ `bf-content`, `bf-tabs` with `bf-stack-outlet` under the router, and an `/overlay` dialog), and all
144
+ of them render without an error. 19 of them render identically twice. The other 9 number their ids
145
+ differently on every render, from a module-level counter, so they could not hydrate: `bf-checkbox`,
146
+ `bf-input`, `bf-radio-group`, `bf-segmented`, `bf-select`, `bf-textarea`, `bf-toggle`,
147
+ `bf-number-input` and the `/overlay` dialog. Hydration itself is still not supported or tested, and
148
+ more stands in its way: `/mobile` writes to `<html>` and adds a keyboard probe to `<body>`, and an
149
+ `/overlay` dialog renders outside the app root and marks it inert.
150
+
151
+ ## Keeping this guide in sync
152
+
153
+ This file is installed by the `bf-ui` CLI that ships with the package. Run `bunx bf-ui sync-ai`
154
+ to write (or refresh) `.ai/UI_GUIDE.md` in the consuming project, and add the CLAUDE.md/AGENTS.md
155
+ snippet it prints. `bunx bf-ui sync-ai --check` exits non-zero when the installed copy is missing
156
+ or out of date — wire it into CI so agent guidance never drifts from the installed package version.
157
+
158
+ ## Using components
159
+
160
+ - Import the standalone class into the component that uses it:
161
+ `import { BfButtonComponent } from '@bitforgehq/ui';` and add it to `imports: [...]`.
162
+ - Icons: `import { BfIconComponent } from '@bitforgehq/ui/icons';` plus the icon data from
163
+ `lucide` (`import { Search } from 'lucide'`), then `<bf-icon [icon]="Search" />`. Every icon
164
+ position in every widget is a content slot (`<bf-icon bfSlot="icon-start" ... />`).
165
+ - Number field: `import { BfNumberInputComponent } from '@bitforgehq/ui/number-input';`. It is
166
+ the one widget in its own entry point, because it loads Angular Forms (`@angular/forms`, a
167
+ required peer); `@bitforgehq/ui` itself loads no forms code, so an app without forms ships none.
168
+ - Layout classes go on the element (`<bf-button class="mt-4">`); never restyle a widget's internals.
169
+ - Colors: use only the semantic tokens (`bg-primary`, `text-text-muted`, `bg-surface`,
170
+ `border-border`, `rounded-field`, `shadow-raised`, `text-body`). Never raw palette classes and
171
+ never `dark:` variants; put `dark` on `<html>` and the theme re-colors everything.
172
+ - Forms: `bf-input`, `bf-number-input`, `bf-textarea`, `bf-select`, `bf-checkbox`, `bf-toggle`,
173
+ `bf-radio-group` and `bf-segmented` expose `value` / `checked` as `model()` and implement
174
+ Signal Forms' `FormValueControl` / `FormCheckboxControl`. Bind either `[formField]="form.field"` or
175
+ `[(value)]` / `[(checked)]`, never both. Import `FormField`, `FormRoot`, `form` and the
176
+ validators from `@angular/forms/signals`. The first error shows once the field is touched: its
177
+ own `message` (`required(p.email, { message: 'Enter your email' })`), else the default text for
178
+ its kind from `provideBfLabels()` ("Fill in this field", "Use at least 5 characters", …), so a
179
+ field never turns red without text. Wrap the fields in
180
+ `<form [formRoot]="myForm">` with `submission: { action, onInvalid }` in `form()`'s options, so
181
+ an empty submit shows every message instead of the browser's bubbles; errors the `action`
182
+ returns show under their field. `errorMessage` is for errors from outside the form.
183
+ - Typed values: `bf-select`, `bf-radio-group` and `bf-segmented` are generic over their option
184
+ values (`BfSelectOption<number>`, `BfRadioOption<T>`, `BfSegmentOption<T>`; `string` by
185
+ default) and bind `T | null`, where `null` means nothing is chosen (start the field at `null`;
186
+ `required()` treats it as empty; `(changed)` emits only a chosen `T`). Options match with
187
+ `Object.is`; for object values pass `[compareWith]="byId"`. Numbers bind to `bf-number-input`
188
+ (`number | null`, `null` while empty; `min()`/`max()` reach the native input; unreadable text is
189
+ a `parse` error on the bound field, blocking `submit()`), never to a `string` model on
190
+ `bf-input type="number"`.
191
+ - Reactive Forms work through Angular 22's built-in interop: `[formControl]` and
192
+ `formControlName` bind every form widget above with no `ControlValueAccessor` (never add one).
193
+ Value, touched, dirty, validity, pending, disabled, required and errors flow into the widget;
194
+ the widget writes back only its value (marking the control dirty) and touched on blur. There is
195
+ no read-only state in Reactive Forms: bind `[readonly]` yourself. `Validators.*` errors carry no
196
+ message, so a touched invalid field shows the default text for the error's kind (`required`,
197
+ `minlength`, … read from the error's `context`); pass `errorMessage` only for your own text.
198
+ Type controls like the widgets' values: `new FormControl<number | null>(null, Validators.min(1))`
199
+ on a `bf-number-input` or a select of numeric ids.
200
+ - Links: navigation is a real `<a>`. `<a bfButton routerLink="/new">` styles a link as a
201
+ `bf-button` (same `variant`/`size`; import `BfButtonComponent`). For a clickable `bf-card` or
202
+ `bf-list-item`, put `bfAction` (`BfActionDirective`) on the one `<a>` or `<button>` holding the
203
+ heading or title, `<h3><a bfAction routerLink="/projects/1">Atlas</a></h3>`: it stretches over
204
+ the surface while the heading stays a heading; give other controls inside `class="relative z-1"`.
205
+ Never use the deprecated `bf-card [interactive]`. Inside `bf-tabs` the tab bar renders a `nav` of
206
+ links with `aria-current`; a standalone `bf-tab-bar` keeps `tablist` semantics.
207
+
208
+ ## API conventions
209
+
210
+ Every public output, name input and type follows one convention (the generator enforces it), so
211
+ you can guess a binding without looking it up:
212
+
213
+ - Outputs are events in the past tense: `(clicked)`, `(changed)`, `(dismissed)`, `(focused)`,
214
+ `(blurred)`, `(scrolled)`, `(reselected)`, `(stackChanged)`, `(transitionStarted)`,
215
+ `(refreshRequested)`. None starts with `on`, and no declared output is `xChange` (that name is
216
+ the implicit output of a `model()`, `[(value)]`). Exceptions: `touch` (the Signal Forms
217
+ contract) and the page lifecycle `bfViewWillEnter`/`bfViewDidEnter`/`bfViewWillLeave`/`bfViewDidLeave`.
218
+ - Actions emit `(clicked)`; a sub-part's action is `<part>Clicked` (the desktop toast's
219
+ `actionClicked`). Exception: menu items (`bfMenuItem`, `bfMenuItemCheckbox`, `bfMenuItemRadio`)
220
+ emit `(triggered)`, mirroring the CDK menu, and it fires on `Enter` and `Space` too; binding
221
+ `(clicked)` on a menu item never fires.
222
+ - Every value control (a `value`/`checked`/`selected`/`active` model: the form fields, `bf-chip`,
223
+ `bf-tab-bar`, `bfMenuItemCheckbox`) has exactly one `(changed)`, emitted with the new value right
224
+ after a user change, never for a programmatic write.
225
+ - Names: `label` is a component's own name (visible, or the accessible name of an icon button,
226
+ spinner, tab bar, progress or icon); every component that renders a native input, select or
227
+ textarea (checkbox and toggle included) also takes `ariaLabel` and `ariaLabelledby` for when no
228
+ visible `label` is shown; a built-in sub-control's name is `<part>Label` (`dismissLabel`,
229
+ `closeLabel`, `actionLabel`); `alt` only on `bf-avatar`.
230
+ - Types: every union or object type of an input, model, output, `Bf…Options`/`Bf…Config` property
231
+ or an interface they name is a named type exported from the same entry
232
+ (`BfToggleLabelPosition`, `BfRefresherRefreshEvent`, `BfDialogRole`, …), so type your own state
233
+ with it instead of repeating the literals. A field's "left off" value `''` has its own type
234
+ (`BfInputInputMode`, `BfInputEnterKeyHint`, `BfInputAutocapitalize`); `BfInputMode`,
235
+ `BfEnterKeyHint` and `BfAutocapitalize` list only the native keywords.
236
+ - Renamed 0.2 outputs keep working as deprecated aliases that emit together with the new name
237
+ until 0.4.0: `bf-chip` `toggled` → `changed`, `bf-tab-bar` `tabChange` → `changed` and
238
+ `tabReselect` → `reselected`, `bf-stack-outlet` `stackChange` → `stackChanged`,
239
+ `transitionStart` → `transitionStarted` and `transitionEnd` → `transitionEnded`, `bf-refresher`
240
+ `refresh` → `refreshRequested`. Write new code with the new names.
241
+
242
+ ## Configuration
243
+
244
+ Two optional providers from `@bitforgehq/ui` set app-wide widget defaults. Each returns a plain
245
+ `Provider` merged over the nearest parent injector's value, so it works in
246
+ `ApplicationConfig.providers` and in a component's `providers` (for a sub-tree, e.g. a dense
247
+ toolbar or a German settings page); a key you leave out keeps the parent's value.
248
+
249
+ - `provideBfDefaults({ size, fieldSize, controlSize })` sets the height step (`BfSize`: `sm` 32,
250
+ `md` 40, `lg` 48 px) a widget uses when its own `size` input is unset. `fieldSize` covers the
251
+ text fields (`bf-input`, `bf-number-input`, `bf-select`, `bf-textarea`), `controlSize` the
252
+ actions and choice controls (`bf-button`, `bf-icon-button`, `bf-chip`, `bf-segmented`,
253
+ `bf-checkbox`, `bf-toggle`, `bf-radio-group`); `size` sets both, and an explicit
254
+ `fieldSize`/`controlSize` wins over it. The library default is compact `md` for both (desktop
255
+ first): a desktop web app sets nothing, a data-dense app or sub-tree (a table toolbar, a filter
256
+ row) sets `{ size: 'sm' }`, a Capacitor app sets `{ fieldSize: 'lg' }` once. Never hard-code
257
+ `size` on every widget to change density; set it in a provider. Under a fine pointer the visible
258
+ box is the hit area (≥ 24 px, 32 px and up for sized controls); under a coarse pointer every
259
+ action and choice control reaches 44 px through an invisible extension, and text fields do not
260
+ (hence `fieldSize: 'lg'` for touch). Keep `gap-2` or more between wrapping rows of `sm` controls.
261
+ - `provideBfLabels({ ... })` replaces the built-in English strings (`loading`, `dismiss`, `tabBar`,
262
+ `tabBadge(count)`, `back`, `ok`, `cancel`, `actions`, `sheet`, `modal`, `refreshing`, `close`,
263
+ `dialog`) and the
264
+ default error text per validator kind (`validation`: `required`, `email`, `pattern`, `parse`,
265
+ `min(n)`, `max(n)`, `minLength(n)`, `maxLength(n)`, `minDate(d)`, `maxDate(d)`, `fallback`),
266
+ merged key by key; a key set to `undefined` also keeps the parent's string. Use it for any
267
+ non-English app instead of passing `errorMessage`/`ariaLabel` text to every widget; German copy
268
+ writes "ss", never "ß" (`dismiss: 'Schliessen'`).
269
+ `bfValidationMessage(error, labels.validation)` returns the text a widget shows for one error:
270
+ its own `message`, else the default for its kind. `bf-sheet` and `bf-modal` opened without an
271
+ `ariaLabel` are named by the first heading in their content (also one that renders later), and
272
+ fall back to `sheet`/`modal`.
273
+ - What a component-level `provideBfLabels()` covers: the widgets, form controls and
274
+ `bf-back-button`/`bf-refresher` rendered inside that component (`loading`, `dismiss`, `tabBar`,
275
+ `tabBadge`, `back`, `refreshing`, `validation`). The overlay services (`BfAlertDialogService`
276
+ `ok`, `BfActionSheetService` `cancel`/`actions`, `BfSheetService` `sheet`, `BfModalService`
277
+ `modal`, `BfDialogService` `close`/`dialog`/`ok`/`cancel`, `BfDesktopToastService`
278
+ `notifications`) are root singletons: they read the app-level labels unless the call passes the
279
+ feature's injector, `open(…, { injector: inject(Injector) })`, which also makes it the parent of
280
+ the overlay content's injector, so the content sees the feature's providers too.
281
+
282
+ ```ts
283
+ // Desktop web app: nothing to configure; md is the default. Localise the strings once.
284
+ export const appConfig: ApplicationConfig = {
285
+ providers: [
286
+ provideBfLabels({
287
+ back: 'Zurück',
288
+ cancel: 'Abbrechen',
289
+ tabBadge: (count) => `${count} neu`,
290
+ validation: { required: 'Pflichtfeld' },
291
+ }),
292
+ ],
293
+ };
294
+
295
+ // Capacitor app: 48 px text fields as on a phone (use { size: 'lg' } to enlarge every control).
296
+ export const appConfig: ApplicationConfig = {
297
+ providers: [
298
+ provideRouter(routes, withBfShellRouting()),
299
+ provideBfShell(),
300
+ provideBfDefaults({ fieldSize: 'lg' }),
301
+ ],
302
+ };
303
+ ```
304
+
305
+ ## Overlays (`@bitforgehq/ui/overlay`)
306
+
307
+ A desktop or plain web app (no Capacitor, no router required) installs `@angular/cdk` and `rxjs`
308
+ alongside `@bitforgehq/ui` and imports this entry for a centred dialog, a stacked toast and
309
+ keyboard-operable menus. Open every overlay from code through its service; never place `bf-dialog`
310
+ or `bf-desktop-toast` in a template outside the service that renders them.
311
+
312
+ - **Dialog vs sheet/modal:** `BfDialogService.open(component | template, config)` opens a centred
313
+ `bf-dialog` (`role="dialog"`, or `"alertdialog"` for `confirm()`) that traps focus, locks page
314
+ scroll and is sized `sm`/`md`/`lg`/`xl`. Use it for any desktop or web app screen. `/mobile`'s
315
+ `BfSheetService`/`BfModalService` render phone-shaped, edge-anchored panels for the Capacitor
316
+ shell — do not use them in a desktop-only app; use `bf-dialog` there instead, even for a form
317
+ that would be a sheet on a phone.
318
+ - **Confirm:** `BfDialogService.confirm({ title, message?, buttons?, size?, injector? })` opens an
319
+ alert dialog and resolves the pressed button's `id ?? label` (default `cancel`/`ok`); focus
320
+ starts on the cancel button, `Escape` answers with it (or is ignored without one, per APG alert
321
+ dialog). It is the desktop equivalent of `/mobile`'s `BfAlertDialogService.open()`.
322
+ - **Desktop toast vs mobile toast:** `BfDesktopToastService.show({ message, action?, duration?,
323
+ injector? })` stacks up to three cards bottom-right (configurable with
324
+ `provideBfDesktopToast()`), following the same timing/persistence/replace rules as `/mobile`'s
325
+ `BfToastService` (which shows one phone-width toast at a time). Use the desktop service outside
326
+ the Capacitor shell; never both in the same app screen.
327
+ - **Menu:** `bf-menu` (a `CdkMenu` panel) opened from `[bfMenuTriggerFor]` on any element, or from
328
+ the styled `bf-menu-button`. Items are `bfMenuItem` (`<button>` or `<a>`), `bfMenuItemCheckbox`,
329
+ `bfMenuItemRadio` (grouped with `bfMenuGroup`), dividers with `bfMenuDivider`, and submenus by
330
+ nesting another `[bfMenuTriggerFor]` on an item. Keyboard follows APG menu button/menu: open
331
+ keys, arrow navigation with wrap, Home/End, type-ahead, `Escape`/`Tab` close with focus back on
332
+ the trigger, `ArrowRight`/`ArrowLeft` for submenus. A menu opened from inside a `bf-dialog`:
333
+ `Escape` closes only the menu, never the dialog underneath.
334
+ - **`closeOnNavigation`:** every overlay (`BfDialogConfig`, `confirm()`, and the `/mobile` sheet,
335
+ modal, action sheet and alert options) closes when the URL path changes by default (`true`);
336
+ pass `closeOnNavigation: false` to keep one open across a navigation (e.g. a draft that must
337
+ survive browser back). Toasts (both desktop and mobile) never close on navigation.
338
+ - **The `injector` option:** `BfDialogService.open()`/`confirm()`, `BfDesktopToastService.show()`
339
+ and the `/mobile` sheet, modal, action sheet and alert services all take an `injector` (default
340
+ the root injector), which becomes the parent of the content's injector and is what a
341
+ feature-level `provideBfLabels()` or other providers must reach through — pass
342
+ `inject(Injector)` from the calling feature, not the app root's. The `/mobile` `BfToastService`
343
+ has no `injector` option (its one-at-a-time toast never opens a labelled stack).
344
+ - **Focus rules:** a dialog traps Tab/Shift+Tab inside its pane and restores focus to the opener
345
+ on close (`autoFocus`: `'first-tabbable'`, the default, or `'panel'` for long content with no
346
+ natural first control); a menu returns focus to its trigger on `Escape`, `Tab` or an item
347
+ activation. Initial focus in `confirm()`/alert-style overlays lands on the `cancel`-role button
348
+ (APG alert dialog), not the first button.
349
+ - **`F6` for toasts:** pressing `F6` on the page moves focus to the action of the newest toast
350
+ that has one; `F6` inside the toast stack moves focus back to where it was (the key is left to
351
+ the browser otherwise) — this is the desktop toast's only keyboard path to its action beyond Tab
352
+ through the page content. It does nothing while a modal overlay is open (`BfOverlayStack.count()
353
+ > 0`), so it never escapes that overlay's focus trap.
354
+ - **Scroll lock and inert outside the shell:** without `provideBfShell()` (a desktop or plain web
355
+ app), an open non-passive overlay (dialog, confirm) blocks document scroll and makes every other
356
+ direct child of `<body>` `inert` except the CDK overlay container and live-announcer elements
357
+ (mark an element that must stay usable, such as your own live region, `data-bf-overlay-keep`).
358
+ Inside the shell (`provideBfShell()`) nothing changes: the document never scrolls, so no lock is
359
+ applied, and the page stack (not the whole body) is made inert instead. Toasts never lock scroll
360
+ or make anything inert, on either entry.
361
+ - **Import migration:** the overlay core (`BfOverlayService`, `BfOverlayRef`, `BfOverlayStack`,
362
+ the animations, …) moved from `@bitforgehq/ui/mobile` to `@bitforgehq/ui/overlay` in 0.3.0;
363
+ `/mobile`'s old exports are deprecated aliases removed in 0.4.0. See the CHANGELOG's `### overlay
364
+ entry` migration table for the full old-import → new-import list.
365
+
366
+ ## Mobile shell (`@bitforgehq/ui/mobile`)
367
+
368
+ A Capacitor app additionally imports `@bitforgehq/ui/mobile` for the native app shell. Three
369
+ rules, then the setup, step by step (the docs site's `/mobile` page shows the same steps):
370
+
371
+ - The app renders inside a fixed page frame — the frame itself never scrolls.
372
+ - `bf-content` is the only scroller inside a page: one per `bf-page`, after its `bf-header`.
373
+ - Routed pages read route state (params, query params, data) through `input()`, never by
374
+ injecting `ActivatedRoute` directly in the page.
375
+
376
+ ### 1. Add the shell and its peers
377
+
378
+ Start from an app set up as on [Install](https://bitforge-ui.web.app/install). `@bitforgehq/ui/mobile` needs `@angular/cdk`, `@angular/router`, `@capacitor/core` and `rxjs`: optional peers of the package, required for this entry. The shell's Capacitor plugins are optional peers, and the entry builds and runs without the ones you skip, but a shell app needs `@capacitor/keyboard`: the `capacitor.config.ts` of step 5 imports `KeyboardResize` from it for the required `resize: 'none'`, as the mobile demo's does. The plugins:
379
+
380
+ - `@capacitor/app` (optional): the Android hardware back button;
381
+ - `@capacitor/keyboard` (needed): the keyboard height and events, and the `Keyboard` config;
382
+ - `@capacitor/haptics` (optional): the tick on a tab change.
383
+
384
+ List the plugins in the `dependencies` of the `package.json` beside `capacitor.config.ts`. The Capacitor CLI finds plugins only there: `cap sync` skips a plugin listed only in a workspace root, so check its "Found N Capacitor plugins" lines.
385
+
386
+ ```bash
387
+ bun add @angular/cdk @angular/router @capacitor/core rxjs
388
+ bun add @capacitor/app @capacitor/keyboard @capacitor/haptics
389
+ bun add -d @capacitor/cli
390
+ ```
391
+
392
+ ### 2. Provide the shell
393
+
394
+ In `src/app/app.config.ts`, add `provideBfShell()` and pass `withComponentInputBinding()` and `withBfShellRouting()` to `provideRouter()`. The shell does not render correctly without `provideBfShell()` and `withBfShellRouting()`; `withComponentInputBinding()` binds route params and data to the pages' inputs. `provideBfDefaults({ fieldSize: 'lg' })` gives text fields their 48 px phone height (see [Density](https://bitforge-ui.web.app/density#capacitor-app)).
395
+
396
+ `app.config.ts`:
397
+
398
+ ```ts
399
+ import { type ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
400
+ import { provideRouter, withComponentInputBinding } from '@angular/router';
401
+ import { provideBfDefaults } from '@bitforgehq/ui';
402
+ import { provideBfShell, withBfShellRouting } from '@bitforgehq/ui/mobile';
403
+ import { routes } from './app.routes';
404
+
405
+ export const appConfig: ApplicationConfig = {
406
+ providers: [
407
+ provideBrowserGlobalErrorListeners(),
408
+ provideRouter(routes, withComponentInputBinding(), withBfShellRouting()),
409
+ provideBfShell(),
410
+ // 48 px text fields, as on a phone; buttons and choice controls keep md with a 44 px touch area.
411
+ provideBfDefaults({ fieldSize: 'lg' }),
412
+ ],
413
+ };
414
+ ```
415
+
416
+ ### 3. Give the root a height
417
+
418
+ The root component (`src/app/app.ts`) hosts one `bf-stack-outlet`, and the outlet is `height: 100%` of it. Give the root's host `block h-dvh`: without a height the outlet is zero pixels tall and nothing renders, with no error in the console.
419
+
420
+ `app.ts`:
421
+
422
+ ```ts
423
+ import { Component } from '@angular/core';
424
+ import { BfStackOutletComponent } from '@bitforgehq/ui/mobile';
425
+
426
+ @Component({
427
+ selector: 'app-root',
428
+ // The outlet is `height: 100%`: without a height here it has zero height and shows nothing.
429
+ host: { class: 'block h-dvh' },
430
+ imports: [BfStackOutletComponent],
431
+ template: `<bf-stack-outlet />`,
432
+ })
433
+ export class App {}
434
+ ```
435
+
436
+ ### 4. Set the viewport and the body
437
+
438
+ In `src/index.html`:
439
+
440
+ - `viewport-fit=cover` lets the page reach under the notch and the home indicator, and the shell pads itself by the safe areas.
441
+ - Leave out `maximum-scale` and `user-scalable`, so the web build keeps pinch zoom (WCAG 1.4.4). On native platforms `provideBfShell()` adds `maximum-scale=1, user-scalable=no` at boot: WKWebView zooms into a focused control whose text is under 16 px and never zooms back, so the native shell forbids zoom and follows the system text size instead.
442
+ - `bg-bg text-text` on `<body>` gives the page the theme colours, in light and dark mode.
443
+
444
+ ```html
445
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
446
+ <!-- … -->
447
+ <body class="bg-bg text-text">
448
+ <app-root></app-root>
449
+ </body>
450
+ ```
451
+
452
+ ### 5. Configure Capacitor
453
+
454
+ Create `capacitor.config.ts` beside `package.json` (it imports `KeyboardResize` from `@capacitor/keyboard`, installed in step 1), with the `appId`, `appName` and the `webDir` your `ng build` writes. Keep each of these keys; the keyboard handling and the safe areas depend on every one:
455
+
456
+ - `ios.scrollEnabled: false`: the document never scrolls, so WKWebView cannot jump the page when focus moves between fields. `bf-content` is the only scroller.
457
+ - `plugins.Keyboard.resize: 'none'` (`KeyboardResize.None`): on iOS the keyboard overlays a stable page, and `bf-content` pads itself and reveals the focused field. Never set `Keyboard.resizeOnFullScreen`: the SystemBars plugin owns the Android keyboard inset.
458
+ - `plugins.SystemBars.insetsHandling: 'css'`: on Android the WebView resizes with the keyboard and the safe-area insets stay correct. `initialViewportFitValueHint: 'cover'` avoids a viewport jump at launch.
459
+
460
+ `capacitor.config.ts`:
461
+
462
+ ```ts
463
+ import type { CapacitorConfig } from '@capacitor/cli';
464
+ import { KeyboardResize } from '@capacitor/keyboard';
465
+
466
+ const config: CapacitorConfig = {
467
+ appId: 'com.example.myapp',
468
+ appName: 'My App',
469
+ webDir: 'dist/my-app/browser',
470
+ // The document never scrolls: no WKWebView page jumps between fields.
471
+ ios: { scrollEnabled: false },
472
+ plugins: {
473
+ // 'none': on iOS the keyboard overlays a stable frame, and bf-content reveals the field.
474
+ Keyboard: { resize: KeyboardResize.None },
475
+ // Android: the WebView resizes with the keyboard and the safe-area insets stay correct.
476
+ SystemBars: { insetsHandling: 'css', initialViewportFitValueHint: 'cover' },
477
+ },
478
+ };
479
+
480
+ export default config;
481
+ ```
482
+
483
+ ### 6. Check the Android manifest
484
+
485
+ `bunx cap add android` (step 11) writes `android/app/src/main/AndroidManifest.xml`. Keep its activity as generated:
486
+
487
+ - `uiMode` stays in `android:configChanges`, so switching the system's dark mode does not restart the app.
488
+ - Add no `android:windowSoftInputMode`: the SystemBars plugin resizes the WebView with the keyboard.
489
+ - Edge to edge needs no setting. From `targetSdkVersion` 35 Android draws the app under the system bars, and SystemBars passes their insets to the page (`--safe-area-inset-*`, and `env(safe-area-inset-*)` on WebView 140 and later), which the theme's `safe-*` spacing reads. Capacitor 8 has no `adjustMarginsForEdgeToEdge` option any more.
490
+
491
+ ```xml
492
+ <activity
493
+ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
494
+ android:name=".MainActivity"
495
+ android:exported="true">
496
+ <!-- … no android:windowSoftInputMode … -->
497
+ </activity>
498
+ ```
499
+
500
+ ### 7. Route a tabbed app
501
+
502
+ In `src/app/app.routes.ts`, a `tabs` route holds the tab bar page, and its children are the tabs. Every page of a tab starts with the tab's segment (`home`, `home/:id`), so it pushes onto that tab's own navigation stack. Routed pages read their params and data through `input()`, which `withComponentInputBinding()` binds; they never inject `ActivatedRoute`.
503
+
504
+ `app.routes.ts`:
505
+
506
+ ```ts
507
+ import { type Routes } from '@angular/router';
508
+
509
+ export const routes: Routes = [
510
+ { path: '', pathMatch: 'full', redirectTo: 'tabs' },
511
+ {
512
+ path: 'tabs',
513
+ loadComponent: () => import('./tabs.page').then((m) => m.TabsPage),
514
+ // One navigation stack per tab, keyed by the segment after `tabs/`.
515
+ children: [
516
+ { path: 'home', loadComponent: () => import('./home.page').then((m) => m.HomePage) },
517
+ {
518
+ path: 'settings',
519
+ loadComponent: () => import('./settings.page').then((m) => m.SettingsPage),
520
+ },
521
+ { path: '', pathMatch: 'full', redirectTo: 'home' },
522
+ ],
523
+ },
524
+ ];
525
+ ```
526
+
527
+ ### 8. Add the tab bar
528
+
529
+ `src/app/tabs.page.ts` is the `tabs` route's page: [`bf-tabs`](https://bitforge-ui.web.app/components/tabs) around one [`bf-stack-outlet`](https://bitforge-ui.web.app/components/stack-outlet) and a [`bf-tab-bar`](https://bitforge-ui.web.app/components/tab-bar). Each [`bf-tab`](https://bitforge-ui.web.app/components/tab)'s `id` is its route segment, and `tabsPath` is the route's path.
530
+
531
+ `tabs.page.ts`:
532
+
533
+ ```ts
534
+ import { Component } from '@angular/core';
535
+ import { BfTabBarComponent, BfTabComponent } from '@bitforgehq/ui';
536
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
537
+ import { BfStackOutletComponent, BfTabsComponent } from '@bitforgehq/ui/mobile';
538
+ import { House, Settings } from 'lucide';
539
+
540
+ @Component({
541
+ selector: 'app-tabs',
542
+ host: { class: 'block h-full' },
543
+ imports: [
544
+ BfTabsComponent,
545
+ BfStackOutletComponent,
546
+ BfTabBarComponent,
547
+ BfTabComponent,
548
+ BfIconComponent,
549
+ ],
550
+ template: `
551
+ <bf-tabs tabsPath="tabs">
552
+ <bf-stack-outlet />
553
+ <bf-tab-bar label="Main">
554
+ <!-- Each tab's id is its route segment under tabs/. -->
555
+ <bf-tab id="home" label="Home"><bf-icon [icon]="house" /></bf-tab>
556
+ <bf-tab id="settings" label="Settings"><bf-icon [icon]="settings" /></bf-tab>
557
+ </bf-tab-bar>
558
+ </bf-tabs>
559
+ `,
560
+ })
561
+ export class TabsPage {
562
+ protected readonly house = House;
563
+ protected readonly settings = Settings;
564
+ }
565
+ ```
566
+
567
+ ### 9. Add the first page
568
+
569
+ Every page is a [`bf-page`](https://bitforge-ui.web.app/components/page): a [`bf-header`](https://bitforge-ui.web.app/components/header), then one [`bf-content`](https://bitforge-ui.web.app/components/content), the only scroller of the page. The app frame never scrolls.
570
+
571
+ `home.page.ts`:
572
+
573
+ ```ts
574
+ import { Component, signal } from '@angular/core';
575
+ import { BfButtonComponent, BfCardComponent } from '@bitforgehq/ui';
576
+ import { BfContentComponent, BfHeaderComponent, BfPageComponent } from '@bitforgehq/ui/mobile';
577
+
578
+ @Component({
579
+ selector: 'app-home',
580
+ imports: [
581
+ BfPageComponent,
582
+ BfHeaderComponent,
583
+ BfContentComponent,
584
+ BfCardComponent,
585
+ BfButtonComponent,
586
+ ],
587
+ template: `
588
+ <bf-page>
589
+ <bf-header title="Home" />
590
+ <!-- bf-content is the page's only scroller. -->
591
+ <bf-content>
592
+ <div class="flex flex-col gap-4 px-4 pt-2 pb-6">
593
+ <bf-card>
594
+ <p class="text-body">Tapped {{ taps() }} times.</p>
595
+ </bf-card>
596
+ <bf-button (clicked)="taps.update((n) => n + 1)">Tap</bf-button>
597
+ </div>
598
+ </bf-content>
599
+ </bf-page>
600
+ `,
601
+ })
602
+ export class HomePage {
603
+ protected readonly taps = signal(0);
604
+ }
605
+ ```
606
+
607
+ ### 10. Add the second tab
608
+
609
+ The Settings tab has the same shape.
610
+
611
+ `settings.page.ts`:
612
+
613
+ ```ts
614
+ import { Component, signal } from '@angular/core';
615
+ import { BfListComponent, BfListItemComponent, BfToggleComponent } from '@bitforgehq/ui';
616
+ import { BfContentComponent, BfHeaderComponent, BfPageComponent } from '@bitforgehq/ui/mobile';
617
+
618
+ @Component({
619
+ selector: 'app-settings',
620
+ imports: [
621
+ BfPageComponent,
622
+ BfHeaderComponent,
623
+ BfContentComponent,
624
+ BfListComponent,
625
+ BfListItemComponent,
626
+ BfToggleComponent,
627
+ ],
628
+ template: `
629
+ <bf-page>
630
+ <bf-header title="Settings" />
631
+ <bf-content>
632
+ <bf-list class="px-4 pt-2">
633
+ <bf-list-item>
634
+ <bf-toggle label="Notifications" labelPosition="start" [(checked)]="notifications" />
635
+ </bf-list-item>
636
+ </bf-list>
637
+ </bf-content>
638
+ </bf-page>
639
+ `,
640
+ })
641
+ export class SettingsPage {
642
+ protected readonly notifications = signal(true);
643
+ }
644
+ ```
645
+
646
+ ### 11. Leave the keyboard and text size to the shell
647
+
648
+ - The shell keeps the focused field clear of the soft keyboard: `bf-content` pads and scrolls itself, and a sheet lifts above the keyboard. Add no keyboard handling of your own. To place something by the keyboard, read [`BfKeyboardService`](https://bitforge-ui.web.app/api/bf-keyboard-service), never `window.innerHeight`.
649
+ - On iOS, [`BfTextScaleService`](https://bitforge-ui.web.app/api/bf-text-scale-service), which `provideBfShell()` starts, follows Dynamic Type. It sets the `<html>` font size to the system body size over 17 px, capped at `dynamicType.maxScale` (default 2.35) and never below 100 %, and follows live changes. Android's WebView applies the system font scale itself.
650
+ - Size text and layout in `rem`, as the library's tokens are, so they scale together; a fixed `px` height clips enlarged text. Opt out with `provideBfShell({ dynamicType: false })`, and never set a `px` font size on `html`.
651
+
652
+ ### 12. Match the system bars to the theme
653
+
654
+ On native iOS and Android `provideBfShell()` keeps the status-bar and navigation-bar icons readable in the in-app theme (`BfPlatformService.dark()`): it calls Capacitor's `SystemBars.setStyle()` at launch and on every theme change, with light icons in the dark theme and dark icons in the light theme. `provideBfShell({ syncSystemBars: false })` leaves the bars to your app.
655
+
656
+ An Android WebView older than 140 pads the page below the bars instead of drawing it under them, so the bar areas show the activity's window background, which follows the system theme, not the in-app one. On those WebViews the shell uses the `Default` style (icons for the system theme). Give the window the page colour, so the bars match the page whenever the two themes agree: in `android/app/src/main/res/values/styles.xml` add the item to `AppTheme.NoActionBar` (its parent is already a `DayNight` theme), and add `res/values/colors.xml` and `res/values-night/colors.xml`.
657
+
658
+ | WebView | App theme vs system theme | Result |
659
+ | ------- | ------------------------- | ------------------------------------------------------------------------- |
660
+ | ≥ 140 | any | The bars draw over the page (edge-to-edge) with icons in the in-app theme |
661
+ | < 140 | same | Bars in the page colour, readable icons |
662
+ | < 140 | different | Bars in the system colour with readable icons |
663
+
664
+ The last row is a known limitation: only app-side native code can recolour the window background.
665
+
666
+ The ≥ 140 row needs the setup of steps 4 and 5: `viewport-fit=cover` in the viewport meta and `SystemBars.insetsHandling: 'css'` (the default), so the WebView reaches under the bars and SystemBars passes their insets to the page. With `insetsHandling: 'disable'` or `'native'`, or without `viewport-fit=cover`, the page never draws under the bars, and they show the window background as on WebView < 140. The shell reads the WebView version from `Chrome/<n>` in the user agent.
667
+
668
+ ```xml
669
+ <!-- res/values/styles.xml, in <style name="AppTheme.NoActionBar"> -->
670
+ <item name="android:windowBackground">@color/bf_window_background</item>
671
+
672
+ <!-- res/values/colors.xml -->
673
+ <resources>
674
+ <color name="bf_window_background">#f7f5ef</color>
675
+ </resources>
676
+
677
+ <!-- res/values-night/colors.xml -->
678
+ <resources>
679
+ <color name="bf_window_background">#111214</color>
680
+ </resources>
681
+ ```
682
+
683
+ ### 13. Run it on a device
684
+
685
+ Build the web app, add the native platforms once, then copy the build into them and run it on a device or simulator. Xcode builds the iOS app and Android Studio the Android one. `cap sync` lists the plugins it found: check that all three from step 1 are there. `cap run android` runs Gradle, which needs a JDK it supports, such as 21 (`JAVA_HOME`); `--list` shows the device ids.
686
+
687
+ `cap run ios` builds with `xcodebuild`, then deploys through `Xcode.app/Contents/Developer/Applications/Simulator.app`. Xcode 27 has no Simulator.app there, so the build succeeds and the deploy fails ("There was an error opening simulator"). Then build, install and launch with `xcodebuild` and `simctl` instead, as below (the scheme is `App`; `ios/App/build` is git-ignored), or run the app from Xcode.
688
+
689
+ ```bash
690
+ ng build
691
+ bun add @capacitor/ios @capacitor/android
692
+ bunx cap add ios
693
+ bunx cap add android
694
+ bunx cap sync
695
+ bunx cap run android --target <device-id>
696
+ bunx cap run ios --target <simulator-id>
697
+
698
+ # iOS without Simulator.app (Xcode 27): build, boot, install and launch
699
+ cd ios/App
700
+ xcodebuild -project App.xcodeproj -scheme App -configuration Debug -sdk iphonesimulator \
701
+ -destination "id=<simulator-id>" -derivedDataPath build build
702
+ xcrun simctl boot <simulator-id> || true # already booted is fine
703
+ xcrun simctl install <simulator-id> build/Build/Products/Debug-iphonesimulator/App.app
704
+ xcrun simctl launch <simulator-id> <appId>
705
+ ```
706
+
707
+ ### Keyboard
708
+
709
+ The shell keeps the focused text field clear of the soft keyboard; don't add your own keyboard
710
+ handling on top.
711
+
712
+ - On a page, `bf-content` pads and scrolls itself (`bfKeyboardAssist`); a custom scroll container
713
+ inside a page takes `bfKeyboardAssist` itself.
714
+ - A sheet lifts above the keyboard (iOS), is capped below the safe-area top while the keyboard is
715
+ up (both platforms), scrolls its content and reveals the focused field 24 px above the keyboard;
716
+ a page-sheet modal ends its content at the keyboard's top edge. Hiding the keyboard returns
717
+ both to their resting size.
718
+ - Keep a sheet's primary action directly below its fields and a modal's in its header, so it
719
+ stays reachable while the keyboard is up.
720
+ - Read the keyboard from `BfKeyboardService` (`visible()`, `height()`, `keyboardTop()`, the
721
+ `--bf-keyboard-height` CSS variable), never from `window.innerHeight`.
722
+
723
+ ### Overlays
724
+
725
+ Overlays (`BfOverlayService` and the sheet, modal, toast, action-sheet and alert services built on
726
+ it) are opened from code through a service, never placed in a template. The overlay core
727
+ (`BfOverlayService`, `BfOverlayRef`, `BF_OVERLAY_DATA`, `BfOverlayStack`, the animations) is
728
+ shared with the desktop overlays and imported from `@bitforgehq/ui/overlay` (see "Overlays
729
+ (`@bitforgehq/ui/overlay`)" above for `closeOnNavigation`, the `injector` option and the inert/
730
+ scroll-lock rules outside the shell); the `/mobile` re-exports of those names are deprecated.
731
+ Shell-specific rules:
732
+
733
+ - Open overlays through their service (`BfSheetService.open(Component, options)`,
734
+ `BfModalService`, `BfToastService.show()`, `BfActionSheetService.open()`,
735
+ `BfAlertDialogService.open()`); never render `bf-sheet`, `bf-modal`, `bf-toast`,
736
+ `bf-action-sheet` or `bf-alert-dialog` yourself.
737
+ - A content component reads its input with `inject(BF_OVERLAY_DATA)` and closes itself with
738
+ `inject(BfOverlayRef).close(result)` (`BfSheetRef` in a sheet); the caller awaits
739
+ `ref.afterClosed` (`open()` returns the promise directly for action sheets and alert dialogs).
740
+ - A sheet's content region is the only scroller inside a sheet: no `bf-content` and no nested
741
+ `overflow-auto` in its content; mark the content's header `data-bf-sheet-drag`.
742
+ - An alert dialog needs a `cancel`-role button to be dismissible: without one, `Escape` and the
743
+ hardware back button are ignored and only a button answers it.
744
+ - A toast with an `action` persists until the action, `Escape` inside it, `dismiss()` or the next
745
+ `show()` (which replaces any persistent toast) ends it; don't give it a short `duration`, and
746
+ `dismiss()` its ref once it is stale. Every toast pauses while hovered or focused, returns focus
747
+ to where it came from when it closes holding focus, and is announced once through
748
+ `LiveAnnouncer` — never wrap one in your own live region.
749
+ - A desktop web app (no Capacitor) shows toasts with `BfDesktopToastService` from
750
+ `@bitforgehq/ui/overlay` instead of `BfToastService` (see "Overlays (`@bitforgehq/ui/overlay`)"
751
+ above).
752
+
753
+ While a modal-like overlay is open the page stack is `inert`; outside the shell (a desktop page
754
+ using the overlays without `provideBfShell()`) every other child of `<body>` is `inert` instead
755
+ (mark an element that must stay usable, such as your own live region, `data-bf-overlay-keep`;
756
+ this works only on a direct child of `<body>`, never inside the app root).
757
+ Inside the shell no scroll lock is needed (the document never scrolls); outside it the overlay
758
+ core blocks document scroll behind every non-passive overlay. `Escape` closes the top dismissible
759
+ overlay, and under `provideBfShell()` so does the hardware back button (neither ever reaches one
760
+ underneath); a backdrop tap closes sheets and action sheets only — modals and alerts ignore it.
761
+ Toasts are passive and never block the page or its scroll.
762
+
763
+ ## Testing
764
+
765
+ Specs of an app on `@bitforgehq/ui` run under the Angular CLI's Vitest (or Jest) with
766
+ `@bitforgehq/ui/testing`: the jsdom shims, the test providers and CDK harnesses (the docs site's
767
+ `/testing` page shows the same steps).
768
+
769
+ ### 1. Add the CDK as a dev dependency
770
+
771
+ `@bitforgehq/ui/testing` holds the jsdom shims, `provideBfTesting()` and the component harnesses. The harnesses build on `@angular/cdk/testing`, so the entry needs `@angular/cdk`; a dev dependency is enough. An app that uses `@bitforgehq/ui/overlay` or `@bitforgehq/ui/mobile` already has the CDK as a dependency: skip this step.
772
+
773
+ ```bash
774
+ bun add -d @angular/cdk
775
+ ```
776
+
777
+ ### 2. Set up Vitest, the CLI default
778
+
779
+ An Angular 22 app runs its specs with Vitest and jsdom through `ng test`. jsdom has no Web Animations (`Element.animate`), no `PointerEvent` and no `matchMedia`, which the overlays and gestures use. Name a setup file in the `test` target of `angular.json`, and in it call `installBfJsdomShims()`, which installs a stand-in for each one that is missing.
780
+
781
+ ```json
782
+ "test": {
783
+ "builder": "@angular/build:unit-test",
784
+ "options": {
785
+ "setupFiles": ["src/test-setup.ts"]
786
+ }
787
+ }
788
+ ```
789
+
790
+ `test-setup.ts`:
791
+
792
+ ```ts
793
+ /*
794
+ * The test runner's setup file (`setupFiles` of the `test` target in angular.json): the browser
795
+ * APIs jsdom lacks and the library needs (Web Animations, PointerEvent, matchMedia), installed only
796
+ * where missing. `@bitforgehq/ui/testing` needs `@angular/cdk` as a dev dependency.
797
+ */
798
+ import { installBfJsdomShims } from '@bitforgehq/ui/testing';
799
+
800
+ installBfJsdomShims();
801
+ ```
802
+
803
+ ### 3. Or set up Jest
804
+
805
+ With Jest and `jest-preset-angular`: install them (`bun add -d jest jest-environment-jsdom jest-preset-angular @types/jest`), and in `tsconfig.spec.json` set `"types": ["jest"]` in place of `vitest/globals`. Then add the two files below: the setup file calls `setupZonelessTestEnv()` (without it every spec fails with "Need to call TestBed.initTestEnvironment() first"), then `installBfJsdomShims()`.
806
+
807
+ The library ships ES modules (`.mjs`), which the preset's own `transformIgnorePatterns` lets through, together with Angular's locale files. A config that sets its own `transformIgnorePatterns` replaces that default: keep `.mjs`, `@angular/common/locales/`, `@bitforgehq/` and `lucide` in it, or the first import fails with `SyntaxError: Cannot use import statement outside a module`.
808
+
809
+ ```js
810
+ // jest.config.mjs
811
+ export default {
812
+ preset: 'jest-preset-angular',
813
+ setupFilesAfterEnv: ['<rootDir>/src/setup-jest.ts'],
814
+ testEnvironment: 'jsdom',
815
+ transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$|@angular/common/locales/.*\\.js$|@bitforgehq/|lucide)'],
816
+ };
817
+
818
+ // src/setup-jest.ts
819
+ import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless';
820
+ import { installBfJsdomShims } from '@bitforgehq/ui/testing';
821
+
822
+ setupZonelessTestEnv();
823
+ installBfJsdomShims();
824
+ ```
825
+
826
+ ### 4. Add the test providers
827
+
828
+ Add one provider to `TestBed.configureTestingModule()`, as the specs below do:
829
+
830
+ - `provideBfTesting()` from `@bitforgehq/ui/testing`, for the widgets and the desktop overlays. It takes the `defaults` and `labels` the app runs with. After each test it removes what overlays and the theme leave on the document: the `dark` class, the scroll block, `inert` on body children and stray overlay panes.
831
+ - `provideBfShellTesting()` from `@bitforgehq/ui/mobile`, for a Capacitor app. It provides the router with `withComponentInputBinding()` and `withBfShellRouting()`, the shell with swipe-back and haptics off, an in-memory theme store and the same cleanup.
832
+
833
+ The cleanup runs when TestBed tears the test environment down. Keep TestBed's default `teardown: { destroyAfterEach: true }`: with `false`, nothing is cleaned between tests.
834
+
835
+ ### 5. Wait until the fixture is stable
836
+
837
+ Angular 22 apps are zoneless: nothing runs change detection between the steps of a test. After you create a component or change its state, `await fixture.whenStable()` before you read the DOM. Harness methods wait on their own.
838
+
839
+ `app.component.spec.ts`:
840
+
841
+ ```ts
842
+ import { Component } from '@angular/core';
843
+ import { TestBed } from '@angular/core/testing';
844
+ import { BfButtonComponent } from '@bitforgehq/ui';
845
+
846
+ @Component({
847
+ imports: [BfButtonComponent],
848
+ template: `<bf-button variant="tonal">Save</bf-button>`,
849
+ })
850
+ class HostComponent {}
851
+
852
+ describe('bf-button in a consumer app (Vitest)', () => {
853
+ it('renders a native button with the projected label', async () => {
854
+ const fixture = TestBed.createComponent(HostComponent);
855
+ await fixture.whenStable();
856
+ const button = (fixture.nativeElement as HTMLElement).querySelector('button');
857
+ expect(button).not.toBeNull();
858
+ expect(button?.textContent?.trim()).toBe('Save');
859
+ });
860
+ });
861
+ ```
862
+
863
+ ### 6. Drive a form widget through its harness
864
+
865
+ The entry ships CDK harnesses for the widgets whose markup is not obvious to drive: `BfSelectHarness`, `BfRadioGroupHarness`, `BfSegmentedHarness`, `BfDialogHarness`, `BfMenuHarness`, `BfMenuItemHarness`, `BfMenuButtonHarness` and `BfDesktopToastHarness`. `with({ label })` finds a widget by its label. Load an in-page widget with `TestbedHarnessEnvironment.loader(fixture)`.
866
+
867
+ `select-harness.spec.ts`:
868
+
869
+ ```ts
870
+ /*
871
+ * A Signal Forms `bf-select` with numeric options, driven through `BfSelectHarness` by label: the
872
+ * form model holds the option's number, and a touched required select shows the default message.
873
+ */
874
+ import { Component, signal } from '@angular/core';
875
+ import { TestBed } from '@angular/core/testing';
876
+ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
877
+ import { FormField, form, required } from '@angular/forms/signals';
878
+ import { BfSelectComponent, type BfSelectOption } from '@bitforgehq/ui';
879
+ import { BfSelectHarness, provideBfTesting } from '@bitforgehq/ui/testing';
880
+
881
+ @Component({
882
+ imports: [BfSelectComponent, FormField],
883
+ template: `<bf-select label="Quantity" [options]="quantities" [formField]="order.quantity" />`,
884
+ })
885
+ class OrderFormComponent {
886
+ readonly quantities: readonly BfSelectOption<number>[] = [
887
+ { value: 1, label: 'One' },
888
+ { value: 2, label: 'Two' },
889
+ { value: 3, label: 'Three' },
890
+ ];
891
+ private readonly model = signal<{ quantity: number | null }>({ quantity: null });
892
+ readonly order = form(this.model, (path) => {
893
+ required(path.quantity);
894
+ });
895
+ }
896
+
897
+ describe('BfSelectHarness in a consumer app (Vitest)', () => {
898
+ beforeEach(() => {
899
+ TestBed.configureTestingModule({ providers: [provideBfTesting()] });
900
+ });
901
+
902
+ async function setup() {
903
+ const fixture = TestBed.createComponent(OrderFormComponent);
904
+ await fixture.whenStable();
905
+ const select = await TestbedHarnessEnvironment.loader(fixture).getHarness(
906
+ BfSelectHarness.with({ label: 'Quantity' }),
907
+ );
908
+ return { fixture, select };
909
+ }
910
+
911
+ it('selects an option by label and the form model holds its number', async () => {
912
+ const { fixture, select } = await setup();
913
+
914
+ expect(await select.getOptionLabels()).toEqual(['One', 'Two', 'Three']);
915
+ await select.selectOption('Two');
916
+
917
+ expect(fixture.componentInstance.order.quantity().value()).toBe(2);
918
+ expect(await select.getValueLabel()).toBe('Two');
919
+ });
920
+
921
+ it('shows the default required message once the select was touched', async () => {
922
+ const { select } = await setup();
923
+
924
+ expect(await select.getErrorText()).toBe('');
925
+ await select.focus();
926
+ await select.blur();
927
+
928
+ expect(await select.getErrorText()).toBe('Fill in this field');
929
+ });
930
+ });
931
+ ```
932
+
933
+ ### 7. Drive a dialog from the document root
934
+
935
+ Dialogs, menus and toasts render in the CDK overlay container at the end of `<body>`, outside the fixture. Load their harnesses with `TestbedHarnessEnvironment.documentRootLoader(fixture)`.
936
+
937
+ `dialog-harness.spec.ts`:
938
+
939
+ ```ts
940
+ /*
941
+ * A `confirm()` alert dialog through `BfDialogHarness`: dialogs render in the CDK overlay
942
+ * container, so the harness comes from the document root loader.
943
+ */
944
+ import { Component, inject } from '@angular/core';
945
+ import { TestBed } from '@angular/core/testing';
946
+ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
947
+ import { BfDialogService } from '@bitforgehq/ui/overlay';
948
+ import { BfDialogHarness, provideBfTesting } from '@bitforgehq/ui/testing';
949
+
950
+ @Component({ template: '' })
951
+ class ProjectActionsComponent {
952
+ private readonly dialogs = inject(BfDialogService);
953
+
954
+ confirmDelete(): Promise<string | undefined> {
955
+ return this.dialogs.confirm({
956
+ title: 'Delete project?',
957
+ message: 'This cannot be undone.',
958
+ buttons: [
959
+ { id: 'cancel', label: 'Cancel', role: 'cancel' },
960
+ { id: 'delete', label: 'Delete', role: 'destructive' },
961
+ ],
962
+ });
963
+ }
964
+ }
965
+
966
+ describe('BfDialogHarness in a consumer app (Vitest)', () => {
967
+ beforeEach(() => {
968
+ TestBed.configureTestingModule({ providers: [provideBfTesting()] });
969
+ });
970
+
971
+ it('reads a confirm() alert dialog and resolves the pressed button id', async () => {
972
+ const fixture = TestBed.createComponent(ProjectActionsComponent);
973
+ await fixture.whenStable();
974
+ const answer = fixture.componentInstance.confirmDelete();
975
+
976
+ const dialog = await TestbedHarnessEnvironment.documentRootLoader(fixture).getHarness(
977
+ BfDialogHarness.with({ label: 'Delete project?' }),
978
+ );
979
+ expect(await dialog.getRole()).toBe('alertdialog');
980
+ expect(await dialog.getText()).toBe('This cannot be undone.');
981
+
982
+ await dialog.clickButton('Delete');
983
+ await expect(answer).resolves.toBe('delete');
984
+ });
985
+ });
986
+ ```
987
+
988
+ ### 8. Test a sheet in a Capacitor app
989
+
990
+ Under `provideBfShellTesting()` the shell's overlay services work as they do in the app. Open a sheet in an injection context, then find it in the document. A theme change stays in memory, so `localStorage` is never written, and the `dark` class is gone after the test.
991
+
992
+ `sheet.spec.ts`:
993
+
994
+ ```ts
995
+ /*
996
+ * The mobile shell under a consumer's Vitest: `provideBfShellTesting()` (router, shell, in-memory
997
+ * theme, document cleanup); a `bf-sheet` opens into the document and is gone once dismissed, and
998
+ * no theme class is left on `<html>` afterwards.
999
+ */
1000
+ import { Component, inject } from '@angular/core';
1001
+ import { TestBed } from '@angular/core/testing';
1002
+ import { BfPlatformService, BfSheetService, provideBfShellTesting } from '@bitforgehq/ui/mobile';
1003
+
1004
+ @Component({
1005
+ selector: 'app-sheet-body',
1006
+ template: `<p class="p-4">Sheet body</p>`,
1007
+ })
1008
+ class SheetBodyComponent {}
1009
+
1010
+ describe('bf-sheet in a consumer app (Vitest)', () => {
1011
+ beforeEach(() => {
1012
+ TestBed.configureTestingModule({ providers: [provideBfShellTesting()] });
1013
+ });
1014
+
1015
+ it('opens a sheet into the document and removes it once dismissed', async () => {
1016
+ const ref = TestBed.runInInjectionContext(() =>
1017
+ inject(BfSheetService).open(SheetBodyComponent),
1018
+ );
1019
+ TestBed.inject(BfPlatformService).theme.set('dark');
1020
+ TestBed.tick();
1021
+
1022
+ expect(document.querySelector('bf-sheet')?.textContent).toContain('Sheet body');
1023
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
1024
+ expect(window.localStorage.getItem('bf-theme')).toBeNull();
1025
+
1026
+ ref.close();
1027
+ await ref.afterClosed;
1028
+
1029
+ expect(document.querySelector('bf-sheet')).toBeNull();
1030
+ expect(document.body.textContent).not.toContain('Sheet body');
1031
+
1032
+ // What runs after each test: the environment's teardown clears the theme class.
1033
+ TestBed.resetTestingModule();
1034
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
1035
+ });
1036
+ });
1037
+ ```
1038
+
1039
+ ### 9. Assert on roles, text and values
1040
+
1041
+ Test what a user and the rest of the app see:
1042
+
1043
+ - the role and accessible name of an element, or a harness found by its label;
1044
+ - the text a widget shows, such as its validation message;
1045
+ - the values it writes into the form model or emits from its outputs.
1046
+
1047
+ Don't assert on the library's CSS classes or internal markup. They are styling details, not API, and they change with the design.
1048
+
1049
+ ## Design foundations (Meadow Sage)
1050
+
1051
+ Warm off-white canvas (`bg-bg`), white surfaces, sage green primary, pill-shaped controls,
1052
+ radii 12/16/24 px (`rounded-tile/field/card`), 1.5 px control borders, soft layered shadows
1053
+ (`shadow-raised`, `shadow-floating`), Manrope type (`text-display/title/heading/body/caption`).
1054
+
1055
+ ### Type and spacing scale
1056
+
1057
+ - Type (rem tokens; px at a 16 px root): `text-display` 40, `text-title` 28, `text-heading` 18,
1058
+ `text-body` 15/24 and `text-label` 15/20, `text-label-sm` 14, `text-caption` 13/18 and
1059
+ `text-label-xs` 13/16, `text-micro` 12 (weight 600), `text-nano` 11. Never `text-[15px]` or
1060
+ Tailwind's `text-sm`/`text-xs`.
1061
+ - Spacing: 2 · 4 · 6 · 8 · 10 · 12 · 14 · 16 · 18 · 20 · 24 · 32 · 48 px as `--spacing` multiples
1062
+ (`p-3.5`, `px-4.5`); never arbitrary px. Semantic steps: `gutter` 16, `card` 18,
1063
+ `control-sm/md/lg` 32/40/48, `hit` 44 (`min-h-hit`), `box` 22 (`size-box`).
1064
+ - Also `rounded-checkbox` (7 px), `bg-scrim` (black in both modes), `ease-ios`/`ease-md`/`ease-settle`.
1065
+
1066
+ ## Component index
1067
+
1068
+ Grouped by category. Each page in `ai/components/<name>.md` follows the same order as the docs
1069
+ site: overview, usage sections with examples, inputs, outputs, slots, dos and don'ts, migration
1070
+ notes.
1071
+
1072
+ ### Actions
1073
+
1074
+ | Component | Entry point | Purpose |
1075
+ | ---------------- | ---------------- | ------------------------------------------------------------------------------ |
1076
+ | `bf-action-bar` | `@bitforgehq/ui` | Floating pill of contextual actions that appears while items are selected |
1077
+ | `bf-button` | `@bitforgehq/ui` | Pill-shaped action button with five variants, three sizes and a loading state |
1078
+ | `bf-icon-button` | `@bitforgehq/ui` | Round icon-only button for toolbars and rows, with a required accessible label |
1079
+
1080
+ ### Forms
1081
+
1082
+ | Component | Entry point | Purpose |
1083
+ | ----------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
1084
+ | `bf-checkbox` | `@bitforgehq/ui` | Rounded checkbox with an optional label that binds to Signal Forms |
1085
+ | `bf-chip` | `@bitforgehq/ui` | Toggleable filter chip with a check mark when selected, in tonal or ink style |
1086
+ | `bf-chip-group` | `@bitforgehq/ui` | Named row of chips that picks one like radios, or groups independent toggles |
1087
+ | `bf-field` | `@bitforgehq/ui` | Label, help text and error text wrapper that wires ids for assistive tech |
1088
+ | `bf-input` | `@bitforgehq/ui` | Single-line text field with label, help and error text, icon slots and Signal Forms binding |
1089
+ | `bf-number-input` | `@bitforgehq/ui/number-input` | Number field that binds number or null, with native min, max and step |
1090
+ | `bf-radio-group` | `@bitforgehq/ui` | Group of radio buttons for choosing one option, with optional per-option descriptions |
1091
+ | `bf-segmented` | `@bitforgehq/ui` | Segmented control for switching between two to four views, keyboard-navigable like radios |
1092
+ | `bf-select` | `@bitforgehq/ui` | Native single-choice dropdown styled like the text field, with Signal Forms binding |
1093
+ | `bf-textarea` | `@bitforgehq/ui` | Multi-line text field with label, help and error text, and Signal Forms binding |
1094
+ | `bf-toggle` | `@bitforgehq/ui` | On/off switch for settings that apply immediately, with Signal Forms binding |
1095
+
1096
+ ### Data display
1097
+
1098
+ | Component | Entry point | Purpose |
1099
+ | -------------- | ---------------- | --------------------------------------------------------------------------------------- |
1100
+ | `bf-avatar` | `@bitforgehq/ui` | Round avatar showing a photo or initials, in three sizes and four color variants |
1101
+ | `bf-badge` | `@bitforgehq/ui` | Small status label in semantic tones, or a compact numeric pill for counts |
1102
+ | `bf-card` | `@bitforgehq/ui` | Content surface with an optional header slot, in four visual styles from raised to flat |
1103
+ | `bf-list` | `@bitforgehq/ui` | Container for list rows, inset by default with rounded corners on a warm background |
1104
+ | `bf-list-item` | `@bitforgehq/ui` | List row with leading slot, title, subtitle, trailing slot and an optional chevron |
1105
+ | `bf-progress` | `@bitforgehq/ui` | Progress bar in two sizes and four tones for completion, quotas and unknown waits |
1106
+
1107
+ ### Feedback
1108
+
1109
+ | Component | Entry point | Purpose |
1110
+ | ---------------- | ---------------- | ---------------------------------------------------------------------------------- |
1111
+ | `bf-alert` | `@bitforgehq/ui` | Inline status message in four tones with a title and an optional dismiss button |
1112
+ | `bf-empty-state` | `@bitforgehq/ui` | Placeholder for an empty screen or list with icon, title, message and action |
1113
+ | `bf-spinner` | `@bitforgehq/ui` | Indeterminate loading spinner in three sizes that inherits its parent's text color |
1114
+
1115
+ ### Navigation
1116
+
1117
+ | Component | Entry point | Purpose |
1118
+ | ----------------- | ----------------------- | ------------------------------------------------------------------------------------------- |
1119
+ | `bf-back-button` | `@bitforgehq/ui/mobile` | Header back button that pops the page stack or falls back to a default route |
1120
+ | `bf-content` | `@bitforgehq/ui/mobile` | Page's single scroll container that keeps focused fields clear of the soft keyboard |
1121
+ | `bf-header` | `@bitforgehq/ui/mobile` | Page header toolbar with a large title that collapses into the toolbar on scroll |
1122
+ | `bf-page` | `@bitforgehq/ui/mobile` | Frame for one stacked mobile screen that supplies lifecycle events and shared page state |
1123
+ | `bf-refresher` | `@bitforgehq/ui/mobile` | Pull-to-refresh indicator with haptic feedback, native iOS bounce and an Android-style pull |
1124
+ | `bf-stack-outlet` | `@bitforgehq/ui/mobile` | Router outlet keeping a live page stack with slide transitions and iOS swipe back |
1125
+ | `bf-tab` | `@bitforgehq/ui` | One tab bar destination with an icon, a label and an optional count badge |
1126
+ | `bf-tab-bar` | `@bitforgehq/ui` | Floating pill tab bar for mobile apps with arrow-key selection and count badges |
1127
+ | `bf-tabs` | `@bitforgehq/ui/mobile` | Tabs page with one navigation stack per tab, wiring the tab bar to the router |
1128
+
1129
+ ### Overlays
1130
+
1131
+ | Component | Entry point | Purpose |
1132
+ | ------------------ | ------------------------ | --------------------------------------------------------------------------------------------- |
1133
+ | `bf-action-sheet` | `@bitforgehq/ui/mobile` | Bottom panel of labelled actions with a separate cancel button, destructive ones shown in red |
1134
+ | `bf-alert-dialog` | `@bitforgehq/ui/mobile` | Centred confirmation dialog with a title, message and a row of role-styled buttons |
1135
+ | `bf-desktop-toast` | `@bitforgehq/ui/overlay` | Desktop notification card in a bottom corner stack of up to three |
1136
+ | `bf-dialog` | `@bitforgehq/ui/overlay` | Desktop modal dialog with a title, scrolling body, footer actions and focus trap |
1137
+ | `bf-menu` | `@bitforgehq/ui/overlay` | Keyboard-operable menu panel with items, checkbox and radio items, groups and submenus |
1138
+ | `bf-menu-button` | `@bitforgehq/ui/overlay` | Styled button that opens a menu, with outline, text, tonal and icon-only looks |
1139
+ | `bf-modal` | `@bitforgehq/ui/mobile` | Modal overlay shown as an inset card over the scaled-down page, or full screen |
1140
+ | `bf-sheet` | `@bitforgehq/ui/mobile` | Bottom sheet with snap points, drag to dismiss and a backdrop |
1141
+ | `bf-toast` | `@bitforgehq/ui/mobile` | Passive status pill with an optional action that never steals focus |
1142
+
1143
+ ### Icons
1144
+
1145
+ | Component | Entry point | Purpose |
1146
+ | --------- | ---------------------- | ------------------------------------------------------------------------------------- |
1147
+ | `bf-icon` | `@bitforgehq/ui/icons` | Tree-shakable Lucide icon renderer, decorative by default or labelled when meaningful |
1148
+
1149
+ ## Upgrading
1150
+
1151
+ Each component page ends with its migration notes per version. `MANIFEST.json` marks deprecated
1152
+ members with their replacement.
1153
+
1154
+ 0.3.0 moved the overlay core (`BfOverlayService`, `BfOverlayRef`, `BfOverlayStack`, the animations,
1155
+ …) from `@bitforgehq/ui/mobile` to the new `@bitforgehq/ui/overlay` entry; `/mobile`'s old exports
1156
+ are deprecated aliases of the same classes and tokens, removed in 0.4.0. The package
1157
+ `CHANGELOG.md` (shipped in `@bitforgehq/ui`), `## 0.3.0`, `### overlay entry` carries the full
1158
+ old-import → new-import table — run a project-wide replace of the import specifier for every name
1159
+ in it rather than editing one call site at a time.