@sableui/react 0.17.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 (154) hide show
  1. package/AGENTS.md +10 -3
  2. package/CHANGELOG.md +235 -0
  3. package/README.md +11 -4
  4. package/bin/sable.mjs +266 -30
  5. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  6. package/dist/activity-DT8VK9fx.d.ts +25 -0
  7. package/dist/adapters/github.d.ts +41 -0
  8. package/dist/adapters/github.js +24 -0
  9. package/dist/blocks/index.d.ts +1 -1
  10. package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
  11. package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
  12. package/dist/components/activity-heatmap/index.js +9 -0
  13. package/dist/components/avatar/avatar.js +16 -7
  14. package/dist/components/avatar/avatar.meta.js +43 -10
  15. package/dist/components/avatar/avatar.variants.js +26 -2
  16. package/dist/components/code-block/code-block.js +5 -3
  17. package/dist/components/code-block/code-block.meta.js +6 -0
  18. package/dist/components/collapsible/collapsible.js +6 -5
  19. package/dist/components/collapsible/collapsible.meta.js +14 -5
  20. package/dist/components/data-table/data-table.js +132 -45
  21. package/dist/components/data-table/data-table.meta.js +63 -4
  22. package/dist/components/date-picker/date-picker.js +13 -6
  23. package/dist/components/date-picker/date-picker.meta.js +22 -0
  24. package/dist/components/dialog/dialog.js +25 -5
  25. package/dist/components/dialog/dialog.meta.js +26 -5
  26. package/dist/components/media-image/media-image.js +3 -1
  27. package/dist/components/media-image/media-image.meta.js +15 -1
  28. package/dist/components/media-tile/media-tile.js +13 -1
  29. package/dist/components/media-tile/media-tile.meta.js +10 -0
  30. package/dist/components/menu/menu.js +2 -0
  31. package/dist/components/menu/menu.meta.js +2 -2
  32. package/dist/components/page-header/page-header.js +9 -4
  33. package/dist/components/page-header/page-header.meta.js +26 -1
  34. package/dist/components/panel-group/panel-group.js +17 -1
  35. package/dist/components/panel-group/panel-group.meta.js +43 -3
  36. package/dist/components/pill/pill.meta.js +15 -6
  37. package/dist/components/pill/pill.variants.js +1 -0
  38. package/dist/components/search-input/search-input.js +30 -5
  39. package/dist/components/search-input/search-input.meta.js +29 -4
  40. package/dist/components/section-header/section-header.js +27 -17
  41. package/dist/components/section-header/section-header.meta.js +27 -5
  42. package/dist/components/sheet/sheet.js +15 -3
  43. package/dist/components/sheet/sheet.meta.js +2 -2
  44. package/dist/components/side-nav/side-nav.variants.js +8 -3
  45. package/dist/components/sidebar/index.js +4 -0
  46. package/dist/components/sidebar/sidebar.js +90 -11
  47. package/dist/components/sidebar/sidebar.meta.js +38 -4
  48. package/dist/components/table/table.js +7 -3
  49. package/dist/components/tabs/tabs.js +10 -2
  50. package/dist/components/tabs/tabs.meta.js +4 -0
  51. package/dist/components/workspace/workspace.js +13 -3
  52. package/dist/components/workspace/workspace.meta.js +109 -1
  53. package/dist/file-hashes.json +94 -80
  54. package/dist/foundations/typography.js +13 -0
  55. package/dist/icon/Icon.js +21 -2
  56. package/dist/icon/index.d.ts +36 -1
  57. package/dist/icon/index.js +5 -0
  58. package/dist/icon/names.js +240 -0
  59. package/dist/icon-sets/reicon.js +41 -1
  60. package/dist/index.d.ts +540 -75
  61. package/dist/index.js +18 -1
  62. package/dist/layout/AspectBox.js +2 -5
  63. package/dist/layout/Bleed.js +10 -6
  64. package/dist/layout/Center.js +11 -3
  65. package/dist/layout/Columns.js +6 -3
  66. package/dist/layout/Grid.js +14 -6
  67. package/dist/layout/GridOverlay.js +7 -7
  68. package/dist/layout/Region.js +18 -3
  69. package/dist/layout/Row.js +27 -20
  70. package/dist/layout/Spacer.js +13 -2
  71. package/dist/layout/SplitPane.js +1 -1
  72. package/dist/layout/Stack.js +25 -18
  73. package/dist/layout/index.d.ts +57 -6
  74. package/dist/lib/chart/activity.js +32 -0
  75. package/dist/lib/chart/calendar.js +67 -0
  76. package/dist/lib/chart/chart-tooltip.js +2 -2
  77. package/dist/lib/chart/heat.js +55 -0
  78. package/dist/lib/cn.js +1 -0
  79. package/dist/lib/focus-return.js +53 -0
  80. package/dist/lib/taxonomy.js +1 -0
  81. package/dist/lib/use-media-query.js +41 -0
  82. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  83. package/dist/patterns/dashboard-page.js +23 -17
  84. package/dist/patterns/index.d.ts +7 -2
  85. package/dist/patterns/index.js +5 -1
  86. package/dist/patterns/tracker-page.js +392 -0
  87. package/dist/patterns/tracker-page.meta.js +40 -0
  88. package/dist/presets/index.d.ts +1 -1
  89. package/dist/styles.css +301 -188
  90. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  91. package/dist/themes/_contract.js +9 -1
  92. package/dist/themes/atelier/index.d.ts +1 -1
  93. package/dist/themes/ember/index.d.ts +1 -1
  94. package/dist/themes/index.d.ts +2 -2
  95. package/dist/themes/meridian/index.d.ts +1 -1
  96. package/dist/themes/poppy/index.d.ts +1 -1
  97. package/dist/themes/prism/index.d.ts +1 -1
  98. package/dist/themes/sable/index.d.ts +1 -1
  99. package/dist/themes/verdant/index.d.ts +1 -1
  100. package/llms/components/activity-heatmap.md +133 -0
  101. package/llms/components/avatar.md +32 -9
  102. package/llms/components/code-block.md +1 -0
  103. package/llms/components/collapsible.md +12 -4
  104. package/llms/components/data-table.md +40 -3
  105. package/llms/components/date-picker.md +15 -1
  106. package/llms/components/dialog.md +34 -3
  107. package/llms/components/media-image.md +12 -0
  108. package/llms/components/media-tile.md +21 -0
  109. package/llms/components/menu.md +1 -1
  110. package/llms/components/page-header.md +16 -1
  111. package/llms/components/panel-group.md +34 -2
  112. package/llms/components/pill.md +24 -5
  113. package/llms/components/search-input.md +37 -4
  114. package/llms/components/section-header.md +28 -4
  115. package/llms/components/sheet.md +2 -2
  116. package/llms/components/sidebar.md +17 -4
  117. package/llms/components/tabs.md +1 -0
  118. package/llms/components/workspace.md +81 -1
  119. package/llms/foundations/typography.md +4 -1
  120. package/llms/layout/aspect-box.md +1 -1
  121. package/llms/layout/bleed.md +1 -1
  122. package/llms/layout/center.md +1 -1
  123. package/llms/layout/columns.md +1 -1
  124. package/llms/layout/grid.md +1 -1
  125. package/llms/layout/region.md +1 -1
  126. package/llms/layout/row.md +9 -1
  127. package/llms/layout/spacer.md +1 -1
  128. package/llms/layout/split-pane.md +1 -1
  129. package/llms/layout/stack.md +1 -1
  130. package/llms/patterns/dashboard-page.md +18 -15
  131. package/llms/patterns/tracker-page.md +450 -0
  132. package/llms/reference/token-intent.md +3 -1
  133. package/llms/reference/utilities.md +4 -1
  134. package/llms/tasks/auditing-an-app.md +35 -1
  135. package/llms/tasks/building-a-page.md +3 -2
  136. package/llms/tasks/building-a-tool.md +101 -5
  137. package/llms/tasks/building-with-sable.md +11 -3
  138. package/llms/tasks/creating-a-preset.md +9 -0
  139. package/llms/tasks/getting-started.md +10 -3
  140. package/llms/tasks/using-presets.md +1 -2
  141. package/llms-full.txt +1215 -89
  142. package/llms.txt +11 -9
  143. package/package.json +7 -1
  144. package/registry.json +1021 -70
  145. package/scripts/adoption.mjs +252 -0
  146. package/scripts/agent-setup.mjs +110 -18
  147. package/scripts/app-css.mjs +129 -0
  148. package/scripts/grade.mjs +178 -2
  149. package/scripts/lint-rules.mjs +126 -0
  150. package/scripts/wiring.mjs +2 -1
  151. package/skills/entry/SKILL.md +1 -1
  152. package/skills/sable/SKILL.md +64 -6
  153. package/skills/sable/agents/openai.yaml +1 -1
  154. package/targets/ios.json +9 -0
@@ -1,7 +1,7 @@
1
1
  // src/components/data-table/data-table.meta.ts
2
2
  var dataTableMeta = {
3
3
  name: "DataTable",
4
- summary: "A data-driven table over the Table primitives \u2014 a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc \u2192 desc \u2192 none), optional row click / selection, and an empty state. For server data or paging, sort/page the data and pair with Pagination.",
4
+ summary: "A data-driven table over the Table primitives \u2014 a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc \u2192 desc \u2192 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.",
5
5
  keywords: [
6
6
  "data table",
7
7
  "table",
@@ -10,7 +10,14 @@ var dataTableMeta = {
10
10
  "rows",
11
11
  "empty state",
12
12
  "pagination",
13
- "list"
13
+ "list",
14
+ "grouped",
15
+ "group by",
16
+ "sections",
17
+ "issue list",
18
+ "issue tracker",
19
+ "status groups",
20
+ "collapsible groups"
14
21
  ],
15
22
  usage: {
16
23
  description: "Use DataTable when a data array and column definition should render a sortable, semantic table with optional row actions and an empty state. Use the lower-level Table parts for hand-authored markup or highly custom row layouts.",
@@ -33,7 +40,11 @@ var dataTableMeta = {
33
40
  },
34
41
  {
35
42
  guidance: "do",
36
- description: "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."
43
+ description: "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."
44
+ },
45
+ {
46
+ guidance: "do",
47
+ description: "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."
37
48
  },
38
49
  {
39
50
  guidance: "dont",
@@ -60,6 +71,11 @@ var dataTableMeta = {
60
71
  required: false,
61
72
  description: "Content spanning all columns when the row array is empty."
62
73
  },
74
+ {
75
+ name: "Group header row",
76
+ required: false,
77
+ description: "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."
78
+ },
63
79
  {
64
80
  name: "Caption",
65
81
  required: false,
@@ -136,6 +152,32 @@ var dataTableMeta = {
136
152
  name: "caption",
137
153
  type: "ReactNode",
138
154
  description: "Bottom table caption."
155
+ },
156
+ {
157
+ name: "groupBy",
158
+ type: "(row) => string",
159
+ description: "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."
160
+ },
161
+ {
162
+ name: "groups",
163
+ type: "{ key: string; label?: ReactNode; icon?: ReactNode; action?: ReactNode; defaultExpanded?: boolean }[]",
164
+ description: "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."
165
+ },
166
+ {
167
+ name: "collapsible",
168
+ type: "boolean",
169
+ default: "true",
170
+ description: "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."
171
+ },
172
+ {
173
+ name: "expandedGroups",
174
+ type: "ReadonlySet<string>",
175
+ description: "The open groups, by key (controlled). Without it each group starts at its defaultExpanded and the table holds the state."
176
+ },
177
+ {
178
+ name: "onExpandedGroupsChange",
179
+ type: "(keys: Set<string>) => void",
180
+ description: "Called with the new set of open group keys when a group header is toggled."
139
181
  }
140
182
  ],
141
183
  tokensUsed: ["muted-foreground", "foreground", "ring"],
@@ -164,6 +206,18 @@ var dataTableMeta = {
164
206
  part: "Row",
165
207
  why: "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."
166
208
  },
209
+ {
210
+ slot: "transition",
211
+ recipe: "motion-interactive",
212
+ part: "Group chevron",
213
+ why: "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."
214
+ },
215
+ {
216
+ slot: "arrival",
217
+ recipe: "none",
218
+ part: "Group rows",
219
+ why: "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."
220
+ },
167
221
  {
168
222
  slot: "activity",
169
223
  recipe: "none",
@@ -174,7 +228,8 @@ var dataTableMeta = {
174
228
  a11y: [
175
229
  "Sorted columns set aria-sort=ascending/descending on the header cell.",
176
230
  "Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.",
177
- "Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
231
+ "Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure.",
232
+ '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.'
178
233
  ],
179
234
  examples: [
180
235
  {
@@ -197,6 +252,10 @@ var dataTableMeta = {
197
252
  label: "In a card, edge to edge",
198
253
  code: '<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>'
199
254
  },
255
+ {
256
+ label: "Issues grouped by status",
257
+ code: 'const STATUSES = [\n { key: "in-progress", label: "In progress", icon: <StatusDot tone="warning" /> },\n { key: "in-review", label: "In review", icon: <StatusDot tone="purple" /> },\n { key: "todo", label: "Todo", icon: <StatusDot /> },\n { key: "done", label: "Done", icon: <StatusDot tone="success" />, defaultExpanded: false },\n]\n\n<DataTable\n data={issues}\n rowKey={(t) => t.id}\n groupBy={(t) => t.status}\n 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> }))}\n onRowClick={(t) => setSelectedId(t.id)}\n isRowSelected={(t) => t.id === selectedId}\n columns={[\n { key: "id", header: "ID", className: "w-20", cell: (t) => <span className="text-mono-caption text-muted-foreground">{t.id}</span> },\n { key: "title", header: "Issue", sortable: true },\n { key: "due", header: "Due", sortable: true, sortValue: (t) => t.due.getTime(), cell: (t) => formatDate(t.due) },\n ]}\n/>'
258
+ },
200
259
  {
201
260
  label: "Empty state",
202
261
  code: '<DataTable data={[]} columns={[{ key: "name", header: "Name" }, { key: "status", header: "Status" }]} empty={<EmptyState icon={<Icon name="search" />} title="No deployments" description="Try a different filter or create a deployment." action={<Button>Create deployment</Button>} />} />'
@@ -12,7 +12,7 @@ import { useId, useState } from "react";
12
12
  import { Icon } from "../../icon/index.js";
13
13
  import { cn } from "../../lib/cn.js";
14
14
  import { useDensity, useTypeTier } from "../../lib/density.js";
15
- import { FIELD_INVALID, FIELD_SHELL, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
15
+ import { FIELD_INVALID, FIELD_SHELL, FIELD_SHELL_XS, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
16
16
  import { Calendar } from "../calendar/calendar.js";
17
17
  import { Popover, PopoverContent, PopoverTrigger } from "../popover/popover.js";
18
18
  import {
@@ -56,6 +56,8 @@ function DatePicker(props) {
56
56
  disabled,
57
57
  minDate,
58
58
  maxDate,
59
+ size = "md",
60
+ formatOptions,
59
61
  className,
60
62
  id,
61
63
  "aria-label": ariaLabel,
@@ -102,11 +104,12 @@ function DatePicker(props) {
102
104
  if (next == null) return;
103
105
  commit(setMinutes(timeBase(), Number(next)));
104
106
  }
105
- const label = value ? (
107
+ const label = value ? formatOptions ? new Intl.DateTimeFormat(void 0, formatOptions).format(value) : (
106
108
  // 24-hour, matching the hour / minute Selects below — the trigger
107
109
  // and the row that sets it must not show the same time two ways.
108
110
  format(value, withTime ? "MMM d, yyyy 'at' HH:mm" : "PP")
109
111
  ) : placeholder ?? (withTime ? "Pick date & time" : "Pick a date");
112
+ const xs = size === "xs";
110
113
  return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
111
114
  /* @__PURE__ */ jsx(
112
115
  PopoverTrigger,
@@ -130,17 +133,21 @@ function DatePicker(props) {
130
133
  "aria-required": ariaRequired,
131
134
  disabled,
132
135
  className: cn(
133
- "inline-flex h-(--control-md) items-center gap-2 ps-3 pe-3 text-start text-foreground",
136
+ "inline-flex items-center text-start text-foreground",
137
+ xs ? "h-(--control-xs) gap-1 px-1.5" : "h-(--control-md) gap-2 ps-3 pe-3",
134
138
  // Select's type: the density tier, plus the 16px phone tier
135
- // its form-row neighbours take (lib/field-shell).
139
+ // its form-row neighbours take (lib/field-shell). The xs rung
140
+ // never sits in a page-register form row, so it skips it.
136
141
  tier.body,
137
- density === "comfortable" && MOBILE_FIELD_TIER,
142
+ density === "comfortable" && !xs && MOBILE_FIELD_TIER,
138
143
  FIELD_SHELL,
144
+ // After FIELD_SHELL, so its well replaces the outlined field.
145
+ xs && FIELD_SHELL_XS,
139
146
  FIELD_INVALID,
140
147
  className
141
148
  ),
142
149
  children: [
143
- /* @__PURE__ */ jsx(Icon, { name: "calendar", className: "shrink-0 text-sm text-muted-foreground" }),
150
+ /* @__PURE__ */ jsx(Icon, { name: "calendar", className: cn("shrink-0 text-muted-foreground", xs ? "text-xs" : "text-sm") }),
144
151
  /* @__PURE__ */ jsx("span", { className: cn("truncate", !value && "text-muted-foreground"), children: label })
145
152
  ]
146
153
  }
@@ -5,6 +5,9 @@ var datePickerMeta = {
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "date picker",
8
+ "due date",
9
+ "property row",
10
+ "inspector",
8
11
  "calendar",
9
12
  "date input",
10
13
  "date time picker",
@@ -104,6 +107,17 @@ var datePickerMeta = {
104
107
  type: "Date",
105
108
  description: "Latest selectable day (inclusive). Later days are disabled and navigation is bounded."
106
109
  },
110
+ {
111
+ name: "size",
112
+ type: '"md" | "xs"',
113
+ default: '"md"',
114
+ description: `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 \u2014 use it for a due date in a PropertyRow instead of a native date input.`
115
+ },
116
+ {
117
+ name: "formatOptions",
118
+ type: "Intl.DateTimeFormatOptions",
119
+ description: `How the trigger writes the chosen date, as Intl.DateTimeFormat options in the reader's locale \u2014 { 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").`
120
+ },
107
121
  {
108
122
  name: "className",
109
123
  type: "string",
@@ -137,6 +151,14 @@ var datePickerMeta = {
137
151
  'With granularity="minute" the hour and minute Selects are full Base UI listboxes (typeahead, ARIA) labelled "Hour" and "Minute"; selecting a day keeps the chosen time-of-day, so day and time can be set in any order.'
138
152
  ],
139
153
  examples: [
154
+ {
155
+ label: "In an inspector row",
156
+ code: `<PanelSection title="Properties">
157
+ <PropertyRow label="Due date">
158
+ <DatePicker size="xs" aria-label="Due date" value={due} onValueChange={setDue} formatOptions={{ month: "short", day: "numeric" }} />
159
+ </PropertyRow>
160
+ </PanelSection>`
161
+ },
140
162
  {
141
163
  label: "Controlled",
142
164
  code: "const [date, setDate] = useState<Date>()\n<DatePicker value={date} onValueChange={setDate} />"
@@ -1,13 +1,19 @@
1
1
  // src/components/dialog/dialog.tsx
2
2
  import { AlertDialog as AlertBase } from "@base-ui/react/alert-dialog";
3
3
  import { Dialog as Base } from "@base-ui/react/dialog";
4
+ import { useMemo } from "react";
4
5
  import { Icon } from "../../icon/index.js";
5
6
  import { cn } from "../../lib/cn.js";
7
+ import { FocusReturnProvider, useFocusReturn, useFocusReturnDefault } from "../../lib/focus-return.js";
6
8
  import { withPortalThemeScope } from "../../lib/portal-theme-scope.js";
7
9
  import { PresentationProvider } from "../../lib/presentation.js";
10
+ import { mergeRefs } from "../../lib/refs.js";
8
11
  import { Button } from "../button/button.js";
9
12
  import { jsx, jsxs } from "react/jsx-runtime";
10
- var Dialog = Base.Root;
13
+ function Dialog({ open, onOpenChange, ...props }) {
14
+ const focus = useFocusReturn(open, onOpenChange);
15
+ return /* @__PURE__ */ jsx(FocusReturnProvider, { value: focus.focusReturn, children: /* @__PURE__ */ jsx(Base.Root, { ...props, open, onOpenChange: focus.onOpenChange }) });
16
+ }
11
17
  var DialogTrigger = Base.Trigger;
12
18
  var DialogClose = Base.Close;
13
19
  var DialogPortal = withPortalThemeScope(Base.Portal);
@@ -29,13 +35,22 @@ function DialogContent({
29
35
  size = "default",
30
36
  showCloseButton,
31
37
  scroll,
38
+ finalFocus,
39
+ ref,
32
40
  ...props
33
41
  }) {
34
- return size === "fullscreen" ? /* @__PURE__ */ jsx(FullscreenContent, { showCloseButton: showCloseButton ?? true, ...props }) : /* @__PURE__ */ jsx(
42
+ const focusReturn = useFocusReturnDefault();
43
+ const popupRef = useMemo(
44
+ () => focusReturn ? mergeRefs(focusReturn.popupRef, ref) : ref,
45
+ [focusReturn, ref]
46
+ );
47
+ const focus = { ref: popupRef, finalFocus: finalFocus === void 0 ? focusReturn?.finalFocus : finalFocus };
48
+ return size === "fullscreen" ? /* @__PURE__ */ jsx(FullscreenContent, { showCloseButton: showCloseButton ?? true, ...focus, ...props }) : /* @__PURE__ */ jsx(
35
49
  CardContent,
36
50
  {
37
51
  showCloseButton: showCloseButton ?? false,
38
52
  scroll: scroll ?? true,
53
+ ...focus,
39
54
  ...props
40
55
  }
41
56
  );
@@ -44,9 +59,10 @@ function FullscreenContent({
44
59
  className,
45
60
  children,
46
61
  showCloseButton,
62
+ keepMounted,
47
63
  ...props
48
64
  }) {
49
- return /* @__PURE__ */ jsxs(DialogPortal, { children: [
65
+ return /* @__PURE__ */ jsxs(DialogPortal, { keepMounted, children: [
50
66
  /* @__PURE__ */ jsx(DialogOverlay, { size: "fullscreen" }),
51
67
  /* @__PURE__ */ jsxs(
52
68
  Base.Popup,
@@ -93,9 +109,10 @@ function CardContent({
93
109
  children,
94
110
  showCloseButton,
95
111
  scroll,
112
+ keepMounted,
96
113
  ...props
97
114
  }) {
98
- return /* @__PURE__ */ jsxs(DialogPortal, { children: [
115
+ return /* @__PURE__ */ jsxs(DialogPortal, { keepMounted, children: [
99
116
  /* @__PURE__ */ jsx(DialogOverlay, {}),
100
117
  /* @__PURE__ */ jsxs(
101
118
  Base.Popup,
@@ -175,7 +192,10 @@ function DialogFooter({
175
192
  }
176
193
  );
177
194
  }
178
- var AlertDialog = AlertBase.Root;
195
+ function AlertDialog({ open, onOpenChange, ...props }) {
196
+ const focus = useFocusReturn(open, onOpenChange);
197
+ return /* @__PURE__ */ jsx(FocusReturnProvider, { value: focus.focusReturn, children: /* @__PURE__ */ jsx(AlertBase.Root, { ...props, open, onOpenChange: focus.onOpenChange }) });
198
+ }
179
199
  var AlertDialogTrigger = AlertBase.Trigger;
180
200
  var AlertDialogClose = DialogClose;
181
201
  var AlertDialogContent = DialogContent;
@@ -59,6 +59,18 @@ var dialogMeta = {
59
59
  description: "Open a dialog for passive status updates. Use Toast, Banner, or InlineStatus instead.",
60
60
  instead: ["Toast", "Banner", "InlineStatus"]
61
61
  },
62
+ {
63
+ guidance: "do",
64
+ description: "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."
65
+ },
66
+ {
67
+ guidance: "do",
68
+ description: "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."
69
+ },
70
+ {
71
+ guidance: "dont",
72
+ description: "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."
73
+ },
62
74
  {
63
75
  guidance: "do",
64
76
  description: '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 \u2014 only Cancel, Esc, or the action. For irreversible deletes, use DestructiveActionModal, which is built on it.'
@@ -101,8 +113,11 @@ var dialogMeta = {
101
113
  {
102
114
  name: "Dialog (Root)",
103
115
  type: "Base UI Dialog.Root props (open, defaultOpen, onOpenChange, modal)",
104
- description: "Controls open state. Uncontrolled by default."
105
- }
116
+ description: "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."
117
+ },
118
+ { name: "open", type: "boolean", description: "Controlled open state. Pair with onOpenChange \u2014 for a dialog opened from your own button instead of a DialogTrigger." },
119
+ { name: "defaultOpen", type: "boolean", default: "false", description: "Initial open state when uncontrolled." },
120
+ { name: "onOpenChange", type: "(open: boolean, eventDetails) => void", description: "Called when the dialog asks to open or close (a trigger, Esc, an outside click, DialogClose)." }
106
121
  ],
107
122
  variants: {
108
123
  size: ["default", "fullscreen"]
@@ -110,8 +125,8 @@ var dialogMeta = {
110
125
  subcomponents: [
111
126
  {
112
127
  name: "DialogContent",
113
- type: 'Base UI Popup props + size?: "default" | "fullscreen" + showCloseButton?: boolean + scroll?: boolean + className',
114
- description: '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.'
128
+ type: 'Base UI Popup props (initialFocus, finalFocus) + size?: "default" | "fullscreen" + showCloseButton?: boolean + scroll?: boolean + keepMounted?: boolean + className',
129
+ description: '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.'
115
130
  },
116
131
  {
117
132
  name: "DialogOverlay",
@@ -151,7 +166,7 @@ var dialogMeta = {
151
166
  {
152
167
  name: "AlertDialog (Root)",
153
168
  type: "Base UI AlertDialog.Root props (open, defaultOpen, onOpenChange)",
154
- description: 'Root for a confirmation that needs an answer. Always modal; the popup is role="alertdialog" and a click outside does not dismiss \u2014 only an AlertDialogClose, an action that sets open to false, or Esc.'
169
+ description: 'Root for a confirmation that needs an answer. Always modal; the popup is role="alertdialog" and a click outside does not dismiss \u2014 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.'
155
170
  },
156
171
  {
157
172
  name: "AlertDialogTrigger",
@@ -210,6 +225,8 @@ var dialogMeta = {
210
225
  a11y: [
211
226
  "Base UI provides focus trapping, scroll lock, aria-modal, and labelled-by wiring (DialogTitle).",
212
227
  "Content taller than the viewport scrolls inside the dialog (thin scrollbar, contained overscroll) rather than overflowing off screen \u2014 the page behind is scroll-locked, so anything past the edge would be unreachable.",
228
+ "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.",
229
+ "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.",
213
230
  'Esc and click-outside dismiss by default. AlertDialog keeps Esc but ignores click-outside, and its popup is role="alertdialog".',
214
231
  "Animate via data-[starting-style]/data-[ending-style] attributes.",
215
232
  '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 \u2014 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).'
@@ -231,6 +248,10 @@ var dialogMeta = {
231
248
  label: "Settings form",
232
249
  code: '<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>'
233
250
  },
251
+ {
252
+ label: "Opened from your own button, keeping a draft",
253
+ code: 'const [open, setOpen] = useState(false)\n\n<Button variant="secondary" onClick={() => setOpen(true)}>New message</Button>\n<Dialog open={open} onOpenChange={setOpen}>\n <DialogContent keepMounted showCloseButton>\n <DialogHeader>\n <DialogTitle>New message</DialogTitle>\n <DialogDescription>Close it any time. Your draft is here when you come back.</DialogDescription>\n </DialogHeader>\n <Field label="Message"><Textarea /></Field>\n <DialogFooter>\n <DialogClose render={<Button variant="secondary">Close</Button>} />\n <Button onClick={() => setOpen(false)}>Send</Button>\n </DialogFooter>\n </DialogContent>\n</Dialog>'
254
+ },
234
255
  {
235
256
  label: "Destructive confirmation",
236
257
  code: '<AlertDialog><AlertDialogTrigger render={<Button variant="destructive">Delete project</Button>} /><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Delete project?</AlertDialogTitle><AlertDialogDescription>This removes the project and cannot be undone.</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogClose render={<Button variant="secondary">Cancel</Button>} /><Button variant="destructive" onClick={deleteProject}>Delete project</Button></AlertDialogFooter></AlertDialogContent></AlertDialog>'
@@ -12,6 +12,7 @@ function MediaImage({
12
12
  seed,
13
13
  fallback,
14
14
  fit = "cover",
15
+ position,
15
16
  srcSet,
16
17
  sizes,
17
18
  loading = "lazy",
@@ -42,7 +43,8 @@ function MediaImage({
42
43
  loading,
43
44
  decoding: "async",
44
45
  onLoadingStatusChange: (next) => setTracked({ src, status: next }),
45
- className: cn("size-full motion-fade", fit === "contain" ? "object-contain" : "object-cover")
46
+ className: cn("size-full motion-fade", fit === "contain" ? "object-contain" : "object-cover"),
47
+ style: position != null ? { objectPosition: position } : void 0
46
48
  }
47
49
  ) : null,
48
50
  showFallback ? /* @__PURE__ */ jsx("div", { "data-slot": "media-image-fallback", "aria-hidden": true, className: "absolute inset-0", children: fallback ?? /* @__PURE__ */ jsx(GradientArt, { seed: seed ?? (alt || void 0), className: "size-full" }) }) : null
@@ -30,6 +30,10 @@ var mediaImageMeta = {
30
30
  guidance: "do",
31
31
  description: "Leave `alt` empty when a visible title sits next to the image \u2014 a tile, a row \u2014 and describe it when the image stands alone. A set `alt` also names the fallback, so the description survives a broken URL."
32
32
  },
33
+ {
34
+ guidance: "do",
35
+ description: 'Set `position` when a cover crop would cut off the subject \u2014 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.'
36
+ },
33
37
  {
34
38
  guidance: "dont",
35
39
  description: "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."
@@ -57,6 +61,12 @@ var mediaImageMeta = {
57
61
  default: '"cover"',
58
62
  description: "`cover` crops to fill (artwork, photos); `contain` letterboxes on the muted surface (a logo, a product that must not be cropped)."
59
63
  },
64
+ {
65
+ name: "position",
66
+ type: 'CSSProperties["objectPosition"]',
67
+ default: '"center"',
68
+ description: 'Where the image sits in its frame \u2014 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.'
69
+ },
60
70
  { name: "srcSet", type: "string", description: "Responsive sources, passed to the `<img>`." },
61
71
  { name: "sizes", type: "string", description: "The `sizes` attribute for `srcSet`." },
62
72
  { name: "loading", type: '"lazy" | "eager"', default: '"lazy"', description: "Native lazy loading. Use `eager` for the first row of a grid above the fold." },
@@ -84,7 +94,11 @@ var mediaImageMeta = {
84
94
  { label: "Cover with fallback", code: '<AspectBox ratio={1} className="rounded-md">\n <MediaImage src={album.cover} seed={album.title} />\n</AspectBox>' },
85
95
  { label: "No image yet", code: '<AspectBox ratio={1} className="rounded-md">\n <MediaImage seed="Low tide, high noon" />\n</AspectBox>' },
86
96
  { label: "Row thumbnail", code: '<MediaImage src={track.art} seed={track.title} className="size-12 shrink-0 rounded-sm" />' },
87
- { label: "Logo, never cropped", code: '<AspectBox ratio={16 / 9} className="rounded-lg">\n <MediaImage src={brand.logo} alt={brand.name} fit="contain" />\n</AspectBox>' }
97
+ { label: "Logo, never cropped", code: '<AspectBox ratio={16 / 9} className="rounded-lg">\n <MediaImage src={brand.logo} alt={brand.name} fit="contain" />\n</AspectBox>' },
98
+ {
99
+ label: "Crop focus per image",
100
+ code: '<AspectBox ratio={1} className="rounded-md">\n <MediaImage src={project.thumbnail} seed={project.title} position={project.focus ?? "center"} />\n</AspectBox>'
101
+ }
88
102
  ]
89
103
  };
90
104
  export {
@@ -34,6 +34,7 @@ function MediaTile({
34
34
  sizes,
35
35
  alt,
36
36
  seed,
37
+ position,
37
38
  aspect = "square",
38
39
  size = "md",
39
40
  href,
@@ -107,7 +108,18 @@ function MediaTile({
107
108
  "data-slot": "media-tile-cover",
108
109
  className: "order-first rounded-(--media-tile-radius)",
109
110
  children: [
110
- /* @__PURE__ */ jsx(MediaImage, { src, srcSet, sizes, alt, seed: seed ?? titleText, loading }),
111
+ /* @__PURE__ */ jsx(
112
+ MediaImage,
113
+ {
114
+ src,
115
+ srcSet,
116
+ sizes,
117
+ alt,
118
+ seed: seed ?? titleText,
119
+ position,
120
+ loading
121
+ }
122
+ ),
111
123
  linked || action != null ? (
112
124
  // The tile answers the pointer and the keyboard alike: a light scrim
113
125
  // deepens the cover so the action reads over any artwork. Hover only
@@ -70,6 +70,12 @@ var mediaTileMeta = {
70
70
  { name: "sizes", type: "string", description: 'The cover\'s layout width for `srcSet`, e.g. `"(min-width: 64rem) 25vw, 50vw"`.' },
71
71
  { name: "alt", type: "string", default: '""', description: "Cover alt text. Empty by default: the title below already names the item." },
72
72
  { name: "seed", type: "string | number", description: "Seeds the fallback art. Defaults to `title` when it is a string." },
73
+ {
74
+ name: "position",
75
+ type: 'CSSProperties["objectPosition"]',
76
+ default: '"center"',
77
+ description: 'Which part of the cover the crop keeps \u2014 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.'
78
+ },
73
79
  {
74
80
  name: "aspect",
75
81
  type: '"square" | "video" | "poster" | number',
@@ -130,6 +136,10 @@ var mediaTileMeta = {
130
136
  label: "Gallery, no links",
131
137
  code: '<Grid minItemWidth="8rem" gap={2}>\n {photos.map((photo) => (\n <MediaTile key={photo.id} size="sm" title={photo.caption} src={photo.url} alt={photo.description} />\n ))}\n</Grid>'
132
138
  },
139
+ {
140
+ label: "Portfolio grid with crop focus",
141
+ code: '<Grid minItemWidth="16rem" gap={4}>\n {projects.map((project) => (\n <MediaTile\n key={project.slug}\n aspect="video"\n title={project.title}\n subtitle={project.client}\n src={project.thumbnail}\n position={project.focus}\n href={`/work/${project.slug}`}\n />\n ))}\n</Grid>'
142
+ },
133
143
  {
134
144
  label: "Poster shelf",
135
145
  code: '<MediaTile aspect="poster" size="lg" titleAs="h3" title="The Long Dark" subtitle="2026 \xB7 Drama" href="/films/the-long-dark" />'
@@ -131,6 +131,7 @@ function MenuSubmenuTrigger({
131
131
  className,
132
132
  children,
133
133
  value,
134
+ icon,
134
135
  ...props
135
136
  }) {
136
137
  const tier = useTypeTier();
@@ -143,6 +144,7 @@ function MenuSubmenuTrigger({
143
144
  ),
144
145
  ...props,
145
146
  children: [
147
+ icon,
146
148
  /* @__PURE__ */ jsx("span", { className: "flex-1 truncate text-start", children }),
147
149
  value !== void 0 ? /* @__PURE__ */ jsx("span", { className: "truncate text-muted-foreground", children: value }) : null,
148
150
  /* @__PURE__ */ jsx(Icon, { name: "chevron-right", className: "text-xs opacity-60" })
@@ -114,8 +114,8 @@ var menuMeta = {
114
114
  },
115
115
  {
116
116
  name: "MenuSubmenuTrigger",
117
- type: "Base UI Menu.SubmenuTrigger props + value: ReactNode + className",
118
- description: '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 \u203A" rather than making the reader open it to find out.'
117
+ type: "Base UI Menu.SubmenuTrigger props + value: ReactNode + icon: ReactNode + className",
118
+ description: '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 \u203A" 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.'
119
119
  },
120
120
  {
121
121
  name: "MenuRadioGroup",
@@ -2,30 +2,35 @@
2
2
  import { cn } from "../../lib/cn.js";
3
3
  import { HeadingRow } from "../../lib/heading-row.js";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
+ var SIZE = {
6
+ md: { root: "gap-2 pb-4", title: "text-heading-2", description: "text-body-sm" },
7
+ lg: { root: "gap-3 pb-6", title: "text-display-lg", description: "max-w-(--measure-prose) text-body-lg" }
8
+ };
5
9
  function PageHeader({
6
10
  title,
7
11
  description,
8
12
  back,
9
13
  actions,
10
14
  titleAs: Title = "h1",
15
+ size = "md",
11
16
  className,
12
17
  children,
13
18
  ...props
14
19
  }) {
15
- return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2 pb-4", className), ...props, children: [
20
+ return /* @__PURE__ */ jsxs("div", { "data-size": size, className: cn("flex flex-col", SIZE[size].root, className), ...props, children: [
16
21
  /* @__PURE__ */ jsx(
17
22
  HeadingRow,
18
23
  {
19
24
  as: Title,
20
25
  title,
21
- titleClassName: "text-heading-2",
22
- band: "flex min-h-(--control-md) items-center",
26
+ titleClassName: SIZE[size].title,
27
+ band: "flex min-h-(--control-md) items-center mt-[min(0px,calc((var(--control-md)_-_1lh)/2))]",
23
28
  titleBasis: "basis-[min(100%,20rem)]",
24
29
  leading: back,
25
30
  actions
26
31
  }
27
32
  ),
28
- description ? /* @__PURE__ */ jsx("p", { className: "text-body-sm text-muted-foreground", children: description }) : null,
33
+ description ? /* @__PURE__ */ jsx("p", { className: cn(SIZE[size].description, "text-muted-foreground"), children: description }) : null,
29
34
  children
30
35
  ] });
31
36
  }
@@ -7,7 +7,11 @@ var pageHeaderMeta = {
7
7
  "page header",
8
8
  "title",
9
9
  "lede",
10
- "page actions"
10
+ "page actions",
11
+ "page title size",
12
+ "display title",
13
+ "editorial header",
14
+ "hero title"
11
15
  ],
12
16
  usage: {
13
17
  description: "Use PageHeader to establish a page's title, supporting description, and primary page actions.",
@@ -20,6 +24,14 @@ var pageHeaderMeta = {
20
24
  guidance: "do",
21
25
  description: "Keep the title literal and put explanation in the lede or nearby content."
22
26
  },
27
+ {
28
+ guidance: "do",
29
+ description: '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 \u2192 heading-2 \u2192 body.'
30
+ },
31
+ {
32
+ guidance: "dont",
33
+ description: '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.'
34
+ },
23
35
  {
24
36
  guidance: "dont",
25
37
  description: "Do not use PageHeader inside repeated cards, dialogs, or compact panels."
@@ -64,8 +76,17 @@ var pageHeaderMeta = {
64
76
  type: '"h1" | "h2"',
65
77
  default: '"h1"',
66
78
  description: "The title's heading element. Keep h1 \u2014 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."
79
+ },
80
+ {
81
+ name: "size",
82
+ type: '"md" | "lg"',
83
+ default: '"md"',
84
+ description: "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."
67
85
  }
68
86
  ],
87
+ variants: {
88
+ size: ["md", "lg"]
89
+ },
69
90
  a11y: [
70
91
  "Renders the title as an <h1>; use one PageHeader per page.",
71
92
  "Its title renders the route's `h1`. Do not render another `h1` elsewhere on the page: heading navigation would report two documents, and the outline a screen-reader user builds from it stops matching the page."
@@ -76,6 +97,10 @@ var pageHeaderMeta = {
76
97
  label: "With actions",
77
98
  code: '<PageHeader title="Storage credits" description="Fund permanent storage." actions={<Button>Top up</Button>} />'
78
99
  },
100
+ {
101
+ label: "Editorial page title (size lg)",
102
+ code: '<PageHeader size="lg" title="Field notes" description="Essays on making things slowly, from a studio by the sea." actions={<Button variant="outline">Subscribe</Button>} />'
103
+ },
79
104
  {
80
105
  label: "One per page, inside the region",
81
106
  code: `// PageHeader introduces the current route: title, optional description, and the