@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
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { NotificationsPanel } from '../NotificationsPanel';
5
+
6
+ afterEach(() => {
7
+ cleanup();
8
+ });
9
+
10
+ const items = [
11
+ {
12
+ id: '1',
13
+ icon: <span>icon</span>,
14
+ title: 'Added to a project',
15
+ timeLabel: '2d ago',
16
+ read: false,
17
+ onOpen: vi.fn(),
18
+ onDismiss: vi.fn(),
19
+ },
20
+ ];
21
+
22
+ describe('NotificationsPanel', () => {
23
+ it('renders a closed trigger with an unread badge', () => {
24
+ render(<NotificationsPanel items={items} unreadCount={1} />);
25
+ expect(screen.getByRole('button', { name: /1 unread/ })).toBeTruthy();
26
+ expect(screen.queryByText('Added to a project')).toBeNull();
27
+ });
28
+
29
+ it('renders no badge when there are no unread notifications', () => {
30
+ render(<NotificationsPanel items={[]} unreadCount={0} />);
31
+ expect(screen.getByRole('button', { name: 'Notifications' })).toBeTruthy();
32
+ });
33
+
34
+ it('opens the panel on trigger click, showing items', () => {
35
+ render(<NotificationsPanel items={items} unreadCount={1} />);
36
+ fireEvent.click(screen.getByRole('button', { name: /unread/ }));
37
+ expect(screen.getByText('Added to a project')).toBeTruthy();
38
+ expect(screen.getByText('2d ago')).toBeTruthy();
39
+ });
40
+
41
+ it('shows an empty state when there are no items', () => {
42
+ render(<NotificationsPanel items={[]} unreadCount={0} />);
43
+ fireEvent.click(screen.getByRole('button', { name: 'Notifications' }));
44
+ expect(screen.getByText('No notifications.')).toBeTruthy();
45
+ });
46
+
47
+ it('calls onOpen when an unread title is clicked', () => {
48
+ render(<NotificationsPanel items={items} unreadCount={1} />);
49
+ fireEvent.click(screen.getByRole('button', { name: /unread/ }));
50
+ fireEvent.click(screen.getByRole('button', { name: 'Mark as read: Added to a project' }));
51
+ expect(items[0]?.onOpen).toHaveBeenCalledOnce();
52
+ });
53
+
54
+ it('calls onDismiss when the dismiss button is clicked', () => {
55
+ render(<NotificationsPanel items={items} unreadCount={1} />);
56
+ fireEvent.click(screen.getByRole('button', { name: /unread/ }));
57
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss: Added to a project' }));
58
+ expect(items[0]?.onDismiss).toHaveBeenCalledOnce();
59
+ });
60
+
61
+ it('calls onMarkAllRead and onClearAll from the header actions', () => {
62
+ const onMarkAllRead = vi.fn();
63
+ const onClearAll = vi.fn();
64
+ render(
65
+ <NotificationsPanel
66
+ items={items}
67
+ unreadCount={1}
68
+ onMarkAllRead={onMarkAllRead}
69
+ onClearAll={onClearAll}
70
+ />,
71
+ );
72
+ fireEvent.click(screen.getByRole('button', { name: /unread/ }));
73
+ fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
74
+ fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
75
+ expect(onMarkAllRead).toHaveBeenCalledOnce();
76
+ expect(onClearAll).toHaveBeenCalledOnce();
77
+ });
78
+ });
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { useIsOffline } from '../../hooks/useIsOffline';
5
+ import { EmptyState } from '../EmptyState/EmptyState';
6
+
7
+ export interface OfflineGateProps {
8
+ children: ReactNode;
9
+ /** Defaults to a generic "this section" phrasing — pass the surface's own name. */
10
+ surfaceName?: string;
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * OfflineGate — blocks administrative/settings surfaces from rendering
16
+ * (possibly stale) cached content while the device has no network, showing an
17
+ * explanatory empty state instead (research 0012, epic task 2.32).
18
+ *
19
+ * Distinct from `OfflineBanner`: the banner is an informational overlay that
20
+ * coexists with normal content everywhere in the shell. This is a hard block
21
+ * for surfaces where operating against stale data is actively wrong — e.g. a
22
+ * Console user list or an Account billing page reflects a point-in-time
23
+ * snapshot the moment it's server-rendered, and any cached copy replayed
24
+ * later has no way to signal that it may already be stale. `OfflineGate`
25
+ * doesn't try to tell staleness apart from freshness; it removes the
26
+ * question by not rendering the content at all while offline.
27
+ *
28
+ * `children` is rendered unconditionally while online, so this adds no cost
29
+ * to the common case — it only decides what to show, never how the wrapped
30
+ * content itself is fetched or cached.
31
+ */
32
+ export function OfflineGate({ children, surfaceName, className }: OfflineGateProps) {
33
+ const isOffline = useIsOffline();
34
+ if (!isOffline) return <>{children}</>;
35
+
36
+ return (
37
+ <EmptyState
38
+ icon="alert-triangle"
39
+ heading="You're offline"
40
+ description={`${surfaceName ?? 'This section'} needs a connection — reconnect to continue.`}
41
+ className={className}
42
+ />
43
+ );
44
+ }
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, render, screen } from '@testing-library/react';
4
+ import { OfflineGate } from '../OfflineGate';
5
+
6
+ describe('OfflineGate', () => {
7
+ beforeEach(() => {
8
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: true });
9
+ });
10
+ afterEach(() => {
11
+ vi.unstubAllGlobals();
12
+ cleanup();
13
+ });
14
+
15
+ it('renders children unchanged while online', () => {
16
+ render(
17
+ <OfflineGate>
18
+ <p>Live content</p>
19
+ </OfflineGate>,
20
+ );
21
+ expect(screen.getByText('Live content')).toBeDefined();
22
+ });
23
+
24
+ it('replaces children with an explanatory empty state while offline', () => {
25
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
26
+ render(
27
+ <OfflineGate>
28
+ <p>Live content</p>
29
+ </OfflineGate>,
30
+ );
31
+ expect(screen.queryByText('Live content')).toBeNull();
32
+ expect(screen.getByText("You're offline")).toBeDefined();
33
+ });
34
+
35
+ it('names the surface in the offline message when provided', () => {
36
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
37
+ render(
38
+ <OfflineGate surfaceName="Console">
39
+ <p>Live content</p>
40
+ </OfflineGate>,
41
+ );
42
+ expect(screen.getByText(/Console needs a connection/)).toBeDefined();
43
+ });
44
+
45
+ it('falls back to generic phrasing with no surface name', () => {
46
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
47
+ render(
48
+ <OfflineGate>
49
+ <p>Live content</p>
50
+ </OfflineGate>,
51
+ );
52
+ expect(screen.getByText(/This section needs a connection/)).toBeDefined();
53
+ });
54
+
55
+ it('swaps back to children on reconnect', () => {
56
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
57
+ render(
58
+ <OfflineGate>
59
+ <p>Live content</p>
60
+ </OfflineGate>,
61
+ );
62
+ expect(screen.getByText("You're offline")).toBeDefined();
63
+ act(() => window.dispatchEvent(new Event('online')));
64
+ expect(screen.getByText('Live content')).toBeDefined();
65
+ });
66
+ });
@@ -6,7 +6,7 @@
6
6
  .header {
7
7
  flex-shrink: 0;
8
8
  background: var(--sv-color-surface);
9
- border-bottom: 1px solid var(--sv-color-border);
9
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
10
10
  }
11
11
 
12
12
  .row {
@@ -5,8 +5,12 @@ import styles from './OverlayHeader.module.css';
5
5
  export interface OverlayHeaderProps {
6
6
  /** Overlay title, shown between the optional back button and the trailing
7
7
  * slots. Optional — some consumers (e.g. `Dialog`'s mobile bar) need the
8
- * row's close button present even when no title applies. */
9
- title?: string;
8
+ * row's close button present even when no title applies. Accepts any
9
+ * `ReactNode`, not just a string — e.g. `Dialog`'s `header` prop passes
10
+ * arbitrary content through here (an icon next to text, etc.); the
11
+ * row's title slot still assumes single-line content sized like text
12
+ * (see the component's own doc comment for the line this doesn't cross). */
13
+ title?: ReactNode;
10
14
  /** Always renders a close button; called on click. */
11
15
  onClose: () => void;
12
16
  /** When provided, renders a back-chevron button before the title (for
@@ -18,6 +22,15 @@ export interface OverlayHeaderProps {
18
22
  /** A second row below the title row — e.g. a tab strip. */
19
23
  secondRow?: ReactNode;
20
24
  className?: string;
25
+ /** Override the title row's own className (e.g. tighter padding to match
26
+ * a consumer's own compact header elsewhere in the same surface) — the
27
+ * row keeps its standard title/back/action/close layout, just restyled.
28
+ * For a genuinely different shape, don't use `OverlayHeader` at all (see
29
+ * this component's own doc comment). */
30
+ rowClassName?: string;
31
+ /** Override the title text's own className (e.g. a smaller font size to
32
+ * match a consumer's own compact header elsewhere in the same surface). */
33
+ titleClassName?: string;
21
34
  }
22
35
 
23
36
  /**
@@ -41,16 +54,18 @@ export function OverlayHeader({
41
54
  action,
42
55
  secondRow,
43
56
  className,
57
+ rowClassName,
58
+ titleClassName,
44
59
  }: OverlayHeaderProps) {
45
60
  return (
46
61
  <div className={[styles.header, className].filter(Boolean).join(' ')}>
47
- <div className={styles.row}>
62
+ <div className={[styles.row, rowClassName].filter(Boolean).join(' ')}>
48
63
  {onBack && (
49
64
  <button type="button" className={styles.backButton} aria-label="Back" onClick={onBack}>
50
65
  <Icon name="chevron-left" size="md" aria-hidden />
51
66
  </button>
52
67
  )}
53
- <span className={styles.title}>{title}</span>
68
+ <span className={[styles.title, titleClassName].filter(Boolean).join(' ')}>{title}</span>
54
69
  {action && <div className={styles.action}>{action}</div>}
55
70
  <button type="button" className={styles.closeButton} aria-label="Close" onClick={onClose}>
56
71
  <Icon name="x" size="sm" aria-hidden />
@@ -48,4 +48,22 @@ describe('OverlayHeader', () => {
48
48
  render(<OverlayHeader title="Account" onClose={() => {}} secondRow={<nav>Tab strip</nav>} />);
49
49
  expect(screen.getByText('Tab strip')).toBeTruthy();
50
50
  });
51
+
52
+ it('accepts arbitrary ReactNode content for title, not just a string', () => {
53
+ // Locks in the type widening (title?: string -> title?: ReactNode) that
54
+ // Dialog's `header` prop relies on (Dialog.tsx passes header through
55
+ // here as `title`) — a plain string title must keep working unchanged.
56
+ render(
57
+ <OverlayHeader
58
+ title={
59
+ <>
60
+ <strong>Card</strong> detail
61
+ </>
62
+ }
63
+ onClose={() => {}}
64
+ />,
65
+ );
66
+ expect(screen.getByText('Card').tagName).toBe('STRONG');
67
+ expect(screen.getByText('detail')).toBeTruthy();
68
+ });
51
69
  });
@@ -0,0 +1,82 @@
1
+ .container {
2
+ width: 100%;
3
+ margin-inline: auto;
4
+ /* Declared explicitly rather than inherited from a host reset: `maxWidth`
5
+ and `padding` land on this same element, so whether the max-width bound
6
+ includes the padding must not depend on whether the consuming app ships
7
+ a global `box-sizing` reset. @sovereignfs/ui is usable standalone
8
+ (task 9.17), where no such reset is guaranteed. Same reason Input,
9
+ Select, Combobox, DatePicker, and FileDropzone each set it locally. */
10
+ box-sizing: border-box;
11
+ }
12
+
13
+ /* ── Max width ──────────────────────────────────────────────────────────── */
14
+
15
+ /* Outer bound, padding included (see box-sizing above): `md` + `padding="md"`
16
+ yields a 960px box with 896px of content, not 1024px. */
17
+
18
+ .sm {
19
+ max-width: 640px;
20
+ }
21
+
22
+ .md {
23
+ max-width: 960px;
24
+ }
25
+
26
+ .lg {
27
+ max-width: 1200px;
28
+ }
29
+
30
+ .full {
31
+ max-width: none;
32
+ }
33
+
34
+ /* ── Padding ────────────────────────────────────────────────────────────── */
35
+
36
+ /* The page gutter, owned by the plugin rather than the runtime shell
37
+ (task 9.25). `md` reproduces the exact values the shell used to apply
38
+ (--sv-space-8 desktop / --sv-space-4 mobile), so a bare <PageContainer>
39
+ renders identically to a pre-9.25 plugin that relied on the shell.
40
+
41
+ A fixed four-step scale, deliberately: the review behind task 9.18 found
42
+ four different ad-hoc padding combinations across plugins, and a free-form
43
+ value would relocate that inconsistency from CSS modules into JSX.
44
+
45
+ --sv-page-gutter is the stand-down hook for an ancestor that *already*
46
+ supplies the gutter: Dialog sets it to 0 on its content region, so an
47
+ overlay plugin whose page is one tree rendered into two hosts — the
48
+ Dialog on soft navigation, the runtime shell on a hard load — is padded
49
+ exactly once in both, with no prop threading and no knowledge of which
50
+ host it is in. Setting it to anything other than 0 defeats the fixed
51
+ scale above and is not a supported use. */
52
+
53
+ .padNone {
54
+ padding: 0;
55
+ }
56
+
57
+ .padSm {
58
+ padding: var(--sv-page-gutter, var(--sv-space-4));
59
+ }
60
+
61
+ .padMd {
62
+ padding: var(--sv-page-gutter, var(--sv-space-8));
63
+ }
64
+
65
+ .padLg {
66
+ padding: var(--sv-page-gutter, var(--sv-space-12));
67
+ }
68
+
69
+ /* Matches the breakpoint Dialog, MobileHeader, and MobileFooter already use. */
70
+ @media (max-width: 768px) {
71
+ .padSm {
72
+ padding: var(--sv-page-gutter, var(--sv-space-3));
73
+ }
74
+
75
+ .padMd {
76
+ padding: var(--sv-page-gutter, var(--sv-space-4));
77
+ }
78
+
79
+ .padLg {
80
+ padding: var(--sv-page-gutter, var(--sv-space-6));
81
+ }
82
+ }
@@ -0,0 +1,64 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+ import styles from './PageContainer.module.css';
3
+
4
+ export interface PageContainerProps extends HTMLAttributes<HTMLDivElement> {
5
+ /**
6
+ * Constrains and centers content width. The bound includes `padding`
7
+ * (the container is `border-box`). Defaults to `'full'` — narrowing is
8
+ * opt-in, so wrapping a page in `PageContainer` never silently clamps it.
9
+ */
10
+ maxWidth?: 'sm' | 'md' | 'lg' | 'full';
11
+ /**
12
+ * The page gutter. Responsive — each step is tighter at ≤768px. Defaults
13
+ * to `'md'`, which is exactly what the runtime shell used to apply
14
+ * (`--sv-space-8` desktop / `--sv-space-4` mobile), so a bare
15
+ * `<PageContainer>` is the correct wrapper for a plugin that previously
16
+ * relied on the shell for its padding.
17
+ *
18
+ * Use `'none'` when an ancestor already supplies the gutter — inside a
19
+ * `Dialog`, whose own content region is padded.
20
+ */
21
+ padding?: 'none' | 'sm' | 'md' | 'lg';
22
+ children: ReactNode;
23
+ }
24
+
25
+ const maxWidthClass: Record<NonNullable<PageContainerProps['maxWidth']>, string> = {
26
+ sm: styles.sm as string,
27
+ md: styles.md as string,
28
+ lg: styles.lg as string,
29
+ full: styles.full as string,
30
+ };
31
+
32
+ const paddingClass: Record<NonNullable<PageContainerProps['padding']>, string> = {
33
+ none: styles.padNone as string,
34
+ sm: styles.padSm as string,
35
+ md: styles.padMd as string,
36
+ lg: styles.padLg as string,
37
+ };
38
+
39
+ /**
40
+ * The shared page wrapper every plugin's own layout or page renders through.
41
+ *
42
+ * A plugin owns its page gutter; the runtime shell does not impose one
43
+ * (task 9.25). The shell still reserves space for chrome a plugin cannot
44
+ * know about — the offline banner's height and the mobile footer's — but it
45
+ * contributes no inset of its own, so this component is the single place a
46
+ * plugin's four-sided padding comes from.
47
+ */
48
+ export function PageContainer({
49
+ maxWidth = 'full',
50
+ padding = 'md',
51
+ className,
52
+ children,
53
+ ...rest
54
+ }: PageContainerProps) {
55
+ const cls = [styles.container, maxWidthClass[maxWidth], paddingClass[padding], className]
56
+ .filter(Boolean)
57
+ .join(' ');
58
+
59
+ return (
60
+ <div className={cls} {...rest}>
61
+ {children}
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,70 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { PageContainer } from '../PageContainer';
5
+
6
+ afterEach(cleanup);
7
+
8
+ /** Classes are unhashed under Vitest's CSS-module transform, so compare token
9
+ * membership rather than substrings — `toContain('md')` would also match
10
+ * `padMd`, which is exactly the confusion these two prop scales invite. */
11
+ function classes(el: Element): string[] {
12
+ return el.className.split(/\s+/).filter(Boolean);
13
+ }
14
+
15
+ describe('PageContainer', () => {
16
+ it('renders its children', () => {
17
+ render(<PageContainer>content</PageContainer>);
18
+ expect(screen.getByText('content')).toBeDefined();
19
+ });
20
+
21
+ it('defaults to full max-width, so wrapping a page never silently clamps it', () => {
22
+ render(<PageContainer>content</PageContainer>);
23
+ expect(classes(screen.getByText('content'))).toContain('full');
24
+ });
25
+
26
+ it('defaults to md padding — the gutter the runtime shell used to apply', () => {
27
+ render(<PageContainer>content</PageContainer>);
28
+ expect(classes(screen.getByText('content'))).toContain('padMd');
29
+ });
30
+
31
+ it('applies sm, md, and lg max-width classes', () => {
32
+ const { rerender } = render(<PageContainer maxWidth="sm">content</PageContainer>);
33
+ expect(classes(screen.getByText('content'))).toContain('sm');
34
+ rerender(<PageContainer maxWidth="md">content</PageContainer>);
35
+ expect(classes(screen.getByText('content'))).toContain('md');
36
+ rerender(<PageContainer maxWidth="lg">content</PageContainer>);
37
+ expect(classes(screen.getByText('content'))).toContain('lg');
38
+ });
39
+
40
+ it('applies none, sm, and lg padding classes', () => {
41
+ const { rerender } = render(<PageContainer padding="none">content</PageContainer>);
42
+ expect(classes(screen.getByText('content'))).toContain('padNone');
43
+ rerender(<PageContainer padding="sm">content</PageContainer>);
44
+ expect(classes(screen.getByText('content'))).toContain('padSm');
45
+ rerender(<PageContainer padding="lg">content</PageContainer>);
46
+ expect(classes(screen.getByText('content'))).toContain('padLg');
47
+ });
48
+
49
+ it('composes max-width and padding independently', () => {
50
+ render(
51
+ <PageContainer maxWidth="sm" padding="none">
52
+ content
53
+ </PageContainer>,
54
+ );
55
+ const cls = classes(screen.getByText('content'));
56
+ expect(cls).toContain('sm');
57
+ expect(cls).toContain('padNone');
58
+ expect(cls).not.toContain('padSm');
59
+ });
60
+
61
+ it('forwards className and other div props', () => {
62
+ render(
63
+ <PageContainer className="custom" data-testid="container">
64
+ content
65
+ </PageContainer>,
66
+ );
67
+ const el = screen.getByTestId('container');
68
+ expect(classes(el)).toContain('custom');
69
+ });
70
+ });
@@ -13,6 +13,17 @@
13
13
  min-width: 0;
14
14
  }
15
15
 
16
+ /* Wraps the optional icon + `.title` so the icon sizes/aligns itself
17
+ without touching `.title`'s own rules — every existing icon-less usage
18
+ renders identically, since a single flex child behaves the same as a
19
+ non-flex one. */
20
+ .titleRow {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: var(--sv-space-2);
24
+ color: var(--sv-color-text-primary);
25
+ }
26
+
16
27
  .title {
17
28
  margin: 0;
18
29
  font-size: var(--sv-font-size-2xl);
@@ -30,3 +41,46 @@
30
41
  .action {
31
42
  flex-shrink: 0;
32
43
  }
44
+
45
+ /* ── Mobile ─────────────────────────────────────────────────────────────── */
46
+ /* A compact single row — back (optional) + title + action (optional) —
47
+ generalized from Kanban's own hand-rolled MobileBoardHeader. Deliberately
48
+ no description handling and no border; stays consistent with .header's
49
+ own minimal, borderless treatment, just tighter. */
50
+
51
+ .mobileHeader {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: var(--sv-space-2);
55
+ margin-bottom: var(--sv-space-3);
56
+ }
57
+
58
+ .mobileBack {
59
+ flex-shrink: 0;
60
+ }
61
+
62
+ .mobileTitleIcon {
63
+ flex-shrink: 0;
64
+ color: var(--sv-color-text-primary);
65
+ }
66
+
67
+ .mobileTitle {
68
+ flex: 1;
69
+ min-width: 0;
70
+ margin: 0;
71
+ overflow: hidden;
72
+ text-overflow: ellipsis;
73
+ white-space: nowrap;
74
+ font-size: var(--sv-font-size-md);
75
+ font-weight: var(--sv-font-weight-semibold);
76
+ color: var(--sv-color-text-primary);
77
+ line-height: 1.2;
78
+ }
79
+
80
+ .mobileAction {
81
+ flex-shrink: 0;
82
+ }
83
+
84
+ .mobileMenu {
85
+ flex-shrink: 0;
86
+ }