@sovereignfs/ui 0.54.0 → 0.83.1

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 (277) hide show
  1. package/dist/Accordion.module.css +1 -1
  2. package/dist/Alert.module.css +1 -1
  3. package/dist/AppsLauncher.module.css +96 -0
  4. package/dist/Avatar.module.css +1 -1
  5. package/dist/Badge.module.css +30 -7
  6. package/dist/BalanceChip.module.css +1 -1
  7. package/dist/Button.module.css +18 -2
  8. package/dist/Card.module.css +1 -1
  9. package/dist/CardTile.module.css +181 -0
  10. package/dist/CodeTextarea.module.css +1 -1
  11. package/dist/Collapsible.module.css +1 -1
  12. package/dist/ColorPicker.module.css +132 -0
  13. package/dist/Combobox.module.css +2 -2
  14. package/dist/Command.module.css +1 -1
  15. package/dist/ConfirmDialog.module.css +2 -2
  16. package/dist/ConsentPrompt.module.css +21 -0
  17. package/dist/DatePicker.module.css +1 -1
  18. package/dist/Dialog.module.css +130 -21
  19. package/dist/Drawer.module.css +36 -7
  20. package/dist/Header.module.css +62 -0
  21. package/dist/HeaderFooterLayout.module.css +21 -0
  22. package/dist/IconPicker.module.css +1 -1
  23. package/dist/Input.module.css +1 -1
  24. package/dist/Kbd.module.css +1 -1
  25. package/dist/Markdown.module.css +24 -0
  26. package/dist/Menu.module.css +1 -0
  27. package/dist/Message.module.css +1 -1
  28. package/dist/MessageScroller.module.css +12 -1
  29. package/dist/MobileAppsDrawer.module.css +10 -9
  30. package/dist/MobileFooter.module.css +1 -1
  31. package/dist/MobileHeader.module.css +14 -1
  32. package/dist/NavList.module.css +130 -0
  33. package/dist/NavTabs.module.css +1 -1
  34. package/dist/NotificationsPanel.module.css +258 -0
  35. package/dist/OverlayHeader.module.css +1 -1
  36. package/dist/PageContainer.module.css +62 -0
  37. package/dist/PageHeader.module.css +54 -0
  38. package/dist/PageLayout.module.css +51 -0
  39. package/dist/Popover.module.css +1 -1
  40. package/dist/QuantityStepper.module.css +1 -1
  41. package/dist/Resizable.module.css +3 -3
  42. package/dist/RootLayout.module.css +12 -0
  43. package/dist/SegmentedControl.module.css +1 -1
  44. package/dist/Select.module.css +1 -1
  45. package/dist/Sheet.module.css +4 -1
  46. package/dist/SplitPane.module.css +4 -4
  47. package/dist/StatusBadge.module.css +1 -1
  48. package/dist/StepStrip.module.css +53 -0
  49. package/dist/SuggestionInput.module.css +2 -2
  50. package/dist/SwipableMobileCarousel.module.css +12 -3
  51. package/dist/SwipableMobileCarouselDots.module.css +15 -0
  52. package/dist/SwipeStack.module.css +101 -0
  53. package/dist/SystemBanner.module.css +10 -0
  54. package/dist/Table.module.css +2 -2
  55. package/dist/Tabs.module.css +1 -1
  56. package/dist/TagInput.module.css +2 -2
  57. package/dist/Textarea.module.css +9 -1
  58. package/dist/ThreeColumnLayout.module.css +35 -0
  59. package/dist/Toast.module.css +62 -3
  60. package/dist/Tooltip.module.css +1 -1
  61. package/dist/UserMenu.module.css +90 -0
  62. package/dist/index.d.ts +1047 -45
  63. package/dist/index.js +3908 -1456
  64. package/dist/tokens/primitives.css +11 -0
  65. package/dist/tokens/semantic.css +32 -0
  66. package/dist/tokens/theme-presets.css +33 -0
  67. package/package.json +25 -9
  68. package/src/__tests__/package-json.test.ts +8 -0
  69. package/src/components/Accordion/Accordion.module.css +1 -1
  70. package/src/components/Alert/Alert.module.css +1 -1
  71. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  72. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  73. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  74. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  75. package/src/components/Avatar/Avatar.module.css +1 -1
  76. package/src/components/Badge/Badge.module.css +30 -7
  77. package/src/components/Badge/Badge.stories.tsx +16 -4
  78. package/src/components/Badge/Badge.tsx +7 -4
  79. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  80. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  81. package/src/components/Button/Button.module.css +18 -2
  82. package/src/components/Card/Card.module.css +1 -1
  83. package/src/components/CardTile/CardTile.module.css +181 -0
  84. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  85. package/src/components/CardTile/CardTile.tsx +128 -0
  86. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  87. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  88. package/src/components/Collapsible/Collapsible.module.css +1 -1
  89. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  90. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  91. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  92. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  93. package/src/components/Combobox/Combobox.module.css +2 -2
  94. package/src/components/Command/Command.module.css +1 -1
  95. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  96. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  97. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  98. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  99. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  100. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  101. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  102. package/src/components/DatePicker/DatePicker.module.css +1 -1
  103. package/src/components/Dialog/Dialog.module.css +130 -21
  104. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  105. package/src/components/Dialog/Dialog.tsx +129 -35
  106. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  107. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  108. package/src/components/Drawer/Drawer.module.css +36 -7
  109. package/src/components/Drawer/Drawer.tsx +9 -10
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +33 -6
  111. package/src/components/GripIcon/GripIcon.tsx +27 -0
  112. package/src/components/Header/Header.module.css +62 -0
  113. package/src/components/Header/Header.stories.tsx +452 -0
  114. package/src/components/Header/Header.tsx +102 -0
  115. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  116. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  117. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  118. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  119. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  120. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  121. package/src/components/Icon/icons/bold.tsx +20 -0
  122. package/src/components/Icon/icons/calendar.tsx +4 -4
  123. package/src/components/Icon/icons/carrot.tsx +3 -3
  124. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  125. package/src/components/Icon/icons/code.tsx +21 -0
  126. package/src/components/Icon/icons/copy.tsx +21 -0
  127. package/src/components/Icon/icons/credit-card.tsx +21 -0
  128. package/src/components/Icon/icons/database.tsx +22 -0
  129. package/src/components/Icon/icons/download.tsx +22 -0
  130. package/src/components/Icon/icons/file-text.tsx +24 -0
  131. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  132. package/src/components/Icon/icons/folder-open.tsx +20 -0
  133. package/src/components/Icon/icons/folder.tsx +20 -0
  134. package/src/components/Icon/icons/folders.tsx +21 -0
  135. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  136. package/src/components/Icon/icons/history.tsx +22 -0
  137. package/src/components/Icon/icons/inbox.tsx +21 -0
  138. package/src/components/Icon/icons/index.ts +161 -1
  139. package/src/components/Icon/icons/italic.tsx +22 -0
  140. package/src/components/Icon/icons/layers.tsx +22 -0
  141. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  142. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  143. package/src/components/Icon/icons/link.tsx +21 -0
  144. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  145. package/src/components/Icon/icons/list.tsx +25 -0
  146. package/src/components/Icon/icons/luggage.tsx +24 -0
  147. package/src/components/Icon/icons/map-pin.tsx +21 -0
  148. package/src/components/Icon/icons/minus.tsx +20 -0
  149. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  150. package/src/components/Icon/icons/panel-left.tsx +21 -0
  151. package/src/components/Icon/icons/pin.tsx +21 -0
  152. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  153. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  154. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  155. package/src/components/Icon/icons/route.tsx +22 -0
  156. package/src/components/Icon/icons/send.tsx +21 -0
  157. package/src/components/Icon/icons/share-2.tsx +24 -0
  158. package/src/components/Icon/icons/sheet.tsx +24 -0
  159. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  160. package/src/components/Icon/icons/table.tsx +23 -0
  161. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  162. package/src/components/Icon/icons/users.tsx +23 -0
  163. package/src/components/IconPicker/IconPicker.module.css +1 -1
  164. package/src/components/Input/Input.module.css +1 -1
  165. package/src/components/Input/Input.tsx +8 -2
  166. package/src/components/Kbd/Kbd.module.css +1 -1
  167. package/src/components/Markdown/Markdown.module.css +24 -0
  168. package/src/components/Markdown/Markdown.stories.tsx +10 -0
  169. package/src/components/Markdown/Markdown.tsx +166 -29
  170. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  171. package/src/components/Menu/Menu.module.css +1 -0
  172. package/src/components/Menu/Menu.tsx +72 -22
  173. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  174. package/src/components/Menubar/Menubar.tsx +1 -1
  175. package/src/components/Message/Message.module.css +1 -1
  176. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  177. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  178. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +10 -9
  179. package/src/components/MobileFooter/MobileFooter.module.css +1 -1
  180. package/src/components/MobileFooter/MobileFooter.tsx +15 -2
  181. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +58 -0
  182. package/src/components/MobileHeader/MobileHeader.module.css +14 -1
  183. package/src/components/MobileHeader/MobileHeader.tsx +15 -2
  184. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +56 -1
  185. package/src/components/NavList/NavList.module.css +130 -0
  186. package/src/components/NavList/NavList.tsx +136 -0
  187. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  188. package/src/components/NavTabs/NavTabs.module.css +1 -1
  189. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  190. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  191. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  192. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  193. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  194. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  195. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  196. package/src/components/PageContainer/PageContainer.module.css +62 -0
  197. package/src/components/PageContainer/PageContainer.tsx +37 -4
  198. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +41 -8
  199. package/src/components/PageHeader/PageHeader.module.css +54 -0
  200. package/src/components/PageHeader/PageHeader.tsx +146 -5
  201. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  202. package/src/components/PageLayout/PageLayout.module.css +51 -0
  203. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  204. package/src/components/PageLayout/PageLayout.tsx +51 -0
  205. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  206. package/src/components/Popover/Popover.module.css +1 -1
  207. package/src/components/Popover/Popover.tsx +86 -4
  208. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  209. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  210. package/src/components/Resizable/Resizable.module.css +3 -3
  211. package/src/components/RootLayout/RootLayout.module.css +12 -0
  212. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  213. package/src/components/RootLayout/RootLayout.tsx +185 -0
  214. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  215. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  216. package/src/components/Select/Select.module.css +1 -1
  217. package/src/components/Sheet/Sheet.module.css +4 -1
  218. package/src/components/Sheet/Sheet.tsx +5 -5
  219. package/src/components/SplitPane/SplitPane.module.css +4 -4
  220. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  221. package/src/components/StepStrip/StepStrip.module.css +53 -0
  222. package/src/components/StepStrip/StepStrip.tsx +74 -0
  223. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  224. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +12 -3
  225. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +25 -9
  226. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +81 -0
  227. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +15 -0
  228. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +12 -1
  229. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  230. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  231. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  232. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  233. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  234. package/src/components/Table/Table.module.css +2 -2
  235. package/src/components/Tabs/Tabs.module.css +1 -1
  236. package/src/components/TagInput/TagInput.module.css +2 -2
  237. package/src/components/Textarea/Textarea.module.css +9 -1
  238. package/src/components/Textarea/Textarea.tsx +28 -5
  239. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  240. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  241. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  242. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  243. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  244. package/src/components/Toast/Toast.module.css +62 -3
  245. package/src/components/Toast/Toast.stories.tsx +1 -1
  246. package/src/components/Tooltip/Tooltip.module.css +1 -1
  247. package/src/components/UserMenu/UserMenu.module.css +90 -0
  248. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  249. package/src/components/UserMenu/UserMenu.tsx +132 -0
  250. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  251. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  252. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  253. package/src/hooks/__tests__/useSnapCarousel.test.tsx +82 -0
  254. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  255. package/src/hooks/index.ts +9 -0
  256. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  257. package/src/hooks/useReorderSensors.ts +88 -0
  258. package/src/hooks/useSnapCarousel.ts +211 -6
  259. package/src/hooks/useSwipeStack.ts +235 -0
  260. package/src/index.ts +61 -1
  261. package/src/overlay-shell.ts +65 -0
  262. package/src/stories/DesignSystemOverview.stories.tsx +617 -11
  263. package/src/stories/GripIcon.stories.tsx +29 -0
  264. package/src/stories/LayoutSystem.stories.tsx +639 -0
  265. package/src/stories/MenuEntries.stories.tsx +86 -0
  266. package/src/stories/NavList.stories.tsx +147 -0
  267. package/src/stories/PageContainer.stories.tsx +52 -7
  268. package/src/stories/PageHeader.stories.tsx +54 -1
  269. package/src/stories/StepStrip.stories.tsx +76 -0
  270. package/src/stories/SwipableMobileCarousel.stories.tsx +58 -0
  271. package/src/stories/SwipeStack.stories.tsx +200 -0
  272. package/src/stories/Textarea.stories.tsx +12 -0
  273. package/src/stories/TokenGallery.stories.tsx +50 -0
  274. package/src/tokens/primitives.css +11 -0
  275. package/src/tokens/semantic.css +32 -0
  276. package/src/tokens/theme-presets.css +33 -0
  277. package/src/tokens/theme-presets.ts +72 -0
@@ -1,22 +1,47 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
- import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { useState } from 'react';
4
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
4
5
  import { ConfirmDialog } from '../ConfirmDialog';
5
6
 
6
7
  // jsdom does not implement HTMLDialogElement.showModal()/close() (a
7
8
  // long-standing, documented gap) — polyfill the minimum behavior the
8
9
  // component depends on: the `open` attribute reflecting visibility, and
9
10
  // close() dispatching the native 'close' event ConfirmDialog listens for.
11
+ //
12
+ // Two details matter here beyond the minimum, both load-bearing for the
13
+ // double-onClose regression tests below: (1) a no-op when already closed,
14
+ // matching the HTML spec's own early return, and (2) the 'close' event is
15
+ // queued rather than dispatched synchronously ("queue an element task ... to
16
+ // fire an event named close" per spec) — deferring it means a listener
17
+ // (re-)attached during the SAME synchronous React commit that triggered this
18
+ // close() call is still reachable once the event actually fires, exactly as
19
+ // in a real browser. Dispatching synchronously instead (the simpler version
20
+ // this replaced) fires the event in the middle of React's effect
21
+ // cleanup/setup phase, when no listener happens to be attached yet — which
22
+ // made the double-call bug this file guards against silently unreproducible
23
+ // in jsdom regardless of whether the underlying component bug was present.
10
24
  beforeEach(() => {
11
25
  HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) {
12
26
  this.setAttribute('open', '');
13
27
  };
14
28
  HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) {
29
+ if (!this.hasAttribute('open')) return;
15
30
  this.removeAttribute('open');
16
- this.dispatchEvent(new Event('close'));
31
+ queueMicrotask(() => this.dispatchEvent(new Event('close')));
17
32
  };
18
33
  });
19
34
 
35
+ // Flushes any chain of queueMicrotask-deferred dispatches and the React
36
+ // state updates/effects they trigger. A single real macrotask tick is
37
+ // enough: the microtask queue always fully drains — including microtasks
38
+ // newly queued from within an already-running one — before a macrotask runs.
39
+ async function flushDeferredDialogEvents() {
40
+ await act(async () => {
41
+ await new Promise((resolve) => setTimeout(resolve, 0));
42
+ });
43
+ }
44
+
20
45
  describe('ConfirmDialog', () => {
21
46
  afterEach(cleanup);
22
47
 
@@ -51,15 +76,58 @@ describe('ConfirmDialog', () => {
51
76
  expect(screen.getByText('Example').tagName).toBe('STRONG');
52
77
  });
53
78
 
54
- it('calls onClose on Cancel', () => {
79
+ it('calls onClose on Cancel', async () => {
55
80
  const onClose = vi.fn();
56
81
  render(
57
82
  <ConfirmDialog open onClose={onClose} onConfirm={() => {}} title="Remove" message="Sure?" />,
58
83
  );
84
+ // Cancel now calls dialogRef.current.close() rather than onClose
85
+ // directly (see ConfirmDialog.tsx) — onClose only fires once the
86
+ // resulting (deferred, see the polyfill above) native 'close' event
87
+ // reaches ConfirmDialog's own listener.
59
88
  fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
89
+ await flushDeferredDialogEvents();
60
90
  expect(onClose).toHaveBeenCalledOnce();
61
91
  });
62
92
 
93
+ // Statefully wired, unlike the test above: a real consumer's onClose flips
94
+ // its own `open` state to false, which re-runs ConfirmDialog's internal
95
+ // `if (open) showModal(); else el.close();` effect and calls el.close()
96
+ // itself. Pre-fix, Cancel/backdrop-click also called onClose directly,
97
+ // so this path fired onClose a second time; the test above's static
98
+ // `open` prop never changes, so it can't exercise that second re-render.
99
+ function StatefulConfirmDialog({ onCloseSpy }: { onCloseSpy: () => void }) {
100
+ const [open, setOpen] = useState(true);
101
+ return (
102
+ <ConfirmDialog
103
+ open={open}
104
+ onClose={() => {
105
+ onCloseSpy();
106
+ setOpen(false);
107
+ }}
108
+ onConfirm={() => {}}
109
+ title="Remove"
110
+ message="Sure?"
111
+ />
112
+ );
113
+ }
114
+
115
+ it('calls onClose exactly once when a stateful consumer dismisses via Cancel', async () => {
116
+ const onCloseSpy = vi.fn();
117
+ render(<StatefulConfirmDialog onCloseSpy={onCloseSpy} />);
118
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
119
+ await flushDeferredDialogEvents();
120
+ expect(onCloseSpy).toHaveBeenCalledOnce();
121
+ });
122
+
123
+ it('calls onClose exactly once when a stateful consumer dismisses via backdrop click', async () => {
124
+ const onCloseSpy = vi.fn();
125
+ render(<StatefulConfirmDialog onCloseSpy={onCloseSpy} />);
126
+ fireEvent.click(screen.getByRole('dialog'));
127
+ await flushDeferredDialogEvents();
128
+ expect(onCloseSpy).toHaveBeenCalledOnce();
129
+ });
130
+
63
131
  it('calls onConfirm on the confirm action, without itself closing', () => {
64
132
  const onConfirm = vi.fn();
65
133
  const onClose = vi.fn();
@@ -0,0 +1,21 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--sv-space-2);
5
+ }
6
+
7
+ .title {
8
+ font-size: var(--sv-font-size-sm);
9
+ color: var(--sv-color-text-primary);
10
+ }
11
+
12
+ .description {
13
+ font-size: var(--sv-font-size-sm);
14
+ color: var(--sv-color-text-muted);
15
+ }
16
+
17
+ .actions {
18
+ display: flex;
19
+ gap: var(--sv-space-2);
20
+ margin-top: var(--sv-space-1);
21
+ }
@@ -0,0 +1,31 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ConsentPrompt } from './ConsentPrompt';
3
+
4
+ const meta = {
5
+ title: 'Components/ConsentPrompt',
6
+ component: ConsentPrompt,
7
+ parameters: { layout: 'padded' },
8
+ args: {
9
+ consumerName: 'Ledger',
10
+ providerName: 'Finance Tracker',
11
+ contract: 'expenses',
12
+ description: 'Monthly expense totals by category, no transaction-level detail.',
13
+ onAllow: () => {},
14
+ onDeny: () => {},
15
+ },
16
+ } satisfies Meta<typeof ConsentPrompt>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export const Default: Story = {};
24
+
25
+ export const NoDescription: Story = {
26
+ args: { description: null },
27
+ };
28
+
29
+ export const Pending: Story = {
30
+ args: { pending: true },
31
+ };
@@ -0,0 +1,66 @@
1
+ import { Button } from '../Button/Button';
2
+ import { Card } from '../Card/Card';
3
+ import styles from './ConsentPrompt.module.css';
4
+
5
+ export interface ConsentPromptProps {
6
+ /** The plugin requesting access. */
7
+ consumerName: string;
8
+ /** The plugin whose data would be read. */
9
+ providerName: string;
10
+ /** The named contract being requested (e.g. `"expenses"`). */
11
+ contract: string;
12
+ /**
13
+ * The provider's own manifest-declared explanation of what this contract
14
+ * exposes (`data.provides[].description`) — never caller-supplied at
15
+ * request time, so a consuming plugin can't write its own persuasive copy
16
+ * for what it's asking to read.
17
+ */
18
+ description?: string | null;
19
+ onAllow: () => void;
20
+ onDeny?: () => void;
21
+ allowLabel?: string;
22
+ denyLabel?: string;
23
+ /** Disables both buttons and shows a busy state on Allow while a request is in flight. */
24
+ pending?: boolean;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * The consent screen shown before one plugin is allowed to read another
30
+ * plugin's data (RFC 0002 §4) — the step that was previously missing between
31
+ * a `ConsentRequiredError` and a grant actually being created. Presentational
32
+ * only: holds no state of its own, so it renders identically wherever it's
33
+ * used (currently Account → Data's pending-requests list).
34
+ */
35
+ export function ConsentPrompt({
36
+ consumerName,
37
+ providerName,
38
+ contract,
39
+ description,
40
+ onAllow,
41
+ onDeny,
42
+ allowLabel = 'Allow',
43
+ denyLabel = 'Not now',
44
+ pending = false,
45
+ className,
46
+ }: ConsentPromptProps) {
47
+ return (
48
+ <Card padding="sm" className={[styles.root, className].filter(Boolean).join(' ')}>
49
+ <p className={styles.title}>
50
+ <strong>{consumerName}</strong> wants to read <strong>{contract}</strong> from{' '}
51
+ <strong>{providerName}</strong>
52
+ </p>
53
+ {description ? <p className={styles.description}>{description}</p> : null}
54
+ <div className={styles.actions}>
55
+ <Button variant="primary" size="sm" onClick={onAllow} disabled={pending} loading={pending}>
56
+ {allowLabel}
57
+ </Button>
58
+ {onDeny ? (
59
+ <Button variant="ghost" size="sm" onClick={onDeny} disabled={pending}>
60
+ {denyLabel}
61
+ </Button>
62
+ ) : null}
63
+ </div>
64
+ </Card>
65
+ );
66
+ }
@@ -64,7 +64,7 @@ export function ContextMenu({ children, items, 'aria-label': ariaLabel }: Contex
64
64
  items={items}
65
65
  onSelect={(entry: MenuItem) => {
66
66
  setOpen(false);
67
- entry.onSelect();
67
+ entry.onSelect?.();
68
68
  }}
69
69
  />
70
70
  );
@@ -17,7 +17,7 @@
17
17
  line-height: 1.5;
18
18
  color: var(--sv-color-text-primary);
19
19
  background-color: var(--sv-color-surface);
20
- border: 1px solid var(--sv-color-border-strong);
20
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
21
21
  border-radius: var(--sv-radius-md);
22
22
  cursor: pointer;
23
23
  transition:
@@ -1,13 +1,31 @@
1
1
  /* Dialog — scrim + centered panel. Tokens only (z-index is structural, not a
2
2
  * theming value, so it has no token).
3
3
  *
4
- * Each size is a FIXED box (width AND height) — the panel never grows or
5
- * shrinks with its content, so switching views inside it (e.g. overlay-plugin
6
- * tabs) keeps the dialog the exact same size. Overflow scrolls inside `.content`
7
- * while the panel and its close button stay put. The scrim's padding is the
8
- * fixed margin `lg` leaves around the viewport; `sm`/`md` are centered and cap
9
- * to that available space on short screens. On mobile every size becomes a
10
- * full-screen sheet.
4
+ * `sm`/`md` are a fixed WIDTH but a content-driven height, capped at a
5
+ * per-size max-height: the panel grows to fit its content and shrinks when
6
+ * content is sparse, only hitting the cap (and scrolling internally, inside
7
+ * `.content`) once content actually exceeds it. Before this, height was
8
+ * fixed too, which left short-content consumers (e.g. a two-field form in
9
+ * console/plugins/PluginAccessDialog.tsx) looking like a near-empty
10
+ * skeleton — see docs/architecture-rules.md. `auto` goes further — content-
11
+ * driven on BOTH width and height, each independently capped rather than
12
+ * either being fixed — for content whose natural footprint varies enough
13
+ * (a card detail modal with an optional checklist/comments section) that a
14
+ * single fixed width doesn't fit it well either short or long. `lg` is a
15
+ * true fixed box (100%/100% of the scrim's available area) — no current
16
+ * overlay-shell plugin renders into it (Console did until workstream 0022
17
+ * moved it to `shell: "default"`; Account never resized off `overlaySize:
18
+ * "lg"`'s original default until epic task 14.5, see `fixed` below).
19
+ * `fixed` (named after its own behavior, not after this file's always-
20
+ * applied `.panel` wrapper class below) is also a true fixed box, width
21
+ * AND height both set — same "content never resizes it" guarantee as `lg`
22
+ * — but capped rather than filling the viewport, for a plugin whose
23
+ * settings-panel-shaped content doesn't warrant `lg`'s full-screen footprint
24
+ * (Account's own case, epic task 14.5 round 3). The close button stays
25
+ * pinned top-right regardless. The scrim's padding is the fixed margin
26
+ * `lg`/`fixed` leave around the viewport; `sm`/`md`/`auto` are centered and
27
+ * cap to that available space on short screens too. On mobile every size
28
+ * becomes a full-screen sheet.
11
29
  *
12
30
  * `--sv-dialog-inset-left` lets a host inset the scrim past a fixed left nav
13
31
  * rail (e.g. the platform sidebar) so the overlay's left margin is measured
@@ -50,7 +68,7 @@
50
68
  overflow: hidden;
51
69
  background: var(--sv-color-surface-raised);
52
70
  color: var(--sv-color-text-primary);
53
- border: 1px solid var(--sv-color-border);
71
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border);
54
72
  border-radius: var(--sv-radius-lg);
55
73
  box-shadow: var(--sv-shadow-overlay);
56
74
  /* Desktop entrance: fade + gentle scale-up. Same two-phase-mount pattern as
@@ -86,7 +104,21 @@
86
104
  flex: 1 1 auto;
87
105
  overflow-y: auto;
88
106
  overflow-x: hidden;
107
+ /* Same slim, token-colored scrollbar as `ScrollArea`/`MessageScroller` —
108
+ without this, a `sm`/`md` dialog whose content exceeds its max-height
109
+ cap (e.g. on a short viewport where the cap itself shrinks to fit) gets
110
+ the browser's default OS scrollbar, which on non-overlay-scrollbar
111
+ platforms is wide enough to visually cut into `.panel`'s rounded
112
+ corner right where `.content` starts, reading as a layout bug rather
113
+ than an intentional scroll affordance. */
114
+ scrollbar-width: thin;
115
+ scrollbar-color: var(--sv-color-border-strong) transparent;
89
116
  padding: var(--sv-space-6);
117
+ /* This region already supplies the page gutter, so a PageContainer rendered
118
+ inside it stands its own down (see PageContainer.module.css). Without
119
+ this, an overlay plugin — one page tree rendered into both the Dialog and
120
+ the full-page shell fallback — would be padded twice here. */
121
+ --sv-page-gutter: 0px;
90
122
  /* Disable elastic overscroll within the dialog: `none` stops both scroll-
91
123
  chaining to the parent and the iOS rubber-band bounce inside the panel.
92
124
  `contain` only prevents chaining — the panel itself still bounces, which
@@ -96,33 +128,94 @@
96
128
  overscroll-behavior: none;
97
129
  }
98
130
 
99
- /* Fixed sizes. `max-*: 100%` caps to the scrim's content box (viewport minus
100
- * margin) so a size never overflows a small viewport. */
131
+ .content::-webkit-scrollbar {
132
+ width: 8px;
133
+ }
134
+
135
+ .content::-webkit-scrollbar-track {
136
+ background: transparent;
137
+ }
138
+
139
+ .content::-webkit-scrollbar-thumb {
140
+ background: var(--sv-color-border-strong);
141
+ border-radius: var(--sv-radius-full);
142
+ }
143
+
144
+ /* `sm`/`md`: fixed width, content-driven height up to the size's own cap
145
+ * (28rem/42rem — the box's old fixed heights, now a ceiling rather than a
146
+ * floor). `min(<cap>, 100%)` also still caps to the scrim's content box
147
+ * (viewport minus margin) so a size never overflows a short viewport. No
148
+ * explicit `height` — the panel's flex column sizes to `.content`'s natural
149
+ * height on its own; see the file-level comment above. */
101
150
  .sm {
102
151
  width: 24rem;
103
- height: 28rem;
104
152
  max-width: 100%;
105
- max-height: 100%;
153
+ max-height: min(28rem, 100%);
106
154
  }
107
155
 
108
156
  .md {
109
157
  width: 36rem;
110
- height: 42rem;
111
158
  max-width: 100%;
112
- max-height: 100%;
159
+ max-height: min(42rem, 100%);
113
160
  }
114
161
 
115
- .lg,
116
- .full {
162
+ /* Content-driven on both axes, unlike `sm`/`md` (width fixed, only height
163
+ * content-driven) — for a dialog whose natural footprint varies a lot (a
164
+ * card detail modal with labels/due-date/assignees/description/checklist/
165
+ * comments, the case that originally motivated the now-removed `xl` size
166
+ * this replaces). `width: fit-content` shrink-wraps to the panel's own
167
+ * content (bounded by min/max-width below) the same way a flex item
168
+ * centered in `.scrim` naturally would with no explicit `width` at all —
169
+ * `min-width` just keeps sparse content (e.g. a short message) from reading
170
+ * as an oddly narrow sliver. Caps chosen to span roughly what `sm`/`xl`
171
+ * used to cover between them. */
172
+ .auto {
173
+ width: fit-content;
174
+ min-width: min(24rem, 100%);
175
+ max-width: min(48rem, 100%);
176
+ max-height: min(48rem, 100%);
177
+ }
178
+
179
+ /* `lg`: a true fixed box, unaffected by the above — see the file-level
180
+ * comment. */
181
+ .lg {
117
182
  width: 100%;
118
183
  height: 100%;
119
184
  }
120
185
 
186
+ /* `fixed`: also a true fixed box (width AND height both set, like `lg`) but
187
+ * capped rather than filling the viewport — see the file-level comment.
188
+ * Named distinctly from the dialog's own always-applied `.panel` wrapper
189
+ * class above (every size gets `styles.panel` + `styles[size]` together —
190
+ * see Dialog.tsx) to avoid colliding with it. `max-width`/`max-height` are
191
+ * still needed despite `width`/`height` being fixed rem values, exactly
192
+ * like `sm`/`md`'s own `max-width: 100%` — a short or narrow viewport (a
193
+ * resized desktop window, not yet at the mobile breakpoint) must still be
194
+ * able to shrink this below its rem values instead of overflowing the
195
+ * scrim. */
196
+ .fixed {
197
+ width: 64rem;
198
+ max-width: 100%;
199
+ height: 44rem;
200
+ max-height: 100%;
201
+ }
202
+
121
203
  .close {
122
204
  position: absolute;
123
205
  top: var(--sv-space-3);
124
206
  right: var(--sv-space-3);
125
- z-index: 1;
207
+ /* 2, not 1: a plugin's own sticky in-body header (e.g. a card detail
208
+ modal's title row) needs to paint above ordinary scrolling content, so
209
+ it sits at z-index: 1 — if `.close` also sat at 1, tree order would
210
+ decide the tie, and `.close` (rendered earlier in this component's own
211
+ JSX, before `.content` and everything a plugin nests inside it) would
212
+ lose to the plugin's later-in-tree header, disappearing underneath it
213
+ once they overlap on scroll. 2 keeps `.close` unambiguously on top
214
+ without relying on a tie-break plugin authors have no visibility into.
215
+ Found live in `sovereign-plugin-kanban`'s card detail modal — see that
216
+ repo's `kanban.module.css` `.cardHeader` comment for the matching half
217
+ of this fix and the Popover-stacking bug that motivated it. */
218
+ z-index: 2;
126
219
  display: inline-flex;
127
220
  align-items: center;
128
221
  justify-content: center;
@@ -132,8 +225,6 @@
132
225
  border-radius: var(--sv-radius-md);
133
226
  background: transparent;
134
227
  color: var(--sv-color-text-muted);
135
- font-size: var(--sv-font-size-xl);
136
- line-height: 1;
137
228
  cursor: pointer;
138
229
  }
139
230
 
@@ -151,11 +242,27 @@
151
242
 
152
243
  /* Hidden on desktop; shown only on mobile where it replaces the close button.
153
244
  * OverlayHeader owns its own background/border/padding — this wrapper class
154
- * only toggles visibility per breakpoint. */
245
+ * only toggles visibility per breakpoint. Not used at all when the `header`
246
+ * prop is active — that OverlayHeader instance renders with no wrapper
247
+ * class, so it shows unconditionally on both breakpoints (see Dialog.tsx). */
155
248
  .mobileHeader {
156
249
  display: none;
157
250
  }
158
251
 
252
+ /* The optional `footer` prop's row — shown on both desktop and mobile (no
253
+ * breakpoint gating, unlike `.mobileHeader`), a flex sibling after `.content`
254
+ * so it never scrolls, mirroring OverlayHeader's own `.header`/`.row`
255
+ * treatment (border on the opposite edge, same padding scale) so header and
256
+ * footer read as a matched pair when both are present. Layout-agnostic
257
+ * beyond that — the footer's own content decides its internal arrangement
258
+ * (e.g. a flex row with `justify-content: flex-end` for action buttons). */
259
+ .footer {
260
+ flex-shrink: 0;
261
+ background: var(--sv-color-surface);
262
+ border-top: var(--sv-border-width-hairline) solid var(--sv-color-border);
263
+ padding: var(--sv-space-3) var(--sv-space-4);
264
+ }
265
+
159
266
  /* Mobile: full-screen sheet (RFC 0001 §3, breakpoint unified to 768px per
160
267
  * RFC 0013). Every size fills the scrim's available area (which is already
161
268
  * inset from the top by --sv-dialog-inset-top when a fixed header is shown).
@@ -177,9 +284,11 @@
177
284
  .sm,
178
285
  .md,
179
286
  .lg,
180
- .full {
287
+ .auto,
288
+ .fixed {
181
289
  width: 100%;
182
290
  height: 100%;
291
+ min-width: 0;
183
292
  max-width: none;
184
293
  max-height: none;
185
294
  border: none;
@@ -9,7 +9,7 @@ function DialogDemo({
9
9
  size = 'lg',
10
10
  label = 'Example dialog',
11
11
  }: {
12
- size?: 'sm' | 'md' | 'lg' | 'full';
12
+ size?: 'sm' | 'md' | 'lg' | 'auto' | 'fixed';
13
13
  label?: string;
14
14
  }) {
15
15
  const [open, setOpen] = useState(false);
@@ -48,7 +48,7 @@ const meta = {
48
48
  docs: {
49
49
  description: {
50
50
  component:
51
- 'Modal surface (scrim + panel). Router-agnostic — caller provides `onClose`. Supports Esc, scrim-click, focus trap and focus restoration. Sizes: `sm` / `md` / `lg` / `full`. Mobile always renders as a full-screen sheet.',
51
+ 'Modal surface (scrim + panel). Router-agnostic — caller provides `onClose`. Supports Esc, scrim-click, focus trap and focus restoration. Sizes: `sm` / `md` / `lg` / `auto` / `fixed`. `sm`/`md` are fixed-width, content-driven height; `auto` is content-driven on both width and height; `lg` is a true fixed 100%/100% box; `fixed` is also a true fixed box (width and height both set, content never resizes it) but capped at 64rem×44rem rather than filling the viewport. Mobile always renders as a full-screen sheet. Three composition shapes via the optional `header`/`footer` props: Body only (both omitted, the default), Header + Body, Header + Body + Footer — see the stories below the size examples.',
52
52
  },
53
53
  },
54
54
  },
@@ -74,6 +74,62 @@ export const Large: Story = {
74
74
  render: (_args) => <DialogDemo size="lg" label="Large dialog" />,
75
75
  };
76
76
 
77
+ /**
78
+ * `fixed` is a true fixed box like `lg` (width AND height both set — content
79
+ * never resizes it) but capped at 64rem×44rem rather than filling the
80
+ * viewport. Added for epic task 14.5 (Account's vertical nav): a plugin
81
+ * whose route tree includes a client-side redirect through a near-empty
82
+ * intermediate page needs a size that won't visibly shrink to fit that empty
83
+ * page and then grow once real content lands — every other size is
84
+ * content-driven on at least one axis and would do exactly that.
85
+ */
86
+ export const Fixed: Story = {
87
+ args: { open: false, onClose: () => {}, children: null },
88
+ render: (_args) => <DialogDemo size="fixed" label="Fixed dialog" />,
89
+ };
90
+
91
+ /**
92
+ * `auto` sizes to content on both width and height, unlike `sm`/`md` (fixed
93
+ * width, content-driven height only) — capped, not fixed, on either axis.
94
+ * This demo's own content is deliberately narrower than `md`'s 36rem, so the
95
+ * panel visibly shrinks below it — a fixed-width size can't do that.
96
+ */
97
+ export const AutoSize: Story = {
98
+ args: { open: false, onClose: () => {}, children: null },
99
+ render: (_args) => {
100
+ function Demo() {
101
+ const [open, setOpen] = useState(false);
102
+ return (
103
+ <>
104
+ <Button onClick={() => setOpen(true)}>Open auto-sized dialog</Button>
105
+ <Dialog open={open} onClose={() => setOpen(false)} size="auto" aria-label="Delete item?">
106
+ <div style={{ padding: 24, fontFamily: 'system-ui', maxWidth: '18rem' }}>
107
+ <h2
108
+ style={{
109
+ fontSize: 18,
110
+ fontWeight: 600,
111
+ color: 'var(--sv-color-text-primary)',
112
+ marginBottom: 12,
113
+ }}
114
+ >
115
+ Delete item?
116
+ </h2>
117
+ <p style={{ color: 'var(--sv-color-text-muted)', marginBottom: 24 }}>
118
+ This panel is narrower than `md`'s fixed 36rem — `auto` shrank to fit this short
119
+ message instead of padding out a fixed width.
120
+ </p>
121
+ <Button variant="secondary" onClick={() => setOpen(false)}>
122
+ Close
123
+ </Button>
124
+ </div>
125
+ </Dialog>
126
+ </>
127
+ );
128
+ }
129
+ return <Demo />;
130
+ },
131
+ };
132
+
77
133
  export const Closed: Story = {
78
134
  args: { open: false, onClose: () => {}, children: null },
79
135
  render: (_args) => (
@@ -145,3 +201,124 @@ export const WithOverlaySecondRow: Story = {
145
201
  return <Demo />;
146
202
  },
147
203
  };
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Composition shapes (header/footer props) — Body only is every story above
207
+ // this point (both props omitted); these three demonstrate the other two.
208
+
209
+ /**
210
+ * "Body only" — the default shape (both `header` and `footer` omitted).
211
+ * Included explicitly alongside the size stories above as the baseline the
212
+ * other two composition shapes are compared against: no visible header on
213
+ * desktop, a floating close button instead, and any actions live inside the
214
+ * scrollable body.
215
+ */
216
+ export const BodyOnly: Story = {
217
+ args: { open: false, onClose: () => {}, children: null },
218
+ render: (_args) => {
219
+ function Demo() {
220
+ const [open, setOpen] = useState(false);
221
+ return (
222
+ <>
223
+ <Button onClick={() => setOpen(true)}>Open body-only dialog</Button>
224
+ <Dialog open={open} onClose={() => setOpen(false)} size="sm" aria-label="Body only">
225
+ <div style={{ padding: 24, fontFamily: 'system-ui' }}>
226
+ <p style={{ color: 'var(--sv-color-text-muted)' }}>
227
+ No `header` or `footer` prop — today's default. Desktop shows only the floating
228
+ close button; actions (if any) live in this scrolling body, same as before this
229
+ task.
230
+ </p>
231
+ </div>
232
+ </Dialog>
233
+ </>
234
+ );
235
+ }
236
+ return <Demo />;
237
+ },
238
+ };
239
+
240
+ /**
241
+ * "Header + Body" — the `header` prop renders a persistent title/close row
242
+ * on both desktop and mobile (unlike the plain `title` prop, which is
243
+ * mobile-only). Resize the viewport to confirm the header stays visible and
244
+ * identically styled at both widths.
245
+ */
246
+ export const HeaderAndBody: Story = {
247
+ args: { open: false, onClose: () => {}, children: null },
248
+ render: (_args) => {
249
+ function Demo() {
250
+ const [open, setOpen] = useState(false);
251
+ return (
252
+ <>
253
+ <Button onClick={() => setOpen(true)}>Open header + body dialog</Button>
254
+ <Dialog
255
+ open={open}
256
+ onClose={() => setOpen(false)}
257
+ size="sm"
258
+ header="Card detail"
259
+ aria-label="Card detail"
260
+ >
261
+ <div style={{ padding: 24, fontFamily: 'system-ui' }}>
262
+ <p style={{ color: 'var(--sv-color-text-muted)' }}>
263
+ The "Card detail" title above is the `header` prop, rendered via the same shared
264
+ header row on both desktop and mobile — resize the viewport to confirm it doesn't
265
+ disappear on desktop the way a plain `title` would.
266
+ </p>
267
+ </div>
268
+ </Dialog>
269
+ </>
270
+ );
271
+ }
272
+ return <Demo />;
273
+ },
274
+ };
275
+
276
+ /**
277
+ * "Header + Body + Footer" — both `header` and `footer` are pinned flex
278
+ * siblings around `.content`; only the body between them scrolls. The body
279
+ * here is long enough to actually scroll, so header/footer staying put is
280
+ * visible, not just theoretical — check this at both a desktop and a
281
+ * ≤768px viewport per this task's own review checklist.
282
+ */
283
+ export const HeaderBodyFooter: Story = {
284
+ args: { open: false, onClose: () => {}, children: null },
285
+ render: (_args) => {
286
+ function Demo() {
287
+ const [open, setOpen] = useState(false);
288
+ return (
289
+ <>
290
+ <Button onClick={() => setOpen(true)}>Open header + body + footer dialog</Button>
291
+ <Dialog
292
+ open={open}
293
+ onClose={() => setOpen(false)}
294
+ size="sm"
295
+ header="Edit card"
296
+ footer={
297
+ <div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
298
+ <Button variant="secondary" onClick={() => setOpen(false)}>
299
+ Cancel
300
+ </Button>
301
+ <Button onClick={() => setOpen(false)}>Save</Button>
302
+ </div>
303
+ }
304
+ aria-label="Edit card"
305
+ >
306
+ <div style={{ padding: 24, fontFamily: 'system-ui', display: 'grid', gap: 16 }}>
307
+ <p style={{ color: 'var(--sv-color-text-muted)' }}>
308
+ Scroll this body — the "Edit card" header above and the Cancel/Save footer below
309
+ both stay pinned; only this region between them moves.
310
+ </p>
311
+ {Array.from({ length: 12 }, (_, i) => (
312
+ <p key={i} style={{ color: 'var(--sv-color-text-muted)' }}>
313
+ Field {i + 1} of 12 — filler content long enough to force `.content` to actually
314
+ scroll, so the pinned header/footer behavior is visible rather than theoretical.
315
+ </p>
316
+ ))}
317
+ </div>
318
+ </Dialog>
319
+ </>
320
+ );
321
+ }
322
+ return <Demo />;
323
+ },
324
+ };