@bitforgehq/ui 0.3.1 → 0.3.3

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 (49) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +9 -8
  3. package/ai/MANIFEST.index.json +18 -0
  4. package/ai/MANIFEST.json +380 -31
  5. package/ai/REFERENCE.index.json +63 -0
  6. package/ai/REFERENCE.json +265 -7
  7. package/ai/UI_GUIDE.md +59 -33
  8. package/ai/components/app-frame.md +335 -0
  9. package/ai/components/card.md +56 -2
  10. package/ai/components/empty-state.md +99 -6
  11. package/ai/components/header.md +14 -1
  12. package/ai/components/sidebar-toggle.md +287 -0
  13. package/ai/components/sidebar.md +188 -10
  14. package/ai/components/stat.md +74 -10
  15. package/ai/components/table.md +152 -5
  16. package/ai/components/tabs.md +5 -1
  17. package/ai/components/toolbar.md +69 -4
  18. package/ai/reference/bf-app-frame-breakpoint.md +27 -0
  19. package/ai/reference/bf-app-frame-content-width.md +29 -0
  20. package/ai/reference/bf-empty-state-heading-level.md +1 -1
  21. package/ai/reference/bf-empty-state-heading.md +33 -0
  22. package/ai/reference/bf-labels.md +7 -0
  23. package/ai/reference/bf-select-harness.md +4 -0
  24. package/ai/reference/bf-sidebar-state-service.md +21 -0
  25. package/ai/reference/bf-sidebar-toggle-harness.md +32 -0
  26. package/ai/reference/bf-size.md +1 -0
  27. package/ai/reference/bf-stat-appearance.md +33 -0
  28. package/ai/reference/bf-stat-variant.md +1 -1
  29. package/ai/reference/bf-table-harness.md +7 -1
  30. package/ai/reference/bf-table-stack.md +29 -0
  31. package/ai/reference/bf_sidebar_state_storage.md +20 -0
  32. package/ai/reference/provide-bf-labels.md +4 -3
  33. package/ai/reference/provide-bf-sidebar-state.md +20 -0
  34. package/bin/bf-ui.js +433 -3
  35. package/fesm2022/bitforgehq-ui-mobile.mjs +11 -2
  36. package/fesm2022/bitforgehq-ui-mobile.mjs.map +1 -1
  37. package/fesm2022/bitforgehq-ui-overlay.mjs +571 -446
  38. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
  39. package/fesm2022/bitforgehq-ui-testing.mjs +22 -2
  40. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
  41. package/fesm2022/bitforgehq-ui.mjs +777 -127
  42. package/fesm2022/bitforgehq-ui.mjs.map +1 -1
  43. package/llms-full.txt +1605 -43
  44. package/llms.txt +11 -0
  45. package/package.json +1 -1
  46. package/theme.css +13 -0
  47. package/types/bitforgehq-ui-overlay.d.ts +51 -0
  48. package/types/bitforgehq-ui-testing.d.ts +49 -1
  49. package/types/bitforgehq-ui.d.ts +284 -20
package/llms-full.txt CHANGED
@@ -117,7 +117,12 @@ the widgets style their native controls themselves.
117
117
  Optional per-app overrides go after the import. A brand colour is a set: override
118
118
  `--color-primary` together with `--color-primary-text` (primary as text, ≥ 4.5:1 on `bg` and
119
119
  `surface`) and the rest of the `--color-primary-*` tokens, in `@theme` and in `.dark`; the docs
120
- site's theme configurator derives the whole contrast-checked set from one colour.
120
+ site's theme configurator derives the whole contrast-checked set from one colour. From a
121
+ terminal, `bunx bf-ui theme '<hex>' [--dark '<hex>']` prints the set (`primary`, its hover,
122
+ pressed, soft and strong steps, `on-primary`, `primary-text`, `selected`, `ring`) as a `@theme`
123
+ and a `.dark` block with each contrast pair's ratio, and exits 1 naming the pairs below their
124
+ minimum and a shade that passes; paste its output, never hand-pick the steps. Example (the
125
+ Meadow Sage green): `bunx bf-ui theme '#238652' --dark '#5cc48a'`.
121
126
  `--radius-control` (`9999px`, pills) reshapes buttons, chips, badges, segmented, the tab bar, the
122
127
  action bar and toggles at once:
123
128
 
@@ -138,16 +143,17 @@ Render library components only in the browser, for example in a `@defer` block o
138
143
  route. Support will be decided before 1.0.
139
144
 
140
145
  The one server-side check is a render smoke, not a support test: the library repository's
141
- `tools/ssr-smoke.ts` renders 42 fixtures with `renderApplication()` (every root widget, `bf-icon`,
146
+ `tools/ssr-smoke.ts` renders 43 fixtures with `renderApplication()` (every root widget, `bf-icon`,
142
147
  `bf-number-input`, a `/charts` line chart, a `/mobile` page with `provideBfShell()`, `bf-page`,
143
148
  `bf-header` and `bf-content`, `bf-tabs` with `bf-stack-outlet` under the router, and an `/overlay`
144
- dialog), and all of them render without an error. 27 of them render identically twice. The other 15
149
+ dialog), and all of them render without an error. 27 of them render identically twice. The other 16
145
150
  number their ids differently on every render, from a module-level counter, so they could not
146
- hydrate: `bf-calendar`, `bf-checkbox`, `bf-input`, `bf-radio-group`, `bf-segmented`, `bf-select`,
147
- `bf-sidebar`, `bf-tab-group`, `bf-table`, `bf-textarea`, `bf-theme-switch`, `bf-toggle`,
148
- `bf-line-chart`, `bf-number-input` and the `/overlay` dialog. Hydration itself is still not
149
- supported or tested, and more stands in its way: `/mobile` writes to `<html>` and adds a keyboard
150
- probe to `<body>`, and an `/overlay` dialog renders outside the app root and marks it inert.
151
+ hydrate: `bf-app-frame`, `bf-calendar`, `bf-checkbox`, `bf-input`, `bf-radio-group`, `bf-segmented`,
152
+ `bf-select`, `bf-sidebar`, `bf-tab-group`, `bf-table`, `bf-textarea`, `bf-theme-switch`,
153
+ `bf-toggle`, `bf-line-chart`, `bf-number-input` and the `/overlay` dialog. Hydration itself is still
154
+ not supported or tested, and more stands in its way: `/mobile` writes to `<html>` and adds a
155
+ keyboard probe to `<body>`, and an `/overlay` dialog renders outside the app root and marks it
156
+ inert.
151
157
 
152
158
  ## Keeping this guide in sync
153
159
 
@@ -335,8 +341,19 @@ protected readonly colorScheme = inject(BfColorSchemeService);
335
341
  `bf-tabs`, `bf-back-button` and `bf-header` are Capacitor shell parts. A desktop web app (no
336
342
  `provideBfShell()`) uses their desktop counterparts from `@bitforgehq/ui`:
337
343
 
338
- - **Navigation:** `bf-sidebar` (groups in `bf-nav-group`, `[(collapsed)]` for an icon rail; the
339
- app owns the toggle button) or `bf-top-nav` for a few sections, holding
344
+ - **App frame:** wrap the whole app in `<bf-app-frame>` (never build the frame by hand): the
345
+ `bf-sidebar` in `bfSlot="sidebar"` (sticky, full height, from `md` up), and below the
346
+ breakpoint a header bar with `bfSlot="brand"`, `bfSlot="menu"` (a `bf-menu-button` of
347
+ `a[bfMenuItem]` links from the same destinations array) and `bfSlot="actions"` (the theme
348
+ menu); the router outlet is the content. It brings the skip link and the focusable main
349
+ landmark; `breakpoint` and `contentWidth` adjust it. Desktop only: never inside
350
+ `provideBfShell()`.
351
+ - **Sidebar collapse:** `<bf-sidebar-toggle>` (`/overlay`, icons in `bfSlot="collapse-icon"` and
352
+ `bfSlot="expand-icon"`) in the sidebar footer beside the icon-only theme menu; with
353
+ `provideBfSidebarState()` bind `[(collapsed)]="sidebarState.collapsed"`
354
+ (`BfSidebarStateService`) so the choice is remembered.
355
+ - **Navigation:** `bf-sidebar` (groups in `bf-nav-group`, `[(collapsed)]` for an icon rail) or
356
+ `bf-top-nav` for a few sections, holding
340
357
  `<a bfNavItem routerLink="…" routerLinkActive ariaCurrentWhenActive="page">` links with an icon
341
358
  in `bfSlot="icon"` and an optional `[badge]` count. The router sets `aria-current="page"` and
342
359
  the item shows it. Drive the sidebar, a below-`md` `bf-menu-button` of `a[bfMenuItem]` links and,
@@ -354,8 +371,9 @@ protected readonly colorScheme = inject(BfColorSchemeService);
354
371
  controls default to `sm`, and `status` is a polite live region for the result count.
355
372
  - **Table:** `<bf-table [rows] [columns] [(sort)] selectable [(selection)]>` with
356
373
  `ng-template[bfTableCell]` for rich cells and `bfSlot="empty"` for the empty state; the app
357
- sorts the rows in a `computed()`, and a selection shows a `bf-action-bar`. Below `md` or on a
358
- coarse pointer render the same rows as `bf-list-item`s.
374
+ sorts the rows in a `computed()`, and a selection shows a `bf-action-bar`. `stack="narrow"`
375
+ (below 36rem of its own width) or `stack="coarse"` turns each row into a label/value tile with
376
+ a sort and select bar; in the Capacitor shell use `bf-list-item` rows instead.
359
377
  - **Details:** `bf-description-list` of `div[bfDescriptionItem]` label/value pairs, and `bf-stat`
360
378
  figure tiles in a grid; both work in both shells.
361
379
  - **Cards in a grid:** the grid stretches each `bf-card` and its `bfSlot="footer"` sits on the
@@ -384,6 +402,12 @@ protected readonly colorScheme = inject(BfColorSchemeService);
384
402
  `bf-sparkline` inside a `bf-chart-card` (title, period slot, loading, empty and error states),
385
403
  themed by the `chart-1`…`chart-8` tokens. Every chart takes a `label` and offers its data as a
386
404
  table.
405
+ - **Surfaces:** one surface style per level. A raised `bf-card` holds sunken tiles
406
+ (`bf-stat variant="tile"`), never an outlined surface (`variant="card"` or a `flat` card) inside
407
+ a raised one; next to raised cards on a dashboard, use raised cards.
408
+ - **Empty pages:** a 404, "no access" or "nothing here yet" page is a `bf-empty-state` with
409
+ `headingLevel` 1, centred in the content area, with the main action and an optional link.
410
+ - **Toolbar on a phone:** `bf-toolbar` keeps its status and actions together on the last row.
387
411
 
388
412
  ## Overlays (`@bitforgehq/ui/overlay`)
389
413
 
@@ -1230,6 +1254,7 @@ notes.
1230
1254
 
1231
1255
  | Component | Entry point | Purpose |
1232
1256
  | --- | --- | --- |
1257
+ | `bf-app-frame` | `@bitforgehq/ui` | Desktop app frame with skip link, sticky sidebar, narrow header and main content |
1233
1258
  | `bf-back-button` | `@bitforgehq/ui/mobile` | Header back button that pops the page stack or falls back to a default route |
1234
1259
  | `bf-breadcrumb` | `@bitforgehq/ui` | Desktop breadcrumb trail of links to the current page, with optional collapsing |
1235
1260
  | `li[bfBreadcrumbItem]` | `@bitforgehq/ui` | One level of a breadcrumb trail: a link, or the current page as text |
@@ -1242,6 +1267,7 @@ notes.
1242
1267
  | `bf-pagination` | `@bitforgehq/ui` | Previous, next and numbered page buttons for a long list or table |
1243
1268
  | `bf-refresher` | `@bitforgehq/ui/mobile` | Pull-to-refresh indicator with haptic feedback, native iOS bounce and an Android-style pull |
1244
1269
  | `bf-sidebar` | `@bitforgehq/ui` | Vertical app navigation with brand, grouped links, footer and an icon rail mode |
1270
+ | `bf-sidebar-toggle` | `@bitforgehq/ui/overlay` | Icon button inside a sidebar that collapses it to an icon rail and back |
1245
1271
  | `bf-stack-outlet` | `@bitforgehq/ui/mobile` | Router outlet keeping a live page stack with slide transitions and iOS swipe back |
1246
1272
  | `bf-tab` | `@bitforgehq/ui` | One tab bar destination with an icon, a label and an optional count badge |
1247
1273
  | `bf-tab-bar` | `@bitforgehq/ui` | Floating pill tab bar for mobile apps with arrow-key selection and count badges |
@@ -2920,6 +2946,344 @@ focusable content. `provideBfShell()` now also provides `BfAlertDialogService`.
2920
2946
 
2921
2947
  ---
2922
2948
 
2949
+ # bf-app-frame
2950
+
2951
+ Desktop app frame with skip link, sticky sidebar, narrow header and main content
2952
+
2953
+ `BfAppFrameComponent` from `@bitforgehq/ui` · Navigation · since 0.3.2
2954
+
2955
+ ## Example
2956
+
2957
+ ```ts
2958
+ import { Component, signal } from '@angular/core';
2959
+ import { RouterLink, RouterLinkActive } from '@angular/router';
2960
+ import {
2961
+ BfAppFrameComponent,
2962
+ BfIconButtonComponent,
2963
+ BfNavItemComponent,
2964
+ BfSidebarComponent,
2965
+ } from '@bitforgehq/ui';
2966
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
2967
+ import {
2968
+ BfMenuButtonComponent,
2969
+ BfMenuComponent,
2970
+ BfMenuItemDirective,
2971
+ } from '@bitforgehq/ui/overlay';
2972
+ import { Bell, type IconNode, Layers, Menu, PanelLeft, PanelTop } from 'lucide';
2973
+
2974
+ /** One top-level destination of the app, shared by the sidebar and the narrow-screen menu. */
2975
+ interface Destination {
2976
+ readonly path: string;
2977
+ readonly label: string;
2978
+ readonly icon: IconNode;
2979
+ }
2980
+
2981
+ /**
2982
+ * The bf-app-frame overview.
2983
+ *
2984
+ * Live demo of `bf-app-frame` in a box of fixed height: one destinations array drives the
2985
+ * `bf-sidebar` (from `md` up) and the narrow header's `bf-menu-button`, at the header's inline
2986
+ * start; the brand follows it, and a notifications action sits at the end. Tab into the frame to reveal its skip link; Enter moves focus
2987
+ * to the content. The result line reports it.
2988
+ */
2989
+ @Component({
2990
+ selector: 'app-example-app-frame-overview',
2991
+ imports: [
2992
+ RouterLink,
2993
+ RouterLinkActive,
2994
+ BfAppFrameComponent,
2995
+ BfIconButtonComponent,
2996
+ BfIconComponent,
2997
+ BfMenuButtonComponent,
2998
+ BfMenuComponent,
2999
+ BfMenuItemDirective,
3000
+ BfNavItemComponent,
3001
+ BfSidebarComponent,
3002
+ ],
3003
+ template: `
3004
+ <!-- In an app the frame is the root of the page; here a fixed height stands in for the
3005
+ viewport, so the sidebar and the header bar stick inside the box. -->
3006
+ <div (focusin)="onFocus($event)">
3007
+ <bf-app-frame
3008
+ mainId="app-frame-demo-content"
3009
+ class="border-border rounded-card h-96 overflow-auto border"
3010
+ >
3011
+ <bf-sidebar bfSlot="sidebar" label="App frame demo">
3012
+ <span bfSlot="header" class="text-heading ps-3">Atlas</span>
3013
+ @for (destination of destinations; track destination.path) {
3014
+ <a
3015
+ bfNavItem
3016
+ [routerLink]="destination.path"
3017
+ routerLinkActive
3018
+ ariaCurrentWhenActive="page"
3019
+ >
3020
+ <bf-icon bfSlot="icon" [icon]="destination.icon" />
3021
+ {{ destination.label }}
3022
+ </a>
3023
+ }
3024
+ </bf-sidebar>
3025
+
3026
+ <bf-menu-button bfSlot="menu" variant="text" [menu]="navMenu">
3027
+ <bf-icon bfSlot="icon-start" [icon]="menu" />
3028
+ Sections
3029
+ </bf-menu-button>
3030
+ <span bfSlot="brand" class="text-heading">Atlas</span>
3031
+ <bf-icon-button
3032
+ bfSlot="actions"
3033
+ variant="text"
3034
+ label="Notifications"
3035
+ (clicked)="status.set('Notifications opened.')"
3036
+ >
3037
+ <bf-icon [icon]="bell" />
3038
+ </bf-icon-button>
3039
+
3040
+ <div class="flex flex-col gap-3">
3041
+ <h2 class="text-title">Inbox</h2>
3042
+ <p class="text-body text-text-muted max-w-prose">
3043
+ The page sits in the content column, beside the sidebar from the md breakpoint up and
3044
+ under the header bar below it.
3045
+ </p>
3046
+ <p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
3047
+ @for (row of rows; track row) {
3048
+ <div class="bg-surface rounded-tile text-body p-4">{{ row }}</div>
3049
+ }
3050
+ </div>
3051
+ </bf-app-frame>
3052
+ </div>
3053
+
3054
+ <ng-template #navMenu>
3055
+ <bf-menu>
3056
+ @for (destination of destinations; track destination.path) {
3057
+ <a
3058
+ bfMenuItem
3059
+ [routerLink]="destination.path"
3060
+ routerLinkActive
3061
+ ariaCurrentWhenActive="page"
3062
+ >{{ destination.label }}</a
3063
+ >
3064
+ }
3065
+ </bf-menu>
3066
+ </ng-template>
3067
+ `,
3068
+ })
3069
+ export class AppFrameOverviewExampleComponent {
3070
+ /** In an app, derive this from the route config, so a new route appears on every surface. */
3071
+ protected readonly destinations: readonly Destination[] = [
3072
+ { path: '/components/app-frame', label: 'App frame', icon: PanelLeft },
3073
+ { path: '/components/sidebar', label: 'Sidebar', icon: PanelTop },
3074
+ { path: '/components/menu-button', label: 'Menu button', icon: Layers },
3075
+ ];
3076
+ protected readonly rows = [
3077
+ 'Review the brand brief',
3078
+ 'Plan the launch',
3079
+ 'Write the release notes',
3080
+ ];
3081
+ protected readonly bell = Bell;
3082
+ protected readonly menu = Menu;
3083
+ readonly status = signal('Tab into the frame to reveal the skip link.');
3084
+
3085
+ /** Reports when focus lands on the content, as the skip link moves it there. */
3086
+ protected onFocus(event: FocusEvent): void {
3087
+ if (event.target instanceof HTMLElement && event.target.id === 'app-frame-demo-content') {
3088
+ this.status.set('Skipped to the content.');
3089
+ }
3090
+ }
3091
+ }
3092
+ ```
3093
+
3094
+ ## Overview
3095
+
3096
+ The frame of a desktop web app: a skip link, a sticky full-height sidebar column from the
3097
+ breakpoint up, a header bar with the narrow-screen menu, the brand and actions below it, and
3098
+ the `main` landmark with a content column of a set maximum width.
3099
+
3100
+ The frame around a desktop web app's pages: a skip link, a [`bf-sidebar`](https://bitforge-ui.web.app/components/sidebar) column that sticks to the viewport at its full height from a breakpoint up, a header bar below the breakpoint with the brand, the narrow-screen [`bf-menu-button`](https://bitforge-ui.web.app/components/menu-button) and end actions, and the `main` landmark with a content column of a set maximum width. It replaces the `hidden md:flex` sidebar, the `md:hidden` menu button and the padded `main` an app would otherwise build by hand. The frame imports no overlay and never reads the router: the menu is your own `bf-menu-button`, projected into a slot.
3101
+
3102
+ ## When to use
3103
+
3104
+ Use `bf-app-frame` once, at the root of a desktop web app whose top-level destinations live in a `bf-sidebar`, with your `router-outlet` as its content. Use [bf-top-nav](https://bitforge-ui.web.app/components/top-nav) in your own header instead for an app with a handful of sections and no sidebar. In the Capacitor shell use [bf-tabs](https://bitforge-ui.web.app/components/tabs) with a [bf-tab-bar](https://bitforge-ui.web.app/components/tab-bar), and a [bf-page](https://bitforge-ui.web.app/components/page) with a [bf-header](https://bitforge-ui.web.app/components/header) per page, instead; never put the frame inside [`provideBfShell()`](https://bitforge-ui.web.app/api/provide-bf-shell).
3105
+
3106
+ ## Slots and layout
3107
+
3108
+ Put the `bf-sidebar` in `bfSlot="sidebar"`, the app's name or logo in `bfSlot="brand"`, the narrow-screen `bf-menu-button` of `a[bfMenuItem]` router links in `bfSlot="menu"` and end actions (the theme menu, the account) in `bfSlot="actions"`; everything else is the page. From the breakpoint up the sidebar column sits at the inline start, `sticky top-0` and `h-dvh` tall, and the `bf-sidebar` inside fills it and scrolls its own destinations; the header bar is hidden. Below it the sidebar column is hidden and the header bar (`surface`, a bottom border, sticky at the top) shows the menu at the inline start, then the brand, and the actions at the end: the top app bar convention, and the menu comes first in the tab order too. The order is fixed, whatever order the slots have in your template. Keep the brand in the sidebar's own `header` slot too, for the wide layout.
3109
+
3110
+ ## Breakpoint and content width
3111
+
3112
+ `breakpoint` (`BfAppFrameBreakpoint`) is the viewport width from which the sidebar shows: `md` (768 px, the default) or `lg` (1024 px) for a wide sidebar that would crowd a tablet. `contentWidth` (`BfAppFrameContentWidth`) caps the content column, centred in the space beside the sidebar: `md` 48rem, `lg` 64rem, `xl` 80rem (the default) and `full` for no limit. The content is padded 16 px below the breakpoint and 32 px from it up.
3113
+
3114
+ ```ts
3115
+ import { Component } from '@angular/core';
3116
+ import { RouterOutlet } from '@angular/router';
3117
+ import { BfAppFrameComponent, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
3118
+
3119
+ /**
3120
+ * A narrow reading column, with the sidebar from lg up.
3121
+ *
3122
+ * A settings or reading app keeps its lines short: `contentWidth="md"` caps the content column at
3123
+ * 48rem (`lg` 64rem, the default `xl` 80rem, `full` no limit), and `breakpoint="lg"` keeps the
3124
+ * header bar up to 1024 px, for a sidebar that would crowd a tablet. `mainId` names the main
3125
+ * landmark, the skip link's target.
3126
+ *
3127
+ * @preview none
3128
+ */
3129
+ @Component({
3130
+ selector: 'app-example-app-frame-content-width',
3131
+ imports: [RouterOutlet, BfAppFrameComponent, BfNavItemComponent, BfSidebarComponent],
3132
+ template: `
3133
+ <bf-app-frame contentWidth="md" breakpoint="lg" mainId="content">
3134
+ <bf-sidebar bfSlot="sidebar" label="Settings">
3135
+ <a bfNavItem href="/account">Account</a>
3136
+ <a bfNavItem href="/billing">Billing</a>
3137
+ </bf-sidebar>
3138
+ <span bfSlot="brand" class="text-heading">Settings</span>
3139
+ <router-outlet />
3140
+ </bf-app-frame>
3141
+ `,
3142
+ })
3143
+ export class AppFrameContentWidthExampleComponent {}
3144
+ ```
3145
+
3146
+ ## Skip link and the main landmark
3147
+
3148
+ The first element in the tab order is a "Skip to main content" link (`provideBfLabels()`'s `skipToContent`), visually hidden until it has focus, then shown as a pill at the top inline-start corner. Activating it moves focus to the frame's main landmark (`tabindex="-1"`, its id from `mainId`, generated when left out) by script and cancels the link's navigation. That interception matters: under `<base href="/">`, which every Angular app has, a bare `#id` link resolves against the base, so following it would load the root URL instead of moving focus. The href stays for the link's semantics. Nested inside another `main` landmark (a docs preview, as on this page) the content drops its `main` role, so the document keeps exactly one.
3149
+
3150
+ ## One array of destinations
3151
+
3152
+ Keep one array of destinations, ideally derived from your route config, and drive every surface from it: the sidebar's `a[bfNavItem]` links, the narrow menu's `a[bfMenuItem]` links and, in the Capacitor shell, the `bf-tab`s of a `bf-tab-bar` (where each destination's path is a child route of the tabs and the tab's id). The overview at the top of this page drives the sidebar and the menu from one array; the shell keeps `bf-tabs` and has no frame:
3153
+
3154
+ ```ts
3155
+ import { Component } from '@angular/core';
3156
+ import { BfTabBarComponent, BfTabComponent } from '@bitforgehq/ui';
3157
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
3158
+ import { BfStackOutletComponent, BfTabsComponent } from '@bitforgehq/ui/mobile';
3159
+ import { Folder, type IconNode, Inbox, Settings } from 'lucide';
3160
+
3161
+ /** One top-level destination: `path` is the route under the tabs, and the tab's id. */
3162
+ interface Destination {
3163
+ readonly path: string;
3164
+ readonly label: string;
3165
+ readonly icon: IconNode;
3166
+ }
3167
+
3168
+ /**
3169
+ * The same destinations in the Capacitor shell.
3170
+ *
3171
+ * Inside `provideBfShell()` there is no `bf-app-frame`: the array that feeds the frame's sidebar
3172
+ * and narrow menu drives the `bf-tab`s of a `bf-tab-bar` in `bf-tabs` instead, and each page
3173
+ * brings its own `bf-page` with a `bf-header`. Each destination's `path` is a child route of
3174
+ * `app` and the tab's id; on desktop the same array feeds `a[bfNavItem]` and `a[bfMenuItem]`
3175
+ * links (`routerLink="/app/{path}"`).
3176
+ *
3177
+ * @preview none
3178
+ */
3179
+ @Component({
3180
+ selector: 'app-example-app-frame-shell',
3181
+ imports: [
3182
+ BfIconComponent,
3183
+ BfStackOutletComponent,
3184
+ BfTabBarComponent,
3185
+ BfTabComponent,
3186
+ BfTabsComponent,
3187
+ ],
3188
+ template: `
3189
+ <bf-tabs tabsPath="app">
3190
+ <bf-stack-outlet />
3191
+ <bf-tab-bar>
3192
+ @for (destination of destinations; track destination.path) {
3193
+ <bf-tab [id]="destination.path" [label]="destination.label"
3194
+ ><bf-icon [icon]="destination.icon"
3195
+ /></bf-tab>
3196
+ }
3197
+ </bf-tab-bar>
3198
+ </bf-tabs>
3199
+ `,
3200
+ })
3201
+ export class AppFrameShellExampleComponent {
3202
+ protected readonly destinations: readonly Destination[] = [
3203
+ { path: 'inbox', label: 'Inbox', icon: Inbox },
3204
+ { path: 'projects', label: 'Projects', icon: Folder },
3205
+ { path: 'settings', label: 'Settings', icon: Settings },
3206
+ ];
3207
+ }
3208
+ ```
3209
+
3210
+ ## Desktop
3211
+
3212
+ The frame is a desktop pattern. The skip link shows its focus ring on keyboard focus only (`focus-visible`); it stays out of sight for pointer users, who never focus it. The sidebar column keeps its height while the page scrolls under the pointer, and the narrow header bar stays at the top. The breakpoint follows the viewport, not a container, so a frame inside a smaller box (as in the preview above) still switches at the viewport's width. Under a coarse pointer (a touchscreen laptop) the sidebar's items and the projected controls take their own touch heights; the frame adds none.
3213
+
3214
+ ## Theming
3215
+
3216
+ - Frame: `bg` background, `text` colour.
3217
+ - Narrow header bar: `surface` with a `border` line at the bottom, at least 56 px tall.
3218
+ - Skip link: `surface` background, `text`, `shadow-floating`, the `control` (pill) radius and the `ring` focus ring.
3219
+ - Content: 16 px padding below the breakpoint, 32 px from it up; `max-w-3xl`, `max-w-5xl`, `max-w-7xl` or no cap by `contentWidth`.
3220
+ - The sidebar's own tokens are on the [sidebar](https://bitforge-ui.web.app/components/sidebar#theming) page.
3221
+
3222
+ ## Accessibility
3223
+
3224
+ The skip link is a native `<a>` with an href to the main landmark and is the first focusable element, so a keyboard user reaches the content in one step; it moves focus by script (see above). The content is a `role="main"` landmark with `tabindex="-1"`, so it takes focus without joining the tab order, and drops the role inside another `main`. The narrow header bar is a native `header` (the banner landmark at the root of a page). Name the `bf-sidebar` by `label` and the menu button by its text; the frame names nothing else itself. Hidden regions use `display: none`, so the sidebar below the breakpoint and the header bar from it up are out of the accessibility tree too.
3225
+
3226
+ ### Keyboard
3227
+
3228
+ | Key | Action |
3229
+ | ------------------------------------------------ | ------------------------------------------------------------------------- |
3230
+ | <kbd>Tab</kbd> | From the top of the page, focuses the skip link first and reveals it |
3231
+ | <kbd>Enter</kbd> | On the skip link, moves focus to the main content |
3232
+ | <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the sidebar or the header bar, then the page, in that order |
3233
+
3234
+ ## Inputs
3235
+
3236
+ | Name | Type | Default | Description |
3237
+ | --- | --- | --- | --- |
3238
+ | `breakpoint` | `BfAppFrameBreakpoint` (`'md' \| 'lg'`) | `'md'` | The viewport width from which the sidebar column replaces the narrow header bar. |
3239
+ | `contentWidth` | `BfAppFrameContentWidth` (`'md' \| 'lg' \| 'xl' \| 'full'`) | `'xl'` | The maximum width of the content column (`md` 48rem, `lg` 64rem, `xl` 80rem, `full`). |
3240
+ | `mainId` | `string` | `generated` | The id of the `main` landmark, the skip link's target; generated when left out. |
3241
+
3242
+ ## Slots
3243
+
3244
+ - `sidebar`: The `bf-sidebar`, shown from the breakpoint up; it fills the column's height.
3245
+ - `menu`: The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links, at the header bar's inline start.
3246
+ - `brand`: The app's name or logo in the narrow header bar, after the menu.
3247
+ - `actions`: End actions of the narrow header bar, such as the theme menu.
3248
+ - `default`: The page: what the router shows, inside the `main` landmark.
3249
+
3250
+ ## Notes
3251
+
3252
+ ### Do
3253
+
3254
+ - Put the frame at the root of the app, with the `router-outlet` as its content, so the sidebar stays put between pages.
3255
+ - Drive the sidebar, the narrow menu and the shell's tab bar from one array of destinations.
3256
+ - Keep one `h1` per page in the content; the frame renders no heading.
3257
+
3258
+ ### Don't
3259
+
3260
+ - Don't use `bf-app-frame` inside the Capacitor shell; use `bf-tabs` with a `bf-tab-bar` and `bf-page` there.
3261
+ - Don't add your own skip link or hide the `bf-sidebar` with `hidden md:flex` inside the frame; the frame does both.
3262
+
3263
+ ## Migration notes
3264
+
3265
+ ### 0.3.3
3266
+
3267
+ Fix: the narrow header bar puts the `menu` slot at the inline start, before the `brand` (0.3.2 put
3268
+ it after the brand), with the actions at the end as before: the top app bar convention. The menu
3269
+ also comes first in the tab order. No API change; an app needs no change unless it relied on the
3270
+ brand coming first.
3271
+
3272
+ ### 0.3.2
3273
+
3274
+ New `bf-app-frame` in `@bitforgehq/ui`: the frame of a desktop web app. Slots `sidebar` (a
3275
+ `bf-sidebar`, in a column that sticks to the viewport at full height from the breakpoint up),
3276
+ `brand`, `menu` (the narrow-screen `bf-menu-button`) and `actions` (the header bar shown below
3277
+ the breakpoint) and the default slot (the page, in the `main` landmark). `breakpoint`
3278
+ (`BfAppFrameBreakpoint`: `'md'` default, `'lg'`), `contentWidth` (`BfAppFrameContentWidth`:
3279
+ `'md'` 48rem, `'lg'` 64rem, `'xl'` 80rem default, `'full'`) and `mainId`. A skip link, first in
3280
+ the tab order and hidden until focused, moves focus to the main landmark by script, so it works
3281
+ under `<base href="/">`; new label key `skipToContent` ("Skip to main content"). Inside another
3282
+ `main` landmark the content drops its `main` role. Desktop only: the Capacitor shell keeps
3283
+ `bf-tabs`.
3284
+
3285
+ ---
3286
+
2923
3287
  # bf-avatar
2924
3288
 
2925
3289
  Round avatar showing a photo or initials, in three sizes and four color variants
@@ -5078,7 +5442,7 @@ Surface for a piece of content: a project summary, a stat, a focus prompt. Four
5078
5442
  the design: raised (default), tint, ink and flat. To make the whole card clickable, put a
5079
5443
  `bfAction` link or button in its heading (the stretched-link pattern).
5080
5444
 
5081
- Cards group a piece of content on the canvas. The raised card is the workhorse; tint highlights a stat or a callout; ink is the dark focus card; flat sits inside another surface.
5445
+ Cards group a piece of content on the canvas. The raised card is the workhorse; tint highlights a stat or a callout; ink is the dark focus card; flat is the outlined card for a page level that should stay quiet, such as a dense grid of cards.
5082
5446
 
5083
5447
  ## When to use
5084
5448
 
@@ -5131,6 +5495,53 @@ import { BfCardComponent } from '@bitforgehq/ui';
5131
5495
  export class CardVariantsExampleComponent {}
5132
5496
  ```
5133
5497
 
5498
+ ## Nesting surfaces
5499
+
5500
+ Each level of a page takes one surface style, and a level inside a card steps down, never up:
5501
+
5502
+ - **Raised card, then sunken tile.** Inside a raised card, group figures or blocks on sunken, borderless tiles: [bf-stat](https://bitforge-ui.web.app/components/stat) `variant="tile"`, or your own block on `bg-surface-sunken` with the `card` radius.
5503
+ - **Never an outlined surface inside a raised one.** A `flat` card or a `card` stat inside a raised card draws a border on top of a shadow, two edges for one level; use a tile instead. Don't nest raised cards either.
5504
+ - **One surface style per level of a dashboard.** All cards on the page are raised (or all flat), and all tiles in them are sunken; mixing raised and flat cards side by side, or tiles and outlined stats in one card, reads as different kinds of content. The [bf-stat dashboard example](https://bitforge-ui.web.app/components/stat) puts raised cards on the page and sunken tiles in each.
5505
+
5506
+ ```ts
5507
+ import { Component } from '@angular/core';
5508
+ import { BfCardComponent, BfStatComponent } from '@bitforgehq/ui';
5509
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
5510
+ import { CircleCheck, Clock, ListTodo } from 'lucide';
5511
+
5512
+ /**
5513
+ * A raised card holding a row of sunken tiles.
5514
+ *
5515
+ * One surface per level: the card is raised on the page, and the stats inside it are sunken,
5516
+ * borderless tiles (`bf-stat variant="tile"`), never outlined surfaces.
5517
+ */
5518
+ @Component({
5519
+ selector: 'app-example-card-nesting',
5520
+ imports: [BfCardComponent, BfIconComponent, BfStatComponent],
5521
+ template: `
5522
+ <bf-card padding="lg">
5523
+ <h3 class="text-heading">This week</h3>
5524
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
5525
+ <bf-stat label="Open tasks" value="14" variant="tile">
5526
+ <bf-icon bfSlot="icon" [icon]="listTodo" />
5527
+ </bf-stat>
5528
+ <bf-stat label="Done" value="23" variant="tile">
5529
+ <bf-icon bfSlot="icon" [icon]="circleCheck" />
5530
+ </bf-stat>
5531
+ <bf-stat label="Time logged" value="31 h" variant="tile">
5532
+ <bf-icon bfSlot="icon" [icon]="clock" />
5533
+ </bf-stat>
5534
+ </div>
5535
+ </bf-card>
5536
+ `,
5537
+ })
5538
+ export class CardNestingExampleComponent {
5539
+ protected readonly listTodo = ListTodo;
5540
+ protected readonly circleCheck = CircleCheck;
5541
+ protected readonly clock = Clock;
5542
+ }
5543
+ ```
5544
+
5134
5545
  ## Footer
5135
5546
 
5136
5547
  Content in `bfSlot="footer"` sits on the card's bottom edge: it takes the free space above it, so in a grid of cards (a `bf-card` fills its grid cell, `h-full`) every footer lines up, however differently the descriptions wrap, and the body keeps its 12 px gap above the footer. Put the footer's parts in one element (`<div bfSlot="footer" class="flex …">`), so they move together.
@@ -5302,13 +5713,20 @@ The card renders a plain `<div>` with no role. A clickable card follows the stre
5302
5713
 
5303
5714
  ### Don't
5304
5715
 
5305
- - Don't nest raised cards; use `flat` inside.
5716
+ - Don't nest raised cards, and don't put an outlined surface (`flat`, a `card` stat) inside a raised one; use sunken tiles (`bf-stat variant="tile"`) inside.
5717
+ - Don't mix surface styles on one level of a dashboard; keep one per level.
5306
5718
  - Don't use `interactive` (deprecated) or wrap a card in an `<a>`; both hide the heading and nest interactive content.
5307
5719
  - Don't put more than one `bfAction` in a card.
5308
5720
  - Don't put a `text` button on an ink card — its primary colour fails contrast on ink; use `tonal` or `outline`.
5309
5721
 
5310
5722
  ## Migration notes
5311
5723
 
5724
+ ### 0.3.2
5725
+
5726
+ New "Nesting surfaces" docs section: a raised card holds sunken tiles, never an outlined
5727
+ surface (`flat`, a `card` stat), and each level of a dashboard keeps one surface style. New
5728
+ `card/nesting` example. No code change.
5729
+
5312
5730
  ### 0.3.1
5313
5731
 
5314
5732
  The footer (`bfSlot="footer"`) sits on the card's bottom edge (`mt-auto`), so in a grid of cards
@@ -10011,6 +10429,89 @@ export class EmptyStateActionExampleComponent {
10011
10429
 
10012
10430
  Set `headingLevel` to `3` when the empty state sits inside a section that already opened with an `h2`, so the page outline stays in order.
10013
10431
 
10432
+ ## As the page
10433
+
10434
+ When the empty state is the whole page (a not-found page, a no-access page, an app's first run), its title is the page's heading: set `headingLevel` to `1` so it renders the `h1`, and give the page no other `h1`. Centre it in the content area: a column with a minimum height (`min-h-[60vh]`) and `justify-center`. Put the primary action first in the `actions` slot (a `bfButton` link home) and an optional secondary link after it (a `text` `bfButton`, such as "Contact support").
10435
+
10436
+ The preview below sits inside this docs page, which already has its `h1`, so it keeps `headingLevel` 2 in a shorter area; a real page uses `1`, as in the code-only example after it.
10437
+
10438
+ ```ts
10439
+ import { Component } from '@angular/core';
10440
+ import { RouterLink } from '@angular/router';
10441
+ import { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';
10442
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
10443
+ import { FileQuestionMark } from 'lucide';
10444
+
10445
+ /**
10446
+ * An empty state as a whole page.
10447
+ *
10448
+ * Centred in a tall content area, with a primary action and a secondary link. This preview sits
10449
+ * inside a docs page that has its own `h1`, so it uses `headingLevel` 2; on a real not-found page
10450
+ * the empty state is the page, so set it to 1 (see the next example).
10451
+ */
10452
+ @Component({
10453
+ selector: 'app-example-empty-state-page',
10454
+ imports: [RouterLink, BfButtonComponent, BfEmptyStateComponent, BfIconComponent],
10455
+ template: `
10456
+ <div class="flex min-h-96 flex-col justify-center">
10457
+ <bf-empty-state
10458
+ title="Page not found"
10459
+ description="The page may have moved, or the link has a typo."
10460
+ [headingLevel]="2"
10461
+ >
10462
+ <bf-icon bfSlot="icon" [icon]="fileQuestion" size="lg" />
10463
+ <a bfSlot="actions" bfButton routerLink="/">Go to the home page</a>
10464
+ <a bfSlot="actions" bfButton variant="text" href="mailto:support@example.com">
10465
+ Contact support
10466
+ </a>
10467
+ </bf-empty-state>
10468
+ </div>
10469
+ `,
10470
+ })
10471
+ export class EmptyStatePageExampleComponent {
10472
+ protected readonly fileQuestion = FileQuestionMark;
10473
+ }
10474
+ ```
10475
+
10476
+ ```ts
10477
+ import { Component } from '@angular/core';
10478
+ import { RouterLink } from '@angular/router';
10479
+ import { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';
10480
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
10481
+ import { FileQuestionMark } from 'lucide';
10482
+
10483
+ /**
10484
+ * A not-found page whose empty state is the page heading.
10485
+ *
10486
+ * The route's whole content: `headingLevel` 1 renders the title as the page's `h1`, and the
10487
+ * `min-h-[60vh]` area centres the empty state vertically in the content column.
10488
+ *
10489
+ * @preview none
10490
+ */
10491
+ @Component({
10492
+ selector: 'app-example-empty-state-not-found-page',
10493
+ imports: [RouterLink, BfButtonComponent, BfEmptyStateComponent, BfIconComponent],
10494
+ template: `
10495
+ <div class="flex min-h-[60vh] flex-col justify-center">
10496
+ <bf-empty-state
10497
+ title="Page not found"
10498
+ description="The page may have moved, or the link has a typo."
10499
+ [headingLevel]="1"
10500
+ >
10501
+ <bf-icon bfSlot="icon" [icon]="fileQuestion" size="lg" />
10502
+ <a bfSlot="actions" bfButton routerLink="/">Go to the home page</a>
10503
+ <a bfSlot="actions" bfButton variant="text" href="mailto:support@example.com">
10504
+ Contact support
10505
+ </a>
10506
+ </bf-empty-state>
10507
+ </div>
10508
+ `,
10509
+ })
10510
+ export class EmptyStateNotFoundPageExampleComponent {
10511
+ protected readonly fileQuestion = FileQuestionMark;
10512
+ }
10513
+ ```
10514
+
10014
10515
  ## Theming
10015
10516
 
10016
10517
  - Icon tile: `surface-sunken` background, `text-muted` icon color, `tile` radius.
@@ -10018,7 +10519,7 @@ Set `headingLevel` to `3` when the empty state sits inside a section that alread
10018
10519
 
10019
10520
  ## Accessibility
10020
10521
 
10021
- The title renders as an `<h2>` by default and an `<h3>` when `headingLevel` is `3`, so it slots into the surrounding page outline instead of always claiming the top level. The icon and action slots carry their own accessibility — an icon should stay decorative, and the action needs its own accessible name.
10522
+ The title renders as an `<h2>` by default, an `<h3>` when `headingLevel` is `3` and an `<h1>` when it is `1` (only when the empty state is the page and the page has no other `h1`), so it slots into the surrounding page outline instead of always claiming the top level. The icon and action slots carry their own accessibility — an icon should stay decorative, and the action needs its own accessible name.
10022
10523
 
10023
10524
  ## Inputs
10024
10525
 
@@ -10026,7 +10527,7 @@ The title renders as an `<h2>` by default and an `<h3>` when `headingLevel` is `
10026
10527
  | --- | --- | --- | --- |
10027
10528
  | `title` | `string` | required | What is missing, as a short title. |
10028
10529
  | `description` | `string` | `''` | One sentence on how to fill it. |
10029
- | `headingLevel` | `BfEmptyStateHeadingLevel` (`2 \| 3`) | `2` | Heading level of the title so the empty state fits the page outline. |
10530
+ | `headingLevel` | `BfEmptyStateHeading` (`2 \| 3 \| 1`) | `2` | Heading level of the title so the empty state fits the page outline; 1 when it is the page. |
10030
10531
 
10031
10532
  ## Slots
10032
10533
 
@@ -10042,10 +10543,20 @@ The title renders as an `<h2>` by default and an `<h3>` when `headingLevel` is `
10042
10543
 
10043
10544
  ### Don't
10044
10545
 
10546
+ - Don't set `headingLevel` to `1` on an empty state inside a page that already has its own `h1`.
10045
10547
  - Don't use `bf-empty-state` for a loading screen; that is `bf-spinner`.
10046
10548
 
10047
10549
  ## Migration notes
10048
10550
 
10551
+ ### 0.3.2
10552
+
10553
+ `headingLevel` now takes the new named type `BfEmptyStateHeading` (`BfEmptyStateHeadingLevel |
10554
+ 1`): `1` renders the title as the page's `h1`, for an empty state that is the page (a not-found
10555
+ or no-access page). The released `BfEmptyStateHeadingLevel` keeps its two members, so `Record`s
10556
+ over it keep compiling. New "As the page" docs section with the `empty-state/page` example
10557
+ (centred in a tall area, a primary action and a secondary link) and the code-only
10558
+ `empty-state/not-found-page`.
10559
+
10049
10560
  ### 0.3.0
10050
10561
 
10051
10562
  New exported `BfEmptyStateHeadingLevel` (`2 | 3`), the type of `headingLevel` (was an inline union
@@ -10992,7 +11503,7 @@ inside one.
10992
11503
 
10993
11504
  ## Desktop
10994
11505
 
10995
- `bf-header` is part of the Capacitor shell (`provideBfShell()`), shaped like an iOS or Material app bar. On a desktop web page use [bf-page-header](https://bitforge-ui.web.app/components/page-header): the page's one `h1` (`title`) with an optional `subtitle`, badges in `meta`, and the actions in `actions` (the main action as a primary [bf-button](https://bitforge-ui.web.app/components/button), secondary ones as `ghost` or `outline` buttons, the rest in an icon-only [bf-menu-button](https://bitforge-ui.web.app/components/menu-button) labelled "More actions"); the actions wrap under the title in a narrow window. On a nested page, put a [bf-breadcrumb](https://bitforge-ui.web.app/components/breadcrumb) in its `breadcrumb` slot, and a [bf-toolbar](https://bitforge-ui.web.app/components/toolbar) under it for search and filters. The two are siblings rather than one shell-aware widget: the large title and the scroll-under surface are phone patterns that depend on `bf-content`.
11506
+ `bf-header` is part of the Capacitor shell (`provideBfShell()`), shaped like an iOS or Material app bar. On a desktop web page use [bf-page-header](https://bitforge-ui.web.app/components/page-header): the page's one `h1` (`title`) with an optional `subtitle`, badges in `meta`, and the actions in `actions` (the main action as a primary [bf-button](https://bitforge-ui.web.app/components/button), secondary ones as `ghost` or `outline` buttons, the rest in an icon-only [bf-menu-button](https://bitforge-ui.web.app/components/menu-button) labelled "More actions"); the actions wrap under the title in a narrow window. On a nested page, put a [bf-breadcrumb](https://bitforge-ui.web.app/components/breadcrumb) in its `breadcrumb` slot, and a [bf-toolbar](https://bitforge-ui.web.app/components/toolbar) under it for search and filters. Below the breakpoint, [bf-app-frame](https://bitforge-ui.web.app/components/app-frame) supplies the app-level header bar (brand, navigation menu, actions) above the page header. The two are siblings rather than one shell-aware widget: the large title and the scroll-under surface are phone patterns that depend on `bf-content`.
10996
11507
 
10997
11508
  ```ts
10998
11509
  import { Component, signal } from '@angular/core';
@@ -11132,6 +11643,19 @@ The title is not in the tab order; after a push, `bf-stack-outlet` focuses it wi
11132
11643
 
11133
11644
  ## Migration notes
11134
11645
 
11646
+ ### 0.3.3
11647
+
11648
+ Fix: `bf-header` measures its title and slots in the next animation frame after a resize, not
11649
+ inside the `ResizeObserver` callback. Its measurement writes the title inset and height, which
11650
+ resize the observed elements, and WebKit then reported "ResizeObserver loop completed with
11651
+ undelivered notifications": an error event that an app's global error listener
11652
+ (`provideBrowserGlobalErrorListeners()`) logs as an error. Several resizes in one frame now
11653
+ measure once.
11654
+
11655
+ ### 0.3.2
11656
+
11657
+ The `## Desktop` docs say `bf-app-frame` supplies the app-level header bar below its breakpoint.
11658
+
11135
11659
  ### 0.3.1
11136
11660
 
11137
11661
  The `## Desktop` docs and When to use name the desktop replacement, `bf-page-header` (with
@@ -20212,9 +20736,9 @@ export class SidebarOverviewExampleComponent {
20212
20736
 
20213
20737
  The vertical navigation of a desktop app: a `nav` landmark with a `header` slot (the brand),
20214
20738
  the destinations (`a[bfNavItem]` links, optionally in `bf-nav-group`s) and a `footer` slot.
20215
- `collapsed` turns it into an icon rail; the consumer owns the toggle button and binds
20216
- `[(collapsed)]`. Below a breakpoint hide it (`class="hidden md:flex"`) and drive a
20217
- `bf-menu-button` from the same routes.
20739
+ `collapsed` turns it into an icon rail: put a `bf-sidebar-toggle` (`@bitforgehq/ui/overlay`) in
20740
+ it, or bind `[(collapsed)]` to your own button. Below a breakpoint hide it
20741
+ (`class="hidden md:flex"`) and drive a `bf-menu-button` from the same routes.
20218
20742
 
20219
20743
  The vertical navigation of a desktop web app: a `nav` landmark down the inline-start edge with a `header` slot for the brand, the destinations as [`a[bfNavItem]`](https://bitforge-ui.web.app/components/nav-item) links (optionally in titled [`bf-nav-group`](https://bitforge-ui.web.app/components/nav-group)s) and a `footer` slot for settings or the account. `collapsed` turns it into an icon rail. Its horizontal sibling for apps with few sections is [`bf-top-nav`](https://bitforge-ui.web.app/components/top-nav); both share the same item, so the current page, the badge and the focus ring look the same in either.
20220
20744
 
@@ -20228,11 +20752,178 @@ Put `bfNavItem` on your own `<a routerLink routerLinkActive ariaCurrentWhenActiv
20228
20752
 
20229
20753
  ## Collapsing to an icon rail
20230
20754
 
20231
- `collapsed` is a model: bind `[(collapsed)]` to your own toggle button, such as a text `bf-icon-button` in the header whose label says what the next press does ("Collapse sidebar", "Expand sidebar"). The widget has no toggle of its own, because the button needs an icon and a widget never imports one. Collapsed, every item shows only its icon, centred in a square; its label stays in the DOM as screen-reader text, so its accessible name does not change, and becomes the link's `title`, the tooltip a pointer user sees on hover. A count badge becomes a small dot that is still announced, and the group headings are hidden visually but still name their groups. The width follows the rail (`w-auto`); expanded the sidebar is `w-60` (240 px).
20755
+ `collapsed` is a model. Put a [`bf-sidebar-toggle`](https://bitforge-ui.web.app/components/sidebar-toggle) (`@bitforgehq/ui/overlay`) in the header or footer: it flips the model, names itself by what the next press does ("Collapse sidebar", "Expand sidebar") and takes its two icons as slots, since a widget never imports one. Bind `[(collapsed)]` to keep the state in your own signal, or bind it to your own button outside the sidebar. Collapsed, every item shows only its icon, centred in a square; its label stays in the DOM as screen-reader text, so its accessible name does not change, and becomes the link's `title`, the tooltip a pointer user sees on hover. A count badge becomes a small dot that is still announced, and the group headings are hidden visually but still name their groups. The width follows the rail (`w-auto`); expanded the sidebar is `w-60` (240 px).
20756
+
20757
+ ## Remember the collapsed sidebar
20758
+
20759
+ `BfSidebarStateService` (from `@bitforgehq/ui`) holds a `collapsed` signal: bind the sidebar's `[(collapsed)]` to it (`sidebarState.collapsed`) and add `provideBfSidebarState()` to the app's providers, and every change is stored under `bf-sidebar-collapsed` (`localStorage` in the browser, `BF_SIDEBAR_STATE_STORAGE` to keep it elsewhere) and read back at the next start, so the app opens as the user left it. Without the provider the signal still holds the state but nothing is stored; on the server it starts expanded and reads nothing. The widget itself injects no service.
20760
+
20761
+ ```ts
20762
+ import { Component, inject } from '@angular/core';
20763
+ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
20764
+ import { BfNavItemComponent, BfSidebarComponent, BfSidebarStateService } from '@bitforgehq/ui';
20765
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
20766
+ import { BfSidebarToggleComponent } from '@bitforgehq/ui/overlay';
20767
+ import { Inbox, PanelLeftClose, PanelLeftOpen } from 'lucide';
20768
+
20769
+ /**
20770
+ * A sidebar that opens collapsed or expanded, as the user left it.
20771
+ *
20772
+ * `[(collapsed)]` binds `BfSidebarStateService.collapsed`; with `provideBfSidebarState()` in the
20773
+ * app's providers every change is stored under `bf-sidebar-collapsed` (`localStorage` in the
20774
+ * browser) and read back at the next start.
20775
+ *
20776
+ * @preview none
20777
+ */
20778
+ @Component({
20779
+ selector: 'app-example-sidebar-remembered',
20780
+ imports: [
20781
+ RouterLink,
20782
+ RouterLinkActive,
20783
+ RouterOutlet,
20784
+ BfIconComponent,
20785
+ BfNavItemComponent,
20786
+ BfSidebarComponent,
20787
+ BfSidebarToggleComponent,
20788
+ ],
20789
+ template: `
20790
+ <div class="flex min-h-dvh">
20791
+ <bf-sidebar [(collapsed)]="sidebarState.collapsed" class="sticky top-0 h-dvh">
20792
+ <a bfNavItem routerLink="/inbox" routerLinkActive ariaCurrentWhenActive="page">
20793
+ <bf-icon bfSlot="icon" [icon]="inbox" />
20794
+ Inbox
20795
+ </a>
20796
+ <bf-sidebar-toggle bfSlot="footer">
20797
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
20798
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
20799
+ </bf-sidebar-toggle>
20800
+ </bf-sidebar>
20801
+ <main class="min-w-0 flex-1 p-6"><router-outlet /></main>
20802
+ </div>
20803
+ `,
20804
+ })
20805
+ export class SidebarRememberedExampleComponent {
20806
+ // app.config.ts: providers: [provideBfSidebarState()]
20807
+ protected readonly sidebarState = inject(BfSidebarStateService);
20808
+ protected readonly inbox = Inbox;
20809
+ protected readonly panelOpen = PanelLeftOpen;
20810
+ protected readonly panelClose = PanelLeftClose;
20811
+ }
20812
+ ```
20813
+
20814
+ ## Footer
20815
+
20816
+ The footer slot takes several elements, stacked with a 4 px gap: a Settings link as an `a[bfNavItem]`, a row holding an icon-only text [`bf-theme-menu-button`](https://bitforge-ui.web.app/components/theme-menu-button) and the `bf-sidebar-toggle`, and an optional version line in `text-caption text-text-muted`. Expanded, the row puts the two buttons at either end (`flex items-center justify-between`). As an icon rail every footer element is centred: switch the row to a column (`flex flex-col items-center gap-1`) so both buttons fit the rail, and hide the version line (`[class.hidden]="collapsed()"`), which has no icon to shrink to. Open the theme menu upwards (`placement="above-start"`), since the footer sits at the bottom of the screen.
20817
+
20818
+ ```ts
20819
+ import { Component, computed, signal } from '@angular/core';
20820
+ import { RouterLink, RouterLinkActive } from '@angular/router';
20821
+ import { type BfColorScheme, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
20822
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
20823
+ import { BfSidebarToggleComponent, BfThemeMenuButtonComponent } from '@bitforgehq/ui/overlay';
20824
+ import {
20825
+ Inbox,
20826
+ type IconNode,
20827
+ Moon,
20828
+ PanelLeftClose,
20829
+ PanelLeftOpen,
20830
+ Settings,
20831
+ Sun,
20832
+ SunMoon,
20833
+ Users,
20834
+ } from 'lucide';
20835
+
20836
+ interface NavLink {
20837
+ readonly path: string;
20838
+ readonly label: string;
20839
+ readonly icon: IconNode;
20840
+ }
20841
+
20842
+ /**
20843
+ * A sidebar footer: settings, theme, toggle and version.
20844
+ *
20845
+ * The footer slot holds a Settings link (`a[bfNavItem]`), a row with an icon-only
20846
+ * `bf-theme-menu-button` and the `bf-sidebar-toggle`, and a version line. Expanded, the two
20847
+ * buttons sit at either end of the row; as an icon rail the row stacks vertically and the version
20848
+ * line hides. The theme binds a local signal so the docs site keeps its theme; an app binds
20849
+ * `BfColorSchemeService.scheme`.
20850
+ */
20851
+ @Component({
20852
+ selector: 'app-example-sidebar-footer',
20853
+ imports: [
20854
+ RouterLink,
20855
+ RouterLinkActive,
20856
+ BfIconComponent,
20857
+ BfNavItemComponent,
20858
+ BfSidebarComponent,
20859
+ BfSidebarToggleComponent,
20860
+ BfThemeMenuButtonComponent,
20861
+ ],
20862
+ template: `
20863
+ <div class="border-border bg-bg rounded-card flex h-[26rem] overflow-hidden border">
20864
+ <bf-sidebar label="Footer demo" [(collapsed)]="collapsed">
20865
+ @for (link of links; track link.path) {
20866
+ <a bfNavItem [routerLink]="link.path" routerLinkActive ariaCurrentWhenActive="page">
20867
+ <bf-icon bfSlot="icon" [icon]="link.icon" />
20868
+ {{ link.label }}
20869
+ </a>
20870
+ }
20871
+ <a
20872
+ bfSlot="footer"
20873
+ bfNavItem
20874
+ routerLink="/theming"
20875
+ routerLinkActive
20876
+ ariaCurrentWhenActive="page"
20877
+ >
20878
+ <bf-icon bfSlot="icon" [icon]="settings" />
20879
+ Settings
20880
+ </a>
20881
+ <div bfSlot="footer" [class]="rowClasses()">
20882
+ <bf-theme-menu-button iconOnly variant="text" placement="above-start" [(value)]="scheme">
20883
+ <bf-icon bfSlot="icon" [icon]="themeIcon()" />
20884
+ </bf-theme-menu-button>
20885
+ <bf-sidebar-toggle>
20886
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
20887
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
20888
+ </bf-sidebar-toggle>
20889
+ </div>
20890
+ <p bfSlot="footer" class="text-caption text-text-muted ps-3" [class.hidden]="collapsed()">
20891
+ Version 0.3.2
20892
+ </p>
20893
+ </bf-sidebar>
20894
+ <div class="min-w-0 flex-1 p-4">
20895
+ <p data-status aria-live="polite" class="text-caption text-text-muted wrap-anywhere">
20896
+ {{ collapsed() ? 'Icon rail' : 'Expanded' }}, theme {{ scheme() }}.
20897
+ </p>
20898
+ </div>
20899
+ </div>
20900
+ `,
20901
+ })
20902
+ export class SidebarFooterExampleComponent {
20903
+ readonly collapsed = signal(false);
20904
+ /** Stands in for `BfColorSchemeService.scheme`. */
20905
+ readonly scheme = signal<BfColorScheme>('system');
20906
+
20907
+ /** Expanded: one row, the buttons at either end. Collapsed: a column in the rail. */
20908
+ protected readonly rowClasses = computed(() =>
20909
+ this.collapsed() ? 'flex flex-col items-center gap-1' : 'flex items-center justify-between',
20910
+ );
20911
+ protected readonly themeIcon = computed(
20912
+ () => ({ system: SunMoon, light: Sun, dark: Moon })[this.scheme()],
20913
+ );
20914
+ protected readonly links: readonly NavLink[] = [
20915
+ { path: '/components/nav-item', label: 'Inbox', icon: Inbox },
20916
+ { path: '/components/nav-group', label: 'Team', icon: Users },
20917
+ ];
20918
+ protected readonly settings = Settings;
20919
+ protected readonly panelOpen = PanelLeftOpen;
20920
+ protected readonly panelClose = PanelLeftClose;
20921
+ }
20922
+ ```
20232
20923
 
20233
20924
  ## Responsive layouts
20234
20925
 
20235
- Below a breakpoint the sidebar does not turn itself into a menu: a root widget cannot render `bf-menu`. Keep one array of destinations (ideally derived from your route config) and drive every surface from it: the sidebar from `md` up (`class="hidden md:flex"` on `bf-sidebar`; its own `flex` loses to your `hidden`), and below it a [`bf-menu-button`](https://bitforge-ui.web.app/components/menu-button) of `a[bfMenuItem]` router links (`class="md:hidden"`, from `@bitforgehq/ui/overlay`). Each surface keeps the router's `routerLinkActive`.
20926
+ Below a breakpoint the sidebar does not turn itself into a menu: a root widget cannot render `bf-menu`. Keep one array of destinations (ideally derived from your route config) and drive every surface from it: the sidebar from `md` up (`class="hidden md:flex"` on `bf-sidebar`; its own `flex` loses to your `hidden`), and below it a [`bf-menu-button`](https://bitforge-ui.web.app/components/menu-button) of `a[bfMenuItem]` router links (`class="md:hidden"`, from `@bitforgehq/ui/overlay`). Each surface keeps the router's `routerLinkActive`. [`bf-app-frame`](https://bitforge-ui.web.app/components/app-frame) does this layout for you: it shows the sidebar in a sticky column from the breakpoint up and the menu button in a header bar below it.
20236
20927
 
20237
20928
  ```ts
20238
20929
  import { Component } from '@angular/core';
@@ -20390,7 +21081,7 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
20390
21081
 
20391
21082
  ## Accessibility
20392
21083
 
20393
- `bf-sidebar` renders a native `<nav>` landmark named by `label`, else `provideBfLabels()`'s `navigation` ("Main"); give each landmark on a page a different name. The header, the destinations and the footer all sit inside it, in that DOM order. The current page is the link the router marks with `aria-current="page"`; the tonal fill is never the only cue under forced colours, where an outline marks it. Collapsing changes only what is visible: every item keeps its label as screen-reader text and each group its name, so the accessible names stay the same. The consumer's toggle button is a native button; name it by what it does ("Collapse sidebar") or give it `aria-pressed`, and keep it in the sidebar or right before it.
21084
+ `bf-sidebar` renders a native `<nav>` landmark named by `label`, else `provideBfLabels()`'s `navigation` ("Main"); give each landmark on a page a different name. The header, the destinations and the footer all sit inside it, in that DOM order. The current page is the link the router marks with `aria-current="page"`; the tonal fill is never the only cue under forced colours, where an outline marks it. Collapsing changes only what is visible: every item keeps its label as screen-reader text and each group its name, so the accessible names stay the same. `bf-sidebar-toggle` is a native button named by what it does ("Collapse sidebar"), with `aria-expanded` and `aria-controls` on the `nav`; your own toggle should do the same, and sit in the sidebar or right before it.
20394
21085
 
20395
21086
  ### Keyboard
20396
21087
 
@@ -20404,7 +21095,7 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
20404
21095
  | Name | Type | Default | Description |
20405
21096
  | --- | --- | --- | --- |
20406
21097
  | `label` | `string` | `''` | Accessible name of the navigation; empty falls back to `provideBfLabels()`'s `navigation` ("Main"). Name each landmark differently when a page has more than one. |
20407
- | `collapsed` | `boolean` | `false` | Collapses the sidebar to an icon rail: items show their icon only, their label stays for screen readers and becomes a tooltip. Bind `[(collapsed)]` to your own toggle button. |
21098
+ | `collapsed` | `boolean` | `false` | Collapses the sidebar to an icon rail: items show their icon only, their label stays for screen readers and becomes a tooltip. A `bf-sidebar-toggle` inside flips it; bind `[(collapsed)]` to keep it in your state. |
20408
21099
 
20409
21100
  ## Slots
20410
21101
 
@@ -20418,6 +21109,7 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
20418
21109
 
20419
21110
  - Mark the current page with `routerLinkActive` and `ariaCurrentWhenActive="page"` on every link.
20420
21111
  - Drive the sidebar, the small-screen menu and the shell's tab bar from one array of destinations.
21112
+ - Remember the collapsed state with `BfSidebarStateService` and `provideBfSidebarState()`.
20421
21113
 
20422
21114
  ### Don't
20423
21115
 
@@ -20426,6 +21118,16 @@ The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the
20426
21118
 
20427
21119
  ## Migration notes
20428
21120
 
21121
+ ### 0.3.2
21122
+
21123
+ New `BfSidebarStateService` (a `collapsed` signal) and `provideBfSidebarState()` in
21124
+ `@bitforgehq/ui`: bind the sidebar's `[(collapsed)]` to `sidebarState.collapsed` and the provider
21125
+ stores every change under `bf-sidebar-collapsed` in `BF_SIDEBAR_STATE_STORAGE` (`localStorage` in
21126
+ the browser, memory elsewhere), read back at the next start; without the provider nothing is
21127
+ written, and on the server nothing is read. The sidebar's `nav` now has an id, for a toggle's
21128
+ `aria-controls`. The docs gain "Remember the collapsed sidebar" and a "Footer" composition
21129
+ (Settings link, theme menu, toggle, version line) that works expanded and as an icon rail.
21130
+
20429
21131
  ### 0.3.1
20430
21132
 
20431
21133
  New `bf-sidebar`: the vertical navigation of a desktop app, a `nav` landmark with `header`,
@@ -20435,6 +21137,296 @@ breakpoint, hide it with `hidden md:flex` and drive a `bf-menu-button` from the
20435
21137
 
20436
21138
  ---
20437
21139
 
21140
+ # bf-sidebar-toggle
21141
+
21142
+ Icon button inside a sidebar that collapses it to an icon rail and back
21143
+
21144
+ `BfSidebarToggleComponent` from `@bitforgehq/ui/overlay` · Navigation · since 0.3.2
21145
+
21146
+ ## Example
21147
+
21148
+ ```ts
21149
+ import { Component, computed, signal } from '@angular/core';
21150
+ import { RouterLink, RouterLinkActive } from '@angular/router';
21151
+ import { BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
21152
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
21153
+ import { BfSidebarToggleComponent } from '@bitforgehq/ui/overlay';
21154
+ import { type IconNode, Layers, PanelLeftClose, PanelLeftOpen, PanelTop } from 'lucide';
21155
+
21156
+ interface NavLink {
21157
+ readonly path: string;
21158
+ readonly label: string;
21159
+ readonly icon: IconNode;
21160
+ }
21161
+
21162
+ /**
21163
+ * The bf-sidebar-toggle overview.
21164
+ *
21165
+ * Live demo of `bf-sidebar-toggle` in the header of a `bf-sidebar`: a press collapses the
21166
+ * sidebar to an icon rail and the next one expands it again. Its name and tooltip say what the
21167
+ * next press does, and only the matching icon slot shows. The sidebar's `[(collapsed)]` keeps
21168
+ * the state in a signal, and the result line reports it.
21169
+ */
21170
+ @Component({
21171
+ selector: 'app-example-sidebar-toggle-overview',
21172
+ imports: [
21173
+ RouterLink,
21174
+ RouterLinkActive,
21175
+ BfIconComponent,
21176
+ BfNavItemComponent,
21177
+ BfSidebarComponent,
21178
+ BfSidebarToggleComponent,
21179
+ ],
21180
+ template: `
21181
+ <div class="border-border bg-bg rounded-card flex h-72 overflow-hidden border">
21182
+ <bf-sidebar label="Toggle demo" [(collapsed)]="collapsed">
21183
+ <div bfSlot="header" class="flex items-center justify-between gap-2">
21184
+ @if (!collapsed()) {
21185
+ <span class="text-heading text-text ps-3">Atlas</span>
21186
+ }
21187
+ <bf-sidebar-toggle>
21188
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
21189
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
21190
+ </bf-sidebar-toggle>
21191
+ </div>
21192
+ @for (link of links; track link.path) {
21193
+ <a bfNavItem [routerLink]="link.path" routerLinkActive ariaCurrentWhenActive="page">
21194
+ <bf-icon bfSlot="icon" [icon]="link.icon" />
21195
+ {{ link.label }}
21196
+ </a>
21197
+ }
21198
+ </bf-sidebar>
21199
+ <div class="min-w-0 flex-1 p-4">
21200
+ <p data-status aria-live="polite" class="text-caption text-text-muted wrap-anywhere">
21201
+ {{ status() }}
21202
+ </p>
21203
+ </div>
21204
+ </div>
21205
+ `,
21206
+ })
21207
+ export class SidebarToggleOverviewExampleComponent {
21208
+ readonly collapsed = signal(false);
21209
+ readonly status = computed(() =>
21210
+ this.collapsed() ? 'Sidebar collapsed to an icon rail.' : 'Sidebar expanded.',
21211
+ );
21212
+
21213
+ protected readonly links: readonly NavLink[] = [
21214
+ { path: '/components/sidebar-toggle', label: 'Sidebar toggle', icon: PanelTop },
21215
+ { path: '/components/sidebar', label: 'Sidebar', icon: Layers },
21216
+ ];
21217
+ protected readonly panelOpen = PanelLeftOpen;
21218
+ protected readonly panelClose = PanelLeftClose;
21219
+ }
21220
+ ```
21221
+
21222
+ ## Overview
21223
+
21224
+ The collapse button of a `bf-sidebar`: a text icon button placed inside the sidebar (usually in
21225
+ its header or footer) that flips the sidebar between full width and an icon rail. Its name and
21226
+ tooltip say what the next press does ("Collapse sidebar", "Expand sidebar"), `aria-expanded`
21227
+ follows the sidebar and `aria-controls` points at its `nav`. It shows the `collapse-icon` slot
21228
+ while expanded and the `expand-icon` slot while collapsed. It reads and writes the sidebar's
21229
+ `collapsed` model, so `[(collapsed)]` on the sidebar sees every press. Outside a `bf-sidebar`
21230
+ (in a `bf-top-nav`, or on its own) it renders nothing.
21231
+
21232
+ The sidebar toggle collapses a [`bf-sidebar`](https://bitforge-ui.web.app/components/sidebar) to an icon rail and expands it again, from `@bitforgehq/ui/overlay` (it shows a `bfTooltip`, so it needs `@angular/cdk`; no Capacitor). Put it inside the sidebar, in its header or footer: it reads and writes the sidebar's `collapsed` model, so the sidebar's `[(collapsed)]` sees every press and the toggle needs no binding of its own. It is a text icon button whose name and tooltip say what the next press does. Import `BfSidebarToggleComponent` from `@bitforgehq/ui/overlay`.
21233
+
21234
+ ## When to use
21235
+
21236
+ Use `bf-sidebar-toggle` for the collapse button of a desktop app's `bf-sidebar`. Bind your own [bf-icon-button](https://bitforge-ui.web.app/components/icon-button) to the sidebar's `[(collapsed)]` instead when the button must sit outside the sidebar, in a top bar. In the Capacitor shell there is no sidebar: use [bf-tabs](https://bitforge-ui.web.app/components/tabs) with a `bf-tab-bar` instead.
21237
+
21238
+ ## Icons
21239
+
21240
+ The toggle never picks an icon. Project two, `bfSlot="collapse-icon"` (shown while the sidebar is expanded, such as Lucide's `PanelLeftClose`) and `bfSlot="expand-icon"` (shown while it is an icon rail, `PanelLeftOpen`); only the one that matches the state shows. Under a right-to-left layout pick the mirrored icons (`PanelRightClose`, `PanelRightOpen`), since the sidebar then sits at the right.
21241
+
21242
+ ## Placement
21243
+
21244
+ The toggle renders only inside a `bf-sidebar`. In a `bf-top-nav`, which never collapses, or on its own it renders nothing and takes no space, so a misplaced toggle never shows a button that does nothing. In the header, put it after the brand and show the brand only while expanded; in the footer, put it beside the theme menu ([footer composition](https://bitforge-ui.web.app/components/sidebar#footer)). It keeps its place in the tab order either way, so a keyboard user finds it where they left it.
21245
+
21246
+ ```ts
21247
+ import { Component, computed, signal } from '@angular/core';
21248
+ import { RouterLink, RouterLinkActive } from '@angular/router';
21249
+ import { type BfColorScheme, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
21250
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
21251
+ import { BfSidebarToggleComponent, BfThemeMenuButtonComponent } from '@bitforgehq/ui/overlay';
21252
+ import {
21253
+ Inbox,
21254
+ type IconNode,
21255
+ Moon,
21256
+ PanelLeftClose,
21257
+ PanelLeftOpen,
21258
+ Settings,
21259
+ Sun,
21260
+ SunMoon,
21261
+ Users,
21262
+ } from 'lucide';
21263
+
21264
+ interface NavLink {
21265
+ readonly path: string;
21266
+ readonly label: string;
21267
+ readonly icon: IconNode;
21268
+ }
21269
+
21270
+ /**
21271
+ * A sidebar footer: settings, theme, toggle and version.
21272
+ *
21273
+ * The footer slot holds a Settings link (`a[bfNavItem]`), a row with an icon-only
21274
+ * `bf-theme-menu-button` and the `bf-sidebar-toggle`, and a version line. Expanded, the two
21275
+ * buttons sit at either end of the row; as an icon rail the row stacks vertically and the version
21276
+ * line hides. The theme binds a local signal so the docs site keeps its theme; an app binds
21277
+ * `BfColorSchemeService.scheme`.
21278
+ */
21279
+ @Component({
21280
+ selector: 'app-example-sidebar-footer',
21281
+ imports: [
21282
+ RouterLink,
21283
+ RouterLinkActive,
21284
+ BfIconComponent,
21285
+ BfNavItemComponent,
21286
+ BfSidebarComponent,
21287
+ BfSidebarToggleComponent,
21288
+ BfThemeMenuButtonComponent,
21289
+ ],
21290
+ template: `
21291
+ <div class="border-border bg-bg rounded-card flex h-[26rem] overflow-hidden border">
21292
+ <bf-sidebar label="Footer demo" [(collapsed)]="collapsed">
21293
+ @for (link of links; track link.path) {
21294
+ <a bfNavItem [routerLink]="link.path" routerLinkActive ariaCurrentWhenActive="page">
21295
+ <bf-icon bfSlot="icon" [icon]="link.icon" />
21296
+ {{ link.label }}
21297
+ </a>
21298
+ }
21299
+ <a
21300
+ bfSlot="footer"
21301
+ bfNavItem
21302
+ routerLink="/theming"
21303
+ routerLinkActive
21304
+ ariaCurrentWhenActive="page"
21305
+ >
21306
+ <bf-icon bfSlot="icon" [icon]="settings" />
21307
+ Settings
21308
+ </a>
21309
+ <div bfSlot="footer" [class]="rowClasses()">
21310
+ <bf-theme-menu-button iconOnly variant="text" placement="above-start" [(value)]="scheme">
21311
+ <bf-icon bfSlot="icon" [icon]="themeIcon()" />
21312
+ </bf-theme-menu-button>
21313
+ <bf-sidebar-toggle>
21314
+ <bf-icon bfSlot="collapse-icon" [icon]="panelClose" />
21315
+ <bf-icon bfSlot="expand-icon" [icon]="panelOpen" />
21316
+ </bf-sidebar-toggle>
21317
+ </div>
21318
+ <p bfSlot="footer" class="text-caption text-text-muted ps-3" [class.hidden]="collapsed()">
21319
+ Version 0.3.2
21320
+ </p>
21321
+ </bf-sidebar>
21322
+ <div class="min-w-0 flex-1 p-4">
21323
+ <p data-status aria-live="polite" class="text-caption text-text-muted wrap-anywhere">
21324
+ {{ collapsed() ? 'Icon rail' : 'Expanded' }}, theme {{ scheme() }}.
21325
+ </p>
21326
+ </div>
21327
+ </div>
21328
+ `,
21329
+ })
21330
+ export class SidebarFooterExampleComponent {
21331
+ readonly collapsed = signal(false);
21332
+ /** Stands in for `BfColorSchemeService.scheme`. */
21333
+ readonly scheme = signal<BfColorScheme>('system');
21334
+
21335
+ /** Expanded: one row, the buttons at either end. Collapsed: a column in the rail. */
21336
+ protected readonly rowClasses = computed(() =>
21337
+ this.collapsed() ? 'flex flex-col items-center gap-1' : 'flex items-center justify-between',
21338
+ );
21339
+ protected readonly themeIcon = computed(
21340
+ () => ({ system: SunMoon, light: Sun, dark: Moon })[this.scheme()],
21341
+ );
21342
+ protected readonly links: readonly NavLink[] = [
21343
+ { path: '/components/nav-item', label: 'Inbox', icon: Inbox },
21344
+ { path: '/components/nav-group', label: 'Team', icon: Users },
21345
+ ];
21346
+ protected readonly settings = Settings;
21347
+ protected readonly panelOpen = PanelLeftOpen;
21348
+ protected readonly panelClose = PanelLeftClose;
21349
+ }
21350
+ ```
21351
+
21352
+ ## Remembering the state
21353
+
21354
+ The toggle holds no state. To open the app with the sidebar as the user left it, bind the sidebar to `BfSidebarStateService` from `@bitforgehq/ui` and add `provideBfSidebarState()` to the app's providers ([remember the collapsed sidebar](https://bitforge-ui.web.app/components/sidebar#remember-the-collapsed-sidebar)).
21355
+
21356
+ ## Sizes
21357
+
21358
+ `size` is `sm` 32, `md` 40 or `lg` 48 px, square; unset it follows `provideBfDefaults()`'s `controlSize`, as `bf-icon-button` does.
21359
+
21360
+ ## Labels
21361
+
21362
+ The name and tooltip read `sidebarCollapse` ("Collapse sidebar") while the sidebar is expanded and `sidebarExpand` ("Expand sidebar") while it is an icon rail. Translate them with `provideBfLabels()`.
21363
+
21364
+ ## Testing
21365
+
21366
+ `BfSidebarToggleHarness` from `@bitforgehq/ui/testing` reads the name with `getLabel()`, the state with `isExpanded()` and presses it with `toggle()`; filter it with `label: 'Collapse sidebar'`.
21367
+
21368
+ ## Desktop
21369
+
21370
+ The toggle looks and behaves like a text `bf-icon-button`: the muted text colour, a `surface-muted` fill and the pointer cursor on hover, a pressed tint while pressed, and the focus ring on keyboard focus only (`focus-visible`). Its tooltip shows after a 500 ms rest of a fine pointer and at once on keyboard focus: above the button while expanded, at the inline end of the rail while collapsed. Under a fine pointer the visible box is the hit area; under a coarse pointer (a touchscreen laptop) a `sm` or `md` toggle extends vertically to 44 px, and no tooltip shows.
21371
+
21372
+ ## Theming
21373
+
21374
+ The toggle consumes `bf-icon-button`'s text variant: `text-muted` at rest, `text` and `surface-muted` on hover, `primary-soft-pressed` while pressed, and the `ring` token for focus. The tooltip consumes `bf-tooltip`'s tokens.
21375
+
21376
+ ## Accessibility
21377
+
21378
+ - A native `<button type="button">` named by `aria-label`: "Collapse sidebar" while expanded, "Expand sidebar" while collapsed, so the name says what a press does. The tooltip shows the same text and describes the button while it shows.
21379
+ - `aria-expanded` is `true` while the sidebar is at full width and `false` as an icon rail; `aria-controls` points at the sidebar's `nav`.
21380
+ - The projected icons add nothing to the name (`bf-icon` is `aria-hidden`).
21381
+ - Collapsing keeps focus on the toggle, and every nav item keeps its accessible name, so nothing a screen reader user relies on moves.
21382
+
21383
+ ### Keyboard
21384
+
21385
+ | Key | Action |
21386
+ | ----------------------------------- | ------------------------------------------------ |
21387
+ | <kbd>Enter</kbd> / <kbd>Space</kbd> | Collapses or expands the sidebar |
21388
+ | <kbd>Escape</kbd> | Hides the tooltip while it shows |
21389
+ | <kbd>Tab</kbd> | Moves on to the next control, hiding the tooltip |
21390
+
21391
+ ## Inputs
21392
+
21393
+ | Name | Type | Default | Description |
21394
+ | --- | --- | --- | --- |
21395
+ | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Height step: sm 32, md 40, lg 48 px (square); unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
21396
+
21397
+ ## Slots
21398
+
21399
+ - `collapse-icon`: The icon shown while the sidebar is expanded (a panel closing).
21400
+ - `expand-icon`: The icon shown while the sidebar is an icon rail (a panel opening).
21401
+
21402
+ ## Notes
21403
+
21404
+ ### Do
21405
+
21406
+ - Put the toggle inside the `bf-sidebar` it collapses, in its header or footer.
21407
+ - Project both icon slots, a panel closing and a panel opening.
21408
+ - Bind the sidebar to `BfSidebarStateService.collapsed` with `provideBfSidebarState()`, so the choice survives a reload.
21409
+
21410
+ ### Don't
21411
+
21412
+ - Don't put it outside a `bf-sidebar`; it renders nothing there. Use a `bf-icon-button` bound to `[(collapsed)]` instead.
21413
+ - Don't add your own `aria-label` or tooltip; the toggle names itself by what the next press does.
21414
+
21415
+ ## Migration notes
21416
+
21417
+ ### 0.3.2
21418
+
21419
+ New `bf-sidebar-toggle` in `@bitforgehq/ui/overlay`: a text icon button placed inside a
21420
+ `bf-sidebar` (header or footer) that flips the sidebar between full width and an icon rail by
21421
+ writing its `collapsed` model, so `[(collapsed)]` sees every press. Its name and `bfTooltip` say
21422
+ what the next press does (new label keys `sidebarCollapse` "Collapse sidebar" and `sidebarExpand`
21423
+ "Expand sidebar"); `aria-expanded` follows the sidebar and `aria-controls` points at its `nav`.
21424
+ Icons are the `collapse-icon` and `expand-icon` slots, only the matching one shown; `size` as
21425
+ `bf-icon-button`. Outside a sidebar (in a `bf-top-nav`, or alone) it renders nothing. A
21426
+ `BfSidebarToggleHarness` in `@bitforgehq/ui/testing` (`getLabel()`, `isExpanded()`, `toggle()`).
21427
+
21428
+ ---
21429
+
20438
21430
  # bf-skeleton
20439
21431
 
20440
21432
  Loading placeholder shaped as text lines, a rectangle or a circle, with a shimmer
@@ -21745,7 +22737,7 @@ Use `bf-stat` for a few headline figures the reader should take in at a glance,
21745
22737
 
21746
22738
  ## Cards
21747
22739
 
21748
- `variant="card"` gives each stat its own outlined tile, the `flat` card surface. In a grid the cards stretch to the row's height, so a row of stats lines up even when one has a supporting line and another has none. Put an icon in the `icon` slot (`bfSlot="icon"`, a `bf-icon`) to show what the figure is about.
22740
+ `variant="card"` gives each stat its own outlined tile, the `flat` card surface, for stats that sit on the page itself; inside a raised card use `tile` instead. In a grid the cards stretch to the row's height, so a row of stats lines up even when one has a supporting line and another has none. Put an icon in the `icon` slot (`bfSlot="icon"`, a `bf-icon`) to show what the figure is about.
21749
22741
 
21750
22742
  ```ts
21751
22743
  import { Component } from '@angular/core';
@@ -21787,6 +22779,60 @@ export class StatOverviewExampleComponent {
21787
22779
  }
21788
22780
  ```
21789
22781
 
22782
+ ## Tiles
22783
+
22784
+ `variant="tile"` is a sunken, borderless tile (`surface-sunken`, the `card` radius and padding) for a row of stats inside a raised [bf-card](https://bitforge-ui.web.app/components/card): a raised card holds sunken tiles, never outlined ones. On a tile the icon tile turns `surface`, so it still shows. Keep one surface style per level of a dashboard: raised cards on the page, tiles in each card.
22785
+
22786
+ ```ts
22787
+ import { Component } from '@angular/core';
22788
+ import { BfCardComponent, BfStatComponent } from '@bitforgehq/ui';
22789
+
22790
+ /**
22791
+ * A dashboard with one surface per level.
22792
+ *
22793
+ * The page holds raised cards, and every card holds sunken stat tiles: each level has one surface
22794
+ * style, so the eye reads the cards as the groups and the tiles as their figures.
22795
+ */
22796
+ @Component({
22797
+ selector: 'app-example-stat-dashboard',
22798
+ imports: [BfCardComponent, BfStatComponent],
22799
+ template: `
22800
+ <div class="grid gap-4 @lg:grid-cols-2">
22801
+ <bf-card>
22802
+ <h3 class="text-heading">Sales</h3>
22803
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
22804
+ <bf-stat label="Revenue" value="€48,200" variant="tile" delta="+12%" trend="up">
22805
+ vs. last month
22806
+ </bf-stat>
22807
+ <bf-stat label="Orders" value="1,204" variant="tile" delta="+3%" trend="up">
22808
+ vs. last month
22809
+ </bf-stat>
22810
+ </div>
22811
+ </bf-card>
22812
+ <bf-card>
22813
+ <h3 class="text-heading">Support</h3>
22814
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
22815
+ <bf-stat
22816
+ label="Open tickets"
22817
+ value="18"
22818
+ variant="tile"
22819
+ delta="-4"
22820
+ trend="down"
22821
+ trendIntent="negative"
22822
+ >
22823
+ vs. last week
22824
+ </bf-stat>
22825
+ <bf-stat label="First reply" value="2 h" variant="tile" delta="0" trend="flat">
22826
+ vs. last week
22827
+ </bf-stat>
22828
+ </div>
22829
+ </bf-card>
22830
+ </div>
22831
+ `,
22832
+ })
22833
+ export class StatDashboardExampleComponent {}
22834
+ ```
22835
+
21790
22836
  ## Plain
21791
22837
 
21792
22838
  `variant="plain"` (the default) draws no surface, for stats that sit on a card or a page section of their own. The default slot is the supporting line under the value: a short trend ("+12% this week") or a `bf-badge`.
@@ -21866,8 +22912,9 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
21866
22912
  - Label (`dt`): `text-caption` in `text-muted`. Value (`dd`): `text-title` in `text`, with tabular figures (`tabular-nums`) so numbers line up across stats.
21867
22913
  - Supporting line: `text-caption` in `text-muted`.
21868
22914
  - Delta pill: the badge's label shape (`control` radius, `text-micro` bold, tabular figures); good changes `success-soft` with `success` text, bad ones `danger-soft` with `danger` text, flat or no trend `neutral-soft` with `neutral` text. The arrow mark is drawn with borders in `currentColor`, so it takes the pill's text colour.
21869
- - Icon tile: `surface-sunken` background, `text-muted` icon, `tile` radius, 40 px.
22915
+ - Icon tile: `surface-sunken` background (`surface` on a `tile` stat), `text-muted` icon, `tile` radius, 40 px.
21870
22916
  - `card`: `surface`, the `card` radius, `--spacing-card` padding (`p-card`) and a 1.5 px `border-strong` edge, as the `flat` `bf-card`.
22917
+ - `tile`: `surface-sunken`, the `card` radius and `--spacing-card` padding, no border.
21871
22918
 
21872
22919
  ## Accessibility
21873
22920
 
@@ -21879,7 +22926,7 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
21879
22926
  | --- | --- | --- | --- |
21880
22927
  | `label` | `string` | required | What the figure measures, shown above it. |
21881
22928
  | `value` | `string` | required | The figure, already formatted (`45 min`, `1,204`, `98%`). |
21882
- | `variant` | `BfStatVariant` (`'plain' \| 'card'`) | `'plain'` | Surface: plain (default) on its container, or card, an outlined tile of its own. |
22929
+ | `variant` | `BfStatAppearance` (`'plain' \| 'card' \| 'tile'`) | `'plain'` | Surface: plain (default) on its container, card (an outlined tile) or tile (sunken, no border). |
21883
22930
  | `delta` | `string` | `''` | A change shown in a pill beside the value, already formatted (`+12%`); empty shows none. |
21884
22931
  | `trend` | `BfStatTrend \| undefined` (`'up' \| 'down' \| 'flat' \| undefined`) | `undefined` | The delta's direction: an arrow mark and a hidden word; left unset, the pill is neutral. |
21885
22932
  | `trendIntent` | `BfStatTrendIntent` (`'positive' \| 'negative'`) | `'positive'` | Whether up is good (positive, default) or down is good (negative, such as costs). |
@@ -21899,12 +22946,21 @@ A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size
21899
22946
 
21900
22947
  ### Don't
21901
22948
 
22949
+ - Don't put `card` stats (outlined) inside a raised `bf-card`; use `tile`.
21902
22950
  - Don't use a stat for a value the user edits or acts on; give it its own control.
21903
22951
  - Don't put long text in `value`; it is set at the `title` size for a short figure.
21904
22952
  - Don't put the comparison period in `delta`; keep it to the change (`+12%`) and say "vs. last month" in the supporting line.
21905
22953
 
21906
22954
  ## Migration notes
21907
22955
 
22956
+ ### 0.3.2
22957
+
22958
+ New `variant="tile"`: a sunken, borderless tile (`surface-sunken`, the `card` radius and
22959
+ padding) for a row of stats inside a raised `bf-card`; on it the icon tile turns `surface`.
22960
+ `variant` now takes the new named type `BfStatAppearance` (`BfStatVariant | 'tile'`); the
22961
+ released `BfStatVariant` keeps its two members, so `Record`s over it keep compiling. New
22962
+ `stat/dashboard` example with one surface per level.
22963
+
21908
22964
  ### 0.3.1
21909
22965
 
21910
22966
  New: `bf-stat`, one labelled figure in its own `dl`, with the value at the `title` size in
@@ -23146,13 +24202,14 @@ export class TableOverviewExampleComponent {
23146
24202
 
23147
24203
  Data table for desktop: column headers that set a sort, optional row selection with
23148
24204
  checkboxes, custom cells from `bfTableCell` templates and an empty slot. It never sorts or
23149
- filters itself; the consumer sorts the rows in a `computed()` from `sort`.
24205
+ filters itself; the consumer sorts the rows in a `computed()` from `sort`. With `stack` it
24206
+ renders each row as a tile of label/value pairs in a narrow space or under touch.
23150
24207
 
23151
24208
  A data table for desktop pages: rows of records the user compares across columns, with headers that sort, an optional checkbox column that selects rows, custom cells and an empty state. `bf-table` is data-driven: you pass `rows` and `columns`, and it never sorts or filters the rows itself. Its headers set the `sort` model and you sort the rows in a `computed()`, on the client or on the server.
23152
24209
 
23153
24210
  ## When to use
23154
24211
 
23155
- Use `bf-table` on a desktop page for records the user scans and compares column by column: tasks, invoices, members. On a phone or in the Capacitor shell use [bf-list](https://bitforge-ui.web.app/components/list) rows instead. Use a [bf-list](https://bitforge-ui.web.app/components/list) on desktop too when each record is one line of text with a status, and a description list for the fields of a single record.
24212
+ Use `bf-table` on a desktop page for records the user scans and compares column by column: tasks, invoices, members. On a phone or in the Capacitor shell use [bf-list](https://bitforge-ui.web.app/components/list) rows instead, or let the table stack its rows into tiles with `stack` (see Stacked rows). Use a [bf-list](https://bitforge-ui.web.app/components/list) on desktop too when each record is one line of text with a status, and a description list for the fields of a single record.
23156
24213
 
23157
24214
  ## Columns and rows
23158
24215
 
@@ -23443,9 +24500,116 @@ export class TablePagedExampleComponent {
23443
24500
 
23444
24501
  `size` sets the row height: `sm` 40 px, `md` 48 px (default), `lg` 56 px, with a 32, 40 or 48 px header. Unset, it follows `provideBfDefaults()`'s `controlSize`, so `{ size: 'sm' }` in a page's `providers` makes a dense table. The checkboxes take the same step. Under a coarse pointer every row is at least 44 px tall, and the sortable header buttons are 44 px targets; a cell grows to fit its content and is never covered by a hit-area extension.
23445
24502
 
24503
+ ## Stacked rows
24504
+
24505
+ `stack` lets the table itself fit a narrow space. With `stack="narrow"` it stacks while the table is narrower than 36rem (576 px at the default root size, Tailwind's `xl` container size): a container query on the table's own width, so a table in a narrow column or a side panel stacks even on a wide screen. The host is then a size container (`container-type: inline-size`), so give the table its width from its parent (a block or `w-full` / `flex-1`), not from its columns. With `stack="coarse"` it stacks under a coarse pointer at any width (phones, tablets, the Capacitor shell). The default `never` keeps the table as it is.
24506
+
24507
+ Stacked, each row is a sunken tile (`surface-sunken`, `rounded-tile`, the tile a raised card holds; a selected row keeps `primary-soft`). Its checkbox and first cell head the tile in bold; every other cell shows its column label at the start and its value at the end. The header row is visually hidden, and a bar above the tiles holds a labelled Select all checkbox (with `selectable`) and a Sort by select, since the hidden header's buttons are not shown. Its first option, Not sorted, shows while `sort` is null and sets it back to null; then every sortable column, ascending and descending (`tableSortBy`, `tableSortNone`, `tableSortAscending`, `tableSortDescending` labels). Choosing a column option sets `sort`, as a header click does. In a test, `BfTableHarness.getSortSelect()` returns that select's `BfSelectHarness`.
24508
+
24509
+ ```ts
24510
+ import { Component, computed, signal } from '@angular/core';
24511
+ import {
24512
+ BfBadgeComponent,
24513
+ BfSegmentedComponent,
24514
+ BfTableCellDirective,
24515
+ BfTableComponent,
24516
+ type BfSegmentOption,
24517
+ type BfTableColumn,
24518
+ type BfTableSort,
24519
+ } from '@bitforgehq/ui';
24520
+
24521
+ interface Task {
24522
+ id: string;
24523
+ title: string;
24524
+ project: string;
24525
+ status: 'Open' | 'Done';
24526
+ hours: number;
24527
+ }
24528
+
24529
+ const TASKS: readonly Task[] = [
24530
+ { id: 'brief', title: 'Write the brief', project: 'Atlas', status: 'Done', hours: 3 },
24531
+ { id: 'moodboard', title: 'Bloom moodboard', project: 'Bloom', status: 'Open', hours: 6 },
24532
+ { id: 'deck', title: 'Pitch deck', project: 'Atlas', status: 'Open', hours: 9 },
24533
+ ];
24534
+
24535
+ type Width = 'phone' | 'full';
24536
+
24537
+ /**
24538
+ * Stacked rows on a narrow table.
24539
+ *
24540
+ * `stack="narrow"` turns each row into a tile of label/value pairs while the table is narrower
24541
+ * than 36rem: the selection checkbox and the first cell head the tile, and a bar above the tiles
24542
+ * holds Select all and a Sort by select, whose first option, Not sorted, shows while `sort` is
24543
+ * null and clears it. Switch the box between phone width and full width.
24544
+ */
24545
+ @Component({
24546
+ selector: 'app-example-table-stacked',
24547
+ imports: [BfTableComponent, BfTableCellDirective, BfBadgeComponent, BfSegmentedComponent],
24548
+ template: `
24549
+ <!-- A stacking table is a size container: its width comes from its parent, never its columns. -->
24550
+ <div class="flex w-152 max-w-full flex-col gap-3">
24551
+ <bf-segmented label="Box width" [options]="widths" [(value)]="width" />
24552
+ <div [class]="width() === 'phone' ? 'w-full max-w-sm' : 'w-full'">
24553
+ <bf-table
24554
+ label="Stacked tasks"
24555
+ stack="narrow"
24556
+ selectable
24557
+ [rows]="rows()"
24558
+ [columns]="columns"
24559
+ [(sort)]="sort"
24560
+ [(selection)]="selection"
24561
+ >
24562
+ <ng-template bfTableCell="status" [bfTableCellOf]="rows()" let-task>
24563
+ <bf-badge [variant]="task.status === 'Done' ? 'success' : 'neutral'">
24564
+ {{ task.status }}
24565
+ </bf-badge>
24566
+ </ng-template>
24567
+ </bf-table>
24568
+ </div>
24569
+ <p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
24570
+ </div>
24571
+ `,
24572
+ })
24573
+ export class TableStackedExampleComponent {
24574
+ readonly widths: BfSegmentOption<Width>[] = [
24575
+ { value: 'phone', label: 'Phone' },
24576
+ { value: 'full', label: 'Full width' },
24577
+ ];
24578
+ readonly width = signal<Width | null>('phone');
24579
+ readonly columns: readonly BfTableColumn[] = [
24580
+ { key: 'title', label: 'Task', sortable: true },
24581
+ { key: 'project', label: 'Project' },
24582
+ { key: 'status', label: 'Status' },
24583
+ { key: 'hours', label: 'Hours', sortable: true, align: 'end' },
24584
+ ];
24585
+ readonly sort = signal<BfTableSort | null>(null);
24586
+ readonly selection = signal<readonly string[]>([]);
24587
+
24588
+ /** The table never sorts: the page sorts its rows from `sort`. */
24589
+ readonly rows = computed(() => {
24590
+ const sort = this.sort();
24591
+ if (!sort) {
24592
+ return TASKS;
24593
+ }
24594
+ const factor = sort.direction === 'ascending' ? 1 : -1;
24595
+ return [...TASKS].sort((a, b) =>
24596
+ sort.column === 'hours'
24597
+ ? (a.hours - b.hours) * factor
24598
+ : a.title.localeCompare(b.title) * factor,
24599
+ );
24600
+ });
24601
+
24602
+ readonly status = computed(() => {
24603
+ const sort = this.sort();
24604
+ const sorted = sort ? `Sorted by ${sort.column}, ${sort.direction}` : 'Unsorted';
24605
+ return `${sorted} · ${this.selection().length} selected.`;
24606
+ });
24607
+ }
24608
+ ```
24609
+
23446
24610
  ## Narrow screens and touch
23447
24611
 
23448
- A table needs width. Below the `md` breakpoint, render the same rows as a divided `bf-list` of `bf-list-item`s (a `md:hidden` wrapper) and the table only from `md` up (a `hidden md:block` wrapper), from the one `rows` array. Inside the Capacitor shell use the list alone. The table's wrapper scrolls sideways when its columns cannot fit, but that is a last resort, not a layout for phones.
24612
+ A table needs width. When stacked tiles are not what a phone should show (long records, a summary per row), render the same rows as a divided `bf-list` of `bf-list-item`s below the `md` breakpoint (a `md:hidden` wrapper) and the table only from `md` up (a `hidden md:block` wrapper), from the one `rows` array. Inside the Capacitor shell use the list alone, or a table with `stack="coarse"`. The table's wrapper scrolls sideways when its columns cannot fit, but that is a last resort, not a layout for phones.
23449
24613
 
23450
24614
  ```ts
23451
24615
  import { Component } from '@angular/core';
@@ -23514,6 +24678,7 @@ A selectable table tints a row `surface-muted` while hovered (a selected row `pr
23514
24678
  - Rows: `text` on the page's surface, divided by `border` lines; hover (selectable only) `surface-muted`; selected `primary-soft`, hover `primary-soft-hover`.
23515
24679
  - Sort arrow: a 1.5 px CSS chevron in the header's text colour.
23516
24680
  - Focus ring: `ring`. Header buttons: `rounded-field`.
24681
+ - Stacked rows: tiles on `surface-sunken` with `rounded-tile`, labels `text-label-xs` bold in `text-muted`.
23517
24682
  - Text: `text-body` (`text-label-sm` at `sm`), `tabular-nums` in `end` columns.
23518
24683
 
23519
24684
  Put the table on a `bf-card` (or another surface) yourself; it has no background or border of its own.
@@ -23522,6 +24687,8 @@ Put the table on a `bf-card` (or another surface) yourself; it has no background
23522
24687
 
23523
24688
  A native `table` with a visually hidden `caption` from `label`, `th scope="col"` headers and one `tr` per row. A sorted column's `th` carries `aria-sort="ascending"` or `"descending"`; the others carry none. A sortable header is a native `button` inside the `th`, so its name is the column label; the arrow is `aria-hidden`. The header checkbox is named by `selectAllLabel`, else `provideBfLabels()`'s `tableSelectAll` ("Select all rows"), and is mixed (`indeterminate`) while some rows are selected. Each row checkbox is named by the row's first data cell through `aria-labelledby`, so it reads as the row's title. The empty row's cell spans every column.
23524
24689
 
24690
+ A table that can stack (`stack` other than `never`) sets explicit `table`, `rowgroup`, `row`, `columnheader` and `cell` roles and names the table from its caption with `aria-labelledby`, because Safari and some screen readers drop a table's semantics once CSS changes its display; so a stacked table still reads as a table with its column headers. Stacked, the header row is visually hidden but stays in the accessibility tree; its sort buttons and checkbox are hidden (a plain label takes a sortable header's place), so no control is focusable where nobody can see it, and the bar's Select all checkbox and Sort by select replace them. A cell's visible label is CSS generated content with empty alternative text (`content: attr(data-label) / ""`, the `content-data-label` utility of `theme.css`), so a screen reader reads the column header once, not the label as well.
24691
+
23525
24692
  ### Keyboard
23526
24693
 
23527
24694
  | Key | Action |
@@ -23532,6 +24699,8 @@ A native `table` with a visually hidden `caption` from `label`, `th scope="col"`
23532
24699
  | <kbd>Space</kbd> | Toggles the focused checkbox, or sorts by the focused header |
23533
24700
  | <kbd>Enter</kbd> | Sorts by the focused header |
23534
24701
 
24702
+ A stacked table's Sort by select is a native select with its own keys.
24703
+
23535
24704
  Text fields, selects and composite widgets (radios, comboboxes, sliders) in a cell keep their own arrow keys.
23536
24705
 
23537
24706
  ## Inputs
@@ -23545,6 +24714,7 @@ Text fields, selects and composite widgets (radios, comboboxes, sliders) in a ce
23545
24714
  | `selectable` | `boolean` | `false` | Adds a first column of checkboxes that select rows. |
23546
24715
  | `selectAllLabel` | `string` | `''` | Accessible name of the header checkbox; empty falls back to `provideBfLabels()`'s `tableSelectAll` ("Select all rows"). |
23547
24716
  | `size` | `BfSize \| undefined` (`'sm' \| 'md' \| 'lg' \| undefined`) | `undefined` | Row height step: sm 40, md 48, lg 56 px (the header 32/40/48; at least 44 px under a coarse pointer); unset follows `provideBfDefaults()` (`controlSize`, `md` by default). |
24717
+ | `stack` | `BfTableStack` (`'never' \| 'narrow' \| 'coarse'`) | `'never'` | When the rows render as cards of label/value pairs: `never`, `narrow` (the table is narrower than 36rem) or `coarse` (under a coarse pointer, at any width). |
23548
24718
  | `sort` | `BfTableSort \| null` | `null` | The sorted column and direction, `null` while unsorted; two-way bindable. A sortable header sets it, and the consumer sorts `rows` from it. Two-way: `[(sort)]`. |
23549
24719
  | `selection` | `readonly string[]` | `[]` | The keys (`rowKey`) of the selected rows; two-way bindable. Two-way: `[(selection)]`. |
23550
24720
 
@@ -23585,15 +24755,48 @@ Selects or clears one row.
23585
24755
 
23586
24756
  - Give every table a `label`, and every sortable column a short header.
23587
24757
  - Sort and filter the rows in a `computed()` from `sort` and your filters.
23588
- - Render the rows as `bf-list-item`s below `md` and inside the Capacitor shell.
24758
+ - Use `stack="narrow"` for a table that may sit in a narrow column, or `stack="coarse"` in the Capacitor shell; otherwise render the rows as `bf-list-item`s below `md`.
23589
24759
 
23590
24760
  ### Don't
23591
24761
 
23592
- - Don't put a table on a phone screen that scrolls sideways; use the list.
24762
+ - Don't put a table on a phone screen that scrolls sideways; stack it or use the list.
24763
+ - Don't put a stacking table in a parent that sizes it from its content: the container query needs a width from outside.
23593
24764
  - Don't make a whole row clickable; put a link or button in a custom cell.
23594
24765
 
23595
24766
  ## Migration notes
23596
24767
 
24768
+ ### 0.3.3
24769
+
24770
+ Fix: stacked rows (`stack="narrow"` and `stack="coarse"`) show their column labels again when the
24771
+ package is installed from npm. 0.3.2 wrote the label as `content-[attr(data-label)_/_""]`; the
24772
+ library build escaped its quotes in the FESM (`\"\"`), so Tailwind built the rule for a class no
24773
+ cell carries and every label `::before` computed to `none`. The cells now carry
24774
+ `before:content-data-label`, a utility `theme.css` defines (below). Apps that added the
24775
+ workaround `@source inline('@max-xl:before:content-[attr(data-label)_/_""] pointer-coarse:before:content-[attr(data-label)_/_""]')`
24776
+ can remove it. consumer-smoke now opens a stacked table built from the packed package in
24777
+ Chromium, under a fine and a coarse pointer, and fails if any FESM class string holds a
24778
+ backslash escape.
24779
+
24780
+ The stacked Sort by select's first option is Not sorted (new label key `tableSortNone`, "Not
24781
+ sorted"): it shows while `sort` is null, so the select is no longer an empty box, and choosing it
24782
+ sets `sort` back to null. New `BfTableHarness.getSortSelect()` returns that select's
24783
+ `BfSelectHarness` (`null` when the table cannot stack or has no sortable column).
24784
+
24785
+ ### 0.3.2
24786
+
24787
+ New opt-in `stack` input (`BfTableStack`: `'never'` by default, so existing tables do not
24788
+ change, `'narrow'` or `'coarse'`): while the table is narrower than 36rem (a container query on
24789
+ the host, which then gets `@container`) or, for `'coarse'`, under a coarse pointer at any width,
24790
+ each body row renders as a sunken tile of label/value pairs. The markup stays a `table` with
24791
+ explicit `table`, `rowgroup`, `row`, `columnheader` and `cell` roles (some browsers drop a
24792
+ table's semantics once its display changes), the header row is visually hidden but still read,
24793
+ the selection checkbox and the first cell head each tile, and every other cell shows its column
24794
+ label (`data-label`, a `::before` with empty alternative text, so it is not read twice). A bar
24795
+ above the tiles holds a labelled Select all checkbox and a Sort by `bf-select`, since the hidden
24796
+ header's controls are not shown. New label keys `tableSortBy` ("Sort by"), `tableSortAscending`
24797
+ and `tableSortDescending` (`"Hours, ascending"`). `BfTableHarness` reads and drives a stacked
24798
+ table the same way. New `table/stacked` example and a Stacked rows docs section.
24799
+
23597
24800
  ### 0.3.1
23598
24801
 
23599
24802
  New `bf-table` and `ng-template[bfTableCell]` (`BfTableCellDirective`) in `@bitforgehq/ui`, a
@@ -23808,7 +25011,7 @@ fires no keyboard event).
23808
25011
 
23809
25012
  ## Desktop
23810
25013
 
23811
- `bf-tabs` is part of the Capacitor shell (`provideBfShell()`). On a desktop web app, put the top-level destinations in a [bf-top-nav](https://bitforge-ui.web.app/components/top-nav) (a few sections) or a [bf-sidebar](https://bitforge-ui.web.app/components/sidebar) (more, in groups, collapsible to an icon rail) of `a[bfNavItem]` links, each with `routerLink`, `routerLinkActive` and `ariaCurrentWhenActive="page"`, above a plain `router-outlet`. The router sets `aria-current="page"` on the link of the current route and the item shows it. Drive both from one array of destinations: inside the shell the same array feeds the `bf-tab`s of a [bf-tab-bar](https://bitforge-ui.web.app/components/tab-bar), and below a breakpoint a [bf-menu-button](https://bitforge-ui.web.app/components/menu-button) of `a[bfMenuItem]` links (see bf-sidebar, Responsive). Inside the shell under a mouse, the tab links take the `bf-tab` hover and focus styles.
25014
+ `bf-tabs` is part of the Capacitor shell (`provideBfShell()`). On a desktop web app, put the top-level destinations in a [bf-top-nav](https://bitforge-ui.web.app/components/top-nav) (a few sections) or a [bf-sidebar](https://bitforge-ui.web.app/components/sidebar) (more, in groups, collapsible to an icon rail) of `a[bfNavItem]` links, each with `routerLink`, `routerLinkActive` and `ariaCurrentWhenActive="page"`, above a plain `router-outlet`. The router sets `aria-current="page"` on the link of the current route and the item shows it. Drive both from one array of destinations: inside the shell the same array feeds the `bf-tab`s of a [bf-tab-bar](https://bitforge-ui.web.app/components/tab-bar), and below a breakpoint a [bf-menu-button](https://bitforge-ui.web.app/components/menu-button) of `a[bfMenuItem]` links (see bf-sidebar, Responsive). Wrap the desktop app in a [bf-app-frame](https://bitforge-ui.web.app/components/app-frame): it holds the sidebar from `md` up and, below it, a header bar with that menu button. Inside the shell under a mouse, the tab links take the `bf-tab` hover and focus styles.
23812
25015
 
23813
25016
  ```ts
23814
25017
  import { Component } from '@angular/core';
@@ -23979,6 +25182,10 @@ content to the top.
23979
25182
 
23980
25183
  ## Migration notes
23981
25184
 
25185
+ ### 0.3.2
25186
+
25187
+ The `## Desktop` docs point at `bf-app-frame` for the frame around the desktop navigation.
25188
+
23982
25189
  ### 0.3.1
23983
25190
 
23984
25191
  The `## Desktop` docs name the desktop replacement: a `bf-top-nav` or `bf-sidebar` of
@@ -26142,7 +27349,7 @@ export class ToolbarStatusExampleComponent {
26142
27349
 
26143
27350
  ## Wrapping
26144
27351
 
26145
- The row is a wrapping flex row with an 8 px gap between items and between rows. In a narrow column the filters wrap onto new rows, and the actions wrap as one group, kept together at the inline end. Give a search field a width (`class="w-full sm:w-56"`) so it fills a phone-wide row and stays compact on a wide one.
27352
+ The row is a wrapping flex row with an 8 px gap between items and between rows. The status and the actions are one group that takes the rest of the last row: on a wide row the status follows the view switch and the actions sit at the inline end; when the group no longer fits beside the filters, it wraps onto a row of its own as a whole, with the status at the start and the actions at the end. So the actions never end up alone on a row, and they stay on the last filter row whenever the group fits there. Give a search field a width (`class="w-full sm:w-56"`) so it fills a phone-wide row and stays compact on a wide one.
26146
27353
 
26147
27354
  ```ts
26148
27355
  import { Component } from '@angular/core';
@@ -26157,8 +27364,8 @@ import {
26157
27364
  /**
26158
27365
  * A toolbar that wraps.
26159
27366
  *
26160
- * In a narrow column the filters wrap onto new rows 8 px apart, and the actions move down
26161
- * together, still at the inline end.
27367
+ * In a narrow column the filters wrap onto new rows 8 px apart, and the status and the actions
27368
+ * move down together: the count at the start of the last row, the actions at the inline end.
26162
27369
  */
26163
27370
  @Component({
26164
27371
  selector: 'app-example-toolbar-wrapping',
@@ -26187,6 +27394,63 @@ export class ToolbarWrappingExampleComponent {
26187
27394
  }
26188
27395
  ```
26189
27396
 
27397
+ ## Phone width
27398
+
27399
+ At phone width (in a desktop web app's narrow window or a narrow column) the search takes a row, the filters share the next, and the status and actions share the last row. A toolbar without actions keeps the status as a plain item after the filters.
27400
+
27401
+ ```ts
27402
+ import { Component, signal } from '@angular/core';
27403
+ import {
27404
+ BfButtonComponent,
27405
+ BfInputComponent,
27406
+ BfSelectComponent,
27407
+ BfToolbarComponent,
27408
+ type BfSelectOption,
27409
+ } from '@bitforgehq/ui';
27410
+
27411
+ /**
27412
+ * A toolbar at phone width.
27413
+ *
27414
+ * In a phone-wide column the search fills its own row, the two filters share the next one, and
27415
+ * the status and the Refresh action share the last row: the count at the start, the action at the
27416
+ * end, never alone on a row of its own.
27417
+ */
27418
+ @Component({
27419
+ selector: 'app-example-toolbar-narrow',
27420
+ imports: [BfButtonComponent, BfInputComponent, BfSelectComponent, BfToolbarComponent],
27421
+ template: `
27422
+ <div class="max-w-xs">
27423
+ <bf-toolbar label="Order filters" [status]="count()">
27424
+ <bf-input
27425
+ type="search"
27426
+ ariaLabel="Search orders"
27427
+ placeholder="Search orders"
27428
+ class="w-full"
27429
+ />
27430
+ <bf-select ariaLabel="Status" [options]="statuses" value="open" />
27431
+ <bf-select ariaLabel="Period" [options]="periods" value="week" />
27432
+ <bf-button bfSlot="actions" variant="outline" (clicked)="refresh()">Refresh</bf-button>
27433
+ </bf-toolbar>
27434
+ </div>
27435
+ `,
27436
+ })
27437
+ export class ToolbarNarrowExampleComponent {
27438
+ protected readonly count = signal('12 orders');
27439
+ protected readonly statuses: BfSelectOption[] = [
27440
+ { value: 'open', label: 'Open' },
27441
+ { value: 'shipped', label: 'Shipped' },
27442
+ ];
27443
+ protected readonly periods: BfSelectOption[] = [
27444
+ { value: 'week', label: 'This week' },
27445
+ { value: 'month', label: 'This month' },
27446
+ ];
27447
+
27448
+ protected refresh(): void {
27449
+ this.count.set('12 orders, updated');
27450
+ }
27451
+ }
27452
+ ```
27453
+
26190
27454
  ## Desktop
26191
27455
 
26192
27456
  `bf-toolbar` is a desktop pattern. Every control and field projected into it resolves its unset `size` to `sm` (32 px tall), because its `providers` set `provideBfDefaults({ size: 'sm' })` for its content; a control that sets `size` itself keeps it, and nothing outside the toolbar changes ([density guide](https://bitforge-ui.web.app/density)). Hover, the pointer cursor and the `focus-visible` ring come from each control; the row adds none of its own. Under a fine pointer each control's visual box is its hit area; under a coarse pointer the `sm` touch extensions reach up to 7.5 px past the visual edge, which the 8 px gap keeps off the neighbouring row.
@@ -26217,7 +27481,7 @@ The `status` element is `role="status"` (an `aria-live="polite"` region), always
26217
27481
 
26218
27482
  - `default`: The search field and the filters (bf-input, bf-select, bf-chip-group), in order.
26219
27483
  - `view`: A view switch after the filters, such as a bf-segmented of list and grid.
26220
- - `actions`: The trailing actions, kept together at the inline end of the row.
27484
+ - `actions`: The trailing actions, kept together at the inline end of the row, beside the status.
26221
27485
 
26222
27486
  ## Notes
26223
27487
 
@@ -26233,6 +27497,14 @@ The `status` element is `role="status"` (an `aria-live="polite"` region), always
26233
27497
 
26234
27498
  ## Migration notes
26235
27499
 
27500
+ ### 0.3.2
27501
+
27502
+ On a narrow row the actions no longer end up alone on a row of their own: the status and the
27503
+ actions are now one group that takes the rest of the last row and wraps as a whole, with the
27504
+ status at the start and the actions at the inline end. The wide layout is unchanged, and a
27505
+ toolbar without actions keeps the status as a plain item of the row. New `toolbar/narrow`
27506
+ example at phone width.
27507
+
26236
27508
  ### 0.3.1
26237
27509
 
26238
27510
  New `bf-toolbar` in `@bitforgehq/ui`: a wrapping `role="group"` row for the search field and
@@ -26759,6 +28031,29 @@ const BF_LABELS: InjectionToken<BfLabels>
26759
28031
 
26760
28032
  ---
26761
28033
 
28034
+ # BF_SIDEBAR_STATE_STORAGE
28035
+
28036
+ Injection token for the store that keeps the collapsed sidebar between visits
28037
+
28038
+ Injection token from `@bitforgehq/ui` · since 0.3.2
28039
+
28040
+ Where `BfSidebarStateService` keeps whether the sidebar is collapsed (the key
28041
+ `bf-sidebar-collapsed`, `'true'` or `'false'`): `localStorage` when it is available, otherwise
28042
+ an in-memory map. Provide another store (`sessionStorage`, or an in-memory one in tests) to keep
28043
+ it elsewhere.
28044
+
28045
+ ```ts
28046
+ import { BF_SIDEBAR_STATE_STORAGE } from '@bitforgehq/ui';
28047
+ ```
28048
+
28049
+ ## Signature
28050
+
28051
+ ```ts
28052
+ const BF_SIDEBAR_STATE_STORAGE: InjectionToken<Pick<Storage, "getItem" | "setItem">>
28053
+ ```
28054
+
28055
+ ---
28056
+
26762
28057
  # BF_TAB_BAR
26763
28058
 
26764
28059
  Injection token through which each tab reaches its enclosing tab bar
@@ -26886,6 +28181,68 @@ type BfAlertVariant = 'neutral' | 'success' | 'warning' | 'danger'
26886
28181
 
26887
28182
  ---
26888
28183
 
28184
+ # BfAppFrameBreakpoint
28185
+
28186
+ The breakpoint where an app frame switches from its header bar to the sidebar
28187
+
28188
+ Type from `@bitforgehq/ui` · since 0.3.2
28189
+
28190
+ The viewport width from which `bf-app-frame` shows its sidebar column instead of the narrow
28191
+ header bar: Tailwind's `md` (768 px) or `lg` (1024 px).
28192
+
28193
+ ```ts
28194
+ import { type BfAppFrameBreakpoint } from '@bitforgehq/ui';
28195
+ ```
28196
+
28197
+ ## Signature
28198
+
28199
+ ```ts
28200
+ type BfAppFrameBreakpoint = 'md' | 'lg'
28201
+ ```
28202
+
28203
+ ## Values
28204
+
28205
+ - `md`
28206
+ - `lg`
28207
+
28208
+ ## Used by
28209
+
28210
+ - [bf-app-frame](../components/app-frame.md)
28211
+
28212
+ ---
28213
+
28214
+ # BfAppFrameContentWidth
28215
+
28216
+ The maximum width of an app frame's content column, from medium to full
28217
+
28218
+ Type from `@bitforgehq/ui` · since 0.3.2
28219
+
28220
+ The maximum width of `bf-app-frame`'s content column: `md` 48rem, `lg` 64rem, `xl` 80rem, or
28221
+ `full` for no limit.
28222
+
28223
+ ```ts
28224
+ import { type BfAppFrameContentWidth } from '@bitforgehq/ui';
28225
+ ```
28226
+
28227
+ ## Signature
28228
+
28229
+ ```ts
28230
+ type BfAppFrameContentWidth = 'md' | 'lg' | 'xl' | 'full'
28231
+ ```
28232
+
28233
+ ## Values
28234
+
28235
+ - `md`
28236
+ - `lg`
28237
+ - `xl`
28238
+ - `full`
28239
+
28240
+ ## Used by
28241
+
28242
+ - [bf-app-frame](../components/app-frame.md)
28243
+
28244
+ ---
28245
+
26889
28246
  # BfAutocapitalize
26890
28247
 
26891
28248
  How a phone keyboard capitalises the text typed into a field
@@ -27555,6 +28912,42 @@ type BfDescriptionListLayout = 'rows' | 'grid'
27555
28912
 
27556
28913
  ---
27557
28914
 
28915
+ # BfEmptyStateHeading
28916
+
28917
+ The heading level of an empty state title, one for a whole page
28918
+
28919
+ Type from `@bitforgehq/ui` · since 0.3.2
28920
+
28921
+ The heading level a `bf-empty-state` title takes: a `BfEmptyStateHeadingLevel`, or `1` when the
28922
+ empty state is the page itself (a not-found or no-access page). A new type, so `Record`s over
28923
+ the released `BfEmptyStateHeadingLevel` keep compiling.
28924
+
28925
+ ```ts
28926
+ import { type BfEmptyStateHeading } from '@bitforgehq/ui';
28927
+ ```
28928
+
28929
+ ## Signature
28930
+
28931
+ ```ts
28932
+ type BfEmptyStateHeading = BfEmptyStateHeadingLevel | 1
28933
+ ```
28934
+
28935
+ ## Values
28936
+
28937
+ - `2`
28938
+ - `3`
28939
+ - `1`
28940
+
28941
+ ## Uses
28942
+
28943
+ - [BfEmptyStateHeadingLevel](bf-empty-state-heading-level.md)
28944
+
28945
+ ## Used by
28946
+
28947
+ - [bf-empty-state](../components/empty-state.md)
28948
+
28949
+ ---
28950
+
27558
28951
  # BfEmptyStateHeadingLevel
27559
28952
 
27560
28953
  The heading level of an empty state title, two for a screen or three
@@ -27580,7 +28973,7 @@ type BfEmptyStateHeadingLevel = 2 | 3
27580
28973
 
27581
28974
  ## Used by
27582
28975
 
27583
- - [bf-empty-state](../components/empty-state.md)
28976
+ - [BfEmptyStateHeading](bf-empty-state-heading.md)
27584
28977
 
27585
28978
  ---
27586
28979
 
@@ -27975,6 +29368,10 @@ import { type BfLabels } from '@bitforgehq/ui';
27975
29368
  | `dialog` | `string` | `'Dialog'` | A dialog's or side panel's name without an ariaLabel, a title or a heading. |
27976
29369
  | `notifications` | `string` | `'Notifications'` | The desktop toast stack's region name. |
27977
29370
  | `tableSelectAll` | `string` | `'Select all rows'` | bf-table's header checkbox, which selects or clears every row. |
29371
+ | `tableSortBy` | `string` | `'Sort by'` | The label of a stacked bf-table's sort select. |
29372
+ | `tableSortNone` | `string` | `'Not sorted'` | A stacked bf-table's first sort option: rows in the order given, no column sorted. |
29373
+ | `tableSortAscending` | `(column: string) => string` | `` (column) => `${column}, ascending` `` | A stacked bf-table's sort option for a column in ascending order. |
29374
+ | `tableSortDescending` | `(column: string) => string` | `` (column) => `${column}, descending` `` | A stacked bf-table's sort option for a column in descending order. |
27978
29375
  | `theme` | `string` | `'Theme'` | bf-theme-switch's and bf-theme-menu-button's name without a label. |
27979
29376
  | `themeSystem` | `string` | `'System'` | The theme choice that follows the system's colour scheme. |
27980
29377
  | `themeLight` | `string` | `'Light'` | The theme choice that forces the light colour scheme. |
@@ -27983,6 +29380,9 @@ import { type BfLabels } from '@bitforgehq/ui';
27983
29380
  | `breadcrumbExpand` | `(count: number) => string` | `` (count) => (count === 1 ? 'Show 1 more level' : `Show ${count} more levels`) `` | bf-breadcrumb's ellipsis button, which shows the levels a `maxItems` trail hides. |
27984
29381
  | `navigation` | `string` | `'Main'` | bf-sidebar's and bf-top-nav's navigation name. |
27985
29382
  | `navBadge` | `(count: number) => string` | `` (count) => `${count} new` `` | a[bfNavItem]'s announcement of its count badge. |
29383
+ | `sidebarCollapse` | `string` | `'Collapse sidebar'` | bf-sidebar-toggle's name and tooltip while the sidebar is expanded. |
29384
+ | `sidebarExpand` | `string` | `'Expand sidebar'` | bf-sidebar-toggle's name and tooltip while the sidebar is an icon rail. |
29385
+ | `skipToContent` | `string` | `'Skip to main content'` | bf-app-frame's skip link, the first stop of the tab order, which moves focus to the content. |
27986
29386
  | `pagination` | `string` | `'Pagination'` | bf-pagination's navigation name. |
27987
29387
  | `previousPage` | `string` | `'Previous page'` | bf-pagination's button to the previous page. |
27988
29388
  | `nextPage` | `string` | `'Next page'` | bf-pagination's button to the next page. |
@@ -28311,6 +29711,30 @@ interface BfSelectOption<T = string>
28311
29711
 
28312
29712
  ---
28313
29713
 
29714
+ # BfSidebarStateService
29715
+
29716
+ The sidebar's collapsed state, kept in storage between visits of the app
29717
+
29718
+ Service from `@bitforgehq/ui` · since 0.3.2
29719
+
29720
+ Whether the app's `bf-sidebar` is collapsed to an icon rail, remembered between visits: the app
29721
+ binds the sidebar's `[(collapsed)]` to `sidebarState.collapsed`. With `provideBfSidebarState()`
29722
+ every change is stored in `BF_SIDEBAR_STATE_STORAGE` under `bf-sidebar-collapsed`; without it
29723
+ the signal still holds the state but nothing is written. Storage is read in the browser only, so a
29724
+ server render starts expanded and writes nothing. The widgets never inject it.
29725
+
29726
+ ```ts
29727
+ import { BfSidebarStateService } from '@bitforgehq/ui';
29728
+ ```
29729
+
29730
+ ## Properties
29731
+
29732
+ | Name | Type | Default | Description |
29733
+ | --- | --- | --- | --- |
29734
+ | `collapsed` | `WritableSignal<boolean>` | | Whether the sidebar is collapsed: the stored state in the browser, else false. Bind it two-way to `bf-sidebar`'s `collapsed`. |
29735
+
29736
+ ---
29737
+
28314
29738
  # BfSize
28315
29739
 
28316
29740
  The three height steps every sized field and control shares
@@ -28361,6 +29785,7 @@ type BfSize = 'sm' | 'md' | 'lg'
28361
29785
  - [bf-segmented](../components/segmented.md)
28362
29786
  - [BfSegmentedSize](bf-segmented-size.md)
28363
29787
  - [bf-select](../components/select.md)
29788
+ - [bf-sidebar-toggle](../components/sidebar-toggle.md)
28364
29789
  - [bf-tab-group](../components/tab-group.md)
28365
29790
  - [bf-table](../components/table.md)
28366
29791
  - [bf-textarea](../components/textarea.md)
@@ -28432,6 +29857,42 @@ type BfSpinnerSize = 'sm' | 'md' | 'lg'
28432
29857
 
28433
29858
  ---
28434
29859
 
29860
+ # BfStatAppearance
29861
+
29862
+ The three stat surfaces, plain, an outlined card or a sunken tile
29863
+
29864
+ Type from `@bitforgehq/ui` · since 0.3.2
29865
+
29866
+ The surface of a `bf-stat`, as its `variant` input takes it: a `BfStatVariant` or tile (a
29867
+ sunken, borderless tile for a stats row inside a raised card). A new type, so `Record`s over
29868
+ the released `BfStatVariant` keep compiling.
29869
+
29870
+ ```ts
29871
+ import { type BfStatAppearance } from '@bitforgehq/ui';
29872
+ ```
29873
+
29874
+ ## Signature
29875
+
29876
+ ```ts
29877
+ type BfStatAppearance = BfStatVariant | 'tile'
29878
+ ```
29879
+
29880
+ ## Values
29881
+
29882
+ - `plain`
29883
+ - `card`
29884
+ - `tile`
29885
+
29886
+ ## Uses
29887
+
29888
+ - [BfStatVariant](bf-stat-variant.md)
29889
+
29890
+ ## Used by
29891
+
29892
+ - [bf-stat](../components/stat.md)
29893
+
29894
+ ---
29895
+
28435
29896
  # BfStatTrend
28436
29897
 
28437
29898
  The three directions of a stat's delta pill, up, down or no change
@@ -28517,7 +29978,7 @@ type BfStatVariant = 'plain' | 'card'
28517
29978
 
28518
29979
  ## Used by
28519
29980
 
28520
- - [bf-stat](../components/stat.md)
29981
+ - [BfStatAppearance](bf-stat-appearance.md)
28521
29982
 
28522
29983
  ---
28523
29984
 
@@ -28825,6 +30286,38 @@ type BfTableSortDirection = 'ascending' | 'descending'
28825
30286
 
28826
30287
  ---
28827
30288
 
30289
+ # BfTableStack
30290
+
30291
+ When a table stacks its rows into cards: never, when narrow, or under touch
30292
+
30293
+ Type from `@bitforgehq/ui` · since 0.3.2
30294
+
30295
+ When a `bf-table` renders each row as a card of label/value pairs: `never` (the default),
30296
+ `narrow` while the table is narrower than 36rem (a container query), or `coarse` under a coarse
30297
+ pointer at any width.
30298
+
30299
+ ```ts
30300
+ import { type BfTableStack } from '@bitforgehq/ui';
30301
+ ```
30302
+
30303
+ ## Signature
30304
+
30305
+ ```ts
30306
+ type BfTableStack = 'never' | 'narrow' | 'coarse'
30307
+ ```
30308
+
30309
+ ## Values
30310
+
30311
+ - `never`
30312
+ - `narrow`
30313
+ - `coarse`
30314
+
30315
+ ## Used by
30316
+
30317
+ - [bf-table](../components/table.md)
30318
+
30319
+ ---
30320
+
28828
30321
  # BfTabLinks
28829
30322
 
28830
30323
  The URLs a router-aware parent gives a tab bar to render its tabs as links
@@ -29032,9 +30525,10 @@ Function from `@bitforgehq/ui` · since 0.3.0
29032
30525
  Replaces built-in strings, merged over the parent's (validation merged key by key); a key left
29033
30526
  out or set to `undefined` keeps the parent's string. In `ApplicationConfig.providers` it covers
29034
30527
  every string. In a component's `providers` it covers that sub-tree's widgets and form controls
29035
- (`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `theme`,
29036
- `themeSystem`, `themeLight`, `themeDark`, `breadcrumb`, `breadcrumbExpand`, `navigation`,
29037
- `navBadge`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,
30528
+ (`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `tableSortBy`,
30529
+ `tableSortNone`, `tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,
30530
+ `breadcrumb`, `breadcrumbExpand`, `navigation`, `navBadge`, `sidebarCollapse`, `sidebarExpand`,
30531
+ `skipToContent`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,
29038
30532
  `calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`,
29039
30533
  `statTrendUp`, `statTrendDown`, `statTrendFlat`, `comboboxEmpty`, `comboboxRemove`,
29040
30534
  `dateInputDay`, `dateInputMonth`, `dateInputYear`, `dateInputOpenCalendar`, `dateInputInvalid`,
@@ -29072,6 +30566,29 @@ function provideBfLabels(overrides: BfLabelsOverrides): Provider
29072
30566
 
29073
30567
  ---
29074
30568
 
30569
+ # provideBfSidebarState
30570
+
30571
+ Remembers the collapsed sidebar between visits, for desktop web apps
30572
+
30573
+ Function from `@bitforgehq/ui` · since 0.3.2
30574
+
30575
+ Lets `BfSidebarStateService` remember the collapsed sidebar: it stores every change of its
30576
+ `collapsed` signal (key `bf-sidebar-collapsed`, `localStorage` in the browser), so the app opens
30577
+ with the sidebar as the user left it. Add it to `ApplicationConfig.providers` and bind the
30578
+ sidebar's `[(collapsed)]` to `sidebarState.collapsed`.
30579
+
30580
+ ```ts
30581
+ import { provideBfSidebarState } from '@bitforgehq/ui';
30582
+ ```
30583
+
30584
+ ## Signature
30585
+
30586
+ ```ts
30587
+ function provideBfSidebarState(): EnvironmentProviders
30588
+ ```
30589
+
30590
+ ---
30591
+
29075
30592
  # BF_DESKTOP_TOAST_DEFAULTS
29076
30593
 
29077
30594
  The library defaults of the desktop toast settings, bottom end with three visible
@@ -35792,6 +37309,45 @@ Focuses the native select.
35792
37309
 
35793
37310
  Blurs the native select, which marks a bound form field touched.
35794
37311
 
37312
+ ## Used by
37313
+
37314
+ - [BfTableHarness](bf-table-harness.md)
37315
+
37316
+ ---
37317
+
37318
+ # BfSidebarToggleHarness
37319
+
37320
+ Test harness for a sidebar toggle: reads and flips the collapsed sidebar
37321
+
37322
+ Class from `@bitforgehq/ui/testing` · since 0.3.2
37323
+
37324
+ Harness for `bf-sidebar-toggle`, the button that collapses a `bf-sidebar` to an icon rail. Its
37325
+ `label` filter matches the button's name ("Collapse sidebar" or "Expand sidebar").
37326
+
37327
+ ```ts
37328
+ import { BfSidebarToggleHarness } from '@bitforgehq/ui/testing';
37329
+ ```
37330
+
37331
+ ## Signature
37332
+
37333
+ ```ts
37334
+ class BfSidebarToggleHarness extends ComponentHarness
37335
+ ```
37336
+
37337
+ ## Methods
37338
+
37339
+ ### `getLabel(): Promise<string>`
37340
+
37341
+ The button's name, which says what the next press does.
37342
+
37343
+ ### `isExpanded(): Promise<boolean>`
37344
+
37345
+ Whether the sidebar is at full width (`aria-expanded`).
37346
+
37347
+ ### `toggle(): Promise<void>`
37348
+
37349
+ Flips the sidebar with a click.
37350
+
35795
37351
  ---
35796
37352
 
35797
37353
  # BfSidePanelHarness
@@ -35886,7 +37442,7 @@ Class from `@bitforgehq/ui/testing` · since 0.3.1
35886
37442
 
35887
37443
  Harness for `bf-table`. It works by column header text and row position, so a test never needs
35888
37444
  the column keys or the row keys (except `getSort()`, which returns the column's `key`, as the
35889
- `sort` model holds it).
37445
+ `sort` model holds it). It reads and drives a stacked table (`stack`) the same way.
35890
37446
 
35891
37447
  ```ts
35892
37448
  import { BfTableHarness } from '@bitforgehq/ui/testing';
@@ -35934,6 +37490,11 @@ Throws for an unknown or unsortable column.
35934
37490
 
35935
37491
  The sorted column (its `key`) and direction, from `aria-sort`.
35936
37492
 
37493
+ ### `getSortSelect(): Promise<BfSelectHarness | null>`
37494
+
37495
+ The Sort by select in a stacked table's bar: its first option, Not sorted (`tableSortNone`),
37496
+ sets `sort` to null and shows while it is; then each sortable column ascending and descending.
37497
+
35937
37498
  ### `selectRow(index: number): Promise<void>`
35938
37499
 
35939
37500
  Checks one row's checkbox, as a user would; a selected row stays selected. Throws when the
@@ -35954,6 +37515,7 @@ The number of selected rows (checked row checkboxes).
35954
37515
 
35955
37516
  ## Uses
35956
37517
 
37518
+ - [BfSelectHarness](bf-select-harness.md)
35957
37519
  - [BfTableSort](bf-table-sort.md)
35958
37520
 
35959
37521
  ---