@bitforgehq/ui 0.3.1 → 0.3.2

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 (45) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +9 -8
  3. package/ai/MANIFEST.index.json +18 -0
  4. package/ai/MANIFEST.json +368 -31
  5. package/ai/REFERENCE.index.json +63 -0
  6. package/ai/REFERENCE.json +247 -5
  7. package/ai/UI_GUIDE.md +59 -33
  8. package/ai/components/app-frame.md +328 -0
  9. package/ai/components/card.md +56 -2
  10. package/ai/components/empty-state.md +99 -6
  11. package/ai/components/header.md +5 -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 +134 -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 +6 -0
  23. package/ai/reference/bf-sidebar-state-service.md +21 -0
  24. package/ai/reference/bf-sidebar-toggle-harness.md +32 -0
  25. package/ai/reference/bf-size.md +1 -0
  26. package/ai/reference/bf-stat-appearance.md +33 -0
  27. package/ai/reference/bf-stat-variant.md +1 -1
  28. package/ai/reference/bf-table-harness.md +1 -1
  29. package/ai/reference/bf-table-stack.md +29 -0
  30. package/ai/reference/bf_sidebar_state_storage.md +20 -0
  31. package/ai/reference/provide-bf-labels.md +4 -3
  32. package/ai/reference/provide-bf-sidebar-state.md +20 -0
  33. package/bin/bf-ui.js +433 -3
  34. package/fesm2022/bitforgehq-ui-overlay.mjs +571 -446
  35. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
  36. package/fesm2022/bitforgehq-ui-testing.mjs +18 -2
  37. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
  38. package/fesm2022/bitforgehq-ui.mjs +759 -127
  39. package/fesm2022/bitforgehq-ui.mjs.map +1 -1
  40. package/llms-full.txt +1560 -43
  41. package/llms.txt +11 -0
  42. package/package.json +1 -1
  43. package/types/bitforgehq-ui-overlay.d.ts +51 -0
  44. package/types/bitforgehq-ui-testing.d.ts +35 -1
  45. package/types/bitforgehq-ui.d.ts +276 -20
@@ -0,0 +1,328 @@
1
+ # bf-app-frame
2
+
3
+ Desktop app frame with skip link, sticky sidebar, narrow header and main content
4
+
5
+ `BfAppFrameComponent` from `@bitforgehq/ui` · Navigation · since 0.3.2
6
+
7
+ ## Example
8
+
9
+ ```ts
10
+ import { Component, signal } from '@angular/core';
11
+ import { RouterLink, RouterLinkActive } from '@angular/router';
12
+ import {
13
+ BfAppFrameComponent,
14
+ BfIconButtonComponent,
15
+ BfNavItemComponent,
16
+ BfSidebarComponent,
17
+ } from '@bitforgehq/ui';
18
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
19
+ import {
20
+ BfMenuButtonComponent,
21
+ BfMenuComponent,
22
+ BfMenuItemDirective,
23
+ } from '@bitforgehq/ui/overlay';
24
+ import { Bell, type IconNode, Layers, Menu, PanelLeft, PanelTop } from 'lucide';
25
+
26
+ /** One top-level destination of the app, shared by the sidebar and the narrow-screen menu. */
27
+ interface Destination {
28
+ readonly path: string;
29
+ readonly label: string;
30
+ readonly icon: IconNode;
31
+ }
32
+
33
+ /**
34
+ * The bf-app-frame overview.
35
+ *
36
+ * Live demo of `bf-app-frame` in a box of fixed height: one destinations array drives the
37
+ * `bf-sidebar` (from `md` up) and the narrow header's `bf-menu-button`; the header also holds the
38
+ * brand and a notifications action. Tab into the frame to reveal its skip link; Enter moves focus
39
+ * to the content. The result line reports it.
40
+ */
41
+ @Component({
42
+ selector: 'app-example-app-frame-overview',
43
+ imports: [
44
+ RouterLink,
45
+ RouterLinkActive,
46
+ BfAppFrameComponent,
47
+ BfIconButtonComponent,
48
+ BfIconComponent,
49
+ BfMenuButtonComponent,
50
+ BfMenuComponent,
51
+ BfMenuItemDirective,
52
+ BfNavItemComponent,
53
+ BfSidebarComponent,
54
+ ],
55
+ template: `
56
+ <!-- In an app the frame is the root of the page; here a fixed height stands in for the
57
+ viewport, so the sidebar and the header bar stick inside the box. -->
58
+ <div (focusin)="onFocus($event)">
59
+ <bf-app-frame
60
+ mainId="app-frame-demo-content"
61
+ class="border-border rounded-card h-96 overflow-auto border"
62
+ >
63
+ <bf-sidebar bfSlot="sidebar" label="App frame demo">
64
+ <span bfSlot="header" class="text-heading ps-3">Atlas</span>
65
+ @for (destination of destinations; track destination.path) {
66
+ <a
67
+ bfNavItem
68
+ [routerLink]="destination.path"
69
+ routerLinkActive
70
+ ariaCurrentWhenActive="page"
71
+ >
72
+ <bf-icon bfSlot="icon" [icon]="destination.icon" />
73
+ {{ destination.label }}
74
+ </a>
75
+ }
76
+ </bf-sidebar>
77
+
78
+ <span bfSlot="brand" class="text-heading">Atlas</span>
79
+ <bf-menu-button bfSlot="menu" variant="text" [menu]="navMenu">
80
+ <bf-icon bfSlot="icon-start" [icon]="menu" />
81
+ Sections
82
+ </bf-menu-button>
83
+ <bf-icon-button
84
+ bfSlot="actions"
85
+ variant="text"
86
+ label="Notifications"
87
+ (clicked)="status.set('Notifications opened.')"
88
+ >
89
+ <bf-icon [icon]="bell" />
90
+ </bf-icon-button>
91
+
92
+ <div class="flex flex-col gap-3">
93
+ <h2 class="text-title">Inbox</h2>
94
+ <p class="text-body text-text-muted max-w-prose">
95
+ The page sits in the content column, beside the sidebar from the md breakpoint up and
96
+ under the header bar below it.
97
+ </p>
98
+ <p data-status aria-live="polite" class="text-caption text-text-muted">{{ status() }}</p>
99
+ @for (row of rows; track row) {
100
+ <div class="bg-surface rounded-tile text-body p-4">{{ row }}</div>
101
+ }
102
+ </div>
103
+ </bf-app-frame>
104
+ </div>
105
+
106
+ <ng-template #navMenu>
107
+ <bf-menu>
108
+ @for (destination of destinations; track destination.path) {
109
+ <a
110
+ bfMenuItem
111
+ [routerLink]="destination.path"
112
+ routerLinkActive
113
+ ariaCurrentWhenActive="page"
114
+ >{{ destination.label }}</a
115
+ >
116
+ }
117
+ </bf-menu>
118
+ </ng-template>
119
+ `,
120
+ })
121
+ export class AppFrameOverviewExampleComponent {
122
+ /** In an app, derive this from the route config, so a new route appears on every surface. */
123
+ protected readonly destinations: readonly Destination[] = [
124
+ { path: '/components/app-frame', label: 'App frame', icon: PanelLeft },
125
+ { path: '/components/sidebar', label: 'Sidebar', icon: PanelTop },
126
+ { path: '/components/menu-button', label: 'Menu button', icon: Layers },
127
+ ];
128
+ protected readonly rows = [
129
+ 'Review the brand brief',
130
+ 'Plan the launch',
131
+ 'Write the release notes',
132
+ ];
133
+ protected readonly bell = Bell;
134
+ protected readonly menu = Menu;
135
+ readonly status = signal('Tab into the frame to reveal the skip link.');
136
+
137
+ /** Reports when focus lands on the content, as the skip link moves it there. */
138
+ protected onFocus(event: FocusEvent): void {
139
+ if (event.target instanceof HTMLElement && event.target.id === 'app-frame-demo-content') {
140
+ this.status.set('Skipped to the content.');
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ ## Overview
147
+
148
+ The frame of a desktop web app: a skip link, a sticky full-height sidebar column from the
149
+ breakpoint up, a header bar with the brand, the narrow-screen menu and actions below it, and
150
+ the `main` landmark with a content column of a set maximum width.
151
+
152
+ 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.
153
+
154
+ ## When to use
155
+
156
+ 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).
157
+
158
+ ## Slots and layout
159
+
160
+ 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 brand at the start, then the menu, and the actions at the end. Keep the brand in the sidebar's own `header` slot too, for the wide layout.
161
+
162
+ ## Breakpoint and content width
163
+
164
+ `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.
165
+
166
+ ```ts
167
+ import { Component } from '@angular/core';
168
+ import { RouterOutlet } from '@angular/router';
169
+ import { BfAppFrameComponent, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';
170
+
171
+ /**
172
+ * A narrow reading column, with the sidebar from lg up.
173
+ *
174
+ * A settings or reading app keeps its lines short: `contentWidth="md"` caps the content column at
175
+ * 48rem (`lg` 64rem, the default `xl` 80rem, `full` no limit), and `breakpoint="lg"` keeps the
176
+ * header bar up to 1024 px, for a sidebar that would crowd a tablet. `mainId` names the main
177
+ * landmark, the skip link's target.
178
+ *
179
+ * @preview none
180
+ */
181
+ @Component({
182
+ selector: 'app-example-app-frame-content-width',
183
+ imports: [RouterOutlet, BfAppFrameComponent, BfNavItemComponent, BfSidebarComponent],
184
+ template: `
185
+ <bf-app-frame contentWidth="md" breakpoint="lg" mainId="content">
186
+ <bf-sidebar bfSlot="sidebar" label="Settings">
187
+ <a bfNavItem href="/account">Account</a>
188
+ <a bfNavItem href="/billing">Billing</a>
189
+ </bf-sidebar>
190
+ <span bfSlot="brand" class="text-heading">Settings</span>
191
+ <router-outlet />
192
+ </bf-app-frame>
193
+ `,
194
+ })
195
+ export class AppFrameContentWidthExampleComponent {}
196
+ ```
197
+
198
+ ## Skip link and the main landmark
199
+
200
+ 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.
201
+
202
+ ## One array of destinations
203
+
204
+ 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:
205
+
206
+ ```ts
207
+ import { Component } from '@angular/core';
208
+ import { BfTabBarComponent, BfTabComponent } from '@bitforgehq/ui';
209
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
210
+ import { BfStackOutletComponent, BfTabsComponent } from '@bitforgehq/ui/mobile';
211
+ import { Folder, type IconNode, Inbox, Settings } from 'lucide';
212
+
213
+ /** One top-level destination: `path` is the route under the tabs, and the tab's id. */
214
+ interface Destination {
215
+ readonly path: string;
216
+ readonly label: string;
217
+ readonly icon: IconNode;
218
+ }
219
+
220
+ /**
221
+ * The same destinations in the Capacitor shell.
222
+ *
223
+ * Inside `provideBfShell()` there is no `bf-app-frame`: the array that feeds the frame's sidebar
224
+ * and narrow menu drives the `bf-tab`s of a `bf-tab-bar` in `bf-tabs` instead, and each page
225
+ * brings its own `bf-page` with a `bf-header`. Each destination's `path` is a child route of
226
+ * `app` and the tab's id; on desktop the same array feeds `a[bfNavItem]` and `a[bfMenuItem]`
227
+ * links (`routerLink="/app/{path}"`).
228
+ *
229
+ * @preview none
230
+ */
231
+ @Component({
232
+ selector: 'app-example-app-frame-shell',
233
+ imports: [
234
+ BfIconComponent,
235
+ BfStackOutletComponent,
236
+ BfTabBarComponent,
237
+ BfTabComponent,
238
+ BfTabsComponent,
239
+ ],
240
+ template: `
241
+ <bf-tabs tabsPath="app">
242
+ <bf-stack-outlet />
243
+ <bf-tab-bar>
244
+ @for (destination of destinations; track destination.path) {
245
+ <bf-tab [id]="destination.path" [label]="destination.label"
246
+ ><bf-icon [icon]="destination.icon"
247
+ /></bf-tab>
248
+ }
249
+ </bf-tab-bar>
250
+ </bf-tabs>
251
+ `,
252
+ })
253
+ export class AppFrameShellExampleComponent {
254
+ protected readonly destinations: readonly Destination[] = [
255
+ { path: 'inbox', label: 'Inbox', icon: Inbox },
256
+ { path: 'projects', label: 'Projects', icon: Folder },
257
+ { path: 'settings', label: 'Settings', icon: Settings },
258
+ ];
259
+ }
260
+ ```
261
+
262
+ ## Desktop
263
+
264
+ 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.
265
+
266
+ ## Theming
267
+
268
+ - Frame: `bg` background, `text` colour.
269
+ - Narrow header bar: `surface` with a `border` line at the bottom, at least 56 px tall.
270
+ - Skip link: `surface` background, `text`, `shadow-floating`, the `control` (pill) radius and the `ring` focus ring.
271
+ - 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`.
272
+ - The sidebar's own tokens are on the [sidebar](https://bitforge-ui.web.app/components/sidebar#theming) page.
273
+
274
+ ## Accessibility
275
+
276
+ 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.
277
+
278
+ ### Keyboard
279
+
280
+ | Key | Action |
281
+ | ------------------------------------------------ | ------------------------------------------------------------------------- |
282
+ | <kbd>Tab</kbd> | From the top of the page, focuses the skip link first and reveals it |
283
+ | <kbd>Enter</kbd> | On the skip link, moves focus to the main content |
284
+ | <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the sidebar or the header bar, then the page, in that order |
285
+
286
+ ## Inputs
287
+
288
+ | Name | Type | Default | Description |
289
+ | -------------- | ----------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------- |
290
+ | `breakpoint` | `BfAppFrameBreakpoint` (`'md' \| 'lg'`) | `'md'` | The viewport width from which the sidebar column replaces the narrow header bar. |
291
+ | `contentWidth` | `BfAppFrameContentWidth` (`'md' \| 'lg' \| 'xl' \| 'full'`) | `'xl'` | The maximum width of the content column (`md` 48rem, `lg` 64rem, `xl` 80rem, `full`). |
292
+ | `mainId` | `string` | `generated` | The id of the `main` landmark, the skip link's target; generated when left out. |
293
+
294
+ ## Slots
295
+
296
+ - `sidebar`: The `bf-sidebar`, shown from the breakpoint up; it fills the column's height.
297
+ - `brand`: The app's name or logo in the narrow header bar.
298
+ - `menu`: The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links.
299
+ - `actions`: End actions of the narrow header bar, such as the theme menu.
300
+ - `default`: The page: what the router shows, inside the `main` landmark.
301
+
302
+ ## Notes
303
+
304
+ ### Do
305
+
306
+ - Put the frame at the root of the app, with the `router-outlet` as its content, so the sidebar stays put between pages.
307
+ - Drive the sidebar, the narrow menu and the shell's tab bar from one array of destinations.
308
+ - Keep one `h1` per page in the content; the frame renders no heading.
309
+
310
+ ### Don't
311
+
312
+ - Don't use `bf-app-frame` inside the Capacitor shell; use `bf-tabs` with a `bf-tab-bar` and `bf-page` there.
313
+ - Don't add your own skip link or hide the `bf-sidebar` with `hidden md:flex` inside the frame; the frame does both.
314
+
315
+ ## Migration notes
316
+
317
+ ### 0.3.2
318
+
319
+ New `bf-app-frame` in `@bitforgehq/ui`: the frame of a desktop web app. Slots `sidebar` (a
320
+ `bf-sidebar`, in a column that sticks to the viewport at full height from the breakpoint up),
321
+ `brand`, `menu` (the narrow-screen `bf-menu-button`) and `actions` (the header bar shown below
322
+ the breakpoint) and the default slot (the page, in the `main` landmark). `breakpoint`
323
+ (`BfAppFrameBreakpoint`: `'md'` default, `'lg'`), `contentWidth` (`BfAppFrameContentWidth`:
324
+ `'md'` 48rem, `'lg'` 64rem, `'xl'` 80rem default, `'full'`) and `mainId`. A skip link, first in
325
+ the tab order and hidden until focused, moves focus to the main landmark by script, so it works
326
+ under `<base href="/">`; new label key `skipToContent` ("Skip to main content"). Inside another
327
+ `main` landmark the content drops its `main` role. Desktop only: the Capacitor shell keeps
328
+ `bf-tabs`.
@@ -95,7 +95,7 @@ Surface for a piece of content: a project summary, a stat, a focus prompt. Four
95
95
  the design: raised (default), tint, ink and flat. To make the whole card clickable, put a
96
96
  `bfAction` link or button in its heading (the stretched-link pattern).
97
97
 
98
- 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.
98
+ 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.
99
99
 
100
100
  ## When to use
101
101
 
@@ -148,6 +148,53 @@ import { BfCardComponent } from '@bitforgehq/ui';
148
148
  export class CardVariantsExampleComponent {}
149
149
  ```
150
150
 
151
+ ## Nesting surfaces
152
+
153
+ Each level of a page takes one surface style, and a level inside a card steps down, never up:
154
+
155
+ - **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.
156
+ - **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.
157
+ - **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.
158
+
159
+ ```ts
160
+ import { Component } from '@angular/core';
161
+ import { BfCardComponent, BfStatComponent } from '@bitforgehq/ui';
162
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
163
+ import { CircleCheck, Clock, ListTodo } from 'lucide';
164
+
165
+ /**
166
+ * A raised card holding a row of sunken tiles.
167
+ *
168
+ * One surface per level: the card is raised on the page, and the stats inside it are sunken,
169
+ * borderless tiles (`bf-stat variant="tile"`), never outlined surfaces.
170
+ */
171
+ @Component({
172
+ selector: 'app-example-card-nesting',
173
+ imports: [BfCardComponent, BfIconComponent, BfStatComponent],
174
+ template: `
175
+ <bf-card padding="lg">
176
+ <h3 class="text-heading">This week</h3>
177
+ <div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
178
+ <bf-stat label="Open tasks" value="14" variant="tile">
179
+ <bf-icon bfSlot="icon" [icon]="listTodo" />
180
+ </bf-stat>
181
+ <bf-stat label="Done" value="23" variant="tile">
182
+ <bf-icon bfSlot="icon" [icon]="circleCheck" />
183
+ </bf-stat>
184
+ <bf-stat label="Time logged" value="31 h" variant="tile">
185
+ <bf-icon bfSlot="icon" [icon]="clock" />
186
+ </bf-stat>
187
+ </div>
188
+ </bf-card>
189
+ `,
190
+ })
191
+ export class CardNestingExampleComponent {
192
+ protected readonly listTodo = ListTodo;
193
+ protected readonly circleCheck = CircleCheck;
194
+ protected readonly clock = Clock;
195
+ }
196
+ ```
197
+
151
198
  ## Footer
152
199
 
153
200
  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.
@@ -319,13 +366,20 @@ The card renders a plain `<div>` with no role. A clickable card follows the stre
319
366
 
320
367
  ### Don't
321
368
 
322
- - Don't nest raised cards; use `flat` inside.
369
+ - 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.
370
+ - Don't mix surface styles on one level of a dashboard; keep one per level.
323
371
  - Don't use `interactive` (deprecated) or wrap a card in an `<a>`; both hide the heading and nest interactive content.
324
372
  - Don't put more than one `bfAction` in a card.
325
373
  - Don't put a `text` button on an ink card — its primary colour fails contrast on ink; use `tonal` or `outline`.
326
374
 
327
375
  ## Migration notes
328
376
 
377
+ ### 0.3.2
378
+
379
+ New "Nesting surfaces" docs section: a raised card holds sunken tiles, never an outlined
380
+ surface (`flat`, a `card` stat), and each level of a dashboard keeps one surface style. New
381
+ `card/nesting` example. No code change.
382
+
329
383
  ### 0.3.1
330
384
 
331
385
  The footer (`bfSlot="footer"`) sits on the card's bottom edge (`mt-auto`), so in a grid of cards
@@ -98,6 +98,89 @@ export class EmptyStateActionExampleComponent {
98
98
 
99
99
  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.
100
100
 
101
+ ## As the page
102
+
103
+ 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").
104
+
105
+ 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.
106
+
107
+ ```ts
108
+ import { Component } from '@angular/core';
109
+ import { RouterLink } from '@angular/router';
110
+ import { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';
111
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
112
+ import { FileQuestionMark } from 'lucide';
113
+
114
+ /**
115
+ * An empty state as a whole page.
116
+ *
117
+ * Centred in a tall content area, with a primary action and a secondary link. This preview sits
118
+ * inside a docs page that has its own `h1`, so it uses `headingLevel` 2; on a real not-found page
119
+ * the empty state is the page, so set it to 1 (see the next example).
120
+ */
121
+ @Component({
122
+ selector: 'app-example-empty-state-page',
123
+ imports: [RouterLink, BfButtonComponent, BfEmptyStateComponent, BfIconComponent],
124
+ template: `
125
+ <div class="flex min-h-96 flex-col justify-center">
126
+ <bf-empty-state
127
+ title="Page not found"
128
+ description="The page may have moved, or the link has a typo."
129
+ [headingLevel]="2"
130
+ >
131
+ <bf-icon bfSlot="icon" [icon]="fileQuestion" size="lg" />
132
+ <a bfSlot="actions" bfButton routerLink="/">Go to the home page</a>
133
+ <a bfSlot="actions" bfButton variant="text" href="mailto:support@example.com">
134
+ Contact support
135
+ </a>
136
+ </bf-empty-state>
137
+ </div>
138
+ `,
139
+ })
140
+ export class EmptyStatePageExampleComponent {
141
+ protected readonly fileQuestion = FileQuestionMark;
142
+ }
143
+ ```
144
+
145
+ ```ts
146
+ import { Component } from '@angular/core';
147
+ import { RouterLink } from '@angular/router';
148
+ import { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';
149
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
150
+ import { FileQuestionMark } from 'lucide';
151
+
152
+ /**
153
+ * A not-found page whose empty state is the page heading.
154
+ *
155
+ * The route's whole content: `headingLevel` 1 renders the title as the page's `h1`, and the
156
+ * `min-h-[60vh]` area centres the empty state vertically in the content column.
157
+ *
158
+ * @preview none
159
+ */
160
+ @Component({
161
+ selector: 'app-example-empty-state-not-found-page',
162
+ imports: [RouterLink, BfButtonComponent, BfEmptyStateComponent, BfIconComponent],
163
+ template: `
164
+ <div class="flex min-h-[60vh] flex-col justify-center">
165
+ <bf-empty-state
166
+ title="Page not found"
167
+ description="The page may have moved, or the link has a typo."
168
+ [headingLevel]="1"
169
+ >
170
+ <bf-icon bfSlot="icon" [icon]="fileQuestion" size="lg" />
171
+ <a bfSlot="actions" bfButton routerLink="/">Go to the home page</a>
172
+ <a bfSlot="actions" bfButton variant="text" href="mailto:support@example.com">
173
+ Contact support
174
+ </a>
175
+ </bf-empty-state>
176
+ </div>
177
+ `,
178
+ })
179
+ export class EmptyStateNotFoundPageExampleComponent {
180
+ protected readonly fileQuestion = FileQuestionMark;
181
+ }
182
+ ```
183
+
101
184
  ## Theming
102
185
 
103
186
  - Icon tile: `surface-sunken` background, `text-muted` icon color, `tile` radius.
@@ -105,15 +188,15 @@ Set `headingLevel` to `3` when the empty state sits inside a section that alread
105
188
 
106
189
  ## Accessibility
107
190
 
108
- 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.
191
+ 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.
109
192
 
110
193
  ## Inputs
111
194
 
112
- | Name | Type | Default | Description |
113
- | -------------- | ------------------------------------- | -------- | -------------------------------------------------------------------- |
114
- | `title` | `string` | required | What is missing, as a short title. |
115
- | `description` | `string` | `''` | One sentence on how to fill it. |
116
- | `headingLevel` | `BfEmptyStateHeadingLevel` (`2 \| 3`) | `2` | Heading level of the title so the empty state fits the page outline. |
195
+ | Name | Type | Default | Description |
196
+ | -------------- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------- |
197
+ | `title` | `string` | required | What is missing, as a short title. |
198
+ | `description` | `string` | `''` | One sentence on how to fill it. |
199
+ | `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. |
117
200
 
118
201
  ## Slots
119
202
 
@@ -129,10 +212,20 @@ The title renders as an `<h2>` by default and an `<h3>` when `headingLevel` is `
129
212
 
130
213
  ### Don't
131
214
 
215
+ - Don't set `headingLevel` to `1` on an empty state inside a page that already has its own `h1`.
132
216
  - Don't use `bf-empty-state` for a loading screen; that is `bf-spinner`.
133
217
 
134
218
  ## Migration notes
135
219
 
220
+ ### 0.3.2
221
+
222
+ `headingLevel` now takes the new named type `BfEmptyStateHeading` (`BfEmptyStateHeadingLevel |
223
+ 1`): `1` renders the title as the page's `h1`, for an empty state that is the page (a not-found
224
+ or no-access page). The released `BfEmptyStateHeadingLevel` keeps its two members, so `Record`s
225
+ over it keep compiling. New "As the page" docs section with the `empty-state/page` example
226
+ (centred in a tall area, a primary action and a secondary link) and the code-only
227
+ `empty-state/not-found-page`.
228
+
136
229
  ### 0.3.0
137
230
 
138
231
  New exported `BfEmptyStateHeadingLevel` (`2 | 3`), the type of `headingLevel` (was an inline union
@@ -608,7 +608,7 @@ inside one.
608
608
 
609
609
  ## Desktop
610
610
 
611
- `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`.
611
+ `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`.
612
612
 
613
613
  ```ts
614
614
  import { Component, signal } from '@angular/core';
@@ -748,6 +748,10 @@ The title is not in the tab order; after a push, `bf-stack-outlet` focuses it wi
748
748
 
749
749
  ## Migration notes
750
750
 
751
+ ### 0.3.2
752
+
753
+ The `## Desktop` docs say `bf-app-frame` supplies the app-level header bar below its breakpoint.
754
+
751
755
  ### 0.3.1
752
756
 
753
757
  The `## Desktop` docs and When to use name the desktop replacement, `bf-page-header` (with