@sableui/react 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/bin/sable.mjs +108 -14
  3. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  4. package/dist/blocks/index.d.ts +1 -1
  5. package/dist/components/avatar/avatar.js +16 -7
  6. package/dist/components/avatar/avatar.meta.js +43 -10
  7. package/dist/components/avatar/avatar.variants.js +26 -2
  8. package/dist/components/collapsible/collapsible.js +6 -5
  9. package/dist/components/collapsible/collapsible.meta.js +14 -5
  10. package/dist/components/data-table/data-table.js +132 -45
  11. package/dist/components/data-table/data-table.meta.js +63 -4
  12. package/dist/components/date-picker/date-picker.js +13 -6
  13. package/dist/components/date-picker/date-picker.meta.js +22 -0
  14. package/dist/components/menu/menu.js +2 -0
  15. package/dist/components/menu/menu.meta.js +2 -2
  16. package/dist/components/panel-group/panel-group.js +17 -1
  17. package/dist/components/panel-group/panel-group.meta.js +43 -3
  18. package/dist/components/side-nav/side-nav.variants.js +8 -3
  19. package/dist/components/sidebar/index.js +4 -0
  20. package/dist/components/sidebar/sidebar.js +90 -11
  21. package/dist/components/sidebar/sidebar.meta.js +38 -4
  22. package/dist/components/table/table.js +7 -3
  23. package/dist/components/tabs/tabs.js +10 -2
  24. package/dist/components/tabs/tabs.meta.js +4 -0
  25. package/dist/components/workspace/workspace.js +13 -3
  26. package/dist/components/workspace/workspace.meta.js +109 -1
  27. package/dist/file-hashes.json +53 -49
  28. package/dist/foundations/typography.js +13 -0
  29. package/dist/icon/Icon.js +21 -2
  30. package/dist/icon/index.d.ts +36 -1
  31. package/dist/icon/index.js +5 -0
  32. package/dist/icon/names.js +240 -0
  33. package/dist/icon-sets/reicon.js +19 -1
  34. package/dist/index.d.ts +272 -50
  35. package/dist/index.js +11 -1
  36. package/dist/lib/chart/heat.js +1 -1
  37. package/dist/lib/cn.js +1 -0
  38. package/dist/lib/use-media-query.js +41 -0
  39. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  40. package/dist/patterns/dashboard-page.js +23 -17
  41. package/dist/patterns/index.d.ts +7 -2
  42. package/dist/patterns/index.js +5 -1
  43. package/dist/patterns/tracker-page.js +392 -0
  44. package/dist/patterns/tracker-page.meta.js +40 -0
  45. package/dist/presets/index.d.ts +1 -1
  46. package/dist/styles.css +179 -74
  47. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  48. package/dist/themes/_contract.js +9 -1
  49. package/dist/themes/atelier/index.d.ts +1 -1
  50. package/dist/themes/ember/index.d.ts +1 -1
  51. package/dist/themes/index.d.ts +2 -2
  52. package/dist/themes/meridian/index.d.ts +1 -1
  53. package/dist/themes/poppy/index.d.ts +1 -1
  54. package/dist/themes/prism/index.d.ts +1 -1
  55. package/dist/themes/sable/index.d.ts +1 -1
  56. package/dist/themes/verdant/index.d.ts +1 -1
  57. package/llms/components/avatar.md +32 -9
  58. package/llms/components/collapsible.md +12 -4
  59. package/llms/components/data-table.md +40 -3
  60. package/llms/components/date-picker.md +15 -1
  61. package/llms/components/menu.md +1 -1
  62. package/llms/components/panel-group.md +34 -2
  63. package/llms/components/sidebar.md +17 -4
  64. package/llms/components/tabs.md +1 -0
  65. package/llms/components/workspace.md +81 -1
  66. package/llms/foundations/typography.md +4 -1
  67. package/llms/patterns/dashboard-page.md +18 -15
  68. package/llms/patterns/tracker-page.md +450 -0
  69. package/llms/reference/token-intent.md +3 -1
  70. package/llms/reference/utilities.md +4 -1
  71. package/llms/tasks/auditing-an-app.md +4 -1
  72. package/llms/tasks/building-a-page.md +3 -2
  73. package/llms/tasks/building-a-tool.md +101 -5
  74. package/llms/tasks/building-with-sable.md +11 -3
  75. package/llms/tasks/creating-a-preset.md +9 -0
  76. package/llms-full.txt +843 -54
  77. package/llms.txt +8 -7
  78. package/package.json +2 -1
  79. package/registry.json +491 -36
  80. package/scripts/adoption.mjs +15 -2
  81. package/scripts/app-css.mjs +129 -0
  82. package/scripts/grade.mjs +161 -2
  83. package/scripts/lint-rules.mjs +126 -0
  84. package/skills/sable/SKILL.md +33 -4
package/llms-full.txt CHANGED
@@ -436,7 +436,10 @@ keyboard/focus behavior, semantic token roles and motion policy. Explain which
436
436
  behavior a replacement gains and what must remain. An unused page pattern is not
437
437
  drift; repeated overrides can indicate a missing system hook rather than app misuse.
438
438
  Run relevant `sable doctor`, `sable lint <paths> --json` and `sable grade <paths>
439
- --json` checks. Lint is lexical; source grades and pattern similarity do not judge
439
+ --json` checks. Grade's `rebuiltParts` lists elements whose class names stand in
440
+ for a part (`.crumb` for Breadcrumbs, `.property` for PropertyRow) and app CSS
441
+ that re-lays a component from outside; `modes` lists colours set for light with no
442
+ dark value, and whether anything lets a user switch modes. Report each. Lint is lexical; source grades and pattern similarity do not judge
440
443
  taste. Deliberately static motion slots remain valid with appropriate feedback.
441
444
  Lint's `label-contrast` measures an app's overrides of the brand fills (`--primary`,
442
445
  `--sidebar-primary`, `--highlight`) and status fills (`--destructive`, `--success`,
@@ -806,8 +809,9 @@ For the full screen (header, grouping, avatars, answer actions, the empty state)
806
809
 
807
810
  Marketing, docs, settings, a feed, a dashboard — anything whose primary
808
811
  interaction is reading and navigating. This is the default register; if you are
809
- building an editor or a canvas app, see
810
- [building-a-tool.md](./building-a-tool.md).
812
+ building an editor, a canvas app, or a records workspace where people work
813
+ through items with the selected one open beside the list (an issue tracker, a
814
+ support queue), see [building-a-tool.md](./building-a-tool.md).
811
815
 
812
816
  ## Start from the brief, then choose composition
813
817
 
@@ -987,12 +991,16 @@ source checks alone says nothing about hierarchy, story, or the resulting polish
987
991
 
988
992
  # Task: building a tool (workspace register)
989
993
 
990
- An editor, a canvas app, a console — anything whose primary interaction is
991
- *operating on something* rather than reading and navigating.
994
+ An editor, a canvas app, a console, or a records workspace (an issue tracker, a
995
+ support queue, a pipeline): anything whose primary interaction is *operating on
996
+ something* rather than reading and navigating.
992
997
 
993
998
  If the primary interaction is reading down a page, stop: you want
994
999
  [building-a-page.md](./building-a-page.md). A dashboard that scrolls is a page
995
- with dense content, not a workspace.
1000
+ with dense content, not a workspace. A tracker or inbox where people work
1001
+ through records one at a time, with the selected one open beside the list, is a
1002
+ workspace. Start that one from the `TrackerPage` pattern
1003
+ (`sable pattern TrackerPage --skeleton`) and read "A records workspace" below.
996
1004
 
997
1005
  ## The shell
998
1006
 
@@ -1016,12 +1024,88 @@ hierarchy do not automatically transfer to dense operating surfaces.
1016
1024
  </Workspace>
1017
1025
  ```
1018
1026
 
1027
+ Every slot is optional; the body is always the one growing row. A bar that
1028
+ belongs to one column (a list's breadcrumbs and view switch, with the inspector
1029
+ running the full height beside it, as in Linear) is a `WorkspaceBar` placed first
1030
+ inside that column, not the `toolbar` slot.
1031
+
1019
1032
  `Workspace` sets the compact density scope for its whole subtree. Nothing
1020
1033
  hardcodes a height: every control reads `--control-*`, so the same
1021
1034
  `<Button size="md">` is 40px on a page and 32px here. A `Dialog` opened from
1022
1035
  inside resets to comfortable, because a modal is a page-register surface even
1023
1036
  when a tool launched it.
1024
1037
 
1038
+ ## A records workspace
1039
+
1040
+ The shape of an issue tracker, a support queue or a CRM pipeline. Every piece
1041
+ below exists, so the build is composition; `sable pattern TrackerPage
1042
+ --skeleton` has the whole tree.
1043
+
1044
+ ```tsx
1045
+ <Workspace statusBar={<StatusBar>…</StatusBar>} bottomNav={<BottomNav>…</BottomNav>}>
1046
+ <PanelGroup>
1047
+ <Panel label="Navigation" width={220} narrow="sheet" side="left"
1048
+ open={narrow ? navOpen : true} onOpenChange={setNavOpen} scroll={false}>
1049
+ <Sidebar variant="inline">
1050
+ <SidebarHeader><SidebarBrand mark={…} label="Studio" />{search}</SidebarHeader>
1051
+ <SidebarNav>
1052
+ <SidebarItem icon={…} label="Inbox" badge={…} />
1053
+ <SidebarGroup label="Workspace">{items}</SidebarGroup>
1054
+ </SidebarNav>
1055
+ <SidebarFooter>
1056
+ <Menu>
1057
+ <MenuTrigger render={<SidebarAccount avatar={<Avatar size="2xs" … />} label="Emma Lee" detail="Studio" />} />
1058
+ <MenuContent side="top" align="start">{settings, appearance, sign out}</MenuContent>
1059
+ </Menu>
1060
+ </SidebarFooter>
1061
+ </Sidebar>
1062
+ </Panel>
1063
+ <WorkspaceCanvas className="flex flex-col">
1064
+ <WorkspaceBar><Breadcrumbs>…</Breadcrumbs></WorkspaceBar>
1065
+ <WorkspaceBar><Tabs><TabsList variant="pill">…</TabsList></Tabs></WorkspaceBar>
1066
+ {filters}
1067
+ <DataTable groupBy={(r) => r.status} groups={…} isRowSelected={…} onRowClick={…} />
1068
+ </WorkspaceCanvas>
1069
+ <Panel label="Details" width={320} narrow="sheet" open={selected != null}
1070
+ onOpenChange={(o) => !o && setSelected(null)} scroll={false}>
1071
+ <PanelSection title="Properties"><PropertyRow label="Status">…</PropertyRow></PanelSection>
1072
+ </Panel>
1073
+ </PanelGroup>
1074
+ </Workspace>
1075
+ ```
1076
+
1077
+ The decisions that keep it one system:
1078
+
1079
+ - **One grid down the rail.** SidebarBrand, SidebarItem, SidebarGroup and
1080
+ SidebarAccount share the icon and label columns, and inside the compact scope
1081
+ they take 32px rows on their own. Do not drop SideNav, an Entity or a stack of
1082
+ ghost Buttons into the rail: each brings its own insets.
1083
+ - **The account is one row and a menu.** Settings, appearance (light, dark,
1084
+ system) and sign-out go in the SidebarAccount's menu, not in a Settings button
1085
+ above it or a badge beside it. Its detail line ("Studio · Demo") carries what a
1086
+ badge would.
1087
+ - **One table, grouped.** Rows grouped by state are one `DataTable` with
1088
+ `groupBy`: one header, columns that line up, a count and an add action per
1089
+ group. Not one table per group inside Collapsibles.
1090
+ - **The inspector renders once.** `Panel narrow="sheet"` docks it on a desktop
1091
+ and makes it a Sheet below `lg`. Not a media-query hook plus a second copy in a
1092
+ Sheet. The nav rail does the same from the left, and fills its sheet.
1093
+ - **Reading inside the tool is page-sized.** An overview, a description, notes or
1094
+ a docs tab goes in `<Density value="comfortable">`, or its prose renders at the
1095
+ 11px chrome tier.
1096
+ - **A view switch in chrome is a pill.** `TabsList variant="pill"`: its fill takes
1097
+ the compact `--button-radius`, about 5px, so it reads as a segment. Underline
1098
+ tabs are the page-section idiom.
1099
+ - **Ship both modes, and a way to switch.** The look is a preset with light and
1100
+ dark; SableThemeProvider applies it, and the account menu or a Settings dialog
1101
+ holds the mode switch. A tool asked to be compact and technical takes
1102
+ `type-base` (13 or 14) in the preset, which rebuilds the whole type scale,
1103
+ instead of per-tier overrides.
1104
+ - **State has a shape.** A status with more than on and off (partly done, in
1105
+ review) can be an app-owned glyph, but give it a label (`role="img"`,
1106
+ `aria-label`) and colour it from the palette (`var(--yellow-500)`) so it
1107
+ follows the mode.
1108
+
1025
1109
  ## What you will need, and what to avoid reaching for
1026
1110
 
1027
1111
  | you want | use | NOT |
@@ -1033,6 +1117,17 @@ when a tool launched it.
1033
1117
  | a colour property | `ColorField` | `<input type="color">` — OS dialog, ignores the theme, wrong size |
1034
1118
  | ambient document facts | `StatusBar` | a toolbar — status is glanced at, not operated |
1035
1119
  | chrome typography | `text-ui-md` / `-sm` / `-body` / `-section` / `-micro` / `-num` | `text-body-*` — page tiers, ~1/3 too large here |
1120
+ | a title in a panel | `text-title-lg` (14px `text-ui-title` in compact) | a heading tier, or a page-tier override on `:root` |
1121
+ | a top bar over one column | `WorkspaceBar` first in the column, with `Breadcrumbs` | a hand-built `<header>` with `/` spans |
1122
+ | nav groups in a rail | `SidebarGroup` | `SideNav` + `SideNavGroup` — another set of columns |
1123
+ | the account at the foot of a rail | `SidebarAccount` as a `MenuTrigger` | an Entity + a Settings Button + a Badge |
1124
+ | a view switch in a bar | `TabsList variant="pill"` | underline tabs (page idiom), a hand-built segmented row |
1125
+ | rows grouped by state | `DataTable groupBy` + `groups` | a DataTable per group in Collapsibles |
1126
+ | a date property | `DatePicker size="xs"` | `<input type="date">` — OS format, wrong size |
1127
+ | a person in a row | `Avatar size="2xs"` / `"xs"`, `AvatarGroup` for several | a class on Avatar to shrink it |
1128
+ | a project or workspace mark | `Avatar shape="square" tone="…"` | a tinted `<span>` |
1129
+ | an inspector on a phone | `Panel narrow="sheet"` | a media-query hook and a second render in a Sheet |
1130
+ | a reading tab | `<Density value="comfortable">` | prose in chrome tiers |
1036
1131
 
1037
1132
  ## Menus, selects and popovers adapt on their own
1038
1133
 
@@ -1091,9 +1186,12 @@ a modal is its own surface rather than part of the control that opened it.
1091
1186
  the nesting reads inverted. Use `treeIndent(depth)`; never hand-write an inset.
1092
1187
  - **No pills in chrome.** The compact scope flattens `--button-radius` to ~5px —
1093
1188
  one of two page-register rules the workspace register deliberately reverses.
1189
+ `TabsList variant="pill"` takes that radius too, so in a bar it is a segment.
1094
1190
  - **Drag with window listeners, not `setPointerCapture`.** Capture throws for an
1095
1191
  inactive pointer id and the throw aborts the handler mid-gesture, silently.
1096
- - **An unmapped `<Icon name>` renders nothing.** No error. Check the contract.
1192
+ - **An unmapped `<Icon name>` renders nothing.** A development build warns and
1193
+ names the nearest names, and Lucide or Heroicons spellings resolve, but check
1194
+ with `sable icon <name>` before you write one, through a wrapper too.
1097
1195
 
1098
1196
  ## Verify
1099
1197
 
@@ -1123,8 +1221,10 @@ and distinguish observed behavior from untested claims.
1123
1221
  ## Read next
1124
1222
 
1125
1223
  - `DESIGN.md → Registers` — doctrine, measured numbers, the two reversals
1224
+ - `sable pattern TrackerPage --skeleton` — the records workspace, whole
1126
1225
  - `llms/components/workspace.md`, `panel-group.md`, `panel-section.md`,
1127
- `tree.md`, `scrub-field.md`, `status-bar.md`, `color-field.md`
1226
+ `sidebar.md`, `data-table.md`, `tree.md`, `scrub-field.md`, `status-bar.md`,
1227
+ `color-field.md`
1128
1228
  - `llms/reference/token-intent.md` — which token to reach for
1129
1229
 
1130
1230
  ---
@@ -1290,7 +1390,13 @@ for each recognizable element with `sable search "<need>" --json`; inspect the
1290
1390
  matching `component <Name> --props` and `--examples` before implementation.
1291
1391
  Use Sable's composed anatomy and supported variants. A hand-built button, field,
1292
1392
  menu, dialog, table, or card that imitates a shipped component violates the contract.
1293
- That binds behaviour and parts, not layouts. Results marked ◇ (patterns, blocks,
1393
+ That binds behaviour and parts, not layouts. Search a part by its plain name and
1394
+ its role before writing a class or component for it (`sable search breadcrumb`,
1395
+ `sable search "top bar"`); exported parts such as `PropertyRow` and `WorkspaceBar`
1396
+ are listed by name, and `sable component <Part>` answers with their entry. When a
1397
+ part's look or size disagrees with the design, use its variant or prop, or report
1398
+ the missing variant; never rebuild it. `sable icon <name>` says whether an icon
1399
+ exists. Results marked ◇ (patterns, blocks,
1294
1400
  and compositions such as MediaTile) are references: use one when its layout fits
1295
1401
  the brief, otherwise compose your own from Sable's parts. The brief's signature
1296
1402
  surfaces, where the product's identity lives, are designed from its direction.
@@ -1309,8 +1415,10 @@ implement genuinely app-specific structure without creating parallel controls.
1309
1415
 
1310
1416
  ## Finish the working product
1311
1417
 
1312
- Run `sable doctor`, `sable lint <source paths> --json`, and the project's build/type
1313
- checks. Inspect desktop/mobile layouts, operate the primary task and recovery
1418
+ Run `sable check <source paths>` (doctor, lint and grade) and the project's
1419
+ build/type checks. Answer each Rebuilt parts note in the reuse inventory, and fix
1420
+ a colour-modes warning: app colours live in a preset with both modes, and a
1421
+ `ThemeSwitcher` under `SableThemeProvider` lets the user review each. Inspect desktop/mobile layouts, operate the primary task and recovery
1314
1422
  path, and check keyboard focus, fonts, reduced motion, and meaningful states.
1315
1423
  Use [reviewing-a-design.md](./reviewing-a-design.md),
1316
1424
  [reviewing-a-journey.md](./reviewing-a-journey.md), and
@@ -1621,6 +1729,15 @@ are optional: set `page-inset` and the `region-*` caps (`form`, `feed`, `content
1621
1729
  `wide`, `full`) in `tokens.shared` only when the direction calls for a different
1622
1730
  page proportion, and keep each cap's role.
1623
1731
 
1732
+ Overall type size is one token. Sable's scale is built on a 16px base;
1733
+ `"type-base": "14"` (or 13) in `tokens.shared` rebuilds every page tier at that
1734
+ base, keeping their ratios, and spacing follows because the rhythm is derived
1735
+ from body size. Use it when the direction is denser or more technical than the
1736
+ bundled presets (a tracker, a console, "like Linear"). Don't rescale the root
1737
+ font-size, and don't override tiers one by one to the same end. Retune a single
1738
+ tier only when that tier's role changes. Inside a Workspace the chrome already
1739
+ uses its own compact `ui-*` tiers.
1740
+
1624
1741
  ```bash
1625
1742
  pnpm exec sable preset --template
1626
1743
  pnpm exec sable preset --check .sable/presets/clay.json --json
@@ -3579,7 +3696,9 @@ Every tier belongs to one family, and the family is the decision. Size follows f
3579
3696
  6. **Cap reading text at --measure-prose (65ch).** Past roughly 75 characters the eye loses the line return. A wide region can still hold a 65ch column.
3580
3697
  7. **Numbers that must align use a mono tier (tabular figures).** Proportional digits change width as values update, so totals and columns shimmy.
3581
3698
  8. **text-wrap: balance on headings, pretty on ledes, neither on long prose.** balance evens short headlines; pretty only refuses a one-word last line. Both cost layout work that long prose does not repay.
3582
- 9. **A mark attached to a line of type (a dot, a bullet, a timeline marker, a leading icon) sits in a box one line tall (h-[1lh] items-center) and is sized in em.** 1lh is the line-height of the text beside it, so the mark centres on that line in every tier, density and theme. A px size with a mt-[3px] nudge is right for one font at one size and wrong everywhere else.
3699
+ 9. **To make all the type smaller or larger, set the base: --type-base: 14 (a unitless px number; 16 is the default). Never retune tiers one by one for it.** Every tier is authored on a 16px base and keeps its ratio to it, so one number rebuilds the whole scale, headings to captions, and the baseline step and spacing follow the body. Shrinking tiers individually breaks the ratios between them. Set it on :root, a theme scope or a preset (tokens.shared["type-base"]) so the rhythm follows too.
3700
+ 10. **Inside a Workspace, a panel's title is text-ui-title (14px · 600); compact text-title-lg resolves to it. A reading view inside the tool (an overview, a document, a long form) is wrapped in <Density value="comfortable"> and gets the page tiers back.** The compact register remaps page tiers onto the 10-12px chrome ramp, which is right for chrome and wrong for prose: an issue overview set in a tool rendered its paragraphs at 11px. The remap stops at a comfortable boundary, so the wrapper is the whole fix.
3701
+ 11. **A mark attached to a line of type (a dot, a bullet, a timeline marker, a leading icon) sits in a box one line tall (h-[1lh] items-center) and is sized in em.** 1lh is the line-height of the text beside it, so the mark centres on that line in every tier, density and theme. A px size with a mt-[3px] nudge is right for one font at one size and wrong everywhere else.
3583
3702
 
3584
3703
  ## Never
3585
3704
 
@@ -3588,6 +3707,7 @@ Every tier belongs to one family, and the family is the decision. Size follows f
3588
3707
  - **Never:** Uppercase in product chrome: nav and menu group labels, page eyebrows, table headers, section titles. **Instead:** Sentence-case text-caption or text-label-md in muted-foreground; hierarchy from size, weight and space. Tracked capitals spread across a UI read as shouting and slow word recognition. The eyebrow tier (label-xs / overline) is an editorial accent: at most one per marketing view.
3589
3708
  - **Never:** Display tiers inside product UI, or two display sizes in one view. **Instead:** heading-1 for a page title; mono-xl for a metric. Display is type as image. In chrome it competes with the content it is supposed to frame.
3590
3709
  - **Never:** Justified text, or reading copy below body-sm. **Instead:** Ragged-right at body-md; body-xs only for dense data. Justification opens rivers on the web, which has no hyphenation control; 12px prose fails people with low vision.
3710
+ - **Never:** Changing the root font-size (html { font-size: 14px }) to make a product denser. **Instead:** --type-base: 14, with Workspace or <Density value="compact"> for denser controls. The root also sizes every rem the app wrote: widths, insets, media queries. --type-base moves only the type and the rhythm built on it, and density moves the controls, so each can be chosen on its own.
3591
3711
  - **Never:** A new font family, or raw font-feature-settings / font-variation-settings. **Instead:** The theme's sans, mono and optional display; font-variant-numeric and font-weight. Raw axis tags silently do nothing on a fallback font, and every theme declares a fallback stack.
3592
3712
 
3593
3713
  ## Details
@@ -5150,7 +5270,7 @@ App-shell admin dashboard: a branded Sidebar with an account footer (AppHeader +
5150
5270
  ## Source
5151
5271
 
5152
5272
  ```tsx
5153
- import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, MenuItem, MenuSeparator, Meter, NotificationBadge, OverflowMenu, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from "@sableui/react"
5273
+ import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger, Meter, NotificationBadge, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarAccount, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from "@sableui/react"
5154
5274
 
5155
5275
  // Account status → Badge tone. Sable has no status vocabulary of its own;
5156
5276
  // an app maps ITS states onto tones once, here, and every badge follows.
@@ -5262,20 +5382,23 @@ export function DashboardPage() {
5262
5382
  <SidebarItem icon={<Icon name="gear" />} label="Settings" render={<a href="/settings" />} />
5263
5383
  </SidebarNav>
5264
5384
  <SidebarFooter>
5265
- <Entity
5266
- className="[--entity-py:0]"
5267
- leading={<Avatar variant="art" size="sm" fallback="AL" alt="" />}
5268
- title="Ada Lovelace"
5269
- subtitle="@ada"
5270
- action={
5271
- <OverflowMenu label="Account" size="icon-sm">
5272
- <MenuItem render={<a href="/@ada" />}>View profile</MenuItem>
5273
- <MenuItem render={<a href="/settings" />}>Settings</MenuItem>
5274
- <MenuSeparator />
5275
- <MenuItem>Sign out</MenuItem>
5276
- </OverflowMenu>
5277
- }
5278
- />
5385
+ <Menu>
5386
+ <MenuTrigger
5387
+ render={
5388
+ <SidebarAccount
5389
+ avatar={<Avatar variant="art" size="xs" fallback="AL" alt="" />}
5390
+ label="Ada Lovelace"
5391
+ detail="@ada"
5392
+ />
5393
+ }
5394
+ />
5395
+ <MenuContent side="top" align="start">
5396
+ <MenuItem render={<a href="/@ada" />}>View profile</MenuItem>
5397
+ <MenuItem render={<a href="/settings" />}>Settings</MenuItem>
5398
+ <MenuSeparator />
5399
+ <MenuItem>Sign out</MenuItem>
5400
+ </MenuContent>
5401
+ </Menu>
5279
5402
  </SidebarFooter>
5280
5403
  </Sidebar>
5281
5404
  }
@@ -7046,6 +7169,459 @@ export function SettingsPage() {
7046
7169
 
7047
7170
  ---
7048
7171
 
7172
+ # TrackerPage
7173
+
7174
+ Records workspace in the tool register: a Sidebar rail of grouped views ending in an account menu, a WorkspaceBar of Breadcrumbs over pill Tabs, a filter row, one grouped DataTable, and a docked inspector of PanelSection property rows with a pinned comment form. Below lg the rail and the inspector become sheets and a BottomNav takes the last row.
7175
+
7176
+ **Intent:** Reach for this when a team works through records in a dense tool — an issue tracker, a support queue, a pipeline: a grouped list, a docked inspector for the selected record, and a rail of views. For records a person reads and edits one at a time on a page, ListDetailPage is the page-register shape; for monitoring with headline numbers, DashboardPage.
7177
+
7178
+ ## Notes
7179
+
7180
+ - Workspace sets the compact density for everything inside: 32px rail rows, 28px tables, 24px property fields. Nothing in the pattern sizes a control or a row by hand. The Overview tab is a reading view, so it sits in `<Density value="comfortable">` and its prose gets page type back.
7181
+ - The rail is Sable's own parts on one grid: SidebarBrand, SidebarGroup, SidebarItem and SidebarAccount share the icon and label columns. The account row is the trigger of the account Menu, which holds Settings, an Appearance submenu wired to useSableTheme (light, dark, system) and Sign out. No Settings button or badge sits in the footer.
7182
+ - There is no Workspace `toolbar`: the bars belong to the list column, so the inspector runs the full height beside them. The first WorkspaceBar carries Breadcrumbs; the second carries pill Tabs, a view switch in chrome, which sits beside the bar's other controls rather than drawing a rule.
7183
+ - One DataTable groups the rows (`groupBy`, `groups`): one header for every group, a StatusDot, a count and an add action per group header, and Done starting closed. The segmented ToggleGroup switches the grouping between status and assignee. Filtering to nothing swaps the table for an EmptyState with a Clear filters action.
7184
+ - Workflow states map onto StatusDot tones once (`STATUSES`), so group headers and the status Select read from one place. A product with a richer status glyph keeps that one mapping and swaps the icon.
7185
+ - The inspector is a Panel with `narrow="sheet"`: docked at 320px from lg, a right sheet below it, its content rendered once. Below lg it opens only when a row is picked (`inspecting`), so a phone never loads with a sheet over the list. Properties are PanelSection + PropertyRow with xs Select triggers and an xs DatePicker; Activity uses RelativeTime; the comment Form is pinned under the scrolling body.
7186
+ - The rail is a Panel with `narrow="sheet" side="left"`, opened from a `lg:hidden` menu button in the first bar; in the sheet the Sidebar fills its width. Workspace `bottomNav` reserves the BottomNav's row below lg, so the status bar ends above it.
7187
+
7188
+ ## Accessibility
7189
+
7190
+ - Workspace renders a skip link to the canvas (`Skip to issues`) and WorkspaceCanvas is the main landmark; the rail and the inspector are labelled complementary regions (`Workspace navigation`, `Issue details`), and as sheets they keep those labels as their dialog titles.
7191
+ - The page has one level-1 heading (visually hidden; the breadcrumb shows the place) and the inspector's title is a level-2 heading.
7192
+ - Group header rows are real buttons with aria-expanded; rows open the inspector from the keyboard (DataTable's row activation). Every icon-only action has an accessible name and a tooltip.
7193
+ - Each SidebarGroup is a named group; the account row names the person, and its menu is a standard Menu with a radio group for the colour mode.
7194
+
7195
+ ## Source
7196
+
7197
+ ```tsx
7198
+ import { useState } from "react"
7199
+ import { Avatar, Badge, BottomNav, BottomNavItem, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, Breadcrumbs, BreadcrumbSeparator, Button, DataTable, DatePicker, Density, EmptyState, Form, Icon, Kbd, Menu, MenuContent, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuSubmenu, MenuSubmenuTrigger, MenuTrigger, Panel, PanelGroup, PanelSection, PropertyRow, RelativeTime, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarAccount, SidebarBrand, SidebarFooter, SidebarGroup, SidebarHeader, SidebarItem, SidebarNav, SimpleTooltip, StatusBar, StatusBarGroup, StatusDot, Tabs, TabsList, TabsPanel, TabsTrigger, Textarea, Toggle, ToggleGroup, useBelowBreakpoint, useSableTheme, Workspace, WorkspaceBar, WorkspaceCanvas, type DataTableColumn } from "@sableui/react"
7200
+
7201
+ // Workflow states → StatusDot tones, mapped once here so every group header,
7202
+ // row and select follows. Sable has no status vocabulary of its own.
7203
+ const STATUSES = [
7204
+ { key: "progress", label: "In progress", tone: "warning" },
7205
+ { key: "review", label: "In review", tone: "2" },
7206
+ { key: "todo", label: "Todo", tone: "neutral" },
7207
+ { key: "done", label: "Done", tone: "success" },
7208
+ ] as const
7209
+ type Status = (typeof STATUSES)[number]["key"]
7210
+
7211
+ const PRIORITIES = ["Urgent", "High", "Medium", "Low"] as const
7212
+ type Priority = (typeof PRIORITIES)[number]
7213
+
7214
+ const people: Record<string, string> = {
7215
+ AK: "Ama Kusi",
7216
+ JR: "Jonas Reiter",
7217
+ LM: "Lena Moreau",
7218
+ TO: "Tomás Ortega",
7219
+ }
7220
+
7221
+ const MIN = 60 * 1000
7222
+ const HOUR = 60 * MIN
7223
+ const DAY = 24 * HOUR
7224
+ const isoDay = (offset: number) => {
7225
+ const d = new Date(Date.now() + offset)
7226
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate())
7227
+ }
7228
+
7229
+ type Note = { id: string; who: string; text: string; at: number }
7230
+ type Issue = {
7231
+ id: string
7232
+ title: string
7233
+ detail: string
7234
+ status: Status
7235
+ priority: Priority
7236
+ assignee: string
7237
+ label: string
7238
+ due: Date
7239
+ notes: Note[]
7240
+ }
7241
+
7242
+ const initialIssues: Issue[] = [
7243
+ { id: "FW-104", title: "Sync offline edits when the app reconnects", detail: "Edits made offline are queued but replayed out of order after a reconnect. Replay them by timestamp and surface conflicts.", status: "progress", priority: "Urgent", assignee: "AK", label: "Sync", due: isoDay(2 * DAY), notes: [{ id: "n1", who: "JR", text: "Repro steps are in the test plan. It only happens after a cold start.", at: -3 * HOUR }] },
7244
+ { id: "FW-105", title: "Onboarding checklist on first launch", detail: "Three steps: connect a calendar, invite a teammate, create a first project.", status: "progress", priority: "Medium", assignee: "LM", label: "Onboarding", due: isoDay(5 * DAY), notes: [] },
7245
+ { id: "FW-108", title: "Push notification settings per project", detail: "Let people mute a project without muting the workspace.", status: "review", priority: "High", assignee: "TO", label: "Notifications", due: isoDay(1 * DAY), notes: [] },
7246
+ { id: "FW-110", title: "Crash when attaching a large photo", detail: "Photos over 20 MB crash the uploader on older devices.", status: "review", priority: "Urgent", assignee: "JR", label: "Bug", due: isoDay(-1 * DAY), notes: [] },
7247
+ { id: "FW-112", title: "Search across archived projects", detail: "Archived projects are left out of search today.", status: "todo", priority: "Low", assignee: "AK", label: "Search", due: isoDay(9 * DAY), notes: [] },
7248
+ { id: "FW-113", title: "Keyboard shortcuts for the board view", detail: "J and K move between cards; C creates an issue in the focused column.", status: "todo", priority: "Medium", assignee: "LM", label: "Board", due: isoDay(12 * DAY), notes: [] },
7249
+ { id: "FW-114", title: "Export a project as CSV", detail: "One row per issue, with status, assignee and dates.", status: "todo", priority: "Low", assignee: "TO", label: "Export", due: isoDay(14 * DAY), notes: [] },
7250
+ { id: "FW-101", title: "Dark mode for the widget", detail: "The home-screen widget ignores the system appearance.", status: "done", priority: "Medium", assignee: "JR", label: "Widget", due: isoDay(-4 * DAY), notes: [] },
7251
+ ]
7252
+
7253
+ const MODE_LABEL = { light: "Light", dark: "Dark", system: "System" } as const
7254
+
7255
+ /** One icon-only action with a visible name on hover and focus. */
7256
+ export function TrackerPage() {
7257
+ const [issues, setIssues] = useState(initialIssues)
7258
+ const [selectedId, setSelectedId] = useState<string | null>("FW-104")
7259
+ // Below lg the inspector is a sheet; it opens only when someone picks a row,
7260
+ // so a phone does not load with a sheet over the list.
7261
+ const [inspecting, setInspecting] = useState(false)
7262
+ const [navOpen, setNavOpen] = useState(false)
7263
+ const [query, setQuery] = useState("")
7264
+ const [priority, setPriority] = useState<"all" | Priority>("all")
7265
+ const [groupBy, setGroupBy] = useState<"status" | "assignee">("status")
7266
+ const [tab, setTab] = useState("issues")
7267
+ const [draft, setDraft] = useState("")
7268
+ const narrow = useBelowBreakpoint("lg")
7269
+ const { preference, setPreference } = useSableTheme()
7270
+
7271
+ const needle = query.trim().toLowerCase()
7272
+ const visible = issues.filter(
7273
+ (i) =>
7274
+ (priority === "all" || i.priority === priority) &&
7275
+ `${i.id} ${i.title} ${i.label}`.toLowerCase().includes(needle),
7276
+ )
7277
+ const selected = issues.find((i) => i.id === selectedId) ?? null
7278
+ const done = issues.filter((i) => i.status === "done").length
7279
+
7280
+ function update(id: string, patch: Partial<Issue>) {
7281
+ setIssues((all) => all.map((i) => (i.id === id ? { ...i, ...patch } : i)))
7282
+ }
7283
+ function open(id: string) {
7284
+ setSelectedId(id)
7285
+ setInspecting(true)
7286
+ }
7287
+ function close() {
7288
+ setInspecting(false)
7289
+ if (!narrow) setSelectedId(null)
7290
+ }
7291
+ function addIssue(status: Status = "todo") {
7292
+ const id = `FW-${115 + issues.length}`
7293
+ setIssues((all) => [
7294
+ ...all,
7295
+ { id, title: "New issue", detail: "", status, priority: "Medium", assignee: "AK", label: "Triage", due: isoDay(7 * DAY), notes: [] },
7296
+ ])
7297
+ open(id)
7298
+ }
7299
+ function addNote() {
7300
+ if (!selected || !draft.trim()) return
7301
+ update(selected.id, { notes: [...selected.notes, { id: `n${Date.now()}`, who: "AK", text: draft.trim(), at: 0 }] })
7302
+ setDraft("")
7303
+ }
7304
+
7305
+ const columns: DataTableColumn<Issue>[] = [
7306
+ { key: "id", header: "ID", className: "w-20 whitespace-nowrap", cell: (i) => <span className="text-ui-num text-muted-foreground">{i.id}</span> },
7307
+ { key: "title", header: "Issue", className: "w-full max-w-0", cell: (i) => <span className="block truncate">{i.title}</span> },
7308
+ { key: "label", header: "Label", className: "w-28 max-sm:hidden", cell: (i) => <Badge appearance="outline" size="sm">{i.label}</Badge> },
7309
+ { key: "due", header: "Due", className: "w-20 whitespace-nowrap max-sm:hidden", sortValue: (i) => i.due.getTime(), cell: (i) => <span className="text-muted-foreground">{i.due.toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span> },
7310
+ { key: "assignee", header: <span className="sr-only">Assignee</span>, className: "w-10", cell: (i) => <Avatar size="2xs" fallback={i.assignee} alt={people[i.assignee]} /> },
7311
+ ]
7312
+
7313
+ const groups =
7314
+ groupBy === "status"
7315
+ ? STATUSES.map((s) => ({
7316
+ key: s.key,
7317
+ label: s.label,
7318
+ icon: <StatusDot tone={s.tone} />,
7319
+ action: <SimpleTooltip content={`Add to ${s.label}`}><Button size="icon-xs" variant="ghost" aria-label={`Add to ${s.label}`} onClick={() => addIssue(s.key)}><Icon name="plus" /></Button></SimpleTooltip>,
7320
+ defaultExpanded: s.key !== "done",
7321
+ }))
7322
+ : Object.entries(people).map(([key, name]) => ({
7323
+ key,
7324
+ label: name,
7325
+ icon: <Avatar size="2xs" fallback={key} alt="" />,
7326
+ }))
7327
+
7328
+ const sidebar = (
7329
+ <Sidebar variant="inline" className="h-full w-full">
7330
+ <SidebarHeader>
7331
+ <SidebarBrand mark={<Avatar shape="square" size="2xs" tone="teal" fallback="F" alt="" />} label="Fieldwork" />
7332
+ <Button variant="outline" size="sm" className="basis-full justify-between">
7333
+ <span className="flex items-center gap-2">
7334
+ <Icon name="search" />
7335
+ Search
7336
+ </span>
7337
+ <Kbd>Ctrl K</Kbd>
7338
+ </Button>
7339
+ </SidebarHeader>
7340
+ <SidebarNav aria-label="Workspace">
7341
+ <SidebarItem icon={<Icon name="inbox" />} label="Inbox" badge={<span className="text-ui-num text-muted-foreground">3</span>} />
7342
+ <SidebarItem icon={<Icon name="circle-check" />} label="My issues" />
7343
+ <SidebarGroup label="Mobile app">
7344
+ <SidebarItem icon={<Icon name="layers" />} label="Issues" active />
7345
+ <SidebarItem icon={<Icon name="refresh" />} label="Cycles" />
7346
+ <SidebarItem icon={<Icon name="archive" />} label="Backlog" />
7347
+ </SidebarGroup>
7348
+ <SidebarGroup label="Workspace">
7349
+ <SidebarItem icon={<Icon name="folder" />} label="Projects" />
7350
+ <SidebarItem icon={<Icon name="users" />} label="Members" />
7351
+ </SidebarGroup>
7352
+ </SidebarNav>
7353
+ <SidebarFooter>
7354
+ <Menu>
7355
+ <MenuTrigger
7356
+ render={<SidebarAccount avatar={<Avatar size="2xs" fallback="AK" alt="" />} label="Ama Kusi" detail="Fieldwork · Admin" />}
7357
+ />
7358
+ <MenuContent side="top" align="start">
7359
+ <MenuItem icon={<Icon name="gear" />}>Settings</MenuItem>
7360
+ <MenuSubmenu>
7361
+ <MenuSubmenuTrigger
7362
+ icon={<Icon name={preference === "dark" ? "moon" : preference === "light" ? "sun" : "desktop"} />}
7363
+ value={MODE_LABEL[preference]}
7364
+ >
7365
+ Appearance
7366
+ </MenuSubmenuTrigger>
7367
+ <MenuContent side="inline-end">
7368
+ <MenuRadioGroup value={preference} onValueChange={(v) => setPreference(v as typeof preference)}>
7369
+ <MenuRadioItem value="light">Light</MenuRadioItem>
7370
+ <MenuRadioItem value="dark">Dark</MenuRadioItem>
7371
+ <MenuRadioItem value="system">System</MenuRadioItem>
7372
+ </MenuRadioGroup>
7373
+ </MenuContent>
7374
+ </MenuSubmenu>
7375
+ <MenuSeparator />
7376
+ <MenuItem icon={<Icon name="logout" />}>Sign out</MenuItem>
7377
+ </MenuContent>
7378
+ </Menu>
7379
+ </SidebarFooter>
7380
+ </Sidebar>
7381
+ )
7382
+
7383
+ const list =
7384
+ visible.length === 0 ? (
7385
+ <EmptyState
7386
+ icon={<Icon name="search" />}
7387
+ title="No issues match"
7388
+ description="Try another search, or clear the priority filter."
7389
+ action={
7390
+ <Button
7391
+ variant="secondary"
7392
+ size="sm"
7393
+ onClick={() => {
7394
+ setQuery("")
7395
+ setPriority("all")
7396
+ }}
7397
+ >
7398
+ Clear filters
7399
+ </Button>
7400
+ }
7401
+ />
7402
+ ) : (
7403
+ <DataTable
7404
+ columns={columns}
7405
+ data={visible}
7406
+ rowKey={(i) => i.id}
7407
+ groupBy={(i) => (groupBy === "status" ? i.status : i.assignee)}
7408
+ groups={groups}
7409
+ onRowClick={(i) => open(i.id)}
7410
+ // A phone shows the selection only while its sheet is open.
7411
+ isRowSelected={(i) => i.id === selectedId && (!narrow || inspecting)}
7412
+ />
7413
+ )
7414
+
7415
+ const inspector = selected ? (
7416
+ <div className="flex h-full min-h-0 flex-col">
7417
+ <WorkspaceBar className="justify-between">
7418
+ <span className="text-ui-num text-muted-foreground">{selected.id}</span>
7419
+ <SimpleTooltip content="Close issue"><Button size="icon-xs" variant="ghost" aria-label="Close issue" onClick={close}><Icon name="xmark" /></Button></SimpleTooltip>
7420
+ </WorkspaceBar>
7421
+ <div className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">
7422
+ <div className="flex flex-col gap-2 px-2 pt-4 pb-3">
7423
+ <h2 className="text-title-lg">{selected.title}</h2>
7424
+ <p className="text-ui-body text-muted-foreground">{selected.detail || "Add the details the team needs."}</p>
7425
+ </div>
7426
+ <PanelSection title="Properties">
7427
+ <PropertyRow label="Status">
7428
+ <Select value={selected.status} items={STATUSES.map((s) => ({ value: s.key, label: s.label }))} onValueChange={(v) => v && update(selected.id, { status: v as Status })}>
7429
+ <SelectTrigger size="xs" aria-label="Status" />
7430
+ <SelectContent>
7431
+ {STATUSES.map((s) => (
7432
+ <SelectItem key={s.key} value={s.key}>{s.label}</SelectItem>
7433
+ ))}
7434
+ </SelectContent>
7435
+ </Select>
7436
+ </PropertyRow>
7437
+ <PropertyRow label="Priority">
7438
+ <Select value={selected.priority} onValueChange={(v) => v && update(selected.id, { priority: v as Priority })}>
7439
+ <SelectTrigger size="xs" aria-label="Priority" />
7440
+ <SelectContent>
7441
+ {PRIORITIES.map((p) => (
7442
+ <SelectItem key={p} value={p}>{p}</SelectItem>
7443
+ ))}
7444
+ </SelectContent>
7445
+ </Select>
7446
+ </PropertyRow>
7447
+ <PropertyRow label="Assignee">
7448
+ <Select value={selected.assignee} items={people} onValueChange={(v) => v && update(selected.id, { assignee: v })}>
7449
+ <SelectTrigger size="xs" aria-label="Assignee" />
7450
+ <SelectContent>
7451
+ {Object.entries(people).map(([key, name]) => (
7452
+ <SelectItem key={key} value={key}>{name}</SelectItem>
7453
+ ))}
7454
+ </SelectContent>
7455
+ </Select>
7456
+ </PropertyRow>
7457
+ <PropertyRow label="Due date">
7458
+ <DatePicker
7459
+ size="xs"
7460
+ aria-label="Due date"
7461
+ value={selected.due}
7462
+ formatOptions={{ month: "short", day: "numeric", year: "numeric" }}
7463
+ onValueChange={(d) => d && update(selected.id, { due: d })}
7464
+ />
7465
+ </PropertyRow>
7466
+ </PanelSection>
7467
+ <PanelSection title="Activity">
7468
+ <div className="flex flex-col gap-3 pb-4">
7469
+ <div className="flex items-center gap-2 text-ui-sm text-muted-foreground">
7470
+ <Icon name="plus" />
7471
+ <span>Created in Mobile app</span>
7472
+ </div>
7473
+ {selected.notes.map((n) => (
7474
+ <div key={n.id} className="flex items-start gap-2">
7475
+ <Avatar size="2xs" fallback={n.who} alt="" />
7476
+ <div className="min-w-0">
7477
+ <div className="flex items-center gap-2">
7478
+ <span className="text-ui-md">{people[n.who]}</span>
7479
+ <RelativeTime date={new Date(Date.now() + n.at)} className="text-ui-sm text-muted-foreground" />
7480
+ </div>
7481
+ <p className="mt-1 text-ui-body">{n.text}</p>
7482
+ </div>
7483
+ </div>
7484
+ ))}
7485
+ </div>
7486
+ </PanelSection>
7487
+ </div>
7488
+ <Form
7489
+ className="flex shrink-0 flex-col gap-2 border-t border-hairline p-2"
7490
+ onSubmit={(e) => {
7491
+ e.preventDefault()
7492
+ addNote()
7493
+ }}
7494
+ >
7495
+ <Textarea aria-label="Add a comment" placeholder="Leave a comment…" rows={2} value={draft} onChange={(e) => setDraft(e.target.value)} />
7496
+ <Button size="xs" variant="secondary" type="submit" className="self-end" disabled={!draft.trim()} iconStart={<Icon name="arrow-up" />}>
7497
+ Comment
7498
+ </Button>
7499
+ </Form>
7500
+ </div>
7501
+ ) : null
7502
+
7503
+ return (
7504
+ <Workspace
7505
+ skipLinkLabel="Skip to issues"
7506
+ statusBar={
7507
+ <StatusBar>
7508
+ <span className="flex items-center gap-2">
7509
+ <StatusDot tone="success" size="sm" />
7510
+ Saved
7511
+ </span>
7512
+ <span className="max-sm:hidden">
7513
+ {issues.length} issues · {done} done
7514
+ </span>
7515
+ <StatusBarGroup align="end">
7516
+ <span className="max-sm:hidden">Ctrl K to search</span>
7517
+ </StatusBarGroup>
7518
+ </StatusBar>
7519
+ }
7520
+ bottomNav={
7521
+ <BottomNav aria-label="Primary">
7522
+ <BottomNavItem icon={<Icon name="inbox" />} label="Inbox" showLabel />
7523
+ <BottomNavItem icon={<Icon name="layers" />} label="Issues" showLabel active />
7524
+ <BottomNavItem icon={<Icon name="folder" />} label="Projects" showLabel />
7525
+ <BottomNavItem icon={<Icon name="users" />} label="Members" showLabel />
7526
+ </BottomNav>
7527
+ }
7528
+ >
7529
+ <PanelGroup>
7530
+ {/* Docked from lg; below it the same rail is a left sheet. */}
7531
+ <Panel label="Workspace navigation" width={232} narrow="sheet" side="left" open={narrow ? navOpen : true} onOpenChange={setNavOpen} scroll={false}>
7532
+ {sidebar}
7533
+ </Panel>
7534
+ <WorkspaceCanvas className="flex flex-col">
7535
+ <WorkspaceBar className="gap-2 px-3">
7536
+ <span className="lg:hidden">
7537
+ <SimpleTooltip content="Open navigation"><Button size="icon-xs" variant="ghost" aria-label="Open navigation" onClick={() => setNavOpen(true)}><Icon name="bars" /></Button></SimpleTooltip>
7538
+ </span>
7539
+ <h1 className="sr-only">Mobile app issues</h1>
7540
+ <Breadcrumbs className="min-w-0">
7541
+ <BreadcrumbItem className="max-sm:hidden">
7542
+ <BreadcrumbLink href="#">Fieldwork</BreadcrumbLink>
7543
+ </BreadcrumbItem>
7544
+ <BreadcrumbSeparator className="max-sm:hidden" />
7545
+ <BreadcrumbItem>
7546
+ <BreadcrumbPage>Mobile app</BreadcrumbPage>
7547
+ </BreadcrumbItem>
7548
+ </Breadcrumbs>
7549
+ </WorkspaceBar>
7550
+ <Tabs value={tab} onValueChange={(v) => setTab(String(v))} className="flex min-h-0 flex-1 flex-col">
7551
+ {/* A view switch in tool chrome: pill, beside the bar's other controls. */}
7552
+ <WorkspaceBar className="px-3">
7553
+ <TabsList variant="pill">
7554
+ <TabsTrigger value="issues">
7555
+ Issues <span className="text-ui-num text-muted-foreground">{issues.length}</span>
7556
+ </TabsTrigger>
7557
+ <TabsTrigger value="overview">Overview</TabsTrigger>
7558
+ </TabsList>
7559
+ </WorkspaceBar>
7560
+ <TabsPanel value="issues" className="flex min-h-0 flex-1 flex-col">
7561
+ <div className="flex flex-wrap items-center gap-2 border-b border-hairline px-3 py-2">
7562
+ <Menu>
7563
+ <MenuTrigger render={<Button variant="ghost" size="xs" iconStart={<Icon name="filter" />}>{priority === "all" ? "Filter" : priority}</Button>} />
7564
+ <MenuContent>
7565
+ <MenuRadioGroup value={priority} onValueChange={(v) => setPriority(v as typeof priority)}>
7566
+ <MenuRadioItem value="all">All priorities</MenuRadioItem>
7567
+ {PRIORITIES.map((p) => (
7568
+ <MenuRadioItem key={p} value={p}>{p}</MenuRadioItem>
7569
+ ))}
7570
+ </MenuRadioGroup>
7571
+ </MenuContent>
7572
+ </Menu>
7573
+ <SearchInput size="xs" value={query} onValueChange={setQuery} placeholder="Find issue…" aria-label="Find issue" className="w-40" />
7574
+ <ToggleGroup appearance="segmented" value={[groupBy]} onValueChange={(v) => v[0] && setGroupBy(v[0] as typeof groupBy)} aria-label="Group by" className="ms-auto">
7575
+ <Toggle value="status">Status</Toggle>
7576
+ <Toggle value="assignee">Assignee</Toggle>
7577
+ </ToggleGroup>
7578
+ <Button size="xs" iconStart={<Icon name="plus" />} onClick={() => addIssue()}>
7579
+ New issue
7580
+ </Button>
7581
+ </div>
7582
+ <div className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">{list}</div>
7583
+ </TabsPanel>
7584
+ <TabsPanel value="overview" className="flex min-h-0 flex-1 flex-col">
7585
+ {/* A reading view: page type comes back inside the compact workspace. */}
7586
+ <Density value="comfortable" className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">
7587
+ <article className="flex max-w-prose flex-col gap-4 p-8 max-sm:p-5">
7588
+ <h2 className="text-heading-3">Mobile app</h2>
7589
+ <p className="text-body-md text-muted-foreground">
7590
+ The phone app for field teams: capture work on site, sync it when the signal comes back, and hand it to the office without retyping anything.
7591
+ </p>
7592
+ <dl className="sable-list-dl">
7593
+ <dt>Lead</dt>
7594
+ <dd>{people.AK}</dd>
7595
+ <dt>Open issues</dt>
7596
+ <dd>{issues.length - done}</dd>
7597
+ <dt>Target</dt>
7598
+ <dd>{isoDay(30 * DAY).toLocaleDateString("en-US", { month: "long", day: "numeric" })}</dd>
7599
+ </dl>
7600
+ </article>
7601
+ </Density>
7602
+ </TabsPanel>
7603
+ </Tabs>
7604
+ </WorkspaceCanvas>
7605
+ {/* Docked beside the list from lg; a sheet below it. Its content renders once. */}
7606
+ <Panel
7607
+ label="Issue details"
7608
+ width={320}
7609
+ narrow="sheet"
7610
+ open={narrow ? inspecting && selected !== null : selected !== null}
7611
+ onOpenChange={(next) => !next && close()}
7612
+ scroll={false}
7613
+ className="border-s border-hairline"
7614
+ >
7615
+ {inspector}
7616
+ </Panel>
7617
+ </PanelGroup>
7618
+ </Workspace>
7619
+ )
7620
+ }
7621
+ ```
7622
+
7623
+ ---
7624
+
7049
7625
  # ApiKeysBlock
7050
7626
 
7051
7627
  An API keys card: each key's name with its masked hint in Code, when it was created and last used, and an OverflowMenu to rename, roll or revoke.
@@ -11717,9 +12293,9 @@ import { Attachment, Message, RelativeTime } from "@sableui/react"
11717
12293
 
11718
12294
  **Status:** stable
11719
12295
 
11720
- Circular profile image over Base UI Avatar — automatic image-load tracking with an initials/glyph fallback. Ergonomic Avatar plus low-level AvatarRoot/AvatarImage/AvatarFallback parts, and AvatarGroup for an overlapping stack with a "+N" overflow count.
12296
+ Circular (or square) profile image over Base UI Avatar — automatic image-load tracking with an initials/glyph fallback. Ergonomic Avatar plus low-level AvatarRoot/AvatarImage/AvatarFallback parts, and AvatarGroup for an overlapping stack with a "+N" overflow count.
11721
12297
 
11722
- **Keywords:** avatar, profile, user, identity, initials, fallback, group, team, stack, overflow, collaborators, facepile
12298
+ **Keywords:** avatar, profile, user, identity, initials, fallback, group, team, stack, overflow, collaborators, facepile, monogram, project mark, workspace icon, org logo, assignee
11723
12299
 
11724
12300
  ## Usage
11725
12301
 
@@ -11730,12 +12306,13 @@ Use Avatar to represent a person, team, or account with a compact visual identit
11730
12306
  - Do: Use `variant="art"` where people without photos sit side by side — a member list, an activity feed, a chat. Seeded by the person, each gets their own colour, so faces stay tellable apart at a glance; the neutral fallback makes everyone the same grey circle.
11731
12307
  - Do: Provide meaningful alt text for profile images, or an empty alt when the adjacent text already names the person.
11732
12308
  - Do: Use initials or a simple glyph fallback so missing images still communicate identity.
11733
- - Do: Pick the size by the job: sm in dense rows and tables, md (the default) beside one line of text, lg in a row with a title and subtitle, xl (96px) for a profile header beside the person's name, 2xl (160px) for an artist or profile hero and roster portraits. Keep one size within a list or group.
12309
+ - Do: Pick the size by the job: 2xs (20px) or xs (24px) in a compact workspace row (an assignee in an issue list, a project mark in a nav item), sm in dense rows and tables, md (the default) beside one line of text, lg in a row with a title and subtitle, xl (96px) for a profile header beside the person's name, 2xl (160px) for an artist or profile hero and roster portraits. Keep one size within a list or group.
11734
12310
  - Do: Use AvatarGroup for a stack of people, and give it an aria-label with the real count ("12 collaborators"). Set `surface` (or `--avatar-group-ring`) to whatever the group sits on, so the separating ring matches a card or popover, not the page. When it shares a row with actions, set `fit` so it gives up avatars to the "+N" count before the row has to wrap.
12311
+ - Do: Use `shape="square"` for what is not a person (a workspace, a project, an org, a team), with `tone` set to a palette family so each one keeps its own colour: `<Avatar shape="square" tone="teal" fallback="N" alt="Northwind storefront" size="xs" />`. Circles mean people; keep that distinction across the app.
11735
12312
  - Don't: Hand-build a stack with negative margins and per-avatar ring classes, or fake the overflow as an Avatar with "+4" initials — screen readers hear "plus 4" with no context. Use AvatarGroup's `max`/`total`, or AvatarGroupCount.
11736
12313
  - Don't: Use avatars as decoration without nearby identity context. They should help users recognize a person or account.
11737
12314
  - Don't: Overload a dense table with large avatars; use sm or omit images when scanning data is more important.
11738
- - Don't: Build a portrait by stretching an Avatar with size classes (`className="size-40"`) — the initials, the status indicator and the image fade stay at row scale. Use size="xl" or "2xl", which scale all three. Past 160px, or for a non-circular cover, it is media: an AspectBox, not an avatar.
12315
+ - Don't: Resize an Avatar with size classes (`className="size-5"`, `className="size-40"`) — the initials, the status indicator and the image fade stay at the size they were drawn for. Use size="2xs"/"xs" for dense rows and size="xl"/"2xl" for portraits, which scale all three. Don't hand-build a tinted initials tile for a project or workspace mark either: that is Avatar shape="square" with a tone. Past 160px, or for a cover, it is media: an AspectBox, not an avatar.
11739
12316
 
11740
12317
  ### Anatomy
11741
12318
 
@@ -11751,11 +12328,11 @@ Use Avatar to represent a person, team, or account with a compact visual identit
11751
12328
 
11752
12329
  | Name | Type | Description |
11753
12330
  | --- | --- | --- |
11754
- | AvatarRoot | Base UI Avatar.Root props + size + className | Low-level circular frame for full composition control — clips the image and holds the `size` axis. Wraps AvatarImage and AvatarFallback. |
12331
+ | AvatarRoot | Base UI Avatar.Root props + size + shape + tone + className | Low-level circular frame for full composition control — clips the image and holds the `size` axis. Wraps AvatarImage and AvatarFallback. |
11755
12332
  | AvatarImage | Base UI Avatar.Image props + className | The profile image. Fills and crops the frame; hidden by Base UI until it loads. |
11756
12333
  | AvatarFallback | Base UI Avatar.Fallback props (delay) + className | Shown when no image is available — typically initials or a glyph. Accepts a delay prop. |
11757
- | AvatarGroup | { size?: "sm" \| "md" \| "lg"; surface?: "background" \| "card" \| "popover" \| "muted"; max?: number; fit?: boolean; total?: number; countLabel?: (count: number) => string; "aria-label"?: string; className?: string; children: ReactNode } | Overlapping stack (role=group) — name it with aria-label ("12 collaborators"). `size` applies to every avatar inside. `max` shows that many and collapses the rest into a "+N" count; `total` is the real head-count when you only pass some people. `fit` shows only as many avatars as fit the width the group is given (it fills the space left in a flex row) and folds the rest into the count, so a tight row reads "MO SR +5" instead of wrapping. The separating ring reads the inherited `--avatar-group-ring` (default: the page background); `surface` sets it to card, popover, or muted. |
11758
- | AvatarGroupCount | { count: number; label?: string; size?: "sm" \| "md" \| "lg"; className?: string } | The "+N" avatar. Shows "+4" and reads "and 4 more" (override with label). AvatarGroup renders it for you from max/total; use it directly only when composing the stack yourself. |
12334
+ | AvatarGroup | { size?: "2xs" \| "xs" \| "sm" \| "md" \| "lg"; surface?: "background" \| "card" \| "popover" \| "muted"; max?: number; fit?: boolean; total?: number; countLabel?: (count: number) => string; "aria-label"?: string; className?: string; children: ReactNode } | Overlapping stack (role=group) — name it with aria-label ("12 collaborators"). `size` applies to every avatar inside. `max` shows that many and collapses the rest into a "+N" count; `total` is the real head-count when you only pass some people. `fit` shows only as many avatars as fit the width the group is given (it fills the space left in a flex row) and folds the rest into the count, so a tight row reads "MO SR +5" instead of wrapping. The separating ring reads the inherited `--avatar-group-ring` (default: the page background); `surface` sets it to card, popover, or muted. |
12335
+ | AvatarGroupCount | { count: number; label?: string; size?: "2xs" \| "xs" \| "sm" \| "md" \| "lg"; className?: string } | The "+N" avatar. Shows "+4" and reads "and 4 more" (override with label). AvatarGroup renders it for you from max/total; use it directly only when composing the stack yourself. |
11759
12336
 
11760
12337
  ## Props
11761
12338
 
@@ -11766,13 +12343,16 @@ Use Avatar to represent a person, team, or account with a compact visual identit
11766
12343
  | fallback | ReactNode | — | Initials or a glyph rendered when no image is displayed. |
11767
12344
  | fallbackDelay | number | — | Delay before showing the fallback, in ms — avoids a flash on fast loads. |
11768
12345
  | status | ReactNode | — | A presence indicator pinned to the bottom-end edge, usually <StatusDot tone="success" />. It sits outside the clipped circle and wears a ring in the surface colour (--avatar-group-ring, else the page background). Use this instead of absolutely positioning a dot over an Avatar. |
11769
- | size | "sm" \| "md" \| "lg" \| "xl" \| "2xl" | "md" | Square footprint: 32px / 40px / 48px for rows; 96px / 160px for portraits (a profile header, an artist hero, a roster tile). The initials, a status indicator and its cut-out ring scale with it, and at the portrait sizes the photo fades in over the resting surface. |
12346
+ | size | "2xs" \| "xs" \| "sm" \| "md" \| "lg" \| "xl" \| "2xl" | "md" | Square footprint: 20px / 24px for compact workspace rows; 32px / 40px / 48px for rows; 96px / 160px for portraits (a profile header, an artist hero, a roster tile). The initials, a status indicator and its cut-out ring scale with it, and at the portrait sizes the photo fades in over the resting surface. |
12347
+ | shape | "circle" \| "square" | "circle" | The frame's corner. circle is a person. square is what is not a face — a workspace, a project, an org, a team — with a radius from the theme's steps that grows with the size (4px at 20–24px to 20px at 160px), so a square theme stays square. A status indicator sits on the square's corner. |
12348
+ | tone | "neutral" \| "red" \| "orange" \| "yellow" \| "green" \| "teal" \| "blue" \| "purple" \| "pink" | "neutral" | The fallback's colour. neutral is the muted surface; a palette family paints its wash behind the initials and its deep ink on them (a soft Badge's pair, held to contrast in every theme), for a category colour such as one per project. Ignored by variant="art", which colours by seed. |
11770
12349
  | variant | "neutral" \| "art" | "neutral" | What the fallback looks like. neutral: initials on the muted surface. art: initials over GradientArt seeded by the person, so each person without a photo gets their own stable colour from the theme. |
11771
12350
  | seed | string \| number | — | The art variant's seed. Defaults to the fallback text, then `alt`, so a person keeps one colour wherever they appear; pass a user id to tell apart two people who share initials. |
11772
12351
 
11773
12352
  ## Variants
11774
12353
 
11775
- - **size**: sm, md, lg, xl, 2xl
12354
+ - **size**: 2xs, xs, sm, md, lg, xl, 2xl
12355
+ - **shape**: circle, square
11776
12356
  - **variant**: neutral, art
11777
12357
 
11778
12358
  ## Tokens used
@@ -11849,6 +12429,25 @@ import { Avatar, StatusDot } from "@sableui/react"
11849
12429
  </div>
11850
12430
  ```
11851
12431
 
12432
+ ### Dense row sizes
12433
+
12434
+ ```tsx
12435
+ import { Avatar } from "@sableui/react"
12436
+
12437
+ <Avatar fallback="EL" alt="Emma Lee" size="2xs" />
12438
+ <Avatar fallback="EL" alt="Emma Lee" size="xs" />
12439
+ ```
12440
+
12441
+ ### Project marks
12442
+
12443
+ ```tsx
12444
+ import { Avatar } from "@sableui/react"
12445
+
12446
+ <Avatar shape="square" tone="teal" size="xs" fallback="N" alt="Northwind storefront" />
12447
+ <Avatar shape="square" tone="orange" size="xs" fallback="H" alt="Harbor brand refresh" />
12448
+ <Avatar shape="square" tone="purple" size="xs" fallback="L" alt="Lumen editorial" />
12449
+ ```
12450
+
11852
12451
  ### Low-level parts
11853
12452
 
11854
12453
  ```tsx
@@ -13912,7 +14511,7 @@ import { CodeBlock } from "@sableui/react"
13912
14511
 
13913
14512
  Show/hide a panel of content over Base UI Collapsible — open/close state, ARIA expanded wiring, and the animated height transition. The minimal primitive behind Accordion: a trigger that toggles an overflow-clipped panel.
13914
14513
 
13915
- **Keywords:** collapsible, disclosure, expand, collapse
14514
+ **Keywords:** collapsible, disclosure, expand, collapse, chevron, caret, rotate, group header, section toggle
13916
14515
 
13917
14516
  ## Usage
13918
14517
 
@@ -13939,7 +14538,7 @@ Use Collapsible for a single piece of optional content that can expand in place.
13939
14538
  | --- | --- | --- |
13940
14539
  | CollapsibleTrigger | Base UI Collapsible.Trigger props + render | The button that toggles the panel. Compose with render={<Button variant="ghost" … />}. |
13941
14540
  | CollapsiblePanel | Base UI Collapsible.Panel props + className (keepMounted, hiddenUntilFound) | The collapsible contents. Overflow-hidden; animates height via the --collapsible-panel-height var Base UI sets. |
13942
- | CollapsibleIndicator | { className? } | Auto-rotating disclosure chevron. Place it inside the trigger content and add the `group/collapsible` class to the trigger so it flips 180° on open. |
14541
+ | CollapsibleIndicator | { direction?: "down" \| "end"; className? } | Auto-rotating disclosure chevron. Place it anywhere inside the trigger content; it reads the trigger's open state itself, no class to add. direction="down" (default) flips 180° on open; direction="end" points to the inline end while closed and turns down on open — the caret for a grouped list's section header or a tree row. Use it instead of rotating your own chevron with CSS, which snaps with no transition. |
13943
14542
 
13944
14543
  ## Props
13945
14544
 
@@ -13958,7 +14557,7 @@ Use Collapsible for a single piece of optional content that can expand in place.
13958
14557
  - **arrival** · Root · _no motion_ — A collapsible is part of the page it sits in, present from first paint. Its trigger is what the reader notices, not an entrance.
13959
14558
  - **response** · CollapsibleTrigger · _no motion_ — The trigger is whatever the consumer renders (usually a Button), and that control brings its own response. Adding one here would double it.
13960
14559
  - **transition** · CollapsiblePanel · `motion-disclosure` — The height change ties the content that appears to the trigger that was pressed; snapped open, everything below jumps with nothing linking cause to effect.
13961
- - **transition** · CollapsibleIndicator · `motion-interactive` — The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180 writes.
14560
+ - **transition** · CollapsibleIndicator · `motion-interactive` — The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180 and rotate-0 write.
13962
14561
  - **activity** · Root · _no motion_ — Nothing here is indeterminate; content that loads on open should put its own loader inside the panel.
13963
14562
 
13964
14563
  ## Accessibility
@@ -13995,7 +14594,15 @@ import { Button, Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@sabl
13995
14594
  ```tsx
13996
14595
  import { Button, Collapsible, CollapsibleIndicator, CollapsiblePanel, CollapsibleTrigger } from "@sableui/react"
13997
14596
 
13998
- <Collapsible><CollapsibleTrigger className="group/collapsible" render={<Button variant="ghost">Details <CollapsibleIndicator /></Button>} /><CollapsiblePanel><p className="pt-3">Revealed on toggle.</p></CollapsiblePanel></Collapsible>
14597
+ <Collapsible><CollapsibleTrigger render={<Button variant="ghost">Details <CollapsibleIndicator /></Button>} /><CollapsiblePanel><p className="pt-3">Revealed on toggle.</p></CollapsiblePanel></Collapsible>
14598
+ ```
14599
+
14600
+ ### Group header with a side caret
14601
+
14602
+ ```tsx
14603
+ import { Button, Collapsible, CollapsibleIndicator, CollapsiblePanel, CollapsibleTrigger } from "@sableui/react"
14604
+
14605
+ <Collapsible defaultOpen><CollapsibleTrigger render={<Button variant="ghost" size="xs"><CollapsibleIndicator direction="end" />In progress <span className="text-ui-num text-muted-foreground">5</span></Button>} /><CollapsiblePanel><ul className="text-ui-body"><li>Refine product detail page</li><li>Build the ingredient library</li></ul></CollapsiblePanel></Collapsible>
13999
14606
  ```
14000
14607
 
14001
14608
  ---
@@ -14683,9 +15290,9 @@ import { ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuGroupLabe
14683
15290
 
14684
15291
  **Status:** stable
14685
15292
 
14686
- A data-driven table over the Table primitives — a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc → desc → none), optional row click / selection, and an empty state. For server data or paging, sort/page the data and pair with Pagination.
15293
+ A data-driven table over the Table primitives — a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc → desc → none), optional row click / selection, rows grouped under collapsible header rows (groupBy), and an empty state. For server data or paging, sort/page the data and pair with Pagination.
14687
15294
 
14688
- **Keywords:** data table, table, sortable, columns, rows, empty state, pagination, list
15295
+ **Keywords:** data table, table, sortable, columns, rows, empty state, pagination, list, grouped, group by, sections, issue list, issue tracker, status groups, collapsible groups
14689
15296
 
14690
15297
  ## Usage
14691
15298
 
@@ -14697,7 +15304,8 @@ Use DataTable when a data array and column definition should render a sortable,
14697
15304
  - Do: Provide stable rowKey values for dynamic data so selection and sorting do not jump.
14698
15305
  - Do: Use sortable only on columns where ordering is obvious and useful.
14699
15306
  - Do: Pair server-side datasets with Pagination and pass already-sorted/paged rows.
14700
- - Do: Move to TanStack Table, rendered with Sable's Table parts, once the table needs server-side sorting or filtering, column visibility or resizing, grouping, or virtualised rows. DataTable stays dependency-free and does not grow into a data grid; `sable docs tables-with-tanstack` maps one onto the other.
15307
+ - Do: Group rows by status for an issue tracker (or by owner, by day) with groupBy + groups on ONE DataTable: every group shares the header and the column widths, each header row carries the group's count and an add action, and sorting applies within each group. Do not stack one DataTable per group inside Collapsibles; the header repeats and the columns stop lining up.
15308
+ - Do: Move to TanStack Table, rendered with Sable's Table parts, once the table needs server-side sorting or filtering, column visibility or resizing, nested or aggregated grouping, or virtualised rows. DataTable stays dependency-free and does not grow into a data grid; `sable docs tables-with-tanstack` maps one onto the other.
14701
15309
  - Don't: Make every row clickable and also fill it with competing buttons. Choose a clear row interaction model.
14702
15310
  - Don't: Use DataTable for heterogeneous cards or timeline content. A list or card layout will scan better.
14703
15311
 
@@ -14708,6 +15316,7 @@ Use DataTable when a data array and column definition should render a sortable,
14708
15316
  | Columns | yes | Definitions for headers, cell renderers, sorting, alignment, and classes. |
14709
15317
  | Rows | yes | The data array rendered through the column definitions. |
14710
15318
  | Empty state | no | Content spanning all columns when the row array is empty. |
15319
+ | Group header row | no | With groupBy: one row per group heading its own tbody (a th with scope=rowgroup across every column) holding the disclosure toggle, the group's icon, label and count, and an optional trailing action. |
14711
15320
  | Caption | no | Accessible table caption rendered by the underlying Table. |
14712
15321
 
14713
15322
  ## Props
@@ -14726,6 +15335,11 @@ Use DataTable when a data array and column definition should render a sortable,
14726
15335
  | defaultSort | { key: string; direction: "asc" \| "desc" } | — | Initial sort column + direction. |
14727
15336
  | empty | ReactNode | "No results." | Rendered spanning all columns when data is empty. Pass an EmptyState for the full treatment (icon, title, next step). |
14728
15337
  | caption | ReactNode | — | Bottom table caption. |
15338
+ | groupBy | (row) => string | — | Group the rows under header rows by the key this returns (a status, an owner, a day). One header serves every group, so the columns line up across them; sorting applies within each group. |
15339
+ | groups | { key: string; label?: ReactNode; icon?: ReactNode; action?: ReactNode; defaultExpanded?: boolean }[] | — | Order and presentation of the groups, by key: the header row's label (defaults to the key), a leading icon such as a status mark, a trailing action such as an icon Button that adds a row to the group, and whether it starts open (default true). Groups groupBy returns that are not listed follow, in the order their first row appears in data. A listed group with no rows is not shown. |
15340
+ | collapsible | boolean | true | Whether each group's header row is a toggle that opens and closes the group. A closed group keeps its header row and count; its rows are hidden and select-all skips them. |
15341
+ | expandedGroups | ReadonlySet<string> | — | The open groups, by key (controlled). Without it each group starts at its defaultExpanded and the table holds the state. |
15342
+ | onExpandedGroupsChange | (keys: Set<string>) => void | — | Called with the new set of open group keys when a group header is toggled. |
14729
15343
 
14730
15344
  ## Tokens used
14731
15345
 
@@ -14734,9 +15348,11 @@ Use DataTable when a data array and column definition should render a sortable,
14734
15348
  ## Motion
14735
15349
 
14736
15350
  - **arrival** · Root · _no motion_ — Rows render with the page or the query that produced them. Staggering rows in delays reading a table someone opened to scan.
15351
+ - **arrival** · Group rows · _no motion_ — Opening a group shows its rows at once: table rows cannot animate their height without leaving the table layout, and the rows are the content the reader asked for.
14737
15352
  - **response** · Row · `motion-interactive` — Body rows ease to a muted wash under the pointer (TableRow's recipe), which keeps the eye on the right line across a wide row of numbers. Sortable headers are ghost Buttons and answer on Button's recipe.
14738
15353
  - **transition** · Sort chevron · `motion-interactive` — One chevron turns to show direction instead of two glyphs swapping, so ascending to descending reads as the same column reversing. Its opacity rises from the resting 60% as the column becomes the sort.
14739
15354
  - **transition** · Row · `motion-interactive` — A checked row's fill eases in across its cells, so selecting by checkbox reads as the row being picked rather than a band stamped over it.
15355
+ - **transition** · Group chevron · `motion-interactive` — A group's chevron turns from pointing along the reading direction (closed) to down (open), so the group reads as folding rather than a glyph being swapped.
14740
15356
  - **activity** · Root · _no motion_ — DataTable has no loading state. While data is fetched, render Skeleton rows (motion-shimmer) in its place — only the caller knows a fetch is in flight.
14741
15357
 
14742
15358
  ## Accessibility
@@ -14744,6 +15360,7 @@ Use DataTable when a data array and column definition should render a sortable,
14744
15360
  - Sorted columns set aria-sort=ascending/descending on the header cell.
14745
15361
  - Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.
14746
15362
  - Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure.
15363
+ - Grouped, each group is its own <tbody> (a rowgroup) headed by a <th scope="rowgroup"> spanning the columns, so group headers are never read as data rows; the toggle is a real button with aria-expanded and aria-controls naming that tbody.
14747
15364
 
14748
15365
  ## Related
14749
15366
 
@@ -14812,6 +15429,33 @@ import { Card, CardContent, CardHeader, CardTitle, DataTable } from "@sableui/re
14812
15429
  <Card><CardHeader><CardTitle>Deployments</CardTitle></CardHeader><CardContent flush><DataTable data={deployments} rowKey={(row) => row.id} columns={[{ key: "project", header: "Project", sortable: true }, { key: "duration", header: "Duration", align: "end" }]} /></CardContent></Card>
14813
15430
  ```
14814
15431
 
15432
+ ### Issues grouped by status
15433
+
15434
+ ```tsx
15435
+ import { Button, DataTable, Icon, StatusDot } from "@sableui/react"
15436
+
15437
+ const STATUSES = [
15438
+ { key: "in-progress", label: "In progress", icon: <StatusDot tone="warning" /> },
15439
+ { key: "in-review", label: "In review", icon: <StatusDot tone="purple" /> },
15440
+ { key: "todo", label: "Todo", icon: <StatusDot /> },
15441
+ { key: "done", label: "Done", icon: <StatusDot tone="success" />, defaultExpanded: false },
15442
+ ]
15443
+
15444
+ <DataTable
15445
+ data={issues}
15446
+ rowKey={(t) => t.id}
15447
+ groupBy={(t) => t.status}
15448
+ groups={STATUSES.map((s) => ({ ...s, action: <Button variant="ghost" size="icon-xs" aria-label={`Add issue to ${s.label}`} onClick={() => create(s.key)}><Icon name="plus" /></Button> }))}
15449
+ onRowClick={(t) => setSelectedId(t.id)}
15450
+ isRowSelected={(t) => t.id === selectedId}
15451
+ columns={[
15452
+ { key: "id", header: "ID", className: "w-20", cell: (t) => <span className="text-mono-caption text-muted-foreground">{t.id}</span> },
15453
+ { key: "title", header: "Issue", sortable: true },
15454
+ { key: "due", header: "Due", sortable: true, sortValue: (t) => t.due.getTime(), cell: (t) => formatDate(t.due) },
15455
+ ]}
15456
+ />
15457
+ ```
15458
+
14815
15459
  ### Empty state
14816
15460
 
14817
15461
  ```tsx
@@ -14828,7 +15472,7 @@ import { Button, DataTable, EmptyState, Icon } from "@sableui/react"
14828
15472
 
14829
15473
  Date (and optionally time) picker: a field-shaped trigger (the Input / SelectTrigger shell) opens a Popover holding Sable's Calendar. With granularity="day" selecting a day fires onValueChange and closes; granularity="minute" adds an hour + minute row of Selects and keeps the time-of-day when the day changes.
14830
15474
 
14831
- **Keywords:** date picker, calendar, date input, date time picker, time, schedule, form
15475
+ **Keywords:** date picker, due date, property row, inspector, calendar, date input, date time picker, time, schedule, form
14832
15476
 
14833
15477
  ## Usage
14834
15478
 
@@ -14857,6 +15501,8 @@ Use DatePicker when users need to choose one date — or, with granularity="minu
14857
15501
  | disabled | boolean | — | Disable the trigger and block selection. |
14858
15502
  | minDate | Date | — | Earliest selectable day (inclusive). Earlier days are disabled and navigation is bounded. |
14859
15503
  | maxDate | Date | — | Latest selectable day (inclusive). Later days are disabled and navigation is bounded. |
15504
+ | size | "md" \| "xs" | "md" | The trigger's rung. md is the form field beside Input and Select. xs is the property-grid rung (24px in a workspace): an inset panel-field well with no border, the same shape as SelectTrigger size="xs" and ScrubField on the neighbouring rows of an inspector — use it for a due date in a PropertyRow instead of a native date input. |
15505
+ | formatOptions | Intl.DateTimeFormatOptions | — | How the trigger writes the chosen date, as Intl.DateTimeFormat options in the reader's locale — { month: "short", day: "numeric" } for "Oct 23" where the rest of a list writes dates that way. Omitted: "Oct 23, 2026" (with "at 14:30" under granularity="minute"). |
14860
15506
  | className | string | — | Extra classes merged onto the trigger. |
14861
15507
  | id / aria-label / aria-labelledby / aria-describedby / aria-invalid / aria-required | button attributes | — | Forwarded to the trigger, so a wrapping Field ties its label to the picker and announces its description and error. Pass them yourself only when labelling the picker without a Field. |
14862
15508
 
@@ -14878,6 +15524,18 @@ Use DatePicker when users need to choose one date — or, with granularity="minu
14878
15524
 
14879
15525
  ## Examples
14880
15526
 
15527
+ ### In an inspector row
15528
+
15529
+ ```tsx
15530
+ import { DatePicker, PanelSection, PropertyRow } from "@sableui/react"
15531
+
15532
+ <PanelSection title="Properties">
15533
+ <PropertyRow label="Due date">
15534
+ <DatePicker size="xs" aria-label="Due date" value={due} onValueChange={setDue} formatOptions={{ month: "short", day: "numeric" }} />
15535
+ </PropertyRow>
15536
+ </PanelSection>
15537
+ ```
15538
+
14881
15539
  ### Controlled
14882
15540
 
14883
15541
  ```tsx
@@ -17532,7 +18190,7 @@ Use Menu for a compact list of actions opened from a visible trigger.
17532
18190
  | MenuLinkItem | Base UI Menu.LinkItem props (href, target, closeOnClick) + className + icon?: ReactNode + description?: ReactNode | A row that navigates: a real <a>, so it opens in a new tab, middle-clicks and copies as a link. With target="_blank" it adds rel="noopener noreferrer" and a trailing ↗. Takes icon and description like MenuItem. |
17533
18191
  | MenuSeparator | Base UI Separator props + className | Hairline rule between item runs. |
17534
18192
  | MenuSubmenu | Base UI Menu.SubmenuRoot props | A nested menu. Wraps a MenuSubmenuTrigger and its own MenuContent — pass side="inline-end" to that content so the submenu opens away from its parent in RTL as well as LTR. |
17535
- | MenuSubmenuTrigger | Base UI Menu.SubmenuTrigger props + value: ReactNode + className | The row that opens a submenu: item styling, a trailing chevron, and an optional `value` showing the current choice so the row reads "Effort High ›" rather than making the reader open it to find out. |
18193
+ | MenuSubmenuTrigger | Base UI Menu.SubmenuTrigger props + value: ReactNode + icon: ReactNode + className | The row that opens a submenu: item styling, a trailing chevron, and an optional `value` showing the current choice so the row reads "Effort High ›" rather than making the reader open it to find out. `icon` leads the row as MenuItem's does; give it one whenever the items around it have icons, or its label starts a column to their left. |
17536
18194
  | MenuRadioGroup | Base UI Menu.RadioGroup props (value, defaultValue, onValueChange) | A single-choice group inside a menu. Controlled via value/onValueChange, uncontrolled via defaultValue. |
17537
18195
  | MenuRadioItem | Base UI Menu.RadioItem props + className (value, disabled) | One option in a MenuRadioGroup, rendered as menuitemradio with a real checked state. The check sits in a reserved leading column so labels never shift sideways as the highlight moves. |
17538
18196
  | MenuCheckboxItem | Base UI Menu.CheckboxItem props + className + { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean } | A toggling row, with the same reserved leading column as MenuRadioItem so a menu mixing the two keeps one text margin. |
@@ -19132,7 +19790,7 @@ const [page, setPage] = useState(searchParams.page ?? 1)
19132
19790
 
19133
19791
  A row of docked chrome panels around a flexible centre, with draggable and keyboard-resizable edges and optional persisted widths.
19134
19792
 
19135
- **Keywords:** panel, panels, resize, resizable, splitter, docked, workspace, tool, chrome, inspector, layout
19793
+ **Keywords:** panel, panels, resize, resizable, splitter, docked, workspace, tool, chrome, inspector, layout, mobile, responsive, sheet, drawer, narrow, detail pane
19136
19794
 
19137
19795
  ## Usage
19138
19796
 
@@ -19143,6 +19801,8 @@ Use PanelGroup for the body of a tool workspace: fixed-width Panels docked left
19143
19801
  - Do: Give every docked Panel a fixed width and leave the centre Panel width-less so it absorbs the remaining space.
19144
19802
  - Do: Drive the width with usePanelWidth and pass a storageKey, so a user's layout survives a reload. Spread its bounds onto the PanelResizer rather than repeating min and max by hand.
19145
19803
  - Do: Label every Panel — they are landmarks, and 'Layers' or 'Properties' is what a screen-reader user navigates by.
19804
+ - Do: Decide what each docked Panel becomes on a phone with `narrow`: "sheet" for an inspector or a navigation rail the user opens on demand, "hidden" for chrome a phone does without. Drive a sheet's `open` from the same state that selects the record, so selecting a row opens the inspector at every width.
19805
+ - Don't: Render the same inspector twice, once in a Panel and once in a Sheet behind a hand-rolled viewport check. `narrow="sheet"` renders the content once, in whichever place applies.
19146
19806
  - Don't: Let the group scroll. Only panels and the canvas scroll; a scrolling group means the shell lost its height constraint.
19147
19807
  - Don't: Use it for page content columns — that's Grid or Columns. PanelGroup is chrome, and it sits outside the content grid.
19148
19808
  - Don't: Omit PanelResizer to save effort and then let users live with a fixed panel. If a panel holds a tree or a property grid, its width is a preference.
@@ -19159,7 +19819,7 @@ Use PanelGroup for the body of a tool workspace: fixed-width Panels docked left
19159
19819
 
19160
19820
  | Name | Type | Description |
19161
19821
  | --- | --- | --- |
19162
- | Panel | { label: string; width?: number; scroll?: boolean } | A docked or flexible column. `label` names the landmark; omit `width` for the flexible centre. `scroll` defaults to true — set it false for a header + scrolling body + pinned footer, so the footer doesn't scroll away with the list. When you own the layout, give the header and footer shrink-0 and the middle min-h-0 flex-1 overflow-y-auto. overflow-y-auto only activates if every flex ancestor can shrink: a single missing min-h-0 anywhere up the chain makes the panel grow instead of scroll, and nothing reports it. |
19822
+ | Panel | { label: string; width?: number; scroll?: boolean; narrow?: "dock" \| "sheet" \| "hidden"; open?: boolean; onOpenChange?: (open: boolean) => void; side?: "left" \| "right"; below?: "sm" \| "md" \| "lg" \| "xl" \| "2xl" } | A docked or flexible column. Below `below` (default lg, where Sidebar and BottomNav swap), `narrow` decides what it becomes: "dock" (default) stays docked, "sheet" renders the same content once inside a Sheet from `side` (default "right"; a panel before the canvas is "left"), titled by `label`, open while `open` is true and closed through `onOpenChange` (Esc, scrim, swipe), and "hidden" drops it. `open` defaults to true docked (false removes the column, so the canvas takes its width) and false as a sheet. Crossing the breakpoint remounts the content, so keep state that must survive it in the app. `label` names the landmark; omit `width` for the flexible centre. `scroll` defaults to true — set it false for a header + scrolling body + pinned footer, so the footer doesn't scroll away with the list. When you own the layout, give the header and footer shrink-0 and the middle min-h-0 flex-1 overflow-y-auto. overflow-y-auto only activates if every flex ancestor can shrink: a single missing min-h-0 anywhere up the chain makes the panel grow instead of scroll, and nothing reports it. |
19163
19823
  | PanelResizer | { side: 'left' \| 'right'; width: number; onWidthChange: (n: number) => void; label: string; min?: number; max?: number; step?: number } | The draggable edge. `side` says which side the resized panel is on, so the drag delta's sign is right. |
19164
19824
  | usePanelWidth | (defaultWidth: number, options?: { storageKey?: string; min?: number; max?: number }) => readonly [number, (n: number) => void, { min: number; max: number }] | Controlled width for a Panel, clamped and optionally persisted to localStorage (only when `storageKey` is passed). Read through useSyncExternalStore, so there is no server/client hydration mismatch and no setState in an effect; do not hand-roll a localStorage read during render or a write from an effect. Without `storageKey` the width is kept in memory per hook instance. The third element is the `{ min, max }` it clamps to: spread it onto the paired PanelResizer so the separator's aria-valuemin/max and Home/End match the real clamp. |
19165
19825
 
@@ -19238,6 +19898,36 @@ function Body() {
19238
19898
  }
19239
19899
  ```
19240
19900
 
19901
+ ### Inspector that becomes a sheet on phones
19902
+
19903
+ ```tsx
19904
+ import { Button, Input, Panel, PanelGroup, PanelSection, PropertyRow } from "@sableui/react"
19905
+
19906
+ function Body() {
19907
+ const [selected, setSelected] = useState<string | null>(null)
19908
+ return (
19909
+ <PanelGroup>
19910
+ <Panel label="Issues">
19911
+ <div className="p-2">
19912
+ <Button size="xs" variant="ghost" onClick={() => setSelected("AES-104")}>AES-104 Refine product page</Button>
19913
+ </div>
19914
+ </Panel>
19915
+ <Panel
19916
+ label="Issue details"
19917
+ width={320}
19918
+ narrow="sheet"
19919
+ open={selected !== null}
19920
+ onOpenChange={(open) => !open && setSelected(null)}
19921
+ >
19922
+ <PanelSection title={selected ?? ""}>
19923
+ <PropertyRow label="Status"><Input defaultValue="In progress" /></PropertyRow>
19924
+ </PanelSection>
19925
+ </Panel>
19926
+ </PanelGroup>
19927
+ )
19928
+ }
19929
+ ```
19930
+
19241
19931
  ### Fixed panel, no resizer
19242
19932
 
19243
19933
  ```tsx
@@ -21756,9 +22446,9 @@ import { ShowMore } from "@sableui/react"
21756
22446
 
21757
22447
  **Status:** stable
21758
22448
 
21759
- Desktop side-navigation rail (in-flow sticky, hidden below lg) with SidebarHeader / SidebarBrand / SidebarNav / SidebarItem / SidebarFooter parts. Items are router-agnostic via the `render` prop; an optional collapsed icon-rail mode reveals labels in tooltips.
22449
+ Desktop side-navigation rail (in-flow sticky, hidden below lg) with SidebarHeader / SidebarBrand / SidebarNav / SidebarGroup / SidebarItem / SidebarFooter / SidebarAccount parts, all on one set of columns. Items are router-agnostic via the `render` prop; an optional collapsed icon-rail mode reveals labels in tooltips.
21760
22450
 
21761
- **Keywords:** sidebar, navigation, app chrome, rail
22451
+ **Keywords:** sidebar, navigation, app chrome, rail, nav group, section label, account, user menu, profile, account switcher, workspace switcher, sidebar footer
21762
22452
 
21763
22453
  ## Usage
21764
22454
 
@@ -21770,6 +22460,9 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
21770
22460
  - Do: Keep item labels predictable and active state obvious.
21771
22461
  - Do: Set surface="page" when a full-width top bar sits above the rail. A theme's tinted or dark rail is for a full-height rail that frames the app.
21772
22462
  - Do: Keep surface="rail" inside AppShell frame="inset": the rail's tint becomes the ground the main panel floats on. Do not add a border-r class back — the gap is the divider.
22463
+ - Do: Group items with SidebarGroup and end the rail with one SidebarAccount that opens the account menu (settings, appearance, sign out). Every part sits on the same icon and label columns, at either density.
22464
+ - Don't: Do not put SideNav, an Entity or a stack of ghost Buttons in the rail. Each brings its own insets, so the icons and labels land on four different columns. SideNav is the in-page section list; the rail has its own parts.
22465
+ - Don't: Do not put a Badge or pill in the account row. Its one quiet detail line carries the plan, role or 'Demo'.
21773
22466
  - Don't: Do not use Sidebar for page-local settings, filters, or temporary action groups.
21774
22467
  - Don't: Do not put page content in it. It is fixed-width chrome OUTSIDE the content grid, so anything in it is exempt from the page's measure and rhythm.
21775
22468
 
@@ -21784,9 +22477,11 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
21784
22477
 
21785
22478
  | Name | Type | Description |
21786
22479
  | --- | --- | --- |
21787
- | SidebarHeader | div props | The top of the rail, at least 64px tall. It holds SidebarBrand and may take a second row under it, such as a search field or a notifications button: give that row basis-full and the header grows to hold it instead of clipping. |
22480
+ | SidebarHeader | div props | The top of the rail, at least 64px tall (the chrome bar's height inside a compact Workspace, so it lines up with the WorkspaceBar beside it). It holds SidebarBrand and may take a second row under it, such as a search field or a notifications button: give that row basis-full and the header grows to hold it instead of clipping. |
21788
22481
  | SidebarBrand | { mark: ReactNode; label: ReactNode; render?: useRender.RenderProp } + div props | The brand row, inside SidebarHeader, on the same grid as SidebarItem: mark in the icons' 24px slot, label on the labels' column, so a logo lines up with the nav below it without copying the rail's inset numbers. Collapsed, it shows the mark alone (named by a string label). render={<Link to="/" />} makes it the home link. |
21789
- | SidebarItem | { label: ReactNode; icon?: ReactNode; active?: boolean; badge?: ReactNode; render?: useRender.RenderProp } + button props | A single nav row. label is the row text (inline when expanded, in a hover tooltip when collapsed); icon is the leading icon node (the consumer swaps fill weight for the active state); active marks the current route (accent fill + aria-current="page"); badge is a trailing node (e.g. a count) pushed to the row end and hidden when collapsed; render composes another element, e.g. render={<Link to="/x" />}. |
22482
+ | SidebarItem | { label: ReactNode; icon?: ReactNode; active?: boolean; badge?: ReactNode; render?: useRender.RenderProp } + button props | A single nav row, 44px on a page and 32px inside a compact scope such as a Workspace (the rail reads the density; nothing to set). label is the row text (inline when expanded, in a hover tooltip when collapsed); icon is the leading icon node (the consumer swaps fill weight for the active state); active marks the current route (accent fill + aria-current="page"); badge is a trailing node (e.g. a count) pushed to the row end and hidden when collapsed; render composes another element, e.g. render={<Link to="/x" />}. |
22483
+ | SidebarGroup | { label?: ReactNode } + div props | A labelled run of SidebarItems inside SidebarNav ("Workspace", "Favorites"). The label starts on the items' icon column; collapsed, a hairline stands in for it and the label stays as the group's accessible name. |
22484
+ | SidebarAccount | { avatar: ReactNode; label: ReactNode; detail?: ReactNode; indicator?: ReactNode \| false; render?: useRender.RenderProp } + button props | The account row at the foot of the rail, inside SidebarFooter: the avatar in the icon slot (Avatar xs on a page rail, 2xs in a compact one), the label on the label column, one muted detail line, and an up-down chevron. Make it the trigger of the account Menu (render it through MenuTrigger render) and put Settings, appearance and sign-out there; it holds its fill while the menu is open. Collapsed, the avatar alone, with the label in a tooltip. |
21790
22485
 
21791
22486
  ## Props
21792
22487
 
@@ -21846,6 +22541,14 @@ const [collapsed, setCollapsed] = useState(false)
21846
22541
  <Sidebar collapsed={collapsed}><SidebarNav><SidebarItem icon={<Icon name="home" />} label="Home" active render={<Link to="/" />} /></SidebarNav><SidebarFooter><Button variant="ghost" size="icon" onClick={() => setCollapsed((c) => !c)} aria-label="Toggle sidebar"><Icon name="chevron-left" /></Button></SidebarFooter></Sidebar>
21847
22542
  ```
21848
22543
 
22544
+ ### Grouped nav with an account menu
22545
+
22546
+ ```tsx
22547
+ import { Avatar, Icon, Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger, Sidebar, SidebarAccount, SidebarBrand, SidebarFooter, SidebarGroup, SidebarHeader, SidebarItem, SidebarNav } from "@sableui/react"
22548
+
22549
+ <Sidebar><SidebarHeader><SidebarBrand mark={<Avatar shape="square" size="xs" fallback="F" alt="" />} label="Forma Studio" /></SidebarHeader><SidebarNav><SidebarItem icon={<Icon name="inbox" />} label="Inbox" badge={<span className="text-ui-num">4</span>} /><SidebarGroup label="Workspace"><SidebarItem icon={<Icon name="folder" />} label="Projects" active /><SidebarItem icon={<Icon name="users" />} label="Clients" /></SidebarGroup></SidebarNav><SidebarFooter><Menu><MenuTrigger render={<SidebarAccount avatar={<Avatar size="xs" fallback="EL" alt="" />} label="Emma Lee" detail="Forma Studio" />} /><MenuContent side="top" align="start"><MenuItem>Settings</MenuItem><MenuSeparator /><MenuItem>Sign out</MenuItem></MenuContent></Menu></SidebarFooter></Sidebar>
22550
+ ```
22551
+
21849
22552
  ### Inline (inside a bounded frame)
21850
22553
 
21851
22554
  ```tsx
@@ -22960,6 +23663,7 @@ Use Tabs to switch between related panels within the same page context.
22960
23663
  ### Best practices
22961
23664
 
22962
23665
  - Do: Use tabs when each panel is peer content and users may compare or switch often.
23666
+ - Do: In tool chrome (a WorkspaceBar, a compact panel header) use variant="pill" for a view switch such as Overview / Issues / Updates: it reads as a control beside the bar's other controls. Underline is the page-section idiom; inside a WorkspaceBar it sits on the bar's hairline rather than drawing its own.
22963
23667
  - Do: Keep tab labels short and make the active tab unmistakable.
22964
23668
  - Do: Use variant="pill" for a short switcher inside a card or toolbar (Preview / Code, List / Grid); keep the default underline for sections of a page.
22965
23669
  - Don't: Do not use Tabs for unrelated pages or primary navigation that needs distinct URLs. → use **SideNav**, **NavigationMenu**
@@ -24448,7 +25152,7 @@ import { Video } from "@sableui/react"
24448
25152
 
24449
25153
  The shell for a tool: a fixed viewport partitioned into independently scrolling regions, with a compact density scope, a skip link, and a canvas landmark.
24450
25154
 
24451
- **Keywords:** workspace, tool, editor, shell, frame, canvas, chrome, panels, density, viewport
25155
+ **Keywords:** workspace, tool, editor, shell, frame, canvas, chrome, panels, density, viewport, issue tracker, tracker, inspector, reading view, mobile, responsive, bottom nav, media query, breakpoint, top bar, topbar
24452
25156
 
24453
25157
  ## Usage
24454
25158
 
@@ -24459,6 +25163,9 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
24459
25163
  - Do: Put a PanelGroup in the body and a WorkspaceCanvas inside it, so panels are aside chrome and the work surface is the main landmark.
24460
25164
  - Do: Let each panel own its scrolling. The shell never scrolls, and the canvas clips rather than scrolls — panning is the viewport's job.
24461
25165
  - Do: Keep the density scope at compact unless you have a specific reason; the workspace register's measured control ladder is what makes tool chrome read as tool chrome.
25166
+ - Do: Put a WorkspaceBar inside a column when the bar belongs to that column only. An issue tracker's bar (breadcrumb, view tabs, filters) sits above the list, while the sidebar and the inspector beside it run the full height: put the WorkspaceBar first inside the WorkspaceCanvas, not in the `toolbar` slot, which spans every panel. Don't hand-build a fixed-height header for it.
25167
+ - Do: Wrap a reading view inside the workspace (a project overview, an update, a doc, release notes) in <Density value="comfortable">. The compact scope maps body text to the 11–12px UI tiers, which is right for chrome and too small for paragraphs; the comfortable scope gives prose its page size again while the chrome around it stays compact.
25168
+ - Do: Plan the phone layout with the shell rather than around it: pass a BottomNav to `bottomNav`, and give docked Panels `narrow="sheet"` (an inspector, a navigation rail) or `narrow="hidden"`. Don't measure the viewport with a resize listener, render the inspector twice, or subtract the nav's height from 100dvh.
24462
25169
  - Don't: Nest a Region or Page inside it to set a width. Region caps are a page-register idea; a workspace is edge-to-edge and its panels are fixed-width chrome outside the content grid.
24463
25170
  - Don't: Hardcode a control height (h-[32px]). CI rejects it (raw-control-height); read the ladder instead, e.g. h-(--control-sm), so the density scope can size it.
24464
25171
  - Don't: Put a Card in the chrome. Use PanelSection — a card inside a narrow panel spends its border and inset on nothing.
@@ -24472,6 +25179,7 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
24472
25179
  | Body | yes | The flexible middle row, normally a PanelGroup. Constrained so its children can shrink and scroll. |
24473
25180
  | WorkspaceCanvas | no | The work surface and the main#main-content landmark the skip link targets. Clips rather than scrolls. |
24474
25181
  | StatusBar | no | Bottom chrome for ambient document facts. |
25182
+ | BottomNav | no | Phone navigation in the `bottomNav` slot. Its row is reserved below lg and gone above it, so the body ends where the nav begins. |
24475
25183
 
24476
25184
  ## Subcomponents
24477
25185
 
@@ -24479,6 +25187,8 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
24479
25187
  | --- | --- | --- |
24480
25188
  | WorkspaceBar | ComponentProps<'header'> | The top chrome strip, sized to --chrome-bar-height with a hairline beneath. |
24481
25189
  | WorkspaceCanvas | ComponentProps<'main'> | The work surface: renders main#main-content, fills the body, clips its overflow. |
25190
+ | useMediaQuery | (query: string, options?: { serverValue?: boolean }) => boolean | True while a media query matches, read through useSyncExternalStore: no resize listener, no setState in an effect, no hydration mismatch (the server reports serverValue, default false). Use it to branch structure a class cannot express; style-only changes stay as lg: classes. |
25191
+ | useBelowBreakpoint | (breakpoint?: "sm" \| "md" \| "lg" \| "xl" \| "2xl", options?: { serverValue?: boolean }) => boolean | True below a named breakpoint (default lg), flipping at the width where that breakpoint's classes start to apply (Tailwind's sm 40 / md 48 / lg 64 / xl 80 / 2xl 96rem). Panel's `narrow` uses it; reach for it instead of a hand-rolled window.innerWidth check. |
24482
25192
 
24483
25193
  ## Props
24484
25194
 
@@ -24486,6 +25196,7 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
24486
25196
  | --- | --- | --- | --- |
24487
25197
  | toolbar | ReactNode | — | Top chrome, normally a WorkspaceBar. |
24488
25198
  | statusBar | ReactNode | — | Bottom chrome, normally a StatusBar. |
25199
+ | bottomNav | ReactNode | — | Phone navigation, normally a BottomNav. Below lg (BottomNav's own breakpoint) the shell reserves the nav's height as its last row, so the body and the status bar end above the nav instead of under it; above lg the row is gone. Every slot is optional: the body is the shell's one growing row whichever of toolbar, statusBar and bottomNav are present. |
24489
25200
  | density | "compact" \| "comfortable" | "compact" | Density scope for everything inside. Sets the --control-* ladder via data-density; compact is the workspace register. Nothing hardcodes a height, so the same size="md" Button is 40px on a page and 32px inside a compact Workspace. A Dialog opened from inside resets to comfortable, because a modal is a page-register surface even when a tool launched it. |
24490
25201
  | skipLinkLabel | string | "Skip to canvas" | Text for the skip link that targets the canvas landmark. |
24491
25202
 
@@ -24567,6 +25278,79 @@ import { Input, Panel, PanelGroup, PanelSection, PropertyRow, StatusBar, Workspa
24567
25278
  </Workspace>
24568
25279
  ```
24569
25280
 
25281
+ ### Issue tracker: column bar, docked inspector, phone sheet
25282
+
25283
+ ```tsx
25284
+ import { BottomNav, BottomNavItem, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, Breadcrumbs, BreadcrumbSeparator, Button, Icon, Input, Panel, PanelGroup, PanelSection, PropertyRow, StatusBar, Workspace, WorkspaceBar, WorkspaceCanvas } from "@sableui/react"
25285
+
25286
+ function Tracker() {
25287
+ const [selected, setSelected] = useState<string | null>("AES-104")
25288
+ return (
25289
+ <Workspace
25290
+ bottomNav={
25291
+ <BottomNav>
25292
+ <BottomNavItem showLabel label="Issues" icon={<Icon name="list" />} active />
25293
+ <BottomNavItem showLabel label="Inbox" icon={<Icon name="inbox" />} />
25294
+ </BottomNav>
25295
+ }
25296
+ statusBar={<StatusBar><span>12 issues</span></StatusBar>}
25297
+ >
25298
+ <PanelGroup>
25299
+ <Panel label="Navigation" width={220} narrow="hidden">
25300
+ <PanelSection title="Workspace">
25301
+ <PropertyRow label="Team"><Input defaultValue="Studio" /></PropertyRow>
25302
+ </PanelSection>
25303
+ </Panel>
25304
+ <WorkspaceCanvas className="flex flex-col">
25305
+ <WorkspaceBar>
25306
+ <Breadcrumbs>
25307
+ <BreadcrumbItem><BreadcrumbLink href="#">Studio</BreadcrumbLink></BreadcrumbItem>
25308
+ <BreadcrumbSeparator />
25309
+ <BreadcrumbItem><BreadcrumbPage>Digital flagship</BreadcrumbPage></BreadcrumbItem>
25310
+ </Breadcrumbs>
25311
+ </WorkspaceBar>
25312
+ <div className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">
25313
+ <Button size="xs" variant="ghost" onClick={() => setSelected("AES-104")}>AES-104 Refine product page</Button>
25314
+ </div>
25315
+ </WorkspaceCanvas>
25316
+ <Panel
25317
+ label="Issue details"
25318
+ width={320}
25319
+ narrow="sheet"
25320
+ open={selected !== null}
25321
+ onOpenChange={(open) => !open && setSelected(null)}
25322
+ >
25323
+ <PanelSection title={selected ?? ""}>
25324
+ <PropertyRow label="Status"><Input defaultValue="In progress" /></PropertyRow>
25325
+ </PanelSection>
25326
+ </Panel>
25327
+ </PanelGroup>
25328
+ </Workspace>
25329
+ )
25330
+ }
25331
+ ```
25332
+
25333
+ ### Reading view inside a workspace
25334
+
25335
+ ```tsx
25336
+ import { Density, PanelGroup, Workspace, WorkspaceCanvas } from "@sableui/react"
25337
+
25338
+ <Workspace>
25339
+ <PanelGroup>
25340
+ <WorkspaceCanvas className="overflow-y-auto scrollbar-quiet">
25341
+ <Density value="comfortable">
25342
+ <article className="max-w-prose p-6">
25343
+ <h2 className="text-heading-3">Project overview</h2>
25344
+ <p className="mt-3 text-body-md text-muted-foreground">
25345
+ Paragraphs keep their page size here, while the chrome around the canvas stays compact.
25346
+ </p>
25347
+ </article>
25348
+ </Density>
25349
+ </WorkspaceCanvas>
25350
+ </PanelGroup>
25351
+ </Workspace>
25352
+ ```
25353
+
24570
25354
  ### Comfortable density
24571
25355
 
24572
25356
  ```tsx
@@ -24776,8 +25560,10 @@ Which token to reach for, and what it is most often confused with. Generated fro
24776
25560
  - **`region-ultrawide (legacy — prefer region-full)`** — Deprecated. Kept so existing usage keeps resolving. *Use for:* Nothing new. Use `region-full`.
24777
25561
  - **Not** `region-full`, which is what this became.
24778
25562
 
24779
- ### typographyOverrides (60)
25563
+ ### typographyOverrides (61)
24780
25564
 
25565
+ - **`type-base`** — The base size the page type scale is built on, as a unitless px number — 16 by default. Every page tier keeps its ratio to it, so `14` rebuilds the whole scale at 14, and the baseline step and spacing follow the body down. *Use for:* When the product asks for smaller or larger type everywhere: a dense technical tool at 13-14, a reading product at 17. Set it on :root, a theme scope or a preset (`tokens.shared["type-base"]: "14"`), not per component.
25566
+ - **Not** Changing the root font-size, which also moves every rem the app wrote and the control ladder; or overriding tier sizes one by one, which breaks the ratios between them. Unitless: `14`, never `14px`.
24781
25567
  - **`type-display-4xl-size`** — The largest type: display-lg through display-4xl. Marketing-scale, one per view at most. *Use for:* A landing page hero, a section opener on a brand surface.
24782
25568
  - **Not** `heading-1` for page titles. Display is a *statement*; headings are *structure*. A settings page titled in display-3xl looks like an advert.
24783
25569
  - **`type-title-lg-size`** — The title of a *component*, not of a document. *Use for:* A card's heading, a dialog title, a section label inside a panel.
@@ -25051,7 +25837,7 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
25051
25837
  - **Instead of** `scrollbar-quiet` on a vertical list. A vertical region with no bar and no fade tells a reader nothing about how far through they are.
25052
25838
  - `<div className="flex gap-3 overflow-x-auto scrollbar-hidden">…</div>`
25053
25839
 
25054
- ### text (12)
25840
+ ### text (13)
25055
25841
 
25056
25842
  - **`sable-list`** — A bulleted list as typography: body type, a hanging muted marker, and item rhythm. Put it on a native `<ul>`. *Use for:* Running content with bullets: release notes, a feature list, steps that are not ordered.
25057
25843
  - **Instead of** A stack of `<div>`s with a hand-drawn dot, or an unstyled `<ul>` that inherits the reset (no markers, no indent).
@@ -25068,6 +25854,9 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
25068
25854
  - **`text-overline`** — The eyebrow / overline / kicker tier: small, letter-spaced, uppercase by default (a theme may set sentence case via `--type-overline-transform`). *Use for:* A section eyebrow above a heading, a stat's label, a badge-style category line. Sits ABOVE and categorises.
25069
25855
  - **Instead of** `text-label-xs uppercase tracking-wider` assembled by hand, or `text-caption`, which sits BELOW and explains. In tool chrome, panel headers use `text-ui-section` instead.
25070
25856
  - `<p className="text-overline text-muted-foreground">Latest news</p>`
25857
+ - **`text-ui-title`** — The title of what a panel or inspector shows — the open issue, the selected object: 14px at 600 on a 20px line, one step above `text-ui-md`. *Use for:* The one title at the top of an inspector, a docked panel or a peek view inside the workspace register. Compact `text-title-lg` resolves to it on its own.
25858
+ - **Instead of** `text-ui-md` made heavier (a title the size of the description under it reads as a label), or `text-heading-*`, which is page structure rather than chrome.
25859
+ - `<h2 className="text-ui-title text-panel-foreground">Refine product detail page</h2>`
25071
25860
  - **`text-ui-md`** — Workspace-register chrome text, the default tier: ~12px at weight ~500 on 16px leading. *Use for:* Labels and values in tool chrome — panels, inspectors, toolbars, trees — inside the workspace register.
25072
25861
  - **Instead of** `text-label-sm` or `text-body-sm` in a dense panel (the page register's weights read as shouting at this size), or a size class plus a weight class composed by hand.
25073
25862
  - `<span className="text-ui-md text-panel-foreground">Opacity</span>`