@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/ai/UI_GUIDE.md 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
 
@@ -1228,27 +1252,29 @@ notes.
1228
1252
 
1229
1253
  ### Navigation
1230
1254
 
1231
- | Component | Entry point | Purpose |
1232
- | ---------------------- | ----------------------- | ------------------------------------------------------------------------------------------- |
1233
- | `bf-back-button` | `@bitforgehq/ui/mobile` | Header back button that pops the page stack or falls back to a default route |
1234
- | `bf-breadcrumb` | `@bitforgehq/ui` | Desktop breadcrumb trail of links to the current page, with optional collapsing |
1235
- | `li[bfBreadcrumbItem]` | `@bitforgehq/ui` | One level of a breadcrumb trail: a link, or the current page as text |
1236
- | `bf-content` | `@bitforgehq/ui/mobile` | Page's single scroll container that keeps focused fields clear of the soft keyboard |
1237
- | `bf-header` | `@bitforgehq/ui/mobile` | Page header toolbar with a large title that collapses into the toolbar on scroll |
1238
- | `bf-nav-group` | `@bitforgehq/ui` | A titled group of sidebar links with a small heading that names it |
1239
- | `a[bfNavItem]` | `@bitforgehq/ui` | Navigation link for a sidebar or top nav, with icon, label and count |
1240
- | `bf-page` | `@bitforgehq/ui/mobile` | Frame for one stacked mobile screen that supplies lifecycle events and shared page state |
1241
- | `bf-page-header` | `@bitforgehq/ui` | Desktop page header with breadcrumb, title, subtitle, badges and a row of actions |
1242
- | `bf-pagination` | `@bitforgehq/ui` | Previous, next and numbered page buttons for a long list or table |
1243
- | `bf-refresher` | `@bitforgehq/ui/mobile` | Pull-to-refresh indicator with haptic feedback, native iOS bounce and an Android-style pull |
1244
- | `bf-sidebar` | `@bitforgehq/ui` | Vertical app navigation with brand, grouped links, footer and an icon rail mode |
1245
- | `bf-stack-outlet` | `@bitforgehq/ui/mobile` | Router outlet keeping a live page stack with slide transitions and iOS swipe back |
1246
- | `bf-tab` | `@bitforgehq/ui` | One tab bar destination with an icon, a label and an optional count badge |
1247
- | `bf-tab-bar` | `@bitforgehq/ui` | Floating pill tab bar for mobile apps with arrow-key selection and count badges |
1248
- | `bf-tab-group` | `@bitforgehq/ui` | In-page tabs that switch between content panels, with keyboard navigation and badges |
1249
- | `bf-tab-panel` | `@bitforgehq/ui` | One tab of a tab group with its label, badge, icon and lazy content |
1250
- | `bf-tabs` | `@bitforgehq/ui/mobile` | Tabs page with one navigation stack per tab, wiring the tab bar to the router |
1251
- | `bf-top-nav` | `@bitforgehq/ui` | Horizontal app navigation, a wrapping row of links for apps with few sections |
1255
+ | Component | Entry point | Purpose |
1256
+ | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------- |
1257
+ | `bf-app-frame` | `@bitforgehq/ui` | Desktop app frame with skip link, sticky sidebar, narrow header and main content |
1258
+ | `bf-back-button` | `@bitforgehq/ui/mobile` | Header back button that pops the page stack or falls back to a default route |
1259
+ | `bf-breadcrumb` | `@bitforgehq/ui` | Desktop breadcrumb trail of links to the current page, with optional collapsing |
1260
+ | `li[bfBreadcrumbItem]` | `@bitforgehq/ui` | One level of a breadcrumb trail: a link, or the current page as text |
1261
+ | `bf-content` | `@bitforgehq/ui/mobile` | Page's single scroll container that keeps focused fields clear of the soft keyboard |
1262
+ | `bf-header` | `@bitforgehq/ui/mobile` | Page header toolbar with a large title that collapses into the toolbar on scroll |
1263
+ | `bf-nav-group` | `@bitforgehq/ui` | A titled group of sidebar links with a small heading that names it |
1264
+ | `a[bfNavItem]` | `@bitforgehq/ui` | Navigation link for a sidebar or top nav, with icon, label and count |
1265
+ | `bf-page` | `@bitforgehq/ui/mobile` | Frame for one stacked mobile screen that supplies lifecycle events and shared page state |
1266
+ | `bf-page-header` | `@bitforgehq/ui` | Desktop page header with breadcrumb, title, subtitle, badges and a row of actions |
1267
+ | `bf-pagination` | `@bitforgehq/ui` | Previous, next and numbered page buttons for a long list or table |
1268
+ | `bf-refresher` | `@bitforgehq/ui/mobile` | Pull-to-refresh indicator with haptic feedback, native iOS bounce and an Android-style pull |
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 |
1271
+ | `bf-stack-outlet` | `@bitforgehq/ui/mobile` | Router outlet keeping a live page stack with slide transitions and iOS swipe back |
1272
+ | `bf-tab` | `@bitforgehq/ui` | One tab bar destination with an icon, a label and an optional count badge |
1273
+ | `bf-tab-bar` | `@bitforgehq/ui` | Floating pill tab bar for mobile apps with arrow-key selection and count badges |
1274
+ | `bf-tab-group` | `@bitforgehq/ui` | In-page tabs that switch between content panels, with keyboard navigation and badges |
1275
+ | `bf-tab-panel` | `@bitforgehq/ui` | One tab of a tab group with its label, badge, icon and lazy content |
1276
+ | `bf-tabs` | `@bitforgehq/ui/mobile` | Tabs page with one navigation stack per tab, wiring the tab bar to the router |
1277
+ | `bf-top-nav` | `@bitforgehq/ui` | Horizontal app navigation, a wrapping row of links for apps with few sections |
1252
1278
 
1253
1279
  ### Overlays
1254
1280
 
@@ -0,0 +1,335 @@
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`, at the header's inline
38
+ * 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
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
+ <bf-menu-button bfSlot="menu" variant="text" [menu]="navMenu">
79
+ <bf-icon bfSlot="icon-start" [icon]="menu" />
80
+ Sections
81
+ </bf-menu-button>
82
+ <span bfSlot="brand" class="text-heading">Atlas</span>
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 narrow-screen menu, the brand 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 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.
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
+ - `menu`: The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links, at the header bar's inline start.
298
+ - `brand`: The app's name or logo in the narrow header bar, after the menu.
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.3
318
+
319
+ Fix: the narrow header bar puts the `menu` slot at the inline start, before the `brand` (0.3.2 put
320
+ it after the brand), with the actions at the end as before: the top app bar convention. The menu
321
+ also comes first in the tab order. No API change; an app needs no change unless it relied on the
322
+ brand coming first.
323
+
324
+ ### 0.3.2
325
+
326
+ New `bf-app-frame` in `@bitforgehq/ui`: the frame of a desktop web app. Slots `sidebar` (a
327
+ `bf-sidebar`, in a column that sticks to the viewport at full height from the breakpoint up),
328
+ `brand`, `menu` (the narrow-screen `bf-menu-button`) and `actions` (the header bar shown below
329
+ the breakpoint) and the default slot (the page, in the `main` landmark). `breakpoint`
330
+ (`BfAppFrameBreakpoint`: `'md'` default, `'lg'`), `contentWidth` (`BfAppFrameContentWidth`:
331
+ `'md'` 48rem, `'lg'` 64rem, `'xl'` 80rem default, `'full'`) and `mainId`. A skip link, first in
332
+ the tab order and hidden until focused, moves focus to the main landmark by script, so it works
333
+ under `<base href="/">`; new label key `skipToContent` ("Skip to main content"). Inside another
334
+ `main` landmark the content drops its `main` role. Desktop only: the Capacitor shell keeps
335
+ `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