@sableui/react 0.16.0 → 0.18.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 (95) hide show
  1. package/AGENTS.md +10 -3
  2. package/CHANGELOG.md +132 -0
  3. package/README.md +11 -4
  4. package/bin/sable.mjs +160 -18
  5. package/dist/activity-DT8VK9fx.d.ts +25 -0
  6. package/dist/adapters/github.d.ts +41 -0
  7. package/dist/adapters/github.js +24 -0
  8. package/dist/blocks/approval-block.js +30 -48
  9. package/dist/blocks/approval-block.meta.js +7 -8
  10. package/dist/blocks/index.d.ts +1 -1
  11. package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
  12. package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
  13. package/dist/components/activity-heatmap/index.js +9 -0
  14. package/dist/components/code-block/code-block.js +5 -3
  15. package/dist/components/code-block/code-block.meta.js +6 -0
  16. package/dist/components/dialog/dialog.js +25 -5
  17. package/dist/components/dialog/dialog.meta.js +26 -5
  18. package/dist/components/media-image/media-image.js +3 -1
  19. package/dist/components/media-image/media-image.meta.js +15 -1
  20. package/dist/components/media-tile/media-tile.js +13 -1
  21. package/dist/components/media-tile/media-tile.meta.js +10 -0
  22. package/dist/components/page-header/page-header.js +9 -4
  23. package/dist/components/page-header/page-header.meta.js +26 -1
  24. package/dist/components/pill/pill.meta.js +15 -6
  25. package/dist/components/pill/pill.variants.js +1 -0
  26. package/dist/components/search-input/search-input.js +30 -5
  27. package/dist/components/search-input/search-input.meta.js +29 -4
  28. package/dist/components/section-header/section-header.js +27 -17
  29. package/dist/components/section-header/section-header.meta.js +27 -5
  30. package/dist/components/section-rail/index.js +7 -0
  31. package/dist/components/section-rail/section-rail.js +153 -0
  32. package/dist/components/section-rail/section-rail.meta.js +189 -0
  33. package/dist/components/sheet/sheet.js +15 -3
  34. package/dist/components/sheet/sheet.meta.js +2 -2
  35. package/dist/file-hashes.json +53 -39
  36. package/dist/icon-sets/reicon.js +23 -1
  37. package/dist/index.d.ts +364 -27
  38. package/dist/index.js +11 -1
  39. package/dist/layout/AspectBox.js +2 -5
  40. package/dist/layout/Bleed.js +10 -6
  41. package/dist/layout/Center.js +11 -3
  42. package/dist/layout/Columns.js +6 -3
  43. package/dist/layout/Grid.js +14 -6
  44. package/dist/layout/GridOverlay.js +7 -7
  45. package/dist/layout/Region.js +18 -3
  46. package/dist/layout/Row.js +27 -20
  47. package/dist/layout/Spacer.js +13 -2
  48. package/dist/layout/SplitPane.js +1 -1
  49. package/dist/layout/Stack.js +25 -18
  50. package/dist/layout/index.d.ts +57 -6
  51. package/dist/lib/chart/activity.js +32 -0
  52. package/dist/lib/chart/calendar.js +67 -0
  53. package/dist/lib/chart/chart-tooltip.js +2 -2
  54. package/dist/lib/chart/heat.js +55 -0
  55. package/dist/lib/focus-return.js +53 -0
  56. package/dist/lib/taxonomy.js +2 -0
  57. package/dist/lib/use-active-section.js +59 -0
  58. package/dist/styles.css +122 -114
  59. package/llms/blocks/approval-block.md +40 -55
  60. package/llms/components/activity-heatmap.md +133 -0
  61. package/llms/components/code-block.md +1 -0
  62. package/llms/components/dialog.md +34 -3
  63. package/llms/components/media-image.md +12 -0
  64. package/llms/components/media-tile.md +21 -0
  65. package/llms/components/page-header.md +16 -1
  66. package/llms/components/pill.md +24 -5
  67. package/llms/components/search-input.md +37 -4
  68. package/llms/components/section-header.md +28 -4
  69. package/llms/components/section-rail.md +101 -0
  70. package/llms/components/sheet.md +2 -2
  71. package/llms/layout/aspect-box.md +1 -1
  72. package/llms/layout/bleed.md +1 -1
  73. package/llms/layout/center.md +1 -1
  74. package/llms/layout/columns.md +1 -1
  75. package/llms/layout/grid.md +1 -1
  76. package/llms/layout/region.md +1 -1
  77. package/llms/layout/row.md +9 -1
  78. package/llms/layout/spacer.md +1 -1
  79. package/llms/layout/split-pane.md +1 -1
  80. package/llms/layout/stack.md +1 -1
  81. package/llms/tasks/auditing-an-app.md +31 -0
  82. package/llms/tasks/getting-started.md +10 -3
  83. package/llms/tasks/using-presets.md +1 -2
  84. package/llms-full.txt +516 -90
  85. package/llms.txt +5 -3
  86. package/package.json +6 -1
  87. package/registry.json +745 -46
  88. package/scripts/adoption.mjs +239 -0
  89. package/scripts/agent-setup.mjs +110 -18
  90. package/scripts/grade.mjs +17 -0
  91. package/scripts/wiring.mjs +2 -1
  92. package/skills/entry/SKILL.md +1 -1
  93. package/skills/sable/SKILL.md +33 -4
  94. package/skills/sable/agents/openai.yaml +1 -1
  95. package/targets/ios.json +18 -0
package/llms-full.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sable (@sableui/react)
2
2
 
3
- > An owned, themeable React component system. Default theme: **Sable** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 121 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
3
+ > An owned, themeable React component system. Default theme: **Sable** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 123 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
4
4
 
5
5
  ## Start here — brief, composition, evidence
6
6
 
@@ -445,6 +445,37 @@ names the nearest pair that reads. Report it with that pair as a recommendation.
445
445
  A reasoned `design-lint-disable-next-line label-contrast` is the app's recorded
446
446
  choice: list it as intentional, not as a defect.
447
447
 
448
+ ## Adoption: an app wired for Sable that barely uses it
449
+
450
+ The commonest drift is not a wrong prop. It is an app where an agent never
451
+ loaded this skill: the stylesheet and tokens are wired, and every control is the
452
+ app's own (a `Modal`, a `PrimaryButton`, a `Tabs` written from scratch). Wiring
453
+ checks pass, and lint only sees the raw elements. `sable check` (and `sable
454
+ grade --json` → `adoption`) measures it: how many UI files compose Sable, which
455
+ app-defined components are named for something Sable ships and do the behaviour
456
+ by hand, and what the app's foundation is. `sable doctor` warns when adoption is
457
+ `none` or `low`. It is never a failure: whether working screens move is the
458
+ user's call.
459
+
460
+ Put the choice to the user before changing anything. Ask, don't migrate silently
461
+ and don't leave it unmentioned. Show the swaps, behaviour first (overlays,
462
+ controls, tabs, menus, anything with focus or keyboard handling), with what each
463
+ one gains ("your Modal has no focus trap or Esc; Sable's Dialog has both").
464
+ Layout and look stay the app's. How strongly to recommend it depends on the
465
+ `foundation` the report names:
466
+
467
+ | Foundation | What to say |
468
+ |---|---|
469
+ | `ad-hoc`: no system besides Sable | Recommend the move plainly. There is nothing to preserve but one-off components, and every one is a behaviour bug waiting. Offer to do it a screen at a time. |
470
+ | `own`: a folder of the app's own primitives | Offer the swaps and say what each gains. Keep the app's own where the user prefers them; suggest building them on Sable's behaviour (a `Button` wrapper around Sable's `Button`) instead. |
471
+ | `library`: another component library (MUI, Radix, shadcn-style `components/ui`) | Ask whether Sable should replace it or only cover new work. Don't mix two control libraries on one screen. |
472
+ | `sable`: Sable already carries the UI | Mention the few swaps listed when those screens are next touched. |
473
+
474
+ Use the question tool the host offers when there is one. Record the answer in
475
+ the project's instructions, so the next task doesn't ask again. On a yes, move
476
+ one screen or component at a time with `fix`/`polish`, running `sable check` and
477
+ looking at the rendered states after each.
478
+
448
479
  ## Deliver evidence-backed findings
449
480
 
450
481
  Save the audit under `design/<surface>/audit/` or the existing review location.
@@ -2274,15 +2305,22 @@ In a monorepo where Sable belongs to one workspace package, run setup in that
2274
2305
  package with `--root` naming the repository root (for example
2275
2306
  `pnpm --filter web exec sable setup --agent --root ../..`); the entries go
2276
2307
  there and resolve Sable through the package.
2308
+ Setup also adds a short managed block to the project's `AGENTS.md` and
2309
+ `CLAUDE.md`, the instructions an agent reads on every task, so a UI request
2310
+ goes through the skill even when it doesn't mention Sable. Only the text between
2311
+ its `<!-- sable:begin -->` and `<!-- sable:end -->` markers is Sable's;
2312
+ `--no-instructions` skips it.
2277
2313
  Each entry resolves `skills/sable/SKILL.md` from the installed package, so package
2278
2314
  upgrades also update the workflow. Other agents can read that file directly.
2279
2315
  Use `$sable build <idea>`, `inspire <references>`, `audit <target>`, or
2280
2316
  `polish <target>` in Codex; use `/sable` with the same modes in Claude Code or
2281
2317
  Cursor. `create` is a build alias. These are agent modes, not shell commands.
2282
2318
 
2283
- Upgrade with `pnpm update @sableui/react --latest`, then
2284
- `pnpm exec sable setup --agent --update`. Setup only replaces managed files
2285
- that have not been locally edited; `--dry-run` checks without writing. Keep
2319
+ Upgrade with `pnpm exec sable upgrade`: it updates the package, then runs the
2320
+ new version's `init`, which refreshes the skill entries and the instructions
2321
+ block and checks the wiring. From 0.17 or earlier, which has no `upgrade`, run
2322
+ `pnpm update @sableui/react --latest` and then `pnpm exec sable init` once.
2323
+ Setup only replaces managed files that have not been locally edited; `--dry-run` checks without writing. Keep
2286
2324
  project preferences in project instructions. Package installation alone does not
2287
2325
  change agent settings. See [building-with-sable.md](./building-with-sable.md),
2288
2326
  [creating-a-preset.md](./creating-a-preset.md), and
@@ -3382,8 +3420,7 @@ saved file. Replace, don't add beside:
3382
3420
  ## Upgrade the package and skill
3383
3421
 
3384
3422
  ```bash
3385
- pnpm update @sableui/react --latest
3386
- pnpm exec sable setup --agent --update
3423
+ pnpm exec sable upgrade
3387
3424
  pnpm exec sable preset --check .sable/presets/clay.json --json
3388
3425
  pnpm exec sable doctor
3389
3426
  ```
@@ -7093,49 +7130,46 @@ export function ApiKeysBlock({ className, framed = true, onCreate, onKeyAction }
7093
7130
 
7094
7131
  # ApprovalBlock
7095
7132
 
7096
- An agent asking permission for one tool call: the question, what will happen and what can't be undone, the call's details, an "allow for the rest of this chat" Checkbox, and Deny / Allow.
7133
+ An agent asking permission for one tool call: the question, what is at stake, the call itself as a ToolCall with its exact input, and Deny / Always allow / Allow once.
7097
7134
 
7098
- **Intent:** Reach for this in a chat or agent run when a tool call waits on the person — sending, paying, deleting, publishing. The call itself, before and after the decision, is a ToolCall with status="awaiting", then "complete" or "denied". A destructive action outside an agent is DestructiveActionModal.
7135
+ **Intent:** Reach for this in a chat or agent run when a tool call waits on the person — running a command, sending, paying, deleting, publishing. It wraps the call's own ToolCall, which shows status="awaiting" until the person answers and then "complete" or "denied". A destructive action outside an agent is DestructiveActionModal.
7099
7136
 
7100
7137
  **Block:** AI & agents · card · designed for 18rem–28rem wide (fills its container up to the max)
7101
7138
 
7102
7139
  ## Notes
7103
7140
 
7104
- - The title is the question in plain words ("Send the invoice to Ada?"), not the tool's name; the tool's name is one row of the details, for the person who wants to know exactly what runs.
7105
- - The description says what can't be undone. That sentence is what the person is deciding on.
7106
- - Deny comes first and Allow is the primary on the end, the same order as every Sable confirmation. Neither is focused on arrival: an approval that a stray Enter can grant is not an approval.
7107
- - After a decision the buttons give way to one InlineStatus line saying what happened, so the thread keeps a record of the answer.
7141
+ - The title is the question in plain words ("Run this command?"); the description says what is at stake. That sentence is what the person is deciding on.
7142
+ - The call is shown as the agent made it — tool name and raw input, open on arrival — because the exact arguments are what a careful person checks. Do not paraphrase them into a form.
7143
+ - Three answers, as agent products settle on: Deny on the start edge, then Always allow and Allow once, the primary on the end. Neither is focused on arrival: an approval that a stray Enter can grant is not an approval.
7144
+ - After a decision the buttons give way to one InlineStatus line saying what happened, and the ToolCall shows the outcome, so the thread keeps a record of the answer.
7108
7145
 
7109
7146
  ## Accessibility
7110
7147
 
7111
7148
  - Focus moves to the outcome line when the buttons leave, instead of dropping to the page; the line is a role=status region, so the decision is announced.
7112
- - The details are a dl, so each value is read with its label.
7113
- - The remember Checkbox has its own Label, so the tool name inside it is part of its accessible name.
7149
+ - The ToolCall announces its own status change, and its input is a labelled code view.
7114
7150
 
7115
7151
  ## Source
7116
7152
 
7117
7153
  ```tsx
7118
- import { useState, useEffect, useRef, useId } from "react"
7119
- import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, cn, Code, InlineStatus, Label } from "@sableui/react"
7154
+ import { useState, useEffect, useRef } from "react"
7155
+ import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, InlineStatus, ToolCall } from "@sableui/react"
7120
7156
 
7121
7157
  const request = {
7122
- tool: "send_email",
7123
- title: "Send the invoice to Ada?",
7124
- description: "The agent wants to email invoice INV-1042. An email can't be unsent.",
7125
- details: [
7126
- { label: "To", value: "ada@example.com" },
7127
- { label: "Subject", value: "INV-1042 for October" },
7128
- { label: "Attached", value: "INV-1042.pdf" },
7129
- ],
7130
- allowed: "Allowed. Sending the email.",
7131
- denied: "Denied. The agent won't send it.",
7158
+ title: "Run this command?",
7159
+ description: "The agent wants to migrate the production database in acme/api. It changes live data.",
7160
+ tool: "run_command",
7161
+ action: "Run a command",
7162
+ // A command reads as the command; a structured call would pass its arguments object.
7163
+ input: "pnpm db:migrate --env production",
7164
+ allowed: "Allowed. Running the migration.",
7165
+ denied: "Denied. The agent won't run it.",
7132
7166
  }
7133
7167
 
7134
7168
  export interface ApprovalBlockProps {
7135
7169
  className?: string
7136
7170
  /** false drops the card's surface and border — for a chat column that already frames it. */
7137
7171
  framed?: boolean
7138
- /** The person allowed the call. `remember` is the "for the rest of this chat" choice. */
7172
+ /** The person allowed the call. `remember` is true for "Always allow". */
7139
7173
  onApprove?: (options: { remember: boolean }) => void
7140
7174
  /** The person declined the call. */
7141
7175
  onDeny?: () => void
@@ -7143,8 +7177,6 @@ export interface ApprovalBlockProps {
7143
7177
 
7144
7178
  export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: ApprovalBlockProps) {
7145
7179
  const [decision, setDecision] = useState<"allowed" | "denied" | null>(null)
7146
- const [remember, setRemember] = useState(false)
7147
- const id = useId()
7148
7180
  const outcome = useRef<HTMLDivElement>(null)
7149
7181
  // The buttons leave with the decision, so focus moves to the line that
7150
7182
  // replaces them instead of falling back to the top of the page.
@@ -7152,37 +7184,30 @@ export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: A
7152
7184
  if (decision) outcome.current?.focus()
7153
7185
  }, [decision])
7154
7186
 
7187
+ const approve = (remember: boolean) => {
7188
+ setDecision("allowed")
7189
+ onApprove?.({ remember })
7190
+ }
7191
+
7155
7192
  return (
7156
7193
  <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-md", className)}>
7157
7194
  <CardHeader>
7158
7195
  <CardTitle>{request.title}</CardTitle>
7159
7196
  <CardDescription>{request.description}</CardDescription>
7160
7197
  </CardHeader>
7161
- <CardContent className="flex flex-col gap-4">
7162
- <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-body-sm">
7163
- {request.details.map((d) => (
7164
- <div key={d.label} className="contents">
7165
- <dt className="text-muted-foreground">{d.label}</dt>
7166
- <dd className="truncate text-foreground">{d.value}</dd>
7167
- </div>
7168
- ))}
7169
- <div className="contents">
7170
- <dt className="text-muted-foreground">Tool</dt>
7171
- <dd className="min-w-0">
7172
- <Code>{request.tool}</Code>
7173
- </dd>
7174
- </div>
7175
- </dl>
7176
- {decision ? null : (
7177
- <div className="flex items-center gap-2">
7178
- <Checkbox id={`${id}-remember`} checked={remember} onCheckedChange={(v: boolean) => setRemember(v)} />
7179
- <Label htmlFor={`${id}-remember`} className="text-body-sm">
7180
- Allow <Code>{request.tool}</Code> for the rest of this chat
7181
- </Label>
7182
- </div>
7183
- )}
7198
+ <CardContent>
7199
+ {/* The call itself, exactly as the agent made it. It answers with the
7200
+ person: held, then done or denied. */}
7201
+ <ToolCall
7202
+ name={request.tool}
7203
+ title={request.action}
7204
+ status={decision === "allowed" ? "complete" : decision === "denied" ? "denied" : "awaiting"}
7205
+ statusLabel={decision === "allowed" ? "Allowed" : undefined}
7206
+ input={request.input}
7207
+ defaultOpen
7208
+ />
7184
7209
  </CardContent>
7185
- <CardFooter actions className="justify-end">
7210
+ <CardFooter actions>
7186
7211
  {decision ? (
7187
7212
  <InlineStatus
7188
7213
  ref={outcome}
@@ -7196,7 +7221,8 @@ export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: A
7196
7221
  ) : (
7197
7222
  <>
7198
7223
  <Button
7199
- variant="outline"
7224
+ variant="ghost"
7225
+ className="me-auto"
7200
7226
  onClick={() => {
7201
7227
  setDecision("denied")
7202
7228
  onDeny?.()
@@ -7204,14 +7230,10 @@ export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: A
7204
7230
  >
7205
7231
  Deny
7206
7232
  </Button>
7207
- <Button
7208
- onClick={() => {
7209
- setDecision("allowed")
7210
- onApprove?.({ remember })
7211
- }}
7212
- >
7213
- Allow
7233
+ <Button variant="outline" onClick={() => approve(true)}>
7234
+ Always allow
7214
7235
  </Button>
7236
+ <Button onClick={() => approve(false)}>Allow once</Button>
7215
7237
  </>
7216
7238
  )}
7217
7239
  </CardFooter>
@@ -10332,7 +10354,7 @@ export function WalletBlock({ className, framed = true, onSend, onReceive }: Wal
10332
10354
 
10333
10355
  **Status:** stable
10334
10356
 
10335
- The page region: fluid, edge-to-edge with the fluid page inset (--page-inset). Opt-in centered max cap picked by ROLE (prose/form/feed/content/wide/full), never a global page max-width. The root of nearly every route.
10357
+ The page region: fluid, edge-to-edge with the fluid page inset (--page-inset). Opt-in centered max cap picked by ROLE (prose/form/feed/content/wide/full), never a global page max-width. The root of nearly every route. The inset and cap are classes (px-(--page-inset), max-w-…), so className overrides them at a breakpoint (max-sm:px-0); only safeArea padding is inline.
10336
10358
 
10337
10359
  **Keywords:** region, page width, container, layout
10338
10360
 
@@ -10439,7 +10461,7 @@ import { Card, CardContent, Page, PageHeader } from "@sableui/react"
10439
10461
 
10440
10462
  **Status:** stable
10441
10463
 
10442
- CSS Grid within a region. Two modes: fixed/responsive cols count, or minItemWidth so cells breathe with NO breakpoints (the default choice for card grids). A minItemWidth grid keeps its empty columns (track="fill"), so one result stays one cell wide; track="fit" stretches a fixed set across the row.
10464
+ CSS Grid within a region. Two modes: fixed/responsive cols count, or minItemWidth so cells breathe with NO breakpoints (the default choice for card grids). A minItemWidth grid keeps its empty columns (track="fill"), so one result stays one cell wide; track="fit" stretches a fixed set across the row. display is the grid class and the gap a CSS variable, so className utilities win at any breakpoint (hidden md:grid, md:gap-6, lg:grid-cols-3).
10443
10465
 
10444
10466
  **Keywords:** grid, card grid, auto fit, layout
10445
10467
 
@@ -10510,7 +10532,7 @@ import { Badge, Grid } from "@sableui/react"
10510
10532
 
10511
10533
  **Status:** stable
10512
10534
 
10513
- An explicit N-column track (default 12) for proportioned layouts — pair with Col spans (list-detail, asymmetric editorial layouts). For uniform card grids prefer Grid minItemWidth.
10535
+ An explicit N-column track (default 12) for proportioned layouts — pair with Col spans (list-detail, asymmetric editorial layouts). For uniform card grids prefer Grid minItemWidth. display is the grid class, so hidden md:grid works; a gap-* utility replaces both gaps and the column-gap cap.
10514
10536
 
10515
10537
  **Keywords:** columns, 12 column, span, layout
10516
10538
 
@@ -10564,7 +10586,7 @@ import { Card, CardHeader, CardTitle, Col, Columns } from "@sableui/react"
10564
10586
 
10565
10587
  **Status:** stable
10566
10588
 
10567
- Vertical flow on the 8px rhythm — the default container for stacked content. Gap by role: tight within a group (1.5–2), looser between groups (3–4), generous between sections (6+).
10589
+ Vertical flow on the 8px rhythm — the default container for stacked content. Gap by role: tight within a group (1.5–2), looser between groups (3–4), generous between sections (6+). Layout lives in classes (flex flex-col items-* justify-*) and the gap in a CSS variable, never inline style, so className utilities override any prop at a breakpoint (max-lg:hidden, md:flex-row, md:gap-8).
10568
10590
 
10569
10591
  **Keywords:** stack, vertical rhythm, layout, spacing
10570
10592
 
@@ -10606,7 +10628,7 @@ import { Field, Input, Stack, Textarea } from "@sableui/react"
10606
10628
 
10607
10629
  **Status:** stable
10608
10630
 
10609
- Horizontal flex flow — inline rows of controls, toolbars, action clusters. Defaults to center-aligned, no wrap.
10631
+ Horizontal flex flow — inline rows of controls, toolbars, action clusters. Defaults to center-aligned, no wrap. Layout lives in classes (flex flex-row items-* justify-*) and the gap in a CSS variable, never inline style, so className utilities override any prop at a breakpoint (hidden md:flex, max-md:flex-col, md:gap-6).
10610
10632
 
10611
10633
  **Keywords:** row, toolbar, horizontal, layout
10612
10634
 
@@ -10643,13 +10665,21 @@ import { Button, Row } from "@sableui/react"
10643
10665
  <Row gap={2} justify="end"><Button variant="ghost">Cancel</Button><Button>Save changes</Button></Row>
10644
10666
  ```
10645
10667
 
10668
+ ### Responsive through className
10669
+
10670
+ ```tsx
10671
+ import { Button, Row } from "@sableui/react"
10672
+
10673
+ <Row justify="between"><span className="text-title-sm">Acme</span><Row gap={1} className="max-md:hidden"><Button variant="ghost" size="sm">Docs</Button><Button variant="ghost" size="sm">Pricing</Button></Row></Row>
10674
+ ```
10675
+
10646
10676
  ---
10647
10677
 
10648
10678
  # Spacer
10649
10679
 
10650
10680
  **Status:** stable
10651
10681
 
10652
- Explicit space on the rhythm, or a flexible grow pusher between siblings (e.g. pushing a footer item to the end of a Row).
10682
+ Explicit space on the rhythm, or a flexible grow pusher between siblings (e.g. pushing a footer item to the end of a Row). The size is a class reading a CSS variable, so className resizes or hides it at a breakpoint.
10653
10683
 
10654
10684
  **Keywords:** spacer, spacing, grow, layout
10655
10685
 
@@ -10691,7 +10721,7 @@ import { Button, Row, Spacer } from "@sableui/react"
10691
10721
 
10692
10722
  **Status:** stable
10693
10723
 
10694
- Horizontally center a block, optionally capping its width (commonly "prose" for a reading column inside a wider layout).
10724
+ Horizontally center a block, optionally capping its width (commonly "prose" for a reading column inside a wider layout). Centering and cap are classes, so className overrides them at a breakpoint.
10695
10725
 
10696
10726
  **Keywords:** center, measure, reading width, layout
10697
10727
 
@@ -10731,7 +10761,7 @@ import { Center } from "@sableui/react"
10731
10761
 
10732
10762
  **Status:** stable
10733
10763
 
10734
- Reserves space via aspect-ratio so async media loads with zero layout shift. Wrap images, video, embeds.
10764
+ Reserves space via aspect-ratio so async media loads with zero layout shift. Wrap images, video, embeds. The ratio is a class reading a CSS variable, so className reshapes it at a breakpoint (md:aspect-video).
10735
10765
 
10736
10766
  **Keywords:** aspect box, aspect ratio, media, layout shift
10737
10767
 
@@ -10771,7 +10801,7 @@ import { AspectBox } from "@sableui/react"
10771
10801
 
10772
10802
  **Status:** stable
10773
10803
 
10774
- Escape a width-capped or inset ancestor to span the full CONTENT area (container-query units, so it stops at a fixed Sidebar instead of bleeding under it). Hero bands, profile covers, full-bleed media inside a capped Page.
10804
+ Escape a width-capped or inset ancestor to span the full CONTENT area (container-query units, so it stops at a fixed Sidebar instead of bleeding under it). Hero bands, profile covers, full-bleed media inside a capped Page. Width and margins are classes, so a breakpoint can turn the bleed off (lg:mx-0 lg:w-auto).
10775
10805
 
10776
10806
  **Keywords:** bleed, full bleed, media band, layout
10777
10807
 
@@ -10805,7 +10835,7 @@ import { AspectBox, Bleed, Page, PageHeader } from "@sableui/react"
10805
10835
 
10806
10836
  **Status:** stable
10807
10837
 
10808
- Responsive two-pane layout: side-by-side at/above a breakpoint, stacked (primary first) below. List-detail, media + metadata.
10838
+ Responsive two-pane layout: side-by-side at/above a breakpoint, stacked (primary first) below. List-detail, media + metadata. display is the grid class and the breakpoint rules are layered, so className utilities (hidden lg:grid, gap-8) win.
10809
10839
 
10810
10840
  **Keywords:** split pane, two pane, list detail, responsive
10811
10841
 
@@ -11025,6 +11055,142 @@ import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger, Icon } from
11025
11055
 
11026
11056
  ---
11027
11057
 
11058
+ # ActivityHeatmap
11059
+
11060
+ **Status:** stable
11061
+
11062
+ A calendar heatmap: one value per day as a grid of weeks by weekdays — the GitHub contribution graph, for any daily measure. Levels by quartile, categories by source, a real ARIA grid with one tab stop, and a sideways scroll on narrow screens that opens on the latest week.
11063
+
11064
+ **Keywords:** heatmap, calendar heatmap, contribution graph, contributions, activity, github, streak, habit, daily, commits, uptime
11065
+
11066
+ ## Usage
11067
+
11068
+ Use ActivityHeatmap to show how often something happened day by day over weeks or months: commits, habits kept, orders, uptime. Pass days as ISO dates with a value; levels, colours, labels, keyboard and tooltips work with no further props. Put it inside a Card and compose any totals beside it (NumberTicker, StatRowBlock). For a value over time that a reader needs to compare precisely, use AreaChart or BarChart; for a month calendar with marked days (a streak calendar), use Calendar.
11069
+
11070
+ ### Best practices
11071
+
11072
+ - Do: Pass dates as "YYYY-MM-DD" strings. The grid reads them as calendar days, so no time zone can shift a day — never build them with `new Date(...).toISOString()` from a local date.
11073
+ - Do: Give it a `unit` ("contribution" / "contributions") so the tooltip and every cell's name say what the number counts.
11074
+ - Do: Let the default quantile scale pick levels — one exceptional day then does not wash every other day out. Pass a day's own `level` when the source already has one (GitHub's API).
11075
+ - Don't: Use it for a handful of values or for values a reader must compare exactly; a heatmap shows rhythm, not amounts. Use BarChart. → use **BarChart**, **AreaChart**
11076
+ - Don't: Use it for a single month of marked days with a streak; that is Calendar with modifiers. → use **Calendar**
11077
+ - Don't: Fetch inside the component or call GitHub from the browser with a token. Fetch on your server and pass days in; `@sableui/react/adapters/github` converts GitHub's response.
11078
+
11079
+ ### Anatomy
11080
+
11081
+ | Part | Required | Description |
11082
+ | --- | --- | --- |
11083
+ | Weekday labels | no | Short weekday names beside the rows, outside the scroller so the weeks never slide under them. |
11084
+ | Month labels | no | Short month names over each month's first week; they scroll with their weeks. |
11085
+ | Cell grid | yes | A role=grid table, one gridcell per day, inside a horizontal Scroller that opens on the latest week. |
11086
+ | Tooltip | no | The chart tooltip card, anchored to the hovered, focused or tapped day. |
11087
+ | Legend | no | A Less–More ramp for intensity, or swatches for categories. |
11088
+
11089
+ ## Props
11090
+
11091
+ | Name | Type | Default | Description |
11092
+ | --- | --- | --- | --- |
11093
+ | label | string | — | What the grid shows ("Contributions in 2026") — its caption and accessible name. |
11094
+ | data | ActivityDay[] \| Record<string, number> | — | The days: `{ date: "YYYY-MM-DD", value, level?, category? }[]`, or a map of date to value. Sparse is fine — a missing day is 0; duplicates sum. |
11095
+ | start | string | 53 weeks before end | First day drawn, "YYYY-MM-DD". The default starts at the beginning of that week, as GitHub's does. |
11096
+ | end | string | today | Last day drawn. Days after today inside the range are drawn hollow and skipped by the keyboard. |
11097
+ | year | number | — | Draw Jan 1 – Dec 31 of this year. Wins over start / end. |
11098
+ | weekStart | 0 \| 1 | the locale's | 0 puts Sunday on the top row, 1 Monday. |
11099
+ | levels | number | 5 | How many levels, including the empty level 0. |
11100
+ | levelScale | "quantile" \| "linear" \| number[] \| ((value, stats) => number) | "quantile" | How a value becomes a level. Quantile splits the positive values evenly, as GitHub does; an array gives each level's lower bound from 1 up; a function decides. A day's own `level` always wins. |
11101
+ | tone | ChartTone | "1" | The hue of the intensity ramp. Decorative tones lead, as on every chart; "success" gives the GitHub look. |
11102
+ | categories | ChartCategories | — | Colour days by `day.category` (label, tone or colour per key). Pair with `levels={2}` for flat category colours. |
11103
+ | unit | string \| { one: string; other: string } | — | Names the value in the tooltip and each cell's accessible name, pluralised with Intl.PluralRules. |
11104
+ | formatValue | (value: number) => string | full to 9,999, compact past | Formats a value wherever it is shown or read. |
11105
+ | tooltip | boolean \| ((day: ActivityHeatmapDay) => ReactNode) | true | Show the tooltip on hover, focus and tap; a function replaces its content on the same card. |
11106
+ | legend | boolean | true | The Less–More ramp for intensity, swatches for categories. |
11107
+ | legendLabels | [string, string] | ["Less", "More"] | The ramp's end words. |
11108
+ | monthLabels | boolean | true | Short month names over each month's first week. |
11109
+ | weekdayLabels | "alternate" \| "all" \| "none" | "alternate" | Which weekday names to show beside the rows; alternate is Mon, Wed, Fri. |
11110
+ | size | "sm" \| "md" \| "lg" | "md" | The smallest a cell may get (10, 12, 14 px) before the grid scrolls instead of shrinking. |
11111
+ | stretch | boolean | true | Grow cells to fill the width, up to 20 px. false keeps them at `size`. |
11112
+ | activeDate | string \| null | — | The hovered or focused day, controlled. Pair with defaultActiveDate / onActiveDateChange. |
11113
+ | selectedDate | string \| null | — | The selected day, controlled. Selection is on only when onSelectDate is set. |
11114
+ | onSelectDate | (date: string) => void | — | Called when a day is clicked or chosen with Enter / Space — to filter a feed below, as GitHub does. |
11115
+ | renderCell | (day, cell) => ReactNode | — | Draw something over a cell's fill — an icon for a milestone, a reward, an outage. |
11116
+ | loading | boolean | false | Draw the empty grid, shimmering, while data loads. |
11117
+ | motion | "full" \| "subtle" \| "off" | "full" | Turns the arrival down; `subtle` shows the finished grid at once. |
11118
+ | locale | string | runtime locale | Month and weekday names, numbers and plurals. |
11119
+
11120
+ ## Tokens used
11121
+
11122
+ `foreground`, `muted-foreground`, `border-strong`, `ring`, `tone-1-mark`, `tone-2-mark`, `tone-3-mark`, `success-mark`, `tone-info-mark`
11123
+
11124
+ ## Motion
11125
+
11126
+ - **arrival** · Cell grid · _custom (outside the recipe layer)_ — Weeks fade in left to right as the grid first scrolls into view, staggered on the chart motion helpers; expressive, so subtle shows the finished grid.
11127
+ - **response** · Cell · _custom (outside the recipe layer)_ — The active day takes a ring outline at once and the tooltip glides to it; the other cells stay still — dimming hundreds of cells reads as a flash.
11128
+ - **transition** · Cell · _custom (outside the recipe layer)_ — When data or the range changes, each cell's fill cross-fades on the base duration token, so the reader sees which days changed.
11129
+ - **activity** · Cell grid · `motion-shimmer` — `loading` shimmers the empty grid until days arrive.
11130
+
11131
+ ## Accessibility
11132
+
11133
+ - The grid is a role=grid table, as GitHub's is: weekday row headers and month column headers carry full names for screen readers, and the caption is `label`.
11134
+ - One tab stop. Arrows move a day or a week; Home / End go to the ends of the row, Page Up / Page Down to the ends of the column, Ctrl+Home / Ctrl+End to the first and last day. Enter or Space selects when onSelectDate is set; Escape hides the tooltip.
11135
+ - Each cell is named with its date and value ("Tuesday, October 14, 2026: 12 contributions") and described by its level's words, which the legend repeats visually.
11136
+ - Levels rise in lightness, so they read without colour; under forced colours each level draws as a larger inner square.
11137
+
11138
+ ## Related
11139
+
11140
+ - Components: Card, NumberTicker, BarChart, Calendar, Scroller
11141
+
11142
+ ## Examples
11143
+
11144
+ ### Contributions, last year
11145
+
11146
+ ```tsx
11147
+ import { ActivityHeatmap } from "@sableui/react"
11148
+
11149
+ const days = [
11150
+ { date: "2026-10-12", value: 4 },
11151
+ { date: "2026-10-13", value: 11 },
11152
+ { date: "2026-10-14", value: 2 },
11153
+ ]
11154
+
11155
+ <ActivityHeatmap label="Contributions in the last year" data={days} tone="success" unit={{ one: "contribution", other: "contributions" }} />
11156
+ ```
11157
+
11158
+ ### Two sources, in a card
11159
+
11160
+ ```tsx
11161
+ import { ActivityHeatmap, Card, CardContent, CardHeader, CardTitle, mergeActivity } from "@sableui/react"
11162
+
11163
+ const github = [{ date: "2026-10-13", value: 6 }]
11164
+ const codex = [{ date: "2026-10-13", value: 1 }, { date: "2026-10-14", value: 3 }]
11165
+
11166
+ <Card>
11167
+ <CardHeader>
11168
+ <CardTitle>Building, day by day</CardTitle>
11169
+ </CardHeader>
11170
+ <CardContent>
11171
+ <ActivityHeatmap
11172
+ label="GitHub and Codex activity in 2026"
11173
+ year={2026}
11174
+ levels={2}
11175
+ data={mergeActivity({ github, codex })}
11176
+ categories={{ github: { label: "GitHub", tone: "success" }, codex: { label: "Codex", tone: "info" }, both: { label: "Both", tone: "2" } }}
11177
+ />
11178
+ </CardContent>
11179
+ </Card>
11180
+ ```
11181
+
11182
+ ### Selecting a day
11183
+
11184
+ ```tsx
11185
+ import { ActivityHeatmap } from "@sableui/react"
11186
+
11187
+ const [day, setDay] = useState<string | null>(null)
11188
+
11189
+ <ActivityHeatmap label="Orders per day" data={{ "2026-10-13": 18, "2026-10-14": 25 }} unit="orders" selectedDate={day} onSelectDate={setDay} />
11190
+ ```
11191
+
11192
+ ---
11193
+
11028
11194
  # AppHeader
11029
11195
 
11030
11196
  **Status:** stable
@@ -13689,6 +13855,7 @@ Use CodeBlock for multiline code samples that users may read, inspect, or copy.
13689
13855
  | showCopy | boolean | true | Show the copy affordance — in the header bar with a filename, floating in the corner without one. |
13690
13856
  | highlight | ((code: string) => ReactNode) \| false | — | Highlighter for this block; overrides CodeHighlightProvider. false renders plain text inside a provider. |
13691
13857
  | prompt | string | — | A shell prompt ("$", ">"). Makes it a one-line command: the prompt is dimmed and unselectable, the line truncates, and copy takes only code. |
13858
+ | wrap | boolean | false | Wrap long lines instead of scrolling them. For prose in a code face (a prompt to paste into an agent, a log line); code keeps scrolling, since wrapping breaks its indentation. |
13692
13859
 
13693
13860
  ## Tokens used
13694
13861
 
@@ -14935,11 +15102,14 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
14935
15102
  - Do: Use showCloseButton for dismissible informational dialogs; keep destructive confirmations action-led.
14936
15103
  - Do: In a destructive confirmation, state the consequence in DialogDescription ("This is permanent.") and reserve the destructive button variant for actions that cannot be undone.
14937
15104
  - Do: Use size="fullscreen" when an image, video, or gallery needs a larger view than the layout can give it. Give the popup an accessible name (aria-label on DialogContent, or a visually hidden DialogTitle) and alt text on the media.
15105
+ - Do: Open a dialog from your own button with controlled open/onOpenChange when the trigger is not a DialogTrigger (a menu item, a row action, a keyboard shortcut). Focus returns on close to whatever held it when the dialog opened, with no finalFocus wiring; pass finalFocus only to send it somewhere else.
15106
+ - Do: Set keepMounted on DialogContent for a draft the user may come back to (a compose form, a long comment, a half-filled request). Closing by Esc or a stray click then hides the dialog instead of unmounting it, so the fields are still filled when it reopens. Clear the draft yourself on submit or discard.
14938
15107
  - Do: Use the AlertDialog family for a confirmation that must be answered (discard changes, leave without saving). It looks identical but is role="alertdialog" and a backdrop click does not dismiss it — only Cancel, Esc, or the action. For irreversible deletes, use DestructiveActionModal, which is built on it.
14939
15108
  - Don't: Put large multipage workflows in a centered dialog. Use Sheet or a page route when the task needs space. → use **Sheet**
14940
15109
  - Don't: Use size="fullscreen" for forms, confirmations, or anything that isn't media. A dialog that happens to need room is a Sheet or a page; the fullscreen affordances (dark scrim, corner close, centred content) all assume something to look at. → use **Sheet**
14941
15110
  - Don't: Hide the close button at size="fullscreen" without another visible way out. A full-bleed viewer that only Esc dismisses is a dead end for anyone who doesn't know the key.
14942
15111
  - Don't: Open a dialog for passive status updates. Use Toast, Banner, or InlineStatus instead. → use **Toast**, **Banner**, **InlineStatus**
15112
+ - Don't: Set keepMounted by default. A dialog that should open fresh (a confirmation, a create form after a successful save) would show stale input, and live content (polling, video) keeps running while hidden.
14943
15113
 
14944
15114
  ### Anatomy
14945
15115
 
@@ -14955,7 +15125,7 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
14955
15125
 
14956
15126
  | Name | Type | Description |
14957
15127
  | --- | --- | --- |
14958
- | DialogContent | Base UI Popup props + size?: "default" \| "fullscreen" + showCloseButton?: boolean + scroll?: boolean + className | Backdrop + popup; portals to <body>. Composes DialogOverlay by default. size="fullscreen" is the media viewer: a near-opaque scrim (bg-overlay/95), an edge-to-edge popup that centres its children with no card chrome, motion-fade instead of motion-pop, and a safe-area-aware floating close button (Button variant="floating"); at that size showCloseButton defaults to true and scroll to false, and the media fits itself with max-h-full max-w-full object-contain. At the default size, showCloseButton (defaults to false) renders a built-in close (X) button in the top-end corner, and the header leaves room for it (Icon name="xmark"). The popup caps at the viewport height less a 1rem margin and wraps its children in a scrolling region, so a tall dialog scrolls instead of overflowing a viewport whose own scroll is locked; pass scroll={false} when the dialog owns its internal layout (a pinned header over a scrolling body) and supplies its own min-h-0 scroll region. |
15128
+ | DialogContent | Base UI Popup props (initialFocus, finalFocus) + size?: "default" \| "fullscreen" + showCloseButton?: boolean + scroll?: boolean + keepMounted?: boolean + className | Backdrop + popup; portals to <body>. Composes DialogOverlay by default. size="fullscreen" is the media viewer: a near-opaque scrim (bg-overlay/95), an edge-to-edge popup that centres its children with no card chrome, motion-fade instead of motion-pop, and a safe-area-aware floating close button (Button variant="floating"); at that size showCloseButton defaults to true and scroll to false, and the media fits itself with max-h-full max-w-full object-contain. At the default size, showCloseButton (defaults to false) renders a built-in close (X) button in the top-end corner, and the header leaves room for it (Icon name="xmark"). The popup caps at the viewport height less a 1rem margin and wraps its children in a scrolling region, so a tall dialog scrolls instead of overflowing a viewport whose own scroll is locked; pass scroll={false} when the dialog owns its internal layout (a pinned header over a scrolling body) and supplies its own min-h-0 scroll region. keepMounted (default false) keeps the content mounted and hidden while closed, so a draft survives close and reopen. finalFocus defaults to the element focused when the dialog opened, trigger or not; an explicit finalFocus (a ref, a function, or false) wins. |
14959
15129
  | DialogOverlay | Base UI Backdrop props + size?: "default" \| "fullscreen" + className | The styleable scrim/backdrop — bg-overlay/(--scrim-alpha) by default (50% light, 72% dark, so it dims a near-black page as visibly as a white one), near-opaque bg-overlay/95 at size="fullscreen". Composed into DialogContent (at the matching size) by default; exported for manual styling. |
14960
15130
  | DialogTrigger | Base UI Dialog.Trigger props (render) | Element that opens the dialog. Compose via render prop. |
14961
15131
  | DialogClose | Base UI Dialog.Close props (render) | Element that dismisses the dialog. Compose via render prop. |
@@ -14963,7 +15133,7 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
14963
15133
  | DialogTitle | Base UI Dialog.Title props + className | The dialog's accessible name — wires aria-labelledby on the popup. |
14964
15134
  | DialogDescription | Base UI Dialog.Description props + className | Muted supporting line; wires aria-describedby. |
14965
15135
  | DialogFooter | ComponentProps<"div"> | Bottom action row (right-aligned buttons). |
14966
- | AlertDialog (Root) | Base UI AlertDialog.Root props (open, defaultOpen, onOpenChange) | Root for a confirmation that needs an answer. Always modal; the popup is role="alertdialog" and a click outside does not dismiss — only an AlertDialogClose, an action that sets open to false, or Esc. |
15136
+ | AlertDialog (Root) | Base UI AlertDialog.Root props (open, defaultOpen, onOpenChange) | Root for a confirmation that needs an answer. Always modal; the popup is role="alertdialog" and a click outside does not dismiss — only an AlertDialogClose, an action that sets open to false, or Esc. Returns focus on close to the element focused when it opened, exactly as Dialog does. |
14967
15137
  | AlertDialogTrigger | Base UI AlertDialog.Trigger props (render) | Opens the alert dialog. |
14968
15138
  | AlertDialogContent / AlertDialogOverlay / AlertDialogTitle / AlertDialogDescription / AlertDialogHeader / AlertDialogFooter / AlertDialogClose | Same props as the matching Dialog part | The Dialog parts under AlertDialog names — Base UI shares the popup, backdrop, title, description and close parts between the two, so the surface is identical. Pass initialFocus to AlertDialogContent (a ref to the Cancel button) to open focus on the safe action. |
14969
15139
 
@@ -14971,7 +15141,10 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
14971
15141
 
14972
15142
  | Name | Type | Default | Description |
14973
15143
  | --- | --- | --- | --- |
14974
- | Dialog (Root) | Base UI Dialog.Root props (open, defaultOpen, onOpenChange, modal) | — | Controls open state. Uncontrolled by default. |
15144
+ | Dialog (Root) | Base UI Dialog.Root props (open, defaultOpen, onOpenChange, modal) | — | Controls open state. Uncontrolled by default. Records the element that holds focus when the dialog opens (through a DialogTrigger, or your own button flipping a controlled open) and returns focus there on close. |
15145
+ | open | boolean | — | Controlled open state. Pair with onOpenChange — for a dialog opened from your own button instead of a DialogTrigger. |
15146
+ | defaultOpen | boolean | false | Initial open state when uncontrolled. |
15147
+ | onOpenChange | (open: boolean, eventDetails) => void | — | Called when the dialog asks to open or close (a trigger, Esc, an outside click, DialogClose). |
14975
15148
 
14976
15149
  ## Variants
14977
15150
 
@@ -14993,6 +15166,8 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
14993
15166
 
14994
15167
  - Base UI provides focus trapping, scroll lock, aria-modal, and labelled-by wiring (DialogTitle).
14995
15168
  - Content taller than the viewport scrolls inside the dialog (thin scrollbar, contained overscroll) rather than overflowing off screen — the page behind is scroll-locked, so anything past the edge would be unreachable.
15169
+ - On close, focus returns to the element that held it when the dialog opened, whether a DialogTrigger opened it or your own button set a controlled open. It is captured before the content mounts, so an autoFocus field inside cannot displace it. If that element has left the page, Base UI's default (the trigger, if any) applies; an explicit finalFocus on the content always wins. AlertDialog and Sheet behave the same.
15170
+ - keepMounted hides closed content with the hidden attribute, so a kept draft is out of the accessibility tree and the tab order until the dialog reopens.
14996
15171
  - Esc and click-outside dismiss by default. AlertDialog keeps Esc but ignores click-outside, and its popup is role="alertdialog".
14997
15172
  - Animate via data-[starting-style]/data-[ending-style] attributes.
14998
15173
  - size="fullscreen" has no title chrome, so name the popup yourself: aria-label on DialogContent (or a visually hidden DialogTitle), plus alt text on the media — the zoomed image is a separate image to assistive technology. Its built-in close button carries aria-label="Close" and clears the safe-area insets (notch, home indicator).
@@ -15036,6 +15211,29 @@ import { Button, Dialog, DialogClose, DialogContent, DialogDescription, DialogFo
15036
15211
  <Dialog><DialogTrigger render={<Button variant="secondary">Rename collection</Button>} /><DialogContent showCloseButton><DialogHeader><DialogTitle>Rename collection</DialogTitle><DialogDescription>Update the display name shown to collectors.</DialogDescription></DialogHeader><Form><Field label="Collection name"><Input defaultValue="Midnight Archive" /></Field><DialogFooter><DialogClose render={<Button variant="secondary">Cancel</Button>} /><Button type="submit">Save changes</Button></DialogFooter></Form></DialogContent></Dialog>
15037
15212
  ```
15038
15213
 
15214
+ ### Opened from your own button, keeping a draft
15215
+
15216
+ ```tsx
15217
+ import { Button, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Field, Textarea } from "@sableui/react"
15218
+
15219
+ const [open, setOpen] = useState(false)
15220
+
15221
+ <Button variant="secondary" onClick={() => setOpen(true)}>New message</Button>
15222
+ <Dialog open={open} onOpenChange={setOpen}>
15223
+ <DialogContent keepMounted showCloseButton>
15224
+ <DialogHeader>
15225
+ <DialogTitle>New message</DialogTitle>
15226
+ <DialogDescription>Close it any time. Your draft is here when you come back.</DialogDescription>
15227
+ </DialogHeader>
15228
+ <Field label="Message"><Textarea /></Field>
15229
+ <DialogFooter>
15230
+ <DialogClose render={<Button variant="secondary">Close</Button>} />
15231
+ <Button onClick={() => setOpen(false)}>Send</Button>
15232
+ </DialogFooter>
15233
+ </DialogContent>
15234
+ </Dialog>
15235
+ ```
15236
+
15039
15237
  ### Destructive confirmation
15040
15238
 
15041
15239
  ```tsx
@@ -16998,6 +17196,7 @@ Use MediaImage wherever content imagery may be missing or broken: a cover in a g
16998
17196
  - Do: Give the box the shape: `<AspectBox ratio={1} className="rounded-md"><MediaImage … /></AspectBox>`, or `size-14 rounded-md` on MediaImage itself for a thumbnail. It clips to whatever radius the box has.
16999
17197
  - Do: Pass `seed` (the title or an id) when `alt` is empty, so the fallback art is the item's own and stays the same in every list it appears in.
17000
17198
  - Do: Leave `alt` empty when a visible title sits next to the image — a tile, a row — and describe it when the image stands alone. A set `alt` also names the fallback, so the description survives a broken URL.
17199
+ - Do: Set `position` when a cover crop would cut off the subject — a face high in a portrait, a product at one edge. Store the focus with the image (`"35% 50%"`) rather than re-cropping the file.
17001
17200
  - Don't: Do not hand-write `<img onError={() => setBroken(true)}>`. An image that fails before hydration never fires the handler on a prerendered page; MediaImage preloads off the DOM and catches it.
17002
17201
  - Don't: Do not use it for profile pictures. A person is a circle with initials — Avatar, whose `variant="art"` is the same fallback with the person's initials over it.
17003
17202
 
@@ -17010,6 +17209,7 @@ Use MediaImage wherever content imagery may be missing or broken: a cover in a g
17010
17209
  | seed | string \| number | — | Seeds the fallback art. Defaults to `alt`; pass the title or an id when `alt` is empty. |
17011
17210
  | fallback | ReactNode | — | Replaces the GradientArt fallback — a glyph or monogram. Fills the frame under the same clipping. |
17012
17211
  | fit | "cover" \| "contain" | "cover" | `cover` crops to fill (artwork, photos); `contain` letterboxes on the muted surface (a logo, a product that must not be cropped). |
17212
+ | position | CSSProperties["objectPosition"] | "center" | Where the image sits in its frame — CSS `object-position`, the sibling of `fit`. Under `cover` it is the part the crop keeps (`"50% 30%"` for a face, `"bottom"` for a horizon); under `contain` it is the side the letterbox gives way to. |
17013
17213
  | srcSet | string | — | Responsive sources, passed to the `<img>`. |
17014
17214
  | sizes | string | — | The `sizes` attribute for `srcSet`. |
17015
17215
  | loading | "lazy" \| "eager" | "lazy" | Native lazy loading. Use `eager` for the first row of a grid above the fold. |
@@ -17080,6 +17280,16 @@ import { AspectBox, MediaImage } from "@sableui/react"
17080
17280
  </AspectBox>
17081
17281
  ```
17082
17282
 
17283
+ ### Crop focus per image
17284
+
17285
+ ```tsx
17286
+ import { AspectBox, MediaImage } from "@sableui/react"
17287
+
17288
+ <AspectBox ratio={1} className="rounded-md">
17289
+ <MediaImage src={project.thumbnail} seed={project.title} position={project.focus ?? "center"} />
17290
+ </AspectBox>
17291
+ ```
17292
+
17083
17293
  ---
17084
17294
 
17085
17295
  # MediaTile
@@ -17126,6 +17336,7 @@ The item in a media grid or shelf: an album, a playlist, a video, a podcast, a f
17126
17336
  | sizes | string | — | The cover's layout width for `srcSet`, e.g. `"(min-width: 64rem) 25vw, 50vw"`. |
17127
17337
  | alt | string | "" | Cover alt text. Empty by default: the title below already names the item. |
17128
17338
  | seed | string \| number | — | Seeds the fallback art. Defaults to `title` when it is a string. |
17339
+ | position | CSSProperties["objectPosition"] | "center" | Which part of the cover the crop keeps — CSS `object-position`, forwarded to the cover's MediaImage: `"50% 30%"` for a face, `"top"` for a poster's title. Store it with the image when each one frames differently. |
17129
17340
  | aspect | "square" \| "video" \| "poster" \| number | "square" | The cover's shape: square (albums, podcasts), video (16:9), poster (2:3 — films, books), or any width / height ratio. |
17130
17341
  | size | "sm" \| "md" \| "lg" | "md" | Moves the type tier, the cover-to-text gap, the corner and the overlay inset together. |
17131
17342
  | href | string | — | Where the tile goes. The title becomes the link, stretched over the whole tile. |
@@ -17250,6 +17461,26 @@ import { Grid, MediaTile } from "@sableui/react"
17250
17461
  </Grid>
17251
17462
  ```
17252
17463
 
17464
+ ### Portfolio grid with crop focus
17465
+
17466
+ ```tsx
17467
+ import { Grid, MediaTile } from "@sableui/react"
17468
+
17469
+ <Grid minItemWidth="16rem" gap={4}>
17470
+ {projects.map((project) => (
17471
+ <MediaTile
17472
+ key={project.slug}
17473
+ aspect="video"
17474
+ title={project.title}
17475
+ subtitle={project.client}
17476
+ src={project.thumbnail}
17477
+ position={project.focus}
17478
+ href={`/work/${project.slug}`}
17479
+ />
17480
+ ))}
17481
+ </Grid>
17482
+ ```
17483
+
17253
17484
  ### Poster shelf
17254
17485
 
17255
17486
  ```tsx
@@ -18629,7 +18860,7 @@ import { Button, Icon, MenuItem, OverflowMenu, Row } from "@sableui/react"
18629
18860
 
18630
18861
  In-flow page heading block: a title row (optional back control + actions) over an optional description, with room for children (filters, tabs) below.
18631
18862
 
18632
- **Keywords:** page header, title, lede, page actions
18863
+ **Keywords:** page header, title, lede, page actions, page title size, display title, editorial header, hero title
18633
18864
 
18634
18865
  ## Usage
18635
18866
 
@@ -18639,6 +18870,8 @@ Use PageHeader to establish a page's title, supporting description, and primary
18639
18870
 
18640
18871
  - Do: Use one PageHeader per major page or route.
18641
18872
  - Do: Keep the title literal and put explanation in the lede or nearby content.
18873
+ - Do: Use size="lg" for an editorial page (a personal site, a marketing page, an article index): a display-tier title over a body-lg lede. Pair it with SectionHeader size="lg" for the sections below, so the steps stay display → heading-2 → body.
18874
+ - Don't: Use size="lg" on product routes (settings, lists, dashboards). Display type is never product chrome: there it competes with the content it frames. Keep md, and do not restyle the title with a display class through className either.
18642
18875
  - Don't: Do not use PageHeader inside repeated cards, dialogs, or compact panels.
18643
18876
  - Don't: Do not use a second PageHeader for a section of the page. It is the route's one h1; each section below it takes a SectionHeader (an h2 with the same title row, description and actions).
18644
18877
  - Don't: Do not put it outside the Region that holds the page content. It then sits at a different measure from what it introduces, and the page reads as two documents.
@@ -18652,6 +18885,11 @@ Use PageHeader to establish a page's title, supporting description, and primary
18652
18885
  | back | ReactNode | — | Leading element before the title — typically a back control on drill-in routes. Keep it a ghost icon Button (size="icon", variant="ghost", aria-label="Back") so it stays quieter than the title. A ghost back control bleeds by its inset at the start, so its glyph lines up with the content edge below (Button bleed="none" opts out). |
18653
18886
  | actions | ReactNode | — | Trailing actions aligned to the title row's end. They drop under the title when the title would have less than 20rem beside them (on a phone, nearly always), and the title wraps rather than truncating. A ghost or link Button at either end of the group bleeds by its own padding, so its label meets the content edge (the end on the title row, the start once wrapped); filled buttons keep their edge on the line. Direct children only. |
18654
18887
  | titleAs | "h1" \| "h2" | "h1" | The title's heading element. Keep h1 — the route's one h1. Use h2 only when something above already owns the h1: a shell that renders the route title, or a page showing a PageHeader as an example. |
18888
+ | size | "md" \| "lg" | "md" | The title's scale. md: text-heading-2 over a text-body-sm description, for product pages. lg: text-display-lg over a text-body-lg lede capped at the prose measure, with more space between and below, for an editorial page (personal site, marketing page, article index). Beside actions or a back control, a title line taller than the controls lifts by half the difference, so its first line stays centred on them. |
18889
+
18890
+ ## Variants
18891
+
18892
+ - **size**: md, lg
18655
18893
 
18656
18894
  ## Tokens used
18657
18895
 
@@ -18676,6 +18914,14 @@ import { Button, PageHeader } from "@sableui/react"
18676
18914
  <PageHeader title="Storage credits" description="Fund permanent storage." actions={<Button>Top up</Button>} />
18677
18915
  ```
18678
18916
 
18917
+ ### Editorial page title (size lg)
18918
+
18919
+ ```tsx
18920
+ import { Button, PageHeader } from "@sableui/react"
18921
+
18922
+ <PageHeader size="lg" title="Field notes" description="Essays on making things slowly, from a studio by the sea." actions={<Button variant="outline">Subscribe</Button>} />
18923
+ ```
18924
+
18679
18925
  ### One per page, inside the region
18680
18926
 
18681
18927
  ```tsx
@@ -19356,7 +19602,7 @@ import { PieChart } from "@sableui/react"
19356
19602
 
19357
19603
  **Status:** stable
19358
19604
 
19359
- A chip someone clicks: a filter, a tag choice, an option in a row. Always a button with aria-pressed. appearance="solid" (filters; selected lifts onto the primary surface) or "outline" (quiet chips that annotate content, like reactions); size md or sm. A static label is a Badge.
19605
+ A chip someone clicks: a filter, a tag choice, an option in a row. Always a button with aria-pressed. appearance="solid" (filters; selected lifts onto the primary surface) or "outline" (quiet chips that annotate content, like reactions); size lg, md or sm. A static label is a Badge.
19360
19606
 
19361
19607
  **Keywords:** pill, chip, filter, tag, toggle chip, category
19362
19608
 
@@ -19368,9 +19614,10 @@ Use Pill for a row of filters or choices a person clicks to narrow or toggle wha
19368
19614
 
19369
19615
  - Do: Use short text that names the filter or choice.
19370
19616
  - Do: Reflect the current choice with selected, so the active filter reads at a glance.
19617
+ - Do: In a toolbar where filter chips share a row with a search field ("All 8 · Products 5 · Open source 2 … [Find a project]"), pair Pill size="lg" with SearchInput size="sm", and Button size="sm" for any action beside them. All three stand on --control-sm (32px, 28 compact), the rung the spacing doctrine gives toolbar controls, so the row is one height in either density. Pill md (28px) has no text field of its height in the page register, so md beside a field always reads as a mismatch.
19371
19618
  - Don't: Do not use Pill as a static label. A pressable-looking chip that does nothing is the most common cause of dead clicks in a filter row — that is a Badge. → use **Badge**
19372
19619
  - Don't: Do not use Pill for primary actions or important system status.
19373
- - Don't: Do not restyle a Pill with border or background classes to get a quieter chip. That is appearance="outline" (and size="sm" for a small one); an override is a look that exists nowhere else in the system.
19620
+ - Don't: Do not restyle a Pill with border or background classes to get a quieter chip. That is appearance="outline" (and size="sm" for a small one); an override is a look that exists nowhere else in the system. Likewise never set h-* on a Pill to match a neighbour: pick the size that shares its rung.
19374
19621
 
19375
19622
  ## Props
19376
19623
 
@@ -19378,14 +19625,14 @@ Use Pill for a row of filters or choices a person clicks to narrow or toggle wha
19378
19625
  | --- | --- | --- | --- |
19379
19626
  | selected | boolean | false | Marks the pill as chosen. Solid lifts onto the primary surface; outline takes a primary tint and soft primary edge. Sets aria-pressed. |
19380
19627
  | appearance | "solid" \| "outline" | "solid" | solid for a filter row; outline for quiet chips that annotate other content (a reaction under a message, a tag on a card), where a filled chip would compete with what it annotates. |
19381
- | size | "md" \| "sm" | "md" | md sits on the density ladder's xs control height (28px); sm is 24px, for chips under or inside other content. |
19628
+ | size | "lg" \| "md" \| "sm" | "md" | lg sits on --control-sm (32px, 28 compact), the toolbar rung it shares with SearchInput sm and Button sm, for a filter row beside a search field. md sits on --control-xs (28px, 24 compact), the chip in a row of its own or among content. sm is a fixed 24px (the target floor, which density does not shrink), for chips under or inside other content. |
19382
19629
  | tone | "neutral" \| "red" \| "orange" \| "yellow" \| "green" \| "teal" \| "blue" \| "purple" \| "pink" | "neutral" | neutral is the chip as described. A palette family colours it as a category, like the choices in a tag picker: a wash at rest, the solid family colour when selected. Painted from the theme's generated palette. |
19383
19630
  | icon | ReactNode | — | Optional leading glyph (e.g. <Icon name="lock" />), rendered before the children with the base gap. |
19384
19631
 
19385
19632
  ## Variants
19386
19633
 
19387
19634
  - **appearance**: solid, outline
19388
- - **size**: md, sm
19635
+ - **size**: lg, md, sm
19389
19636
  - **selected**: true, false
19390
19637
  - **tone**: neutral, red, orange, yellow, green, teal, blue, purple, pink
19391
19638
 
@@ -19408,7 +19655,7 @@ Use Pill for a row of filters or choices a person clicks to narrow or toggle wha
19408
19655
 
19409
19656
  ## Related
19410
19657
 
19411
- - Components: Badge, ToggleGroup
19658
+ - Components: Badge, ToggleGroup, SearchInput
19412
19659
 
19413
19660
  ## Examples
19414
19661
 
@@ -19428,6 +19675,24 @@ import { Pill, Row } from "@sableui/react"
19428
19675
  <Row gap={1}><Pill selected onClick={() => {}}>All</Pill><Pill onClick={() => {}}>Projects</Pill><Pill onClick={() => {}}>Reports</Pill></Row>
19429
19676
  ```
19430
19677
 
19678
+ ### Filters with search
19679
+
19680
+ ```tsx
19681
+ import { Pill, Row, SearchInput } from "@sableui/react"
19682
+
19683
+ const [filter, setFilter] = useState("all")
19684
+ const [q, setQ] = useState("")
19685
+
19686
+ <Row gap={2} align="center" justify="between" wrap>
19687
+ <Row gap={1}>
19688
+ <Pill size="lg" selected={filter === "all"} onClick={() => setFilter("all")}>All 8</Pill>
19689
+ <Pill size="lg" selected={filter === "products"} onClick={() => setFilter("products")}>Products 5</Pill>
19690
+ <Pill size="lg" selected={filter === "oss"} onClick={() => setFilter("oss")}>Open source 2</Pill>
19691
+ </Row>
19692
+ <SearchInput size="sm" value={q} onValueChange={setQ} placeholder="Find a project…" aria-label="Find a project" className="max-w-64" />
19693
+ </Row>
19694
+ ```
19695
+
19431
19696
  ### Outline, small (reactions)
19432
19697
 
19433
19698
  ```tsx
@@ -20446,7 +20711,7 @@ import { PropertyRow, ScrubField, Slider } from "@sableui/react"
20446
20711
 
20447
20712
  **Status:** stable
20448
20713
 
20449
- Styled search field with a leading magnifier and a clearable X. Soft-square (rounded-sm), the theme's field surface, 2px focus ring; the clear button appears only when there is a value.
20714
+ Styled search field with a leading magnifier and a clearable X. Soft-square (rounded-sm), the theme's field surface, 2px focus ring; the clear button appears only when there is a value. size md (form), sm (toolbar, matching Pill lg and Button sm) or xs (inset tool-panel field).
20450
20715
 
20451
20716
  **Keywords:** search, input, filter, query
20452
20717
 
@@ -20460,6 +20725,8 @@ Use SearchInput for text queries that filter or search a known content set.
20460
20725
  - Do: Provide a visible clear affordance when query state persists.
20461
20726
  - Do: When a button stands in for search (a header trigger that opens CommandMenu with ⌘K), use CommandMenuTrigger. It wears this field's shell from the same source, so the two cannot drift; a hand-styled button already did once, into a borderless chip that matched neither the field nor a button.
20462
20727
  - Do: Light the query in the results it filters with `useMatchHighlight(query)` — a callback ref for the results container, with `selector` to keep it to titles. It paints on the `highlight` pair without wrapping matches in `<mark>`, the same paint Combobox and CommandMenu use for their rows.
20728
+ - Do: In a toolbar beside filter chips, use size="sm" with Pill size="lg" (and Button size="sm" for actions). All three stand on --control-sm (32px, 28 compact), the rung the spacing doctrine gives toolbar controls, so the row is one height in either density. Keep md for a search field in a form or a page header beside md buttons, and xs for a filter inside a workspace panel, where it matches Input xs's inset surface.
20729
+ - Don't: Do not match a neighbour's height with h-* on className or inputClassName. The magnifier, padding and clear button would stay sized for the old rung; pick the size that shares the neighbour's rung.
20463
20730
  - Don't: Do not use SearchInput as a command palette; use CommandMenu for global commands, opened from the header by CommandMenuTrigger. A SearchInput that opens a dialog on focus is a text box you cannot type in.
20464
20731
  - Don't: Do not search on every keystroke against an expensive backend without debouncing. The user sees results flicker and reorder under them, and you send a request per character.
20465
20732
 
@@ -20467,6 +20734,7 @@ Use SearchInput for text queries that filter or search a known content set.
20467
20734
 
20468
20735
  | Name | Type | Default | Description |
20469
20736
  | --- | --- | --- | --- |
20737
+ | size | "md" \| "sm" \| "xs" | "md" | The rung of the control ladder, named as on Input, Select and Combobox. md is the form field (--control-md, 40px / 32 compact). sm is the toolbar field (--control-sm, 32px / 28 compact), the rung Pill lg and Button sm share, so filters + search + actions in one row are one height. xs is the tool-panel field (--control-xs, 28px / 24 compact) on Input xs's inset panel-field surface. The magnifier, inline padding and clear button scale with the rung and stay vertically centred. |
20470
20738
  | value | string | — | Controlled value of the search field. |
20471
20739
  | defaultValue | string | — | Uncontrolled initial value. The field tracks its own text, so the clear button still appears and empties it. |
20472
20740
  | className | string | — | Sizes and places the whole field — magnifier, input and clear button together (e.g. max-w-80). A width cap here keeps the clear button inside the field. |
@@ -20475,11 +20743,11 @@ Use SearchInput for text queries that filter or search a known content set.
20475
20743
  | onClear | () => void | — | Called when the clear button is pressed. The component also fires onValueChange('') so a controlled value resets. |
20476
20744
  | placeholder | string | "Search" | Placeholder text shown when the field is empty. |
20477
20745
  | disabled | boolean | false | Dims the control and blocks pointer interaction. |
20478
- | ...props | ComponentPropsWithRef<"input"> | — | All native input attributes (name, autoFocus, ref, aria-invalid, …) pass through to the <input> except value/onChange, which are owned by value/onValueChange, and className, which sizes the whole field. |
20746
+ | ...props | ComponentPropsWithRef<"input"> | — | All native input attributes (name, autoFocus, ref, aria-invalid, …) pass through to the <input> except value/onChange, which are owned by value/onValueChange, className, which sizes the whole field, and the native numeric size attribute, which the size prop replaces. |
20479
20747
 
20480
20748
  ## Tokens used
20481
20749
 
20482
- `field-border`, `field`, `foreground`, `muted-foreground`, `ring`, `destructive`
20750
+ `field-border`, `field`, `foreground`, `muted-foreground`, `ring`, `destructive`, `panel-field`, `panel-field-hover`
20483
20751
 
20484
20752
  ## Motion
20485
20753
 
@@ -20498,7 +20766,7 @@ Use SearchInput for text queries that filter or search a known content set.
20498
20766
 
20499
20767
  ## Related
20500
20768
 
20501
- - Components: Input, CommandMenu, Combobox
20769
+ - Components: Input, CommandMenu, Combobox, Pill
20502
20770
 
20503
20771
  ## Examples
20504
20772
 
@@ -20544,6 +20812,36 @@ const shown = posts.filter((post) => post.title.toLowerCase().includes(q.trim().
20544
20812
  </ul>
20545
20813
  ```
20546
20814
 
20815
+ ### Filters with search
20816
+
20817
+ ```tsx
20818
+ import { Pill, Row, SearchInput } from "@sableui/react"
20819
+
20820
+ const [filter, setFilter] = useState("all")
20821
+ const [q, setQ] = useState("")
20822
+
20823
+ <Row gap={2} align="center" justify="between" wrap>
20824
+ <Row gap={1}>
20825
+ <Pill size="lg" selected={filter === "all"} onClick={() => setFilter("all")}>All 8</Pill>
20826
+ <Pill size="lg" selected={filter === "products"} onClick={() => setFilter("products")}>Products 5</Pill>
20827
+ <Pill size="lg" selected={filter === "oss"} onClick={() => setFilter("oss")}>Open source 2</Pill>
20828
+ </Row>
20829
+ <SearchInput size="sm" value={q} onValueChange={setQ} placeholder="Find a project…" aria-label="Find a project" className="max-w-64" />
20830
+ </Row>
20831
+ ```
20832
+
20833
+ ### Sizes
20834
+
20835
+ ```tsx
20836
+ import { SearchInput, Stack } from "@sableui/react"
20837
+
20838
+ <Stack gap={1}>
20839
+ <SearchInput size="md" placeholder="Search posts" aria-label="Search posts" />
20840
+ <SearchInput size="sm" placeholder="Find a project…" aria-label="Find a project" />
20841
+ <SearchInput size="xs" placeholder="Filter layers" aria-label="Filter layers" />
20842
+ </Stack>
20843
+ ```
20844
+
20547
20845
  ### Disabled
20548
20846
 
20549
20847
  ```tsx
@@ -20560,7 +20858,7 @@ import { SearchInput } from "@sableui/react"
20560
20858
 
20561
20859
  The heading block for one section of a page: a title (h2 by default), an optional line of description, and actions on the title row — a "View all" link or a couple of buttons — that drop beneath the title only when the row runs out of room.
20562
20860
 
20563
- **Keywords:** section header, section heading, section title, shelf, view all, see all, show all, h2, subsection, optical alignment, bleed
20861
+ **Keywords:** section header, section heading, section title, shelf, view all, see all, show all, h2, subsection, optical alignment, bleed, editorial section, large section title
20564
20862
 
20565
20863
  ## Usage
20566
20864
 
@@ -20569,7 +20867,7 @@ Use SectionHeader to head each section below the page title — a home-page shel
20569
20867
  ### Best practices
20570
20868
 
20571
20869
  - Do: Wrap the header and its content in a `<section aria-labelledby={titleId}>` (a flex column with a gap, or a Stack inside it) so the section is a named region and the header's title is its name.
20572
- - Do: Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: md for a page's sections and shelves, sm for denser groups like settings.
20870
+ - Do: Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: lg for an editorial page's sections under a PageHeader size="lg", md for a page's sections and shelves, sm for denser groups like settings.
20573
20871
  - Do: Keep actions to one or two `size="sm"` buttons — the title is centred on that height. A shelf's "View all" is a ghost Button rendered as a link (`render={<a href=… />}`), named for what it opens when the label alone is ambiguous (aria-label="View all new releases").
20574
20872
  - Do: Pass a ghost or link action as a plain Button and let the header align it: the last direct child bleeds by its own padding at the end, so "View all" sits on the edge of the grid below, and the first bleeds at the start, which is the edge once the actions wrap beneath the title. Filled buttons keep their edge on the line. Set bleed="none" on the Button to opt one out.
20575
20873
  - Don't: Nudge a View all onto the edge with -me-3, or wrap the actions in your own div: the header aligns direct children only, and only by each button's real padding. If you must wrap them, set bleed on the Button inside instead.
@@ -20594,11 +20892,11 @@ Use SectionHeader to head each section below the page title — a home-page shel
20594
20892
  | actions | ReactNode | — | Trailing actions on the title row — a View all link or a couple of buttons, size="sm". They stay on the title's row and drop beneath it only when the row runs out of room; the title wraps rather than truncating. A ghost or link Button that ends the group bleeds by its padding at the end (and one that starts it, at the start — the edge once wrapped), so its label lines up with the content below; Button bleed="none" opts out. Direct children only. |
20595
20893
  | titleAs | "h2" \| "h3" \| "h4" | "h2" | The heading element, chosen by the document outline: h2 for a section under the page's h1, h3 for a subsection inside one. |
20596
20894
  | titleId | string | — | The title's id, so the section it heads can point at it: <section aria-labelledby={titleId}>. Generated when omitted. |
20597
- | size | "md" \| "sm" | "md" | Visual weight, independent of the heading level: md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets — settings groups, subsections. |
20895
+ | size | "lg" \| "md" \| "sm" | "md" | Visual weight, independent of the heading level: lg (text-heading-2 over a text-body-md description) for an editorial page's sections, under a PageHeader size="lg"; md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets — settings groups, subsections. lg stops at heading-2: heading-1 is the page title's, one per route. The actions stay centred on the title's first line at every size. |
20598
20896
 
20599
20897
  ## Variants
20600
20898
 
20601
- - **size**: md, sm
20899
+ - **size**: lg, md, sm
20602
20900
 
20603
20901
  ## Tokens used
20604
20902
 
@@ -20691,6 +20989,30 @@ import { Button, Entity, Icon, SectionHeader, Stack } from "@sableui/react"
20691
20989
  </section>
20692
20990
  ```
20693
20991
 
20992
+ ### An editorial section (size lg)
20993
+
20994
+ ```tsx
20995
+ import { Button, Card, CardDescription, CardHeader, CardTitle, Grid, SectionHeader } from "@sableui/react"
20996
+
20997
+ <section aria-labelledby="selected-work" className="flex flex-col gap-6">
20998
+ <SectionHeader
20999
+ titleId="selected-work"
21000
+ size="lg"
21001
+ title="Selected work"
21002
+ description="Five projects from the last two years."
21003
+ actions={
21004
+ <Button variant="ghost" size="sm" render={<a href="/work" />} aria-label="View all work">
21005
+ View all
21006
+ </Button>
21007
+ }
21008
+ />
21009
+ <Grid minItemWidth="16rem" gap={3}>
21010
+ <Card><CardHeader><CardTitle>Tidewater</CardTitle><CardDescription>Brand identity</CardDescription></CardHeader></Card>
21011
+ <Card><CardHeader><CardTitle>Northlight</CardTitle><CardDescription>Product design</CardDescription></CardHeader></Card>
21012
+ </Grid>
21013
+ </section>
21014
+ ```
21015
+
20694
21016
  ### A subsection inside a section
20695
21017
 
20696
21018
  ```tsx
@@ -20701,6 +21023,110 @@ import { SectionHeader } from "@sableui/react"
20701
21023
 
20702
21024
  ---
20703
21025
 
21026
+ # SectionRail
21027
+
21028
+ **Status:** stable
21029
+
21030
+ A slim vertical rail of dots, one per section of a long page, that shows where the reader is and jumps to any section. The current section's bar glides as the page scrolls; a shared tooltip names each dot.
21031
+
21032
+ **Keywords:** section rail, scroll spy, scrollspy, dot navigation, page navigation, table of contents, on this page, chapter nav, scroll progress, case study nav
21033
+
21034
+ ## Usage
21035
+
21036
+ Use SectionRail beside a long, single-column read — a case study, an essay, a long landing page — so the reader can see which section they are in and jump to another without leaving the text. It follows the scroll by itself (useActiveSection); pass value when the parent already knows the current section.
21037
+
21038
+ ### Best practices
21039
+
21040
+ - Do: Give every section an id that matches its entry, and keep the list to the page's top-level sections — about ten at most, so the rail stays shorter than the viewport.
21041
+ - Do: Place it with layout classes — usually `fixed top-1/2 left-6 z-(--z-nav) -translate-y-1/2` — and hide it below the width where it would overlap the text (`max-lg:hidden`). Offer the same links another way on narrow screens, such as a Collapsible list at the top of the page.
21042
+ - Do: Pass `root` when the content scrolls inside an element (a Scroller, an inset AppShell main) rather than the window, and give sections `scroll-mt-*` when a sticky header would cover their tops after a jump.
21043
+ - Do: Set `side="right"` when the rail sits on the right edge, so labels open toward the content.
21044
+ - Don't: Use it for app navigation between pages; that is SideNav or Sidebar. A SectionRail only moves within one page. → use **SideNav**, **Sidebar**
21045
+ - Don't: Use it to show the steps of a task or a flow; that is Stepper, which shows progress through steps rather than position in a read. → use **Stepper**
21046
+ - Don't: Use it for a handful of switchable views in one place; that is Tabs. → use **Tabs**
21047
+
21048
+ ### Anatomy
21049
+
21050
+ | Part | Required | Description |
21051
+ | --- | --- | --- |
21052
+ | Rail | yes | The `<nav>` landmark: a rounded muted track holding one link per section. |
21053
+ | Dot | yes | A link to `#id`, drawn as a small dot that grows under the pointer or focus. |
21054
+ | Marker | yes | One primary bar that sits on the current section's dot and glides to the next. |
21055
+ | Label | yes | A shared tooltip that names the hovered or focused dot and travels between dots. |
21056
+
21057
+ ## Props
21058
+
21059
+ | Name | Type | Default | Description |
21060
+ | --- | --- | --- | --- |
21061
+ | sections | readonly { id: string; label: string }[] | — | The sections, in document order. `id` is the section element's id (the link goes to `#id`); `label` is the tooltip and the link's accessible name. |
21062
+ | value | string | — | The current section's id, when the parent decides it. Leave it unset and the rail follows the scroll itself. |
21063
+ | onValueChange | (id: string) => void | — | Called with the new id when the reader scrolls into a section or picks one. |
21064
+ | offset | number | 0.35 | Where the reading line sits, as a fraction of the viewport from its top. A section becomes current once its top crosses it. |
21065
+ | root | RefObject<HTMLElement \| null> | — | The element that scrolls, when it is not the window. |
21066
+ | side | "left" \| "right" | "left" | Which edge of the page the rail sits on. The labels open on the other side, toward the content. |
21067
+ | motion | "full" \| "subtle" \| "off" | "full" | How much motion this component uses: `full` inherits the app's policy, `subtle` keeps feedback and drops the marker's glide, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. |
21068
+ | aria-label | string | "Sections" | The nav landmark's name. Name it for the page ("Case study sections") when it has other navs. |
21069
+
21070
+ ## Tokens used
21071
+
21072
+ `muted`, `muted-foreground`, `foreground`, `primary`, `ring`, `popover`, `popover-foreground`, `border-floating`
21073
+
21074
+ ## Motion
21075
+
21076
+ - **arrival** · Root · _no motion_ — Page chrome arrives with the page; the marker lands on the current section with no transition, because a bar sliding in on load narrates a move nobody made.
21077
+ - **response** · Dot · `motion-interactive` — The dot darkens and grows under the pointer or focus, and the label travels between dots as one tooltip (TooltipGroup) rather than closing and reopening at each.
21078
+ - **transition** · Marker · `motion-glide` — One bar travels to the new section as the page scrolls, so the change reads as movement down the page. A picked section holds the bar until the reader scrolls by hand, so it goes straight there instead of stepping through every section in between, and stays on the pick even when the page ends before that section can reach the reading line. Expressive: `subtle` moves it at once.
21079
+ - **activity** · Root · _no motion_ — Navigation chrome has no working state.
21080
+
21081
+ ## Accessibility
21082
+
21083
+ - Renders a <nav> landmark (default name "Sections") of real links to `#id`, so it works with no script, middle-click and copy-link.
21084
+ - Each link is named by its section's label; the current one carries aria-current="location".
21085
+ - Activating a link from the keyboard moves focus into the section, as following an anchor does, so the next Tab continues from there.
21086
+ - The tooltip opens on focus as well as hover; the dots are 44×32 targets.
21087
+ - Under reduced motion the jump is instant and the marker moves without travel.
21088
+
21089
+ ## Related
21090
+
21091
+ - Components: SideNav, Tooltip, Stepper, Tabs, GlidingHighlight
21092
+
21093
+ ## Examples
21094
+
21095
+ ### Fixed beside a long read
21096
+
21097
+ ```tsx
21098
+ import { SectionRail } from "@sableui/react"
21099
+
21100
+ <SectionRail
21101
+ aria-label="Case study sections"
21102
+ sections={[
21103
+ { id: "overview", label: "Overview" },
21104
+ { id: "problem", label: "The problem" },
21105
+ { id: "process", label: "Process" },
21106
+ { id: "outcome", label: "Outcome" },
21107
+ ]}
21108
+ className="fixed top-1/2 left-6 z-(--z-nav) -translate-y-1/2 max-lg:hidden"
21109
+ />
21110
+ ```
21111
+
21112
+ ### On the right edge
21113
+
21114
+ ```tsx
21115
+ import { SectionRail } from "@sableui/react"
21116
+
21117
+ <SectionRail
21118
+ side="right"
21119
+ sections={[
21120
+ { id: "intro", label: "Introduction" },
21121
+ { id: "usage", label: "Usage" },
21122
+ { id: "api", label: "API" },
21123
+ ]}
21124
+ className="fixed top-1/2 right-6 z-(--z-nav) -translate-y-1/2 max-lg:hidden"
21125
+ />
21126
+ ```
21127
+
21128
+ ---
21129
+
20704
21130
  # SegmentedBar
20705
21131
 
20706
21132
  **Status:** stable
@@ -21180,7 +21606,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
21180
21606
 
21181
21607
  | Name | Type | Default | Description |
21182
21608
  | --- | --- | --- | --- |
21183
- | Sheet (Root) | Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle, snapPoints, snapPoint, defaultSnapPoint, onSnapPointChange, snapToSequentialPoints) — minus swipeDirection | — | Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it. |
21609
+ | Sheet (Root) | Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle, snapPoints, snapPoint, defaultSnapPoint, onSnapPointChange, snapToSequentialPoints) — minus swipeDirection | — | Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it. Records the element focused when the sheet opens, trigger or not, and returns focus there on close. |
21184
21610
  | snapPoints | (number \| string)[] | — | Stops a bottom sheet rests at, smallest first: a fraction of the viewport (0.4), pixels (320), or a px/rem string ("20rem"). The panel is the full stop's height and opens at the first stop; a swipe moves between stops and a swipe below the first closes it. side="bottom" only: other sides ignore them, with a development warning. |
21185
21611
  | snapPoint | number \| string \| null | — | The active stop, controlled. Pair with onSnapPointChange; use defaultSnapPoint to open at a different stop uncontrolled. |
21186
21612
  | onSnapPointChange | (snapPoint: number \| string \| null, eventDetails) => void | — | Fires when a swipe (or your code) moves the sheet to another stop. |
@@ -21200,7 +21626,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
21200
21626
  - The panel scrolls its own content (thin scrollbar, contained overscroll) when it outgrows its bound — the full height on left/right, 80vh on top/bottom.
21201
21627
  - Esc and click-outside dismiss by default; on touch, a swipe toward the sheet's own edge dismisses too. A short or slow drag springs back.
21202
21628
  - Swipe is an extra path, never the only one: Esc, click-outside and SheetClose all still work.
21203
- - Focus matches Dialog: the first tabbable on mouse/keyboard open, the panel itself on touch (so the virtual keyboard stays down); focus returns to the trigger on close.
21629
+ - Focus matches Dialog: the first tabbable on mouse/keyboard open, the panel itself on touch (so the virtual keyboard stays down). On close, focus returns to the element that held it when the sheet opened: the SheetTrigger, or your own button when it drives a controlled open. It is captured before the content mounts, so an autoFocus field inside cannot displace it; an explicit finalFocus on SheetContent wins.
21204
21630
  - Render a SheetClose inside SheetContent so screen-reader users can escape the popup.
21205
21631
  - Slide animation runs via data-[starting-style]/data-[ending-style] attributes on the motion-duration token; while swiping the panel tracks the finger directly, and a released swipe finishes on the same token (scaled by flick speed), so reduced motion makes it instant.
21206
21632
  - A mouse drag that starts on sheet content selects text rather than dragging the panel; mouse dragging only starts from the padding edge.