@sovereignfs/ui 0.45.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 (323) 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/LegalLinks.module.css +24 -0
  26. package/dist/Markdown.module.css +73 -0
  27. package/dist/Menu.module.css +1 -0
  28. package/dist/Message.module.css +1 -1
  29. package/dist/MessageScroller.module.css +12 -1
  30. package/dist/MobileAppsDrawer.module.css +82 -0
  31. package/dist/MobileFooter.module.css +73 -0
  32. package/dist/MobileHeader.module.css +76 -0
  33. package/dist/NavList.module.css +130 -0
  34. package/dist/NavTabs.module.css +1 -1
  35. package/dist/NotificationsPanel.module.css +258 -0
  36. package/dist/OverlayHeader.module.css +1 -1
  37. package/dist/PageContainer.module.css +82 -0
  38. package/dist/PageHeader.module.css +54 -0
  39. package/dist/PageLayout.module.css +51 -0
  40. package/dist/Popover.module.css +1 -1
  41. package/dist/QuantityStepper.module.css +1 -1
  42. package/dist/Resizable.module.css +3 -3
  43. package/dist/RootLayout.module.css +12 -0
  44. package/dist/SegmentedControl.module.css +1 -1
  45. package/dist/Select.module.css +1 -1
  46. package/dist/Sheet.module.css +4 -1
  47. package/dist/SplitPane.module.css +4 -4
  48. package/dist/StatusBadge.module.css +1 -1
  49. package/dist/StepStrip.module.css +53 -0
  50. package/dist/SuggestionInput.module.css +2 -2
  51. package/dist/SwipableMobileCarousel.module.css +64 -0
  52. package/dist/SwipableMobileCarouselDots.module.css +64 -0
  53. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  54. package/dist/SwipeStack.module.css +101 -0
  55. package/dist/SystemBanner.module.css +10 -0
  56. package/dist/Table.module.css +2 -2
  57. package/dist/Tabs.module.css +1 -1
  58. package/dist/TagInput.module.css +2 -2
  59. package/dist/Textarea.module.css +9 -1
  60. package/dist/ThreeColumnLayout.module.css +35 -0
  61. package/dist/Toast.module.css +62 -3
  62. package/dist/Tooltip.module.css +1 -1
  63. package/dist/UserMenu.module.css +90 -0
  64. package/dist/index.d.ts +1680 -30
  65. package/dist/index.js +4618 -1443
  66. package/dist/tokens/primitives.css +36 -0
  67. package/dist/tokens/semantic.css +32 -0
  68. package/dist/tokens/theme-presets.css +33 -0
  69. package/package.json +32 -12
  70. package/src/__tests__/package-json.test.ts +8 -0
  71. package/src/components/Accordion/Accordion.module.css +1 -1
  72. package/src/components/Alert/Alert.module.css +1 -1
  73. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  74. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  75. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  76. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  77. package/src/components/Avatar/Avatar.module.css +1 -1
  78. package/src/components/Badge/Badge.module.css +30 -7
  79. package/src/components/Badge/Badge.stories.tsx +16 -4
  80. package/src/components/Badge/Badge.tsx +8 -11
  81. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  82. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  83. package/src/components/Button/Button.module.css +18 -2
  84. package/src/components/Card/Card.module.css +1 -1
  85. package/src/components/CardTile/CardTile.module.css +181 -0
  86. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  87. package/src/components/CardTile/CardTile.tsx +128 -0
  88. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  89. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  90. package/src/components/Collapsible/Collapsible.module.css +1 -1
  91. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  92. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  93. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  94. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  95. package/src/components/Combobox/Combobox.module.css +2 -2
  96. package/src/components/Command/Command.module.css +1 -1
  97. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  98. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  99. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  100. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  101. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  102. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  103. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  104. package/src/components/DatePicker/DatePicker.module.css +1 -1
  105. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  106. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  107. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  108. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  109. package/src/components/Dialog/Dialog.module.css +130 -21
  110. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  111. package/src/components/Dialog/Dialog.tsx +137 -94
  112. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  113. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  114. package/src/components/Drawer/Drawer.module.css +36 -7
  115. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  116. package/src/components/Drawer/Drawer.tsx +27 -66
  117. package/src/components/Drawer/__tests__/Drawer.test.tsx +55 -6
  118. package/src/components/GripIcon/GripIcon.tsx +27 -0
  119. package/src/components/Header/Header.module.css +62 -0
  120. package/src/components/Header/Header.stories.tsx +452 -0
  121. package/src/components/Header/Header.tsx +102 -0
  122. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  123. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  124. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  125. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  126. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  127. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  128. package/src/components/Icon/icons/bold.tsx +20 -0
  129. package/src/components/Icon/icons/calendar.tsx +4 -4
  130. package/src/components/Icon/icons/carrot.tsx +3 -3
  131. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  132. package/src/components/Icon/icons/code.tsx +21 -0
  133. package/src/components/Icon/icons/copy.tsx +21 -0
  134. package/src/components/Icon/icons/credit-card.tsx +21 -0
  135. package/src/components/Icon/icons/database.tsx +22 -0
  136. package/src/components/Icon/icons/download.tsx +22 -0
  137. package/src/components/Icon/icons/file-text.tsx +24 -0
  138. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  139. package/src/components/Icon/icons/folder-open.tsx +20 -0
  140. package/src/components/Icon/icons/folder.tsx +20 -0
  141. package/src/components/Icon/icons/folders.tsx +21 -0
  142. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  143. package/src/components/Icon/icons/history.tsx +22 -0
  144. package/src/components/Icon/icons/inbox.tsx +21 -0
  145. package/src/components/Icon/icons/index.ts +169 -1
  146. package/src/components/Icon/icons/italic.tsx +22 -0
  147. package/src/components/Icon/icons/layers.tsx +22 -0
  148. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  149. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  150. package/src/components/Icon/icons/link.tsx +21 -0
  151. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  152. package/src/components/Icon/icons/list.tsx +25 -0
  153. package/src/components/Icon/icons/luggage.tsx +24 -0
  154. package/src/components/Icon/icons/map-pin.tsx +21 -0
  155. package/src/components/Icon/icons/menu.tsx +22 -0
  156. package/src/components/Icon/icons/minus.tsx +20 -0
  157. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  158. package/src/components/Icon/icons/panel-left.tsx +21 -0
  159. package/src/components/Icon/icons/pin.tsx +21 -0
  160. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  161. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  162. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  163. package/src/components/Icon/icons/route.tsx +22 -0
  164. package/src/components/Icon/icons/send.tsx +21 -0
  165. package/src/components/Icon/icons/share-2.tsx +24 -0
  166. package/src/components/Icon/icons/sheet.tsx +24 -0
  167. package/src/components/Icon/icons/smartphone.tsx +21 -0
  168. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  169. package/src/components/Icon/icons/table.tsx +23 -0
  170. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  171. package/src/components/Icon/icons/users.tsx +23 -0
  172. package/src/components/IconPicker/IconPicker.module.css +1 -1
  173. package/src/components/Input/Input.module.css +1 -1
  174. package/src/components/Input/Input.tsx +8 -2
  175. package/src/components/Kbd/Kbd.module.css +1 -1
  176. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  177. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  178. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  179. package/src/components/Markdown/Markdown.module.css +73 -0
  180. package/src/components/Markdown/Markdown.stories.tsx +40 -0
  181. package/src/components/Markdown/Markdown.tsx +305 -0
  182. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  183. package/src/components/Menu/Menu.module.css +1 -0
  184. package/src/components/Menu/Menu.tsx +72 -22
  185. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  186. package/src/components/Menubar/Menubar.tsx +1 -1
  187. package/src/components/Message/Message.module.css +1 -1
  188. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  189. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  190. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +82 -0
  191. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  192. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  193. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  194. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  195. package/src/components/MobileFooter/MobileFooter.tsx +122 -0
  196. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +129 -0
  197. package/src/components/MobileHeader/MobileHeader.module.css +76 -0
  198. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  199. package/src/components/MobileHeader/MobileHeader.tsx +90 -0
  200. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +113 -0
  201. package/src/components/NavList/NavList.module.css +130 -0
  202. package/src/components/NavList/NavList.tsx +136 -0
  203. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  204. package/src/components/NavTabs/NavTabs.module.css +1 -1
  205. package/src/components/NavTabs/NavTabs.tsx +39 -11
  206. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  207. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  208. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  209. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  210. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  211. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  212. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  213. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  214. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  215. package/src/components/PageContainer/PageContainer.module.css +82 -0
  216. package/src/components/PageContainer/PageContainer.tsx +64 -0
  217. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +70 -0
  218. package/src/components/PageHeader/PageHeader.module.css +54 -0
  219. package/src/components/PageHeader/PageHeader.tsx +157 -2
  220. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  221. package/src/components/PageLayout/PageLayout.module.css +51 -0
  222. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  223. package/src/components/PageLayout/PageLayout.tsx +51 -0
  224. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  225. package/src/components/Popover/Popover.module.css +1 -1
  226. package/src/components/Popover/Popover.tsx +86 -4
  227. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  228. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  229. package/src/components/Resizable/Resizable.module.css +3 -3
  230. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  231. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  232. package/src/components/RootLayout/RootLayout.module.css +12 -0
  233. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  234. package/src/components/RootLayout/RootLayout.tsx +185 -0
  235. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  236. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  237. package/src/components/Select/Select.module.css +1 -1
  238. package/src/components/Sheet/Sheet.module.css +4 -1
  239. package/src/components/Sheet/Sheet.tsx +14 -56
  240. package/src/components/SplitPane/SplitPane.module.css +4 -4
  241. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  242. package/src/components/StepStrip/StepStrip.module.css +53 -0
  243. package/src/components/StepStrip/StepStrip.tsx +74 -0
  244. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  245. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +64 -0
  246. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +243 -0
  247. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  248. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  249. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  250. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  251. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  252. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +214 -0
  253. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  254. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  255. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +64 -0
  256. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +73 -0
  257. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  258. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  259. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  260. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  261. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  262. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  263. package/src/components/Table/Table.module.css +2 -2
  264. package/src/components/Tabs/Tabs.module.css +1 -1
  265. package/src/components/TagInput/TagInput.module.css +2 -2
  266. package/src/components/Textarea/Textarea.module.css +9 -1
  267. package/src/components/Textarea/Textarea.tsx +28 -5
  268. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  269. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  270. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  271. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  272. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  273. package/src/components/Toast/Toast.module.css +62 -3
  274. package/src/components/Toast/Toast.stories.tsx +1 -1
  275. package/src/components/Tooltip/Tooltip.module.css +1 -1
  276. package/src/components/UserMenu/UserMenu.module.css +90 -0
  277. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  278. package/src/components/UserMenu/UserMenu.tsx +132 -0
  279. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  280. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  281. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  282. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  283. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  284. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  285. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  286. package/src/hooks/__tests__/useSnapCarousel.test.tsx +175 -0
  287. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  288. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  289. package/src/hooks/index.ts +27 -0
  290. package/src/hooks/useCarouselRouteSync.ts +92 -0
  291. package/src/hooks/useIsOffline.ts +41 -0
  292. package/src/hooks/useOfflineTileState.ts +45 -0
  293. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  294. package/src/hooks/useReorderSensors.ts +88 -0
  295. package/src/hooks/useResponsiveLayout.ts +36 -0
  296. package/src/hooks/useSnapCarousel.ts +284 -0
  297. package/src/hooks/useSwipeReveal.ts +107 -0
  298. package/src/hooks/useSwipeStack.ts +235 -0
  299. package/src/index.ts +124 -3
  300. package/src/overlay-shell.ts +159 -0
  301. package/src/stories/DesignSystemOverview.stories.tsx +933 -6
  302. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  303. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  304. package/src/stories/GripIcon.stories.tsx +29 -0
  305. package/src/stories/InteractionHooks.stories.tsx +483 -0
  306. package/src/stories/LayoutSystem.stories.tsx +639 -0
  307. package/src/stories/MenuEntries.stories.tsx +86 -0
  308. package/src/stories/MobilePatterns.stories.tsx +247 -2
  309. package/src/stories/NavList.stories.tsx +147 -0
  310. package/src/stories/NavTabs.stories.tsx +18 -0
  311. package/src/stories/OfflineGate.stories.tsx +42 -0
  312. package/src/stories/PageContainer.stories.tsx +82 -0
  313. package/src/stories/PageHeader.stories.tsx +65 -1
  314. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  315. package/src/stories/StepStrip.stories.tsx +76 -0
  316. package/src/stories/SwipableMobileCarousel.stories.tsx +362 -0
  317. package/src/stories/SwipeStack.stories.tsx +200 -0
  318. package/src/stories/Textarea.stories.tsx +12 -0
  319. package/src/stories/TokenGallery.stories.tsx +115 -0
  320. package/src/tokens/primitives.css +36 -0
  321. package/src/tokens/semantic.css +32 -0
  322. package/src/tokens/theme-presets.css +33 -0
  323. 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:
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { EmptyState } from '../EmptyState/EmptyState';
5
+
6
+ export interface DeviceOnlyGateProps {
7
+ children: ReactNode;
8
+ /** Defaults to a generic "this app" phrasing — pass the plugin's own name. */
9
+ surfaceName?: string;
10
+ /**
11
+ * The caller's own `isDeviceOnlyTierAvailable()` result (from
12
+ * `@sovereignfs/sdk/device-client`) — taken as a plain boolean rather than
13
+ * computed inside this component, so `@sovereignfs/ui` never depends on
14
+ * `@sovereignfs/sdk` (this package must stay usable standalone, outside the
15
+ * plugin runtime — RFC 0073).
16
+ */
17
+ available: boolean;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * DeviceOnlyGate — blocks a `device-only`-tier plugin's content from
23
+ * rendering on a surface that cannot provide the durable, encrypted,
24
+ * device-auth-gated store that tier requires, showing an explanatory empty
25
+ * state instead of a broken screen (research 0012, epic tasks 2.33 + 3.36).
26
+ *
27
+ * A `device-only` plugin wraps its own root content in this the same way
28
+ * Console and Account wrap theirs in `OfflineGate` — an opt-in pattern each
29
+ * surface applies to itself, not a platform-level route gate. The launcher
30
+ * tile's own "Phone only" badge (`useOfflineTileState`) is advisory UI on top
31
+ * of this; this component is the actual gate, since a user can always reach a
32
+ * route directly (a bookmark, a deep link) without passing through the
33
+ * launcher tile at all.
34
+ *
35
+ * **Not a security boundary.** Availability here is a capability signal, not
36
+ * an authorization check — the real protection for `device-only` data is
37
+ * that it is encrypted and the key requires device auth to release (epic task
38
+ * 1.22), so it stays inaccessible regardless of whether this gate is present,
39
+ * bypassed, or simply never reached (e.g. a hand-crafted request against the
40
+ * plugin's own API routes).
41
+ */
42
+ export function DeviceOnlyGate({
43
+ children,
44
+ surfaceName,
45
+ available,
46
+ className,
47
+ }: DeviceOnlyGateProps) {
48
+ if (available) return <>{children}</>;
49
+
50
+ return (
51
+ <EmptyState
52
+ icon="smartphone"
53
+ heading="Phone only"
54
+ description={`${surfaceName ?? 'This app'} is only available on a phone with secure storage set up.`}
55
+ className={className}
56
+ />
57
+ );
58
+ }
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { DeviceOnlyGate } from '../DeviceOnlyGate';
5
+
6
+ describe('DeviceOnlyGate', () => {
7
+ afterEach(cleanup);
8
+
9
+ it('renders children unchanged when available', () => {
10
+ render(
11
+ <DeviceOnlyGate available={true}>
12
+ <p>Wallet content</p>
13
+ </DeviceOnlyGate>,
14
+ );
15
+ expect(screen.getByText('Wallet content')).toBeDefined();
16
+ });
17
+
18
+ it('replaces children with an explanatory empty state when unavailable', () => {
19
+ render(
20
+ <DeviceOnlyGate available={false}>
21
+ <p>Wallet content</p>
22
+ </DeviceOnlyGate>,
23
+ );
24
+ expect(screen.queryByText('Wallet content')).toBeNull();
25
+ expect(screen.getByText('Phone only')).toBeDefined();
26
+ });
27
+
28
+ it('names the surface in the message when provided', () => {
29
+ render(
30
+ <DeviceOnlyGate available={false} surfaceName="Wallet">
31
+ <p>Wallet content</p>
32
+ </DeviceOnlyGate>,
33
+ );
34
+ expect(screen.getByText(/Wallet is only available on a phone/)).toBeDefined();
35
+ });
36
+
37
+ it('falls back to generic phrasing with no surface name', () => {
38
+ render(
39
+ <DeviceOnlyGate available={false}>
40
+ <p>Wallet content</p>
41
+ </DeviceOnlyGate>,
42
+ );
43
+ expect(screen.getByText(/This app is only available on a phone/)).toBeDefined();
44
+ });
45
+ });
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { EmptyState } from '../EmptyState/EmptyState';
5
+
6
+ /**
7
+ * `'checking'` covers the moment before the caller's own async status check
8
+ * resolves — same transitional-state pattern `EncryptionSection`'s local
9
+ * `LocalState` uses, so this gate never flashes an empty state before the
10
+ * real answer is known. `'no-device-auth'` is the hard-block case (RFC 0093
11
+ * §5, epic task 1.22): the environment supports the tier, but this device
12
+ * has no passcode, fingerprint, or face unlock configured, so a key cannot
13
+ * be created here at all — distinct from `'not-set-up'`, where it can.
14
+ */
15
+ export type DeviceStorageKeyGateStatus =
16
+ 'checking' | 'unsupported' | 'no-device-auth' | 'not-set-up' | 'set-up';
17
+
18
+ export interface DeviceStorageKeyGateProps {
19
+ children: ReactNode;
20
+ /** Defaults to a generic "This app" phrasing — pass the plugin's own name. */
21
+ surfaceName?: string;
22
+ /**
23
+ * The caller's own `getDeviceStorageKeyStatus()` result (from
24
+ * `@sovereignfs/sdk/device-only-storage`) — taken as a plain status rather
25
+ * than computed inside this component, so `@sovereignfs/ui` never depends
26
+ * on `@sovereignfs/sdk` (same boundary `DeviceOnlyGate`'s `available` prop
27
+ * draws — this package must stay usable standalone, outside the plugin
28
+ * runtime — RFC 0073).
29
+ */
30
+ status: DeviceStorageKeyGateStatus;
31
+ /**
32
+ * Rendered under the "not set up" message — typically a link to Account →
33
+ * Security. Not built in here: this package depends on no router, so the
34
+ * caller supplies its own navigable element (a plain `<a>`, a framework
35
+ * `<Link>`, whatever the host app uses).
36
+ */
37
+ setupAction?: ReactNode;
38
+ className?: string;
39
+ }
40
+
41
+ /**
42
+ * DeviceStorageKeyGate — blocks a `device-only`-tier plugin's content until
43
+ * the user has set up their Device Storage Key (RFC 0093 §2, epic task
44
+ * 1.22). Distinct from `DeviceOnlyGate`: that one checks whether the *tier
45
+ * itself* is available on this surface (native shell, secure-storage bridge
46
+ * present); this one checks whether the user has actually completed the
47
+ * one-time, per-device enrollment that tier depends on. A `device-only`
48
+ * plugin typically wants both — `DeviceOnlyGate` outermost (no point
49
+ * checking enrollment on a surface that cannot offer the tier at all), this
50
+ * gate inside it.
51
+ *
52
+ * Enrollment is centralized and decoupled from any single plugin (RFC 0093
53
+ * §2): a plugin that finds no key set up does not run its own enrollment
54
+ * ceremony inline. It stops here and points the user at Account → Security —
55
+ * the one place enrollment happens for every `device-only` plugin on this
56
+ * device — via `setupAction`, the same opt-in, self-applied pattern
57
+ * `OfflineGate`/`DeviceOnlyGate` already use for their own surfaces.
58
+ */
59
+ export function DeviceStorageKeyGate({
60
+ children,
61
+ surfaceName,
62
+ status,
63
+ setupAction,
64
+ className,
65
+ }: DeviceStorageKeyGateProps) {
66
+ if (status === 'checking') return null;
67
+ if (status === 'set-up') return <>{children}</>;
68
+
69
+ if (status === 'unsupported') {
70
+ return (
71
+ <EmptyState
72
+ icon="smartphone"
73
+ heading="Phone only"
74
+ description={`${surfaceName ?? 'This app'} needs a security feature (WebAuthn PRF and Origin Private File System support) that isn't available here.`}
75
+ className={className}
76
+ />
77
+ );
78
+ }
79
+
80
+ if (status === 'no-device-auth') {
81
+ return (
82
+ <EmptyState
83
+ icon="alert-triangle"
84
+ heading="Set up a device passcode"
85
+ description={`${surfaceName ?? 'This app'} needs a passcode, fingerprint, or face unlock set up on this device before its Device Storage Key can be created. Set one up in your device's system settings, then come back.`}
86
+ className={className}
87
+ />
88
+ );
89
+ }
90
+
91
+ return (
92
+ <EmptyState
93
+ icon="lock"
94
+ heading="Set up your Device Storage Key"
95
+ description={`${surfaceName ?? 'This app'} keeps its data only on this device, protected by a Device Storage Key you set up once in Account → Security.`}
96
+ action={setupAction}
97
+ className={className}
98
+ />
99
+ );
100
+ }
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { DeviceStorageKeyGate } from '../DeviceStorageKeyGate';
5
+
6
+ describe('DeviceStorageKeyGate', () => {
7
+ afterEach(cleanup);
8
+
9
+ it('renders nothing while checking', () => {
10
+ const { container } = render(
11
+ <DeviceStorageKeyGate status="checking">
12
+ <p>Notes content</p>
13
+ </DeviceStorageKeyGate>,
14
+ );
15
+ expect(container.firstChild).toBeNull();
16
+ });
17
+
18
+ it('renders children unchanged once set up', () => {
19
+ render(
20
+ <DeviceStorageKeyGate status="set-up">
21
+ <p>Notes content</p>
22
+ </DeviceStorageKeyGate>,
23
+ );
24
+ expect(screen.getByText('Notes content')).toBeDefined();
25
+ });
26
+
27
+ it('shows a "Phone only"-style message when the environment is unsupported', () => {
28
+ render(
29
+ <DeviceStorageKeyGate status="unsupported">
30
+ <p>Notes content</p>
31
+ </DeviceStorageKeyGate>,
32
+ );
33
+ expect(screen.queryByText('Notes content')).toBeNull();
34
+ expect(screen.getByText('Phone only')).toBeDefined();
35
+ });
36
+
37
+ it('shows the setup message and prompt when not-set-up', () => {
38
+ render(
39
+ <DeviceStorageKeyGate status="not-set-up">
40
+ <p>Notes content</p>
41
+ </DeviceStorageKeyGate>,
42
+ );
43
+ expect(screen.queryByText('Notes content')).toBeNull();
44
+ expect(screen.getByText('Set up your Device Storage Key')).toBeDefined();
45
+ });
46
+
47
+ it('names the surface in the not-set-up message when provided', () => {
48
+ render(
49
+ <DeviceStorageKeyGate status="not-set-up" surfaceName="Notes">
50
+ <p>Notes content</p>
51
+ </DeviceStorageKeyGate>,
52
+ );
53
+ expect(screen.getByText(/Notes keeps its data only on this device/)).toBeDefined();
54
+ });
55
+
56
+ it('falls back to generic phrasing with no surface name', () => {
57
+ render(
58
+ <DeviceStorageKeyGate status="not-set-up">
59
+ <p>Notes content</p>
60
+ </DeviceStorageKeyGate>,
61
+ );
62
+ expect(screen.getByText(/This app keeps its data only on this device/)).toBeDefined();
63
+ });
64
+
65
+ it('renders the caller-supplied setupAction when not-set-up', () => {
66
+ render(
67
+ <DeviceStorageKeyGate
68
+ status="not-set-up"
69
+ setupAction={<a href="/account/security">Go to Account → Security</a>}
70
+ >
71
+ <p>Notes content</p>
72
+ </DeviceStorageKeyGate>,
73
+ );
74
+ expect(screen.getByRole('link', { name: 'Go to Account → Security' })).toBeDefined();
75
+ });
76
+
77
+ it('renders no setupAction when unsupported, even if one is passed', () => {
78
+ render(
79
+ <DeviceStorageKeyGate
80
+ status="unsupported"
81
+ setupAction={<a href="/account/security">Go to Account → Security</a>}
82
+ >
83
+ <p>Notes content</p>
84
+ </DeviceStorageKeyGate>,
85
+ );
86
+ expect(screen.queryByRole('link')).toBeNull();
87
+ });
88
+
89
+ it('shows the hard-block message when no-device-auth', () => {
90
+ render(
91
+ <DeviceStorageKeyGate status="no-device-auth">
92
+ <p>Notes content</p>
93
+ </DeviceStorageKeyGate>,
94
+ );
95
+ expect(screen.queryByText('Notes content')).toBeNull();
96
+ expect(screen.getByText('Set up a device passcode')).toBeDefined();
97
+ });
98
+
99
+ it('names the surface in the no-device-auth message when provided', () => {
100
+ render(
101
+ <DeviceStorageKeyGate status="no-device-auth" surfaceName="Notes">
102
+ <p>Notes content</p>
103
+ </DeviceStorageKeyGate>,
104
+ );
105
+ expect(screen.getByText(/Notes needs a passcode, fingerprint, or face unlock/)).toBeDefined();
106
+ });
107
+
108
+ it('renders no setupAction when no-device-auth, even if one is passed — fixing this is a device settings change, not a Sovereign link', () => {
109
+ render(
110
+ <DeviceStorageKeyGate
111
+ status="no-device-auth"
112
+ setupAction={<a href="/account/security">Go to Account → Security</a>}
113
+ >
114
+ <p>Notes content</p>
115
+ </DeviceStorageKeyGate>,
116
+ );
117
+ expect(screen.queryByRole('link')).toBeNull();
118
+ });
119
+ });