@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/MANIFEST.json CHANGED
@@ -512,6 +512,130 @@
512
512
  "accessibility": "The overlay panel is a `role=\"alertdialog\"` with `aria-modal=\"true\"`, `aria-labelledby`\npointing at the title and, when there is a `message`, `aria-describedby` pointing at it; the page stack behind it is `inert`. Focus lands on the `cancel`-role\nbutton, else on the first button (the title and message hold no focusable content), so Enter on\nopen never confirms a destructive action ([APG alert dialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/):\nfocus the least destructive choice). Tab is trapped inside, and focus returns to the\nelement that opened the dialog on close. There is no backdrop dismiss. `Escape` and the hardware\nback button resolve with the cancel button's `id ?? label` when `buttons` has one; with none, both are\nclaimed and otherwise ignored, so the dialog stays open — **an alert must have a cancel button to\nbe dismissible** any other way than tapping a button. Either way the key stops at the alert: an\nalert opened over a sheet never lets `Escape` close the sheet underneath. Under `prefers-reduced-motion` the card\ncross-fades in and out instead of scaling.\n\n### Keyboard\n\n| Key | Action |\n| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves between the buttons, wrapping inside the dialog; focus starts on the `cancel`-role button, else the first |\n| <kbd>Enter</kbd> / <kbd>Space</kbd> | Activates the focused button |\n| <kbd>Escape</kbd> | Answers with the `cancel`-role button; without one, it is claimed and ignored |",
513
513
  "notes": "## Do\n\n- Give every dismissible confirmation a `cancel`-role button, so `Escape` and the hardware back\n button work the way people expect.\n- Keep `message` short and put the consequence in it (\"This cannot be undone\") rather than in the\n title.\n- Use `role: 'destructive'` on the button that causes data loss, and word it as the action\n (\"Delete\", not \"Yes\").\n\n## Don't\n\n- Don't open an alert with no cancel button unless every button is a safe, equally valid answer\n (an informational dialog with just \"OK\") — otherwise give it a cancel button.\n- Don't stack more than two or three buttons; use an action sheet (`BfActionSheetService`) for a\n longer list of choices.\n- Don't rely on a backdrop tap to close the dialog — there is none, by design, so the user must\n make a choice."
514
514
  },
515
+ {
516
+ "name": "app-frame",
517
+ "selector": "bf-app-frame",
518
+ "selectors": ["bf-app-frame"],
519
+ "className": "BfAppFrameComponent",
520
+ "entryPoint": "@bitforgehq/ui",
521
+ "file": "src/components/app-frame/app-frame.component.ts",
522
+ "category": "Navigation",
523
+ "summary": "Desktop app frame with skip link, sticky sidebar, narrow header and main content",
524
+ "description": "The frame of a desktop web app: a skip link, a sticky full-height sidebar column from the\nbreakpoint up, a header bar with the narrow-screen menu, the brand and actions below it, and\nthe `main` landmark with a content column of a set maximum width.",
525
+ "overview": "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.",
526
+ "example": "<bf-app-frame>\n <bf-sidebar bfSlot=\"sidebar\" label=\"Main\">…</bf-sidebar>\n <bf-menu-button bfSlot=\"menu\" [menu]=\"navMenu\">Sections</bf-menu-button>\n <span bfSlot=\"brand\" class=\"text-heading\">Atlas</span>\n <router-outlet />\n</bf-app-frame>",
527
+ "primaryExample": "app-frame/overview",
528
+ "examples": [
529
+ {
530
+ "id": "app-frame/overview",
531
+ "title": "The bf-app-frame overview",
532
+ "file": "projects/docs/src/app/examples/app-frame/overview-example.component.ts",
533
+ "source": "import { Component, signal } from '@angular/core';\nimport { RouterLink, RouterLinkActive } from '@angular/router';\nimport {\n BfAppFrameComponent,\n BfIconButtonComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n} from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport {\n BfMenuButtonComponent,\n BfMenuComponent,\n BfMenuItemDirective,\n} from '@bitforgehq/ui/overlay';\nimport { Bell, type IconNode, Layers, Menu, PanelLeft, PanelTop } from 'lucide';\n\n/** One top-level destination of the app, shared by the sidebar and the narrow-screen menu. */\ninterface Destination {\n readonly path: string;\n readonly label: string;\n readonly icon: IconNode;\n}\n\n/**\n * The bf-app-frame overview.\n *\n * Live demo of `bf-app-frame` in a box of fixed height: one destinations array drives the\n * `bf-sidebar` (from `md` up) and the narrow header's `bf-menu-button`, at the header's inline\n * 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\n * to the content. The result line reports it.\n */\n@Component({\n selector: 'app-example-app-frame-overview',\n imports: [\n RouterLink,\n RouterLinkActive,\n BfAppFrameComponent,\n BfIconButtonComponent,\n BfIconComponent,\n BfMenuButtonComponent,\n BfMenuComponent,\n BfMenuItemDirective,\n BfNavItemComponent,\n BfSidebarComponent,\n ],\n template: `\n <!-- In an app the frame is the root of the page; here a fixed height stands in for the\n viewport, so the sidebar and the header bar stick inside the box. -->\n <div (focusin)=\"onFocus($event)\">\n <bf-app-frame\n mainId=\"app-frame-demo-content\"\n class=\"border-border rounded-card h-96 overflow-auto border\"\n >\n <bf-sidebar bfSlot=\"sidebar\" label=\"App frame demo\">\n <span bfSlot=\"header\" class=\"text-heading ps-3\">Atlas</span>\n @for (destination of destinations; track destination.path) {\n <a\n bfNavItem\n [routerLink]=\"destination.path\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"destination.icon\" />\n {{ destination.label }}\n </a>\n }\n </bf-sidebar>\n\n <bf-menu-button bfSlot=\"menu\" variant=\"text\" [menu]=\"navMenu\">\n <bf-icon bfSlot=\"icon-start\" [icon]=\"menu\" />\n Sections\n </bf-menu-button>\n <span bfSlot=\"brand\" class=\"text-heading\">Atlas</span>\n <bf-icon-button\n bfSlot=\"actions\"\n variant=\"text\"\n label=\"Notifications\"\n (clicked)=\"status.set('Notifications opened.')\"\n >\n <bf-icon [icon]=\"bell\" />\n </bf-icon-button>\n\n <div class=\"flex flex-col gap-3\">\n <h2 class=\"text-title\">Inbox</h2>\n <p class=\"text-body text-text-muted max-w-prose\">\n The page sits in the content column, beside the sidebar from the md breakpoint up and\n under the header bar below it.\n </p>\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted\">{{ status() }}</p>\n @for (row of rows; track row) {\n <div class=\"bg-surface rounded-tile text-body p-4\">{{ row }}</div>\n }\n </div>\n </bf-app-frame>\n </div>\n\n <ng-template #navMenu>\n <bf-menu>\n @for (destination of destinations; track destination.path) {\n <a\n bfMenuItem\n [routerLink]=\"destination.path\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >{{ destination.label }}</a\n >\n }\n </bf-menu>\n </ng-template>\n `,\n})\nexport class AppFrameOverviewExampleComponent {\n /** In an app, derive this from the route config, so a new route appears on every surface. */\n protected readonly destinations: readonly Destination[] = [\n { path: '/components/app-frame', label: 'App frame', icon: PanelLeft },\n { path: '/components/sidebar', label: 'Sidebar', icon: PanelTop },\n { path: '/components/menu-button', label: 'Menu button', icon: Layers },\n ];\n protected readonly rows = [\n 'Review the brand brief',\n 'Plan the launch',\n 'Write the release notes',\n ];\n protected readonly bell = Bell;\n protected readonly menu = Menu;\n readonly status = signal('Tab into the frame to reveal the skip link.');\n\n /** Reports when focus lands on the content, as the skip link moves it there. */\n protected onFocus(event: FocusEvent): void {\n if (event.target instanceof HTMLElement && event.target.id === 'app-frame-demo-content') {\n this.status.set('Skipped to the content.');\n }\n }\n}\n",
534
+ "preview": "inline"
535
+ },
536
+ {
537
+ "id": "app-frame/content-width",
538
+ "title": "A narrow reading column, with the sidebar from lg up",
539
+ "file": "projects/docs/src/app/examples/app-frame/content-width-example.component.ts",
540
+ "source": "import { Component } from '@angular/core';\nimport { RouterOutlet } from '@angular/router';\nimport { BfAppFrameComponent, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';\n\n/**\n * A narrow reading column, with the sidebar from lg up.\n *\n * A settings or reading app keeps its lines short: `contentWidth=\"md\"` caps the content column at\n * 48rem (`lg` 64rem, the default `xl` 80rem, `full` no limit), and `breakpoint=\"lg\"` keeps the\n * header bar up to 1024 px, for a sidebar that would crowd a tablet. `mainId` names the main\n * landmark, the skip link's target.\n *\n * @preview none\n */\n@Component({\n selector: 'app-example-app-frame-content-width',\n imports: [RouterOutlet, BfAppFrameComponent, BfNavItemComponent, BfSidebarComponent],\n template: `\n <bf-app-frame contentWidth=\"md\" breakpoint=\"lg\" mainId=\"content\">\n <bf-sidebar bfSlot=\"sidebar\" label=\"Settings\">\n <a bfNavItem href=\"/account\">Account</a>\n <a bfNavItem href=\"/billing\">Billing</a>\n </bf-sidebar>\n <span bfSlot=\"brand\" class=\"text-heading\">Settings</span>\n <router-outlet />\n </bf-app-frame>\n `,\n})\nexport class AppFrameContentWidthExampleComponent {}\n",
541
+ "preview": "none"
542
+ },
543
+ {
544
+ "id": "app-frame/shell",
545
+ "title": "The same destinations in the Capacitor shell",
546
+ "file": "projects/docs/src/app/examples/app-frame/shell-example.component.ts",
547
+ "source": "import { Component } from '@angular/core';\nimport { BfTabBarComponent, BfTabComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { BfStackOutletComponent, BfTabsComponent } from '@bitforgehq/ui/mobile';\nimport { Folder, type IconNode, Inbox, Settings } from 'lucide';\n\n/** One top-level destination: `path` is the route under the tabs, and the tab's id. */\ninterface Destination {\n readonly path: string;\n readonly label: string;\n readonly icon: IconNode;\n}\n\n/**\n * The same destinations in the Capacitor shell.\n *\n * Inside `provideBfShell()` there is no `bf-app-frame`: the array that feeds the frame's sidebar\n * and narrow menu drives the `bf-tab`s of a `bf-tab-bar` in `bf-tabs` instead, and each page\n * brings its own `bf-page` with a `bf-header`. Each destination's `path` is a child route of\n * `app` and the tab's id; on desktop the same array feeds `a[bfNavItem]` and `a[bfMenuItem]`\n * links (`routerLink=\"/app/{path}\"`).\n *\n * @preview none\n */\n@Component({\n selector: 'app-example-app-frame-shell',\n imports: [\n BfIconComponent,\n BfStackOutletComponent,\n BfTabBarComponent,\n BfTabComponent,\n BfTabsComponent,\n ],\n template: `\n <bf-tabs tabsPath=\"app\">\n <bf-stack-outlet />\n <bf-tab-bar>\n @for (destination of destinations; track destination.path) {\n <bf-tab [id]=\"destination.path\" [label]=\"destination.label\"\n ><bf-icon [icon]=\"destination.icon\"\n /></bf-tab>\n }\n </bf-tab-bar>\n </bf-tabs>\n `,\n})\nexport class AppFrameShellExampleComponent {\n protected readonly destinations: readonly Destination[] = [\n { path: 'inbox', label: 'Inbox', icon: Inbox },\n { path: 'projects', label: 'Projects', icon: Folder },\n { path: 'settings', label: 'Settings', icon: Settings },\n ];\n}\n",
548
+ "preview": "none"
549
+ }
550
+ ],
551
+ "usage": [
552
+ {
553
+ "heading": "Slots and layout",
554
+ "body": "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."
555
+ },
556
+ {
557
+ "heading": "Breakpoint and content width",
558
+ "body": "`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.\n\n<!-- example: app-frame/content-width -->"
559
+ },
560
+ {
561
+ "heading": "Skip link and the main landmark",
562
+ "body": "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."
563
+ },
564
+ {
565
+ "heading": "One array of destinations",
566
+ "body": "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:\n\n<!-- example: app-frame/shell -->"
567
+ }
568
+ ],
569
+ "inputs": [
570
+ {
571
+ "name": "breakpoint",
572
+ "kind": "input",
573
+ "type": "BfAppFrameBreakpoint",
574
+ "required": false,
575
+ "doc": "The viewport width from which the sidebar column replaces the narrow header bar.",
576
+ "default": "'md'",
577
+ "expandedType": "'md' | 'lg'"
578
+ },
579
+ {
580
+ "name": "contentWidth",
581
+ "kind": "input",
582
+ "type": "BfAppFrameContentWidth",
583
+ "required": false,
584
+ "doc": "The maximum width of the content column (`md` 48rem, `lg` 64rem, `xl` 80rem, `full`).",
585
+ "default": "'xl'",
586
+ "expandedType": "'md' | 'lg' | 'xl' | 'full'"
587
+ },
588
+ {
589
+ "name": "mainId",
590
+ "kind": "input",
591
+ "type": "string",
592
+ "required": false,
593
+ "doc": "The id of the `main` landmark, the skip link's target; generated when left out.",
594
+ "default": "generated"
595
+ }
596
+ ],
597
+ "outputs": [],
598
+ "models": [],
599
+ "slots": [
600
+ {
601
+ "name": "sidebar",
602
+ "doc": "The `bf-sidebar`, shown from the breakpoint up; it fills the column's height."
603
+ },
604
+ {
605
+ "name": "menu",
606
+ "doc": "The narrow-screen navigation: a `bf-menu-button` of `a[bfMenuItem]` links, at the header bar's inline start."
607
+ },
608
+ {
609
+ "name": "brand",
610
+ "doc": "The app's name or logo in the narrow header bar, after the menu."
611
+ },
612
+ {
613
+ "name": "actions",
614
+ "doc": "End actions of the narrow header bar, such as the theme menu."
615
+ },
616
+ {
617
+ "name": "default",
618
+ "doc": "The page: what the router shows, inside the `main` landmark."
619
+ }
620
+ ],
621
+ "methods": [],
622
+ "changelog": [
623
+ {
624
+ "version": "0.3.3",
625
+ "text": "Fix: the narrow header bar puts the `menu` slot at the inline start, before the `brand` (0.3.2 put\nit after the brand), with the actions at the end as before: the top app bar convention. The menu\nalso comes first in the tab order. No API change; an app needs no change unless it relied on the\nbrand coming first."
626
+ },
627
+ {
628
+ "version": "0.3.2",
629
+ "text": "New `bf-app-frame` in `@bitforgehq/ui`: the frame of a desktop web app. Slots `sidebar` (a\n`bf-sidebar`, in a column that sticks to the viewport at full height from the breakpoint up),\n`brand`, `menu` (the narrow-screen `bf-menu-button`) and `actions` (the header bar shown below\nthe breakpoint) and the default slot (the page, in the `main` landmark). `breakpoint`\n(`BfAppFrameBreakpoint`: `'md'` default, `'lg'`), `contentWidth` (`BfAppFrameContentWidth`:\n`'md'` 48rem, `'lg'` 64rem, `'xl'` 80rem default, `'full'`) and `mainId`. A skip link, first in\nthe tab order and hidden until focused, moves focus to the main landmark by script, so it works\nunder `<base href=\"/\">`; new label key `skipToContent` (\"Skip to main content\"). Inside another\n`main` landmark the content drops its `main` role. Desktop only: the Capacitor shell keeps\n`bf-tabs`."
630
+ }
631
+ ],
632
+ "since": "0.3.2",
633
+ "whenToUse": "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).",
634
+ "desktop": "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.",
635
+ "theming": "- Frame: `bg` background, `text` colour.\n- Narrow header bar: `surface` with a `border` line at the bottom, at least 56 px tall.\n- Skip link: `surface` background, `text`, `shadow-floating`, the `control` (pill) radius and the `ring` focus ring.\n- 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`.\n- The sidebar's own tokens are on the [sidebar](https://bitforge-ui.web.app/components/sidebar#theming) page.",
636
+ "accessibility": "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.\n\n### Keyboard\n\n| Key | Action |\n| ------------------------------------------------ | ------------------------------------------------------------------------- |\n| <kbd>Tab</kbd> | From the top of the page, focuses the skip link first and reveals it |\n| <kbd>Enter</kbd> | On the skip link, moves focus to the main content |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the sidebar or the header bar, then the page, in that order |",
637
+ "notes": "## Do\n\n- Put the frame at the root of the app, with the `router-outlet` as its content, so the sidebar stays put between pages.\n- Drive the sidebar, the narrow menu and the shell's tab bar from one array of destinations.\n- Keep one `h1` per page in the content; the frame renders no heading.\n\n## Don't\n\n- Don't use `bf-app-frame` inside the Capacitor shell; use `bf-tabs` with a `bf-tab-bar` and `bf-page` there.\n- Don't add your own skip link or hide the `bf-sidebar` with `hidden md:flex` inside the frame; the frame does both."
638
+ },
515
639
  {
516
640
  "name": "avatar",
517
641
  "selector": "bf-avatar",
@@ -1703,7 +1827,7 @@
1703
1827
  "category": "Data display",
1704
1828
  "summary": "Content surface with an optional header slot, in four visual styles from raised to flat",
1705
1829
  "description": "Surface for a piece of content: a project summary, a stat, a focus prompt. Four variants from\nthe design: raised (default), tint, ink and flat. To make the whole card clickable, put a\n`bfAction` link or button in its heading (the stretched-link pattern).",
1706
- "overview": "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.",
1830
+ "overview": "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.",
1707
1831
  "example": "<bf-card>\n <span bfSlot=\"header\" class=\"text-caption text-text-muted\">Project</span>\n <h3 class=\"text-heading\"><a bfAction routerLink=\"/projects/atlas\">Atlas rebrand</a></h3>\n <bf-progress [value]=\"68\" label=\"Progress\" />\n</bf-card>",
1708
1832
  "primaryExample": "card/overview",
1709
1833
  "examples": [
@@ -1728,6 +1852,13 @@
1728
1852
  "source": "import { Component } from '@angular/core';\nimport { BfCardComponent } from '@bitforgehq/ui';\n\n/** The tint and ink cards, and a flat card inside a raised one. */\n@Component({\n selector: 'app-example-card-variants',\n imports: [BfCardComponent],\n template: `\n <div class=\"grid gap-3 @sm:grid-cols-2\">\n <bf-card variant=\"tint\"><span class=\"text-display\">18</span></bf-card>\n <bf-card variant=\"ink\"\n ><span class=\"text-heading\">Block 45 minutes for the Atlas deck</span></bf-card\n >\n <bf-card class=\"@sm:col-span-2\">\n <bf-card variant=\"flat\">Inside a raised card</bf-card>\n </bf-card>\n </div>\n `,\n})\nexport class CardVariantsExampleComponent {}\n",
1729
1853
  "preview": "inline"
1730
1854
  },
1855
+ {
1856
+ "id": "card/nesting",
1857
+ "title": "A raised card holding a row of sunken tiles",
1858
+ "file": "projects/docs/src/app/examples/card/nesting-example.component.ts",
1859
+ "source": "import { Component } from '@angular/core';\nimport { BfCardComponent, BfStatComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { CircleCheck, Clock, ListTodo } from 'lucide';\n\n/**\n * A raised card holding a row of sunken tiles.\n *\n * One surface per level: the card is raised on the page, and the stats inside it are sunken,\n * borderless tiles (`bf-stat variant=\"tile\"`), never outlined surfaces.\n */\n@Component({\n selector: 'app-example-card-nesting',\n imports: [BfCardComponent, BfIconComponent, BfStatComponent],\n template: `\n <bf-card padding=\"lg\">\n <h3 class=\"text-heading\">This week</h3>\n <div class=\"grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3\">\n <bf-stat label=\"Open tasks\" value=\"14\" variant=\"tile\">\n <bf-icon bfSlot=\"icon\" [icon]=\"listTodo\" />\n </bf-stat>\n <bf-stat label=\"Done\" value=\"23\" variant=\"tile\">\n <bf-icon bfSlot=\"icon\" [icon]=\"circleCheck\" />\n </bf-stat>\n <bf-stat label=\"Time logged\" value=\"31 h\" variant=\"tile\">\n <bf-icon bfSlot=\"icon\" [icon]=\"clock\" />\n </bf-stat>\n </div>\n </bf-card>\n `,\n})\nexport class CardNestingExampleComponent {\n protected readonly listTodo = ListTodo;\n protected readonly circleCheck = CircleCheck;\n protected readonly clock = Clock;\n}\n",
1860
+ "preview": "inline"
1861
+ },
1731
1862
  {
1732
1863
  "id": "card/grid",
1733
1864
  "title": "A grid of cards whose descriptions wrap differently, with every footer on the bottom edge",
@@ -1752,6 +1883,10 @@
1752
1883
  "heading": "Variants",
1753
1884
  "body": "<!-- example: card/variants -->"
1754
1885
  },
1886
+ {
1887
+ "heading": "Nesting surfaces",
1888
+ "body": "Each level of a page takes one surface style, and a level inside a card steps down, never up:\n\n- **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.\n- **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.\n- **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.\n\n<!-- example: card/nesting -->"
1889
+ },
1755
1890
  {
1756
1891
  "heading": "Footer",
1757
1892
  "body": "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.\n\n<!-- example: card/grid -->"
@@ -1817,6 +1952,10 @@
1817
1952
  ],
1818
1953
  "methods": [],
1819
1954
  "changelog": [
1955
+ {
1956
+ "version": "0.3.2",
1957
+ "text": "New \"Nesting surfaces\" docs section: a raised card holds sunken tiles, never an outlined\nsurface (`flat`, a `card` stat), and each level of a dashboard keeps one surface style. New\n`card/nesting` example. No code change."
1958
+ },
1820
1959
  {
1821
1960
  "version": "0.3.1",
1822
1961
  "text": "The footer (`bfSlot=\"footer\"`) sits on the card's bottom edge (`mt-auto`), so in a grid of cards\nwhose descriptions wrap differently every footer lines up. A card whose footer already followed\nthe body directly looks the same unless the card is taller than its content."
@@ -1839,7 +1978,7 @@
1839
1978
  "desktop": "A card holding a `bfAction` shows the pointer and lifts to `shadow-floating` while the pointer is anywhere over it; hover styles apply only on devices that can hover, so a tap never leaves one stuck. The ring is drawn around the whole card while its link or button has keyboard focus, not after a click. A link card keeps the browser's link behaviour: Ctrl/Cmd-click and a middle click open it in a new tab, and the status bar shows its URL on hover. Text under the stretched link cannot be selected with the mouse; give text that must stay copyable `class=\"relative z-1\"`.",
1840
1979
  "theming": "Consumes tokens by variant, background then text, plus elevation:\n\n- `raised` (default): `surface` / `text`, `shadow-raised`\n- `tint`: `primary-soft` / `text`\n- `ink`: `text` / `bg`\n- `flat`: `surface` / `text`, `border-strong` (≥ 3:1, so the edge reads without a shadow)\n\nPadding comes from the spacing tokens: `md` is `--spacing-card` (18 px, `p-card`) and `lg` is `--spacing-card-lg` (24 px, `p-card-lg`), so an app can retune both in its `@theme`.\n\nA card holding its own `bfAction` element (or the deprecated `interactive` card) shows a pointer cursor, lifts to `shadow-floating` on hover, drops back to `shadow-raised` while its action is pressed and draws a focus ring on the `ring` token while its action has keyboard focus.\n\nContent is aligned to the inline start (`text-start`), so it follows `dir=\"rtl\"`.",
1841
1980
  "accessibility": "The card renders a plain `<div>` with no role. A clickable card follows the stretched-link pattern: the only interactive element is the `bfAction` link or button in its heading, so the heading stays in the screen reader's heading list, the element's accessible name is the title alone, a link keeps link semantics ([WAI-ARIA APG link pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/): Enter, a new tab on Ctrl/Cmd-click) and a button keeps button semantics ([APG button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/): Enter and Space). Tab reaches the action once per card, then any raised secondary control (a link action: in Safari and iOS WKWebView, Tab reaches links only when the user turns on \"Press Tab to highlight each item\" (Option+Tab otherwise); VoiceOver and Full Keyboard Access are unaffected). Under forced colours (Windows High Contrast), where the card's ring is dropped, a system-coloured outline around the whole card marks the focused action. The `::after` extension is invisible and adds nothing to the accessibility tree. The deprecated `interactive` card renders a native `<button>` around all content instead, which hides headings and must not hold other controls.\n\n### Keyboard\n\n| Key | Action |\n| ---------------- | ------------------------------------------------------------------------------------ |\n| <kbd>Tab</kbd> | Moves to the card's `bfAction` link or button, then to each raised secondary control |\n| <kbd>Enter</kbd> | Follows a `bfAction` link, or activates a `bfAction` button |\n| <kbd>Space</kbd> | Activates a `bfAction` button; a link ignores it |",
1842
- "notes": "## Do\n\n- One idea per card; use `bf-list` for repeated rows (`[inset]=\"false\" divided class=\"-mx-card\"` runs them edge to edge on the card).\n- Use `lg` padding for cards that hold a heading and several blocks.\n- Put a card's actions and status in one `bfSlot=\"footer\"` element, so they line up across a grid.\n- Make a card clickable with one `bfAction` link or button in its heading; give other controls in it `class=\"relative z-1\"`.\n\n## Don't\n\n- Don't nest raised cards; use `flat` inside.\n- Don't use `interactive` (deprecated) or wrap a card in an `<a>`; both hide the heading and nest interactive content.\n- Don't put more than one `bfAction` in a card.\n- Don't put a `text` button on an ink card — its primary colour fails contrast on ink; use `tonal` or `outline`."
1981
+ "notes": "## Do\n\n- One idea per card; use `bf-list` for repeated rows (`[inset]=\"false\" divided class=\"-mx-card\"` runs them edge to edge on the card).\n- Use `lg` padding for cards that hold a heading and several blocks.\n- Put a card's actions and status in one `bfSlot=\"footer\"` element, so they line up across a grid.\n- Make a card clickable with one `bfAction` link or button in its heading; give other controls in it `class=\"relative z-1\"`.\n\n## Don't\n\n- 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.\n- Don't mix surface styles on one level of a dashboard; keep one per level.\n- Don't use `interactive` (deprecated) or wrap a card in an `<a>`; both hide the heading and nest interactive content.\n- Don't put more than one `bfAction` in a card.\n- Don't put a `text` button on an ink card — its primary colour fails contrast on ink; use `tonal` or `outline`."
1843
1982
  },
1844
1983
  {
1845
1984
  "name": "chart-card",
@@ -4280,6 +4419,20 @@
4280
4419
  "file": "projects/docs/src/app/examples/empty-state/action-example.component.ts",
4281
4420
  "source": "import { Component, signal } from '@angular/core';\nimport { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { ListTodo } from 'lucide';\n\n/** An empty state with an icon and an action. */\n@Component({\n selector: 'app-example-empty-state-action',\n imports: [BfButtonComponent, BfEmptyStateComponent, BfIconComponent],\n template: `\n <bf-empty-state\n title=\"No tasks yet\"\n [description]=\"created() ? 'Task created.' : 'Add your first task to get started.'\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"listTodo\" size=\"lg\" />\n <bf-button bfSlot=\"actions\" (clicked)=\"created.set(true)\">New task</bf-button>\n </bf-empty-state>\n `,\n})\nexport class EmptyStateActionExampleComponent {\n protected readonly listTodo = ListTodo;\n protected readonly created = signal(false);\n}\n",
4282
4421
  "preview": "inline"
4422
+ },
4423
+ {
4424
+ "id": "empty-state/page",
4425
+ "title": "An empty state as a whole page",
4426
+ "file": "projects/docs/src/app/examples/empty-state/page-example.component.ts",
4427
+ "source": "import { Component } from '@angular/core';\nimport { RouterLink } from '@angular/router';\nimport { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { FileQuestionMark } from 'lucide';\n\n/**\n * An empty state as a whole page.\n *\n * Centred in a tall content area, with a primary action and a secondary link. This preview sits\n * inside a docs page that has its own `h1`, so it uses `headingLevel` 2; on a real not-found page\n * the empty state is the page, so set it to 1 (see the next example).\n */\n@Component({\n selector: 'app-example-empty-state-page',\n imports: [RouterLink, BfButtonComponent, BfEmptyStateComponent, BfIconComponent],\n template: `\n <div class=\"flex min-h-96 flex-col justify-center\">\n <bf-empty-state\n title=\"Page not found\"\n description=\"The page may have moved, or the link has a typo.\"\n [headingLevel]=\"2\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"fileQuestion\" size=\"lg\" />\n <a bfSlot=\"actions\" bfButton routerLink=\"/\">Go to the home page</a>\n <a bfSlot=\"actions\" bfButton variant=\"text\" href=\"mailto:support@example.com\">\n Contact support\n </a>\n </bf-empty-state>\n </div>\n `,\n})\nexport class EmptyStatePageExampleComponent {\n protected readonly fileQuestion = FileQuestionMark;\n}\n",
4428
+ "preview": "inline"
4429
+ },
4430
+ {
4431
+ "id": "empty-state/not-found-page",
4432
+ "title": "A not-found page whose empty state is the page heading",
4433
+ "file": "projects/docs/src/app/examples/empty-state/not-found-page-example.component.ts",
4434
+ "source": "import { Component } from '@angular/core';\nimport { RouterLink } from '@angular/router';\nimport { BfButtonComponent, BfEmptyStateComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { FileQuestionMark } from 'lucide';\n\n/**\n * A not-found page whose empty state is the page heading.\n *\n * The route's whole content: `headingLevel` 1 renders the title as the page's `h1`, and the\n * `min-h-[60vh]` area centres the empty state vertically in the content column.\n *\n * @preview none\n */\n@Component({\n selector: 'app-example-empty-state-not-found-page',\n imports: [RouterLink, BfButtonComponent, BfEmptyStateComponent, BfIconComponent],\n template: `\n <div class=\"flex min-h-[60vh] flex-col justify-center\">\n <bf-empty-state\n title=\"Page not found\"\n description=\"The page may have moved, or the link has a typo.\"\n [headingLevel]=\"1\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"fileQuestion\" size=\"lg\" />\n <a bfSlot=\"actions\" bfButton routerLink=\"/\">Go to the home page</a>\n <a bfSlot=\"actions\" bfButton variant=\"text\" href=\"mailto:support@example.com\">\n Contact support\n </a>\n </bf-empty-state>\n </div>\n `,\n})\nexport class EmptyStateNotFoundPageExampleComponent {\n protected readonly fileQuestion = FileQuestionMark;\n}\n",
4435
+ "preview": "none"
4283
4436
  }
4284
4437
  ],
4285
4438
  "usage": [
@@ -4290,6 +4443,10 @@
4290
4443
  {
4291
4444
  "heading": "With an action",
4292
4445
  "body": "Put the icon in the `icon` slot (a `bf-icon` or any glyph) and the primary action in the `actions` slot; the empty state centers both for you.\n\n<!-- example: empty-state/action -->\n\nSet `headingLevel` to `3` when the empty state sits inside a section that already opened with an `h2`, so the page outline stays in order."
4446
+ },
4447
+ {
4448
+ "heading": "As the page",
4449
+ "body": "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\").\n\nThe 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.\n\n<!-- example: empty-state/page -->\n\n<!-- example: empty-state/not-found-page -->"
4293
4450
  }
4294
4451
  ],
4295
4452
  "inputs": [
@@ -4311,11 +4468,11 @@
4311
4468
  {
4312
4469
  "name": "headingLevel",
4313
4470
  "kind": "input",
4314
- "type": "BfEmptyStateHeadingLevel",
4471
+ "type": "BfEmptyStateHeading",
4315
4472
  "required": false,
4316
- "doc": "Heading level of the title so the empty state fits the page outline.",
4473
+ "doc": "Heading level of the title so the empty state fits the page outline; 1 when it is the page.",
4317
4474
  "default": "2",
4318
- "expandedType": "2 | 3"
4475
+ "expandedType": "2 | 3 | 1"
4319
4476
  }
4320
4477
  ],
4321
4478
  "outputs": [],
@@ -4332,6 +4489,10 @@
4332
4489
  ],
4333
4490
  "methods": [],
4334
4491
  "changelog": [
4492
+ {
4493
+ "version": "0.3.2",
4494
+ "text": "`headingLevel` now takes the new named type `BfEmptyStateHeading` (`BfEmptyStateHeadingLevel |\n1`): `1` renders the title as the page's `h1`, for an empty state that is the page (a not-found\nor no-access page). The released `BfEmptyStateHeadingLevel` keeps its two members, so `Record`s\nover it keep compiling. New \"As the page\" docs section with the `empty-state/page` example\n(centred in a tall area, a primary action and a secondary link) and the code-only\n`empty-state/not-found-page`."
4495
+ },
4335
4496
  {
4336
4497
  "version": "0.3.0",
4337
4498
  "text": "New exported `BfEmptyStateHeadingLevel` (`2 | 3`), the type of `headingLevel` (was an inline union\nwith the same values).\n\nJSDoc of `empty-state.component.ts` for the API reference: `@slot` docs for `icon`, `actions`. No\nbehaviour change."
@@ -4344,8 +4505,8 @@
4344
4505
  "since": "0.1.0",
4345
4506
  "whenToUse": "Use `bf-empty-state` where a list, a search result or a screen has nothing to show yet, with the one action that fills it. Use [bf-spinner](https://bitforge-ui.web.app/components/spinner) instead while the content is still loading, and [bf-alert](https://bitforge-ui.web.app/components/alert) when loading failed.",
4346
4507
  "theming": "- Icon tile: `surface-sunken` background, `text-muted` icon color, `tile` radius.\n- Title: `text`. Description: `text-muted`.",
4347
- "accessibility": "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.",
4348
- "notes": "## Do\n\n- Keep `description` to one sentence that says what to do next.\n- Offer the action that resolves the empty state, not a link elsewhere.\n\n## Don't\n\n- Don't use `bf-empty-state` for a loading screen; that is `bf-spinner`."
4508
+ "accessibility": "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.",
4509
+ "notes": "## Do\n\n- Keep `description` to one sentence that says what to do next.\n- Offer the action that resolves the empty state, not a link elsewhere.\n\n## Don't\n\n- Don't set `headingLevel` to `1` on an empty state inside a page that already has its own `h1`.\n- Don't use `bf-empty-state` for a loading screen; that is `bf-spinner`."
4349
4510
  },
4350
4511
  {
4351
4512
  "name": "field",
@@ -4668,6 +4829,14 @@
4668
4829
  ],
4669
4830
  "methods": [],
4670
4831
  "changelog": [
4832
+ {
4833
+ "version": "0.3.3",
4834
+ "text": "Fix: `bf-header` measures its title and slots in the next animation frame after a resize, not\ninside the `ResizeObserver` callback. Its measurement writes the title inset and height, which\nresize the observed elements, and WebKit then reported \"ResizeObserver loop completed with\nundelivered notifications\": an error event that an app's global error listener\n(`provideBrowserGlobalErrorListeners()`) logs as an error. Several resizes in one frame now\nmeasure once."
4835
+ },
4836
+ {
4837
+ "version": "0.3.2",
4838
+ "text": "The `## Desktop` docs say `bf-app-frame` supplies the app-level header bar below its breakpoint."
4839
+ },
4671
4840
  {
4672
4841
  "version": "0.3.1",
4673
4842
  "text": "The `## Desktop` docs and When to use name the desktop replacement, `bf-page-header` (with\n`bf-breadcrumb` and `bf-toolbar`), with an example. No code change."
@@ -4683,7 +4852,7 @@
4683
4852
  ],
4684
4853
  "since": "0.2.0",
4685
4854
  "whenToUse": "Use `bf-header` at the top of every `bf-page` in the Capacitor shell: the bar with its start and end actions and the collapsing large title. Use [bf-page-header](https://bitforge-ui.web.app/components/page-header) instead on a desktop web page, and [bf-back-button](https://bitforge-ui.web.app/components/back-button) in its `start` slot on a pushed page.",
4686
- "desktop": "`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`.\n\n<!-- example: header/desktop-header -->\n\nUnder the shell, a browser window shorter than 500 px gets the compact bar (no large title), as a phone in landscape does.",
4855
+ "desktop": "`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`.\n\n<!-- example: header/desktop-header -->\n\nUnder the shell, a browser window shorter than 500 px gets the compact bar (no large title), as a phone in landscape does.",
4687
4856
  "theming": "- Background: `bg` at rest in both modes; scrolled under, `bg/85` with `backdrop-blur-xl` (iOS) or\n `surface-muted` (Material), on the host.\n- Hairline: iOS only, 1 px `border` under the whole header while scrolled under.\n- Type: large title `text-display`, capped at 80/88 px; bar title `text-heading` (semibold in iOS mode, regular in\n Material mode); the `subtitle` eyebrow `text-caption`.\n- Text: large title and bar title in `text`; the `subtitle` eyebrow in `text-muted`.\n- Spacing: the large title block is inset by the 16 px screen gutter (`px-gutter`), so it lines up\n with page content padded the same way. The toolbar is padded by the top safe area\n (`pt-safe-top`) while the page's `BfPageContext.safeAreaTop` is true; a page-sheet `bf-modal`\n sets it to false, since its card already sits below the status bar, so the header adds no\n second status-bar band there.\n- Variables it writes: `--bf-header-progress` (0…1), `--bf-header-title-inset` (px),\n `--bf-header-title-height` (px) and, in iOS mode, `--bf-header-start-max` (px) on its host, `--bf-header-collapse` (px) on the page's scroller.\n- Attributes it writes on its host: `data-bf-mode` (`ios` or `md`) and `data-bf-scrolled` (while\n content is under the bar), usable for app-level styling.",
4688
4857
  "accessibility": "Exactly one `h1` is ever rendered — the large title's when `largeTitle` is true, the toolbar's\notherwise — and it carries `data-bf-page-title`, the element `bf-stack-outlet` focuses after a\npush so a screen reader announces where navigation landed. With `largeTitle`, the toolbar shows a\nsecond, `aria-hidden=\"true\"` mirror of the title purely for the visual collapse; it carries no\nsemantics of its own. `prefers-reduced-motion` drops the large title's upward translate, the Material tint's colour\ntransition and makes the scroll-end snap instant; the fade and the height collapse follow the\nscroll position directly (direct manipulation, not an animation). The centred iOS title box is\n`pointer-events-none` so it never covers a slot; custom content projected into the toolbar `h1`\nstays interactive. At the largest text sizes nothing in the bar clips (WCAG 1.4.4): the bar grows,\nthe title wraps and the back text gives way before any action.\n\n### Keyboard\n\nThe header has no keys of its own: its slots set the order in which Tab reaches the page actions.\nThe title is not in the tab order; after a push, `bf-stack-outlet` focuses it with\n`tabindex=\"-1\"`.\n\n| Key | Action |\n| ------------------------------------------------ | -------------------------------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the start slot's controls, then the end slot's, in DOM order |",
4689
4858
  "notes": "## Do\n\n- Give every page exactly one `bf-header` (or another element carrying `data-bf-page-title`) so\n push focus has somewhere to land.\n- Turn off `largeTitle` on pushed detail pages; keep it on tab roots.\n- Put `bf-back-button` in `start` on any page that isn't a tab root.\n- iOS: use text buttons for Done, Edit and Cancel and plain glyph icons otherwise, at most two in\n `end`. Material: use icon buttons only, at most three in `end`.\n\n## Don't\n\n- Don't put a text button in a Material bar or a filled icon button in an iOS bar, and don't put\n more than one control in `start`.\n- Don't put more than one `h1` in a page's header — the toolbar mirror is `aria-hidden` on\n purpose, not a second heading.\n- Don't expect the collapse to run without a `bf-content` inside the same `bf-page`; the header\n reads the scroller `bf-content` registers in `BfPageContext`."
@@ -9007,7 +9176,7 @@
9007
9176
  "file": "src/components/sidebar/sidebar.component.ts",
9008
9177
  "category": "Navigation",
9009
9178
  "summary": "Vertical app navigation with brand, grouped links, footer and an icon rail mode",
9010
- "description": "The vertical navigation of a desktop app: a `nav` landmark with a `header` slot (the brand),\nthe destinations (`a[bfNavItem]` links, optionally in `bf-nav-group`s) and a `footer` slot.\n`collapsed` turns it into an icon rail; the consumer owns the toggle button and binds\n`[(collapsed)]`. Below a breakpoint hide it (`class=\"hidden md:flex\"`) and drive a\n`bf-menu-button` from the same routes.",
9179
+ "description": "The vertical navigation of a desktop app: a `nav` landmark with a `header` slot (the brand),\nthe destinations (`a[bfNavItem]` links, optionally in `bf-nav-group`s) and a `footer` slot.\n`collapsed` turns it into an icon rail: put a `bf-sidebar-toggle` (`@bitforgehq/ui/overlay`) in\nit, or bind `[(collapsed)]` to your own button. Below a breakpoint hide it\n(`class=\"hidden md:flex\"`) and drive a `bf-menu-button` from the same routes.",
9011
9180
  "overview": "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.",
9012
9181
  "example": "<bf-sidebar label=\"Main\" [(collapsed)]=\"collapsed\" class=\"hidden md:flex\">\n <a bfSlot=\"header\" routerLink=\"/\">Atlas</a>\n <bf-nav-group label=\"Workspace\">\n <a bfNavItem routerLink=\"/inbox\" routerLinkActive ariaCurrentWhenActive=\"page\">\n <bf-icon bfSlot=\"icon\" [icon]=\"Inbox\" />\n Inbox\n </a>\n </bf-nav-group>\n</bf-sidebar>",
9013
9182
  "primaryExample": "sidebar/overview",
@@ -9019,6 +9188,20 @@
9019
9188
  "source": "import { Component, computed, signal } from '@angular/core';\nimport { RouterLink, RouterLinkActive } from '@angular/router';\nimport {\n BfIconButtonComponent,\n BfNavGroupComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n} from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport {\n BookOpen,\n Layers,\n type IconNode,\n PanelLeftClose,\n PanelLeftOpen,\n PanelTop,\n Rows3,\n Settings,\n Smartphone,\n} from 'lucide';\n\ninterface NavLink {\n readonly path: string;\n readonly label: string;\n readonly icon: IconNode;\n readonly badge?: number;\n}\n\n/**\n * The bf-sidebar overview.\n *\n * Live demo of `bf-sidebar`: a brand and a collapse toggle in the header, two `bf-nav-group`s of\n * `a[bfNavItem]` router links (this page is the current one, one item has a count badge) and a\n * footer link. The toggle binds `[(collapsed)]`; collapsed, the items show their icon only and\n * name themselves in a tooltip. The result line reports the state.\n */\n@Component({\n selector: 'app-example-sidebar-overview',\n imports: [\n RouterLink,\n RouterLinkActive,\n BfIconComponent,\n BfIconButtonComponent,\n BfNavGroupComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n ],\n template: `\n <div class=\"border-border bg-bg rounded-card flex h-96 overflow-hidden border\">\n <bf-sidebar label=\"Docs\" [(collapsed)]=\"collapsed\">\n <div bfSlot=\"header\" class=\"flex items-center justify-between gap-2\">\n @if (!collapsed()) {\n <span class=\"text-heading text-text ps-3\">Atlas</span>\n }\n <bf-icon-button\n variant=\"text\"\n [label]=\"collapsed() ? 'Expand sidebar' : 'Collapse sidebar'\"\n (clicked)=\"collapsed.set(!collapsed())\"\n >\n <bf-icon [icon]=\"collapsed() ? panelOpen : panelClose\" />\n </bf-icon-button>\n </div>\n <bf-nav-group label=\"Desktop\">\n @for (link of desktop; track link.path) {\n <a\n bfNavItem\n [routerLink]=\"link.path\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n [badge]=\"link.badge ?? null\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"link.icon\" />\n {{ link.label }}\n </a>\n }\n </bf-nav-group>\n <bf-nav-group label=\"Shell\">\n @for (link of shell; track link.path) {\n <a\n bfNavItem\n [routerLink]=\"link.path\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n [badge]=\"link.badge ?? null\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"link.icon\" />\n {{ link.label }}\n </a>\n }\n </bf-nav-group>\n <a\n bfSlot=\"footer\"\n bfNavItem\n routerLink=\"/theming\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"settings\" />\n Theming\n </a>\n </bf-sidebar>\n <div class=\"min-w-0 flex-1 p-4\">\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted wrap-anywhere\">\n {{ status() }}\n </p>\n </div>\n </div>\n `,\n})\nexport class SidebarOverviewExampleComponent {\n readonly collapsed = signal(false);\n readonly status = computed(() =>\n this.collapsed() ? 'Sidebar collapsed to an icon rail.' : 'Sidebar expanded.',\n );\n\n protected readonly desktop: readonly NavLink[] = [\n { path: '/components/sidebar', label: 'Sidebar', icon: PanelTop },\n { path: '/components/top-nav', label: 'Top nav', icon: Rows3 },\n { path: '/components/nav-group', label: 'Nav group', icon: Layers },\n ];\n protected readonly shell: readonly NavLink[] = [\n { path: '/components/tabs', label: 'Tabs', icon: Smartphone, badge: 3 },\n { path: '/mobile', label: 'Mobile guide', icon: BookOpen },\n ];\n protected readonly settings = Settings;\n protected readonly panelOpen = PanelLeftOpen;\n protected readonly panelClose = PanelLeftClose;\n}\n",
9020
9189
  "preview": "inline"
9021
9190
  },
9191
+ {
9192
+ "id": "sidebar/remembered",
9193
+ "title": "A sidebar that opens collapsed or expanded, as the user left it",
9194
+ "file": "projects/docs/src/app/examples/sidebar/remembered-example.component.ts",
9195
+ "source": "import { Component, inject } from '@angular/core';\nimport { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';\nimport { BfNavItemComponent, BfSidebarComponent, BfSidebarStateService } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { BfSidebarToggleComponent } from '@bitforgehq/ui/overlay';\nimport { Inbox, PanelLeftClose, PanelLeftOpen } from 'lucide';\n\n/**\n * A sidebar that opens collapsed or expanded, as the user left it.\n *\n * `[(collapsed)]` binds `BfSidebarStateService.collapsed`; with `provideBfSidebarState()` in the\n * app's providers every change is stored under `bf-sidebar-collapsed` (`localStorage` in the\n * browser) and read back at the next start.\n *\n * @preview none\n */\n@Component({\n selector: 'app-example-sidebar-remembered',\n imports: [\n RouterLink,\n RouterLinkActive,\n RouterOutlet,\n BfIconComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n BfSidebarToggleComponent,\n ],\n template: `\n <div class=\"flex min-h-dvh\">\n <bf-sidebar [(collapsed)]=\"sidebarState.collapsed\" class=\"sticky top-0 h-dvh\">\n <a bfNavItem routerLink=\"/inbox\" routerLinkActive ariaCurrentWhenActive=\"page\">\n <bf-icon bfSlot=\"icon\" [icon]=\"inbox\" />\n Inbox\n </a>\n <bf-sidebar-toggle bfSlot=\"footer\">\n <bf-icon bfSlot=\"collapse-icon\" [icon]=\"panelClose\" />\n <bf-icon bfSlot=\"expand-icon\" [icon]=\"panelOpen\" />\n </bf-sidebar-toggle>\n </bf-sidebar>\n <main class=\"min-w-0 flex-1 p-6\"><router-outlet /></main>\n </div>\n `,\n})\nexport class SidebarRememberedExampleComponent {\n // app.config.ts: providers: [provideBfSidebarState()]\n protected readonly sidebarState = inject(BfSidebarStateService);\n protected readonly inbox = Inbox;\n protected readonly panelOpen = PanelLeftOpen;\n protected readonly panelClose = PanelLeftClose;\n}\n",
9196
+ "preview": "none"
9197
+ },
9198
+ {
9199
+ "id": "sidebar/footer",
9200
+ "title": "A sidebar footer: settings, theme, toggle and version",
9201
+ "file": "projects/docs/src/app/examples/sidebar/footer-example.component.ts",
9202
+ "source": "import { Component, computed, signal } from '@angular/core';\nimport { RouterLink, RouterLinkActive } from '@angular/router';\nimport { type BfColorScheme, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { BfSidebarToggleComponent, BfThemeMenuButtonComponent } from '@bitforgehq/ui/overlay';\nimport {\n Inbox,\n type IconNode,\n Moon,\n PanelLeftClose,\n PanelLeftOpen,\n Settings,\n Sun,\n SunMoon,\n Users,\n} from 'lucide';\n\ninterface NavLink {\n readonly path: string;\n readonly label: string;\n readonly icon: IconNode;\n}\n\n/**\n * A sidebar footer: settings, theme, toggle and version.\n *\n * The footer slot holds a Settings link (`a[bfNavItem]`), a row with an icon-only\n * `bf-theme-menu-button` and the `bf-sidebar-toggle`, and a version line. Expanded, the two\n * buttons sit at either end of the row; as an icon rail the row stacks vertically and the version\n * line hides. The theme binds a local signal so the docs site keeps its theme; an app binds\n * `BfColorSchemeService.scheme`.\n */\n@Component({\n selector: 'app-example-sidebar-footer',\n imports: [\n RouterLink,\n RouterLinkActive,\n BfIconComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n BfSidebarToggleComponent,\n BfThemeMenuButtonComponent,\n ],\n template: `\n <div class=\"border-border bg-bg rounded-card flex h-[26rem] overflow-hidden border\">\n <bf-sidebar label=\"Footer demo\" [(collapsed)]=\"collapsed\">\n @for (link of links; track link.path) {\n <a bfNavItem [routerLink]=\"link.path\" routerLinkActive ariaCurrentWhenActive=\"page\">\n <bf-icon bfSlot=\"icon\" [icon]=\"link.icon\" />\n {{ link.label }}\n </a>\n }\n <a\n bfSlot=\"footer\"\n bfNavItem\n routerLink=\"/theming\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"settings\" />\n Settings\n </a>\n <div bfSlot=\"footer\" [class]=\"rowClasses()\">\n <bf-theme-menu-button iconOnly variant=\"text\" placement=\"above-start\" [(value)]=\"scheme\">\n <bf-icon bfSlot=\"icon\" [icon]=\"themeIcon()\" />\n </bf-theme-menu-button>\n <bf-sidebar-toggle>\n <bf-icon bfSlot=\"collapse-icon\" [icon]=\"panelClose\" />\n <bf-icon bfSlot=\"expand-icon\" [icon]=\"panelOpen\" />\n </bf-sidebar-toggle>\n </div>\n <p bfSlot=\"footer\" class=\"text-caption text-text-muted ps-3\" [class.hidden]=\"collapsed()\">\n Version 0.3.2\n </p>\n </bf-sidebar>\n <div class=\"min-w-0 flex-1 p-4\">\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted wrap-anywhere\">\n {{ collapsed() ? 'Icon rail' : 'Expanded' }}, theme {{ scheme() }}.\n </p>\n </div>\n </div>\n `,\n})\nexport class SidebarFooterExampleComponent {\n readonly collapsed = signal(false);\n /** Stands in for `BfColorSchemeService.scheme`. */\n readonly scheme = signal<BfColorScheme>('system');\n\n /** Expanded: one row, the buttons at either end. Collapsed: a column in the rail. */\n protected readonly rowClasses = computed(() =>\n this.collapsed() ? 'flex flex-col items-center gap-1' : 'flex items-center justify-between',\n );\n protected readonly themeIcon = computed(\n () => ({ system: SunMoon, light: Sun, dark: Moon })[this.scheme()],\n );\n protected readonly links: readonly NavLink[] = [\n { path: '/components/nav-item', label: 'Inbox', icon: Inbox },\n { path: '/components/nav-group', label: 'Team', icon: Users },\n ];\n protected readonly settings = Settings;\n protected readonly panelOpen = PanelLeftOpen;\n protected readonly panelClose = PanelLeftClose;\n}\n",
9203
+ "preview": "inline"
9204
+ },
9022
9205
  {
9023
9206
  "id": "sidebar/responsive",
9024
9207
  "title": "One route array, two surfaces",
@@ -9041,11 +9224,19 @@
9041
9224
  },
9042
9225
  {
9043
9226
  "heading": "Collapsing to an icon rail",
9044
- "body": "`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)."
9227
+ "body": "`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)."
9228
+ },
9229
+ {
9230
+ "heading": "Remember the collapsed sidebar",
9231
+ "body": "`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.\n\n<!-- example: sidebar/remembered -->"
9232
+ },
9233
+ {
9234
+ "heading": "Footer",
9235
+ "body": "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.\n\n<!-- example: sidebar/footer -->"
9045
9236
  },
9046
9237
  {
9047
9238
  "heading": "Responsive layouts",
9048
- "body": "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`.\n\n<!-- example: sidebar/responsive -->\n\nInside the Capacitor shell the same array drives the `bf-tab`s of a `bf-tab-bar` in `bf-tabs`; each destination's path is a tab id and a child route of the tabs.\n\n<!-- example: sidebar/shell -->"
9239
+ "body": "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.\n\n<!-- example: sidebar/responsive -->\n\nInside the Capacitor shell the same array drives the `bf-tab`s of a `bf-tab-bar` in `bf-tabs`; each destination's path is a tab id and a child route of the tabs.\n\n<!-- example: sidebar/shell -->"
9049
9240
  }
9050
9241
  ],
9051
9242
  "inputs": [
@@ -9065,7 +9256,7 @@
9065
9256
  "kind": "model",
9066
9257
  "type": "boolean",
9067
9258
  "required": false,
9068
- "doc": "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.",
9259
+ "doc": "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.",
9069
9260
  "default": "false",
9070
9261
  "twoWay": true
9071
9262
  }
@@ -9086,6 +9277,10 @@
9086
9277
  ],
9087
9278
  "methods": [],
9088
9279
  "changelog": [
9280
+ {
9281
+ "version": "0.3.2",
9282
+ "text": "New `BfSidebarStateService` (a `collapsed` signal) and `provideBfSidebarState()` in\n`@bitforgehq/ui`: bind the sidebar's `[(collapsed)]` to `sidebarState.collapsed` and the provider\nstores every change under `bf-sidebar-collapsed` in `BF_SIDEBAR_STATE_STORAGE` (`localStorage` in\nthe browser, memory elsewhere), read back at the next start; without the provider nothing is\nwritten, and on the server nothing is read. The sidebar's `nav` now has an id, for a toggle's\n`aria-controls`. The docs gain \"Remember the collapsed sidebar\" and a \"Footer\" composition\n(Settings link, theme menu, toggle, version line) that works expanded and as an icon rail."
9283
+ },
9089
9284
  {
9090
9285
  "version": "0.3.1",
9091
9286
  "text": "New `bf-sidebar`: the vertical navigation of a desktop app, a `nav` landmark with `header`,\ndefault and `footer` slots, named by `label` (else the new `navigation` label, \"Main\").\n`collapsed` (a model the app binds to its own toggle button) turns it into an icon rail. Below a\nbreakpoint, hide it with `hidden md:flex` and drive a `bf-menu-button` from the same routes."
@@ -9095,8 +9290,100 @@
9095
9290
  "whenToUse": "Use `bf-sidebar` for the top-level destinations of a desktop web app with more than four or five sections, or with sections that fall into groups. Use [bf-top-nav](https://bitforge-ui.web.app/components/top-nav) instead for an app with a handful of sections, and [bf-segmented](https://bitforge-ui.web.app/components/segmented) to switch a view in place. In the Capacitor shell use [bf-tabs](https://bitforge-ui.web.app/components/tabs) with [bf-tab-bar](https://bitforge-ui.web.app/components/tab-bar) instead.",
9096
9291
  "desktop": "The sidebar is a desktop pattern. Its items take a `surface-muted` fill and the pointer on hover, a `surface-pressed` fill while pressed and the focus ring on keyboard focus only (`focus-visible`); the current page keeps its tonal fill with its own hover and pressed steps. The destinations scroll on their own when they outgrow the height, while the header and footer stay put; a 4 px inset keeps the focus ring inside the scroll box. Item height follows `provideBfDefaults()` (`controlSize`); under a coarse pointer (a touchscreen laptop) every item is at least 44 px tall. Collapsed, the `title` tooltip names each item on hover. A link keeps the browser's behaviour: Ctrl/Cmd-click and a middle click open it in a new tab.",
9097
9292
  "theming": "- Surface: `surface` with a `border` line on its inline-end edge (`border-e`); 12 px padding and 24 px between the header, the groups and the footer.\n- Width: `w-60` (240 px) expanded, the icon rail's own width collapsed.\n- The items' and the groups' tokens are on the [nav item](https://bitforge-ui.web.app/components/nav-item#theming) and [nav group](https://bitforge-ui.web.app/components/nav-group#theming) pages.",
9098
- "accessibility": "`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.\n\n### Keyboard\n\n| Key | Action |\n| ------------------------------------------------ | -------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the header, the links and the footer |\n| <kbd>Enter</kbd> | Follows the focused link |",
9099
- "notes": "## Do\n\n- Mark the current page with `routerLinkActive` and `ariaCurrentWhenActive=\"page\"` on every link.\n- Drive the sidebar, the small-screen menu and the shell's tab bar from one array of destinations.\n\n## Don't\n\n- Don't put actions (Create, Log out) among the destinations; a sidebar is for places, so put an action in the header or footer as a button.\n- Don't use `bf-sidebar` inside the Capacitor shell; use `bf-tabs` with a `bf-tab-bar` there."
9293
+ "accessibility": "`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.\n\n### Keyboard\n\n| Key | Action |\n| ------------------------------------------------ | -------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the header, the links and the footer |\n| <kbd>Enter</kbd> | Follows the focused link |",
9294
+ "notes": "## Do\n\n- Mark the current page with `routerLinkActive` and `ariaCurrentWhenActive=\"page\"` on every link.\n- Drive the sidebar, the small-screen menu and the shell's tab bar from one array of destinations.\n- Remember the collapsed state with `BfSidebarStateService` and `provideBfSidebarState()`.\n\n## Don't\n\n- Don't put actions (Create, Log out) among the destinations; a sidebar is for places, so put an action in the header or footer as a button.\n- Don't use `bf-sidebar` inside the Capacitor shell; use `bf-tabs` with a `bf-tab-bar` there."
9295
+ },
9296
+ {
9297
+ "name": "sidebar-toggle",
9298
+ "selector": "bf-sidebar-toggle",
9299
+ "selectors": ["bf-sidebar-toggle"],
9300
+ "className": "BfSidebarToggleComponent",
9301
+ "entryPoint": "@bitforgehq/ui/overlay",
9302
+ "file": "overlay/sidebar-toggle/sidebar-toggle.component.ts",
9303
+ "category": "Navigation",
9304
+ "summary": "Icon button inside a sidebar that collapses it to an icon rail and back",
9305
+ "description": "The collapse button of a `bf-sidebar`: a text icon button placed inside the sidebar (usually in\nits header or footer) that flips the sidebar between full width and an icon rail. Its name and\ntooltip say what the next press does (\"Collapse sidebar\", \"Expand sidebar\"), `aria-expanded`\nfollows the sidebar and `aria-controls` points at its `nav`. It shows the `collapse-icon` slot\nwhile expanded and the `expand-icon` slot while collapsed. It reads and writes the sidebar's\n`collapsed` model, so `[(collapsed)]` on the sidebar sees every press. Outside a `bf-sidebar`\n(in a `bf-top-nav`, or on its own) it renders nothing.",
9306
+ "overview": "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`.",
9307
+ "example": "<bf-sidebar [(collapsed)]=\"sidebarState.collapsed\">\n <bf-sidebar-toggle bfSlot=\"footer\">\n <bf-icon bfSlot=\"collapse-icon\" [icon]=\"PanelLeftClose\" />\n <bf-icon bfSlot=\"expand-icon\" [icon]=\"PanelLeftOpen\" />\n </bf-sidebar-toggle>\n</bf-sidebar>",
9308
+ "primaryExample": "sidebar-toggle/overview",
9309
+ "examples": [
9310
+ {
9311
+ "id": "sidebar-toggle/overview",
9312
+ "title": "The bf-sidebar-toggle overview",
9313
+ "file": "projects/docs/src/app/examples/sidebar-toggle/overview-example.component.ts",
9314
+ "source": "import { Component, computed, signal } from '@angular/core';\nimport { RouterLink, RouterLinkActive } from '@angular/router';\nimport { BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { BfSidebarToggleComponent } from '@bitforgehq/ui/overlay';\nimport { type IconNode, Layers, PanelLeftClose, PanelLeftOpen, PanelTop } from 'lucide';\n\ninterface NavLink {\n readonly path: string;\n readonly label: string;\n readonly icon: IconNode;\n}\n\n/**\n * The bf-sidebar-toggle overview.\n *\n * Live demo of `bf-sidebar-toggle` in the header of a `bf-sidebar`: a press collapses the\n * sidebar to an icon rail and the next one expands it again. Its name and tooltip say what the\n * next press does, and only the matching icon slot shows. The sidebar's `[(collapsed)]` keeps\n * the state in a signal, and the result line reports it.\n */\n@Component({\n selector: 'app-example-sidebar-toggle-overview',\n imports: [\n RouterLink,\n RouterLinkActive,\n BfIconComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n BfSidebarToggleComponent,\n ],\n template: `\n <div class=\"border-border bg-bg rounded-card flex h-72 overflow-hidden border\">\n <bf-sidebar label=\"Toggle demo\" [(collapsed)]=\"collapsed\">\n <div bfSlot=\"header\" class=\"flex items-center justify-between gap-2\">\n @if (!collapsed()) {\n <span class=\"text-heading text-text ps-3\">Atlas</span>\n }\n <bf-sidebar-toggle>\n <bf-icon bfSlot=\"collapse-icon\" [icon]=\"panelClose\" />\n <bf-icon bfSlot=\"expand-icon\" [icon]=\"panelOpen\" />\n </bf-sidebar-toggle>\n </div>\n @for (link of links; track link.path) {\n <a bfNavItem [routerLink]=\"link.path\" routerLinkActive ariaCurrentWhenActive=\"page\">\n <bf-icon bfSlot=\"icon\" [icon]=\"link.icon\" />\n {{ link.label }}\n </a>\n }\n </bf-sidebar>\n <div class=\"min-w-0 flex-1 p-4\">\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted wrap-anywhere\">\n {{ status() }}\n </p>\n </div>\n </div>\n `,\n})\nexport class SidebarToggleOverviewExampleComponent {\n readonly collapsed = signal(false);\n readonly status = computed(() =>\n this.collapsed() ? 'Sidebar collapsed to an icon rail.' : 'Sidebar expanded.',\n );\n\n protected readonly links: readonly NavLink[] = [\n { path: '/components/sidebar-toggle', label: 'Sidebar toggle', icon: PanelTop },\n { path: '/components/sidebar', label: 'Sidebar', icon: Layers },\n ];\n protected readonly panelOpen = PanelLeftOpen;\n protected readonly panelClose = PanelLeftClose;\n}\n",
9315
+ "preview": "inline"
9316
+ },
9317
+ {
9318
+ "id": "sidebar/footer",
9319
+ "title": "A sidebar footer: settings, theme, toggle and version",
9320
+ "file": "projects/docs/src/app/examples/sidebar/footer-example.component.ts",
9321
+ "source": "import { Component, computed, signal } from '@angular/core';\nimport { RouterLink, RouterLinkActive } from '@angular/router';\nimport { type BfColorScheme, BfNavItemComponent, BfSidebarComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { BfSidebarToggleComponent, BfThemeMenuButtonComponent } from '@bitforgehq/ui/overlay';\nimport {\n Inbox,\n type IconNode,\n Moon,\n PanelLeftClose,\n PanelLeftOpen,\n Settings,\n Sun,\n SunMoon,\n Users,\n} from 'lucide';\n\ninterface NavLink {\n readonly path: string;\n readonly label: string;\n readonly icon: IconNode;\n}\n\n/**\n * A sidebar footer: settings, theme, toggle and version.\n *\n * The footer slot holds a Settings link (`a[bfNavItem]`), a row with an icon-only\n * `bf-theme-menu-button` and the `bf-sidebar-toggle`, and a version line. Expanded, the two\n * buttons sit at either end of the row; as an icon rail the row stacks vertically and the version\n * line hides. The theme binds a local signal so the docs site keeps its theme; an app binds\n * `BfColorSchemeService.scheme`.\n */\n@Component({\n selector: 'app-example-sidebar-footer',\n imports: [\n RouterLink,\n RouterLinkActive,\n BfIconComponent,\n BfNavItemComponent,\n BfSidebarComponent,\n BfSidebarToggleComponent,\n BfThemeMenuButtonComponent,\n ],\n template: `\n <div class=\"border-border bg-bg rounded-card flex h-[26rem] overflow-hidden border\">\n <bf-sidebar label=\"Footer demo\" [(collapsed)]=\"collapsed\">\n @for (link of links; track link.path) {\n <a bfNavItem [routerLink]=\"link.path\" routerLinkActive ariaCurrentWhenActive=\"page\">\n <bf-icon bfSlot=\"icon\" [icon]=\"link.icon\" />\n {{ link.label }}\n </a>\n }\n <a\n bfSlot=\"footer\"\n bfNavItem\n routerLink=\"/theming\"\n routerLinkActive\n ariaCurrentWhenActive=\"page\"\n >\n <bf-icon bfSlot=\"icon\" [icon]=\"settings\" />\n Settings\n </a>\n <div bfSlot=\"footer\" [class]=\"rowClasses()\">\n <bf-theme-menu-button iconOnly variant=\"text\" placement=\"above-start\" [(value)]=\"scheme\">\n <bf-icon bfSlot=\"icon\" [icon]=\"themeIcon()\" />\n </bf-theme-menu-button>\n <bf-sidebar-toggle>\n <bf-icon bfSlot=\"collapse-icon\" [icon]=\"panelClose\" />\n <bf-icon bfSlot=\"expand-icon\" [icon]=\"panelOpen\" />\n </bf-sidebar-toggle>\n </div>\n <p bfSlot=\"footer\" class=\"text-caption text-text-muted ps-3\" [class.hidden]=\"collapsed()\">\n Version 0.3.2\n </p>\n </bf-sidebar>\n <div class=\"min-w-0 flex-1 p-4\">\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted wrap-anywhere\">\n {{ collapsed() ? 'Icon rail' : 'Expanded' }}, theme {{ scheme() }}.\n </p>\n </div>\n </div>\n `,\n})\nexport class SidebarFooterExampleComponent {\n readonly collapsed = signal(false);\n /** Stands in for `BfColorSchemeService.scheme`. */\n readonly scheme = signal<BfColorScheme>('system');\n\n /** Expanded: one row, the buttons at either end. Collapsed: a column in the rail. */\n protected readonly rowClasses = computed(() =>\n this.collapsed() ? 'flex flex-col items-center gap-1' : 'flex items-center justify-between',\n );\n protected readonly themeIcon = computed(\n () => ({ system: SunMoon, light: Sun, dark: Moon })[this.scheme()],\n );\n protected readonly links: readonly NavLink[] = [\n { path: '/components/nav-item', label: 'Inbox', icon: Inbox },\n { path: '/components/nav-group', label: 'Team', icon: Users },\n ];\n protected readonly settings = Settings;\n protected readonly panelOpen = PanelLeftOpen;\n protected readonly panelClose = PanelLeftClose;\n}\n",
9322
+ "preview": "inline"
9323
+ }
9324
+ ],
9325
+ "usage": [
9326
+ {
9327
+ "heading": "Icons",
9328
+ "body": "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."
9329
+ },
9330
+ {
9331
+ "heading": "Placement",
9332
+ "body": "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.\n\n<!-- example: sidebar/footer -->"
9333
+ },
9334
+ {
9335
+ "heading": "Remembering the state",
9336
+ "body": "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))."
9337
+ },
9338
+ {
9339
+ "heading": "Sizes",
9340
+ "body": "`size` is `sm` 32, `md` 40 or `lg` 48 px, square; unset it follows `provideBfDefaults()`'s `controlSize`, as `bf-icon-button` does."
9341
+ },
9342
+ {
9343
+ "heading": "Labels",
9344
+ "body": "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()`."
9345
+ },
9346
+ {
9347
+ "heading": "Testing",
9348
+ "body": "`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'`."
9349
+ }
9350
+ ],
9351
+ "inputs": [
9352
+ {
9353
+ "name": "size",
9354
+ "kind": "input",
9355
+ "type": "BfSize | undefined",
9356
+ "required": false,
9357
+ "doc": "Height step: sm 32, md 40, lg 48 px (square); unset follows `provideBfDefaults()` (`controlSize`, `md` by default).",
9358
+ "default": "undefined",
9359
+ "expandedType": "'sm' | 'md' | 'lg' | undefined"
9360
+ }
9361
+ ],
9362
+ "outputs": [],
9363
+ "models": [],
9364
+ "slots": [
9365
+ {
9366
+ "name": "collapse-icon",
9367
+ "doc": "The icon shown while the sidebar is expanded (a panel closing)."
9368
+ },
9369
+ {
9370
+ "name": "expand-icon",
9371
+ "doc": "The icon shown while the sidebar is an icon rail (a panel opening)."
9372
+ }
9373
+ ],
9374
+ "methods": [],
9375
+ "changelog": [
9376
+ {
9377
+ "version": "0.3.2",
9378
+ "text": "New `bf-sidebar-toggle` in `@bitforgehq/ui/overlay`: a text icon button placed inside a\n`bf-sidebar` (header or footer) that flips the sidebar between full width and an icon rail by\nwriting its `collapsed` model, so `[(collapsed)]` sees every press. Its name and `bfTooltip` say\nwhat the next press does (new label keys `sidebarCollapse` \"Collapse sidebar\" and `sidebarExpand`\n\"Expand sidebar\"); `aria-expanded` follows the sidebar and `aria-controls` points at its `nav`.\nIcons are the `collapse-icon` and `expand-icon` slots, only the matching one shown; `size` as\n`bf-icon-button`. Outside a sidebar (in a `bf-top-nav`, or alone) it renders nothing. A\n`BfSidebarToggleHarness` in `@bitforgehq/ui/testing` (`getLabel()`, `isExpanded()`, `toggle()`)."
9379
+ }
9380
+ ],
9381
+ "since": "0.3.2",
9382
+ "whenToUse": "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.",
9383
+ "desktop": "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.",
9384
+ "theming": "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.",
9385
+ "accessibility": "- 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.\n- `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`.\n- The projected icons add nothing to the name (`bf-icon` is `aria-hidden`).\n- Collapsing keeps focus on the toggle, and every nav item keeps its accessible name, so nothing a screen reader user relies on moves.\n\n### Keyboard\n\n| Key | Action |\n| ----------------------------------- | ------------------------------------------------ |\n| <kbd>Enter</kbd> / <kbd>Space</kbd> | Collapses or expands the sidebar |\n| <kbd>Escape</kbd> | Hides the tooltip while it shows |\n| <kbd>Tab</kbd> | Moves on to the next control, hiding the tooltip |",
9386
+ "notes": "## Do\n\n- Put the toggle inside the `bf-sidebar` it collapses, in its header or footer.\n- Project both icon slots, a panel closing and a panel opening.\n- Bind the sidebar to `BfSidebarStateService.collapsed` with `provideBfSidebarState()`, so the choice survives a reload.\n\n## Don't\n\n- Don't put it outside a `bf-sidebar`; it renders nothing there. Use a `bf-icon-button` bound to `[(collapsed)]` instead.\n- Don't add your own `aria-label` or tooltip; the toggle names itself by what the next press does."
9100
9387
  },
9101
9388
  {
9102
9389
  "name": "skeleton",
@@ -9643,7 +9930,7 @@
9643
9930
  "summary": "One labelled figure with an optional icon tile and a supporting line",
9644
9931
  "description": "One figure with its label, such as a count, a duration or a total: a native `dl` pair with the\nvalue set large, an optional icon tile at the start and a supporting line under the value.\n\nA `delta` with a `trend` adds a pill beside the value, toned by `trendIntent`.",
9645
9932
  "overview": "A stat shows one figure with its label: a recipe's cook time, a project's open tasks, a month's revenue. The value is set large, an optional icon tile sits at the start, a supporting line under the value adds context such as a period or a badge, and an optional delta pill beside the value shows the change and its direction. `bf-stat` renders its own native `dl` with the label as the `dt` and the value as the `dd`. It works the same in both shells: in the Capacitor shell and in a desktop web app.",
9646
- "example": "<bf-stat label=\"Revenue\" value=\"€48,200\" delta=\"+12%\" trend=\"up\" />\n<bf-stat label=\"Cook time\" value=\"30 min\" variant=\"card\">\n <bf-icon bfSlot=\"icon\" [icon]=\"Flame\" />\n Oven at 200 °C\n</bf-stat>",
9933
+ "example": "<bf-stat label=\"Revenue\" value=\"€48,200\" delta=\"+12%\" trend=\"up\" />\n<bf-stat label=\"Orders\" value=\"1,204\" variant=\"tile\" />\n<bf-stat label=\"Cook time\" value=\"30 min\" variant=\"card\">\n <bf-icon bfSlot=\"icon\" [icon]=\"Flame\" />\n Oven at 200 °C\n</bf-stat>",
9647
9934
  "primaryExample": "stat/overview",
9648
9935
  "examples": [
9649
9936
  {
@@ -9653,6 +9940,13 @@
9653
9940
  "source": "import { Component } from '@angular/core';\nimport { BfStatComponent } from '@bitforgehq/ui';\nimport { BfIconComponent } from '@bitforgehq/ui/icons';\nimport { Clock, CookingPot, Timer, Users } from 'lucide';\n\n/**\n * The bf-stat overview.\n *\n * Live demo of `bf-stat`: a recipe's prep time, cook time, total time and servings as four card\n * stats, each with an icon tile, wrapping from four across to two.\n */\n@Component({\n selector: 'app-example-stat-overview',\n imports: [BfStatComponent, BfIconComponent],\n template: `\n <div class=\"grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3\">\n <bf-stat label=\"Prep time\" value=\"15 min\" variant=\"card\">\n <bf-icon bfSlot=\"icon\" [icon]=\"timer\" />\n </bf-stat>\n <bf-stat label=\"Cook time\" value=\"30 min\" variant=\"card\">\n <bf-icon bfSlot=\"icon\" [icon]=\"cookingPot\" />\n </bf-stat>\n <bf-stat label=\"Total time\" value=\"45 min\" variant=\"card\">\n <bf-icon bfSlot=\"icon\" [icon]=\"clock\" />\n </bf-stat>\n <bf-stat label=\"Servings\" value=\"4\" variant=\"card\">\n <bf-icon bfSlot=\"icon\" [icon]=\"users\" />\n </bf-stat>\n </div>\n `,\n})\nexport class StatOverviewExampleComponent {\n protected readonly timer = Timer;\n protected readonly cookingPot = CookingPot;\n protected readonly clock = Clock;\n protected readonly users = Users;\n}\n",
9654
9941
  "preview": "inline"
9655
9942
  },
9943
+ {
9944
+ "id": "stat/dashboard",
9945
+ "title": "A dashboard with one surface per level",
9946
+ "file": "projects/docs/src/app/examples/stat/dashboard-example.component.ts",
9947
+ "source": "import { Component } from '@angular/core';\nimport { BfCardComponent, BfStatComponent } from '@bitforgehq/ui';\n\n/**\n * A dashboard with one surface per level.\n *\n * The page holds raised cards, and every card holds sunken stat tiles: each level has one surface\n * style, so the eye reads the cards as the groups and the tiles as their figures.\n */\n@Component({\n selector: 'app-example-stat-dashboard',\n imports: [BfCardComponent, BfStatComponent],\n template: `\n <div class=\"grid gap-4 @lg:grid-cols-2\">\n <bf-card>\n <h3 class=\"text-heading\">Sales</h3>\n <div class=\"grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3\">\n <bf-stat label=\"Revenue\" value=\"€48,200\" variant=\"tile\" delta=\"+12%\" trend=\"up\">\n vs. last month\n </bf-stat>\n <bf-stat label=\"Orders\" value=\"1,204\" variant=\"tile\" delta=\"+3%\" trend=\"up\">\n vs. last month\n </bf-stat>\n </div>\n </bf-card>\n <bf-card>\n <h3 class=\"text-heading\">Support</h3>\n <div class=\"grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3\">\n <bf-stat\n label=\"Open tickets\"\n value=\"18\"\n variant=\"tile\"\n delta=\"-4\"\n trend=\"down\"\n trendIntent=\"negative\"\n >\n vs. last week\n </bf-stat>\n <bf-stat label=\"First reply\" value=\"2 h\" variant=\"tile\" delta=\"0\" trend=\"flat\">\n vs. last week\n </bf-stat>\n </div>\n </bf-card>\n </div>\n `,\n})\nexport class StatDashboardExampleComponent {}\n",
9948
+ "preview": "inline"
9949
+ },
9656
9950
  {
9657
9951
  "id": "stat/plain",
9658
9952
  "title": "Plain stats on a card, with a trend and a badge as their supporting lines",
@@ -9671,7 +9965,11 @@
9671
9965
  "usage": [
9672
9966
  {
9673
9967
  "heading": "Cards",
9674
- "body": "`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.\n\n<!-- example: stat/overview -->"
9968
+ "body": "`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.\n\n<!-- example: stat/overview -->"
9969
+ },
9970
+ {
9971
+ "heading": "Tiles",
9972
+ "body": "`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.\n\n<!-- example: stat/dashboard -->"
9675
9973
  },
9676
9974
  {
9677
9975
  "heading": "Plain",
@@ -9700,11 +9998,11 @@
9700
9998
  {
9701
9999
  "name": "variant",
9702
10000
  "kind": "input",
9703
- "type": "BfStatVariant",
10001
+ "type": "BfStatAppearance",
9704
10002
  "required": false,
9705
- "doc": "Surface: plain (default) on its container, or card, an outlined tile of its own.",
10003
+ "doc": "Surface: plain (default) on its container, card (an outlined tile) or tile (sunken, no border).",
9706
10004
  "default": "'plain'",
9707
- "expandedType": "'plain' | 'card'"
10005
+ "expandedType": "'plain' | 'card' | 'tile'"
9708
10006
  },
9709
10007
  {
9710
10008
  "name": "delta",
@@ -9747,6 +10045,10 @@
9747
10045
  ],
9748
10046
  "methods": [],
9749
10047
  "changelog": [
10048
+ {
10049
+ "version": "0.3.2",
10050
+ "text": "New `variant=\"tile\"`: a sunken, borderless tile (`surface-sunken`, the `card` radius and\npadding) for a row of stats inside a raised `bf-card`; on it the icon tile turns `surface`.\n`variant` now takes the new named type `BfStatAppearance` (`BfStatVariant | 'tile'`); the\nreleased `BfStatVariant` keeps its two members, so `Record`s over it keep compiling. New\n`stat/dashboard` example with one surface per level."
10051
+ },
9750
10052
  {
9751
10053
  "version": "0.3.1",
9752
10054
  "text": "New: `bf-stat`, one labelled figure in its own `dl`, with the value at the `title` size in\ntabular figures, an `icon` slot for a tile at the start and a supporting line in the default slot.\n`variant=\"card\"` gives it the outlined `flat` card surface; `plain` (default) draws none. An\nopt-in trend: `delta` (the formatted change, such as `+12%`; empty shows nothing) with `trend`\n(`up`, `down`, `flat`) renders a pill beside the value with a CSS arrow mark and the direction as a\nhidden word (new labels `statTrendUp`, `statTrendDown`, `statTrendFlat`). `trendIntent`\n(`positive` default, up is good; `negative` when down is good, such as costs) picks `success` or\n`danger`; flat is neutral. New types `BfStatVariant`, `BfStatTrend` and `BfStatTrendIntent`."
@@ -9755,9 +10057,9 @@
9755
10057
  "since": "0.3.1",
9756
10058
  "whenToUse": "Use `bf-stat` for a few headline figures the reader should take in at a glance, usually in a row or grid of them. Use [bf-description-list](https://bitforge-ui.web.app/components/description-list) instead for many details of one record, and [bf-card](https://bitforge-ui.web.app/components/card) instead when the tile holds more than one figure (a title, a progress bar, actions).",
9757
10059
  "desktop": "A stat is not interactive: no hover, no pointer cursor, no focus stop. Its size does not follow `provideBfDefaults()`; the figure keeps the `title` type on every density. Inside `provideBfShell()` (the Capacitor shell) it renders and behaves exactly the same. A grid of stats wraps by itself with `grid-cols-[repeat(auto-fit,minmax(9rem,1fr))]` on the grid that holds them, so the same row reads as four across on a desktop and two on a phone.",
9758
- "theming": "- Label (`dt`): `text-caption` in `text-muted`. Value (`dd`): `text-title` in `text`, with tabular figures (`tabular-nums`) so numbers line up across stats.\n- Supporting line: `text-caption` in `text-muted`.\n- 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.\n- Icon tile: `surface-sunken` background, `text-muted` icon, `tile` radius, 40 px.\n- `card`: `surface`, the `card` radius, `--spacing-card` padding (`p-card`) and a 1.5 px `border-strong` edge, as the `flat` `bf-card`.",
10060
+ "theming": "- Label (`dt`): `text-caption` in `text-muted`. Value (`dd`): `text-title` in `text`, with tabular figures (`tabular-nums`) so numbers line up across stats.\n- Supporting line: `text-caption` in `text-muted`.\n- 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.\n- Icon tile: `surface-sunken` background (`surface` on a `tile` stat), `text-muted` icon, `tile` radius, 40 px.\n- `card`: `surface`, the `card` radius, `--spacing-card` padding (`p-card`) and a 1.5 px `border-strong` edge, as the `flat` `bf-card`.\n- `tile`: `surface-sunken`, the `card` radius and `--spacing-card` padding, no border.",
9759
10061
  "accessibility": "`bf-stat` renders a native `dl` holding the label as the `dt`, the value as a `dd` and the supporting line as a second `dd`, so a screen reader announces the label with its value and context. The icon tile sits outside the `dl`; keep the icon decorative (a `bf-icon` without a `label` is hidden from assistive technology). The delta pill sits in the value's `dd`, so it is read with the value; its arrow mark is `aria-hidden`, and a visually hidden word (the `statTrendUp`, `statTrendDown` and `statTrendFlat` labels, \"Up\", \"Down\", \"No change\"; translate them with `provideBfLabels()`) names the direction before the delta. The pill uses the badge's tone pairs, so its text keeps the badge's contrast in both modes; in forced colours the mark still shows, as it is drawn with borders. The stat adds no other ARIA.",
9760
- "notes": "## Do\n\n- Format the value before passing it (`45 min`, `1,204`, `98%`); the stat prints it as given.\n- Keep the supporting line to a few words.\n- Set `trendIntent=\"negative\"` for figures where a fall is good news (costs, churn, errors).\n\n## Don't\n\n- Don't use a stat for a value the user edits or acts on; give it its own control.\n- Don't put long text in `value`; it is set at the `title` size for a short figure.\n- Don't put the comparison period in `delta`; keep it to the change (`+12%`) and say \"vs. last month\" in the supporting line."
10062
+ "notes": "## Do\n\n- Format the value before passing it (`45 min`, `1,204`, `98%`); the stat prints it as given.\n- Keep the supporting line to a few words.\n- Set `trendIntent=\"negative\"` for figures where a fall is good news (costs, churn, errors).\n\n## Don't\n\n- Don't put `card` stats (outlined) inside a raised `bf-card`; use `tile`.\n- Don't use a stat for a value the user edits or acts on; give it its own control.\n- Don't put long text in `value`; it is set at the `title` size for a short figure.\n- Don't put the comparison period in `delta`; keep it to the change (`+12%`) and say \"vs. last month\" in the supporting line."
9761
10063
  },
9762
10064
  {
9763
10065
  "name": "tab",
@@ -10358,7 +10660,7 @@
10358
10660
  "file": "src/components/table/table.component.ts",
10359
10661
  "category": "Data display",
10360
10662
  "summary": "Desktop data table with sortable headers, row selection, custom cells and an empty slot",
10361
- "description": "Data table for desktop: column headers that set a sort, optional row selection with\ncheckboxes, custom cells from `bfTableCell` templates and an empty slot. It never sorts or\nfilters itself; the consumer sorts the rows in a `computed()` from `sort`.",
10663
+ "description": "Data table for desktop: column headers that set a sort, optional row selection with\ncheckboxes, custom cells from `bfTableCell` templates and an empty slot. It never sorts or\nfilters itself; the consumer sorts the rows in a `computed()` from `sort`. With `stack` it\nrenders each row as a tile of label/value pairs in a narrow space or under touch.",
10362
10664
  "overview": "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.",
10363
10665
  "example": "<bf-table label=\"Tasks\" [rows]=\"sorted()\" [columns]=\"columns\" [(sort)]=\"sort\" selectable [(selection)]=\"selected\">\n <ng-template bfTableCell=\"title\" [bfTableCellOf]=\"sorted()\" let-task>{{ task.title }}</ng-template>\n <bf-empty-state bfSlot=\"empty\" title=\"No tasks\" />\n</bf-table>",
10364
10666
  "primaryExample": "table/overview",
@@ -10391,6 +10693,13 @@
10391
10693
  "source": "import { Component, computed, signal } from '@angular/core';\nimport {\n BfPaginationComponent,\n BfTableComponent,\n type BfTableColumn,\n type BfTableSort,\n} from '@bitforgehq/ui';\n\ninterface Invoice {\n id: string;\n client: string;\n amount: number;\n}\n\nconst CLIENTS = ['Atlas', 'Bloom', 'Cedar', 'Delta', 'Ember'];\nconst INVOICES: readonly Invoice[] = Array.from({ length: 23 }, (_, i) => ({\n id: `INV-${String(1001 + i)}`,\n client: CLIENTS[i % CLIENTS.length] ?? 'Atlas',\n amount: 120 + ((i * 37) % 90) * 10,\n}));\nconst PAGE_SIZE = 5;\n\n/**\n * A paged table.\n *\n * Twenty-three invoices, five per page: the table shows the current page's rows, sliced in a\n * `computed()` from the sorted rows, and a `bf-pagination` under it binds the page with `total`\n * and `pageSize`. Sorting goes back to page 1.\n */\n@Component({\n selector: 'app-example-table-paged',\n imports: [BfTableComponent, BfPaginationComponent],\n template: `\n <div class=\"flex flex-col gap-3\">\n <bf-table\n label=\"Invoices\"\n [rows]=\"pageRows()\"\n [columns]=\"columns\"\n [sort]=\"sort()\"\n (sortChange)=\"sortBy($event)\"\n />\n <bf-pagination\n class=\"self-end\"\n label=\"Invoice pages\"\n [(page)]=\"page\"\n [total]=\"invoices.length\"\n [pageSize]=\"pageSize\"\n />\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted\">{{ status() }}</p>\n </div>\n `,\n})\nexport class TablePagedExampleComponent {\n readonly columns: readonly BfTableColumn[] = [\n { key: 'id', label: 'Invoice', sortable: true },\n { key: 'client', label: 'Client' },\n { key: 'amount', label: 'Amount', sortable: true, align: 'end' },\n ];\n readonly invoices = INVOICES;\n readonly pageSize = PAGE_SIZE;\n readonly page = signal(1);\n readonly sort = signal<BfTableSort | null>(null);\n\n readonly sorted = computed(() => {\n const sort = this.sort();\n if (!sort) {\n return this.invoices;\n }\n const factor = sort.direction === 'ascending' ? 1 : -1;\n return [...this.invoices].sort((a, b) =>\n sort.column === 'amount' ? (a.amount - b.amount) * factor : a.id.localeCompare(b.id) * factor,\n );\n });\n\n /** The current page's rows: the table never sees the others. */\n readonly pageRows = computed(() => {\n const start = (this.page() - 1) * PAGE_SIZE;\n return this.sorted().slice(start, start + PAGE_SIZE);\n });\n\n readonly status = computed(() => {\n const start = (this.page() - 1) * PAGE_SIZE + 1;\n const end = Math.min(start + PAGE_SIZE - 1, this.invoices.length);\n return `Invoices ${start}–${end} of ${this.invoices.length}.`;\n });\n\n /** A new sort starts at the top of the new order: back to page 1. */\n sortBy(sort: BfTableSort | null): void {\n this.sort.set(sort);\n this.page.set(1);\n }\n}\n",
10392
10694
  "preview": "inline"
10393
10695
  },
10696
+ {
10697
+ "id": "table/stacked",
10698
+ "title": "Stacked rows on a narrow table",
10699
+ "file": "projects/docs/src/app/examples/table/stacked-example.component.ts",
10700
+ "source": "import { Component, computed, signal } from '@angular/core';\nimport {\n BfBadgeComponent,\n BfSegmentedComponent,\n BfTableCellDirective,\n BfTableComponent,\n type BfSegmentOption,\n type BfTableColumn,\n type BfTableSort,\n} from '@bitforgehq/ui';\n\ninterface Task {\n id: string;\n title: string;\n project: string;\n status: 'Open' | 'Done';\n hours: number;\n}\n\nconst TASKS: readonly Task[] = [\n { id: 'brief', title: 'Write the brief', project: 'Atlas', status: 'Done', hours: 3 },\n { id: 'moodboard', title: 'Bloom moodboard', project: 'Bloom', status: 'Open', hours: 6 },\n { id: 'deck', title: 'Pitch deck', project: 'Atlas', status: 'Open', hours: 9 },\n];\n\ntype Width = 'phone' | 'full';\n\n/**\n * Stacked rows on a narrow table.\n *\n * `stack=\"narrow\"` turns each row into a tile of label/value pairs while the table is narrower\n * than 36rem: the selection checkbox and the first cell head the tile, and a bar above the tiles\n * holds Select all and a Sort by select, whose first option, Not sorted, shows while `sort` is\n * null and clears it. Switch the box between phone width and full width.\n */\n@Component({\n selector: 'app-example-table-stacked',\n imports: [BfTableComponent, BfTableCellDirective, BfBadgeComponent, BfSegmentedComponent],\n template: `\n <!-- A stacking table is a size container: its width comes from its parent, never its columns. -->\n <div class=\"flex w-152 max-w-full flex-col gap-3\">\n <bf-segmented label=\"Box width\" [options]=\"widths\" [(value)]=\"width\" />\n <div [class]=\"width() === 'phone' ? 'w-full max-w-sm' : 'w-full'\">\n <bf-table\n label=\"Stacked tasks\"\n stack=\"narrow\"\n selectable\n [rows]=\"rows()\"\n [columns]=\"columns\"\n [(sort)]=\"sort\"\n [(selection)]=\"selection\"\n >\n <ng-template bfTableCell=\"status\" [bfTableCellOf]=\"rows()\" let-task>\n <bf-badge [variant]=\"task.status === 'Done' ? 'success' : 'neutral'\">\n {{ task.status }}\n </bf-badge>\n </ng-template>\n </bf-table>\n </div>\n <p data-status aria-live=\"polite\" class=\"text-caption text-text-muted\">{{ status() }}</p>\n </div>\n `,\n})\nexport class TableStackedExampleComponent {\n readonly widths: BfSegmentOption<Width>[] = [\n { value: 'phone', label: 'Phone' },\n { value: 'full', label: 'Full width' },\n ];\n readonly width = signal<Width | null>('phone');\n readonly columns: readonly BfTableColumn[] = [\n { key: 'title', label: 'Task', sortable: true },\n { key: 'project', label: 'Project' },\n { key: 'status', label: 'Status' },\n { key: 'hours', label: 'Hours', sortable: true, align: 'end' },\n ];\n readonly sort = signal<BfTableSort | null>(null);\n readonly selection = signal<readonly string[]>([]);\n\n /** The table never sorts: the page sorts its rows from `sort`. */\n readonly rows = computed(() => {\n const sort = this.sort();\n if (!sort) {\n return TASKS;\n }\n const factor = sort.direction === 'ascending' ? 1 : -1;\n return [...TASKS].sort((a, b) =>\n sort.column === 'hours'\n ? (a.hours - b.hours) * factor\n : a.title.localeCompare(b.title) * factor,\n );\n });\n\n readonly status = computed(() => {\n const sort = this.sort();\n const sorted = sort ? `Sorted by ${sort.column}, ${sort.direction}` : 'Unsorted';\n return `${sorted} · ${this.selection().length} selected.`;\n });\n}\n",
10701
+ "preview": "inline"
10702
+ },
10394
10703
  {
10395
10704
  "id": "table/narrow-fallback",
10396
10705
  "title": "The same rows as a list on narrow screens",
@@ -10428,9 +10737,13 @@
10428
10737
  "heading": "Density",
10429
10738
  "body": "`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."
10430
10739
  },
10740
+ {
10741
+ "heading": "Stacked rows",
10742
+ "body": "`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.\n\nStacked, 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`.\n\n<!-- example: table/stacked -->"
10743
+ },
10431
10744
  {
10432
10745
  "heading": "Narrow screens and touch",
10433
- "body": "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.\n\n<!-- example: table/narrow-fallback -->"
10746
+ "body": "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.\n\n<!-- example: table/narrow-fallback -->"
10434
10747
  }
10435
10748
  ],
10436
10749
  "inputs": [
@@ -10488,6 +10801,15 @@
10488
10801
  "doc": "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).",
10489
10802
  "default": "undefined",
10490
10803
  "expandedType": "'sm' | 'md' | 'lg' | undefined"
10804
+ },
10805
+ {
10806
+ "name": "stack",
10807
+ "kind": "input",
10808
+ "type": "BfTableStack",
10809
+ "required": false,
10810
+ "doc": "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).",
10811
+ "default": "'never'",
10812
+ "expandedType": "'never' | 'narrow' | 'coarse'"
10491
10813
  }
10492
10814
  ],
10493
10815
  "outputs": [],
@@ -10568,17 +10890,25 @@
10568
10890
  }
10569
10891
  ],
10570
10892
  "changelog": [
10893
+ {
10894
+ "version": "0.3.3",
10895
+ "text": "Fix: stacked rows (`stack=\"narrow\"` and `stack=\"coarse\"`) show their column labels again when the\npackage is installed from npm. 0.3.2 wrote the label as `content-[attr(data-label)_/_\"\"]`; the\nlibrary build escaped its quotes in the FESM (`\\\"\\\"`), so Tailwind built the rule for a class no\ncell carries and every label `::before` computed to `none`. The cells now carry\n`before:content-data-label`, a utility `theme.css` defines (below). Apps that added the\nworkaround `@source inline('@max-xl:before:content-[attr(data-label)_/_\"\"] pointer-coarse:before:content-[attr(data-label)_/_\"\"]')`\ncan remove it. consumer-smoke now opens a stacked table built from the packed package in\nChromium, under a fine and a coarse pointer, and fails if any FESM class string holds a\nbackslash escape.\n\nThe stacked Sort by select's first option is Not sorted (new label key `tableSortNone`, \"Not\nsorted\"): it shows while `sort` is null, so the select is no longer an empty box, and choosing it\nsets `sort` back to null. New `BfTableHarness.getSortSelect()` returns that select's\n`BfSelectHarness` (`null` when the table cannot stack or has no sortable column)."
10896
+ },
10897
+ {
10898
+ "version": "0.3.2",
10899
+ "text": "New opt-in `stack` input (`BfTableStack`: `'never'` by default, so existing tables do not\nchange, `'narrow'` or `'coarse'`): while the table is narrower than 36rem (a container query on\nthe host, which then gets `@container`) or, for `'coarse'`, under a coarse pointer at any width,\neach body row renders as a sunken tile of label/value pairs. The markup stays a `table` with\nexplicit `table`, `rowgroup`, `row`, `columnheader` and `cell` roles (some browsers drop a\ntable's semantics once its display changes), the header row is visually hidden but still read,\nthe selection checkbox and the first cell head each tile, and every other cell shows its column\nlabel (`data-label`, a `::before` with empty alternative text, so it is not read twice). A bar\nabove the tiles holds a labelled Select all checkbox and a Sort by `bf-select`, since the hidden\nheader's controls are not shown. New label keys `tableSortBy` (\"Sort by\"), `tableSortAscending`\nand `tableSortDescending` (`\"Hours, ascending\"`). `BfTableHarness` reads and drives a stacked\ntable the same way. New `table/stacked` example and a Stacked rows docs section."
10900
+ },
10571
10901
  {
10572
10902
  "version": "0.3.1",
10573
10903
  "text": "New `bf-table` and `ng-template[bfTableCell]` (`BfTableCellDirective`) in `@bitforgehq/ui`, a\ndata table for desktop pages: `rows`, `columns` (`BfTableColumn`: `key`, `label`, `sortable`,\n`align` of `BfTableAlign`), `rowKey` (`BfTableRowKey`, the row's `id` by default) and a\nvisually hidden caption from `label`. Sortable headers set the `sort` model (`BfTableSort`,\n`BfTableSortDirection`) and `aria-sort`; the consumer sorts the rows. `selectable` adds a\ncheckbox column bound to the `selection` model of row keys, with a mixed header checkbox named\nby the new `tableSelectAll` label (\"Select all rows\") or `selectAllLabel`. A `bfTableCell`\ntemplate replaces one column's cells (`BfTableCellContext`; `bfTableCellOf` types the row), the\n`empty` slot shows while there are no rows, `size` follows `controlSize`, and ArrowUp / ArrowDown\nmove focus between the controls of a column. New `BfTableHarness` in `@bitforgehq/ui/testing`.\n\nThe docs gain a Paging section and a `table/paged` example: one page of rows with a\n`bf-pagination` under the table, back to page 1 on a sort."
10574
10904
  }
10575
10905
  ],
10576
10906
  "since": "0.3.1",
10577
- "whenToUse": "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.",
10907
+ "whenToUse": "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.",
10578
10908
  "desktop": "A selectable table tints a row `surface-muted` while hovered (a selected row `primary-soft-hover`); a table without selection does not, because its rows are not targets. Sortable headers darken from `text-muted` to `text` on hover and show the pointer. Hover styles apply only on devices that can hover. The focus ring shows on keyboard focus only, on the header button, the checkbox or the control in a custom cell. Desktop density is the default `md` step; `sm` suits a dense admin page ([density guide](https://bitforge-ui.web.app/density)).",
10579
- "theming": "- Header: `text-label-xs`, bold, `text-muted`; a sortable header's hover `text`. A `border` line under the header row.\n- Rows: `text` on the page's surface, divided by `border` lines; hover (selectable only) `surface-muted`; selected `primary-soft`, hover `primary-soft-hover`.\n- Sort arrow: a 1.5 px CSS chevron in the header's text colour.\n- Focus ring: `ring`. Header buttons: `rounded-field`.\n- Text: `text-body` (`text-label-sm` at `sm`), `tabular-nums` in `end` columns.\n\nPut the table on a `bf-card` (or another surface) yourself; it has no background or border of its own.",
10580
- "accessibility": "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.\n\n### Keyboard\n\n| Key | Action |\n| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the header buttons, checkboxes and the controls in custom cells in document order |\n| <kbd>ArrowDown</kbd> | On a button, link or checkbox in a row (or the header): moves focus to the control at the same place in the nearest row below that has one; nothing happens at the last row |\n| <kbd>ArrowUp</kbd> | The same, upwards, up to the header row |\n| <kbd>Space</kbd> | Toggles the focused checkbox, or sorts by the focused header |\n| <kbd>Enter</kbd> | Sorts by the focused header |\n\nText fields, selects and composite widgets (radios, comboboxes, sliders) in a cell keep their own arrow keys.",
10581
- "notes": "## Do\n\n- Give every table a `label`, and every sortable column a short header.\n- Sort and filter the rows in a `computed()` from `sort` and your filters.\n- Render the rows as `bf-list-item`s below `md` and inside the Capacitor shell.\n\n## Don't\n\n- Don't put a table on a phone screen that scrolls sideways; use the list.\n- Don't make a whole row clickable; put a link or button in a custom cell."
10909
+ "theming": "- Header: `text-label-xs`, bold, `text-muted`; a sortable header's hover `text`. A `border` line under the header row.\n- Rows: `text` on the page's surface, divided by `border` lines; hover (selectable only) `surface-muted`; selected `primary-soft`, hover `primary-soft-hover`.\n- Sort arrow: a 1.5 px CSS chevron in the header's text colour.\n- Focus ring: `ring`. Header buttons: `rounded-field`.\n- Stacked rows: tiles on `surface-sunken` with `rounded-tile`, labels `text-label-xs` bold in `text-muted`.\n- Text: `text-body` (`text-label-sm` at `sm`), `tabular-nums` in `end` columns.\n\nPut the table on a `bf-card` (or another surface) yourself; it has no background or border of its own.",
10910
+ "accessibility": "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.\n\nA 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.\n\n### Keyboard\n\n| Key | Action |\n| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves through the header buttons, checkboxes and the controls in custom cells in document order |\n| <kbd>ArrowDown</kbd> | On a button, link or checkbox in a row (or the header): moves focus to the control at the same place in the nearest row below that has one; nothing happens at the last row |\n| <kbd>ArrowUp</kbd> | The same, upwards, up to the header row |\n| <kbd>Space</kbd> | Toggles the focused checkbox, or sorts by the focused header |\n| <kbd>Enter</kbd> | Sorts by the focused header |\n\nA stacked table's Sort by select is a native select with its own keys.\n\nText fields, selects and composite widgets (radios, comboboxes, sliders) in a cell keep their own arrow keys.",
10911
+ "notes": "## Do\n\n- Give every table a `label`, and every sortable column a short header.\n- Sort and filter the rows in a `computed()` from `sort` and your filters.\n- 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`.\n\n## Don't\n\n- Don't put a table on a phone screen that scrolls sideways; stack it or use the list.\n- Don't put a stacking table in a parent that sizes it from its content: the container query needs a width from outside.\n- Don't make a whole row clickable; put a link or button in a custom cell."
10582
10912
  },
10583
10913
  {
10584
10914
  "name": "tabs",
@@ -10720,6 +11050,10 @@
10720
11050
  }
10721
11051
  ],
10722
11052
  "changelog": [
11053
+ {
11054
+ "version": "0.3.2",
11055
+ "text": "The `## Desktop` docs point at `bf-app-frame` for the frame around the desktop navigation."
11056
+ },
10723
11057
  {
10724
11058
  "version": "0.3.1",
10725
11059
  "text": "The `## Desktop` docs name the desktop replacement: a `bf-top-nav` or `bf-sidebar` of\n`a[bfNavItem]` links with `routerLinkActive` and `ariaCurrentWhenActive=\"page\"`, driven by the\nsame destinations array as the shell's `bf-tab-bar`, with an example. No code change."
@@ -10735,7 +11069,7 @@
10735
11069
  ],
10736
11070
  "since": "0.2.0",
10737
11071
  "whenToUse": "Use `bf-tabs` for the tab root of a Capacitor app: one navigation stack per tab behind a floating [bf-tab-bar](https://bitforge-ui.web.app/components/tab-bar). Use [bf-stack-outlet](https://bitforge-ui.web.app/components/stack-outlet) instead for an app with one stack and no tabs, and on desktop a [bf-sidebar](https://bitforge-ui.web.app/components/sidebar) or a [bf-top-nav](https://bitforge-ui.web.app/components/top-nav) of `a[bfNavItem]` links over a plain `router-outlet`.",
10738
- "desktop": "`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.\n\n<!-- example: tabs/desktop-nav -->",
11072
+ "desktop": "`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.\n\n<!-- example: tabs/desktop-nav -->",
10739
11073
  "theming": "- `--bf-tab-bar-inset` on the `bf-tabs` host:\n `calc(<height>px + 8px + max(var(--spacing-safe-bottom), 12px) + 8px)` (the bar's measured\n height, its bottom offset of 8 px above the safe area with a 12 px floor, and 8 px of room above\n the bar: 114 px on an iPhone with a home indicator at the default text size, 104 px with Android\n gesture navigation, 92 px without an inset) while a bar is present and visible, `0px` while it is\n hidden or absent. The height is measured with a `ResizeObserver` on the projected bar's `nav`, so\n the inset follows a bar that grows with large text (a two-line label at Android font scale 2.0,\n iOS Dynamic Type) or slims down at compact heights (phone landscape); until the first\n measurement it is the bar's 64 px minimum. The observer is disconnected with the tabs. `bf-content`\n pads its scroller's bottom by it; use it for anything else that must clear the bar.\n- `--bf-tab-bar-inset-global` on `<html>`: the same value, for overlays (the toast), which render in\n the CDK overlay container outside `bf-tabs`; removed when the tabs are destroyed. Only overlays\n read it — a page pushed outside `bf-tabs` keeps no bottom padding.\n- The bar itself keeps the `bf-tab-bar` tokens (`surface`, `shadow-floating`, `primary-soft`).\n- The host is `display: block; height: 100%` around a `relative flex h-full flex-col` column; give\n the tabs page a height (it fills the outlet frame it is routed into).",
10740
11074
  "accessibility": "`bf-tabs` provides `BF_TAB_LINKS`, so its bar is a navigation landmark of links, not a `tablist`:\nthe tabs switch routes, not in-page panels, which is what the\n[WAI-ARIA APG link pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/) is for (the\n[tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) assumes tab panels in the same\npage). The bar is `nav` > a plain group > one `<a href>` per tab, pointing at the tab's root URL\n(`/tabs/<id>`, through the app's `LocationStrategy`, so the base href is included), with\n`aria-current` on the active tab: `page` while its root page is shown, `true` on a child page of it\n(the tab is then the current section, not the current page). Tab and Shift+Tab move between the\ntabs, Enter follows one, and the arrow keys do nothing; Ctrl/Cmd-click or a middle click opens the tab's root in a new\nbrowser tab. A plain click is not followed as a link: the bar turns it into a selection, so\n`bf-tabs` still brings the tab back where it was left, plays its haptic and pops to the root on\nreselect. A standalone `bf-tab-bar` outside `bf-tabs` keeps its `tablist` semantics. While the\nkeyboard hides it, the bar's `nav` is `aria-hidden=\"true\"` and `inert`, so it can be neither\nfocused nor read. Each tab's\nstack follows `bf-stack-outlet`'s rules: parked stacks are `visibility: hidden`, `inert` and\n`aria-hidden`, and after a switch focus moves to the restored page's title (`data-bf-page-title`).\n\n**Safari and iOS WKWebView (the Capacitor iOS app) do not move Tab to links by default.** With a\nhardware keyboard (an iPad with a Magic Keyboard, a Mac), Tab skips the tab bar's links unless the\nuser turns on \"Press Tab to highlight each item\" (Safari settings on macOS; on iPadOS the\nkeyboard's Full Keyboard Access); Option+Tab reaches them otherwise. VoiceOver and Full Keyboard\nAccess navigation are unaffected, and Chromium-based browsers and Android WebView tab to links as\nusual. The old `button role=\"tab\"` bar was reachable with plain Tab in WebKit; the link bar trades\nthat for real URLs, opening a tab in a new browser tab and `aria-current`.\n\nOnly the `bf-tab-bar` placed directly inside `<bf-tabs>` in the same template becomes links. An\nin-page `bf-tab-bar` inside a tab's page (a routed page) keeps its `tablist`.\n\n### Keyboard\n\nIn Safari and iOS WKWebView, Tab reaches the links only with \"Press Tab to highlight each item\" on (Option+Tab otherwise).\n\n| Key | Action |\n| ------------------------------------------------ | --------------------------------------------------------- |\n| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Moves between the tab links; a disabled tab is skipped |\n| <kbd>Enter</kbd> | Follows the focused tab's link; the arrow keys do nothing |",
10741
11075
  "notes": "## Do\n\n- Give every `bf-tab` the `id` of its route segment, and start every page of a tab with it.\n- Put `bf-content` in every tab page so it pads for the bar.\n\n## Don't\n\n- Don't bind `[(active)]` or `(changed)` on the bar inside `bf-tabs`; `bf-tabs` owns both.\n- Don't look for `role=\"tab\"` inside `bf-tabs` in tests or scripts; query the tabs as links\n (`getByRole('link', { name: 'Projects' })`) and the active one by `aria-current` (`page` at the\n tab's root, `true` below it).\n- Don't nest a second `bf-tabs` inside a tab."
@@ -12107,7 +12441,14 @@
12107
12441
  "id": "toolbar/wrapping",
12108
12442
  "title": "A toolbar that wraps",
12109
12443
  "file": "projects/docs/src/app/examples/toolbar/wrapping-example.component.ts",
12110
- "source": "import { Component } from '@angular/core';\nimport {\n BfButtonComponent,\n BfInputComponent,\n BfSelectComponent,\n BfToolbarComponent,\n type BfSelectOption,\n} from '@bitforgehq/ui';\n\n/**\n * A toolbar that wraps.\n *\n * In a narrow column the filters wrap onto new rows 8 px apart, and the actions move down\n * together, still at the inline end.\n */\n@Component({\n selector: 'app-example-toolbar-wrapping',\n imports: [BfButtonComponent, BfInputComponent, BfSelectComponent, BfToolbarComponent],\n template: `\n <div class=\"max-w-sm\">\n <bf-toolbar label=\"Invoice filters\" status=\"18 invoices\">\n <bf-input type=\"search\" ariaLabel=\"Search invoices\" placeholder=\"Search invoices\" />\n <bf-select ariaLabel=\"Customer\" [options]=\"customers\" value=\"all\" />\n <bf-select ariaLabel=\"Period\" [options]=\"periods\" value=\"month\" />\n <bf-button bfSlot=\"actions\" variant=\"outline\">Export</bf-button>\n <bf-button bfSlot=\"actions\">New invoice</bf-button>\n </bf-toolbar>\n </div>\n `,\n})\nexport class ToolbarWrappingExampleComponent {\n protected readonly customers: BfSelectOption[] = [\n { value: 'all', label: 'All customers' },\n { value: 'acme', label: 'Acme' },\n ];\n protected readonly periods: BfSelectOption[] = [\n { value: 'month', label: 'This month' },\n { value: 'year', label: 'This year' },\n ];\n}\n",
12444
+ "source": "import { Component } from '@angular/core';\nimport {\n BfButtonComponent,\n BfInputComponent,\n BfSelectComponent,\n BfToolbarComponent,\n type BfSelectOption,\n} from '@bitforgehq/ui';\n\n/**\n * A toolbar that wraps.\n *\n * In a narrow column the filters wrap onto new rows 8 px apart, and the status and the actions\n * move down together: the count at the start of the last row, the actions at the inline end.\n */\n@Component({\n selector: 'app-example-toolbar-wrapping',\n imports: [BfButtonComponent, BfInputComponent, BfSelectComponent, BfToolbarComponent],\n template: `\n <div class=\"max-w-sm\">\n <bf-toolbar label=\"Invoice filters\" status=\"18 invoices\">\n <bf-input type=\"search\" ariaLabel=\"Search invoices\" placeholder=\"Search invoices\" />\n <bf-select ariaLabel=\"Customer\" [options]=\"customers\" value=\"all\" />\n <bf-select ariaLabel=\"Period\" [options]=\"periods\" value=\"month\" />\n <bf-button bfSlot=\"actions\" variant=\"outline\">Export</bf-button>\n <bf-button bfSlot=\"actions\">New invoice</bf-button>\n </bf-toolbar>\n </div>\n `,\n})\nexport class ToolbarWrappingExampleComponent {\n protected readonly customers: BfSelectOption[] = [\n { value: 'all', label: 'All customers' },\n { value: 'acme', label: 'Acme' },\n ];\n protected readonly periods: BfSelectOption[] = [\n { value: 'month', label: 'This month' },\n { value: 'year', label: 'This year' },\n ];\n}\n",
12445
+ "preview": "inline"
12446
+ },
12447
+ {
12448
+ "id": "toolbar/narrow",
12449
+ "title": "A toolbar at phone width",
12450
+ "file": "projects/docs/src/app/examples/toolbar/narrow-example.component.ts",
12451
+ "source": "import { Component, signal } from '@angular/core';\nimport {\n BfButtonComponent,\n BfInputComponent,\n BfSelectComponent,\n BfToolbarComponent,\n type BfSelectOption,\n} from '@bitforgehq/ui';\n\n/**\n * A toolbar at phone width.\n *\n * In a phone-wide column the search fills its own row, the two filters share the next one, and\n * the status and the Refresh action share the last row: the count at the start, the action at the\n * end, never alone on a row of its own.\n */\n@Component({\n selector: 'app-example-toolbar-narrow',\n imports: [BfButtonComponent, BfInputComponent, BfSelectComponent, BfToolbarComponent],\n template: `\n <div class=\"max-w-xs\">\n <bf-toolbar label=\"Order filters\" [status]=\"count()\">\n <bf-input\n type=\"search\"\n ariaLabel=\"Search orders\"\n placeholder=\"Search orders\"\n class=\"w-full\"\n />\n <bf-select ariaLabel=\"Status\" [options]=\"statuses\" value=\"open\" />\n <bf-select ariaLabel=\"Period\" [options]=\"periods\" value=\"week\" />\n <bf-button bfSlot=\"actions\" variant=\"outline\" (clicked)=\"refresh()\">Refresh</bf-button>\n </bf-toolbar>\n </div>\n `,\n})\nexport class ToolbarNarrowExampleComponent {\n protected readonly count = signal('12 orders');\n protected readonly statuses: BfSelectOption[] = [\n { value: 'open', label: 'Open' },\n { value: 'shipped', label: 'Shipped' },\n ];\n protected readonly periods: BfSelectOption[] = [\n { value: 'week', label: 'This week' },\n { value: 'month', label: 'This month' },\n ];\n\n protected refresh(): void {\n this.count.set('12 orders, updated');\n }\n}\n",
12111
12452
  "preview": "inline"
12112
12453
  }
12113
12454
  ],
@@ -12122,7 +12463,11 @@
12122
12463
  },
12123
12464
  {
12124
12465
  "heading": "Wrapping",
12125
- "body": "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.\n\n<!-- example: toolbar/wrapping -->"
12466
+ "body": "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.\n\n<!-- example: toolbar/wrapping -->"
12467
+ },
12468
+ {
12469
+ "heading": "Phone width",
12470
+ "body": "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.\n\n<!-- example: toolbar/narrow -->"
12126
12471
  }
12127
12472
  ],
12128
12473
  "inputs": [
@@ -12156,11 +12501,15 @@
12156
12501
  },
12157
12502
  {
12158
12503
  "name": "actions",
12159
- "doc": "The trailing actions, kept together at the inline end of the row."
12504
+ "doc": "The trailing actions, kept together at the inline end of the row, beside the status."
12160
12505
  }
12161
12506
  ],
12162
12507
  "methods": [],
12163
12508
  "changelog": [
12509
+ {
12510
+ "version": "0.3.2",
12511
+ "text": "On a narrow row the actions no longer end up alone on a row of their own: the status and the\nactions are now one group that takes the rest of the last row and wraps as a whole, with the\nstatus at the start and the actions at the inline end. The wide layout is unchanged, and a\ntoolbar without actions keeps the status as a plain item of the row. New `toolbar/narrow`\nexample at phone width."
12512
+ },
12164
12513
  {
12165
12514
  "version": "0.3.1",
12166
12515
  "text": "New `bf-toolbar` in `@bitforgehq/ui`: a wrapping `role=\"group\"` row for the search field and\nfilters (default slot), a view switch (`bfSlot=\"view\"`) and the trailing actions\n(`bfSlot=\"actions\"`, kept together at the inline end). `label` names the group; `status` renders\na polite `role=\"status\"` live region for the result count. Its providers set\n`provideBfDefaults({ size: 'sm' })`, so every projected control and field is `sm` unless it sets\n`size` itself. Desktop only; in the Capacitor shell use `bf-header` actions and a `bf-sheet` of\nfilters."