@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/CHANGELOG.md ADDED
@@ -0,0 +1,3665 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@bitforgehq/ui`. One `##` section per version; one `###` sub-section per touched component (the agent-docs generator lifts those into each component's page).
4
+
5
+ The heading for the version being worked on carries `(unreleased)` in place of a date. At release time, that placeholder is replaced with the release date (`## 0.3.1 (unreleased)` becomes `## 0.3.1 (YYYY-MM-DD)`) in the same commit that bumps `projects/ui/package.json`. See "Releasing" in the root `README.md`. 0.3.0 is the first version published to npm; the 0.2.0 and 0.1.0 sections below are the history of versions that were never published.
6
+
7
+ ## 0.3.0 (2026-09-28)
8
+
9
+ ### Migration
10
+
11
+ Breaking changes from 0.2.x, each with its fix:
12
+
13
+ 1. Fields show default text for errors that carry no `message` (Signal Forms validators without
14
+ `{ message }`, Reactive `Validators.*`). Before: a touched invalid field turned red with no
15
+ text unless you passed `errorMessage`. After: it shows its kind's default text ("Fill in this
16
+ field", "Use at least 5 characters", …). Fix: translate with
17
+ `provideBfLabels({ validation: {...} })`, or keep passing `errorMessage`.
18
+ 2. Text fields default to the compact `md` step. Before: `bf-input`, `bf-select` and `bf-textarea`
19
+ defaulted to `lg` (48 px, 16 px sides). After: `md` (40 px, 14 px sides, 14 px type; the
20
+ textarea's padding and type). Fix: Capacitor apps add `provideBfDefaults({ fieldSize: 'lg' })`
21
+ to their `ApplicationConfig.providers` to keep the 0.2 look, or `provideBfDefaults({ size: 'lg' })`
22
+ to enlarge every control; a single field takes `size="lg"`.
23
+ 3. `bf-segmented` heights follow the shared scale. Before: `md` (default) was a 44 px track (36 px
24
+ segments), `sm` a 40 px track. After: the track is `sm` 32, `md` 40, `lg` 48 px. Fix:
25
+ `size="lg"` (48) where the old 44 px control must stay touch-sized, or
26
+ `provideBfDefaults({ controlSize: 'lg' })` app-wide.
27
+ 4. `bf-chip` has a `size`. Before: a fixed 36 px pill. After: `md` 40 px by default (`sm` 32,
28
+ `lg` 48). Fix: `size="sm"` for a 32 px filter row.
29
+ 5. `BfFieldSize`, `BfButtonSize` and `BfSegmentedSize` are deprecated aliases of `BfSize`. Before:
30
+ separate unions (`BfFieldSize` `'md' | 'lg'`, `BfSegmentedSize` `'sm' | 'md'`). After: all three
31
+ are `BfSize` (`'sm' | 'md' | 'lg'`). Fix: no action needed unless you switch exhaustively over
32
+ `BfFieldSize` or `BfSegmentedSize` (add the new step); import `BfSize` from `@bitforgehq/ui`
33
+ going forward, as the aliases are removed in 1.0.0.
34
+ 6. "Nothing selected" is `null`, not `''`, in `bf-select`, `bf-radio-group` and `bf-segmented`.
35
+ Before: `value` was a `string` defaulting to `''`, and `bf-select` showed its placeholder for any
36
+ falsy value. After: the controls are generic (`BfSelectComponent<T = string>`, …) with
37
+ `value: T | null` defaulting to `null`; an option whose value is `''` is now a real, selectable
38
+ option, and the placeholder shows only while no option matches. `[formField]` and
39
+ `[(value)]` bindings to a `string` field or signal keep compiling (Angular does not type-check
40
+ the write-back of a two-way binding); an explicit `(valueChange)="x.set($event)"` into a
41
+ `string` signal no longer compiles (`string | null` is not assignable to `string`). Fix: type
42
+ the bound signal or field `T | null` and start it at `null` (a `required()` rule treats `null`
43
+ as empty), compare against `null` instead of `''`, or listen to `(changed)`, which emits only a
44
+ chosen, never-null `T`.
45
+ 7. Object option values need `compareWith`. Before: options matched by `===` on strings only.
46
+ After: a value is matched to an option with `Object.is` by default, so an object value that is
47
+ equal but not the same instance (e.g. reloaded from the server) selects nothing. Fix: pass
48
+ `[compareWith]="byId"` with `byId = (a: Team, b: Team) => a.id === b.id`.
49
+ 8. Numbers bind to `bf-number-input`. Before: a numeric field used `bf-input type="number"` with a
50
+ `string` model and could not use Signal Forms `min()`/`max()`. After: `bf-number-input` binds
51
+ `number | null` (empty is `null`), takes `min`/`max`/`step`, and reports unreadable text as a
52
+ `parse` error. Fix: replace `<bf-input type="number" [(value)]="qty">` and its string model with
53
+ `<bf-number-input [(value)]="qty">` (import `BfNumberInputComponent` from
54
+ `@bitforgehq/ui/number-input`) and `qty = signal<number | null>(…)`;
55
+ `bf-input type="number"` still works and stays string-typed.
56
+ 9. Object option values render their index as the native value. Before: every option value was a
57
+ string, rendered as `<option value="…">` and as the radio's `value`. After: string, number and
58
+ boolean values still render as themselves (`"42"` for `42`); an object value renders its option
59
+ index (`"0"`, `"1"`, …) in `bf-select` and `bf-radio-group`. Fix: for object options, pick by
60
+ label in browser tests (`selectOption({ label })`) and post the model, not native form data.
61
+ 10. `bf-segmented`'s public `tabStop` is an index. Before: the value of the segment that owned the
62
+ tab stop (`''` when none). After: its option index (`-1` when every option is disabled). Fix:
63
+ read `options()[tabStop()]?.value` where you read `tabStop()`.
64
+ 11. Inside `bf-tabs` the tab bar is a `nav` of links, not a `tablist`. Before: `nav` >
65
+ `role="tablist"` > `button role="tab"` with `aria-selected`, a roving tab stop and arrow-key
66
+ selection. After: `nav` > plain group > `<a href>` per tab with `aria-current` on the active
67
+ one (`page` at the tab's root page, `true` on a child page of it); every tab is in the tab order
68
+ and the arrow keys do nothing (Tab moves between tabs). In Safari and iOS WKWebView (the
69
+ Capacitor iOS app) Tab reaches links only with "Press Tab to highlight each item" turned on
70
+ (Option+Tab otherwise), so a hardware-keyboard user who reached the old tab buttons with Tab
71
+ now needs Option+Tab there; VoiceOver and Full Keyboard Access are unaffected. A standalone
72
+ `bf-tab-bar`, and one inside a routed page under `bf-tabs`, is unchanged. Fix: in tests and
73
+ scripts, query `getByRole('link', { name: 'Projects' })` instead of `getByRole('tab', …)` and
74
+ assert `aria-current` (`page`, or `true` below the tab's root) instead of
75
+ `aria-selected="true"`; select a tab with a click or Enter, not the arrow keys.
76
+ 12. `bf-card [interactive]` and its `clicked` output are deprecated. Before:
77
+ `<bf-card [interactive]="true" (clicked)="open()">` made the whole card one `<button>`, hiding
78
+ headings and nesting controls. After: they still work in 0.3 and are removed in 1.0.0. Fix: put
79
+ a `bfAction` link or button in the card's heading,
80
+ `<h3><a bfAction routerLink="/projects/atlas">Atlas rebrand</a></h3>` (import
81
+ `BfActionDirective`), and give other controls in the card `class="relative z-1"`.
82
+ 13. Custom themes set `--color-primary-text`. Before: the text button, the focused field label
83
+ and the iOS back button used `--color-primary` as text, so a light brand primary (yellow, light
84
+ green) failed 4.5:1. After: they use the new `--color-primary-text` (`#2b26ba` light, `#8c88f5`
85
+ dark by default), which the library cannot derive from your override. Fix: an app that
86
+ overrides `--color-primary` adds `--color-primary-text` (a shade of it with ≥ 4.5:1 on `bg` and
87
+ `surface`) to its `@theme` and `.dark` blocks; regenerate the snippet with the docs site's
88
+ theme configurator, which also derives a contrast-safe `--color-primary-strong`. Optionally set
89
+ `--radius-control` (default `9999px`) to move buttons, chips, badges, segmented, the tab bar,
90
+ the action bar and toggles off the pill.
91
+ 14. `BfTabBarHost` and `BfTabLinks` gained required members. Before: `BfTabBarHost` had `active`,
92
+ `tabStop` and `select`. After: it also requires `linkMode`, `linkHref(id)` and
93
+ `linkIsRoot(id)`, and the new `BfTabLinks` (`BF_TAB_LINKS`) requires `href(id)` and
94
+ `isRoot(id)`. Fix: only a class that implements `BfTabBarHost` itself (a custom bar providing
95
+ `BF_TAB_BAR`) breaks; add `readonly linkMode = false`, `linkHref = () => null` and
96
+ `linkIsRoot = () => true`.
97
+ 15. `bf-alert`'s role follows `urgent`, not `variant`. Before: a `danger` alert was
98
+ `role="alert"` (assertive), every other variant `role="status"`. After: every alert is
99
+ `status` unless it has `urgent`, in any variant. Fix: add `urgent` to danger alerts that must
100
+ interrupt; in tests, query `getByRole('status')` for the others.
101
+ 16. `bf-alert`'s dismiss button is named after the title. Before: "Dismiss" (or `dismissLabel`).
102
+ After: "Dismiss: Sync failed" when the alert has a `title`, the label alone without one. Fix:
103
+ in tests, query `getByRole('button', { name: 'Dismiss: Sync failed' })`, or match the label as
104
+ a prefix.
105
+ 17. Alert dialogs and action sheets focus the cancel button first. Before: focus landed on the
106
+ first button (`BfAlertDialogService`) or the first row (`BfActionSheetService`). After: it lands
107
+ on the `cancel`-role button (an action sheet always has one), else the first button. Fix: in
108
+ tests, stop assuming the first button is focused; press Tab or click the button you mean.
109
+ 18. `BfAlertDialogService.open()` returns `Promise<string | undefined>`. Before: `Promise<string>`;
110
+ an alert without a cancel button could only be answered by tapping a button. After: a
111
+ navigation to another URL path closes every alert, resolving the cancel button's answer, or
112
+ `undefined` when it has no cancel button. Fix: handle `undefined` like cancel (for example
113
+ `if (answer === 'delete')` already does); where a `string` is required, write
114
+ `(await alerts.open(…)) ?? 'cancel'`, or pass `closeOnNavigation: false` to keep the alert open
115
+ until a button is tapped.
116
+ 19. Overlays close when the URL path changes. Before: a sheet, modal, action sheet or alert stayed
117
+ open over the next page after a push, a pop, a tab switch or a deep link. After: they close
118
+ (`afterClosed` resolves `undefined`, an action sheet `undefined`, an alert its cancel answer);
119
+ query- and fragment-only changes do not close them, and toasts never close. An overlay opened
120
+ before the first navigation ends (from the root component, an `APP_INITIALIZER` or a
121
+ shell-level "What's new" sheet) closes when the start URL redirects (`''` to `'today'`). On
122
+ the web, browser back closes an open form sheet or modal before a route `canDeactivate` guard
123
+ runs. Fix: pass `closeOnNavigation: false` to an overlay that must stay open across a
124
+ navigation; open a start-up overlay after the first `NavigationEnd`; persist a draft that
125
+ must survive browser back.
126
+ 20. `/mobile` overlays outside `provideBfShell()` make the page `inert`. Before: opening a sheet,
127
+ modal, action sheet or alert without the shell left the rest of the page interactive. After:
128
+ every direct child of `<body>` becomes `inert` while one is open, except the CDK overlay
129
+ container, the CDK live announcer and `AriaDescriber` containers,
130
+ `script`/`style`/`template`/`link`, and body children marked `data-bf-overlay-keep`. Fix: mark
131
+ an element that must stay usable outside the overlay (a body-level chat widget, your own live
132
+ region) `data-bf-overlay-keep`.
133
+ 21. `/mobile` overlays outside `provideBfShell()` register no hardware back handler. Before: the
134
+ Android back button closed a sheet, modal, action sheet or alert through
135
+ `BfBackButtonService` alone. After: overlays reach the back button only through the new
136
+ optional `BF_OVERLAY_HOST` token, which `provideBfShell()` provides; without it, back falls
137
+ through to the page. Fix: an app that uses `/mobile` overlays without `provideBfShell()` and
138
+ relies on Android back closing them provides `BF_OVERLAY_HOST` itself.
139
+ 22. `provideBfShell()` provides no service itself. Before: it listed `BfNavController`, the
140
+ platform services and every overlay service, so a lazy route's `providers: [provideBfShell()]`
141
+ created its own copies in that route's environment injector. After: all of them are
142
+ `providedIn: 'root'` and resolve from the root injector even under a lazy
143
+ `provideBfShell()`; only the config, the route reuse strategy and `BF_OVERLAY_HOST` stay in
144
+ that injector. Fix: put `provideBfShell()` in the application providers (as the setup guide
145
+ does); nothing changes there.
146
+ 23. Deprecated output names. Before: `bf-chip` `(toggled)`, `bf-tab-bar` `(tabChange)` and
147
+ `(tabReselect)`, `bf-stack-outlet` `(stackChange)`, `(transitionStart)` and `(transitionEnd)`,
148
+ `bf-refresher` `(refresh)`. After: one naming convention (outputs are past-tense events; a value
149
+ control has one `changed`) names them `(changed)`, `(changed)` and `(reselected)`,
150
+ `(stackChanged)`, `(transitionStarted)` and `(transitionEnded)`, and `(refreshRequested)`; the
151
+ old names stay as deprecated aliases that emit the same payload at the same moment. Fix: rename
152
+ the binding (`(toggled)="x()"` → `(changed)="x()"`, a subscription `bar.tabChange` →
153
+ `bar.changed`); the old names keep emitting until 0.4.0, where they are removed.
154
+ 24. `bf-tabs` no longer writes the bar's `active`, so `(activeChange)` and `[(active)]` on a bar
155
+ inside `bf-tabs` no longer fire. Before: `bf-tabs` set the projected `bf-tab-bar`'s `active`
156
+ to the current tab (and back after a refused navigation), which emitted `activeChange`, and
157
+ hid the bar with `setHidden()`. After: the bar derives its selection and hiding from the new
158
+ `BF_TAB_BAR_CONTROLLER` that `bf-tabs` provides; `active` keeps its initial value and
159
+ `activeChange` stays silent. Fix: listen to `(changed)` instead of `(activeChange)`, and read
160
+ the shown tab from the bar's `selected()` or `BfTabsComponent.current()`/`tabKey()` instead of
161
+ `active()`.
162
+ 25. `bf-field` keeps its help text while an error shows. Before: the error paragraph replaced the
163
+ help paragraph, and `describedBy()` and the controls' `aria-describedby` pointed at one of
164
+ them. After: the help paragraph stays and the error renders under it; `aria-describedby` lists
165
+ both (`"<id>-help <id>-error"`), and every control that shows an error sets
166
+ `aria-errormessage="<id>-error"` while `aria-invalid="true"`. An error that appears while a
167
+ pointer button is held down shows once the press ends. Fix: shorten help text that repeated the
168
+ error, and update a test or a custom control that expected `describedBy()` to be the error id
169
+ alone (read the ids with `split(' ')`).
170
+ 26. `bf-spinner` draws a CSS ring instead of an SVG, and does not rotate under reduced motion.
171
+ Before: an inline `<svg>` (`width`/`height` 16/20/24, a faint `<circle>` track and a `<path>`
172
+ arc) with `animate-spin`, spinning even under `prefers-reduced-motion`. After: a
173
+ `<span data-bf-spinner-ring aria-hidden="true">` ring with a `currentColor` border, inset by a
174
+ 1 px margin in the same 16/20/24 px box (`motion-safe:animate-spin`), which pulses its
175
+ opacity under reduced motion (`motion-reduce:animate-pulse`). The `px` member (the box in px)
176
+ is deprecated. Fix: move CSS or test selectors from `bf-spinner svg` (`circle`, `path`,
177
+ `[width]`) to `bf-spinner [data-bf-spinner-ring]`, and read the `size` input instead of
178
+ `px()`, which keeps returning 16/20/24 until it is removed in 0.4.0; colour the spinner
179
+ through its parent's text colour as before.
180
+ 27. `bf-icon` keeps the fills Lucide sets. Before: the renderer dropped `fill`, so icons with
181
+ filled details lost them (the dots of `Palette`, the points of `ChartScatter`, the hole of
182
+ `KeyRound`, 10 distinct icons in all). After: those shapes render as solid `currentColor`. Fix:
183
+ nothing, unless an app drew the missing dots itself or styled `bf-icon circle { fill }`;
184
+ drop that.
185
+ 28. A dismissible sheet has two controls of its own ahead of its content. Before: the sheet's
186
+ first focusable element was the content's. After: a visually hidden "Close" button
187
+ (`[data-bf-sheet-close]`) comes first, then, with two or more snap points, the grabber button
188
+ (`[data-bf-sheet-grabber-button]`, "Expand"/"Collapse"); initial focus never lands on them:
189
+ it goes to the panel (the default) or, with `autoFocus: 'first-field'`, to the content's first
190
+ field (see Migration 33). Fix: tests that count a sheet's buttons or walk its Tab order skip
191
+ the two (`:not([data-bf-sheet-close]):not([data-bf-sheet-grabber-button])`); keep a visible
192
+ close or cancel control in the content.
193
+ 29. The page-sheet `bf-modal` starts below the status bar. Before: the card sat 12 px from the
194
+ viewport top (`top-3`) and its `bf-header` padded itself by the top safe area. After: the
195
+ card's top is the safe-area top plus 10 px, and a `bf-header` (or `fullscreen` `bf-content`)
196
+ inside it adds no safe-area padding (`BfPageContext.safeAreaTop` false). Fix: drop any
197
+ padding an app added to make up for either; a fullscreen modal is unchanged. A `bf-page`
198
+ inside a sheet modal's content is not supported (its fresh context brings the band back); the
199
+ modal docs say so.
200
+ 30. Native system bars follow the in-app theme. Before: the shell left the status-bar and
201
+ navigation-bar style to the app. After: under `provideBfShell()` on native iOS and Android,
202
+ `SystemBars.setStyle()` follows `BfPlatformService.dark()` at launch and on every theme
203
+ change (`Default` on Android WebView < 140). Fix: an app that styles the bars itself passes
204
+ `provideBfShell({ syncSystemBars: false })`.
205
+ 31. `bf-header` collapses its large title over the title's rendered height. Before: the collapse
206
+ ran over a fixed 56 px of scroll (the iOS large title is about 70 px tall, so it finished
207
+ early). After: the range is the large title block's measured height, at most 200 px (a taller
208
+ title collapses its whole height over 200 px, so faster than it scrolls), at least 44 px, and
209
+ 56 px until measured; the snap goes to it; a page whose rest range is shorter keeps the title
210
+ expanded; `data-bf-scrolled` still switches on at full collapse, now at that height. At compact
211
+ heights (`(max-height: 500px)`) there is no large title and no collapse. Fix: tests or styles
212
+ that scroll 56 px to collapse the header scroll by the large title's height (`.bf-large` block
213
+ `offsetHeight`, capped at 200) instead.
214
+ 32. `bf-list-item` titles wrap. Before: the title, the title slot and the subtitle each truncated
215
+ on one line. After: long ones wrap (`wrap-anywhere`), so no text is lost at large text sizes.
216
+ Fix: add `truncate` to rows that should keep one line (`<bf-list-item truncate>`).
217
+ 33. Sheets focus their panel on open. Before: `BfSheetService.open()` focused the content's first
218
+ focusable element, so a sheet with a text field raised the Android keyboard at once. After:
219
+ focus goes to the sheet itself (the named `role="dialog"` pane, `tabindex="-1"`), new option
220
+ `autoFocus: 'panel' | 'first-field'` (default `'panel'`). Fix: pass `autoFocus: 'first-field'`
221
+ for a sheet that exists to be typed into; tests that expected the first field focused focus it
222
+ themselves or pass the option.
223
+ 34. Android back at a stack root backgrounds the app. Before: when no overlay and no stack could go
224
+ back, `BfBackButtonService` called `App.exitApp()`, so the next launch started cold. After: it
225
+ calls `App.minimizeApp()`, and a relaunch resumes warm on the same tab. Fix: pass
226
+ `provideBfShell({ backAtRoot: 'exit' })` to keep finishing the activity.
227
+ 35. Overlays close on a browser's close request. Before: Android back in Chrome or a PWA (no
228
+ Capacitor) went back in history under an open overlay. After: where `CloseWatcher` exists,
229
+ each non-passive overlay creates one, and a close request runs its `Escape` path (the
230
+ overlay closes, or `onEscape` runs); the URL stays. Fix: nothing; an overlay that must survive
231
+ Android web back passes `dismissOnEscape: false`.
232
+ 36. The shown page's `bf-content` is the `main` landmark. Before: the shell rendered no `main`.
233
+ After: the scroller of the page a `bf-stack-outlet` shows has `role="main"` (not inside
234
+ `bf-modal`, and not for a `bf-page` outside an outlet), and while it overflows with nothing
235
+ focusable inside it is a tab stop (`tabindex="0"`). Fix: remove a `main` element or
236
+ `role="main"` an app put around the outlet or inside its pages, so there is one `main`; tests
237
+ that count `main` landmarks or Tab stops expect these.
238
+ 37. `bfHorizontalScroll` is deprecated. Before: the docs said a horizontal scroller in a stack page
239
+ needed it. After: a horizontal scroller pans on its own (`touch-action` is not inherited past
240
+ a scroll container); the directive still sets `touch-action: pan-x pan-y` until 0.4.0. Fix:
241
+ remove `bfHorizontalScroll` and its import.
242
+ 38. `bf-segmented` keeps its labels whole and scrolls sideways when they cannot fit. Before: a
243
+ label broke anywhere (`wrap-anywhere`), letter by letter at the largest text sizes ("S y st e
244
+ m" at iOS AX5), and the side padding was rem (it grew with Dynamic Type). After: the side
245
+ padding is px (12/14/18, the same at the default size), a segment shrinks only to its longest
246
+ word (`min-w-min`), labels wrap between words, and when the segments still cannot fit (iOS
247
+ 235 % on a phone) the control scrolls sideways inside a new `[data-bf-segmented-scroller]`
248
+ wrapper around the track. Fix: nothing; tests that read the track as the host's first child
249
+ look inside the scroller, and styles that set `px-*` on segments use px values.
250
+ 39. The iOS edge swipe-back reacts to touch only. Before: any primary pointer that pressed in the
251
+ left 50 px edge zone and dragged right armed it, so a mouse drag (a desktop page with the mode
252
+ forced to `ios`) popped the page. After: it arms only for `pointerType` `touch`; a mouse, a
253
+ text selection from the edge and a pen never pop. Fix: nothing in an app (a desktop user goes
254
+ back with the header's back button); a test that simulates swipe-back dispatches its
255
+ `pointerdown`/`pointermove`/`pointerup` with `pointerType: 'touch'` (as
256
+ `e2e/navigation.spec.ts` does) instead of a mouse drag.
257
+
258
+ ### config
259
+
260
+ New app-level configuration in `@bitforgehq/ui` (additive). `BfSize` (`'sm' | 'md' | 'lg'`, 32/40/48
261
+ px) is the one control-height scale. `provideBfDefaults({ size, fieldSize, controlSize })` sets
262
+ the default height step of text fields (`fieldSize`) and of actions and choice controls
263
+ (`controlSize`) through the `BF_DEFAULTS` token (library default `BF_DEFAULT_VALUES`, `md` for
264
+ both); `size` sets both, an explicit `fieldSize`/`controlSize` wins. `provideBfLabels(overrides)`
265
+ replaces the library's built-in English strings (`BfLabels`: `loading`, `dismiss`, `tabBar`,
266
+ `tabBadge(count)`, `back`, `ok`, `cancel`, `actions`, `sheet`, `modal`, `refreshing`, `close`,
267
+ `expand`, `collapse`, `sheetPosition`, `dialog`, `notifications`) and the default error text per validator kind (`validation`, a `BfValidationMessages`) through the
268
+ `BF_LABELS` token (defaults `BF_DEFAULT_LABELS`). Both providers return a plain `Provider` that
269
+ merges over the parent injector's value (labels' `validation` key by key; a key set to
270
+ `undefined` keeps the parent's string), so they work in
271
+ `ApplicationConfig.providers` and in a component's `providers` for a sub-tree. A component-level
272
+ `provideBfLabels()` covers the widgets, form controls, `bf-back-button` and `bf-refresher` inside
273
+ it; the overlay services (`ok`, `cancel`, `actions`, `sheet`, `modal`, `close`, `dialog`,
274
+ `notifications`) are root singletons and use it only when the call passes the feature's `injector` (see those entries).
275
+ `close` ("Close") names the `bf-dialog` close button, `dialog` ("Dialog") a dialog with no
276
+ title, heading or `ariaLabel`, and `notifications` ("Notifications") the desktop toast stack's
277
+ region.
278
+ `bfValidationMessage(error, messages)` returns an error's own `message`, else the default for its
279
+ kind (Signal Forms kinds and the Reactive Forms interop kinds, limits read from the error or its
280
+ `context`), else `validation.fallback`.
281
+
282
+ JSDoc of `defaults.ts` for the API reference: `@summary`/`@since` on the exported symbols;
283
+ `@default` on the option properties the code applies; `@param` docs. No behaviour change.
284
+
285
+ JSDoc of `labels.ts` for the API reference: `@summary`/`@since` on the exported symbols; `@default`
286
+ on the option properties the code applies; `@param` docs. No behaviour change.
287
+
288
+ JSDoc of `size.ts` for the API reference: `@summary`/`@since` on the exported symbols. No behaviour
289
+ change.
290
+
291
+ JSDoc of `validation-messages.ts` for the API reference: `@summary`/`@since` on the exported
292
+ symbols; `@default` on the option properties the code applies; `@param` docs. No behaviour change.
293
+
294
+ `document-reset.ts` (new, internal, exported as `ɵprovideBfDocumentReset` for the `/testing` and
295
+ `/mobile` test providers only; not public API): the document cleanup of `provideBfTesting()` and
296
+ `provideBfShellTesting()`.
297
+
298
+ New label keys `expand` ("Expand") and `collapse` ("Collapse"), the names of `bf-sheet`'s grabber
299
+ button below and at the top snap point, and `sheetPosition(position, count)` ("Sheet at 1 of 2"),
300
+ the sheet's position, which completes the grabber's name and is announced when it changes; `close` ("Close") now also names `bf-sheet`'s
301
+ screen-reader Close. Like the other overlay labels they come from the injector the sheet is
302
+ opened with.
303
+
304
+ ### theme
305
+
306
+ **Breaking for custom themes:** new `--color-primary-text` (primary as text: the text button, the
307
+ focused `bf-field` label, the iOS back button), declared with its own value in `@theme`
308
+ (`#2b26ba`) and `.dark` (`#8c88f5`), ≥ 4.5:1 on `bg` and `surface`. An app that overrides
309
+ `--color-primary` must set it too. See Migration 13.
310
+
311
+ New `--radius-control: 9999px` (utility `rounded-control`): the shape of buttons, icon buttons,
312
+ chips, badges, segmented, the tab bar and its tabs, the action bar, toggles and the alert's dismiss
313
+ button. The default keeps them pills; avatars, radios, the tab count badge and the spinner stay
314
+ `rounded-full` (a circle carries meaning). Additive.
315
+
316
+ New pressed tokens, light and dark (F-045), each ≥ 4.5:1 with the text it carries:
317
+ `--color-primary-pressed` (`#1f1b8c` / `#b9b7fb`, primary filled), `--color-primary-soft-pressed`
318
+ (`#cfcdf6` / `#34357a`, tonal fills and outline or text presses), `--color-surface-pressed`
319
+ (`#e4e0d6` / `#2e3139`, neutral rows, segments and unselected chips) and `--color-danger-pressed`
320
+ (`#7f2618` / `#f4a497`, danger filled). `tools/theme-contrast.test.ts` checks every
321
+ text-on-pressed pair. A custom theme that overrides `--color-primary` should set the two primary
322
+ pressed tokens too; the docs theme configurator derives them one step past their hover siblings.
323
+ New `--animate-progress-indeterminate` with its `bf-progress-indeterminate` keyframes (the
324
+ indeterminate `bf-progress` stripe).
325
+
326
+ **Behaviour:** light `--color-text-muted` is `#5b6555` (was `#616b5b`), so muted text keeps 4.5:1
327
+ on `--color-surface-pressed` (a pressed list row's subtitle and trailing notes: 4.63:1; 6.11:1 on
328
+ `surface`).
329
+
330
+ New `--spacing-card-lg: 1.5rem` (24 px, utility `p-card-lg`): the `bf-card` `lg` padding, beside
331
+ `--spacing-card` (18 px, `md`). Additive (F-063).
332
+
333
+ ### input
334
+
335
+ New `changed` output: the new text right after each user edit (not for a programmatic write), so
336
+ every value control has one `changed`. New exported `BfInputInputMode` (`BfInputMode | ''`),
337
+ `BfInputEnterKeyHint` (`BfEnterKeyHint | ''`) and `BfInputAutocapitalize`
338
+ (`BfAutocapitalize | ''`), the types of `inputMode`, `enterKeyHint` and `autocapitalize` (were
339
+ the same unions written inline; `''`, the default, leaves the attribute off). `BfInputMode`,
340
+ `BfEnterKeyHint` and `BfAutocapitalize` keep their 0.2.0 members (their `@since` now says 0.2.0).
341
+
342
+ **Breaking:** the default `size` is `md` (40 px) instead of `lg` (48 px); `size` is a `BfSize`
343
+ (`sm` 32, `md` 40, `lg` 48 px — `sm` is new) and, unset, follows `provideBfDefaults()`'s
344
+ `fieldSize`. See Migration 2. Icon insets and padding are logical (`start-*`/`end-*`, `ps-*`/`pe-*`),
345
+ so `icon-start` and `icon-end` sit at the inline start and end under `dir="rtl"`.
346
+
347
+ **Behaviour:** an error without a message shows its kind's default text (was a red border only):
348
+ once touched, the field shows the first error's own `message`, else `provideBfLabels()`'s
349
+ `validation` text for its kind (Signal Forms and Reactive interop kinds), else
350
+ `validation.fallback`. `errorMessage` still wins. See Migration 1.
351
+
352
+ JSDoc of `input.component.ts` for the API reference: `@summary`/`@since` on the exported symbols;
353
+ `@slot` docs for `icon-start`, `icon-end`; `@param` docs; template handlers and framework hooks
354
+ marked `@internal`, so the reference leaves them out. No behaviour change.
355
+
356
+ **Behaviour:** the help text stays while an error shows, with the error under it (F-020):
357
+ `aria-describedby` lists both (`"<id>-help <id>-error"`), and `aria-errormessage` points at the
358
+ error paragraph while `aria-invalid="true"`. See Migration 25.
359
+
360
+ ### textarea
361
+
362
+ New `changed` output: the new text right after each user edit (not for a programmatic write).
363
+
364
+ **Breaking:** the default `size` is `md` instead of `lg`; `size` is a `BfSize` (`sm` is new) and,
365
+ unset, follows `provideBfDefaults()`'s `fieldSize`. A step sets padding and type, never a height
366
+ (`rows` and `autoResize` do), and the docs now say so. See Migration 2.
367
+
368
+ **Behaviour:** an error without a message shows its kind's default text (was a red border only):
369
+ once touched, the field shows the first error's own `message`, else `provideBfLabels()`'s
370
+ `validation` text for its kind (Signal Forms and Reactive interop kinds), else
371
+ `validation.fallback`. `errorMessage` still wins. See Migration 1.
372
+
373
+ JSDoc of `textarea.component.ts` for the API reference: `@param` docs; template handlers and
374
+ framework hooks marked `@internal`, so the reference leaves them out. No behaviour change.
375
+
376
+ **Behaviour:** the help text stays while an error shows, with the error under it (F-020):
377
+ `aria-describedby` lists both (`"<id>-help <id>-error"`), and `aria-errormessage` points at the
378
+ error paragraph while `aria-invalid="true"`. See Migration 25.
379
+
380
+ New `maxRows = input<number | undefined>(undefined)` (F-019). With `autoResize` the box keeps
381
+ `field-sizing: content` and now starts at `rows` lines (a `min-block-size` of `rows` times `1lh`
382
+ plus the size's padding and border), so an empty auto-resizing textarea is as tall as a fixed one with the
383
+ same `rows` (it was one line); with `maxRows` it stops at that many lines (`max-block-size`, the same
384
+ way) and scrolls. **Behaviour:** where the browser has no `field-sizing` (`CSS.supports()` says no, read once;
385
+ also on the server) the box keeps its fixed `rows` and the resize
386
+ handle instead of the one-line box it had.
387
+
388
+ ### select
389
+
390
+ **Breaking:** typed values. `BfSelectComponent<T = string>` is generic over the option value
391
+ (`BfSelectOption<T = string>`), so numbers, ids and objects bind directly; `value` is `T | null`
392
+ and defaults to `null` (was `''`), which shows the placeholder. New `compareWith`
393
+ (`BfCompareWith<T>`, default `Object.is`) matches `value` to an option, and new `changed` emits the
394
+ chosen value (never `null`) after a user change. A string, number or boolean option value is the
395
+ native `<option value>` (`"42"` for `42`); an object option's is its index; a choice maps back by
396
+ position, so duplicate native values still resolve. An option whose value is `''` is now
397
+ selectable instead of reading as "empty"; an option whose value is `null` is unsupported (it reads
398
+ as nothing selected). See Migration 6, 7 and 9.
399
+
400
+ **Breaking:** the default `size` is `md` (40 px) instead of `lg` (48 px); `size` is a `BfSize`
401
+ (`sm` 32, `md` 40, `lg` 48 px — `sm` is new) and, unset, follows `provideBfDefaults()`'s
402
+ `fieldSize`. See Migration 2. The chevron and the text padding are logical, so the chevron sits at
403
+ the inline end under `dir="rtl"`.
404
+
405
+ **Behaviour:** an error without a message shows its kind's default text (was a red border only):
406
+ once touched, the field shows the first error's own `message`, else `provideBfLabels()`'s
407
+ `validation` text for its kind (Signal Forms and Reactive interop kinds), else
408
+ `validation.fallback`. `errorMessage` still wins. See Migration 1.
409
+
410
+ JSDoc of `select.component.ts` for the API reference: `@summary`/`@since` on the exported symbols;
411
+ `@param` docs; template handlers and framework hooks marked `@internal`, so the reference leaves
412
+ them out. No behaviour change.
413
+
414
+ **Behaviour:** the help text stays while an error shows, with the error under it (F-020):
415
+ `aria-describedby` lists both (`"<id>-help <id>-error"`), and `aria-errormessage` points at the
416
+ error paragraph while `aria-invalid="true"`. See Migration 25.
417
+
418
+ ### checkbox
419
+
420
+ New `ariaLabelledby` input: the id(s) of the element(s) that name the box when no visible `label`
421
+ is shown (a table's column header and row title), set as the native checkbox's `aria-labelledby`.
422
+ Additive.
423
+
424
+ **Behaviour:** new `size` (`BfSize`; unset follows `provideBfDefaults()`'s `controlSize`): rows
425
+ are `sm` 32, `md` 40 (default), `lg` 48 px under a fine pointer, and at least 44 px under a coarse
426
+ pointer (was 44 px everywhere); the box is 18 / 22 / 24 px with a matching check mark, and the error
427
+ text's indent is logical (`ps-*`). The row is at least 24 px wide (`min-w-6`), so a label-less box
428
+ with only `ariaLabel` still meets the 24×24 px target size of WCAG 2.5.8 under a fine pointer.
429
+
430
+ **Behaviour:** an error without a message shows its kind's default text (was a red border only):
431
+ once touched, the field shows the first error's own `message`, else `provideBfLabels()`'s
432
+ `validation` text for its kind (Signal Forms and Reactive interop kinds), else
433
+ `validation.fallback`. `errorMessage` still wins. See Migration 1.
434
+
435
+ JSDoc of `checkbox.component.ts` for the API reference: `@slot` docs for `default`; `@param` docs;
436
+ template handlers and framework hooks marked `@internal`, so the reference leaves them out. No
437
+ behaviour change.
438
+
439
+ **Behaviour:** a pressed state (F-045): the box border turns `text` while the row is held down. None while disabled or read-only; under forced colours the system colours win.
440
+
441
+ **Behaviour:** sets `aria-errormessage` to its error paragraph (`<id>-error`) while
442
+ `aria-invalid="true"`, and an error that appears while a pointer button is held down (a press
443
+ elsewhere that blurred it) shows once the press ends, so the layout never moves under the pointer
444
+ (F-020).
445
+
446
+ ### toggle
447
+
448
+ New `ariaLabelledby` input: the id of the element that names the switch when no visible `label`
449
+ is shown, set as the native switch's `aria-labelledby`. Additive.
450
+
451
+ New exported `BfToggleLabelPosition` (`'end' | 'start'`), the type of `labelPosition` (was an
452
+ inline union with the same values).
453
+
454
+ **Behaviour:** new `size` (`BfSize`; unset follows `provideBfDefaults()`'s `controlSize`): rows
455
+ are `sm` 32, `md` 40 (default), `lg` 48 px under a fine pointer, and at least 44 px under a coarse
456
+ pointer (was 44 px everywhere); the track is 24×40 / 28×48 / 32×56 px with a matching knob, and the knob
457
+ travels to the inline end (mirrored under `dir="rtl"`).
458
+
459
+ **Behaviour:** an error without a message shows its kind's default text (was a red border only):
460
+ once touched, the field shows the first error's own `message`, else `provideBfLabels()`'s
461
+ `validation` text for its kind (Signal Forms and Reactive interop kinds), else
462
+ `validation.fallback`. `errorMessage` still wins. See Migration 1.
463
+
464
+ **Behaviour:** the track and the knob use `rounded-control` (`--radius-control`, `9999px` by
465
+ default, so they render as before).
466
+
467
+ **Behaviour:** the knob carries the state for any configured primary: `on-primary` on the on track
468
+ (was `surface`), `primary-strong` when read-only and on (was `primary`); off and disabled knobs
469
+ stay `surface`.
470
+
471
+ JSDoc of `toggle.component.ts` for the API reference: `@slot` docs for `default`; `@param` docs;
472
+ template handlers and framework hooks marked `@internal`, so the reference leaves them out. No
473
+ behaviour change.
474
+
475
+ **Behaviour:** a pressed state (F-045): the track takes a `text` border while the row is held down. None while disabled or read-only; under forced colours the system colours win.
476
+
477
+ **Behaviour:** sets `aria-errormessage` to its error paragraph (`<id>-error`) while
478
+ `aria-invalid="true"`, and an error that appears while a pointer button is held down (a press
479
+ elsewhere that blurred it) shows once the press ends, so the layout never moves under the pointer
480
+ (F-020).
481
+
482
+ **Behaviour:** the label wraps (`min-w-0 wrap-anywhere`) instead of overflowing its row at large
483
+ text sizes (F-051): a `labelPosition="start"` label in a `bf-list-item` spilled past the row's
484
+ start edge at the iOS AX sizes.
485
+
486
+ ### radio-group
487
+
488
+ New exported `BfRadioGroupOrientation` (`'vertical' | 'horizontal'`), the type of `orientation`
489
+ (was an inline union with the same values).
490
+
491
+ **Breaking:** typed values. `BfRadioGroupComponent<T = string>` is generic over the option value
492
+ (`BfRadioOption<T = string>`); `value` is `T | null` and defaults to `null` (was `''`), with
493
+ nothing checked; `changed` emits `T` (was `string`). New `compareWith` (default `Object.is`)
494
+ matches `value` to an option. A radio's native `value` is its option value for a string, number
495
+ or boolean and its index for an object. See Migration 6, 7 and 9.
496
+
497
+ **Behaviour:** new `size` (`BfSize`; unset follows `provideBfDefaults()`'s `controlSize`): rows
498
+ are `sm` 32, `md` 40 (default), `lg` 48 px under a fine pointer, and at least 44 px under a coarse
499
+ pointer (was 44 px everywhere); the ring is 18 / 22 / 24 px with a matching dot.
500
+
501
+ **Behaviour:** an error without a message shows its kind's default text (was a red border only):
502
+ once touched, the field shows the first error's own `message`, else `provideBfLabels()`'s
503
+ `validation` text for its kind (Signal Forms and Reactive interop kinds), else
504
+ `validation.fallback`. `errorMessage` still wins. See Migration 1.
505
+
506
+ **Behaviour:** the dot is `primary-strong` (was `primary`), so it reaches 3:1 in its `surface`
507
+ ring for any configured primary.
508
+
509
+ JSDoc of `radio-group.component.ts` for the API reference: `@summary`/`@since` on the exported
510
+ symbols; `@param` docs; template handlers and framework hooks marked `@internal`, so the reference
511
+ leaves them out. No behaviour change.
512
+
513
+ **Behaviour:** a pressed state (F-045): an option's ring border turns `text` while the option is held down. None while disabled or read-only; under forced colours the system colours win.
514
+
515
+ **Behaviour:** sets `aria-errormessage` to its error paragraph (`<id>-error`) while
516
+ `aria-invalid="true"`, and an error that appears while a pointer button is held down (a press
517
+ elsewhere that blurred it) shows once the press ends, so the layout never moves under the pointer
518
+ (F-020).
519
+
520
+ ### segmented
521
+
522
+ **Breaking:** typed values. `BfSegmentedComponent<T = string>` is generic over the option value
523
+ (`BfSegmentOption<T = string>`); `value` is `T | null` and defaults to `null` (was `''`), with
524
+ nothing checked and the first enabled segment owning the tab stop; `changed` emits `T` (was
525
+ `string`). New `compareWith` (default `Object.is`) matches `value` to an option. `tabStop` is now
526
+ the index of the segment that owns the tab stop (was its value, `-1` when every option is
527
+ disabled), and `select(value)` matches through `compareWith`. See Migration 6, 7 and 10.
528
+
529
+ **Breaking:** heights follow the shared `BfSize` scale: the track is `sm` 32, `md` 40 (default,
530
+ was 44), `lg` 48 px (new); unset, `size` follows `provideBfDefaults()`'s `controlSize`. The
531
+ segments' 44 px hit extension applies only under a coarse pointer (`pointer-coarse:`). See
532
+ Migration 3.
533
+
534
+ **Behaviour:** an error without a message shows its kind's default text (was a red border only):
535
+ once touched, the field shows the first error's own `message`, else `provideBfLabels()`'s
536
+ `validation` text for its kind (Signal Forms and Reactive interop kinds), else
537
+ `validation.fallback`. `errorMessage` still wins. See Migration 1.
538
+
539
+ **Behaviour:** the selected segment carries a 1.5 px inset `primary-strong` outline (≥ 3:1 on
540
+ `surface` and the track), so the selection no longer rests on a fill 1.18:1 from the track; a
541
+ disabled selected segment is outlined in `text-disabled`. The focus ring sits on a 2 px `surface`
542
+ offset (≥ 3:1) instead of on the track (2.79:1). The track and segments use `rounded-control`
543
+ (`--radius-control`).
544
+
545
+ **Behaviour:** under forced colours the selected segment's outline, focus ring (on a `Canvas`
546
+ offset) and shadow use system colours only (`CanvasText`; no shadow), since it opts out of the
547
+ adjustment; a disabled selected segment's outline is `GrayText` instead of the authored
548
+ `text-disabled`.
549
+
550
+ JSDoc of `segmented.component.ts` for the API reference: `@summary`/`@since` on the exported
551
+ symbols; `@param` docs; template handlers and framework hooks marked `@internal`, so the reference
552
+ leaves them out. No behaviour change.
553
+
554
+ **Behaviour:** a pressed state (F-045): an editable segment takes `surface-pressed` (an unselected one with `text`, which holds 4.5:1 there) while held down. None while disabled or read-only; under forced colours the system colours win.
555
+
556
+ **Behaviour:** sets `aria-errormessage` to its error paragraph (`<id>-error`) while
557
+ `aria-invalid="true"`, and an error that appears while a pointer button is held down (a press
558
+ elsewhere that blurred it) shows once the press ends, so the layout never moves under the pointer
559
+ (F-020).
560
+
561
+ **Behaviour:** labels stay whole at every text size. The segments' side padding is px
562
+ (`px-[12px]`/`px-[14px]`/`px-[18px]`, the rem values at the default size) and so is the track's
563
+ (`px-[4px]`, `gap-[2px]`), so Dynamic Type spends the width on the label; a segment shrinks only to
564
+ its longest word (`min-w-min`, was `min-w-0`) and a label wraps between words (was
565
+ `wrap-anywhere`, which broke "System" letter by letter at iOS AX5). When the segments still cannot
566
+ fit, the track grows past the control and a new `[data-bf-segmented-scroller]` wrapper
567
+ (`overflow-x-auto`, padded so the focus ring is not clipped) scrolls it sideways; the host gains
568
+ `max-w-full`. On the demo's Theme control the three segments fit side by side at iOS 200 %
569
+ (WCAG 1.4.4) on a 393 px phone, and scroll at 235 %. See Migration 38.
570
+
571
+ ### number-input
572
+
573
+ New `changed` output: the new `number | null` right after a user edit that changes the model
574
+ (unreadable text changes nothing and does not emit). `step` is typed with the new exported
575
+ `BfNumberInputStep` (`number | 'any'`) and `inputMode` with `BfNumberInputMode`
576
+ (`'numeric' | 'decimal' | ''`); `enterKeyHint` takes `BfInputEnterKeyHint` (`BfEnterKeyHint | ''`).
577
+
578
+ New `bf-number-input`: a native `<input type="number">` with the chrome, sizes (`fieldSize`), slots
579
+ and states of `bf-input` that implements `FormValueControl<number | null>` through Angular's
580
+ `transformedValue()`. An empty field is `null`; text the browser cannot read as a number keeps the
581
+ model and raises a `parse` error ("Enter a valid number", `provideBfLabels()`'s
582
+ `validation.parse`), shown once the field is left, with or without a form. `min`, `max` and
583
+ `step` are native attributes that limit stepping, bound by Signal Forms `min()`/`max()` (whose
584
+ errors and default messages are the only range errors; a step mismatch is not reported). Unset,
585
+ `inputMode` is `numeric` for an integer (or no) step and `decimal` otherwise only when `min >= 0`;
586
+ without such a `min` it stays off, so phone keyboards keep a minus key. A form `reset()` replaces
587
+ unreadable text with the number. Works
588
+ with Reactive Forms `FormControl<number | null>` through the Signal Forms interop. See Migration 8.
589
+ It ships in its own entry point, `import { BfNumberInputComponent } from
590
+ '@bitforgehq/ui/number-input'`, because it parses with Angular's `transformedValue()` and so loads
591
+ Angular Forms (`@angular/forms`, a required peer); a parse error reaches the bound Signal Forms
592
+ field (blocking `submit()`) or Reactive `FormControl` (invalid) with no setup. The widgets entry
593
+ `@bitforgehq/ui` loads no `@angular/forms` code at runtime, so an app without forms ships none of
594
+ it.
595
+
596
+ JSDoc of `number-input.component.ts` for the API reference: `@slot` docs for `icon-start`,
597
+ `icon-end`; `@param` docs; template handlers and framework hooks marked `@internal`, so the
598
+ reference leaves them out. No behaviour change.
599
+
600
+ **Behaviour:** the help text stays while an error shows, with the error under it (F-020):
601
+ `aria-describedby` lists both (`"<id>-help <id>-error"`), and `aria-errormessage` points at the
602
+ error paragraph while `aria-invalid="true"`. See Migration 25.
603
+
604
+ ### spinner
605
+
606
+ **Behaviour:** `label` defaults to `''` and falls back to `provideBfLabels()`'s `loading`
607
+ ("Loading"); the rendered English default is unchanged.
608
+
609
+ JSDoc of `spinner.component.ts` for the API reference: `@summary`/`@since` on the exported symbols.
610
+ No behaviour change.
611
+
612
+ **Behaviour:** a pure-CSS ring with no inline SVG (F-066): a `rounded-full` ring sized
613
+ `size-3.5`, `size-4.5` or `size-5.5` (14/18/22 px, close to the old SVG's 14/17.5/21 px outer
614
+ edge) and inset by `m-px`, so the box stays 16/20/24 px, with a `border-2`, `border-2` or
615
+ `border-[3px]` border, `border-current/25` for the track and `border-t-current` for the arc; the
616
+ arc ends are square. It rotates with `motion-safe:animate-spin`; under `prefers-reduced-motion` it no longer
617
+ rotates at all but pulses its opacity (`motion-reduce:animate-pulse`, F-064). Under forced colours
618
+ the track is `GrayText` and the arc `CanvasText`. The `decorative`, `label` and status semantics
619
+ are unchanged.
620
+
621
+ **Deprecated:** `px` (the box in px, 16/20/24): use the `size` input; removed in 0.4.0. See
622
+ Migration 26.
623
+
624
+ ### alert
625
+
626
+ **Behaviour:** `dismissLabel` defaults to `''` and falls back to `provideBfLabels()`'s `dismiss`
627
+ ("Dismiss"); the rendered English default is unchanged.
628
+
629
+ **Behaviour:** the dismiss button uses `rounded-control` (`--radius-control`).
630
+
631
+ **Behaviour:** the role no longer follows the variant: the region is `role="status"` (polite)
632
+ unless the new `urgent` input makes it `role="alert"` (assertive), in any variant. Migration: add
633
+ `urgent` to danger alerts that must interrupt (see Migration 15).
634
+
635
+ New `announce` input: the live region renders empty and fills on the next frame, so an alert
636
+ inserted after an action is announced (APG alert, WCAG 4.1.3); it is read once, at creation.
637
+
638
+ **Behaviour:** with a `title` the dismiss button is named `<dismissLabel>: <title>` ("Dismiss:
639
+ Sync failed"), so two dismissible alerts have distinct buttons (see Migration 16). The docs show
640
+ moving focus in the `dismissed` handler; the widget never moves it.
641
+
642
+ JSDoc of `alert.component.ts` for the API reference: `@summary`/`@since` on the exported symbols;
643
+ `@slot` docs for `icon`, `default`, `actions`. No behaviour change.
644
+
645
+ ### tab-bar
646
+
647
+ **Deprecated:** `tabChange` → `changed` and `tabReselect` → `reselected`. The new outputs emit the
648
+ tab id at the same moments; the old names still emit the same id right after them until 0.4.0 (see
649
+ Migration 23). `bf-tabs` listens to the new names.
650
+
651
+ **Breaking:** (ARIA contract) with the new `BF_TAB_LINKS` token provided by a router-aware parent
652
+ (`bf-tabs`) in the same template, the bar renders a `nav` holding a plain group of links instead
653
+ of a `tablist`: no roving tab stop and no arrow-key, Home or End handling. The token is looked up
654
+ with `host: true`, so a bar inside a routed page under `bf-tabs` stays a standalone `tablist`.
655
+ `BfTabBarHost` gains `linkMode`, `linkHref(id)` and `linkIsRoot(id)`. See Migration 11 and 14.
656
+
657
+ **Behaviour:** `label` defaults to `''` and falls back to `provideBfLabels()`'s `tabBar` ("Main");
658
+ the rendered English default is unchanged.
659
+
660
+ **Behaviour:** shaped with the new `rounded-control` (`--radius-control`, `9999px` by default, so it renders as before); override the token to move off the pill.
661
+
662
+ JSDoc of `tab-bar.component.ts` for the API reference: `@slot` docs for `default`; `@param` docs;
663
+ template handlers and framework hooks marked `@internal`, so the reference leaves them out. No
664
+ behaviour change.
665
+
666
+ JSDoc of `tab-bar.token.ts` for the API reference: `@summary`/`@since` on the exported symbols;
667
+ `@param` docs. No behaviour change.
668
+
669
+ **Behaviour:** new `BF_TAB_BAR_CONTROLLER` token and `BfTabBarController` interface (additive,
670
+ extends `BfTabLinks`): a parent that projects the bar and drives it provides `current()`,
671
+ `hidden()`, `hideTransition()`, `select(id)` and `reselect(id)`. With a controller (looked up with
672
+ `host: true`, like `BF_TAB_LINKS`) the bar shows `current()` as its selection (`''` while it is
673
+ `null`), hides while `hidden()` is true with the transition `hideTransition()` gives, and hands a
674
+ user selection to `select(id)`/`reselect(id)`, still emitting `changed` or `reselected` (and the
675
+ deprecated `tabChange`/`tabReselect` right after them); it never writes `active` then. New
676
+ `selected` on the bar: the id shown as selected, `active` without a controller; `BfTabBarHost`
677
+ gains it as an optional member (`bf-tab` falls back to `active`), so a custom `BF_TAB_BAR`
678
+ provider keeps working. Inside `bf-tabs` `activeChange` no longer emits. Without a controller
679
+ nothing changes; `setHidden()` stays for other parents. See Migration 24.
680
+
681
+ **Behaviour:** large text and compact heights (F-051, F-054): the tabs' row has a minimum height
682
+ in px (`min-h-[64px]`, was the rem `h-16`) and grows with a label wrapped to two lines, which
683
+ Android's font scale (a text zoom that does not scale the bar) needs at 2.0. Being px, the bar no
684
+ longer grows with iOS Dynamic Type itself, as UITabBar does not: at AX5 it was 154.7 px (the rem
685
+ `h-16` at a 235 % root) and is now 98 px (a capsule and two capped label lines). At compact
686
+ heights (`(max-height: 500px)`, phone landscape) it is at least 48 px (`min-h-[48px]`). The bottom
687
+ offset stays.
688
+
689
+ ### tab
690
+
691
+ **Breaking:** (ARIA contract) inside `bf-tabs` a tab is `<a href>` with `aria-current` while
692
+ active: `page` on the tab's root page, `true` on a child page of it (no `role="tab"` or
693
+ `aria-selected`); a disabled one is `<a role="link" aria-disabled="true">` without `href`. A plain
694
+ primary click selects the tab through the bar (default prevented); Ctrl/Cmd, Shift, Alt and middle
695
+ clicks open the link in a new tab or window. The link's underline is removed with an important
696
+ utility (`no-underline!`), so an unlayered global link rule cannot underline it. See Migration 11.
697
+
698
+ **Behaviour:** a badged tab is named by `aria-label` = label, comma, announcement ("Projects, 3
699
+ new"; was "Projects , 3 new", with a pause, from a visually hidden span that browsers joined with a
700
+ space), in both forms; the `sr-only` announcement span is gone. Without a badge the visible label
701
+ is the name, as before.
702
+
703
+ **Behaviour:** the badge is placed with a logical inset (`-end-2`), so it sits at the icon's
704
+ inline end under `dir="rtl"`.
705
+
706
+ **Behaviour:** a numeric `badge` is announced through `provideBfLabels()`'s `tabBadge(count)`
707
+ (`"{n} new"` by default, now translatable); a string badge is announced as given.
708
+
709
+ **Behaviour:** the active capsule carries a 1.5 px inset `primary-strong` outline (≥ 3:1 on its
710
+ `primary-soft` fill and the bar), so the active tab no longer rests on a fill 1.23:1 from the bar;
711
+ a disabled active capsule is outlined in `text-disabled`. The tab and its capsule use
712
+ `rounded-control` (`--radius-control`); the count badge stays a circle.
713
+
714
+ **Docs:** `bf-tab` has a page of its own (`tab-bar/tab.docs.md`): its id, its badges (moved from
715
+ the `bf-tab-bar` page) and a disabled tab, with the tab's own theming and accessibility; the
716
+ `bf-tab-bar` page links to it.
717
+
718
+ JSDoc of `tab.component.ts` for the API reference: `@slot` docs for `default`; template handlers and
719
+ framework hooks marked `@internal`, so the reference leaves them out. No behaviour change.
720
+
721
+ **Behaviour:** a pressed state (F-045): the capsule of an enabled tab takes `primary-soft-pressed` (an inactive one with the `text` icon) while the tab is held down. None while disabled; under forced colours the system colours win.
722
+
723
+ **Behaviour:** large text (F-051): the label's size and line height are capped at 200 %
724
+ (`min(var(--text-nano), 22px)`, `min(var(--text-nano--line-height), 28px)`, in px because iOS
725
+ Dynamic Type scales the root); it still wraps to at most two lines and never clips vertically. The
726
+ tab has 4 px of vertical padding, so a grown tab keeps off the bar's edge. The capsule (30 × 52
727
+ px), the badge (18 px, its count capped at 13 px), the padding and the gaps are px, so they keep
728
+ their size at every text size. The label hyphenates a long word where the engine has
729
+ hyphenation for the page's `lang` (`hyphens-auto wrap-break-word`, was `wrap-anywhere`), and
730
+ otherwise still breaks it rather than overflowing (WKWebView on the iOS 18 simulator does not
731
+ hyphenate "Projects", so it still reads "Projec/ts" at AX5). At
732
+ compact heights (`(max-height: 500px)`, F-054) the icon and the label sit side by side
733
+ (`flex-row gap-[6px]`).
734
+
735
+ ### back-button
736
+
737
+ **Behaviour:** `label` defaults to `''` and falls back to `provideBfLabels()`'s `back` ("Back"),
738
+ both as the iOS text and as the Material button's name.
739
+
740
+ **Behaviour:** the iOS label and chevron use `primary-text` (was `primary`); hover stays
741
+ `primary-strong`.
742
+
743
+ JSDoc of `back-button.component.ts` for the API reference: docs for the undocumented members and
744
+ methods. No behaviour change.
745
+
746
+ JSDoc of `back-button.service.ts` for the API reference: `@summary`/`@since` on the exported
747
+ symbols; `@param` docs. No behaviour change.
748
+
749
+ **Behaviour:** large text (F-051): the host (`min-w-0 shrink`) and the iOS button (`shrink`, no
750
+ longer `shrink-0`) may shrink inside the header's `start` slot, down to the 44 px hit area: the
751
+ text truncates and then disappears before the chevron, as UIKit drops the back title when space is
752
+ short. The text stays the button's name. It carries `data-bf-back-text`, the part `bf-header`'s iOS
753
+ start-slot cap lets give way.
754
+
755
+ ### alert-dialog
756
+
757
+ New exported `BfAlertDialogButtonRole` (`'cancel' | 'destructive'`), the type of
758
+ `BfAlertDialogButton.role` (was an inline union with the same values).
759
+
760
+ **Type:** `open()` returns `Promise<string | undefined>` (was `Promise<string>`): a navigation to
761
+ another URL path closes the alert and resolves the cancel button's answer, else `undefined`. New
762
+ `closeOnNavigation` option (default true). Migration: handle `undefined` like cancel (see
763
+ Migration 18).
764
+
765
+ **Behaviour:** the default button (no `buttons`) is labelled with `provideBfLabels()`'s `ok`
766
+ ("OK").
767
+
768
+ New `injector` option: labels resolve from it per call (default the root injector) and it becomes
769
+ the parent of the content's injector, so a feature-level `provideBfLabels()` and the feature's
770
+ other providers reach the dialog.
771
+
772
+ `BfAlertDialogButton` gains `id?: string`; `open()` resolves the tapped button's `id ?? label`,
773
+ and `Escape` and the hardware back button the cancel button's. Buttons render with
774
+ `track $index`, so two buttons with the same label both render.
775
+
776
+ **Behaviour:** focus starts on the `cancel`-role button, else the first button (APG alert dialog),
777
+ even when a destructive button comes first (see Migration 17).
778
+
779
+ JSDoc of `alert-dialog.service.ts` for the API reference: `@summary`/`@since` on the exported
780
+ symbols; `@default` on the option properties the code applies; `@param` docs. No behaviour change.
781
+
782
+ The docs say that Android back in a browser with `CloseWatcher` answers with the cancel button,
783
+ and that an alert without one swallows it; see `### overlay core` and Migration 35.
784
+
785
+ ### action-sheet
786
+
787
+ New exported `BfActionSheetActionRole` (`'destructive' | 'cancel'`), the type of
788
+ `BfActionSheetAction.role` (was an inline union with the same values).
789
+
790
+ **Behaviour:** a navigation to another URL path closes it, resolving `undefined`; new
791
+ `closeOnNavigation` option (default true). Migration: pass `closeOnNavigation: false` to keep it
792
+ open (see Migration 19).
793
+
794
+ **Behaviour:** the synthesized cancel button is labelled with `provideBfLabels()`'s `cancel`
795
+ ("Cancel"), and a sheet without `title` is named by its `actions` label ("Actions").
796
+
797
+ New `injector` option: labels resolve from it per call (default the root injector) and it becomes
798
+ the parent of the content's injector, so a feature-level `provideBfLabels()` and the feature's
799
+ other providers reach the action sheet.
800
+
801
+ `BfActionSheetAction` gains `id?: string`; `open()` resolves the tapped row's `id ?? label` (the
802
+ cancel action still resolves `undefined`). Rows render with `track $index`, so two rows with the
803
+ same label both render.
804
+
805
+ **Behaviour:** focus starts on the cancel button, not the first row (see Migration 17).
806
+
807
+ JSDoc of `action-sheet.component.ts` for the API reference: `@summary`/`@since` on the exported
808
+ symbols. No behaviour change.
809
+
810
+ JSDoc of `action-sheet.service.ts` for the API reference: `@summary`/`@since` on the exported
811
+ symbols; `@default` on the option properties the code applies; `@param` docs. No behaviour change.
812
+
813
+ The docs say that Android back in a browser with `CloseWatcher` closes the action sheet, resolving
814
+ `undefined`; see `### overlay core` and Migration 35.
815
+
816
+ ### sheet
817
+
818
+ **Behaviour:** a navigation to another URL path (a push, a pop, a tab switch) closes it with no
819
+ result, also a `dismissible: false` one; new `closeOnNavigation` option (default true). Migration:
820
+ pass `closeOnNavigation: false` to keep it open (see Migration 19).
821
+
822
+ **Behaviour:** named by the first heading: without `ariaLabel`, the panel is named by the first
823
+ heading in its content that is not `aria-hidden` (`h1`–`h6` or `role="heading"`, given an id
824
+ `bf-overlay-title-<n>` when it has none) through `aria-labelledby`, once the content has rendered;
825
+ without a heading, by `provideBfLabels()`'s `sheet` ("Sheet"). An explicit `ariaLabel` always wins.
826
+
827
+ New `injector` option: labels resolve from it per call (default the root injector) and it becomes
828
+ the parent of the content's injector, so a feature-level `provideBfLabels()` and the feature's
829
+ other providers reach the sheet.
830
+
831
+ A heading that renders after the overlay opened (behind `@if`, a `resource()`) still names it: the
832
+ panel is watched until its first heading appears.
833
+
834
+ JSDoc of `sheet-ref.ts` for the API reference: `@summary`/`@since` on the exported symbols; `@param`
835
+ docs. No behaviour change.
836
+
837
+ JSDoc of `sheet.service.ts` for the API reference: `@summary`/`@since` on the exported symbols;
838
+ `@default` on the option properties the code applies; `@param` docs. No behaviour change.
839
+
840
+ **Behaviour:** the keyboard lift and cap (`translate`, `padding-bottom`, `max-height`,
841
+ `transition`) are written in the render phase (`afterRenderEffect({ write })`) instead of an
842
+ `effect`; internal timing only, no API change.
843
+
844
+ With two or more snap points the grabber is a button (`[data-bf-sheet-grabber-button]`), so the
845
+ sheet can be resized without dragging (WCAG 2.5.7): a tap, click, `Enter` or `Space` moves it one
846
+ point up, and from the top point back to the lowest; `ArrowUp`/`ArrowDown` step one point (never
847
+ past the ends, never dismissing), `Home`/`End` jump to the lowest/top point, all through
848
+ `snapTo()` (instant under reduced motion). It is named after the next press and the position
849
+ ("Expand, Sheet at 1 of 2": `expand` below the top point, `collapse` at it, then
850
+ `sheetPosition`); focus stays on it, and every settle on another point (button, key, drag or
851
+ `snapTo()`) announces `sheetPosition` politely through CDK `LiveAnnouncer`. A drag that starts on
852
+ it still drags (a pointer click within the 6 px slop is the press; a keyboard or screen-reader
853
+ click, `detail` 0, is never swallowed). Alt, Ctrl and Meta combinations are left alone. It is
854
+ the full-width 24 px handle row, extended downward to 44 px under a coarse pointer, so a tap just
855
+ above the sheet reaches the backdrop. With one snap point the grabber stays decorative
856
+ (`aria-hidden`) and nothing is announced.
857
+
858
+ **Behaviour:** every dismissible sheet renders a visually hidden "Close" button
859
+ (`provideBfLabels()`'s `close`) as its first element, shown as a pill at the top-end corner when
860
+ focused; activating it closes the sheet. VoiceOver reaches it by swiping, since WKWebView does not
861
+ deliver the two-finger scrub to web content. A `dismissible: false` sheet has none. The Close and
862
+ the grabber button never take the initial focus. Keep a visible close or cancel control in the
863
+ content (Migration 28).
864
+
865
+ **Behaviour:** a sheet opens with focus on its panel (the `role="dialog"` pane, named and
866
+ `tabindex="-1"`), so a screen reader lands inside it and no soft keyboard comes up on open. New
867
+ option `autoFocus?: BfSheetAutoFocus` (`'panel' | 'first-field'`, default `'panel'`; new exported
868
+ type), mapped to the overlay core's `autoFocus` (`'first-field'` → `'first-tabbable'`, which
869
+ honours `[autofocus]`/`[cdkFocusInitial]` and skips the sheet's Close and grabber). Action sheets
870
+ and modals are unchanged. See Migration 33.
871
+
872
+ `bf-sheet` provides a fresh `BfPageContext` (`mainLandmark` false) to its content: a `bf-content`
873
+ in a sheet opened with a page's injector no longer resolves the page's context, so it is no second
874
+ `main` and does not replace the page's registered scroller.
875
+
876
+ Android back in a browser (no Capacitor) closes an open sheet first where `CloseWatcher` exists
877
+ (see `### overlay core`, Migration 35).
878
+
879
+ ### modal
880
+
881
+ **Behaviour:** a navigation to another URL path closes it with no result, also a
882
+ `dismissible: false` one; new `closeOnNavigation` option (default true). Migration: pass
883
+ `closeOnNavigation: false` to keep it open (see Migration 19).
884
+
885
+ **Behaviour:** named by the first heading: without `ariaLabel`, the panel is named by the first
886
+ heading in its content that is not `aria-hidden` (such as the `bf-header` title) through
887
+ `aria-labelledby`, once the content has rendered; without a heading, by `provideBfLabels()`'s
888
+ `modal` ("Modal"). An explicit `ariaLabel` always wins.
889
+
890
+ New `injector` option: labels resolve from it per call (default the root injector) and it becomes
891
+ the parent of the content's injector, so a feature-level `provideBfLabels()` and the feature's
892
+ other providers reach the modal.
893
+
894
+ A heading that renders after the overlay opened (behind `@if`, a `resource()`) still names it: the
895
+ panel is watched until its first heading appears.
896
+
897
+ JSDoc of `modal.component.ts` for the API reference: `@summary`/`@since` on the exported symbols. No
898
+ behaviour change.
899
+
900
+ JSDoc of `modal.service.ts` for the API reference: `@summary`/`@since` on the exported symbols;
901
+ `@default` on the option properties the code applies; `@param` docs. No behaviour change.
902
+
903
+ **Behaviour:** the page-sheet card (`presentation: 'sheet'`) starts 10 px below the top safe
904
+ area (`top-[calc(var(--spacing-safe-top)+10px)]`, UIKit's page-sheet inset; was `top-3`), so it
905
+ sits below the status bar and the parent page peeks above it, and it sets its `BfPageContext`'s
906
+ `safeAreaTop` to false, so its content's header adds no second status-bar band (Migration 29). A
907
+ fullscreen modal is unchanged.
908
+
909
+ **Behaviour:** its content is no `main` landmark: `bf-modal` sets its `BfPageContext.mainLandmark`
910
+ to false (content in a dialog is not the page's main content; the page behind stays `main`, inert
911
+ while the modal is open). Android web back closes it where `CloseWatcher` exists (Migration 35).
912
+
913
+ ### refresher
914
+
915
+ **Deprecated:** `refresh` → `refreshRequested`, typed with the new exported
916
+ `BfRefresherRefreshEvent` (`{ complete: () => void }`, was an inline object type). Both outputs
917
+ emit the same event object for each completed pull until `refresh` is removed in 0.4.0 (see
918
+ Migration 23).
919
+
920
+ **Behaviour:** the status region announces `provideBfLabels()`'s `refreshing` ("Refreshing").
921
+
922
+ ### button
923
+
924
+ **Behaviour:** `size` is a `BfSize` and, unset, follows `provideBfDefaults()`'s `controlSize`
925
+ (`md` by default, as before). The 44 px hit extension of `sm` and `md` applies only under a coarse
926
+ pointer (`pointer-coarse:`), so dense desktop rows do not overlap; the `outline` variant's
927
+ extension adds its 1.5 px border back and reaches 44 px (was 42). See Migration 5 for
928
+ `BfButtonSize`.
929
+
930
+ New link form: the selector is `bf-button, a[bfButton]`. `<a bfButton routerLink="…">` (or
931
+ `href`) puts the variant, size and full-width classes on your own `<a>` (plus an important
932
+ `no-underline!`, so an unlayered global link rule cannot underline it; no
933
+ inner `<button>`), so a navigation action is a real link that looks like the button: exposed as
934
+ `link`, activated with Enter (not Space), opened in a new tab with Ctrl/Cmd-click, the URL
935
+ visible. `clicked` still emits; `type`, `disabled` and `loading` do not apply (a link is never
936
+ disabled; render a disabled `bf-button` instead). Additive. The host of `bf-button` now binds its
937
+ classes through `[class]`; a consumer's own `class` on the element is kept.
938
+
939
+ **Behaviour:** the `text` variant is coloured `primary-text` (new token, ≥ 4.5:1 on `bg` and
940
+ `surface`; was `primary`), still `primary-strong` on hover. The `danger` variant's hover label is
941
+ `danger-soft` on `danger` (was `on-primary`, which a custom light primary can turn black, 2.7:1 on
942
+ the default danger).
943
+
944
+ **Behaviour:** shaped with the new `rounded-control` (`--radius-control`, `9999px` by default, so
945
+ it renders as before); override the token to move off the pill.
946
+
947
+ JSDoc of `button.component.ts` for the API reference: `@summary`/`@since` on the exported symbols;
948
+ `@slot` docs for `icon-start`, `default`, `icon-end`; template handlers and framework hooks marked
949
+ `@internal`, so the reference leaves them out. No behaviour change.
950
+
951
+ **Behaviour:** a pressed state (F-045): `primary` takes `primary-pressed`, `tonal`, `outline` and `text` `primary-soft-pressed` (the `text` label turns `primary-strong`), `danger` `danger-pressed` with `danger-soft` text, while the button is held down (`enabled:active:`, and `active:` on `a[bfButton]`). None while disabled or loading; under forced colours the system colours win.
952
+
953
+ ### icon-button
954
+
955
+ **Behaviour:** `size` is a `BfSize` and, unset, follows `provideBfDefaults()`'s `controlSize`. The
956
+ 44 px hit extension applies only under a coarse pointer, and the `outline` variant's compensates its
957
+ 1.5 px border.
958
+
959
+ **Behaviour:** shaped with the new `rounded-control` (`--radius-control`, `9999px` by default, so it renders as before); override the token to move off the pill.
960
+
961
+ JSDoc of `icon-button.component.ts` for the API reference: `@summary`/`@since` on the exported
962
+ symbols; `@slot` docs for `default`; template handlers and framework hooks marked `@internal`, so
963
+ the reference leaves them out. No behaviour change.
964
+
965
+ **Behaviour:** a pressed state (F-045): `primary` takes `primary-pressed`, `tonal`, `outline` and `text` `primary-soft-pressed` (the `text` icon turns `text`) while the button is held down. None while disabled; under forced colours the system colours win.
966
+
967
+ ### chip-group
968
+
969
+ New `bf-chip-group` (`BfChipGroupComponent<T>`, `BfChipGroupMode` `'single' | 'multiple'`, root
970
+ entry, F-038): a named row of `bf-chip`s. `single` (default): the host is `role="radiogroup"`
971
+ named by `label` or `ariaLabelledby`, the chips are `role="radio"` with `aria-checked`, one roving
972
+ tab stop (the selected chip, else the first enabled), and the arrow keys (wrapping, skipping
973
+ disabled chips), `Home` and `End` move focus and the selection, as in `bf-segmented`; `Space` and
974
+ `Enter` select, there is no deselect. `[(value)]` holds the selected chip's `value` (`null` for
975
+ none) and `(changed)` emits it after a user change. `multiple`: the host is `role="group"` and the
976
+ chips stay independent `aria-pressed` toggles with their own `[(selected)]`. `disabled` disables
977
+ every chip. It is not a Signal Forms control; for a form field use `bf-segmented` or
978
+ `bf-radio-group`.
979
+
980
+ A chip without a `value` inside a `single` group never matches the group's value and cannot be
981
+ selected, so chips that all leave it unset never read as selected together; give every chip a
982
+ `value`.
983
+
984
+ ### chip
985
+
986
+ **Deprecated:** `toggled` → `changed`. The new `changed` output emits the new `selected` state
987
+ after a user toggle; `toggled` still emits the same value right after it until 0.4.0 (see
988
+ Migration 23).
989
+
990
+ **Breaking:** new `size` (`BfSize`; unset follows `provideBfDefaults()`'s `controlSize`): `sm` 32,
991
+ `md` 40 (default, was a fixed 36), `lg` 48 px. The hit extension applies only under a coarse
992
+ pointer and compensates the chip's 1.5 px border, so it reaches 44 px (was 42). See Migration 4.
993
+
994
+ **Behaviour:** shaped with the new `rounded-control` (`--radius-control`, `9999px` by default, so it renders as before); override the token to move off the pill.
995
+
996
+ JSDoc of `chip.component.ts` for the API reference: `@summary`/`@since` on the exported symbols;
997
+ `@slot` docs for `icon-start`, `default`; template handlers and framework hooks marked `@internal`,
998
+ so the reference leaves them out. No behaviour change.
999
+
1000
+ **Behaviour:** a pressed state (F-045): unselected chips take `surface-pressed`, selected `tonal` ones `primary-soft-pressed`, selected `ink` ones `text-secondary` while held down. None while disabled; under forced colours the system colours win.
1001
+
1002
+ New `value = input<unknown>(undefined)`: what the chip stands for inside a `bf-chip-group`
1003
+ (compared with `Object.is`); unused outside one. Inside a `single` group the chip renders
1004
+ `role="radio"` with `aria-checked` (no `aria-pressed`), takes the group's roving `tabindex`, is
1005
+ disabled with the group, and its own `selected` is ignored: the group's `value` decides. A
1006
+ single-choice row without a group keeps working, and `deselectable` keeps its behaviour; the docs'
1007
+ single-choice section now points to the group. New `focus(options?)` focuses the chip's button.
1008
+
1009
+ ### list-item
1010
+
1011
+ **Behaviour:** logical properties: text aligns to the inline start and the chevron sits at the
1012
+ inline end, pointing there (mirrored under `dir="rtl"`).
1013
+
1014
+ New navigation rows: a `title` content slot (`bfSlot="title"`), rendered in the title position
1015
+ and type, takes an `<a bfAction routerLink="…">` (the new `BfActionDirective`), whose `::after`
1016
+ stretches over the row. The row stays a `listitem`, the link's name is the title, and the row
1017
+ shows the pointer, the `surface-muted` hover and the focus ring (on keyboard focus) while it holds
1018
+ its own `bfAction`; the row box is `relative`. Text under the stretched link is not selectable.
1019
+
1020
+ **Behaviour:** the trailing slot is wrapped in a `relative z-1` flex box (`gap-3`, as between the
1021
+ row's items, hidden when empty), so a toggle or button there stays clickable above a stretched
1022
+ link. `interactive` stays for rows that act on the page.
1023
+
1024
+ **Docs:** `bf-list-item` has a page of its own (`list/list-item.docs.md`): the row's slots, the
1025
+ navigation rows and the action and settings rows (moved from the `bf-list` page), with the row's own
1026
+ theming and accessibility. The `bf-list` page keeps the container: a list of rows and the plain
1027
+ list, and links to the row page.
1028
+
1029
+ JSDoc of `list-item.component.ts` for the API reference: `@slot` docs for `leading`, `title`,
1030
+ `default`, `trailing`. No behaviour change.
1031
+
1032
+ **Behaviour:** a pressed state (F-045): an `interactive` row takes `surface-pressed` while held down, and a row holding a `bfAction` while its action is (`has-[[data-bf-action]:active]`). None while disabled; under forced colours the system colours win.
1033
+
1034
+ While a row is pressed its chevron turns `text-muted` (`text-subtle` misses 3:1 on
1035
+ `surface-pressed`).
1036
+
1037
+ **Behaviour:** the title, the title slot and the subtitle wrap by default (`wrap-anywhere`, no
1038
+ clamp) instead of truncating, so no text is lost at large text sizes (F-051). New
1039
+ `truncate = input(false, { transform: booleanAttribute })` restores one line with an ellipsis for
1040
+ each. See Migration 32. The mobile demo's Inbox rows use `truncate` (a mail list); its Today,
1041
+ project and Me rows wrap.
1042
+
1043
+ ### card
1044
+
1045
+ **Behaviour:** logical properties: content aligns to the inline start (`text-start`).
1046
+
1047
+ **Deprecated:** `interactive` and `clicked` (removed in 1.0.0): a card-sized `<button>` hid the
1048
+ heading and could not hold other controls. Use the new stretched-link pattern instead: a
1049
+ `bfAction` link or button (`BfActionDirective`, new in `@bitforgehq/ui`) in the card's heading
1050
+ stretches its hit area over the card, which is now always `relative` and shows the pointer, the
1051
+ `shadow-floating` hover and the focus ring (on keyboard focus) while it holds its own `bfAction`
1052
+ (it registers with the nearest card or row, so a card holding navigation rows is not lit by them);
1053
+ secondary controls take `class="relative z-1"`. Text under the stretched area is not selectable.
1054
+ See Migration 12.
1055
+
1056
+ JSDoc of `card.component.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1057
+ `@slot` docs for `header`, `default`, `footer`. No behaviour change.
1058
+
1059
+ **Behaviour:** a pressed state (F-045): a card holding a `bfAction` drops from `shadow-floating` back to `shadow-raised` while its action is held down (`has-[[data-bf-action]:active]`), the deprecated `interactive` card while it is. None while disabled; under forced colours the system colours win.
1060
+
1061
+ `padding="lg"` is `p-card-lg` (the new `--spacing-card-lg` token, 24 px) instead of `p-6`
1062
+ (F-063); the rendered padding is unchanged, and an app can now retune it in its `@theme`.
1063
+
1064
+ ### action-bar
1065
+
1066
+ **Behaviour:** logical properties: the pill's and the label's padding use `ps-*`/`pe-*`, so the
1067
+ label leads under `dir="rtl"`.
1068
+
1069
+ **Behaviour:** shaped with the new `rounded-control` (`--radius-control`, `9999px` by default, so it renders as before); override the token to move off the pill.
1070
+
1071
+ JSDoc of `action-bar.component.ts` for the API reference: `@slot` docs for `default`. No behaviour
1072
+ change.
1073
+
1074
+ ### overlay core
1075
+
1076
+ New exported `BfOverlayRole` (`'dialog' | 'alertdialog' | 'status'`) and `BfOverlayAutoFocus`
1077
+ (`'first-tabbable' | 'panel'`), the types of `BfOverlayOptions.role` and `autoFocus` (were inline
1078
+ unions with the same values).
1079
+
1080
+ **Behaviour:** overlays close when the URL path changes. New `closeOnNavigation` option of
1081
+ `BfOverlayService.open()` (default true, false for passive overlays) and `onNavigation` (runs
1082
+ instead of `close()`, e.g. to answer with a cancel id). The core listens through
1083
+ `Location.onUrlChange()` from `@angular/common` (router navigations, `popstate` and `hashchange`),
1084
+ so `/overlay` still needs no `@angular/router`; the path is compared without its query and
1085
+ fragment, so query- or fragment-only changes never close (a matrix-parameter change is a path
1086
+ change and closes; a `history.pushState()` outside Angular's `Location` is not seen). Migration:
1087
+ pass `closeOnNavigation: false` to an overlay that must survive a navigation. The internal path
1088
+ watcher's unregister function is idempotent.
1089
+
1090
+ New `providers` option of `BfOverlayService.open()`: extra providers of the content's injector,
1091
+ built with the new overlay's ref (`BfDialogService` provides its typed `BfDialogRef` this way).
1092
+ New `autoFocus` option: `'first-tabbable'` (default, as before) or `'panel'`, which focuses the
1093
+ pane itself (for long content). Additive.
1094
+
1095
+ **Behaviour:** initial focus on an `[autofocus]`/`[cdkFocusInitial]` element: a focusable mark
1096
+ (a control, or any element with a `tabindex`, including `tabindex="-1"`, e.g. a region to start
1097
+ reading at) takes focus itself, as before; a mark that is not focusable (e.g. a `bf-button` host)
1098
+ now forwards focus to the first tabbable element inside it instead of nowhere; a mark whose
1099
+ controls are all disabled is skipped for the first tabbable element of the panel, else the panel
1100
+ (before: focus stayed behind the overlay). Migration: none.
1101
+
1102
+ **Behaviour:** on close, when the element that opened the overlay is gone (a deleted row, content
1103
+ re-rendered under a nested overlay), focus moves to the topmost overlay still open instead of
1104
+ the page behind it; with none left it stays on the body. Migration: after a destructive action
1105
+ that removes the opener, move focus yourself (the next row, the list heading).
1106
+
1107
+ **Behaviour:** an `ariaLabelledBy` whose id names no element inside the rendered panel is removed
1108
+ when `ariaLabel` is unset, so a panel never points at nothing. Migration: render the heading in
1109
+ the overlay content (or pass `ariaLabel`).
1110
+
1111
+ **Behaviour:** the animation helpers (`fadeIn`, `fadeOut`, `fadeUp`, `fadeDown`, `scaleIn`,
1112
+ `slideUp`, `slideDown`) and the backdrop fade work without the Web Animations API (jsdom in a
1113
+ consumer's Vitest or Jest run): they apply the end state (an exit's last frame; an enter leaves
1114
+ the element as it is) and return an already finished `Animation`. Migration: consumer tests can
1115
+ drop their `Element.prototype.animate` stand-in.
1116
+
1117
+ **Behaviour:** `Escape` from inside another CDK overlay pane (a CDK menu, such as `bf-menu`,
1118
+ opened from a dialog) no longer closes the overlay underneath: the menu closes and the dialog
1119
+ stays open. Migration: none.
1120
+
1121
+ **Behaviour:** pressing the backdrop of a modal overlay no longer moves focus to `<body>` (out of
1122
+ the dialog, alert or sheet): its `mousedown` is cancelled, so focus stays where it was; a
1123
+ dismissing backdrop still closes on its click. Migration: none.
1124
+
1125
+ The heading helper that names a `/mobile` sheet or modal by its content's first heading moved into
1126
+ the core (internal, `ɵ`-prefixed export; `/mobile` imports it by package name).
1127
+
1128
+ New `injector` option of `BfOverlayService.open()`: the parent of the content's injector (default
1129
+ the root injector), so overlay content resolves the opener's providers. Additive.
1130
+
1131
+ **Behaviour:** outside a shell the page behind a modal overlay is `inert`. When no page root is
1132
+ registered (no `bf-stack-outlet`, e.g. a desktop page), `BfOverlayStack` makes every direct child
1133
+ of `<body>` `inert` while a non-passive overlay is open, except the CDK overlay container, the CDK
1134
+ live announcer and `AriaDescriber` containers, `script`/`style`/`template`/`link` and direct body
1135
+ children marked `data-bf-overlay-keep`; an element that was already `inert` keeps it, and
1136
+ destroying the app (its injector) with an overlay open removes every `inert` the stack set. Inside
1137
+ the shell (registered roots) nothing changes. Migration: mark an element outside the overlay that
1138
+ must stay usable (your own live region, a chat widget) `data-bf-overlay-keep`; it works only on a
1139
+ direct child of `<body>`, since nothing inside an inert element (such as your app root) can opt
1140
+ out. A custom `OverlayContainer` placed inside the app root keeps that body child active, so the
1141
+ page is not isolated.
1142
+
1143
+ **Behaviour:** the hardware back button reaches overlays through the new optional
1144
+ `BF_OVERLAY_HOST` token (`registerBack()`, `documentScrolls`), which `provideBfShell()` provides
1145
+ (back handlers on `BfBackButtonService`, no scroll lock). Without `provideBfShell()` overlays
1146
+ register no hardware back handler, and the scroll lock follows `documentScrolls` instead of
1147
+ `BF_SHELL_CONFIG`. Migration: an app that used `/mobile` overlays without `provideBfShell()` and
1148
+ relied on Android back closing them provides `BF_OVERLAY_HOST` itself.
1149
+
1150
+ JSDoc of `motion.ts` for the API reference: `@summary`/`@since` on the exported symbols; `@param`
1151
+ docs. No behaviour change.
1152
+
1153
+ JSDoc of `overlay-animations.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1154
+ `@param` docs. No behaviour change.
1155
+
1156
+ JSDoc of `overlay-host.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1157
+ `@param` docs. No behaviour change.
1158
+
1159
+ JSDoc of `overlay-ref.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1160
+ `@param` docs. No behaviour change.
1161
+
1162
+ JSDoc of `overlay-stack.service.ts` for the API reference: `@summary`/`@since` on the exported
1163
+ symbols; `@default` on the option properties the code applies; `@param` docs. No behaviour change.
1164
+
1165
+ JSDoc of `overlay.service.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1166
+ `@default` on the option properties the code applies; `@param` docs. No behaviour change.
1167
+
1168
+ `BfOverlayStack` no longer injects the CDK `OverlayContainer`: outside a shell it keeps every body
1169
+ child that holds a `.cdk-overlay-container` active, found by that class, so `bf-stack-outlet` and
1170
+ `bf-tabs`, which register their hosts as page roots, pin no CDK overlay code. No behaviour change:
1171
+ the library's overlays and a consumer's own CDK overlay pushed directly stay usable, and a second
1172
+ container (a custom `OverlayContainer` beside the default) stays active too.
1173
+
1174
+ Initial focus (`autoFocus: 'first-tabbable'`) skips the library's own container controls
1175
+ (`bf-sheet`'s hidden Close and grabber button, marked with the internal `data-bf-overlay-chrome`);
1176
+ they stay in the Tab order. Documented on `autoFocus`; the marker is not API.
1177
+
1178
+ **Behaviour:** Android web back closes the top overlay. For every non-passive overlay, where the
1179
+ window has `CloseWatcher` (Chromium 120+) and the host does not handle the system back itself,
1180
+ the core creates a watcher on open and destroys it on close; its close request runs the same path
1181
+ as `Escape` (`onEscape`, else close when `dismissOnEscape` is not false). An overlay that stays
1182
+ open (not dismissible, or below the top one) creates a new watcher, so it keeps claiming the next
1183
+ back press. `Escape` stays a single dismissal: the core cancels the key, so it never reaches a
1184
+ watcher as a second close request. See Migration 35. A non-dismissible overlay (`dismissOnEscape: false` without
1185
+ `onEscape`) therefore swallows Android web back while it is open, as the `BfOverlayService` JSDoc
1186
+ now says.
1187
+
1188
+ New `BfOverlayHost.handlesSystemBack?: boolean`: true when the host already routes the system back
1189
+ to `registerBack`; the overlays then create no `CloseWatcher`. `provideBfShell()` sets it on
1190
+ native Android.
1191
+
1192
+ ### dialog
1193
+
1194
+ New exported `BfConfirmSize` (`'sm' | 'md'`), the type of `BfConfirmOptions.size`,
1195
+ `BfDialogRole` (`'dialog' | 'alertdialog'`), the type of `BfDialogConfig.role`, and
1196
+ `BfConfirmButtonRole` (`'cancel' | 'destructive'`), the type of `BfConfirmButton.role`;
1197
+ `BfDialogConfig` `autoFocus` takes the new `BfOverlayAutoFocus`.
1198
+
1199
+ New `bf-dialog` and `BfDialogService` in `@bitforgehq/ui/overlay`: a centred modal dialog for
1200
+ desktop and web apps with no Capacitor dependency. `open(component | template, config)` returns
1201
+ a typed `BfDialogRef<R, C>` (`afterClosed`, `closed()`, `componentInstance`, `close(result)`);
1202
+ the content injects `BfDialogRef` and `BF_DIALOG_DATA`, a template gets `{ $implicit: ref, data }`.
1203
+ `BfDialogConfig`: `data`, `size` (`sm` 24, `md` 32 (default), `lg` 48, `xl` 64 rem, capped at the
1204
+ viewport minus the gutters, or `fullscreen`), `role` (`dialog`/`alertdialog`), `ariaLabel`,
1205
+ `ariaDescribedBy`, `dismissOnBackdrop` (default true for `dialog`, false for `alertdialog`),
1206
+ `dismissOnEscape`, `autoFocus`, `restoreFocus`, `panelClass`, `injector`. `bf-dialog` lays out a
1207
+ `title` (the `h2` the panel is labelled by), `[bfSlot=header]`, a scrolling body (focusable only
1208
+ while it overflows, then a region named by the title when there is one) and an end-aligned
1209
+ `[bfSlot=footer]`, with a close button (`closeButton`, `closeLabel`, default the `close` label)
1210
+ last in the tab order. `confirm({ title, message?, buttons?, size?, injector? })` opens an alert
1211
+ dialog and resolves with the pressed button's `id ?? label` (default buttons Cancel and OK, ids
1212
+ `cancel` and `ok`); focus starts on the cancel button, `Escape` answers with it or is ignored
1213
+ without one. A navigation to another URL path closes a dialog with no result and a `confirm()`
1214
+ with the cancel button's answer (else `undefined`); `closeOnNavigation: false` (on `BfDialogConfig`
1215
+ and `confirm()`) keeps it open. Title and message ids come from the library's shared id counter,
1216
+ so they never repeat an id of a `bf-dialog` rendered in the page. Focus is trapped and restored,
1217
+ the page behind is `inert` and document scroll is locked (APG dialog (modal) and alert dialog
1218
+ patterns).
1219
+
1220
+ JSDoc of `dialog-ref.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1221
+ `@param` docs. No behaviour change.
1222
+
1223
+ JSDoc of `dialog.component.ts` for the API reference: `@slot` docs for `header`, `default`,
1224
+ `footer`. No behaviour change.
1225
+
1226
+ JSDoc of `dialog.service.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1227
+ `@default` on the option properties the code applies; `@param` docs. No behaviour change.
1228
+
1229
+ The `bf-dialog` host carries `data-bf-dialog-id` (its title id, also without a rendered title), so
1230
+ `BfDialogHarness` finds the overlay panel the card sits in, also under a stacked dialog.
1231
+
1232
+ The docs say that Android back in a browser with `CloseWatcher` closes the dialog like `Escape`
1233
+ (and that `dismissOnEscape: false` swallows it); see `### overlay core` and Migration 35.
1234
+
1235
+ ### desktop-toast
1236
+
1237
+ The card's action button output is `actionClicked` (an action emits `clicked`, a sub-part's
1238
+ action `<part>Clicked`); `BfDesktopToastService` binds it.
1239
+
1240
+ New `BfDesktopToastService` and `bf-desktop-toast` in `@bitforgehq/ui/overlay`: toasts for desktop
1241
+ and web apps with no Capacitor dependency. `show({ message, action?, duration?, injector? })`
1242
+ returns a `BfToastRef` and follows the `/mobile` toast's rules: 3000 ms by default, persistent
1243
+ (`duration` 0) with an action, a later `show()` replaces every persistent toast (`'dismiss'`),
1244
+ `Escape` inside a toast dismisses it, focus returns only when it was inside the closing toast, one
1245
+ polite `LiveAnnouncer` announcement per toast, everything ends with the injector. Up to three
1246
+ cards are visible at once in one passive overlay pane, bottom right (bottom left in RTL) 24 px in
1247
+ plus the safe area, the newest nearest the edge; later toasts wait. The pointer over the stack or
1248
+ focus inside it pauses every visible toast. The stack is a `region` named by the new
1249
+ `notifications` label ("Notifications"); no backdrop, no focus move, no scroll lock, nothing
1250
+ `inert`. Cards fade in with an 8 px slide (a fade under reduced motion). A navigation never closes
1251
+ a toast. `F6` from the page moves focus to the action of the newest toast that has one, and `F6`
1252
+ inside the stack moves it back (the key is left to the browser otherwise). In a right-to-left page
1253
+ the end inset reads the left safe area (`--spacing-safe-left`).
1254
+ `provideBfDesktopToast(config)` sets `position` (`'bottom-end'` (default), `'bottom-center'`,
1255
+ `'top-end'` or `'top-center'`), `maxVisible` (default 3) and `ariaLabel` (the region name; empty
1256
+ uses the label), over `BF_DESKTOP_TOAST_DEFAULTS`.
1257
+
1258
+ JSDoc of `desktop-toast.service.ts` for the API reference: `@summary`/`@since` on the exported
1259
+ symbols; `@default` on the option properties the code applies; `@param` docs. No behaviour change.
1260
+
1261
+ ### menu
1262
+
1263
+ New `changed` output on `bfMenuItemCheckbox`: the new `checked` state right after the user toggles
1264
+ the item (after `triggered`, beside the model's `checkedChange`), so it has the one `changed` of
1265
+ every value control.
1266
+
1267
+ New `bf-menu` and its parts in `@bitforgehq/ui/overlay`, on the Angular CDK menu (APG menu button
1268
+ and menu patterns): `bfMenuItem` on a `<button>` or a link (`<a>` with `href` or `routerLink`),
1269
+ `bfMenuItemCheckbox` and `bfMenuItemRadio` (`checked`, CSS-drawn check mark and dot),
1270
+ `bfMenuGroup` (radio items exclusive), `bfMenuDivider` and `[bfMenuTriggerFor]` (on any element,
1271
+ or on an item for a submenu with a CSS chevron, mirrored in RTL). Items take `bfMenuItemDisabled`
1272
+ (focusable, `aria-disabled`) and `bfMenuItemTypeahead` and emit `triggered`; the trigger takes
1273
+ `bfMenuTriggerData` and `bfMenuPlacement` (`below-start` (default), `below-end`, `above-start`,
1274
+ `above-end`, flipping on overflow; submenus open beside their item) and emits `bfMenuOpened` and
1275
+ `bfMenuClosed`. `bf-menu` takes `size` (item rows `sm` 32, `md` 36, `lg` 44 px, 44 px under a
1276
+ coarse pointer; unset follows `provideBfDefaults()` `controlSize`), `ariaLabel` and
1277
+ `ariaLabelledby`; without either it is named by its trigger. Keyboard: `Enter`, `Space`,
1278
+ `ArrowDown` open on the first item, `ArrowUp` on the last; arrows wrap, `Home`/`End`, type-ahead,
1279
+ `Escape` and `Tab` close with focus back on the trigger, `ArrowRight`/`ArrowLeft` open and close
1280
+ submenus (swapped in RTL); `Space` also follows a link item (once, ignoring auto-repeat), and a
1281
+ disabled link item never navigates, also with `routerLink`. `bfMenuItemCheckbox` emits
1282
+ `checkedChange` with the state it flipped to, so `[(checked)]` binds it two-way. An open submenu
1283
+ stays open while the pointer moves diagonally towards it (CDK menu aim). Under forced colours the
1284
+ focused item is drawn in `Highlight`, a focused disabled one in `GrayText` on `Canvas` with a
1285
+ `Highlight` outline. A navigation to another URL path closes an open menu (query- and
1286
+ fragment-only changes do not); the trigger watches the URL only while its menu is open.
1287
+
1288
+ JSDoc of `menu-item.directive.ts` for the API reference: `@summary`/`@since` on the exported
1289
+ symbols; `@binding` docs for the host-directive bindings `bfMenuItemDisabled`,
1290
+ `bfMenuItemTypeahead`, `triggered`, `checked`. No behaviour change.
1291
+
1292
+ JSDoc of `menu-trigger.directive.ts` for the API reference: `@summary`/`@since` on the exported
1293
+ symbols; `@binding` docs for the host-directive bindings `bfMenuTriggerFor`, `bfMenuTriggerData`,
1294
+ `bfMenuOpened`, `bfMenuClosed`. No behaviour change.
1295
+
1296
+ JSDoc of `menu.component.ts` for the API reference: `@slot` docs for `default`. No behaviour change.
1297
+
1298
+ The two effects that write into the CDK host directives (`bfMenuItemCheckbox`'s `checked`,
1299
+ `bfMenuTrigger`'s `menuPosition` and `ngOnChanges`) carry an `effect-justified` comment: the CDK
1300
+ exposes no input they could derive from. No behaviour change.
1301
+
1302
+ ### menu-button
1303
+
1304
+ New `bf-menu-button` in `@bitforgehq/ui/overlay`: a native `<button>` that opens a `bf-menu`
1305
+ (`menu`, a `TemplateRef`) with `aria-haspopup="menu"` and `aria-expanded`, styled like
1306
+ `bf-button` (`variant` `outline` (default), `text`, `tonal`; a CSS caret) or, with `iconOnly`,
1307
+ like `bf-icon-button`. `size` (unset follows `controlSize`), `placement`, `label` (the
1308
+ `aria-label`, required with `iconOnly`), `disabled`, and `opened`/`closed` outputs. Its classes
1309
+ come from `bf-button`'s and `bf-icon-button`'s own class maps (internal `ɵ` exports of
1310
+ `@bitforgehq/ui`), so the looks never drift.
1311
+
1312
+ JSDoc of `menu-button.component.ts` for the API reference: `@summary`/`@since` on the exported
1313
+ symbols; `@slot` docs for `icon-start`, `default`. No behaviour change.
1314
+
1315
+ **Behaviour:** a pressed state (F-045): the button and icon-only forms take `bf-button`'s and `bf-icon-button`'s pressed fills from their shared class maps. None while disabled; under forced colours the system colours win.
1316
+
1317
+ ### toast
1318
+
1319
+ The toast engine (timing, hold, replace, focus return, announcements, teardown) moved into
1320
+ `@bitforgehq/ui/overlay`, shared with the new desktop toast; `BfToastService` renders one toast at
1321
+ a time on it exactly as before. `BfToastRef`, `BfToastDismissReason` and `BfToastAction` are now
1322
+ also exported from `@bitforgehq/ui/overlay`; the `/mobile` exports stay (not deprecated) and are
1323
+ the same types and class. No behaviour change: a navigation never closes a toast (it opts out of
1324
+ the core's new `closeOnNavigation`).
1325
+
1326
+ JSDoc of `toast.component.ts` for the API reference: `@summary`/`@since` on the exported symbols. No
1327
+ behaviour change.
1328
+
1329
+ JSDoc of `toast.service.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1330
+ `@default` on the option properties the code applies; `@param` docs. No behaviour change.
1331
+
1332
+ **Behaviour:** `--bf-toast-keyboard` is written in the render phase (`afterRenderEffect`) instead
1333
+ of an `effect`; internal timing only, no API change.
1334
+
1335
+ ### overlay entry
1336
+
1337
+ New secondary entry `@bitforgehq/ui/overlay`: the overlay core with no Capacitor and no router
1338
+ dependency (peers `@angular/cdk` and `rxjs`). `BfOverlayService`, `BfOverlayRef`,
1339
+ `BfComponentOverlayRef`, `BF_OVERLAY_DATA`, `BfOverlayStack`, their option types and the
1340
+ animations (`fadeIn`, `fadeOut`, `fadeUp`, `fadeDown`, `scaleIn`, `slideUp`, `slideDown`) moved
1341
+ here from `@bitforgehq/ui/mobile`, joined by `BF_OVERLAY_HOST`/`BfOverlayHost`,
1342
+ `OVERLAY_REDUCED_DURATION`, `prefersReducedMotion` (still exported from `/mobile` too) and
1343
+ `BfOverlayController` (for entries that extend `BfOverlayRef`). `/mobile` builds its sheet, modal,
1344
+ toast, action sheet and alert dialog on it.
1345
+
1346
+ **Type:** the `/mobile` exports of the moved names are deprecated aliases of the `/overlay` ones
1347
+ (the same classes, tokens and functions), removed in 0.4.0. Migration: import them from
1348
+ `@bitforgehq/ui/overlay` instead of `@bitforgehq/ui/mobile`.
1349
+
1350
+ **Migration — every moved core import:**
1351
+
1352
+ | Old (`@bitforgehq/ui/mobile`, deprecated) | New (`@bitforgehq/ui/overlay`) |
1353
+ | ----------------------------------------- | ------------------------------ |
1354
+ | `BF_OVERLAY_DATA` | `BF_OVERLAY_DATA` |
1355
+ | `BfComponentOverlayRef` | `BfComponentOverlayRef` |
1356
+ | `BfOverlayOptions` | `BfOverlayOptions` |
1357
+ | `BfOverlayPosition` | `BfOverlayPosition` |
1358
+ | `BfOverlayRef` | `BfOverlayRef` |
1359
+ | `BfOverlayService` | `BfOverlayService` |
1360
+ | `BfOverlayStack` | `BfOverlayStack` |
1361
+ | `BfOverlayStackOptions` | `BfOverlayStackOptions` |
1362
+ | `BfOverlayTemplateContext` | `BfOverlayTemplateContext` |
1363
+ | `BfStackedOverlay` | `BfStackedOverlay` |
1364
+ | `OVERLAY_DURATION` | `OVERLAY_DURATION` |
1365
+ | `OVERLAY_EASING` | `OVERLAY_EASING` |
1366
+ | `fadeDown` | `fadeDown` |
1367
+ | `fadeIn` | `fadeIn` |
1368
+ | `fadeOut` | `fadeOut` |
1369
+ | `fadeUp` | `fadeUp` |
1370
+ | `scaleIn` | `scaleIn` |
1371
+ | `slideDown` | `slideDown` |
1372
+ | `slideUp` | `slideUp` |
1373
+
1374
+ `/mobile` keeps these as deprecated aliases (same class/token/function identity) until 0.4.0; new
1375
+ code imports them from `@bitforgehq/ui/overlay` directly. `prefersReducedMotion` and
1376
+ `OVERLAY_REDUCED_DURATION` are new in `/overlay` and stay exported from `/mobile` too (not
1377
+ deprecated — no import change needed). `BfToastRef`, `BfToastDismissReason` and `BfToastAction`
1378
+ also moved, but `/mobile`'s export of them is the same class/type, not deprecated (see `toast`
1379
+ above). An app that adds `@bitforgehq/ui/overlay` installs `@angular/cdk` and `rxjs` (peers of
1380
+ `/overlay`); `/mobile` needs those plus `@angular/router` and `@capacitor/core` (unchanged).
1381
+
1382
+ **Behaviour changes bundled with the move** (each detailed under its own `###` above; one line
1383
+ each here for a single migration pass):
1384
+
1385
+ - Outside a shell (no `provideBfShell()`), the page behind an open non-passive overlay is now
1386
+ `inert` (`### overlay core`, Migration 20).
1387
+ - The hardware back button now reaches overlays only through the optional `BF_OVERLAY_HOST`
1388
+ token, provided by `provideBfShell()`; without it, no back handler is registered
1389
+ (`### overlay core`, Migration 21).
1390
+ - Overlays close by default when the URL path changes (`closeOnNavigation`, default true; toasts
1391
+ never close) (`### overlay core`, Migration 19).
1392
+ - `bf-alert`'s role follows the new `urgent` input, not `variant`: add `urgent` to a danger alert
1393
+ that must interrupt (`### alert`, Migration 15).
1394
+ - `bf-alert`'s dismiss button is named after the alert's title, not a bare "Dismiss" (`### alert`,
1395
+ Migration 16).
1396
+ - Alert dialogs and action sheets focus the `cancel`-role button first, not the first button
1397
+ (`### alert-dialog`, `### action-sheet`, Migration 17).
1398
+ - `BfAlertDialogService.open()` returns `Promise<string | undefined>` (was `Promise<string>`): a
1399
+ navigation may resolve `undefined` when there is no cancel button (`### alert-dialog`, Migration
1400
+ 18).
1401
+
1402
+ ### testing entry
1403
+
1404
+ New secondary entry `@bitforgehq/ui/testing` (additive), for consumer specs under Vitest (the
1405
+ Angular CLI default) or Jest. It imports only `@angular/core`, `@angular/cdk/testing` and
1406
+ `@bitforgehq/ui` (never `/overlay`, `/mobile` or Capacitor; lint- and consumer-smoke-checked), so
1407
+ it needs `@angular/cdk` as a dev dependency; the CDK stays an optional peer.
1408
+
1409
+ - `installBfJsdomShims(options?)` (`jsdom-shims.ts`) installs, only where missing,
1410
+ `Element.prototype.animate` (an `Animation` whose `finished` resolves at once), `PointerEvent`
1411
+ (a `MouseEvent` with `pointerId`, `isPrimary`, `pointerType`, `width`, `height`, `pressure`)
1412
+ and a `matchMedia` stub (every query false; `reducedMotion: true` reports
1413
+ `(prefers-reduced-motion: reduce)` as matching; `matchMedia: false` skips it). It is idempotent
1414
+ and returns a function that removes exactly what it installed (a replaced global is restored
1415
+ from its descriptor, getters included; a global it cannot set is left alone). The shims moved here from the
1416
+ library's own `setup-jest.ts`; the library, docs, web-demo and mobile-demo Jest setups now call
1417
+ it. The shimmed `Animation` is a small class implementing the interface in full (no cast, the
1418
+ same shape as `overlay-animations.ts`'s own `BfFinishedAnimation`); its `finished` resolves to
1419
+ the animation itself, matching the real Web Animations API, not `undefined` as before.
1420
+ - `provideBfTesting({ defaults?, labels? })` (`provide-bf-testing.ts`) passes the options to
1421
+ `provideBfDefaults()`/`provideBfLabels()` and, when the test environment is torn down, removes
1422
+ `dark` and `cdk-global-scrollblock` from `<html>`, `inert` from the children of `<body>` and every
1423
+ child of a `.cdk-overlay-container`. The cleanup needs TestBed's default
1424
+ `teardown: { destroyAfterEach: true }`.
1425
+ - CDK component harnesses (`harnesses/*.harness.ts`), each with `static with({ label })` (the
1426
+ shared `BfHarnessFilters`, the CDK's `selector`/`ancestor` plus `label`): `BfSelectHarness`
1427
+ (label, value label, option labels, `selectOption(label)`, disabled, read-only, required,
1428
+ error and help text, focus, blur), `BfRadioGroupHarness` and `BfSegmentedHarness` (label, option
1429
+ labels, selected label, `select(label)`, disabled, error text), `BfDialogHarness` (title, text,
1430
+ role, `clickButton(label)`, `close()`, `pressEscape()`; through
1431
+ `TestbedHarnessEnvironment.documentRootLoader()`), `BfMenuButtonHarness` (`open()`, `isOpen()`,
1432
+ `getMenu()`), `BfMenuHarness` (`getItems(filter?)`, `clickItem(label, ...submenuPath)`),
1433
+ `BfMenuItemHarness` (text, disabled, checked, submenu, `click()`, `getSubmenu()`) and
1434
+ `BfDesktopToastHarness` (message, `clickAction()`, `pressEscape()`). They work by label, so a
1435
+ spec never needs the native option values of the typed choice controls. Choosing in a disabled
1436
+ select, or a disabled option, segment, radio group or segmented control, throws and leaves the
1437
+ value alone, as a user's click would. `BfDialogHarness.getRole()` reads the dialog's own panel
1438
+ (found through the host's `data-bf-dialog-id`), `null` for a `bf-dialog` rendered in the page. Plain native controls
1439
+ (input, textarea, checkbox, toggle, button) get none: query their native element.
1440
+
1441
+ ### provide-bf-shell-testing
1442
+
1443
+ New in `@bitforgehq/ui/mobile` (additive): `provideBfShellTesting({ routes?, config? })`, the test
1444
+ providers for an app on the shell: `provideRouter(routes ?? [], withComponentInputBinding(),
1445
+ withBfShellRouting())`, `provideBfShell({ swipeBack: false, hapticsOnTabChange: false, ...config })`,
1446
+ an in-memory `BF_THEME_STORAGE` (a theme change never reaches `localStorage`) and the same document
1447
+ cleanup as `provideBfTesting()` (which needs TestBed's default `destroyAfterEach: true`). It lives
1448
+ in `/mobile` so that `/testing` never loads Capacitor.
1449
+
1450
+ ### tabs
1451
+
1452
+ **Breaking:** (ARIA contract) `bf-tabs` provides `BF_TAB_LINKS`, so the `bf-tab-bar` projected
1453
+ directly into it is a `nav` of links with `aria-current` (`page` at the tab's root, `true` on a
1454
+ child page) instead of a `tablist` (the tabs switch routes, not in-page panels); an in-page bar on
1455
+ a tab's page keeps its `tablist`. New `href(id)`: the tab's root URL, `/<tabsPath>/<id>` through
1456
+ `LocationStrategy` (base href included), used as each tab's `href`, so Ctrl/Cmd-click opens a tab
1457
+ in a new browser tab; a plain click still restores the tab's last page. New `isRoot(id)`: whether
1458
+ the current URL is that root. Tab moves between tabs (in Safari and iOS WKWebView only with "Press
1459
+ Tab to highlight each item", else Option+Tab); the arrow keys no longer do (they stopped after one
1460
+ step before). See Migration 11.
1461
+
1462
+ JSDoc of `tab-context.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1463
+ `@param` docs. No behaviour change.
1464
+
1465
+ JSDoc of `tabs.component.ts` for the API reference: `@slot` docs for `default`; `@param` docs. No
1466
+ behaviour change.
1467
+
1468
+ **Behaviour:** the bar is driven through `BF_TAB_BAR_CONTROLLER` instead of written into (F-021).
1469
+ `current()` (read-only) is the tab of the in-flight navigation's target after redirects once they
1470
+ are applied (`router.currentNavigation()`), else of
1471
+ the settled URL (`router.lastSuccessfulNavigation()`), keeping the last tab while neither is under
1472
+ `tabsPath`: a clicked tab shows at once and a navigation a guard refuses drops back by itself.
1473
+ `hidden()` is `keyboard.visible() || keyboard.imminent()`, `hideTransition()` is true on iOS, and
1474
+ `select(id)`/`reselect(id)` are the former `changed`/`reselected` handlers. `tabKey()` and
1475
+ `isRoot(id)` read the router's navigation signals; the `router.events` subscription and the three
1476
+ effects that wrote into the bar are gone. `--bf-tab-bar-inset` (and `--bf-tab-bar-inset-global` on
1477
+ `<html>`) is written in the render phase (`afterRenderEffect`). See Migration 24.
1478
+
1479
+ **Behaviour:** `--bf-tab-bar-inset` (and `--bf-tab-bar-inset-global`) use the bar's measured height
1480
+ instead of the fixed 4 rem (F-051, F-054): an `afterRenderEffect` observes the projected bar's
1481
+ `nav` with a `ResizeObserver` and writes
1482
+ `calc(<height>px + 8px + max(var(--spacing-safe-bottom), 12px) + 8px)`, or `0px` while the bar is
1483
+ hidden; until the first measurement it uses the bar's 64 px minimum. The observer is disconnected on
1484
+ destroy. So the page's bottom padding follows a bar grown by large text or slimmed at compact
1485
+ heights.
1486
+
1487
+ ### action
1488
+
1489
+ New `BfActionDirective` (`a[bfAction], button[bfAction]`): marks the one interactive element of a
1490
+ `bf-card` or `bf-list-item` (`data-bf-action`) and stretches its hit area over the card or row with
1491
+ an empty `::after` (`absolute inset-0`), the stretched-link pattern, and gives a `button` the
1492
+ pointer cursor a link has. It registers with the nearest card or row (an internal token), which
1493
+ draws the pointer, hover and focus ring for its own action only. Under forced colours a
1494
+ transparent outline on the `::after` becomes a system-coloured focus indicator around the whole
1495
+ card or row (the ring, a box-shadow, is dropped there). See `card` and `list-item`.
1496
+ Additive.
1497
+
1498
+ JSDoc of `action.directive.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1499
+ template handlers and framework hooks marked `@internal`, so the reference leaves them out. No
1500
+ behaviour change.
1501
+
1502
+ ### badge
1503
+
1504
+ **Behaviour:** the label and the count pill use `rounded-control` (`--radius-control`, `9999px` by
1505
+ default, so they render as before).
1506
+
1507
+ JSDoc of `badge.component.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1508
+ `@slot` docs for `default`. No behaviour change.
1509
+
1510
+ New `countLabel = input('')` (F-039): with `count` set, it renders visually hidden after the
1511
+ number (`<span class="sr-only">`), so the pill reads "99+ unread messages". Ignored without
1512
+ `count`.
1513
+
1514
+ ### field
1515
+
1516
+ **Behaviour:** the label of a focused field is `primary-text` (was `primary`).
1517
+
1518
+ New export `bfFieldControlClass(state: { size: BfSize; invalid: boolean; disabled: boolean;
1519
+ readonly: boolean }): string` (and its `BfFieldControlState` type) from `@bitforgehq/ui`: the class
1520
+ list `bf-input` renders on its native `<input>` in the same state (box, height and type per size,
1521
+ padding, focus ring, `border-strong` or, while `invalid`, `danger-border`), so a custom control
1522
+ inside `bf-field` looks like the library's own fields without copying its classes. The disabled
1523
+ and read-only styles come from the control's native `disabled`/`readonly` attributes. `bf-input`
1524
+ builds its own list from the same pieces, and a spec asserts the two agree for every size and
1525
+ state. The field docs and the `@example` bind the control's `aria-describedby` to the field's
1526
+ `describedBy()` through a template reference (`#field`); the new `field/custom-control` example
1527
+ does so for a native input styled by `bfFieldControlClass()`.
1528
+
1529
+ JSDoc of `field-classes.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1530
+ `@param` docs. No behaviour change.
1531
+
1532
+ JSDoc of `field.component.ts` for the API reference: `@slot` docs for `default`. No behaviour
1533
+ change.
1534
+
1535
+ **Behaviour:** the help paragraph renders always (when set) and the error paragraph under it;
1536
+ help text no longer disappears on error (F-020). `describedBy()` lists both ids,
1537
+ `"<controlId>-help <controlId>-error"`, `"<controlId>-help"`, `"<controlId>-error"` or `null`. A new
1538
+ `error` that arrives while a pointer button is held down anywhere in the page shows one task after
1539
+ the release (or at once on a key press, or a pointer move with no button down): the press on a
1540
+ Save button blurs the field and marks it touched on mousedown, and the message pushed Save about
1541
+ 22 px down before the mouseup, so the click was lost in every browser. The field holds its layout
1542
+ through the press instead of reserving an empty line under every field. See Migration 25.
1543
+
1544
+ `fieldDescribedBy(id, errorText, helpText)` (internal, `ɵfieldDescribedBy`) returns
1545
+ `"<id>-help <id>-error"`, `"<id>-help"`, `"<id>-error"` or `null`; the new internal
1546
+ `ɵfieldErrorMessageId` gives the controls' `aria-errormessage`.
1547
+
1548
+ While a press holds a new error back, its paragraph is already rendered, visually hidden
1549
+ (`sr-only`), so the controls' `aria-describedby` and `aria-errormessage` always point at an element
1550
+ that exists and assistive tech reads the error when focus returns to the field; the same paragraph
1551
+ turns visible on release. Only the primary mouse button, a touch or a pen starts a hold (a
1552
+ right-click never does). The self-rendering controls (checkbox, toggle, radio group, segmented)
1553
+ behave the same way.
1554
+
1555
+ ### progress
1556
+
1557
+ **Behaviour:** the default `primary` tone fills with `primary-strong` (was `primary`), so the fill
1558
+ reaches 3:1 on its `surface-sunken` track for any configured primary.
1559
+
1560
+ JSDoc of `progress.component.ts` for the API reference: `@summary`/`@since` on the exported symbols.
1561
+ No behaviour change.
1562
+
1563
+ **Behaviour:** `value` is optional (`input<number | undefined>(undefined)`, was required); without
1564
+ it and without `indeterminate` the bar renders 0 %. The redundant `role="progressbar"` attribute on
1565
+ the native `<progress>` is dropped (it is a progressbar natively). New `valueText = input('')`,
1566
+ rendered as `aria-valuetext` when set (F-039). New `indeterminate` (boolean): the native bar
1567
+ renders without `value` (indeterminate for assistive tech) and a 40 % stripe in the tone's fill
1568
+ runs across the track (`animate-progress-indeterminate`), standing still at the start under
1569
+ `motion-reduce:`. The host is a one-cell `grid` (was `block`) with `overflow-hidden
1570
+ rounded-full`, so the stripe shares the track's cell and stays in it.
1571
+
1572
+ The indeterminate stripe runs from the inline start: under `dir="rtl"` it takes
1573
+ `--animate-progress-indeterminate-rtl` (mirrored keyframes), and stands still under reduced motion
1574
+ in both directions.
1575
+
1576
+ ### icon
1577
+
1578
+ JSDoc of `icon.component.ts` for the API reference: `@summary`/`@since` on the exported symbols. No
1579
+ behaviour change.
1580
+
1581
+ **Behaviour:** `bf-icon` binds `[attr.fill]` on every element it renders (F-065), so the filled
1582
+ details of Lucide icons show (`Palette`'s dots and 9 other distinct icons); elements without a Lucide
1583
+ `fill` get no attribute and inherit the `none` of the `<svg>`. `tools/lucide-attributes.test.ts`
1584
+ fails when an installed Lucide icon sets an attribute the renderer does not bind. See Migration 27.
1585
+
1586
+ ### content
1587
+
1588
+ New exported `BfContentScrollEvent` (`{ scrollTop: number }`), the payload of `scrolled` (was an
1589
+ inline object type of the same shape).
1590
+
1591
+ JSDoc of `content.component.ts` for the API reference: `@slot` docs for `default`; `@param` docs. No
1592
+ behaviour change.
1593
+
1594
+ **Behaviour:** `--bf-header-height` is written in the render phase (`afterRenderEffect`), and the
1595
+ scroller registers in `BfPageContext` once after the first render (`afterNextRender`) instead of
1596
+ from an `effect`; it is still cleared on destroy. Internal timing only, no API change.
1597
+
1598
+ `fullscreen` pads the top safe area only while the page's `BfPageContext.safeAreaTop()` is true;
1599
+ inside a page-sheet `bf-modal` (false) it adds no top padding (Migration 29).
1600
+
1601
+ **Behaviour:** in the page a `bf-stack-outlet` shows, the scroller is the `main` landmark
1602
+ (`role="main"`, while `BfPageContext.mainLandmark()` is true). While it overflows and holds nothing
1603
+ focusable it is a tab stop (`tabindex="0"`, with an inset focus ring), so a keyboard can scroll it
1604
+ in Safari and WKWebView (which never focus a scroll container on their own; Chromium does); a
1605
+ control appearing inside takes it out of the Tab order again (re-checked per frame on resize and
1606
+ content changes, including `hidden` and `inert` toggles). Hidden, inert and unrendered controls do
1607
+ not count, and overflow means more than 2 px (the iOS body's 1 px rubber-band extra is not
1608
+ overflow). See Migration 36.
1609
+
1610
+ ### header
1611
+
1612
+ JSDoc of `header.component.ts` for the API reference: `@slot` docs for `start`, `default`, `end`. No
1613
+ behaviour change.
1614
+
1615
+ The toolbar pads the top safe area (`pt-safe-top`) only while the page's
1616
+ `BfPageContext.safeAreaTop()` is true; inside a page-sheet `bf-modal` (false) it adds none
1617
+ (Migration 29).
1618
+
1619
+ **Behaviour:** large text (F-051). The bar has a minimum height instead of a fixed one
1620
+ (`min-h-11` in iOS mode, `min-h-16` in Material mode) and grows with its content; the header
1621
+ measures the bar title as `--bf-header-title-height` (the same `ResizeObserver` pass as the slot
1622
+ insets) and keeps the bar at least that tall, since the centred iOS title box is absolutely
1623
+ positioned. The bar title wraps to at most two lines (`line-clamp-2 wrap-anywhere`, centred in iOS
1624
+ mode) instead of truncating. The `start` slot may shrink (`min-w-0 shrink`) and the `end` slot
1625
+ never does (`shrink-0`), so actions stay whole. In iOS mode, where the centred title is taken out
1626
+ of the row's flow, the `start` slot is capped by `--bf-header-start-max` only when the title does
1627
+ not fit on one line beside the slots at their full width: the title may then claim up to half the
1628
+ bar, and only a back button's text (`bf-back-button`'s `[data-bf-back-text]`) gives way. The cap
1629
+ is never below the slot's other content or 44 px, so two start buttons stay whole, and the title
1630
+ inset reads the slot's `scrollWidth` too. It is measured from the bar's width, a hidden one-line
1631
+ copy of the title and the slot's full and minimum widths, so it never depends on the capped slot
1632
+ itself. A projected title (no `title` input) gets no cap. The large title's font and line height are capped
1633
+ at 200 % (`min(var(--text-display), 80px)`, `min(var(--text-display--line-height), 88px)`), in px
1634
+ because iOS Dynamic Type scales the root.
1635
+
1636
+ **Behaviour:** the collapse range is the large title block's rendered height, bounded to 44–200 px
1637
+ (was a fixed 56 px, while the iOS large title renders about 70 px; a block taller than 200 px
1638
+ collapses its whole height over 200 px of scroll), re-read whenever it resizes; the snap goes to
1639
+ it; `data-bf-scrolled` switches on at full collapse, as before, now at that height (with 0.5 px of
1640
+ tolerance for a fractional snap landing). See Migration 31.
1641
+
1642
+ **Behaviour:** compact heights (F-054). Under `(max-height: 500px)` (phone landscape, split
1643
+ screen; read with `matchMedia` and followed live, the listener removed on destroy) the header
1644
+ renders no large title: the title is the bar's `h1`, the bar is at least 44 px in both modes and
1645
+ nothing collapses or snaps.
1646
+
1647
+ **Behaviour:** without a large title (`largeTitle` false, or a compact height) the scroll never
1648
+ moves `--bf-header-progress` and never snaps; a detail page used to snap to 0 or 56 px when a
1649
+ scroll stopped in its first 56 px.
1650
+
1651
+ ### keyboard-assist
1652
+
1653
+ New exported `BfKeyboardAssistBehavior` (`'smooth' | 'instant'`), the type of
1654
+ `bfKeyboardAssistBehavior` (was an inline union with the same values).
1655
+
1656
+ JSDoc of `keyboard-assist.directive.ts` for the API reference: `@summary`/`@since` on the exported
1657
+ symbols. No behaviour change.
1658
+
1659
+ **Behaviour:** the paddings are measured in the render phase's early read and written in its write
1660
+ phase (`afterRenderEffect`) instead of an `effect`, and the settled correction runs after that
1661
+ render's write (measured in the early read phase, scrolled in the write phase, and only while the
1662
+ keyboard is still shown, also after a deferral); the will-show and settled reactions stay
1663
+ `effect`s, since they schedule timers. Internal timing only, no API change.
1664
+
1665
+ ### overscroll-stretch
1666
+
1667
+ JSDoc of `overscroll-stretch.directive.ts` for the API reference: `@summary`/`@since` on the
1668
+ exported symbols. No behaviour change.
1669
+
1670
+ ### motion
1671
+
1672
+ JSDoc of `motion.ts` for the API reference: `@summary`/`@since` on the exported symbols. No
1673
+ behaviour change.
1674
+
1675
+ ### horizontal-scroll
1676
+
1677
+ JSDoc of `horizontal-scroll.directive.ts` for the API reference: `@summary`/`@since` on the exported
1678
+ symbols. No behaviour change.
1679
+
1680
+ **Deprecated:** `bfHorizontalScroll` (`BfHorizontalScrollDirective`) is not needed; removed in
1681
+ 0.4.0. `touch-action` is not inherited, and a touch intersects the values only up to its nearest
1682
+ scroll container, so an `overflow-x-auto` row pans on its own under the outlet's
1683
+ `touch-action: pan-y`, as a device check on the iOS Simulator and the Android emulator confirmed
1684
+ (both scrolled without the directive). The docs and JSDoc now say so; the directive keeps setting
1685
+ `touch-action: pan-x pan-y` and `data-bf-horizontal-scroll` until it is removed. See Migration 37.
1686
+
1687
+ ### nav-controller
1688
+
1689
+ JSDoc of `nav-controller.service.ts` for the API reference: `@summary`/`@since` on the exported
1690
+ symbols; `@param` docs; template handlers and framework hooks marked `@internal`, so the reference
1691
+ leaves them out. No behaviour change.
1692
+
1693
+ The `router.events` subscription says in a comment why it stays: the direction hint belongs to one
1694
+ navigation id's start and end. No behaviour change.
1695
+
1696
+ ### page
1697
+
1698
+ JSDoc of `page-context.ts` for the API reference: `@summary`/`@since` on the exported symbols. No
1699
+ behaviour change.
1700
+
1701
+ JSDoc of `page.component.ts` for the API reference: `@slot` docs for `default`. No behaviour change.
1702
+
1703
+ `BfPageContext` gains `safeAreaTop` (a `signal(true)`): whether the page reaches the top of the
1704
+ screen, so its header and a `fullscreen` content pad the top safe area. `bf-modal`'s page-sheet
1705
+ presentation sets it to false.
1706
+
1707
+ `BfPageContext` gains `mainLandmark` (a `signal(true)`): whether the page's `bf-content` is the
1708
+ `main` landmark. A `bf-page` outside a `bf-stack-outlet` (a docs illustration) or nested in
1709
+ another page sets it to false, so the document keeps one `main` (Migration 36).
1710
+
1711
+ ### stack-outlet
1712
+
1713
+ **Deprecated:** `stackChange` → `stackChanged`, `transitionStart` → `transitionStarted` and
1714
+ `transitionEnd` → `transitionEnded`. The new outputs emit the same `BfStackChange` and
1715
+ `BfStackTransitionEvent` objects at the same moments; the old names still emit them right after
1716
+ until 0.4.0 (see Migration 23).
1717
+
1718
+ JSDoc of `stack-outlet.component.ts` for the API reference: `@summary`/`@since` on the exported
1719
+ symbols; `@param` docs; template handlers and framework hooks marked `@internal`, so the reference
1720
+ leaves them out. No behaviour change.
1721
+
1722
+ Setup in `docs.md`: `provideBfShell()` provides the configuration and the reuse strategy and starts
1723
+ the platform services; `BfNavController` and the overlay services are root-provided and
1724
+ tree-shakable.
1725
+
1726
+ The `router.events` subscription says in a comment why it stays: the stack pairs each navigation
1727
+ id's start with its own end. No behaviour change.
1728
+
1729
+ **Behaviour:** the iOS edge swipe-back arms only for `pointerType` `touch`: a mouse drag or a
1730
+ text selection from the left edge, and a pen, never pop the page (a desktop web page with the mode
1731
+ forced to `ios`). See Migration 39.
1732
+
1733
+ New `maxLivePages` (through `provideBfShell()`, default `Infinity`): above it, the outlet destroys
1734
+ the components of the deepest pages of a stack and keeps a record of each (route, key, history
1735
+ position, navigation id, URL and the `bf-content` scroll position); the top `maxLivePages` pages
1736
+ stay alive. A pop back to such a page creates it again from the re-activated route in a new frame,
1737
+ restores its scroll after the first render and runs the pop transition and lifecycle. Deep pages
1738
+ come back fresh: nested outlets inside a destroyed page lose their stacks. With `maxLivePages: 1`
1739
+ the swipe-back does not arm (the page below is a record). The deprecated `stackChange`,
1740
+ `transitionStart` and `transitionEnd` keep emitting after their replacements.
1741
+
1742
+ The host's `touch-action: pan-y` comment and the docs follow the Pointer Events model: page
1743
+ scrollers, horizontal rows included, keep their own panning (see `### horizontal-scroll`).
1744
+
1745
+ ### stack-reuse-strategy
1746
+
1747
+ JSDoc of `stack-reuse-strategy.ts` for the API reference: `@summary`/`@since` on the exported
1748
+ symbols; `@param` docs. No behaviour change.
1749
+
1750
+ ### transitions
1751
+
1752
+ JSDoc of `transitions.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1753
+ `@param` docs. No behaviour change.
1754
+
1755
+ ### view-lifecycle
1756
+
1757
+ JSDoc of `view-lifecycle.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1758
+ `@param` docs; template handlers and framework hooks marked `@internal`, so the reference leaves
1759
+ them out. No behaviour change.
1760
+
1761
+ ### haptics
1762
+
1763
+ JSDoc of `haptics.service.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1764
+ `@param` docs. No behaviour change.
1765
+
1766
+ ### keyboard
1767
+
1768
+ JSDoc of `keyboard.service.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1769
+ `@param` docs. No behaviour change.
1770
+
1771
+ ### platform
1772
+
1773
+ JSDoc of `platform.service.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1774
+ `@param` docs. No behaviour change.
1775
+
1776
+ New token `BF_THEME_STORAGE` (`InjectionToken<Pick<Storage, 'getItem' | 'setItem'>>`, additive):
1777
+ `BfPlatformService` reads and writes the theme choice (key `bf-theme`) through it. Its default is
1778
+ `localStorage` when available, otherwise an in-memory map (before: every read and write went to
1779
+ `localStorage` directly and was skipped where it threw). Provide another store to keep the choice
1780
+ elsewhere; `provideBfShellTesting()` provides an in-memory one.
1781
+
1782
+ **Behaviour:** `BfPlatformService` applies the `dark` class on `<html>` and stores the theme
1783
+ synchronously at construction, so a freshly provided shell has the class before any change
1784
+ detection; the `effect` only keeps later changes in sync. No API change.
1785
+
1786
+ **Behaviour:** on native iOS and Android with `provideBfShell()` (and `syncSystemBars` not
1787
+ `false`), `BfPlatformService` calls Capacitor's `SystemBars.setStyle()` at launch and on every
1788
+ change of `dark()` (and of the system theme), so the bar icons stay readable in the in-app theme;
1789
+ a failure goes to `console.error` (Migration 30). New exported `bfSystemBarsStyle(dark, platform,
1790
+ webViewMajor)`: `Dark` (light icons) for a dark theme, else `Light`, and `Default` on Android
1791
+ WebView < 140 (parsed from `Chrome/<n>` in the user agent), which pads the page below the bars so
1792
+ that they show the activity's window background, which follows the system theme. Give the window
1793
+ the page colour (`android:windowBackground`, see the `/mobile` guide) so the bars match the page
1794
+ when both themes agree. The WebView ≥ 140 case assumes `viewport-fit=cover` and the default
1795
+ `SystemBars.insetsHandling: 'css'`.
1796
+
1797
+ ### text-scale
1798
+
1799
+ JSDoc of `text-scale.service.ts` for the API reference: `@summary`/`@since` on the exported symbols.
1800
+ No behaviour change.
1801
+
1802
+ ### shell
1803
+
1804
+ New exported `BfShellConfigMode` (`'ios' | 'md' | 'auto'`), `BfShellDynamicType`
1805
+ (`false | BfShellDynamicTypeLimit`) and `BfShellDynamicTypeLimit` (`{ maxScale: number }`), the
1806
+ types of `BfShellConfig.mode` and `dynamicType` (were inline types with the same values).
1807
+
1808
+ JSDoc of `provide-bf-shell.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1809
+ `@param` docs. No behaviour change.
1810
+
1811
+ JSDoc of `shell-config.ts` for the API reference: `@summary`/`@since` on the exported symbols;
1812
+ `@default` on the option properties the code applies. No behaviour change.
1813
+
1814
+ **Behaviour:** `provideBfShell()` lists only the shell config, the route reuse strategy,
1815
+ `BF_OVERLAY_HOST` and the environment initializer (which still starts the platform, back-button,
1816
+ keyboard, haptics and text-scale services). `BfNavController`, the platform services and the
1817
+ overlay services (`BfOverlayStack`, `BfOverlayService`, `BfSheetService`, `BfModalService`,
1818
+ `BfToastService`, `BfActionSheetService`, `BfAlertDialogService`) are root-provided and
1819
+ tree-shakable, so a shell app that never injects an overlay service bundles no overlay code (the
1820
+ consumer smoke's new `mobile-shell` build holds no `cdk-overlay-pane` or `cdk-overlay-backdrop`
1821
+ string). Under
1822
+ `provideBfShell()` in a lazy route's environment injector, the overlay services now resolve from
1823
+ the root injector (Migration 22).
1824
+
1825
+ New `BfShellConfig.syncSystemBars` (default `true`; also in `BF_SHELL_DEFAULTS`): the native
1826
+ system bars follow the in-app theme (see `### platform`); `false` leaves them to the app.
1827
+
1828
+ New `BfShellConfig.backAtRoot?: BfBackAtRoot` (`'minimize' | 'exit'`, default `'minimize'`; new
1829
+ exported type; also in `BF_SHELL_DEFAULTS`): what the Android back button does when nothing can go
1830
+ back. See `### back-button service` and Migration 34.
1831
+
1832
+ New `BfShellConfig.maxLivePages?: number` (default `Infinity`, also in `BF_SHELL_DEFAULTS`): how
1833
+ many pages of each stack stay alive in every `bf-stack-outlet` (see `### stack-outlet`). No
1834
+ behaviour change by default.
1835
+
1836
+ `provideBfShell()`'s `BF_OVERLAY_HOST` sets `handlesSystemBack` on native Android, where
1837
+ Capacitor's `backButton` event already reaches the overlays, so they create no `CloseWatcher`
1838
+ there.
1839
+
1840
+ ### back-button service
1841
+
1842
+ **Behaviour:** when no handler claims the back press (every shown stack at its first page),
1843
+ `BfBackButtonService` calls `App.minimizeApp()`, so the app moves to the background and a
1844
+ relaunch resumes warm; with `provideBfShell({ backAtRoot: 'exit' })` it calls `App.exitApp()` as
1845
+ before. It reads `BF_SHELL_CONFIG` (optional; `'minimize'` without it), and a failure goes to
1846
+ `console.error`. See Migration 34.
1847
+
1848
+ ### toast-ref
1849
+
1850
+ JSDoc of `toast-ref.ts` for the API reference: `@summary`/`@since` on the exported symbols. No
1851
+ behaviour change.
1852
+
1853
+ ### choice
1854
+
1855
+ JSDoc of `choice.ts` for the API reference: `@summary`/`@since` on the exported symbols. No
1856
+ behaviour change.
1857
+
1858
+ ### avatar
1859
+
1860
+ JSDoc of `avatar.component.ts` for the API reference: `@summary`/`@since` on the exported symbols.
1861
+ No behaviour change.
1862
+
1863
+ **Behaviour:** the circle is at least 4.5ex of its initials (`min-w-[4.5ex] min-h-[4.5ex]`), so at
1864
+ Android font scale 2.0 (a text zoom that doubles the rendered initials, and `ex` with them, but not
1865
+ em, rem or px lengths) it grows with
1866
+ them instead of cutting them off, e.g. the `sm` avatar in a header's `end` slot (F-051). At the
1867
+ default size and with iOS Dynamic Type every size is larger than that minimum (with Manrope: sm
1868
+ 30 px against 26.7, md 40 against 31.5, lg 84 against 63), so nothing changes.
1869
+
1870
+ ### empty-state
1871
+
1872
+ New exported `BfEmptyStateHeadingLevel` (`2 | 3`), the type of `headingLevel` (was an inline union
1873
+ with the same values).
1874
+
1875
+ JSDoc of `empty-state.component.ts` for the API reference: `@slot` docs for `icon`, `actions`. No
1876
+ behaviour change.
1877
+
1878
+ ### list
1879
+
1880
+ JSDoc of `list.component.ts` for the API reference: `@slot` docs for `default`. No behaviour change.
1881
+
1882
+ ### docs site
1883
+
1884
+ Examples and demos use the convention's output names: `(changed)` on `bf-chip`, `bf-tab-bar` and
1885
+ `bfMenuItemCheckbox`, `(stackChanged)` on `bf-stack-outlet`, `(refreshRequested)` on
1886
+ `bf-refresher`. The API reference index gains an `@bitforgehq/ui/number-input` group for its
1887
+ two new types.
1888
+
1889
+ The theme configurator derives contrast-safe tokens for any primary: `primary-strong` steps darker
1890
+ (light) or lighter (dark) until 4.5:1 on `primary-soft`, `primary-soft-hover`, `bg` and `surface`
1891
+ (`#fde047` gave 2.03:1 before); the new `primary-text` steps until 4.5:1 on `bg` and `surface`;
1892
+ `on-primary` is white only at ≥ 4.5:1 (was 3:1), else black, and the hover keeps 4.5:1 under it;
1893
+ status colours step until 4.5:1 on their soft tint, `bg` and `surface`, and `danger-border` until
1894
+ 3:1. New "Control radius" slider (0–24 px) with a "Pill" switch (on by default) emitting
1895
+ `--radius-control`; a stored config from before (no control radius) reads as a pill.
1896
+ `tools/theme-contrast.test.ts` asserts every brand, status and ring pair for the shipped theme
1897
+ and for six primaries (`#fde047`, `#0b0b0b`, `#2b26ba`, `#10b981`, `#ef4444`, `#a855f7`) in both
1898
+ modes, plus the state indicators against what they sit on (checkbox mark and toggle knob
1899
+ on the primary fill, radio dot in its ring, progress fill on its track).
1900
+
1901
+ New guides: "Density and sizes" (`/density`: the `BfSize` scale per widget, a live 20-field
1902
+ Signal Forms form in all three steps, each a sub-tree with its own `provideBfDefaults()`, setups for
1903
+ a desktop app, a sub-tree and a Capacitor app, and the hit-area rule; its filter row is a named
1904
+ `role="group"`, and the page states that its density-scaled layout gaps are the demo's own
1905
+ convention, read from `BF_DEFAULTS`, which `provideBfDefaults()` never applies to an app's layout)
1906
+ and "Labels and language"
1907
+ (`/labels`: tables of every built-in string and default validation message rendered from
1908
+ `BF_DEFAULT_LABELS`, so they cannot drift, a live German sub-tree under `provideBfLabels()`, and the
1909
+ "ss, never ß" rule for German copy); both in the navigation, search, `llms.txt` and the sitemap.
1910
+ The Forms guide gains "Typed values" (numeric select ids, `null` for none, `compareWith`,
1911
+ `bf-number-input` with `min()`) and "Default messages"; its live example's team select binds
1912
+ numeric ids, and its Reactive Forms example types its controls `number | null` and shows default
1913
+ messages without any `errorMessage`. The consumer-smoke job also builds and tests a dense (`sm`)
1914
+ desktop Signal Forms form with German default messages, typed values and links.
1915
+
1916
+ The desktop overlay pages (`dialog`, `desktop-toast`, `menu`, `menu-button`) run their live demos
1917
+ in the page itself; the dialog demo adds "Release notes" (a body that scrolls) and "Share…" (a
1918
+ `bf-menu-button` in the dialog footer), reports the `confirm()` answer id, and every overlay demo
1919
+ marks its triggers and result line with `data-testid`. The install line of a component page now
1920
+ names the peers of its entry point: `bun add @bitforgehq/ui @angular/cdk` for
1921
+ `@bitforgehq/ui/overlay`, `bun add @bitforgehq/ui @angular/cdk @angular/router @capacitor/core rxjs`
1922
+ for `@bitforgehq/ui/mobile`. The `/mobile` overlay pages (alert dialog, toast, modal, sheet, action
1923
+ sheet) gain a "Desktop" section pointing to `bf-dialog`, `BfDialogService.confirm()`,
1924
+ `BfDesktopToastService` and `bf-menu`. New end-to-end proof at 1280 × 800
1925
+ (`e2e/docs/overlay-desktop.spec.ts`): wheeling over an open dialog or its backdrop leaves the page
1926
+ where it was and it scrolls again once closed; a long dialog scrolls only its body; the page is
1927
+ `inert`, focus is trapped and restored; `confirm()` focuses Cancel and ignores the backdrop;
1928
+ history back and a link item close the dialog or menu; `Escape` in a menu inside a dialog closes
1929
+ only the menu; a toast sits bottom right, is announced, never locks or isolates the page, pauses
1930
+ under the pointer and hands focus back on `Escape`; the full menu-button keyboard path. axe also
1931
+ scans the open dialog, confirm, menu, submenu and toast stack at `moderate` impact, light and dark.
1932
+
1933
+ Every example is a compiled file, and the Code tab shows exactly the file the Preview renders
1934
+ (F-079). The demos moved to `projects/docs/src/app/examples/<name>/overview-example.component.ts`
1935
+ (`<Name>OverviewExampleComponent`, `app-example-<name>-overview`) and the guide examples to
1936
+ `examples/guides/{forms,density,labels}/`; each loads in its own chunk through the generated
1937
+ `examples/example-registry.ts`, and the generated `examples/example-sources.ts` holds every file
1938
+ verbatim for the Code tabs. The new `app-example` card renders one example by id: in the page, in
1939
+ the phone frame (a `/mobile` component's Code tab is now its mobile-demo showcase page), or as code
1940
+ only with no tab list (the Forms guide's Reactive Forms example, a consumer-smoke fixture file).
1941
+ The Density and Labels guide examples gain a Code tab. `docs.md` sections can place examples with
1942
+ `<!-- example: <id> -->` markers. At 320 px the Preview column takes the full width (it shrinks to
1943
+ the demo only from `sm` up, inside `p-4 sm:p-10`), so no widget page's demo frame overflows
1944
+ (F-094, list-item); `e2e/docs/widgets.spec.ts` checks every widget page.
1945
+
1946
+ Every example in the component docs is a compiled file now (F-092): the fenced `html` and `ts`
1947
+ blocks of every `docs.md` became `examples/<name>/<slug>-example.component.ts` files, each with a
1948
+ `renderExample()` spec, placed with `<!-- example: <id> -->` markers. A component page shows each
1949
+ as its own Preview | Code card; `/mobile` examples are compiled but code-only (`@preview none`),
1950
+ as are the few that would cover the page (the floating tab bar, a fixed action bar, an app config
1951
+ or a route guard). Markers work in every section (the overview, Theming, Accessibility and the
1952
+ notes too), as in the agent pages. A docs file keeps only `css`, `bash`, `json` and `text` fences.
1953
+ `tools/docs-contract.test.ts` enforces it (no other fence, no `<bf-` in a fence, every marker
1954
+ resolves, every docs file has a marker, no two components share their usage sections), and the
1955
+ new `tools/attr-drift.test.ts` parses every example template, every `@example` and every inline
1956
+ `<…>` code span with Angular's template parser and fails when an attribute or binding on a library
1957
+ element names no input, output, model or alias of it (or a native attribute), so renaming an input
1958
+ breaks `bun run test:tools` wherever the docs still use the old name. The generator reads a
1959
+ co-located `<component>.docs.md` before the folder's `docs.md` (`bf-tab`, `bf-list-item`). An
1960
+ example chunk that fails to load (a stale deploy) shows "Couldn't load the example" and logs the
1961
+ error instead of "No demo"; the `app-example` card no longer copies its `id` input onto the host as
1962
+ a DOM id (it names its example in `data-example`). The drift check also fails on library markup
1963
+ that does not parse (it checks the markup a TypeScript `@example` carries in its comments and
1964
+ template literals).
1965
+
1966
+ A deep link to a section (`/components/input#with-signal-forms`) keeps its heading in view while
1967
+ the cards above it load: for 3 s every resize of the page scrolls to it again, until the user
1968
+ scrolls (wheel, touch, a scrollbar drag), clicks or presses a key, or the page navigates on.
1969
+
1970
+ A component page no longer downloads `ai/MANIFEST.json` (784 kB, most of it example sources the
1971
+ site bundles anyway): `bun run ai:docs` also writes `projects/docs/public/manifest/<name>.json`, the
1972
+ component's manifest entry without `examples[].source`, and the page fetches only its own
1973
+ (`manifest/button.json`, at most 25 kB raw). `ai/MANIFEST.json` is unchanged for agents;
1974
+ `tools/examples.test.ts` checks every site entry against it.
1975
+
1976
+ API tables come from the checker (F-082). The columns are Property, Type, Default and
1977
+ Description; badges mark a required member, a two-way `model()` (`[(value)]`), a member that
1978
+ `[formField]` sets (`invalid`, `touched`, `errors`, …) and a deprecated one. The type cell links
1979
+ every public symbol it names (`BfSize` to `/api/bf-size`, a component class to its page) and
1980
+ shows the expanded union under an alias. Component pages gain a Slots table (each slot once, with
1981
+ its doc) and a Methods section (signature, doc and a parameter table). New API reference:
1982
+ `/api` lists every service, directive, function, token, class, interface, type and constant by
1983
+ entry point and kind, and `/api/<slug>` renders one from `ai/REFERENCE.json` (fetched on demand):
1984
+ its import line, a directive's usage docs with live examples, the signature, a union's values,
1985
+ the members (a directive's inputs and outputs, an interface's properties with their defaults),
1986
+ the methods, what it uses and what uses it. The overlay services' pages link their options
1987
+ types, whose Options tables show the defaults. Titles and descriptions come from the bundled
1988
+ `REFERENCE.index.json`; the sitemap lists every reference page. `bfOverscrollStretch`'s
1989
+ hand-written input table is gone: its reference page shows the generated one. `/api/<component class slug>`
1990
+ (`/api/bf-button-component`) redirects to the component's page.
1991
+
1992
+ Every component page (and a directive's reference page) gains a "When to use" section right after
1993
+ the Overview, saying when to pick the widget and what to use instead, with links to the related
1994
+ component and `/api` pages (F-087). Every interactive widget's Accessibility section ends with a
1995
+ "Keyboard" table (`| Key | Action |`, keys as `<kbd>`) of what the code does: native elements list
1996
+ the browser's keys, the menu and dialog keep 3c's tables. A "Desktop" section before Theming
1997
+ covers hover, the pointer, `focus-visible` and density on desktop for every interactive widget,
1998
+ and what to use on desktop for every `/mobile` page (3c's overlay "Desktop" sections are kept).
1999
+ The page order is fixed: header, primary example, Overview, When to use, the usage sections,
2000
+ Desktop, Theming, Accessibility (with Keyboard), API, Notes, Migration notes. The `bf-tabs` page's
2001
+ "Keyboard" section (the soft keyboard) is now "Soft keyboard" (`#soft-keyboard`).
2002
+ `tools/docs-contract.test.ts` fails on a missing or misplaced section: When to use in every docs
2003
+ file, first, with an "instead" and a `/components` or `/api` link; a Keyboard table for every
2004
+ component or directive that is interactive by the extracted API (a model or an output, or a
2005
+ `button`, `a`, `input`, `select`, `textarea` or `tabindex` in its template) unless it is on a short
2006
+ allow-list with a reason (`bf-content`, `bf-page`, `bf-stack-outlet`, `bf-refresher`); Desktop for
2007
+ every interactive and every `/mobile` widget; and every `/components/<name>` and `/api/<slug>`
2008
+ link in the docs must name a page that exists. Links to the site's own pages in rendered markdown
2009
+ (root-relative or on `https://bitforge-ui.web.app`) resolve against the app's base href, so they
2010
+ work on a mirror served under a sub-path and in local builds, and stay real links (middle-click,
2011
+ copy link). A heading inside the prose (`### Keyboard`) carries its slug as an id, so
2012
+ `/components/menu#keyboard` lands on it at any width. The interactive rule also counts a template
2013
+ element that another interactive library widget matches by selector (transitively: the action
2014
+ sheet's `bf-list-item` and `bf-button` rows) and host key, click and focus listeners
2015
+ (`bfAction`'s focus and blur); `bf-modal`, `bf-sheet`, `bf-tabs` and `bf-header`, whose keys come
2016
+ from the overlay core or projected content, are listed by hand, and `bfAction`'s keys are checked
2017
+ on the card and list-item pages.
2018
+
2019
+ How-to guides. `/testing` covers the CDK dev dependency, the Vitest setup file (the CLI default)
2020
+ or a Jest config with `installBfJsdomShims()` and the `transformIgnorePatterns` the library needs,
2021
+ `provideBfTesting()` and `provideBfShellTesting()`, zoneless waiting, the harnesses in the page
2022
+ and in the overlay container, and a sheet under the shell's test providers; every file it shows is
2023
+ a consumer-smoke fixture spec that passes under Vitest. `/mobile` is now the Capacitor how-to
2024
+ ("Mobile: Capacitor setup"): peers, providers, the root host's height, `index.html`,
2025
+ `capacitor.config.ts`, the Android manifest, a tabbed app (routes, `bf-tabs`, `bf-stack-outlet`,
2026
+ `bf-page`), keyboard and Dynamic Type, and running on a device. Its files are fixture files under
2027
+ `tools/fixtures/consumer/src/mobile/`, which consumer-smoke's `mobile-full` step builds as one app;
2028
+ then the phone frame ("Try it") and a Reference of every `/mobile` export, linked. `/theming`
2029
+ ("Theming and dark mode") shows the theme configurator inline beside a live preview, where the
2030
+ generated block goes, dark mode (`dark`, `color-scheme`, the body classes), following the OS and
2031
+ remembering the choice, app-wide and sub-tree token overrides, the radius tokens and fonts; the
2032
+ configurator's controls are now `app-theme-configurator-panel`, and the floating launcher shows a
2033
+ visible "Theme" label from `sm` (its accessible name stays "Theme configurator"). `/overlays`
2034
+ ("Dialogs, menus and toasts on desktop") has live examples for confirming a delete, a Signal Forms
2035
+ form in a dialog, row actions in a menu and undo in a toast, and says when a Capacitor app uses the
2036
+ `/mobile` overlays instead. `/troubleshooting` lists seven known failures with cause, fix and link.
2037
+ The guide steps live in `consts/testing.const.ts` and `consts/capacitor.const.ts`, which the site
2038
+ and `UI_GUIDE.md` both render. The Install row of a `/testing` reference page reads
2039
+ `bun add @bitforgehq/ui && bun add -d @angular/cdk`. `fixture/…` example ids may hold dots
2040
+ (`fixture/testing/sheet.spec`).
2041
+ The Testing guide's Jest path: install `jest`, `jest-environment-jsdom`, `jest-preset-angular` and
2042
+ `@types/jest`, set `tsconfig.spec.json`'s `types` to `["jest"]`, and call
2043
+ `setupZonelessTestEnv()` before `installBfJsdomShims()` (a bare import of
2044
+ `jest-preset-angular/setup-env/zoneless` initialises nothing, so every spec failed with "Need to
2045
+ call TestBed.initTestEnvironment() first"); its `transformIgnorePatterns` keeps Angular's locale
2046
+ files (`@angular/common/locales/.*\.js$`). consumer-smoke now runs the testing specs, plus a
2047
+ `de-CH` locale spec, under Jest with the guide's `jest.config.mjs` and setup file written from the
2048
+ same consts. The Capacitor guide says `@capacitor/keyboard` is needed, since the required
2049
+ `capacitor.config.ts` imports `KeyboardResize` from it.
2050
+
2051
+ The site navigation follows Diátaxis, from one source, `consts/nav.const.ts` (`NAV_GROUPS`, which
2052
+ replaces `guides.const.ts`): Get started (Install, Tutorial, Concepts), Guides (Forms and
2053
+ validation, Density, Labels and translation, Theming and dark mode, Dialogs, menus and toasts,
2054
+ Testing, Capacitor apps, Troubleshooting), Components, Foundations, Reference (API, For agents,
2055
+ Compatibility) and Changelog. No existing path changed. The header links Get started, Guides,
2056
+ Components, Reference and Changelog and marks the current group; the sidebar and the phone drawer
2057
+ show every group as a disclosure, open while it holds the current page; the footer mirrors the
2058
+ groups; search, the sitemap and `llms.txt` list the same pages. The home page is desktop first: a
2059
+ new subhead and six feature cards (forms and density, desktop overlays, theming, testing, agent
2060
+ docs, then the Capacitor shell), and the hero offers the `bun add @bitforgehq/ui` line with
2061
+ the version and a copy button. New pages: `/tutorial` ("Build a settings page", five compiled steps: the
2062
+ form, validation with the default messages, saving with a desktop toast, a "Delete account"
2063
+ confirm dialog and dark mode; consumer-smoke builds the five steps as one app against the packed
2064
+ library), `/concepts` (entry points and their peers, tree shaking, tokens, signals and Signal
2065
+ Forms, desktop versus Capacitor, client-only for 0.x, labels), `/compatibility` (the peer ranges,
2066
+ generated from `package.json`, the toolchain and Angular's supported browsers) and `/changelog`
2067
+ (this file, with a list of sections per version; anchors use the version number only, such as
2068
+ `#v0-3-0` and `#v0-3-0-migration`, so links survive the release). Each tutorial step is `SettingsComponent` (`app-settings`),
2069
+ saved verbatim as `src/app/settings.component.ts`, with step 5's
2070
+ `theme-preference.state.service.ts` beside it; consumer-smoke writes exactly those files and the
2071
+ page's `app.ts` into the fixture app and builds every step. The `/overlay` Install row now reads
2072
+ `@angular/cdk rxjs`, from the same entry point table as Concepts and Compatibility. The page keeps
2073
+ 5 rem of scroll padding at the bottom, so the Theme launcher never covers a focused element.
2074
+ `deploy:docs` runs `ai:docs:check` first.
2075
+
2076
+ Every route is prerendered (F-086): `ng build docs` (`outputMode: 'static'`, no SSR server)
2077
+ writes one HTML file per path of `app/site-routes.ts` (home, the navigation pages, one page per
2078
+ component and per reference symbol from the package indexes, and `/404`), the one list the
2079
+ sitemap, the hosting check and the e2e suite read too. Each page carries its own `<title>`,
2080
+ description, canonical link and Open Graph tags, and its content: a component page's body is
2081
+ prerendered and hydrates when the browser is idle, and the entry it was rendered with comes with
2082
+ the page (no second request, no loading state first). Unknown paths answer 404 with the "Page not
2083
+ found" page: `firebase.json` drops the catch-all rewrite (`trailingSlash: false`, `no-cache` on
2084
+ pages), and `tools/finalize-docs.ts` moves `404/index.html` to `404.html` and fails the build when
2085
+ a route has no page or no head of its own; an unknown component name or reference slug falls
2086
+ through to the `**` route, so it hydrates the 404 page as prerendered. Example cards load their
2087
+ Preview (the example chunk, or the phone frame) only as they near the viewport, and render and
2088
+ highlight their code only once the Code tab is first shown. Until then, and while its demo loads,
2089
+ a card's Preview keeps the height it last rendered at (`examples/example-heights.json`, measured
2090
+ at 412 and 1280 px by `bun tools/measure-examples.ts`), so nothing below it moves: CLS on
2091
+ `/components/input` drops from 0.101 to 0.074. A reference page fetches its own
2092
+ symbol, `reference/<slug>.json` (written by `ai:docs`; the largest is 8 kB), instead of the whole
2093
+ 280 kB `REFERENCE.json`, which stays whole for agents. A custom theme from the configurator goes
2094
+ on before the first paint (its derived tokens are kept under `bf-docs-theme-tokens`), as dark mode
2095
+ already did, and `ThemeStateService` touches no storage on the server. The header's theme toggle
2096
+ renders both icons and `styles.css` shows the one `html.dark` calls for, so a dark-mode reader
2097
+ sees the sun icon from the first paint, not only after hydration. Every prerendered page
2098
+ preloads the fonts. The e2e suite fails on any Angular hydration message (`NG05xx`). A prerendered `/changelog`
2099
+ keeps its versions as prerendered and never hydrates them (`@defer (hydrate never)`), so
2100
+ `CHANGELOG.md` is neither fetched again nor inlined in the page (561 kB to 361 kB, 149 kB to
2101
+ 85 kB gzip); its section links are same-page anchors there, and its code fences stay
2102
+ unhighlighted.
2103
+
2104
+ Every page names a default Open Graph image, `og-image.png` (1200 × 630, with its alt text, as a
2105
+ `summary_large_image` card), so link previews show the brand instead of a bare title. It is
2106
+ rendered from `tools/og-image.html` (the docs tokens, Manrope, the favicon mark and the home hero's
2107
+ copy) by `bun tools/og-image.ts`, run by hand when the brand changes.
2108
+
2109
+ Code is highlighted in a web worker (`services/highlight.worker.ts`), so compiling Shiki's
2110
+ grammars and tokenizing a page's code never blocks the main thread: Lighthouse's total blocking
2111
+ time on the install, forms and mobile pages drops from 270–580 ms to at most 10 ms. Without
2112
+ `Worker` it highlights on the main thread as before. A request the worker answers with an error
2113
+ is highlighted on the main thread alone; a worker that crashes or leaves a request unanswered for
2114
+ 10 s is started once more, then highlighting stays on the main thread. `finalize-docs.ts` fails a
2115
+ build whose worker bundle imports a chunk the build did not write, and `tools/highlight-worker.test.ts`
2116
+ fails a dynamic `import()` in the worker's module graph.
2117
+
2118
+ `bun run docs:hosting-check` serves the build in the Firebase hosting emulator (port 4402, a
2119
+ `demo-` project, no login) and checks every sitemap URL (200, its own title, description and
2120
+ canonical link), that unknown paths answer 404 with the "Page not found" page, and every link in
2121
+ `llms.txt` (F-083). `e2e/serve.ts` and Lighthouse answer like Hosting (`tools/static-resolve.ts`).
2122
+ Lighthouse now also audits `/components/input`, `/install` and `/forms` (F-091), from port 4410,
2123
+ keeping the median of three runs; a page may hold a conscious lower threshold in
2124
+ `PAGE_THRESHOLDS`, and none needs one.
2125
+
2126
+ The deploy no longer ships `index.csr.html` (`finalize-docs.ts` deletes the unused
2127
+ client-rendering shell), the `/mobile-demo/` rewrite takes extension-less paths only, so a missing
2128
+ demo script is a 404 instead of the demo's HTML, and the hosting check proves both, stops at once
2129
+ when port 4402 already answers, and loads an embedded demo route.
2130
+
2131
+ The ⌘K palette searches the full text of the site (F-084): every page and its sections, every
2132
+ component with its usage sections (the Keyboard tables included), inputs, outputs and methods,
2133
+ every reference symbol and its members, and "Not in Bitforge UI" hints for what the library does
2134
+ not provide (tooltip, table or data grid, date picker, combobox or autocomplete, rich text and
2135
+ charts), each pointing at what to use instead. Synonyms (dark mode, dropdown, modal, snackbar,
2136
+ validation, tailwind, capacitor) lead to the page that answers them. Results are grouped as Pages,
2137
+ Components, API, Sections and Hints, best match first; a section opens at its heading, and every
2138
+ result is a real link under the base href, so a modifier click opens it in a new tab. The index,
2139
+ `search-index.json`, is built after the prerender by `tools/gen-search-index.ts` from
2140
+ `MANIFEST.json`, `REFERENCE.json`, the prerendered pages and `consts/search-hints.const.ts`, and
2141
+ searched in the browser with MiniSearch. It is fetched once, when the palette first opens; until
2142
+ it arrives the palette filters page and component titles as before. `docs:hosting-check` checks it
2143
+ is served with its five-minute cache. A result is named by its title, the "Not in Bitforge UI"
2144
+ badge and its page, with its text as the description, so a screen reader reads a short name on
2145
+ each arrow press; group names are read in plain case; a pointer press keeps focus in the field;
2146
+ the field is `aria-expanded` only while there are results; a failed index fetch is retried on the
2147
+ next open; and a hint pointing at a page or section that is not indexed fails the build.
2148
+
2149
+ A deep link opened from inside the site (a search result, a link to `/components/sheet#snap-points`)
2150
+ lands on its heading even when the content above it grows first: a scroll the browser makes as the
2151
+ page changes height no longer ends the hold on the heading, only a scroll of the user's does.
2152
+
2153
+ The header's large-title collapse is proven in real browser engines (F-071): the new
2154
+ `e2e/header.spec.ts` runs on `iphone` (WebKit, iOS mode) and `pixel` (Chromium, Material mode)
2155
+ against the mobile demo's header showcase and reads the rendered page. At rest the large title is
2156
+ opaque and nothing is under the bar; half-way down its height (held, so the scroll-end snap waits)
2157
+ it is part-faded, the toolbar title not fully shown, no `data-bf-scrolled`, and the scroller's
2158
+ range is unchanged; nearly collapsed there is still no `data-bf-scrolled`; at the large title's
2159
+ height it is gone, the toolbar title shows and `data-bf-scrolled` switches on; back at the top the
2160
+ rest state returns. Halving the collapse range or switching the scroll-under trigger back to
2161
+ `scrollTop > 0` fails it.
2162
+
2163
+ The docs site is checked in WebKit too (F-073): the new `docs-webkit` Playwright project runs the
2164
+ page, axe and search specs in Desktop Safari against the same build, so a regression only Safari
2165
+ shows fails CI. axe blocks at `moderate` impact on every page (F-077), with an allow-list that takes
2166
+ only third-party or iframe content, with a reason, and is empty; it also scans, in both themes, the
2167
+ ⌘K palette empty, with results and with none, the phone navigation drawer at 390 px, the floating
2168
+ and the inline theme configurator, the Code tab of `/components/button` and `/components/sheet`,
2169
+ `/`, `/components/button` and `/mobile` under reduced motion, and the desktop overlays. Fixes it
2170
+ found: the site header is labelled "Bitforge UI", so its banner stays unique next to the embedded
2171
+ demo's; the palette's results scroller is in the tab order, so a keyboard can scroll a long list;
2172
+ the extra tab bars on `/components/tab-bar` are named ("Sections", "Feed"); and the phone frame
2173
+ writes its iframe `src` only when it changes, so hydrating `/mobile` no longer loads the embedded
2174
+ demo a second time (WebKit logged the aborted module scripts as errors). New e2e: a hard-loaded
2175
+ `/changelog#<version>` lands on its heading, and the changelog's "On this page" lists the versions,
2176
+ jumps to one and follows the scroll.
2177
+
2178
+ The docs deploy from CI (F-083): `.github/workflows/docs.yml` verifies every push to `main` (the
2179
+ library build, agent docs, tools tests, the docs build, the docs e2e in Chromium and WebKit, the
2180
+ hosting check), then
2181
+ deploys that tested build to Firebase Hosting once the `FIREBASE_SERVICE_ACCOUNT_BITFORGE_UI` secret
2182
+ exists (without it the job prints a notice and skips) and to the GitHub Pages mirror once the
2183
+ `DOCS_PAGES_ENABLED` variable is `true`. `ci.yml` also runs the hosting check, and its Lighthouse
2184
+ step takes the median of five runs per page; `tools/lighthouse.ts` audits a page that misses a
2185
+ threshold once more before it fails. README "Docs deployment" lists the owner's steps. Both
2186
+ deploy jobs run only for `main` (a manual run on another branch verifies without deploying), the
2187
+ Firebase and Bun actions are pinned to commit SHAs, and the Pages job serves its sub-path build
2188
+ under `/BitforgeUI/` and checks its pages, base hrefs, linked assets and 404 before
2189
+ uploading (`tools/pages-check.ts`). The palette's results scroller is a group named "Search
2190
+ results".
2191
+
2192
+ The docs initial bundle grew from 517.98 kB on main to 563.06 kB (139.45 kB transfer; +8.7 %),
2193
+ across the nav, prerender/hydration and search work above: the eager nav const and sidebar logic
2194
+ of the new site chrome (Task 7, ~+7 kB); `provideClientHydration(withEventReplay(),
2195
+ withIncrementalHydration())`, the SEO service and the deep-link fragment hold that ships with
2196
+ prerendering (Task 8); and the ⌘K search palette's trigger and result list (Task 9; MiniSearch and
2197
+ `search-index.json` themselves stay in the palette's own lazy chunk, loaded only on first open, not
2198
+ in this total). Lighthouse still scores ≥ 90 on every audited page. The `initial` budget warning
2199
+ (`angular.json`, docs project) is now 580 kB, down from 600 kB, so the next regression is caught
2200
+ with less headroom.
2201
+
2202
+ The docs site's markdown pipe renders only markup that markdown itself generates (F-093): raw
2203
+ HTML, block or inline, renders as text (a bare `<kbd>` excepted, which the Keyboard tables use),
2204
+ and a link or image whose URL is not `http:`, `https:`, `mailto:`, relative or a `#fragment`
2205
+ renders as its text alone, also in inline mode (member descriptions in the API tables), and also
2206
+ when the scheme hides behind a character reference (`javascript&#58;`) or a tab. Together with
2207
+ the generator's raw-HTML refusal, an `<img onerror>` in a JSDoc line can no longer run on the site.
2208
+
2209
+ New "Rendering" section on the Compatibility page: 0.x is client-rendered only, and what the
2210
+ server-render smoke (`tools/ssr-smoke.ts`) shows: all 27 fixtures (every root widget, `bf-icon`,
2211
+ `bf-number-input`, two `/mobile` pages and an `/overlay` dialog) render on the server without an
2212
+ error and 18 render identically twice; the list of the other 9 is built from the smoke's recorded
2213
+ results (`SSR_RECORDED`), and hydration stays unsupported and untested. The README and the UI guide
2214
+ gain a "Compatibility" section with the same text.
2215
+
2216
+ New examples: the `bf-chip-group` page (`chip-group/overview`, `single`, `multiple`),
2217
+ `badge/count-label`, `progress/value-text` and `progress/indeterminate`, `field/help-and-error`
2218
+ and `textarea/max-rows`. The Foundations page lists the four pressed tokens, and the theme
2219
+ configurator derives `--color-primary-pressed` and `--color-primary-soft-pressed` (the fill 0.12
2220
+ darker in light mode, lightness 0.88 in dark; the soft tint at 0.86 / 0.40), with
2221
+ `primary-strong` searched to 4.5:1 on the soft pressed tint as well. The demo host spec queues
2222
+ `requestAnimationFrame` for every test.
2223
+
2224
+ The Foundations page gains Focus, Motion and Z-index sections (F-061, F-062), and
2225
+ `docs/design/foundations.md` the same three tables. Focus states both treatments with their exact
2226
+ classes: controls `focus-visible:outline-hidden focus-visible:ring-4 focus-visible:ring-ring`
2227
+ (keyboard focus only, 4 px) and text-entry controls
2228
+ `focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-ring` (any focus, because the
2229
+ caret field must show where typing goes), both ≥ 3:1 (WCAG 1.4.11) and the system outline under
2230
+ forced colours. Motion lists `--bf-duration-fast`, `--bf-duration-overlay`, `--bf-duration-push`,
2231
+ `--ease-ios`, `--ease-md` and `--ease-settle` with their users and the reduced-motion behaviour;
2232
+ Z-index lists `--bf-z-dim`, `--bf-z-sticky`, `--bf-z-tabbar` and `--bf-z-overlay`. The spacing
2233
+ steps list `--spacing-card-lg`. `tokens.const.spec.ts` fails when a `--bf-duration-*`, `--ease-*`
2234
+ or `--bf-z-*` declared in `theme.css` is missing from the tables, and checks the quoted focus
2235
+ classes against the class maps. New example `icon/filled` (Lucide icons with filled details).
2236
+ Focus also lists the local additions: the `bf-segmented` ring offset
2237
+ (`focus-visible:ring-offset-2 focus-visible:ring-offset-surface`) and its selected item's own
2238
+ forced-colours ring, and the outline variants' `focus-visible:border-primary`. Motion notes
2239
+ `DURATION_MD` (220 ms), the one duration without a CSS token because only Web Animations started
2240
+ from TypeScript use it (the Material fade-up, the refresher settle, the overscroll release).
2241
+
2242
+ The `/mobile` guide gains the step "Match the system bars to the theme" (what the shell does,
2243
+ the `android:windowBackground` and `colors.xml` snippet, and the outcome per Android WebView
2244
+ version). "Run it on a device" now says that `cap run ios` cannot deploy on Xcode 27 (no
2245
+ Simulator.app) and shows the `xcodebuild`/`simctl` fallback, and passes `--target` to both
2246
+ `cap run` commands. The labels guide lists `expand`, `collapse` and `sheetPosition`. The system-bars step states the WebView ≥ 140 preconditions (`viewport-fit=cover`, `insetsHandling: 'css'`).
2247
+
2248
+ New examples: `header/long-title` (a long title wrapping in a grown bar, the back text giving way)
2249
+ under the header's new "Large text and compact heights" section, and `list-item/long-text` (a
2250
+ wrapping row and a `truncate` row) under the row's new "Long text" section.
2251
+
2252
+ The shell's `main` is named "Documentation" (`aria-label`), so it stays distinct from the embedded
2253
+ phone demo's own `main` landmark (axe `landmark-unique` checks across frames).
2254
+
2255
+ New examples: `sheet/autofocus` (`autoFocus: 'first-field'`), `stack-outlet/bounded`
2256
+ (`maxLivePages`) and `stack-outlet/scroller` (a horizontal chips row in a stacked page, no
2257
+ directive).
2258
+
2259
+ Every install surface shows `bun add @bitforgehq/ui`: the home hero, Install step 4 and each
2260
+ component and reference page's Install row with the entry point's peers; the generated
2261
+ `ai/UI_GUIDE.md`, `llms-full.txt` and package README follow. The header shows the version,
2262
+ `v0.3.0`. The repository is `https://github.com/bitforge/BitforgeUI`, and the GitHub Pages
2263
+ mirror serves under `/BitforgeUI/`.
2264
+
2265
+ ### agent docs
2266
+
2267
+ `UI_GUIDE.md` has a new API conventions section: the output, value-control, name and type rules,
2268
+ and the deprecated 0.2 output names with their replacements.
2269
+
2270
+ `UI_GUIDE.md`: Configuration covers when to set which density (desktop nothing, dense `sm`,
2271
+ Capacitor `fieldSize: 'lg'`), the hit-area rule and German labels; Forms covers
2272
+ `bf-number-input`, typed values (`T | null`, `compareWith`) and typed Reactive controls with
2273
+ default messages; a new Links item covers `a[bfButton]`, `bfAction` on cards and list rows, and
2274
+ the `nav` of links inside `bf-tabs`. `llms.txt` lists the two new guides. The package README gains
2275
+ a "Density and labels" paragraph.
2276
+
2277
+ **type**: `MANIFEST.json` entries gain `primaryExample` (an example id: `<name>/overview`, or
2278
+ `mobile-demo/showcase/<name>` for a `/mobile` component) and `examples` (`{ id, title, file, source,
2279
+ preview }[]`: the primary example, then those the `docs.md` references). Migration for agents: read
2280
+ `examples[0].source` for a complete, compiled usage file; `example` stays the short `@example`
2281
+ snippet. The `ai/components/*.md` pages replace "Quick example" with "Example", the primary example's
2282
+ full source as a `ts` block, and expand every `<!-- example: <id> -->` marker into a `ts` block of
2283
+ that file.
2284
+
2285
+ **type**: in `MANIFEST.json`, a usage, Theming, Accessibility or notes body now holds `<!-- example: <id> -->`
2286
+ markers where it held fenced `html`/`ts` snippets, and `examples` lists every example they place.
2287
+ Migration for agents: resolve a marker through the entry's `examples` (by `id`) for the full
2288
+ compiled file, or read the `ai/components/*.md` page, where every marker is already expanded.
2289
+ `bf-tab` and `bf-list-item` have their own `overview` and `usage`, no longer copies of `bf-tab-bar`
2290
+ and `bf-list`.
2291
+
2292
+ **type**: the API now comes from the TypeScript checker and the Angular template parser
2293
+ (`tools/api-extract.ts`), not from initializer text. `MANIFEST.json` gains `schemaVersion: 2`.
2294
+ Members gain `expandedType` (the union with its aliases resolved, e.g. `'sm' | 'md' | 'lg' |
2295
+ undefined` for `BfSize | undefined`, only when it differs from `type`), `twoWay` (a `model()`,
2296
+ bind it `[(name)]`), `formControlled` (a `FormUiControl` member that `[formField]` sets) and
2297
+ `alias` (the public binding name of an aliased member). `slots` are `{ name, doc }[]` (they were
2298
+ `string[]`), each named once (the header's two default projections are one slot) with its
2299
+ `@slot` doc. Components gain `methods` (`{ name, signature, params, returns, doc }[]`). Identifier
2300
+ defaults resolve to their literal (`bf-stack-outlet`'s `name` is `'primary'`, not
2301
+ `PRIMARY_OUTLET`), and `bf-number-input`'s `id` is typed `string` (was `any`). Migration for
2302
+ agents: read `slots[i].name` where you read `slots[i]`; read `expandedType ?? type` for the
2303
+ allowed values; never bind a `formControlled` member next to `[formField]`.
2304
+
2305
+ **type**: new `ai/REFERENCE.json` (every public symbol that is not a component: services,
2306
+ directives, classes, functions, tokens, constants, interfaces and types, with `signature`,
2307
+ `values`, `members`, `methods`, `selector`, `references`, `usedBy`, `alsoExportedFrom` and, for a
2308
+ directive with its own `docs.md`, `docs`), `ai/REFERENCE.index.json` (`{ name, slug, kind,
2309
+ entryPoint, summary }`) and one `ai/reference/<slug>.md` page per symbol (`BfSheetService` →
2310
+ `bf-sheet-service`, a constant keeps its underscores: `BF_LABELS` → `bf_labels`). The option
2311
+ interfaces carry the defaults the services apply (`@default`), and a table shows a property's
2312
+ default in place of "required" (`BfShellConfig.mode` reads `'auto'`). Migration for agents: look a
2313
+ service, directive, token or type up in `REFERENCE.index.json`, then read its
2314
+ `ai/reference/<slug>.md` page or its `REFERENCE.json` entry; a deprecated `/mobile` alias of an
2315
+ overlay symbol is listed on the overlay symbol under `alsoExportedFrom`.
2316
+
2317
+ **type**: every component line of `llms.txt` links its agent page first, then its site page:
2318
+ `- [bf-button](<origin>/ai/components/button.md): <summary> ([docs](<origin>/components/button))`,
2319
+ and a new `## Reference` section lists every reference page by entry point in the same form.
2320
+ `llms-full.txt` appends the reference pages after the component pages. Migration for agents:
2321
+ follow the first link of a line for the markdown page, the `docs` link for the rendered page.
2322
+
2323
+ The generator refuses to run on raw HTML outside code spans and fences (`<kbd>` excepted) in any
2324
+ JSDoc, `docs.md` or this CHANGELOG, naming the file and line (F-093, generator part): the site
2325
+ renders that markdown as HTML. The one offender, `bf-alert`'s `dismissLabel` doc, now says it in
2326
+ words. `tools/docs-contract.test.ts` requires an 8–15 word `@summary` and a `@since` on every
2327
+ exported symbol, a doc on every member, method, parameter and slot, and every export of every
2328
+ `public-api.ts` in `MANIFEST.json` or `REFERENCE.json`.
2329
+
2330
+ The reference covers `@bitforgehq/ui/testing` too: `REFERENCE.json`, `REFERENCE.index.json`,
2331
+ `ai/reference/` and the `## Reference` section of `llms.txt` gain its group, and the site's `/api`
2332
+ index lists it after `/mobile`.
2333
+
2334
+ **type**: `MANIFEST.json` entries, the site's `manifest/<name>.json` files and a directive's
2335
+ `REFERENCE.json` `docs` gain `whenToUse` and `desktop` (markdown, optional: the "When to use" and
2336
+ "Desktop" sections of its `docs.md`, which no longer appear in `usage`). The Keyboard table stays
2337
+ inside `accessibility` under `### Keyboard`. The `ai/components/*.md` pages follow the site's
2338
+ order (the example first, then a new "Overview" heading with the other selectors, the description
2339
+ and the overview, then When to use, the usage sections, Desktop, Theming, Accessibility, the API,
2340
+ Notes and Migration notes). A root-relative site link in a `docs.md` (`/components/menu`,
2341
+ `/api/bf-size`, `/density`) is made absolute (`https://bitforge-ui.web.app/components/menu`)
2342
+ everywhere the generator writes it: `MANIFEST.json`, `REFERENCE.json`, the site's
2343
+ `manifest/<name>.json` and the `ai/*.md` pages, so every link an agent reads can be followed. The
2344
+ site maps those links back onto its own base href. Migration for agents: read
2345
+ `whenToUse` to choose between widgets and `desktop` for desktop behaviour; a `usage` section named
2346
+ "Desktop" (the `/mobile` overlays) is now `desktop`.
2347
+
2348
+ `UI_GUIDE.md` gains a `## Testing` section and a step-by-step setup in `## Mobile shell`, both
2349
+ generated from the docs site's guide consts (`<!-- testing -->` and `<!-- mobile-setup -->` in
2350
+ `tools/ui-guide.template.md`), with each file shown in full. The hand-written mobile setup text
2351
+ (the `capacitor.config.ts` block, `index.html`, text size and Android manifest bullets) is replaced
2352
+ by those steps; the content is the same, plus the root host's `block h-dvh`, the providers, a
2353
+ tabbed app and running on a device. `llms.txt` lists the four new guides.
2354
+
2355
+ **type**: `llms.txt` follows the site navigation: its `## Guides` section becomes one section per group, in
2356
+ order (`## Get started`, `## Guides`, `## Components` with a `###` per component category under it,
2357
+ `## Foundations`, `## Reference` with the site's reference pages before the symbols by entry
2358
+ point, `## Changelog`), and it lists the new Tutorial, Concepts, Compatibility and Changelog
2359
+ pages. Migration for agents: find Install under `## Get started`, For agents under
2360
+ `## Reference` and Foundations under `## Foundations`, no longer under `## Guides`; a component
2361
+ category is now a `###` heading (`### Actions`), no longer a `##`. `bun run ai:docs` also writes the docs site's
2362
+ `consts/compat.ts` (`UI_PEERS`) from `package.json`.
2363
+
2364
+ ### tooling
2365
+
2366
+ `bun run ai:docs` enforces one API naming convention (`PROJECT_GUIDE.md` "API conventions",
2367
+ F-016): outputs are past-tense events (no `on` prefix, no declared `xChange`), actions emit
2368
+ `clicked` (menu items keep `triggered`, the CDK's event, as a named exemption), every value control
2369
+ has exactly one `changed`, every component that renders a native input, select or textarea takes
2370
+ `label`, `ariaLabel` and `ariaLabelledby`, other names are `<part>Label` or (on an image) `alt`,
2371
+ and every union or object type of a public input, model or output, of a `Bf…Options`/`Bf…Config`
2372
+ property or of an interface they name is a named type exported from the entry (the written type,
2373
+ else the checker's, so a cast is caught). The pure check is `tools/api-conventions.ts`;
2374
+ `gen-ai-docs.ts` collects its facts from the extracted API, the checker and the template parser and
2375
+ refuses to run on a breach, listing `selector.member: rule`, and on an extraction with no component
2376
+ or output, a missing class or an unresolved signal type.
2377
+
2378
+ New `bun run typecheck` type-checks every spec (the `tsconfig.spec.json` of `ui`, `web-demo`,
2379
+ `mobile-demo` and `docs`), the e2e suite and `tools/` with `tsc`, because Jest compiles specs with
2380
+ `isolatedModules` and never type-checks them. CI (`verify`) and `release.yml` run it right after
2381
+ the library build. The errors it found are fixed without relaxing a compiler option: bracket access
2382
+ on `Keyframe` in `transitions.spec.ts`, the `v`-flag pattern built with `new RegExp(…, 'v')` in
2383
+ `input.component.spec.ts`, a throwing `defined()` narrowing helper in `derive-tokens.spec.ts`,
2384
+ typed fixtures for the generic `bf-select`, `bf-radio-group` and `bf-segmented` specs, pattern
2385
+ labels in the dialog and toast harness specs, the router's third `canMatch` argument in two docs
2386
+ guard specs, and `EnvironmentProviders` accepted by the docs `renderExample()` helper.
2387
+
2388
+ `stack-outlet.component.spec.ts` waits for no wall-clock time: the deferred transition start and
2389
+ the swipe's 600 ms back fallback run on Jest's fake clock (`onFakeClock()`), and the one remaining
2390
+ real `setTimeout` is a commented 0 ms macrotask yield for the fake history's popstate order. The
2391
+ web and mobile demo Jest projects fail below 60 % branch and 70 % function, line and statement
2392
+ coverage (they were at 0). `tools/release-tag.test.ts` pins that any pre-release suffix
2393
+ (`v1.2.3-rc.1`) publishes to `next` and that build metadata (`v1.2.3+build.5`) is rejected with the
2394
+ shape message; the `TAG` comment and the README's "Releasing" state both rules.
2395
+
2396
+ New `tools/effect-usage.test.ts`: fails on an `effect()` in `src`, `mobile` or `overlay` that
2397
+ writes DOM or another object's state, unless the line carries `// effect-justified: <reason>`
2398
+ (three allowed: the shell's `<html>` class and the menu directives' writes into their own CDK
2399
+ host directives). It also flags property assignments on anything but `this`, lifecycle hooks and
2400
+ method calls with arguments on injected or queried objects. New `tools/ssr-smoke.ts` and `tools/ssr-smoke.test.ts`
2401
+ (in `test:tools`, which runs the smoke in a child process): renders a fixture per root widget,
2402
+ `/icons`, `/number-input`, `/mobile` and `/overlay` with `renderApplication()`, checks output each
2403
+ widget's own template renders (the native control, its role and state, the dialog panel), and
2404
+ compares which render and which are stable across two renders with the recorded `SSR_RECORDED`.
2405
+
2406
+ The API convention treats `valueText` (a progress bar's `aria-valuetext`) as content, like
2407
+ `helpText`, not as a name input. The server-render smoke renders a `bf-chip-group` fixture
2408
+ (28 fixtures, 19 of them stable) and matches the native `<progress>` without its dropped role. `tools/measure-examples.ts`
2409
+ honours the `--port <n>` its usage line documents (it always used 4408).
2410
+
2411
+ The mobile demo's `initial` budget warning (`angular.json`, the `mobile-demo` production and
2412
+ `embedded` configurations) is 630 kB, up from 610 kB. Its initial total grew from 606.72 kB at the
2413
+ start of this release's mobile-and-polish work to 622.10 kB (main 488.49 kB, styles 125.23 kB):
2414
+ +10.3 kB came with the pressed states, contextual badge and progress text, help next to errors and
2415
+ the bounded textarea (`@bitforgehq/ui` JS +7.5 kB, CSS +4.0 kB, because `theme.css` generates the
2416
+ classes of every entry), the rest from the API naming aliases, the signal refactors and the icon
2417
+ `fill` bindings. The docs app stays at 570.40 kB of its 580 kB.
2418
+
2419
+ It is now 650 kB, up from 630 kB: the initial total grew from 622.10 kB to 636.44 kB (main
2420
+ 488.5 → 499.8 kB, styles 125.2 → 128.2 kB) with the mobile features of the same release: +5.7 kB
2421
+ for the sheet snap control, its Close button and announcements and the system bars, +4.8 kB for
2422
+ the measured header and the px tab bar, and +3.8 kB for the CloseWatcher Escape path, the bounded
2423
+ stacks and the `main` landmark. None of it is an accidental inclusion: every demo page is lazy,
2424
+ and the library code in `main` is the one `@bitforgehq/ui`, `/mobile` and `/overlay` module each
2425
+ that esbuild keeps whole in the initial chunk. The docs app stays at 573.38 kB of its 580 kB.
2426
+
2427
+ Every `@deprecated` tag reads `<version> use <X>; removed in <version>`: the overlay core aliases of
2428
+ `/mobile` name their `@bitforgehq/ui/overlay` replacement (`use BfOverlayService from
2429
+ '@bitforgehq/ui/overlay'`), and the size aliases (`BfFieldSize`, `BfButtonSize`,
2430
+ `BfSegmentedSize`) and the card's `interactive`/`clicked` state the 1.0.0 removal Migrations 5 and
2431
+ 12 promise. `tools/api-conventions.test.ts` checks the form over every non-spec `.ts` file of the
2432
+ library.
2433
+
2434
+ New `tools/no-inline-svg.test.ts` (F-066): no `<svg` in the non-spec `.ts` and `.html` files of
2435
+ `projects/ui/{src,mobile,overlay,number-input,testing}`, the docs site (chrome, pages and
2436
+ examples), both demos and the OG image template; `projects/ui/icons` (`bf-icon`) is the only SVG
2437
+ renderer, with no exception. New `tools/lucide-attributes.test.ts` (F-065): collects every
2438
+ attribute each element type carries across all `lucide` icons and fails on one the `@case` blocks
2439
+ of `icon.component.ts` do not bind. The server-render smoke matches the spinner's ring instead of
2440
+ its SVG.
2441
+
2442
+ `tools/effect-usage.test.ts` allow-lists `BfPlatformService`'s `SystemBars.setStyle()` call (the
2443
+ native bars are external to any view). `tools/guides.test.ts` checks that the mobile demo's
2444
+ Android window background matches the guide's snippet and the theme's `bg` tokens.
2445
+ `tools/docs-contract.test.ts` now derives `BfSheetComponent` as an interactive widget (its grabber
2446
+ button and hidden Close) instead of listing it by hand.
2447
+
2448
+ New `e2e/large-text.spec.ts` (iphone and pixel): on Today, Projects, a pushed task and Me, at root
2449
+ font sizes 100 %, 200 % and 235 % and at a simulated Android font scale 2.0 (every `--text-*` token
2450
+ doubled, lengths unchanged, as Android's text zoom does), no header slot leaves the viewport or the
2451
+ bar, every tab label shows its text within two lines inside the bar, `--bf-tab-bar-inset-global`
2452
+ equals the bar's measured height plus its offsets (±1 px), and no list title is cut with an
2453
+ ellipsis unless its row sets `truncate`; in landscape (844×390, 915×412) Today and Projects render
2454
+ no large title and leave at least 60 % of the height to content. `e2e/header.spec.ts` now expects
2455
+ the full collapse, and the scroll-under state, at the large title's rendered height (the title
2456
+ still showing at 56 px).
2457
+
2458
+ ### packaging
2459
+
2460
+ `theme.css` registers `@source './fesm2022/*.mjs'` instead of the whole `fesm2022` folder, so
2461
+ Tailwind scans the FESM code and skips the source maps beside it; the maps stay in the package for
2462
+ debugging. The consumer smoke's `widgets` build checks that its stylesheet still has
2463
+ `.bg-primary-soft`, a class only library code uses, and `tools/theme-source.test.ts` pins the
2464
+ glob.
2465
+
2466
+ A pre-release heading without entries of its own (`## X.Y.Z-next.N`) is followed by one line
2467
+ naming the section that applies, `Pre-release of X.Y.Z: its changes are listed under "X.Y.Z
2468
+ (unreleased)" above.`, so a `next` installer reading the shipped CHANGELOG finds the notes.
2469
+ `tools/changelog.test.ts` checks that line, that the top heading is `(unreleased)` or dated with
2470
+ the `projects/ui/package.json` version, and that `### Migration` opens the top version and every
2471
+ unreleased version with breaks.
2472
+
2473
+ First npm release: `@bitforgehq/ui` 0.3.0 is published to the `latest` dist-tag
2474
+ (`bun add @bitforgehq/ui`). `package.json`'s `repository.url` points at the new home,
2475
+ `git+https://github.com/bitforge/BitforgeUI.git` (private). The never-published `0.3.0-next.1` and
2476
+ `0.2.0-next.1` headings are folded into their versions, and the 0.2.0 and 0.1.0 headings read
2477
+ `(not published)`. `tools/release-tag.test.ts` pins that `v0.3.0` resolves to `latest`. The
2478
+ release checklist and README "Releasing" describe the 0.3.0 publish from `bitforge/BitforgeUI`
2479
+ (the `NPM_TOKEN` secret and `npm` environment, the `FIREBASE_SERVICE_ACCOUNT_BITFORGE_UI` secret,
2480
+ the `v0.3.0` tag, `latest` without provenance).
2481
+
2482
+ ## 0.2.0 (not published)
2483
+
2484
+ ### overlay core
2485
+
2486
+ New overlay foundation of `@bitforgehq/ui/mobile` on the Angular CDK `Overlay` (no CDK dialog or
2487
+ bottom-sheet). `BfOverlayService.open(componentOrTemplate, options)` attaches the content in a
2488
+ global-positioned panel (`position`: `center` default, `bottom`, `top`, `fullscreen`; class
2489
+ `bf-overlay-panel` plus `panelClass`; scroll strategy as in the "Fixed" note below) over a `bf-overlay-backdrop` backdrop
2490
+ (a black scrim, `rgb(0 0 0 / 0.4)`, `0.6` under `.dark`, a new rule in `theme.css`; it fades out
2491
+ with the panel's exit, instantly under reduced motion); the panel gets `role` (default `dialog`,
2492
+ none for passive overlays), `aria-modal="true"`, `aria-label`/`aria-labelledby`/`aria-describedby`
2493
+ and `tabindex="-1"`, a CDK focus trap
2494
+ (`trapFocus`), focus on its first tabbable element (else the panel) and focus back to the
2495
+ previously focused element on close (`restoreFocus`). `Escape` (`dismissOnEscape`), a backdrop
2496
+ click (`dismissOnBackdrop`) and the hardware back button (priority `backPriority`, default 100,
2497
+ newest overlay first) close it. Only the topmost non-passive overlay (`BfOverlayStack.isTop()`)
2498
+ answers `Escape`, and it always claims the key: one that does not close on it (`dismissOnEscape:
2499
+ false`) swallows it, or runs `onEscape` instead, so it never reaches an overlay underneath. The content injects `BF_OVERLAY_DATA` and `BfOverlayRef`
2500
+ (`close(result?)`, `afterClosed: Promise<T | undefined>`, `closed` signal, `id`,
2501
+ `updatePosition()`); components get a `BfComponentOverlayRef` with `componentRef`; a template
2502
+ receives `{ $implicit: ref, data }`. `enter`/`exit` take WAAPI animations (default `fadeIn`/`fadeOut`;
2503
+ `close()` awaits the exit before detaching); `slideUp`, `slideDown`, `fadeIn`, `fadeOut` and `scaleIn`
2504
+ run 260 ms `cubic-bezier(0.32, 0.72, 0, 1)` and fall back to a 150 ms fade under
2505
+ `prefers-reduced-motion`. A `passive` overlay (toasts) has no backdrop, focus handling or back
2506
+ handler and leaves the page interactive. `BfOverlayStack` tracks the modal-like overlays (`count`,
2507
+ `depth` for `countsAsModal`) and, while any is open, sets `inert` and `--bf-modal-depth` on every
2508
+ registered page root; the outermost `bf-stack-outlet` registers its host (`registerRoot`) on init
2509
+ and unregisters on destroy. `provideBfShell()` provides both services.
2510
+
2511
+ Fixed: outside the shell (no `BF_SHELL_CONFIG`, e.g. a desktop page), a non-passive overlay now
2512
+ uses the CDK `block()` scroll strategy, so the document no longer scrolls behind an open dialog,
2513
+ sheet or modal (the "scroll bleed" at 1280 px). Passive overlays (toasts) and every overlay inside
2514
+ the shell, whose document never scrolls, keep the `noop()` strategy.
2515
+
2516
+ ### sheet
2517
+
2518
+ New `BfSheetService.open(componentOrTemplate, { snapPoints, initial, dismissible, data, ariaLabel })`
2519
+ in `@bitforgehq/ui/mobile`, a bottom sheet on the overlay core (`position: 'bottom'`, back priority
2520
+ 100, not counted as a modal; slides up on open and down on close). It returns a `BfSheetRef`
2521
+ (extends `BfOverlayRef`) with `snapTo(index)` and a `snapIndex` signal; the content injects the
2522
+ caller's `data` as `BF_OVERLAY_DATA` and the ref as `BfSheetRef` and `BfOverlayRef` (a template
2523
+ gets `{ $implicit: ref, data }`). The `bf-sheet` container is a `bg-surface rounded-t-card
2524
+ shadow-floating` card with `pb-safe-bottom`, a 36 × 5 px `bg-control-off` grabber (`aria-hidden`,
2525
+ `ButtonText` under forced colors)
2526
+ in a 24 px `touch-none` handle row and a content region; its height is `--bf-sheet-height`, a snap
2527
+ point (fraction of `BfKeyboardService.restHeight()`, else `innerHeight`; default `[0.5, 0.92]`)
2528
+ re-applied on resize. Dragging the handle row, any `[data-bf-sheet-drag]` element or the content
2529
+ (below the top point, or downward at the top point while scrolled to 0) moves the sheet with
2530
+ Pointer Events under pointer capture and a non-passive `touchmove` guard: rubber-banded (30 %)
2531
+ above the top point, released onto the nearest point or, faster than 0.3 px/ms, the next one in
2532
+ the fling's direction; a release more than 25 % of the lowest point's height below it, or a
2533
+ downward fling from it, dismisses. The content scrolls (`overflow-y-auto`) only at the top point,
2534
+ `overflow-hidden` below it, always `overscroll-contain`. `dismissible: false` ignores the backdrop,
2535
+ `Escape` and downward dismissal and swallows the hardware back button while open. Settling is a
2536
+ 260 ms WAAPI animation, instant under `prefers-reduced-motion` (the drag still tracks the
2537
+ pointer). The panel is `role="dialog"` `aria-modal` with `aria-label` (default "Sheet"), focus
2538
+ moves in and returns on close.
2539
+
2540
+ Fixed: the handle row shows a `grab` cursor (`grabbing` while pressed) and the grabber darkens to
2541
+ `text-muted` on hover.
2542
+
2543
+ Fixed (behaviour change): the soft keyboard no longer covers a sheet's focused field and actions.
2544
+ While the keyboard overlays the sheet's bottom edge (iOS with `Keyboard.resize: 'none'`, a mobile
2545
+ browser), `bf-sheet` is lifted by the covered height (an inline `translate`, bottom safe-area
2546
+ padding dropped, since the keyboard covers the home indicator); while the keyboard is up at all
2547
+ (also Android, where the WebView resizes and nothing is lifted) its height is capped at the
2548
+ keyboard's top edge minus the safe-area top minus 8 px (`max-height`), and the content region
2549
+ scrolls at every snap point, with `bfKeyboardAssist` revealing the focused control 24 px above the
2550
+ keyboard. The lift follows the keyboard with a 250 ms `translate` transition on the iOS curve
2551
+ (instant under `prefers-reduced-motion`), and is left alone once the sheet is closing. The snap
2552
+ heights (`--bf-sheet-height`) are unchanged, and a drag of a capped sheet starts from its rendered
2553
+ (capped) height, so it moves from the first pixel. Hiding the keyboard removes the lift and the
2554
+ cap together and at once (the drop is not animated, so a sheet at a snap point taller than the cap
2555
+ never has its top edge above the screen), so the sheet rests at its snap height again, and content
2556
+ left scrolled while the keyboard was up returns to its top when the keyboard hides below the top
2557
+ snap point (a drag between snap points without the keyboard keeps the scroll offset, as before).
2558
+
2559
+ ### modal
2560
+
2561
+ New `BfModalService.open(component, { data, presentation, dismissible, ariaLabel })` in
2562
+ `@bitforgehq/ui/mobile`, a page-level modal on the overlay core (`position: 'fullscreen'`, back
2563
+ priority 100, no backdrop dismiss). `presentation: 'sheet'` (default) renders `bf-modal` as a card
2564
+ inset 12 px from the top (`bg-bg rounded-t-card shadow-floating`) and, in iOS mode only, counts as
2565
+ a modal, driving `--bf-modal-depth` on every registered `bf-stack-outlet` root (see
2566
+ `### stack-outlet`) so the page behind scales down (Material mode leaves it unscaled);
2567
+ `'fullscreen'` renders it edge to edge (`bg-bg`, no radius or shadow) and does not count towards
2568
+ that depth. iOS mode slides the frame up on enter and down on exit; Material mode fades it up 16 px
2569
+ on enter and down on exit (new `fadeUp`/`fadeDown` helpers in `overlay-animations.ts`). It returns
2570
+ the `BfOverlayRef<T>` the content also injects (with the caller's `data` as `BF_OVERLAY_DATA`) and
2571
+ closes with a result through; unlike `bf-sheet` there is no drag-to-dismiss, so the content needs
2572
+ its own close control. `dismissible: false` (default true) ignores `Escape` and swallows the
2573
+ hardware back button while open instead of closing or falling through to the page behind. Like
2574
+ `bf-page`, `bf-modal` provides a fresh `BfPageContext` so the content's own `bf-header`
2575
+ (`largeTitle="false"`, typically a close `bf-icon-button` in `start`) and `bf-content` work
2576
+ inside it. `provideBfShell()` now also provides `BfSheetService` and `BfModalService`.
2577
+
2578
+ Fixed (behaviour change): a `presentation: 'sheet'` modal ends above the soft keyboard. While the
2579
+ keyboard overlays the card's bottom edge (iOS, a mobile browser), `bf-modal` gets an inline
2580
+ `padding-bottom` of the covered height, so the content's `bf-content` ends at the keyboard's top
2581
+ edge and its keyboard assist reveals the focused control there; it goes away with the keyboard. A
2582
+ `fullscreen` modal, and Android, where the WebView resizes, are unchanged.
2583
+
2584
+ ### toast
2585
+
2586
+ New `BfToastService.show({ message, action?, duration, position })` in `@bitforgehq/ui/mobile`, a
2587
+ queued status pill on the overlay core opened `passive: true` (no backdrop, no focus trap, the page
2588
+ stays interactive and it never counts towards `BfOverlayStack.count()`). Only one toast shows at a
2589
+ time; later `show()` calls queue in order behind a timed toast and appear once it ends (a persistent
2590
+ one gives way, see the behaviour changes below). It returns a `BfToastRef` (`dismiss()`,
2591
+ `afterDismissed: Promise<'timeout' | 'action' | 'dismiss'>`) — the toast resolves `'timeout'` after
2592
+ `duration` ms (`0` keeps it up until `dismiss()` or its action; the default is 3000 without an
2593
+ action and 0 with one, see below), `'action'` when the optional action button is clicked (its
2594
+ `handler` runs first), and `'dismiss'` from `dismiss()`, whether the toast is showing or still
2595
+ queued. `bf-toast` is an ink `bg-text text-bg rounded-full shadow-floating` pill (message
2596
+ `line-clamp-2`, announced through `LiveAnnouncer`, see the "Changed" note below); the action renders
2597
+ as a plain underlined `text-bg` button rather than `bf-button`'s `text` variant, which has no
2598
+ contrast on the ink surface. `position: 'bottom'` (default) keeps 16 px clear of the floating tab
2599
+ bar (`--bf-tab-bar-inset-global` on `<html>`, see `### tabs`) and the soft keyboard, written live as
2600
+ `--bf-toast-keyboard`: `BfKeyboardService.height()` where the keyboard overlays the WebView (native
2601
+ iOS), `0` where the WebView already resizes (Android, the web); `position: 'top'` sits 16 px below
2602
+ the status bar instead, as a top margin (`mt-[calc(var(--spacing-safe-top)+var(--spacing)*4)]`)
2603
+ outside the pill: an earlier `pt-safe-top` on the host overrode the pill's own `py-3` (0 top padding
2604
+ on the web, about 50 px on a notched iPhone). Enter fades in with a 12 px slide from its anchored
2605
+ edge, exit is a plain fade; both collapse to a fade-only under `prefers-reduced-motion`.
2606
+ `provideBfShell()` now also provides `BfToastService`. The action button has a 44 px hit area
2607
+ (`min-h-hit px-3`) that reaches into the pill's padding (`-my-2.5 -mx-3`), so the pill keeps its 48
2608
+ px height and width.
2609
+
2610
+ Fixed: the action button shows a pointer cursor and fades to 80 % opacity on hover.
2611
+
2612
+ Changed: with the new tab-bar inset (see `### tabs`, which now includes 8 px above the bar) a
2613
+ bottom toast sits 16 px above the inset and so clears the floating tab bar by 24 px (was 16 px).
2614
+
2615
+ Behaviour change: a toast with an `action` no longer auto-dismisses by default (`duration`
2616
+ defaults to `0` with an action, still `3000` without one; an explicit `duration` wins), so an
2617
+ "Undo" stays reachable for keyboard, switch and screen-reader users (WCAG 2.2.1). Every toast's
2618
+ timer pauses while the pointer is over it or focus is inside it and resumes with the remaining
2619
+ time. When a toast that holds focus closes (action or `dismiss()`), focus returns with
2620
+ `preventScroll` to the element it came from (the first `focusin`'s `relatedTarget`, else the
2621
+ element focused when it opened), if still in the page; before, it fell to `<body>`.
2622
+
2623
+ Changed: toasts are announced through CDK `LiveAnnouncer` (`announce(text, 'polite')`, once per
2624
+ toast as it shows; the text is the message plus the action label). The `bf-toast` host no longer
2625
+ carries `role="status"`/`aria-live="polite"`: a live region inserted together with its text was
2626
+ not reliably announced. Tests locating the pill by `role="status"` should use `bf-toast`.
2627
+
2628
+ Behaviour change: persistent toasts (`duration` 0, the default with an action) never block the
2629
+ queue — the next `show()` dismisses one, visible or still queued, resolving `'dismiss'` (the
2630
+ Material snackbar model; a timed toast still queues the next one). `Escape` while focus is inside a
2631
+ toast dismisses it (`'dismiss'`) and is claimed there. Focus returns only while the toast still
2632
+ holds focus at close time, to the element focused last outside it; once focus has fallen to the
2633
+ page body it is not moved to the element that was focused when the toast opened. The service ends
2634
+ every toast (timers and document listeners included) when its injector is destroyed, and the
2635
+ announcement joins a message ending in `:`, `;` or `,` to the action label with a space.
2636
+
2637
+ ### mobile entry
2638
+
2639
+ New secondary entry point `@bitforgehq/ui/mobile`, the Capacitor app shell for phase 2. Its first
2640
+ export is `BfPlatformService` (`providedIn: 'root'`): `isNative` and `platform` (`'ios' | 'android' | 'web'`)
2641
+ read once from `Capacitor.isNativePlatform()`/`Capacitor.getPlatform()`; `mode` (`'ios' | 'md'`)
2642
+ derives the shell mode from the platform; `theme` is a writable `signal<'system' | 'light' | 'dark'>`
2643
+ (default `'system'`) persisted to `localStorage` under `bf-theme`; `dark` follows the OS `prefers-color-scheme` while
2644
+ `theme` is `'system'` and otherwise reflects the explicit choice, applied as the `dark` class on
2645
+ `<html>` in an effect. `@angular/cdk`, `@angular/router` and `@capacitor/core` are optional peers
2646
+ of the package that an app importing `@bitforgehq/ui/mobile` must install (a web-only consumer that
2647
+ never imports this entry does not need them); `lucide` is a required peer of the package, since
2648
+ `bf-back-button` renders a Lucide icon (see `### packaging`). `@capacitor/app`,
2649
+ `@capacitor/keyboard` and `@capacitor/haptics` are optional peers for apps that use those
2650
+ plugins. Widgets in `@bitforgehq/ui`/`@bitforgehq/ui/icons` still may not import from
2651
+ `mobile/` (lint-enforced); the mobile entry may import widgets.
2652
+
2653
+ Added: `resolveShellMode(config, platform)` and `injectShellMode()` exports: the shell mode in
2654
+ effect (`config.mode`, or `BfPlatformService.mode()` for `'auto'`), the resolution the stack outlet,
2655
+ the modal service, `bf-header` and `bf-back-button` share, so apps can follow the same per-mode
2656
+ conventions (e.g. text vs icon header actions).
2657
+
2658
+ ### stack-outlet
2659
+
2660
+ New `bf-stack-outlet`: a router outlet that keeps a stack of live pages instead of destroying the
2661
+ one you leave, implementing `RouterOutletContract` in place of `<router-outlet>`. A push slides
2662
+ the new page in and parks the previous one (`visibility: hidden`, `inert`, `aria-hidden`, detached
2663
+ change detector, scroll and state kept); a pop slides the top page out, destroys it and restores
2664
+ the page below with focus returned (`preventScroll`) to whatever had it. Push vs. pop is
2665
+ classified from the router's navigation trigger and the history positions the router records
2666
+ (`computed` resolution), so `replaceUrl`, redirects, refused guards, `history.go(-n)` and browser
2667
+ forward resolve correctly. A pop always goes back to the history entry before the page was pushed,
2668
+ in one step, even when the page added entries of its own (query-only or nested child
2669
+ navigations); a swipe whose history step does not pop the outlet slides the page back. Transitions follow `BfShellConfig.mode`: iOS is a 500 ms
2670
+ slide-and-parallax under a dimming layer with an edge-swipe-back (50 px left edge, Pointer Events,
2671
+ scrubs the pop's `currentTime`, completes on velocity or half-width, `Location.back()` on
2672
+ completion); Material is a 220 ms fade-up with no gesture; `prefers-reduced-motion` reduces both to
2673
+ a 150 ms cross-fade. `stackChange`, `transitionStart` and `transitionEnd` outputs report depth and
2674
+ transition state. `BfNavController` (root, `providedIn: 'root'`) exposes `forward()`, `back()`,
2675
+ `root()`, `canGoBack` and `depth` (see `### nav controller`; `back()` pops the top page in one
2676
+ history step); `withBfShellRouting()` sets
2677
+ `canceledNavigationResolution: 'computed'` so a refused back navigation stays in sync with the
2678
+ outlet. The outlet binds route params, query params and route data to its pages' inputs itself
2679
+ (`bindInputs`), so a parked page's `ActivatedRoute` staying stale after a pop is never a problem.
2680
+ Every primary outlet registers with `BfNavController` on init (`registerOutlet(outlet, level)`,
2681
+ `level` = number of stack outlets around it) and unregisters on destroy; it no longer registers
2682
+ a hardware back handler of its own.
2683
+
2684
+ Multi-stack: inside `bf-tabs` (optional `BF_TAB_CONTEXT`) the outlet keeps one stack per tab key
2685
+ (`Map<tabKey, StackEntry[]>`). A navigation onto another tab key is a `switch`: the whole current
2686
+ stack is parked (every page hidden, `inert`, change detection detached), and the target stack is
2687
+ restored — its page for the URL re-activated (attached, visible, focus to its title) with any
2688
+ pages above it dropped — or started with a new page; no transition and no gesture. A `root`
2689
+ navigation into another tab replaces only that tab's stack. `depth`/`canGoBack` reflect the active
2690
+ stack, pops stay within it, and `stackChange` now emits `{ depth, tabKey }` (`tabKey` null outside
2691
+ `bf-tabs`). New public methods `lastUrlOf(tabKey)` (URL of a tab's top page), `popToRoot()` (goes back in
2692
+ history to the active stack's first page in one step; false when its position is unknown) and
2693
+ `scrollActiveContentToTop(behavior?)` (through the active page's `BfPageContext` scroller, which
2694
+ the outermost `bf-page` now registers with the outlet).
2695
+
2696
+ Nested outlets: an outlet inside a page of another outlet (the outlet in `bf-tabs` on a tabs page
2697
+ hosted by the root outlet) keeps its stacks while the outer outlet parks that page. Each page gets
2698
+ a per-page host token that tells the outlets inside it whether the page is shown; when the router
2699
+ deactivates the inner outlet because the outer one pushed a page above its host (e.g.
2700
+ `/tabs/today/task/1` → `/gallery`), the inner outlet suspends instead of destroying its stacks,
2701
+ and when the outer outlet pops back it resumes the same page (same DOM nodes, every tab stack
2702
+ intact) without a transition. A history step that lands on another page of the inner stack pops
2703
+ within it as usual. The page shown by the inner outlet follows its host page's lifecycle: it gets
2704
+ `willLeave`/`didLeave` when the host is parked and `willEnter`/`didEnter` when it comes back
2705
+ (forwarded only on a real state change, so no duplicate events). An edge swipe is claimed by the
2706
+ innermost outlet that can pop — also while that outlet is mid-transition, where the swipe is
2707
+ ignored — so nested outlets never pop together.
2708
+
2709
+ Modal depth: the outlet's host now carries a scale rule keyed to `--bf-modal-depth` (written by
2710
+ `BfOverlayStack` on every registered outlet root while a page-sheet `bf-modal` is open — see
2711
+ `### modal`): `transform: scale(calc(1 - var(--bf-modal-depth, 0) * 0.08))
2712
+ translateY(calc(var(--bf-modal-depth, 0) * 8px))`, `border-radius: calc(var(--bf-modal-depth, 0) *
2713
+ 12px)`, a 260 ms transition on both (skipped under `prefers-reduced-motion`), `transform-origin:
2714
+ top center`. At depth 0 the rule is a no-op; each stacked modal scales the page stack down another
2715
+ 8 % and rounds its corners another 12 px.
2716
+
2717
+ ### nav controller
2718
+
2719
+ `BfNavController` keeps a registry of the primary stack outlets and their nesting level; `depth`
2720
+ and `canGoBack` follow the **deepest active outlet** (the innermost one whose page is shown: the
2721
+ tab stack inside `bf-tabs`, or the root outlet once it pushed a page above the tabs page), and
2722
+ `back()` pops the deepest active outlet that has a page to pop, with that outlet's one-step
2723
+ history logic. It registers the priority-0 hardware back handler once (instead of one per
2724
+ outlet): it pops while an active outlet has more than one page and lets `BfBackButtonService`
2725
+ exit the app only when every active outlet is at depth ≤ 1 (a parked outlet cannot be popped, so
2726
+ it never blocks the exit). `setDepth()` and `registerPopTarget()` are replaced by
2727
+ `registerOutlet(outlet, level)` (called by `bf-stack-outlet`). Fixes a page pushed on the root
2728
+ outlet above the tabs page reporting depth 0 (hardware back exited the app) and destroying every
2729
+ tab stack, and `nav.root()` from inside the tabs leaving the root outlet without a pop target.
2730
+
2731
+ ### page
2732
+
2733
+ New `bf-page`: the page frame (`absolute inset-0 flex flex-col bg-bg`) that `bf-stack-outlet`
2734
+ positions one of per stacked page. Injects the per-page `BfViewLifecycle` the outlet provides
2735
+ (falling back to a local, inert instance when used standalone, e.g. in a docs demo) and re-exposes
2736
+ it as `viewState` (`computed`) plus outputs `bfViewWillEnter`, `bfViewDidEnter`, `bfViewWillLeave`,
2737
+ `bfViewDidLeave`, delivered synchronously through `BfViewLifecycle.on()`: a new page gets
2738
+ `bfViewWillEnter` and `bfViewDidEnter` (also the first page, without animation and after
2739
+ `nav.root()`), and a popped or root-replaced page gets `bfViewWillLeave` and `bfViewDidLeave` before
2740
+ it is destroyed. Carries no `tabindex` itself; the outlet still owns push/pop focus
2741
+ management on its own wrapper.
2742
+
2743
+ `bf-page` now provides a per-page `BfPageContext` (`providers: [BfPageContext]`) whose `scroller`
2744
+ signal (`HTMLElement | null`) is where `bf-content` registers its scroll container on init and
2745
+ clears it on destroy, so `bf-header` can follow the page's scroll without querying the DOM. Its
2746
+ `headerHeight` signal (px, 0 by default) is where a `translucent` `bf-header` publishes its
2747
+ expanded height for `bf-content` to pad by.
2748
+
2749
+ Inside `bf-stack-outlet`, the outermost `bf-page` of a stacked page also registers its
2750
+ `BfPageContext` with the outlet (a nested `bf-page` leaves it), so the outlet can scroll the page's
2751
+ content to the top when its tab is re-selected.
2752
+
2753
+ ### back-button
2754
+
2755
+ New `bf-back-button`: a `bf-icon-button` (`text` variant, chevron-left) that pops the primary
2756
+ stack via `BfNavController.back()`, or replaces it with `defaultHref` via `nav.root()` when there
2757
+ is nothing to pop back to (a deep link landing directly on the page). Visible when
2758
+ `nav.canGoBack()` or `defaultHref` is set, hidden otherwise. `label` (default `"Back"`) is the
2759
+ button's accessible name.
2760
+
2761
+ Changed: the anatomy follows the shell mode (resolved like `bf-stack-outlet`). iOS: a native
2762
+ button with a 24 px chevron-left and `label` as visible text (`text-heading`, regular) in
2763
+ `primary`, one 44 px tall control (`h-11 min-w-hit`, text truncated past 192 px), named by its
2764
+ text. Material: a bare 24 px arrow-left `bf-icon-button` (`text` variant) with `label` as its
2765
+ `aria-label`. `defaultHref` works the same in both.
2766
+
2767
+ New `BfBackButtonService` (`providedIn: 'root'`, registered and started at bootstrap by
2768
+ `provideBfShell()`'s environment initializer, together with `BfPlatformService`): the hardware
2769
+ back button queue. `register(priority, handler)` returns an unregister function; `dispatch()`
2770
+ (public, for tests and 2c's overlays) runs the registered handlers from highest to lowest priority
2771
+ until one returns true (on equal priority the newest registration runs first, so stacked overlays
2772
+ close top-down), then falls back to `App.exitApp()`; `BfNavController` registers the priority-0
2773
+ handler that pops the deepest active stack while any stack has more than one page. On native Android it subscribes to
2774
+ `App.addListener('backButton', …)` via `await import('@capacitor/app')` in a try/catch (never a
2775
+ static import, so a web or iOS bundle never loads the plugin); nothing is registered on iOS or
2776
+ web, where `Escape` closes overlays instead.
2777
+
2778
+ ### horizontal-scroll
2779
+
2780
+ New `bfHorizontalScroll`: an attribute directive (`touch-pan-x touch-pan-y` host classes plus a
2781
+ `data-bf-horizontal-scroll` marker) that opts a chips row or carousel back into horizontal panning
2782
+ inside a `bf-stack-outlet` page. The outlet host is `touch-action: pan-y`, and its pages inherit
2783
+ that constraint down to their own scroll containers, so the edge swipe-back can claim horizontal
2784
+ drags. It only sets `touch-action`; the outlet's edge guard does
2785
+ not inspect the attribute, since the swipe only arms inside the 50 px left-edge zone, where it
2786
+ always wins regardless of what's under the finger.
2787
+
2788
+ ### content
2789
+
2790
+ New `bf-content`: the page's single scroll container (host `block min-h-0 flex-1`, scroller
2791
+ `h-full overflow-y-auto overscroll-contain touch-pan-y pl-safe-left pr-safe-right` with
2792
+ `padding-bottom: var(--bf-tab-bar-inset, 0px)`, plus `pt-safe-top` when `fullscreen`, otherwise
2793
+ `padding-top` and `scroll-padding-top` of `var(--bf-header-height, 0px)`, a variable it writes on
2794
+ its scroller from `BfPageContext.headerHeight` while a `translucent` header is drawn over it). It applies
2795
+ `bfKeyboardAssist` to its scroller (`keyboardAssist`, default `true`), emits `scrolled`
2796
+ (`{ scrollTop }`) from a passive listener only while `scrollEvents` is true, offers
2797
+ `scrollToTop(behavior = 'smooth')`, `scrollToPoint(x, y, behavior)` and a `scrollElement` getter,
2798
+ and registers its scroller in the page's `BfPageContext`.
2799
+
2800
+ Changed: the projected content now sits in a plain block body inside the scroller. In iOS mode
2801
+ (`BfPlatformService.mode()`) the body is `min-h-[calc(100%+1px)]`, so the scroller always overflows
2802
+ by 1 px and WebKit rubber-bands short pages too (it only bounces an overflowing scroller); in
2803
+ Material mode the scroller carries `bfOverscrollStretch`, the Android edge stretch the WebView does
2804
+ not draw. `bf-refresher` without a page context now tracks its nearest scrolling ancestor (past
2805
+ the body wrapper) instead of its parent element. The stretch stays off in iOS WebKit (Safari or a
2806
+ home-screen app, where Capacitor reports platform `web` and the mode is Material, detected from an
2807
+ `iPhone`/`iPad`/`iPod` user agent or `MacIntel` with more than one touch point), which rubber-bands
2808
+ natively, so the two never stack.
2809
+
2810
+ Migration: the body's height is `auto`, so children that filled the scroller with `h-full` (a
2811
+ centred empty state) now collapse, and `min-h-full` or a `flex-1` inside a wrapper sized that way
2812
+ collapses too. Size such a child from its content or the viewport on your own wrapper
2813
+ (`min-h-[60dvh]`), not with `h-full`. The overscroll (the iOS bounce, the Android stretch and the
2814
+ refresher's iOS bounce mode) follows the engine (`BfPlatformService.mode()`), not
2815
+ `provideBfShell({ mode })`: Chrome never reports a negative `scrollTop`, so an Android app forced
2816
+ into the iOS look keeps the stretch and the material pull.
2817
+
2818
+ Fixed: the scroller's bottom padding also adds `--bf-header-collapse`, the height a solid
2819
+ `bf-header` has collapsed by (`pb-[calc(var(--bf-tab-bar-inset,0px)+var(--bf-header-collapse,0px))]`,
2820
+ replacing `pb-(--bf-tab-bar-inset,0px)`), so the scroll range stays constant while the large title
2821
+ collapses. The keyboard assist's inline `padding-bottom` still overrides it while the keyboard is up.
2822
+
2823
+ ### header
2824
+
2825
+ New `bf-header`: a 44 px toolbar (`pt-safe-top`, `start` | title | `end` slots, `bfSlot="start"` /
2826
+ `bfSlot="end"`) and, with `largeTitle` (default `true`), a Meadow Sage large title
2827
+ (`text-display`) below it with an optional `subtitle` eyebrow (`text-caption text-text-muted`)
2828
+ above the title. Exactly one `h1` carries `data-bf-page-title`, the focus target `bf-stack-outlet`
2829
+ moves to after a push — the large title's when `largeTitle` is true, the toolbar's otherwise; with
2830
+ `largeTitle`, the toolbar shows a second, `aria-hidden="true"` `span` mirror of the title for the
2831
+ visual collapse only. `title` empty projects the default-slot content into whichever `h1` is
2832
+ active instead. Reads its sibling scroller from the page's `BfPageContext` (set by `bf-content`)
2833
+ through an `effect`: once non-null it attaches a passive `scroll` listener that maps the first
2834
+ 56 px of scroll to `--bf-header-progress` (0…1, 3 decimals, written to the host only when it
2835
+ changes) and detaches on scroller change or destroy. That variable drives the large title's
2836
+ opacity and an 8 px upward translate, the toolbar mirror's fade-in and a `divider` hairline that
2837
+ fades in with it; the same listener sets the large title block's height to its natural height (measured
2838
+ after render and on `ResizeObserver` changes, never per frame) times `1 − progress`, so at
2839
+ progress 1 the header is exactly the toolbar. Scrolling schedules no Angular change detection;
2840
+ `prefers-reduced-motion` drops the translate. Solid (default), the header is in the page's flow on
2841
+ `bg-bg`; `translucent` draws it over the content (`absolute inset-x-0 top-0 z-(--bf-z-sticky)`,
2842
+ `bg-bg/85 backdrop-blur`) and publishes its expanded height in `BfPageContext.headerHeight`, which
2843
+ `bf-content` pads its scroller by. The large title block is inset by the 16 px screen gutter
2844
+ (`px-gutter`, was `px-5`), so it lines up with page content padded the same way.
2845
+
2846
+ Fixed: the large title no longer sits half-collapsed or collapses during a rubber-band. The
2847
+ progress is computed from `scrollTop` clamped to `[0, scrollHeight − clientHeight]`, so overscroll
2848
+ past either end never moves the header, and stays 0 on a page that cannot scroll the full 56 px
2849
+ (such as a short iOS page, whose body overflows by only 1 px). When a scroll ends part-way
2850
+ (`scrollend`, or 120 ms without a `scroll` event, whichever comes first) with no touch or pointer
2851
+ down on the scroller (passive `touchstart`/`touchend`/`touchcancel` and
2852
+ `pointerdown`/`pointerup`/`pointercancel` listeners), the header scrolls the scroller to 0 below
2853
+ half-way, else to 56 px (`behavior: 'smooth'`, `'instant'` under `prefers-reduced-motion`). The
2854
+ snap's own scroll never schedules another, and it is skipped while `BfKeyboardService.visible()`
2855
+ is true, on a page hidden by `visibility` (`checkVisibility({ visibilityProperty: true })`) and
2856
+ after destroy; its timer is cleared on scroller change and destroy. A mouse released outside the
2857
+ scroller is heard on the `document` (`pointerup`/`pointercancel`, listened to only while a press
2858
+ is tracked), and a press released without a scroll in between (a tap) schedules no snap.
2859
+
2860
+ Fixed: on a page whose rest scroll range lies between 56 px and 56 px plus the large title's
2861
+ height, the header flipped between open and part-collapsed on every frame (measured on a search
2862
+ page at 393 × 800: `scrollTop/max@progress` `40/72@0 → 35/35@0.714 → 40/72@0 …`): the collapse made
2863
+ the scroller taller, the range fell under 56 and the short-page rule reset the progress. The header
2864
+ now writes the collapsed height (`largeHeight × progress` in px, the exact height the block gave
2865
+ up; `0px` at rest, when translucent or without a large title) as `--bf-header-collapse` on the
2866
+ page's scroller together with `--bf-header-progress`, and removes it on destroy (or from a
2867
+ replaced scroller); `bf-content` pads the scroller's bottom by it, so `scrollHeight − clientHeight`
2868
+ stays the rest range and the short-page rule and the clamp read one stable value.
2869
+
2870
+ Fixed: the centred toolbar title sat in the space left between the `start` and `end` slots, so a
2871
+ back button or an avatar pushed it off the bar's centre. It is now laid out over the whole toolbar
2872
+ row (`absolute`, `pointer-events-none`, truncating) with symmetric insets: the header measures both
2873
+ slot containers with its `ResizeObserver` and writes `--bf-header-title-inset` (the 8 px row
2874
+ padding + the wider slot's width + 8 px; 16 px without slots) on its host, which the title box
2875
+ uses for `left` and `right`.
2876
+
2877
+ Changed: the bar follows the shell mode (`provideBfShell({ mode })`, the platform's for `'auto'`,
2878
+ resolved like `bf-stack-outlet`; the host carries `data-bf-mode`). iOS: a 44 px bar with the
2879
+ title (`text-heading font-semibold`, the closest step to UIKit's 17 pt semibold) centred on it.
2880
+ Material: a 64 px bar with the title (`text-heading font-normal`, 18 px, the closest step to
2881
+ Material's 22 dp regular title) leading after the `start` slot, 16 px from the edge without one,
2882
+ never centred. The large title stays left-aligned and collapses the same way in both modes.
2883
+
2884
+ Changed: scroll-under appearance. The scroll listener also sets `data-bf-scrolled` on the host
2885
+ while content is under the bar: with a large title once it has fully collapsed (progress 1; like
2886
+ UIKit, the large title is part of the scroll-edge bar, so a page too short to collapse never gets
2887
+ it), without one (`largeTitle` false) while `scrollTop` (clamped, so a rubber-band past the top does
2888
+ not count) is above 0, on any page length. iOS: opaque `bg-bg` at rest; scrolled under, `bg-bg/85` with `backdrop-blur-xl` and a 1 px
2889
+ `border`-coloured hairline under the whole header (the host's `::after`, so the header's height
2890
+ never changes; it replaces the `divider` hairline that faded in with the collapse). Material: no
2891
+ hairline ever; scrolled under, a `surface-muted` tint with a 150 ms colour transition
2892
+ (`motion-safe`) and no blur. `translucent` keeps its meaning (drawn over the content, which
2893
+ `bf-content` pads) and now takes the same per-mode surface instead of a permanent
2894
+ `bg-bg/85 backdrop-blur`.
2895
+
2896
+ Changed: the large title's exit is spelled in utilities instead of component CSS (the header has
2897
+ no `styles` any more): inside the collapsing clip the block fades (`opacity: 1 − progress`) and
2898
+ rises 8 px (`translateY(−8px × progress)`, `motion-safe` only, so reduced motion keeps the fade
2899
+ and drops the rise), and the toolbar mirror fades in (`opacity: progress`), all read from
2900
+ `--bf-header-progress`, never transitioned.
2901
+
2902
+ ### keyboard-assist
2903
+
2904
+ New `bfKeyboardAssist` directive for a scroll container: on keyboard will-show it sets
2905
+ `padding-bottom` to the covered part of the container (`max(0, bottom − (restHeight −
2906
+ keyboardHeight))`) and `scroll-padding-bottom` to that plus `bfKeyboardAssistMargin` (default 24),
2907
+ then scrolls the focused `input`, `textarea` or `[contenteditable]` until it sits the margin above
2908
+ the keyboard — on the container's first `scroll` event after will-show or after 120 ms, whichever
2909
+ comes first (after WebKit's own focus scroll), with one instant correction at did-show and the same
2910
+ delayed reveal on `focusin` while the keyboard is open. The covered part is measured again at
2911
+ did-show and on every window or `visualViewport` resize while the keyboard is up, so on Android,
2912
+ where the WebView shrinks about 20 ms after will-show, the padding ends at 0. Both paddings and any
2913
+ pending reveal are removed on hide; non-text controls are ignored. `bfKeyboardAssistBehavior` is `'smooth'` (default)
2914
+ or `'instant'`; `[bfKeyboardAssist]="false"` turns it off.
2915
+
2916
+ Fixed: over-scroll on Android and padding left behind on parked pages. The covered part is now
2917
+ `max(0, bottom − BfKeyboardService.keyboardTop())`, so a WebView that has already shrunk at
2918
+ will-show gets no padding (it was padded by the whole keyboard, and the engine's focus reveal
2919
+ honoured the inflated `scroll-padding-bottom` and pushed the field under the header). A hidden host
2920
+ (`checkVisibility({ visibilityProperty: true })` false, so a parked page's `visibility: hidden`
2921
+ counts) gets no padding or reveal, drops an earlier padding and cancels a pending reveal;
2922
+ every window or `visualViewport` resize measures again whether or not the keyboard is up, so a
2923
+ padded scroller always ends at 0. The reveal now waits for the scroller to settle (100 ms after its
2924
+ last `scroll` event, 120 ms if none arrives, at most 400 ms) and scrolls once from the settled
2925
+ rects; the did-show correction also waits for quiet and is skipped while a reveal is pending.
2926
+
2927
+ Fixed: in a container too short for the focused control and both margins (a sheet or modal
2928
+ capped by the keyboard in phone landscape), the reveal and the did-show correction no longer
2929
+ ping-pong between the two edges and leave the control clipped or 2 px into the keyboard gap: the
2930
+ gap above the control shrinks first, then the one above the keyboard, and a control shorter than
2931
+ the container stays whole (a taller one keeps its bottom at the keyboard's top edge). With room
2932
+ for both margins nothing changes.
2933
+
2934
+ New `refresh()`: measures the covered part again and, while the keyboard is up, corrects the
2935
+ reveal once, for a container that moves with the keyboard outside the keyboard events (`bf-sheet`
2936
+ calls it when its lift transition ends, so a padding measured while the sheet was still sliding up
2937
+ does not stay behind).
2938
+
2939
+ ### overscroll-stretch
2940
+
2941
+ New `bfOverscrollStretch` directive for a scroll container: Android-style edge stretch, since an
2942
+ Android WebView draws no overscroll effect for an inner scroller. A vertical touch drag past the
2943
+ top or bottom edge scales the container's first element child with `scaleY(1 + 0.14 × (1 −
2944
+ e^(−pull / 240)))` from that edge (`transform-origin: center top` / `center bottom`,
2945
+ `will-change: transform` during the gesture) and springs it back on release with the Web
2946
+ Animations API (220 ms, `SETTLE_EASING`; instant under `prefers-reduced-motion`), then clears its
2947
+ inline styles. Passive touch listeners only, never `preventDefault`; the axis is decided after
2948
+ 8 px and a horizontal-dominant gesture is ignored; the top edge is skipped while an enabled
2949
+ `bf-refresher` (`[data-bf-refresher]`) is inside the container. `[bfOverscrollStretch]="false"`
2950
+ leaves it inert. The gesture follows the finger that started it (by `Touch.identifier`) and ends
2951
+ when that finger lifts; other fingers are ignored.
2952
+
2953
+ ### keyboard service
2954
+
2955
+ New `BfKeyboardService` (`providedIn: 'root'`, registered and started at bootstrap by
2956
+ `provideBfShell()`): `height`, `visible`, `phase` (`'hidden' | 'showing' | 'shown'`),
2957
+ `overlaysContent` (true on native iOS), `restHeight`, `willShow` (a counter that increments on every
2958
+ will-show) and `hide()`. On native it loads `@capacitor/keyboard` with a dynamic import (a silent
2959
+ no-op without the plugin) and listens to will/did show/hide; either hide event moves straight to
2960
+ `hidden`, because iOS can deliver `keyboardDidHide` before `keyboardWillHide`. On the web it falls
2961
+ back to `visualViewport` resizes with an 80 px threshold; in Jest and SSR it is inert. `restHeight`
2962
+ comes from a fixed `100lvh` probe element appended to `document.body`, never `innerHeight`. The
2963
+ height is also written as `--bf-keyboard-height` on `<html>` (`0px` while hidden, removed on
2964
+ destroy).
2965
+ `hide()` calls `Keyboard.hide()` on native and blurs the focused element on the web.
2966
+
2967
+ Added `imminent`, a signal that is true from `focusin` on a text-entry control until its
2968
+ `focusout` or either keyboard hide event (never cleared by will-show): on Android the WebView
2969
+ shrinks before the plugin's `keyboardWillShow`, so chrome such as the tab bar hides on
2970
+ `visible() || imminent()`. When it turns true with the keyboard down it also clears itself after
2971
+ 2500 ms if neither will-show nor a visible keyboard arrives, so a hardware keyboard (iPad Magic
2972
+ Keyboard, a simulator with the host keyboard), which fires no keyboard event, no longer keeps the
2973
+ tab bar hidden while a field has focus. Added `keyboardTop()`, the keyboard's top edge in viewport px
2974
+ (`restHeight() − height()` on native iOS, the bottom of `visualViewport` — else `innerHeight` —
2975
+ where the viewport shrinks, `restHeight()` while hidden). `isTextEntry(element)` is now exported.
2976
+ Fixed: the rest height is no longer measured at will-show, where the Android WebView has already
2977
+ shrunk (it read 554 instead of 866 px and made `bfKeyboardAssist` over-scroll); it is measured at
2978
+ construction and on window resizes while the keyboard is hidden and no text control is focused.
2979
+
2980
+ Fixed: on the web, a pinch-zoomed visual viewport (`visualViewport.scale` more than 0.01 from 1)
2981
+ is never read as a keyboard, so zooming a web build (zoom stays on there) no longer lifts sheets or
2982
+ pads scrollers; zooming in with the keyboard up reports it hidden until the page is back at 1×.
2983
+
2984
+ ### refresher
2985
+
2986
+ New `bf-refresher`: pull-to-refresh, placed as the first child of `bf-content`. It tracks Pointer
2987
+ Events on the page's scroller (read from `BfPageContext`, falling back to its own parent element)
2988
+ only while `scrollTop === 0`. A press by itself changes nothing; only once a move clears an 8 px
2989
+ downward slop (out-vertical'ing any horizontal travel) does it claim the gesture, capture the
2990
+ pointer on the scroller, and start resisting the drag at `delta * 0.5` (capped at 72 px) into a
2991
+ 40 px `bg-surface shadow-raised rounded-full` disc holding a `bf-spinner`, from a resting −48 px
2992
+ up to a 64 px threshold — so a plain click on the content underneath (a row, a button) still
2993
+ reaches it. Crossing the threshold ticks `BfHapticsService.impact('light')` once; releasing past
2994
+ it sets `refreshing` (a readonly signal), parks the disc at 0 and emits `refresh` with `{
2995
+ complete: () => void }` — releasing before the threshold animates the disc back to rest instead.
2996
+ `complete()` animates the disc away and clears `refreshing`, after which the next pull starts
2997
+ working again; `disabled` stops tracking pulls entirely, and further pulls are ignored while
2998
+ already `refreshing`. The park/settle/`complete()` transitions use the Web Animations API;
2999
+ `prefers-reduced-motion` only skips those — the disc still tracks the pointer 1:1 during the pull
3000
+ itself, since direct manipulation isn't motion. For touch, a non-passive `touchmove` guard,
3001
+ attached only while a press is tracked, prevents a downward drag at the top (and every move once
3002
+ claimed) so the browser does not take it as a pan and cancel the pointer stream; upward drags keep
3003
+ scrolling. Only the scroller's own `lostpointercapture` ends a pull, not the bubbling one from the
3004
+ row that held the touch's implicit capture. The disc (with its spinner) is `aria-hidden` and
3005
+ `visibility: hidden` until a pull is claimed (so its shadow never bleeds above the content); a
3006
+ separate, permanent visually hidden `role="status" aria-live="polite"` region is empty at rest and
3007
+ reads "Refreshing" while a refresh runs. It never touches the scroller's `overscroll-behavior`.
3008
+
3009
+ Changed: on iOS a touch rides the native bounce. In iOS mode (`BfPlatformService.mode()`) a touch
3010
+ press (`pointerType === 'touch'`, or a `touchstart`, tracked with passive listeners) is not
3011
+ claimed — no pointer capture, no `preventDefault`, no `touchmove` guard — so the scroller
3012
+ rubber-bands natively. While a finger is down, passive `scroll` events with a negative `scrollTop`
3013
+ show the disc at its resting −48 px, inside the gap the bounce opens, once `−scrollTop > 8`, with
3014
+ `opacity` = progress and `scale(0.6 + 0.4 × progress)` (progress = `min(1, −scrollTop / 64)`) and
3015
+ one light haptic at 1. Lifting the last finger at `−scrollTop ≥ 64` sets `refreshing`, settles the
3016
+ disc to 0 and emits `refresh`; otherwise the disc hides. A negative `scrollTop` with no finger down
3017
+ (a fling's overshoot) never shows it. Material mode and a mouse or pen in iOS mode keep the pull
3018
+ unchanged. The host carries `data-bf-refresher` while not `disabled`. A pen press in iOS mode,
3019
+ which also fires touch events, never starts a bounce, so its trailing `touchend` cannot hide the
3020
+ parked disc; the bounce writes the disc's styles only when it shows, changes or hides, not on
3021
+ every scroll; and a scroller change or destroy mid-bounce resets the disc's `opacity`, `transform`
3022
+ and `visibility`.
3023
+
3024
+ ### haptics service
3025
+
3026
+ New `BfHapticsService` (`providedIn: 'root'`, registered by `provideBfShell()`): `impact('light' |
3027
+ 'medium' | 'heavy')`, `selection()` and `notification('success' | 'warning' | 'error')`, all
3028
+ `Promise<void>`. On native it loads `@capacitor/haptics` with a dynamic import (cached after the
3029
+ first call) and forwards to `Haptics.impact()`, `Haptics.selectionChanged()` and
3030
+ `Haptics.notification()`; on the web, and when the plugin fails to load, every method resolves as
3031
+ a silent no-op without importing anything.
3032
+
3033
+ ### tabs
3034
+
3035
+ New `bf-tabs` (`tabsPath` required input): the tabs page with one navigation stack per tab. It
3036
+ provides `BF_TAB_CONTEXT` (`tabKey` signal from `NavigationEnd.urlAfterRedirects`, `keyOf(url)` =
3037
+ the primary segment after `tabsPath`, parsed with `Router.parseUrl`, so query, fragment, matrix
3038
+ params and auxiliary routes are ignored) to the projected `bf-stack-outlet`, sets the
3039
+ projected `bf-tab-bar`'s `active` to the tab key, and handles its selections: another tab navigates
3040
+ with `nav.forward()` to where that tab was left (`outlet.lastUrlOf(key)`, else `/<tabsPath>/<key>`)
3041
+ and, once it has succeeded, `BfHapticsService.selection()` when `hapticsOnTabChange`, putting the
3042
+ bar back (without a haptic) if a guard refuses; the active tab again (`tabReselect`) at depth > 1 pops the stack to its existing root page
3043
+ (`outlet.popToRoot()`, one `Location.historyGo(-n)` step computed from the outlet's history
3044
+ positions, so the root keeps its DOM, scroll and state), falling back to
3045
+ `nav.root(['/', tabsPath, key])` when the position is unknown; at depth 1 it scrolls the active
3046
+ page's content to the top. It hides the bar while
3047
+ `BfKeyboardService.visible()` (`setHidden()`, animated on iOS only, instant on Android and the web) and writes
3048
+ `--bf-tab-bar-inset` on its host: `calc(64px + 24px + var(--spacing-safe-bottom))` while a bar is
3049
+ present and visible, `0px` otherwise. The same value is mirrored on `<html>` as
3050
+ `--bf-tab-bar-inset-global` for overlays, which render outside the host (the toast reads it), and
3051
+ removed on destroy; `bf-content` keeps reading `--bf-tab-bar-inset`, so a page outside `bf-tabs`
3052
+ gets no bottom padding.
3053
+
3054
+ Fixed: on Android the bar flashed above the keyboard, because the WebView shrinks (lifting the fixed
3055
+ bar) before `keyboardWillShow`. The bar now hides on `BfKeyboardService.visible() ||
3056
+ BfKeyboardService.imminent()`, i.e. already on `focusin` of a text control.
3057
+
3058
+ Fixed: an empty `tabsPath` (the tabs mounted at the root URL) built `['/', '', key]`, a doubled
3059
+ slash the router turned into a broken navigation. `onTabChange` and `onTabReselect` now build the
3060
+ command array from the non-empty segments of `tabsPath`, so `['/', key]` results when it is empty.
3061
+
3062
+ Changed: `--bf-tab-bar-inset` (and `--bf-tab-bar-inset-global`) follows the bar's new position:
3063
+ `calc(64px + 8px + max(var(--spacing-safe-bottom), 12px) + 8px)` (the bar's height, its offset of
3064
+ 8 px above the safe area with a 12 px floor, and 8 px of room above the bar; 114 px on an iPhone
3065
+ with a home indicator, was 122 px), replacing `calc(64px + 24px + var(--spacing-safe-bottom))`.
3066
+
3067
+ Fixed: the inset counts the bar's height in `rem` (`calc(4rem + 8px + max(var(--spacing-safe-bottom),
3068
+ 12px) + 8px)`), like the bar's own `h-16`. With iOS Dynamic Type (`BfTextScaleService`) the bar
3069
+ grows with the root font size; a fixed 64 px left the end of every page under the bar at the
3070
+ accessibility sizes (at AX3 the bar is 150 px tall). Unchanged at the default text size.
3071
+
3072
+ ### tab-bar
3073
+
3074
+ New inputs `hidden` (default `false`) and `hideTransition` (default `true`), both with
3075
+ `booleanAttribute` (`hidden` intentionally shadows the global attribute, which is removed from the
3076
+ host so a static `<bf-tab-bar hidden>` slides out instead of becoming `display: none`): a hidden bar gets
3077
+ `translate-y-[calc(100%+40px)] opacity-0 pointer-events-none` and `aria-hidden="true"` plus
3078
+ `inert` on its `nav`, with `transition-[translate,opacity] duration-200` only while
3079
+ `hideTransition` (and never under `prefers-reduced-motion`: `motion-reduce:transition-none`). The
3080
+ floating bar sits on `z-(--bf-z-tabbar)`, `bottom-[calc(var(--spacing)*6+var(--spacing-safe-bottom))]`. New method `setHidden(hidden, transition?)`, the imperative counterpart for a
3081
+ parent that projects the bar (`bf-tabs`); the bar is hidden while the input or it is true, and
3082
+ `isHidden` exposes the result. New output `tabReselect`, emitted with the id when the already
3083
+ active tab is clicked (`tabChange` still fires only on a change; arrow keys never reselect).
3084
+
3085
+ Changed: the floating bar sits 8 px above the bottom safe area, with a 12 px floor where there is
3086
+ none, like other apps' floating bars: `bottom-[calc(max(var(--spacing-safe-bottom),12px)+8px)]`
3087
+ (42 px from the screen edge on an iPhone, 32 px with Android gesture navigation, 20 px without an
3088
+ inset), replacing `bottom-[calc(var(--spacing)*6+var(--spacing-safe-bottom))]` (58 px on an
3089
+ iPhone). Hidden, it slides down by `translate-y-[calc(100%+64px)]` (was `100% + 40px`), so it still
3090
+ leaves the screen with its shadow.
3091
+
3092
+ ### action-sheet
3093
+
3094
+ New `BfActionSheetService.open({ title?, actions })` in `@bitforgehq/ui/mobile`, resolving
3095
+ `Promise<string | undefined>`. Renders `bf-action-sheet` on the overlay core (`position:
3096
+ 'bottom'`, back priority 100, not counted as a modal, `role="dialog"` with `aria-label` = `title`
3097
+ or "Actions"; slides up on open and down on close). The panel (`bg-surface rounded-t-card
3098
+ shadow-floating w-screen px-3 pt-3 pb-[max(var(--spacing-safe-bottom),16px)]`, spanning the
3099
+ screen with at least 16 px under the cancel button) is an optional `title` caption over a `bf-list
3100
+ [inset]="false"` of the non-`cancel` `actions` as interactive rows (an icon from `icon` in the
3101
+ leading slot; `destructive`-role labels render `text-danger`, projected through the row's default
3102
+ slot since `bf-list-item` has no way to colour its own title), and a separate `tonal` `bf-button`
3103
+ (`fullWidth`) for the cancel action — the caller's first `cancel`-role entry if any (further ones are
3104
+ ignored), else a synthesized "Cancel". Destructive labels truncate like the other rows.
3105
+ Tapping a row resolves with its label; the cancel button, a backdrop tap, `Escape` and the
3106
+ hardware back button all resolve `undefined` (the overlay core's defaults, unchanged). The panel's
3107
+ `px-3` inset is deliberately tighter than the 16 px gutter: the rows add their own 14 px.
3108
+ `provideBfShell()` now also provides `BfActionSheetService`.
3109
+
3110
+ ### alert-dialog
3111
+
3112
+ New `BfAlertDialogService.open({ title, message?, buttons? })` in `@bitforgehq/ui/mobile`,
3113
+ resolving `Promise<string>` with the tapped button's label. Renders `bf-alert-dialog` on the
3114
+ overlay core (`position: 'center'`, back priority 100, not counted as a modal, no backdrop
3115
+ dismiss, `role="alertdialog"` with `aria-labelledby` the generated title id and, when there is a
3116
+ message, `aria-describedby` its paragraph). The card (`w-75
3117
+ max-w-[calc(100vw-2*var(--spacing-gutter))] bg-surface rounded-card shadow-floating p-6 flex flex-col gap-4`) is an
3118
+ `h2.text-heading` title, an optional `text-body text-text-secondary` message and a right-aligned
3119
+ `buttons` row (default `[{ label: 'OK' }]`): `cancel`-role renders `text`, `destructive`-role
3120
+ `danger`, the first plain button `primary`, further plain ones `tonal`. Unlike every other
3121
+ overlay, `Escape` and the hardware back button do not close through the core's
3122
+ `dismissOnEscape`/back handling (the service passes `onEscape` and claims the back button itself):
3123
+ they
3124
+ resolve with the cancel button's label when `buttons` has one, and are otherwise claimed and
3125
+ ignored, since an alert with no cancel button can only be answered by tapping a button. Focus
3126
+ lands on the first button — the core's first-tabbable search, since the title and message hold no
3127
+ focusable content. `provideBfShell()` now also provides `BfAlertDialogService`.
3128
+
3129
+ ### theme
3130
+
3131
+ Type tokens are in rem (same metrics at a 16 px root: `display` 2.5rem/2.75rem, `title`
3132
+ 1.75rem/2.125rem, `heading` 1.125rem/1.625rem, `body` 0.9375rem/1.5rem, `caption`
3133
+ 0.8125rem/1.125rem), so a user's font-size preference scales every style together. New type
3134
+ tokens for controls and labels: `text-label` (15/20), `text-label-sm` (14/20), `text-label-xs`
3135
+ (13/16), `text-micro` (12/16, weight 600), `text-nano` (11/14) and `text-avatar-lg` (26); the
3136
+ widgets' `text-[15px]`, `text-sm`, `text-[13px]`, `text-xs` and `text-[11px]` now use them. New
3137
+ spacing steps `gutter` (1rem), `card` (1.125rem), `control-sm/md/lg` (2/2.5/3rem), `hit`
3138
+ (2.75rem) and `box` (1.375rem), and the arbitrary px sizes became scale steps (`px-4.5`,
3139
+ `size-box`, `size-7.5`, `w-13`, `h-4.5`, `size-21`, `w-75`, `py-1.25`, `px-1.75`, `min-h-hit`).
3140
+ New `--radius-checkbox` (7px, `rounded-checkbox`), `--color-scrim` (`#000000` in both modes; the
3141
+ backdrop rule documents it), motion tokens `--ease-ios`, `--ease-md`, `--ease-settle` and plain
3142
+ `:root` variables `--bf-duration-fast/overlay/push` and `--bf-z-sticky/tabbar/dim/overlay`.
3143
+
3144
+ WCAG retunes (visual): the focus ring is a darker primary tint, `--color-ring` `#8784dc` (dark
3145
+ `#6362af`; was `#c9c8f5` / `#3d3e8a`), 3.03:1 on `bg` and 3.30:1 on `surface` (dark 3.48 / 3.03),
3146
+ so it passes WCAG 1.4.11 as the only focus indicator. Control boundaries move to `border-strong`,
3147
+ now `#8e8e82` (dark `#686a70`; was `#c9c4b5` / `#3a3d44`), 3.31:1 on `surface` (dark 3.02); `border`
3148
+ stays for decorative lines. `control-off` (toggle off track) is `#8d8f84` (dark `#686a70`; was
3149
+ `#d9d5c8` / `#3a3d44`), 3.28:1. `text-muted` is `#616b5b` (was `#66705f`): 5.58:1 on `surface`, 4.73
3150
+ on `surface-sunken`, 4.54 on `primary-soft`; placeholders and the select's empty value use it now.
3151
+ `text-subtle` is `#869181` (was `#95a08f`), 3.29:1, for icons and chevrons only, never text.
3152
+ `surface-muted` is `#efece5` (was `#f3f1ea`) so a hover row reads on `bg`; dark `surface-sunken`
3153
+ is `#18191c` (was `#0c0d0f`) so tracks (segmented, progress, neutral alert) read on the page. The
3154
+ docs theme configurator derives a ring of at least 3:1 from any primary.
3155
+
3156
+ `:root` sets `color-scheme: light` and `.dark` sets `color-scheme: dark`, so with `class="dark"`
3157
+ on `<html>` the browser's own parts (scrollbars, native form controls, the default canvas) turn
3158
+ dark with the palette. Before, only the tokens changed and those stayed light.
3159
+
3160
+ Behaviour change: because `:root` now sets `color-scheme: light`, it overrides an app's `<meta
3161
+ name="color-scheme" content="light dark">`. An app that relied on the browser's dark canvas and
3162
+ scrollbars following the system preference without `.dark` now stays light until it toggles
3163
+ `.dark` itself, for example from `matchMedia('(prefers-color-scheme: dark)')` as the docs site
3164
+ does. To keep the old behaviour in light mode, add `:root:not(.dark) { color-scheme: normal; }`
3165
+ after the theme import.
3166
+
3167
+ ### motion
3168
+
3169
+ New `projects/ui/mobile/motion.ts`, exported from `@bitforgehq/ui/mobile`: `IOS_EASING`,
3170
+ `MD_EASING`, `SETTLE_EASING`, `DURATION_FAST` (150), `DURATION_OVERLAY` (260), `DURATION_PUSH`
3171
+ (500) and `DURATION_MD` (220). The page transitions, the overlay animations and the refresher
3172
+ import them (the refresher's own `SETTLE_EASING` copy is gone); `IOS_DURATION`, `MD_DURATION`,
3173
+ `REDUCED_DURATION`, `OVERLAY_DURATION` and `OVERLAY_EASING` stay as aliases. `bf-stack-outlet`'s
3174
+ modal-depth transition uses `var(--bf-duration-overlay) var(--ease-ios)` and `var(--radius-tile)`,
3175
+ and its dim layer is `bg-scrim/15` on `z-(--bf-z-dim)` (was `bg-text/15`, which dimmed with a
3176
+ light colour in dark mode).
3177
+
3178
+ ### spinner
3179
+
3180
+ Sizes are now the `bf-icon` sizes: `sm` 16, `md` 20 (default), `lg` 24 px (were 16/24/32).
3181
+
3182
+ Added: `decorative` input (`booleanAttribute`). A decorative spinner has no `role="status"`, no
3183
+ `aria-live` and no `sr-only` label, and its wrapper is `aria-hidden="true"`: for a spinner inside a
3184
+ control that already reports its busy state, such as a loading `bf-button`.
3185
+
3186
+ ### button
3187
+
3188
+ The loading spinner is `md` (20 px), the size of a default `bf-icon`, so swapping the start icon
3189
+ for the spinner no longer changes the button's width. The `outline` border is `border-strong`
3190
+ (hover `text-muted`). The label renders in a `data-label` span that truncates with an ellipsis
3191
+ (`max-w-full min-w-0 truncate`) when the container is narrower than the label; the icons keep
3192
+ their size, and the host and button are capped at `max-w-full`.
3193
+
3194
+ Fixed: the enabled button shows a pointer cursor (`cursor-pointer`; Tailwind's preflight gave
3195
+ `<button>` the default arrow), `not-allowed` when disabled.
3196
+
3197
+ Behaviour change: `loading` no longer sets native `disabled`, so keyboard focus stays on the button
3198
+ when an action starts (it fell to `<body>` in Chromium and WebKit). While loading the button carries
3199
+ `aria-disabled="true"` and `aria-busy="true"`, stays in the tab order, cancels a click or Enter/Space
3200
+ with `preventDefault()` (a `type="submit"` button no longer submits its form) and emits nothing. It
3201
+ keeps its variant fill instead of the disabled grey, with a `cursor-progress` cursor (a new `STATE`
3202
+ class map: `idle` `cursor-pointer`, `loading` `cursor-progress`). The spinner inside is
3203
+ `decorative`, so it adds no second status announcement. Consumers styling `:disabled` on a loading
3204
+ button see the change.
3205
+
3206
+ ### tab
3207
+
3208
+ The label is `text-nano` (11/14; the line was 16.5 px). Dropped the `[&>svg]:h-5 [&>svg]:w-5`
3209
+ selector, which never matched (icons render inside the `bf-icon` host). A disabled tab uses tokens
3210
+ instead of `opacity-40`: `surface-sunken` capsule, `text-disabled` icon and label. The tab can
3211
+ shrink (`min-w-0`) and its label is capped at `max-w-full px-1` (it wraps, see "Changed" below), so
3212
+ a long label no longer overflows its quarter of the bar.
3213
+
3214
+ Fixed: the tab shows a pointer cursor and its capsule a hover (`primary-soft-hover` when active,
3215
+ `surface-muted` fill and `text` icon when inactive); a disabled tab keeps its sunken capsule.
3216
+
3217
+ Changed: a long label wraps to at most two lines (`line-clamp-2 wrap-anywhere text-center`) instead
3218
+ of truncating to one, so "Benachrichtigungen" stays readable in a quarter-width tab.
3219
+
3220
+ ### list
3221
+
3222
+ `bf-list-item`'s title is `text-label` (15/20): its line is 20 px instead of an inherited 22.5, so
3223
+ a row without a 40 px leading slot is 2.5 px shorter (a title-only row is 48 px, above the 44 px
3224
+ hit area); rows with an avatar keep their 68 px.
3225
+
3226
+ Fixed: `bf-list-item` sets `role="listitem"` only inside a `bf-list` (which provides the new internal
3227
+ `BF_LIST` token); a standalone row has no role, so axe no longer reports `aria-required-parent` for it.
3228
+
3229
+ ### alert
3230
+
3231
+ The body text is `text-label` (15 px, was 14) like other body copy; the dismiss glyph is
3232
+ `text-heading font-normal` (18 px, was 20; the glyph keeps its regular weight).
3233
+
3234
+ Fixed: the dismiss button shows a pointer cursor.
3235
+
3236
+ Fixed: the title and body break long words anywhere (`wrap-anywhere`), and a dismissible alert's text
3237
+ column reserves the dismiss button's width (`pe-10`, was `pr-12` without word breaking), so a long
3238
+ word no longer runs under the ×. The dismiss button is placed with `end-3` (was `right-3`).
3239
+
3240
+ ### field
3241
+
3242
+ Fixed: the label, help and error text break long words anywhere (`wrap-anywhere`), so a German or
3243
+ Finnish compound no longer runs past the field at 320 px.
3244
+
3245
+ ### progress
3246
+
3247
+ The value's width transition is off under `prefers-reduced-motion`.
3248
+
3249
+ ### input
3250
+
3251
+ The rest border is `border-strong` (was `border`, 1.32:1) and the placeholder `text-muted` (was
3252
+ `text-subtle`, 2.73:1); the icon slots stay `text-subtle`.
3253
+
3254
+ Changed: the border narrows to 1 px (was 1.5 px); contrast stays ≥ 3:1.
3255
+
3256
+ Changed: the focus ring narrows to 2 px (was 4 px); it still shows on mouse focus, not just
3257
+ keyboard focus.
3258
+
3259
+ Added: `ariaLabel` input (mirrors `bf-toggle`) sets `aria-label` on the native input for a field
3260
+ with no visible `label` (e.g. a search box); ignored while `label` is set, so the visible label
3261
+ stays the sole accessible name.
3262
+
3263
+ Added: `maxLength`, `minLength`, `min`, `max`, `step`, `pattern` (exactly one pattern with no flags,
3264
+ `u` or `v` renders the native attribute; other flags such as `i` cannot be expressed in HTML, so the
3265
+ form alone validates them), `inputMode`, `enterKeyHint`, `spellcheck` and `autocapitalize` inputs,
3266
+ each rendered on the native `<input>` only when set; Signal Forms binds the constraint rules to them
3267
+ (`maxLength(p.code, 20)` renders `maxlength="20"`). New exported types `BfInputMode`,
3268
+ `BfEnterKeyHint` and `BfAutocapitalize`. `BfInputType` adds `date`, `time`, `datetime-local` and
3269
+ `month`, which open the native picker. `ariaLabelledby` sets `aria-labelledby` while there is no
3270
+ visible `label`. `focus(options?)` focuses the native control, so Signal Forms'
3271
+ `focusBoundControl()` and "focus the first invalid field" on submit now work (they were no-ops).
3272
+
3273
+ ### textarea
3274
+
3275
+ The rest border is `border-strong` and the placeholder `text-muted`, as on `bf-input`.
3276
+
3277
+ Changed: the border narrows to 1 px (was 1.5 px), as on `bf-input`.
3278
+
3279
+ Changed: the focus ring narrows to 2 px (was 4 px), as on `bf-input`.
3280
+
3281
+ Added: `minLength` (native `minlength`), and `ariaLabel` / `ariaLabelledby` for a textarea without a
3282
+ visible `label` (ignored while `label` is set). `focus(options?)` focuses the native control, so
3283
+ Signal Forms' `focusBoundControl()` and "focus the first invalid field" on submit now work (they
3284
+ were no-ops).
3285
+
3286
+ ### select
3287
+
3288
+ The rest border is `border-strong`; the empty value (placeholder) is `text-muted` (was
3289
+ `text-subtle`). The chevron stays `text-subtle`.
3290
+
3291
+ Changed: the border narrows to 1 px (was 1.5 px); the chevron stroke stays 1.5 px.
3292
+
3293
+ Changed: the focus ring narrows to 2 px (was 4 px), as on `bf-input`.
3294
+
3295
+ Fixed: the native select shows a pointer cursor and a hover border (`text-muted`, `danger` in the
3296
+ error state); focus and disabled borders still win.
3297
+
3298
+ Added: `readonly` input. Behaviour change: the control ignored Signal Forms `readonly()` rules and
3299
+ could be edited; it now declares `readonly` and blocks changes. While read-only the select keeps its
3300
+ value on the `surface-muted` background with `aria-readonly="true"` and stays in the tab order; a
3301
+ mouse press focuses it without opening the picker, the picker keys (arrows, Alt+Arrow, Enter, Space,
3302
+ F4, type-ahead) are blocked, and a change that still gets through (the platform picker on touch
3303
+ screens, which offers no press to cancel) is reverted without writing the model. `ariaLabel` / `ariaLabelledby` name a
3304
+ select without a visible `label` (ignored while `label` is set). `focus(options?)` focuses the
3305
+ native control, so Signal Forms' `focusBoundControl()` and "focus the first invalid field" on submit
3306
+ now work (they were no-ops).
3307
+
3308
+ ### checkbox
3309
+
3310
+ The error text sits 6 px under the row (`gap-1.5`, was 4 px), the label-to-message spacing of the
3311
+ other form widgets.
3312
+
3313
+ Fixed: hovering an enabled row tints the box (unchecked border `text-muted`, `danger` on error;
3314
+ checked `primary-hover`); the label was already `cursor-pointer`.
3315
+
3316
+ Added: `readonly` input. Behaviour change: the control ignored Signal Forms `readonly()` rules and
3317
+ could be edited; it now declares `readonly` and blocks changes. While read-only the click (which
3318
+ Space also triggers) is cancelled, `aria-readonly="true"` is set, the box stays focusable, and the
3319
+ box rests on `surface-muted` without hover (default cursor), like a read-only radio; a checked box
3320
+ keeps a `primary` border and mark instead of the fill. `focus(options?)` focuses the
3321
+ native control, so Signal Forms' `focusBoundControl()` and "focus the first invalid field" on submit
3322
+ now work (they were no-ops).
3323
+
3324
+ Behaviour change (breaking for a bare attribute): `indeterminate` is now a `model(false)` (it was an
3325
+ `input` with `booleanAttribute`). A user change clears it and writes `false` back, so after a click
3326
+ the box shows the check mark that assistive tech reports instead of a stale dash; the "select all"
3327
+ pattern needs no `(changed)` handler to reset it. Migration: `[indeterminate]="expr"` still works
3328
+ one-way and `[(indeterminate)]` binds both ways; a bare `indeterminate` attribute no longer compiles,
3329
+ so write `[indeterminate]="true"`.
3330
+
3331
+ Fixed: the column is `items-start`, so the label row is only as wide as its content and a click on
3332
+ empty space beside it no longer toggles the box.
3333
+
3334
+ ### radio-group
3335
+
3336
+ The legend, the options and the error text sit 6 px apart (legend `mb-1.5`, fieldset `gap-1.5`;
3337
+ the legend was 4 px above the options and the error 8 px below them).
3338
+
3339
+ Fixed: hovering an enabled, unselected option darkens its ring (`text-muted`, `danger` on
3340
+ error).
3341
+
3342
+ Added: `readonly` input. Behaviour change: the control ignored Signal Forms `readonly()` rules and
3343
+ could be edited; it now declares `readonly` and blocks changes. While read-only clicks (and Space)
3344
+ and arrow keys are cancelled, the fieldset carries `aria-readonly="true"`, the radios stay
3345
+ focusable, and the rings rest on `surface-muted` without hover. `ariaLabel` / `ariaLabelledby` name
3346
+ a group without a visible `label`. `focus(options?)` focuses the checked radio, else the first
3347
+ enabled one, so Signal Forms' `focusBoundControl()` works.
3348
+
3349
+ Fixed: a disabled option's label dims to `text-disabled` (`has-[input:disabled]:text-text-disabled`
3350
+ on the row; `peer-disabled:` could not reach the text through its wrapper span). Vertical option
3351
+ rows are `items-start`, so a click on empty space beside an option no longer selects it.
3352
+
3353
+ ### toggle
3354
+
3355
+ The track sits 10 px from the label (`gap-2.5`, was 12 px), like checkbox and radio. Label and
3356
+ error text are wrapped in a `flex flex-col items-start gap-1.5` column, so the error sits 6 px
3357
+ under the 44 px row (it followed with no gap). Disabled uses tokens instead of `opacity-60`: the
3358
+ track is `primary-soft` when checked and `surface-sunken` when unchecked, the label `text-disabled`.
3359
+
3360
+ Fixed: hovering an enabled row darkens the track (`primary-hover` on, `text-muted` off).
3361
+
3362
+ Added: `readonly` input. Behaviour change: the control ignored Signal Forms `readonly()` rules and
3363
+ could be edited; it now declares `readonly` and blocks changes. While read-only the click (which
3364
+ Space also triggers) is cancelled and `aria-readonly="true"` is set; the switch stays focusable, the
3365
+ track rests on `surface-muted` without hover, bordered `primary` with a `primary` knob when on and
3366
+ `control-off` (keeping the ≥ 3:1 boundary) when off. `focus(options?)` focuses the native control, so Signal
3367
+ Forms' `focusBoundControl()` and "focus the first invalid field" on submit now work (they were
3368
+ no-ops).
3369
+
3370
+ ### icon-button
3371
+
3372
+ The `outline` border is `border-strong` (hover `text-muted`).
3373
+
3374
+ Fixed: pointer cursor while enabled, `not-allowed` when disabled.
3375
+
3376
+ ### chip
3377
+
3378
+ The unselected border is `border-strong` (hover `text-muted`). Disabled uses tokens instead of
3379
+ `opacity-60`: `surface-sunken` / `text-disabled`, transparent border. The label renders in a
3380
+ `data-label` span that truncates with an ellipsis; the chip and its host are capped at
3381
+ `max-w-full`. Icon to label stays 6 px (the pill's proportion at 36 px; `bf-button` uses 8).
3382
+
3383
+ Fixed: pointer cursor while enabled; a selected chip now has a hover too (`tonal`
3384
+ `primary-soft-hover`, `ink` `text-secondary`, fill and border).
3385
+
3386
+ ### segmented
3387
+
3388
+ Each segment's hit area reaches 44 px through an `::after` extension (`after:-inset-y-1` on md
3389
+ 36 px, `after:-inset-y-1.5` on sm 32 px); the visual size is unchanged. Labels render in a
3390
+ `data-label` span (it wraps, see "Changed" below), and segments are `min-w-0`, so a long label in
3391
+ `fullWidth` no longer overflows its share. Disabled: unselected segments take `surface-sunken`, the selected one
3392
+ keeps its `surface` pill without the shadow; text `text-disabled` as before.
3393
+
3394
+ Fixed: every segment shows a pointer cursor while enabled (`not-allowed` when disabled). The
3395
+ active segment now also tints on hover (`enabled:hover:bg-surface-muted`), matching the active
3396
+ tab's hover in `bf-tab-bar`.
3397
+
3398
+ Added: validation state, mirroring `bf-radio-group`. New `required`, `invalid`, `touched`, `errors`
3399
+ and `errorMessage` inputs; once touched and invalid (or with an explicit `errorMessage`) the first
3400
+ message renders under the track (`id="{id}-error"`), the radiogroup gets `aria-invalid="true"` and
3401
+ `aria-describedby`, and the track a 1.5 px `danger-border` ring; `aria-required` follows
3402
+ `required`. A required segmented choice no longer fails silently.
3403
+
3404
+ Layout change: the host is now a column (`inline-flex flex-col items-start gap-1.5`) so the message
3405
+ sits under the track. Without an error it renders as before; `justify-*` or `items-*` utilities a
3406
+ consumer put on the host now act on the column.
3407
+
3408
+ Added: `readonly` input. Behaviour change: the control ignored Signal Forms `readonly()` rules and
3409
+ could be edited; it now declares `readonly` and blocks changes. While read-only `select()` and the
3410
+ arrow, Home and End keys do nothing, the group carries `aria-readonly="true"`, the tab stop stays
3411
+ focusable, and the track rests on `surface-muted` without segment hover (default cursor).
3412
+ `ariaLabel` / `ariaLabelledby` name the group when `label` is not set. `focus(options?)` focuses the
3413
+ tab-stop segment, so Signal Forms' `focusBoundControl()` works. `name` is documented as bound but
3414
+ not rendered (the control has no native form element).
3415
+
3416
+ Fixed: under forced colours the selected label is legible. The selected segment adds
3417
+ `forced-colors:forced-color-adjust-none` next to `Highlight`/`HighlightText` (Chromium painted a
3418
+ Canvas text backplate under `HighlightText`, white on white in light, black on black in dark) and
3419
+ a `CanvasText` focus outline; unselected segments repaint in `ButtonText` on a transparent fill.
3420
+
3421
+ Changed: a long label wraps instead of truncating. Segments are `min-h-8`/`min-h-9` with `py-1`
3422
+ (were fixed `h-8`/`h-9`, `whitespace-nowrap`), the label span is `wrap-anywhere text-center`, so a
3423
+ segment grows taller rather than showing "Tages…".
3424
+
3425
+ ### card
3426
+
3427
+ The `flat` border is `border-strong` (was `border`, 1.32:1).
3428
+
3429
+ ### action-bar
3430
+
3431
+ The bar and its host are capped at `max-w-full`, so a long label no longer pushes the actions out
3432
+ of a narrow container (the label keeps its width and the actions wrap, see "Changed" below).
3433
+
3434
+ Changed: the label (the selection count) never truncates: it keeps its width (`shrink-0`, breaking a
3435
+ long word only past the bar's width), and the bar is `flex-wrap`, so actions that do not fit beside
3436
+ it move to a second row.
3437
+
3438
+ ### platform service
3439
+
3440
+ Fixed: `BfPlatformService` now applies the `dark` class to `<html>` and persists the theme choice
3441
+ to `localStorage` only when `provideBfShell()` ran (`BF_SHELL_CONFIG` is present, checked via
3442
+ `inject(BF_SHELL_CONFIG, { optional: true })`). The `theme`/`dark` signals still compute correctly
3443
+ without it. Previously, injecting `BfPlatformService` on its own — e.g. a docs demo that only
3444
+ wants `mode`/`platform` for a widget preview — ran the DOM/storage effect anyway, which reverted
3445
+ the host page's own theme choice to the OS `prefers-color-scheme` on every change.
3446
+
3447
+ The shell now enforces a no-zoom viewport: with `provideBfShell()` on a native platform,
3448
+ `BfPlatformService` makes `meta[name=viewport]` contain `maximum-scale=1` and `user-scalable=no`
3449
+ (other directives kept; the meta is created with `width=device-width, initial-scale=1,
3450
+ maximum-scale=1, user-scalable=no, viewport-fit=cover` if missing). Fixed: iOS zoomed to 16/15 when
3451
+ a 15 px field was focused and stayed zoomed after blur; WKWebView honours `user-scalable=no` and
3452
+ drops the focus zoom along with pinch and double-tap zoom. See the behaviour change below for the
3453
+ recommended `index.html` viewport, which does not carry these directives statically.
3454
+
3455
+ **Behaviour change:** the no-zoom lock is native-only by design (the owner's ruling, audit §12),
3456
+ and the viewport guidance now says so. The recommended `index.html` viewport is `width=device-width,
3457
+ initial-scale=1, viewport-fit=cover` without `maximum-scale`/`user-scalable`, so web builds keep
3458
+ pinch zoom and pass axe `meta-viewport`; `provideBfShell()` still adds the lock at boot on iOS and
3459
+ Android, where the system text size takes its place (`BfTextScaleService` on iOS, the WebView's
3460
+ font scale on Android). The mobile demo's `index.html`, which also serves the web build and the
3461
+ docs' embedded phone, no longer locks zoom. Migration: drop `maximum-scale=1, user-scalable=no`
3462
+ from your `index.html` if the app also ships to the web; native builds behave as before.
3463
+
3464
+ ### text scale service
3465
+
3466
+ New `BfTextScaleService` (`providedIn: 'root'`, started at bootstrap by `provideBfShell()`): iOS
3467
+ Dynamic Type in the native shell. A hidden, persistent probe with `font: -apple-system-body`
3468
+ gives the user's body text size (17 px at the default "Large", 21 px at xxL, 40 px at AX3), and
3469
+ `<html>` gets `font-size: probe / 17 × 100 %` (rounded to 0.1 %), so every `rem`-based type token
3470
+ and spacing step grows with it; a `ResizeObserver` on the probe and `visibilitychange` re-apply it
3471
+ when the setting changes while the app runs. The scale (`scale()` signal, 1 by default) never goes
3472
+ below 1 and is capped by the new optional `BfShellConfig.dynamicType` member (`{ maxScale: 2.35 }`
3473
+ when left out, the AX3 size, at least 200 %; `false` opts out), so a full `BfShellConfig` literal
3474
+ written without it still compiles. It acts only with `provideBfShell()` on
3475
+ native iOS; Android's WebView applies the system font scale itself, and the web keeps the
3476
+ browser's text size and zoom. Destroying the service removes the probe and the root font size.
3477
+ Fixed: iOS text could not be enlarged at all in the shell (F-048).
3478
+
3479
+ ### agent docs
3480
+
3481
+ The package ships `ai/MANIFEST.index.json` beside `ai/MANIFEST.json`: one small record per
3482
+ component (`name`, `selector`, `className`, `category`, `entryPoint`, `summary`, `since`), generated
3483
+ with the rest of `ai/**` (`bun run ai:docs`). An agent can pick a component from it without reading
3484
+ the full manifest; the docs site bundles it and fetches the full manifest on demand. Every
3485
+ `MANIFEST.json` entry also gains a `summary` field, and `llms.txt` and the `UI_GUIDE.md` component
3486
+ table now show it in place of the description's first sentence; each `ai/components/*.md` carries
3487
+ it as a line under the title.
3488
+
3489
+ ### docs site
3490
+
3491
+ Every `@bitforgehq/ui/mobile` component page runs a live demo in a drawn phone: the docs'
3492
+ `app-phone-frame` embeds the mobile demo's web build at the component's showcase route
3493
+ (`mobile-demo/?mode=ios|md&embed=1&start=showcase/<name>`), with an iPhone / Pixel picker
3494
+ remembered across pages and an "Open full screen" link. The Mobile page uses the same frame on
3495
+ the whole app (`start=tabs`). The phone follows the docs theme: the configurator's tokens and
3496
+ light/dark reach the demo through a same-origin `postMessage` (`bf-docs-theme`). The mobile demo
3497
+ gains the showcase routes, the `?start=` entry (only `tabs` or a known showcase; a device build
3498
+ has no query string and still starts on `tabs`) and, with `?embed=1` only, the theme bridge. The
3499
+ frame also takes an Appearance override (follow the docs theme, or pin light/dark) independent of
3500
+ the site theme, and sends its drawn status-bar/tab-bar heights to the embedded demo as
3501
+ `?insets=<top>,<bottom>` so its header and tab bar clear them.
3502
+
3503
+ New this phase: every component page renders from the same fixed skeleton (breadcrumb, title,
3504
+ summary, meta chips, install/import snippet, Preview | Code example card, Overview, usage
3505
+ sections, Theming, Accessibility, API, notes, migration notes, then previous/next links) sourced
3506
+ from the shipped manifest; a command-palette search (`Cmd/Ctrl+K`) finds any component or guide
3507
+ page; `SeoService` sets a per-route title, meta description, canonical link and Open Graph/Twitter
3508
+ tags, always under the Firebase origin regardless of which host serves the page, and marks the 404
3509
+ view `noindex`; every page passes an axe accessibility check (zero `serious`/`critical`
3510
+ violations, in light and dark) and the WAI-ARIA patterns for its tabs, search and table of
3511
+ contents; and CI's last step runs Lighthouse against five pages, blocking under performance 90,
3512
+ accessibility 100, best practices 95 or SEO 100.
3513
+
3514
+ Docs correctness: a site-wide pre-release note (an `aside` labelled "Release status") says the
3515
+ package is not on npm yet and links to the Install page's "Before the npm release", inside the
3516
+ "Add the packages" step: it states that the source repository is private and needs read access,
3517
+ clones it beside the app, packs the built library into `../bitforge-ui-packages/` (outside
3518
+ `dist/`, which every build wipes), adds that tarball and shows how to update it later; the
3519
+ generated guide and README install blocks carry the same note and instructions. The home page's
3520
+ install command reads `bun add @bitforgehq/ui@next`, labelled "Pre-release — not on npm yet". The
3521
+ Install page is a verified quick start rendered from one source, `INSTALL_STEPS`
3522
+ (`projects/docs/src/app/consts/install.const.ts`): prerequisites (Node in the Angular CLI 22
3523
+ range `^22.22.3 || ^24.15.0 || >=26.0.0`), `ng new`, `ng add tailwindcss`,
3524
+ one `bun add`, the `styles.css` block (no `@tailwindcss/forms`), `bg-bg text-text` on `<body>`,
3525
+ dark mode, a first component (removing the welcome page and updating the generated `app.spec.ts`)
3526
+ and what you should now see. `ai/UI_GUIDE.md` §Install and the
3527
+ install block of `projects/ui/README.md` are generated from the same steps, and the consumer-smoke job builds a
3528
+ clean app from them with 0 errors and 0 warnings. A new "Forms and validation" guide (`/forms`)
3529
+ covers Signal Forms setup, validators with messages, `[formRoot]` submission with focus on the
3530
+ first invalid field and server errors, with a live example whose Code view is the compiled file;
3531
+ Reactive Forms (supported through Angular 22's interop, no `ControlValueAccessor`) with an example
3532
+ the consumer-smoke job compiles and a precise account of what the interop syncs; template-driven
3533
+ forms; and read-only versus disabled. The footer links the guide. Deep links
3534
+ to a component-page section (`/components/button#api`) now scroll to it once the deferred body
3535
+ renders. The `bf-input` "With Signal Forms" snippet now compiles as shown (the imports,
3536
+ `[formRoot]` instead of a bare `(submit)`, and validators with messages). `llms.txt` links to the canonical origin (`https://bitforge-ui.web.app`, was a GitHub
3537
+ Pages mirror that returned 404) and lists the guide pages.
3538
+
3539
+ ### packaging
3540
+
3541
+ Behaviour change for installs: the peer metadata now matches what each entry imports.
3542
+ `@angular/cdk`, `@angular/router` and `@capacitor/core` are optional peers, so a desktop-only
3543
+ install no longer pulls them in; an app that imports `@bitforgehq/ui/mobile` installs them itself.
3544
+ `rxjs` (`^7.8.0`, imported by `/mobile`) is declared as an optional peer. `lucide` is now a required
3545
+ peer (`bf-back-button` imports its icons statically, and `@bitforgehq/ui/icons` renders Lucide
3546
+ data). `@capacitor/app`, `@capacitor/keyboard` and `@capacitor/haptics` stay optional.
3547
+ `publishConfig` sets `access: public` and `provenance: false`: the source repository is private,
3548
+ and npm generates no provenance for private repositories.
3549
+
3550
+ ### haptics
3551
+
3552
+ Fixed: a missing `@capacitor/haptics` no longer breaks the build. Its dynamic import sits inside a
3553
+ `try` with a `.catch()`, so esbuild tolerates the unresolved optional peer and `BfHapticsService`
3554
+ resolves every method as a no-op when the plugin is not installed. Before, every
3555
+ `@bitforgehq/ui/mobile` import failed to build with `Could not resolve "@capacitor/haptics"`.
3556
+
3557
+ ### all components
3558
+
3559
+ Every boolean `input()` across `projects/ui/src` and `projects/ui/mobile` now carries `{ transform:
3560
+ booleanAttribute }`, so a bare HTML-style attribute (`<bf-button disabled fullWidth loading>`,
3561
+ `<bf-list-item interactive chevron>`, `<bf-input required readonly>`) compiles and works the same
3562
+ as `[disabled]="true"` under `strictTemplates`. A new `tools/boolean-inputs.test.ts` (`bun run
3563
+ test:tools`) scans every non-spec source file for a boolean `input()` missing the transform, so a
3564
+ regression fails CI. Boolean `model()` inputs are unaffected (a `transform` is not supported on
3565
+ `model()`).
3566
+
3567
+ ### forms
3568
+
3569
+ The Signal Forms integration spec now covers all seven value controls (value both ways,
3570
+ `disabled()`, `readonly()`, `required` with the message after touch, `reset()`,
3571
+ `focusBoundControl()`, `submit()` focusing the first invalid field, and the `maxLength`,
3572
+ `minLength` and `pattern` rules on the native attributes). A new Reactive Forms interop spec pins
3573
+ `[formControl]` on every control with Angular 22's custom-control support (no
3574
+ `ControlValueAccessor`): value both ways, touched through `touch`, `disable()`/`enable()`,
3575
+ `Validators.required` to `required`, and errors without a `message`, whose text comes from
3576
+ `errorMessage`. `tools/form-spec-members.test.ts` fails when a form control's input, model or output
3577
+ is not exercised in its spec: only `setInput('<member>'`, `componentInstance.<member>`, a
3578
+ `.<member>(` call or a `[<member>]` / `(<member>)` binding count, never an error `kind` string or a
3579
+ DOM property read.
3580
+
3581
+ ## 0.1.0 (not published)
3582
+
3583
+ ### theme
3584
+
3585
+ Dark surfaces now sit further apart (`surface` #1e2024, `surface-muted` #262930, `surface-sunken` #0c0d0f) so sunken tracks and raised segments stay distinguishable on the neutral dark palette. Default primary changed from the Meadow Sage green (`#2f8f5b`) to indigo `#2b26ba`, with the derived brand tokens (`primary-hover`, `primary-soft`, `primary-soft-hover`, `primary-strong`, `selected`, `ring`) re-tuned in both modes. The dark palette is now neutral gray (surfaces, lines, text) instead of green-tinted, so any primary colour fits without touching the rest of the theme.
3586
+
3587
+ Initial widgets: `bf-button`, `bf-badge`, `bf-spinner`, `bf-icon`, the Meadow Sage theme, and the form set `bf-field`, `bf-input`, `bf-textarea`, `bf-select`, `bf-checkbox`, `bf-toggle`, `bf-radio-group` (all Signal Forms ready), and the display set `bf-icon-button`, `bf-chip`, `bf-segmented`, `bf-list`/`bf-list-item`, `bf-card`, `bf-progress`, `bf-avatar`, `bf-alert`, `bf-empty-state`, `bf-action-bar`, `bf-tab-bar`/`bf-tab`.
3588
+
3589
+ ### input
3590
+
3591
+ Field sizes renamed: `sm`/`md` (40/48 px) is now `md`/`lg` (40/48 px), default stays 48 px but the default token changed to `lg`. The host no longer reflects a static `id` attribute onto `<bf-input>`; the id lands only on the native `<input>`.
3592
+
3593
+ ### textarea
3594
+
3595
+ Same `md`/`lg` size rename as `bf-input`. The host no longer reflects a static `id` attribute onto `<bf-textarea>`.
3596
+
3597
+ ### select
3598
+
3599
+ Same `md`/`lg` size rename as `bf-input`. Without a `placeholder`, an unmatched `value` (e.g. the empty string) now renders a hidden, disabled, selected placeholder option so the native control shows no visible selection instead of falling back to the first real option. The inert `[value]` binding on `<select>` was removed; selection is driven entirely by `[selected]` on each `<option>`. The host no longer reflects a static `id` attribute onto `<bf-select>`. Added `bg-none` so consumers loading `@tailwindcss/forms` don't get a second, duplicate chevron drawn by the plugin's default `background-image` alongside the library's own CSS chevron.
3600
+
3601
+ ### checkbox
3602
+
3603
+ The label's cursor styling is now a computed `labelClasses`. The redundant `[attr.aria-checked]` binding was removed from the native checkbox; the native `checked`/`indeterminate` state already conveys it to assistive tech. The focus ring now clears in forced-colors mode. The host no longer reflects a static `id` attribute onto `<bf-checkbox>`. Fixed the checked box never showing its fill: `bg-surface`/`after:opacity-0` and the "on" branch's `bg-primary`/`after:opacity-100` were both present at once, and Tailwind's stylesheet order let `bg-surface` win, leaving a checked box white with an invisible check mark. The base class list and the on/off branches now each emit exactly one background and one after-opacity utility. The label's clickable hit area is now `min-h-11 items-center` instead of a `::after` pseudo-element; a `::after` extension over projected content (the label text) is the wrong tool — `min-h-11` gets the same ≥ 44 px target without an overlay. Dropped the box's `mt-0.5` top-margin nudge; with the row already `min-h-11 items-center`, the box centres on the label line on its own. Added `ariaLabel` (mirrors `bf-toggle`) for an accessible name when there's no visible `label`, e.g. a row-selection box; the label `<span>` no longer renders when `label` is empty, so an `ariaLabel`-only checkbox has no empty gap next to the box. Projected label content and the `label` input share one styled text span, which hides itself when both are empty (`empty:hidden`).
3604
+
3605
+ ### toggle
3606
+
3607
+ Added `errorMessage` and Signal Forms error rendering (matching `bf-checkbox`): a `<p>` under the label, `aria-invalid` and `aria-describedby` on the input. `labelPosition="start"` now stretches the label to `w-full` with `justify-between` for settings-list layouts. The track and knob keep a transparent 1.5px border and the focus ring clears in forced-colors mode. The host no longer reflects a static `id` attribute onto `<bf-toggle>`. Fixed the knob sitting off-centre: the track's padding was `p-[3px]` alongside its `border-[1.5px]`, so the 22px knob had unequal 4.5px/1.5px margins at the two ends of its 20px `translate-x-5` travel; padding is now `p-[1.5px]` for equal 3px margins on both sides. Also removed the redundant `box-border` from the knob (preflight already sets `box-sizing: border-box` globally). The label's clickable hit area is now `min-h-11 items-center` instead of a `::after` pseudo-element, in both `labelPosition` branches, for the same reason as `bf-checkbox`.
3608
+
3609
+ ### radio-group
3610
+
3611
+ The fieldset now carries `role="radiogroup"`. The ring's focus ring clears in forced-colors mode and the selected dot renders in `CanvasText` under forced colors. The host no longer reflects a static `id` attribute onto `<bf-radio-group>`; the id lands on the `<fieldset>`. Each option's label now carries `min-h-11 items-center`, so a stacked row is itself at least 44 px tall and is its own hit area, rather than a `::after` pseudo-element that overlapped into the neighbouring row's 10px gap and stole its clicks near the boundary; the vertical option list's gap tightened from `gap-2.5` to `gap-1` to match the now taller rows.
3612
+
3613
+ ### field
3614
+
3615
+ `disabled` no longer dims the whole field; it dims only the label. Controls already carry their own disabled styling.
3616
+
3617
+ ### icon-button
3618
+
3619
+ New `bf-icon-button`: a round button that holds a single icon, with `tonal` (default), `primary`, `outline` and `text` variants and `sm`/`md`/`lg` (32/40/48 px) square sizes. `label` is required and renders as `aria-label` since the icon alone gives assistive tech no name. `sm` and `md` extend the hit area vertically to 44 px with an invisible `::after`, matching `bf-button`, so adjacent icon buttons in a toolbar don't overlap.
3620
+
3621
+ ### chip
3622
+
3623
+ New `bf-chip`: a toggleable pill for filters and tags, 36 px tall, with `tonal` (default) and `ink` treatments. Renders a `<button aria-pressed>` and shows a `::before` check mark while selected. Two-way `[(selected)]` model plus a `toggled` output fired after a user click; the hit area extends vertically with `::after`, matching `bf-button`. Fixed the check mark rendering as a thin vertical line instead of a tick: the `::before` pseudo-element had no `display` utility, and an inline, non-replaced box ignores explicit `width`/`height`; added `before:block`. Added `deselectable` (default `true`): set it `false` in a single-choice filter row so clicking the already-selected chip does nothing instead of clearing the selection. The unselected state's hover border is now `enabled:hover:border-border-strong` so a disabled chip doesn't show a hover state it can't act on.
3624
+
3625
+ ### segmented
3626
+
3627
+ New `bf-segmented`: a `role="radiogroup"` control for switching between two to four views, implementing `FormValueControl<string>`. `sm`/`md` (32/36 px) item heights, optional `fullWidth`. Keyboard arrow keys move the roving-tabindex selection and wrap around, skipping disabled options; `Home`/`End` jump to the first/last enabled option. The roving tab stop (`tabStop`) now falls back to the first enabled option when the selected value is missing or disabled, instead of leaving every segment at `tabIndex -1`. A keyboard selection also moves DOM focus to the newly selected button. The active segment's fill is now visible under forced-colors (`forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]`), and the inactive segment explicitly clears its background there.
3628
+
3629
+ ### list
3630
+
3631
+ New `bf-list` and `bf-list-item`: a `role="list"` container (inset by default, with a warm background and rounded corners; `inset=false` drops it for rows on a page) and a row with `leading`/`trailing` content slots, `title`/`subtitle` text, an optional trailing chevron, and an `interactive` mode that renders the row as a `<button>` emitting `clicked`. The interactive row's hover background is now `enabled:hover:bg-surface-muted` so a disabled row doesn't show a hover state; removed the unused `relative` from the row's base classes.
3632
+
3633
+ ### card
3634
+
3635
+ The card surface now fills its host (`h-full`), so cards in a stretched grid row end at the same height instead of ragged bottoms; block hosts of auto height are unaffected.
3636
+
3637
+ New `bf-card`: a content surface with `header`/default/`footer` slots and four variants — `raised` (default, `shadow-raised`), `tint`, `ink` and `flat`. `padding` is `md` (18 px, default) or `lg` (24 px). `interactive` renders the card as a `<button>` that emits `clicked` and adds a hover shadow and focus ring.
3638
+
3639
+ ### progress
3640
+
3641
+ New `bf-progress`: a determinate `role="progressbar"` for completion and quotas. `value` is clamped to 0–100 and rounded; `size` is `sm` (8 px, default) or `md` (10 px); `tone` (`primary` default, `success`, `warning`, `danger`) colors the fill. `label` is required and becomes the bar's `aria-label`. The native `<progress>` element now carries an explicit `role="progressbar"` attribute (it already had the role implicitly) so consumers and specs can select on it directly. `percent` now guards against a non-finite `value` (e.g. `NaN`), rendering `0` instead of propagating `NaN` into the fill width and `aria-valuenow`.
3642
+
3643
+ ### avatar
3644
+
3645
+ New `bf-avatar`: a round avatar showing a photo (`src`/`alt`) or initials on a tinted circle. `size` is `sm` (30 px), `md` (40 px, default) or `lg` (84 px); `variant` is `tint` (default), `ink`, `warning` or `neutral`. The host now carries `role="img"`/`aria-label` only when `alt` is set; without `alt` the host is `aria-hidden="true"` and decorative, instead of always announcing itself with the initials as a fallback name. The inner `<img>` now always has `alt=""` so the image itself is hidden from the accessibility tree and the host's `aria-label` is the single source of the accessible name.
3646
+
3647
+ ### alert
3648
+
3649
+ New `bf-alert`: an inline `role="status"` (`role="alert"` for `danger`) message with `neutral` (default), `success`, `warning` and `danger` variants, an `icon` slot, a `title`, and an `actions` slot. `dismissible` shows a close button that emits `dismissed`; the component itself never hides. The dismiss button now renders as a sibling of the `role="status"`/`role="alert"` element (both wrapped in an outer `div`) instead of inside it, so assistive tech reading the status/alert region doesn't also announce the dismiss control as part of the message; the visual position (absolute, top-right) is unchanged. Added `dismissLabel` (default `"Dismiss"`) to customize the button's accessible name. The 32 px dismiss button now extends its hit area to 44 px with `after:-inset-1.5`, a self-contained control so the `::after` extension is the right tool here (unlike a control over projected content).
3650
+
3651
+ ### empty-state
3652
+
3653
+ New `bf-empty-state`: a centered placeholder with a 64 px `icon` tile, a required `title`, an optional `description`, and an `actions` slot for the action that fills it. Added `headingLevel` (`2` default or `3`) so the title renders as `h2` or `h3` to fit the page outline.
3654
+
3655
+ ### action-bar
3656
+
3657
+ New `bf-action-bar`: a floating pill for actions on a selection, with an optional leading `label` and a default slot for buttons. It only styles itself; the consumer positions it (fixed bottom-centre on web, `pb-safe-bottom` on mobile). Uses `role="group"` (was toolbar): the bar's contents aren't a fixed, single-tab-stop toolbar of controls, so `group` is the correct ARIA role; `aria-labelledby` now points at the `label` span instead of relying on visible text alone.
3658
+
3659
+ ### tab-bar
3660
+
3661
+ New `bf-tab-bar`: the Meadow Sage floating pill tab bar for mobile navigation, 64 px tall, holding a `role="tablist"` of `bf-tab` children. `active` (model) tracks the selected tab id; `label` (default `Main`) names the tablist; `floating` (default `true`) fixes the bar above the safe area with `shadow-floating`, or renders it inline with `shadow-raised` when false. ArrowLeft/ArrowRight/Home/End navigation moves both selection and focus together across the enabled tabs, wrapping at the ends and skipping disabled ones; selecting a tab emits `tabChange`. The bar and `bf-tab` communicate through the `BF_TAB_BAR` injection token so neither file imports the other. Added a `tabStop` computed, exposed through `BF_TAB_BAR`: the roving tab stop now falls back to the first enabled tab when `active` matches no enabled tab (empty, unresolved, or the active tab is disabled), instead of leaving every tab at `tabIndex -1` and removing the bar from the tab order.
3662
+
3663
+ ### tab
3664
+
3665
+ New `bf-tab`: one destination inside `bf-tab-bar` — an icon in the default slot, `label` under it, and an optional `badge` count. `id` and `label` are required; `disabled` removes it from keyboard navigation and blocks clicks. Renders a native `<button role="tab">` with `aria-selected` and a roving `tabindex` driven by the bar's `tabStop`; the active tab's icon sits on a `primary-soft` capsule (`forced-colors:bg-[Highlight]`) with a `primary-strong` label, inactive tabs are `text-muted`. The host no longer reflects `id` as a DOM attribute (`bf-tab id="today"` previously duplicated `id="today"` onto both the host and the inner `<button>`); the id now lands once, on the button. The badge is `aria-hidden`; a visually hidden `sr-only` span after the label announces it instead (`"{n} new"` for a numeric badge, the string as-is otherwise), so the tab's accessible name carries meaning instead of a bare number.