@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,4 +1,5 @@
1
1
  // @vitest-environment jsdom
2
+ import { StrictMode, useState } from 'react';
2
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
3
4
  import { cleanup, render, screen, fireEvent } from '@testing-library/react';
4
5
  import { Popover } from '../Popover';
@@ -76,6 +77,53 @@ describe('Popover', () => {
76
77
  expect(onClose).toHaveBeenCalledOnce();
77
78
  });
78
79
 
80
+ describe('Escape precedence with a nested Popover', () => {
81
+ it('defers Escape to a nested open Popover instead of closing the outer one', () => {
82
+ const outerOnClose = vi.fn();
83
+
84
+ function NestedHarness() {
85
+ // Starts closed and opens via a later click, matching the real
86
+ // sequence (e.g. a date-picker Popover opened from a field inside a
87
+ // Combobox's own popover content) — mounting both already-open in
88
+ // the same initial render would instead have React fire the child's
89
+ // registration effect before the parent's in that one shared commit,
90
+ // the one case this stack-order approach doesn't handle (see the
91
+ // equivalent Dialog+ConfirmDialog regression test's own note).
92
+ const [innerOpen, setInnerOpen] = useState(false);
93
+ return (
94
+ <Popover open={true} onClose={outerOnClose} aria-label="Outer" trigger={trigger}>
95
+ <button type="button" onClick={() => setInnerOpen(true)}>
96
+ Open inner
97
+ </button>
98
+ <Popover
99
+ open={innerOpen}
100
+ onClose={() => setInnerOpen(false)}
101
+ aria-label="Inner"
102
+ trigger={<span />}
103
+ >
104
+ Inner content
105
+ </Popover>
106
+ </Popover>
107
+ );
108
+ }
109
+
110
+ render(<NestedHarness />);
111
+ fireEvent.click(screen.getByRole('button', { name: 'Open inner' }));
112
+ expect(screen.getByRole('dialog', { name: 'Inner' })).toBeTruthy();
113
+
114
+ // The nested Popover is topmost — the outer Popover's own Escape
115
+ // listener must not act while it's open.
116
+ fireEvent.keyDown(document, { key: 'Escape' });
117
+ expect(outerOnClose).not.toHaveBeenCalled();
118
+ expect(screen.queryByRole('dialog', { name: 'Inner' })).toBeNull();
119
+
120
+ // With the inner one dismissed, the outer Popover is topmost again —
121
+ // a second Escape now reaches it.
122
+ fireEvent.keyDown(document, { key: 'Escape' });
123
+ expect(outerOnClose).toHaveBeenCalledOnce();
124
+ });
125
+ });
126
+
79
127
  it('does not call onClose when clicking inside the panel', () => {
80
128
  const onClose = vi.fn();
81
129
  render(
@@ -95,4 +143,121 @@ describe('Popover', () => {
95
143
  );
96
144
  expect((screen.getByRole('dialog') as HTMLElement).style.width).toBe('320px');
97
145
  });
146
+
147
+ describe('horizontal viewport clamp', () => {
148
+ const rect = (partial: Partial<DOMRect>): DOMRect =>
149
+ ({
150
+ top: 0,
151
+ left: 0,
152
+ right: 0,
153
+ bottom: 0,
154
+ width: 0,
155
+ height: 0,
156
+ x: 0,
157
+ y: 0,
158
+ toJSON() {},
159
+ ...partial,
160
+ }) as DOMRect;
161
+
162
+ afterEach(() => {
163
+ vi.restoreAllMocks();
164
+ });
165
+
166
+ it('shifts the panel back on-screen when align="right" would overflow the left edge', () => {
167
+ // A 340px-wide panel right-aligned under a trigger sitting near the
168
+ // right edge of a 375px viewport (e.g. a mobile header bell) — its
169
+ // natural `right: 0` position runs off the left edge, mirroring the
170
+ // real KanbanNotificationBell overflow found live-testing at 375px.
171
+ Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
172
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
173
+ const containerRect = rect({ left: 275, right: 319, top: 12, bottom: 56 });
174
+ const panelRect = rect({
175
+ left: -21,
176
+ right: 319,
177
+ top: 64,
178
+ bottom: 200,
179
+ width: 340,
180
+ height: 136,
181
+ });
182
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
183
+ this: Element,
184
+ ) {
185
+ return this.getAttribute('role') === 'dialog' ? panelRect : containerRect;
186
+ });
187
+
188
+ render(
189
+ <Popover open={true} onClose={() => {}} aria-label="Menu" width={340} trigger={trigger}>
190
+ Content
191
+ </Popover>,
192
+ );
193
+
194
+ const panel = screen.getByRole('dialog') as HTMLElement;
195
+ // containerRect.left (275) + style.left must land the panel's left
196
+ // edge at the 4px viewport margin: 4 - 275 = -271.
197
+ expect(panel.style.left).toBe('-271px');
198
+ expect(panel.style.right).toBe('auto');
199
+ });
200
+
201
+ it('leaves alignment untouched when the panel already fits the viewport', () => {
202
+ Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
203
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
204
+ const containerRect = rect({ left: 900, right: 944, top: 12, bottom: 56 });
205
+ const panelRect = rect({
206
+ left: 604,
207
+ right: 944,
208
+ top: 64,
209
+ bottom: 200,
210
+ width: 340,
211
+ height: 136,
212
+ });
213
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
214
+ this: Element,
215
+ ) {
216
+ return this.getAttribute('role') === 'dialog' ? panelRect : containerRect;
217
+ });
218
+
219
+ render(
220
+ <Popover open={true} onClose={() => {}} aria-label="Menu" width={340} trigger={trigger}>
221
+ Content
222
+ </Popover>,
223
+ );
224
+
225
+ const panel = screen.getByRole('dialog') as HTMLElement;
226
+ expect(panel.style.left).toBe('');
227
+ expect(panel.style.right).toBe('');
228
+ });
229
+
230
+ it('stays clamped under StrictMode double-invoked layout effects', () => {
231
+ // Regression test: an earlier version of this clamp measured the
232
+ // panel's *current* `left`/`right` off the DOM to decide whether a
233
+ // correction was needed. That's not idempotent — StrictMode's dev-only
234
+ // double-invoke of layout effects re-ran the same measurement against
235
+ // the panel's own just-applied correction, saw a rect that already
236
+ // looked fine, and reset `horizontalOffset` back to null before first
237
+ // paint (found live: the panel visibly overflowed again after HMR).
238
+ // The fix derives the natural position analytically from `containerRect`
239
+ // instead, which StrictMode's replay can't perturb.
240
+ Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
241
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
242
+ const containerRect = rect({ left: 275, right: 319, top: 12, bottom: 56 });
243
+ const panelWidthOnly = rect({ width: 340, height: 136, top: 64, bottom: 200 });
244
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
245
+ this: Element,
246
+ ) {
247
+ return this.getAttribute('role') === 'dialog' ? panelWidthOnly : containerRect;
248
+ });
249
+
250
+ render(
251
+ <StrictMode>
252
+ <Popover open={true} onClose={() => {}} aria-label="Menu" width={340} trigger={trigger}>
253
+ Content
254
+ </Popover>
255
+ </StrictMode>,
256
+ );
257
+
258
+ const panel = screen.getByRole('dialog') as HTMLElement;
259
+ expect(panel.style.left).toBe('-271px');
260
+ expect(panel.style.right).toBe('auto');
261
+ });
262
+ });
98
263
  });
@@ -3,7 +3,7 @@
3
3
  align-items: center;
4
4
  gap: var(--sv-space-1);
5
5
  padding: var(--sv-space-1);
6
- border: 1px solid var(--sv-color-border-strong);
6
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
7
7
  border-radius: var(--sv-radius-full);
8
8
  background: var(--sv-color-surface-sunken);
9
9
  }
@@ -4,7 +4,7 @@
4
4
  width: 100%;
5
5
  height: 100%;
6
6
  min-height: 320px;
7
- border: 1px solid var(--sv-color-border);
7
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border);
8
8
  border-radius: var(--sv-radius-lg);
9
9
  overflow: hidden;
10
10
  background: var(--sv-color-surface);
@@ -27,7 +27,7 @@
27
27
  width: 12px;
28
28
  padding: 0;
29
29
  border: 0;
30
- border-inline: 1px solid var(--sv-color-border);
30
+ border-inline: var(--sv-border-width-hairline) solid var(--sv-color-border);
31
31
  cursor: col-resize;
32
32
  background: var(--sv-color-surface-sunken);
33
33
  }
@@ -36,7 +36,7 @@
36
36
  width: 100%;
37
37
  height: 12px;
38
38
  border-inline: 0;
39
- border-block: 1px solid var(--sv-color-border);
39
+ border-block: var(--sv-border-width-hairline) solid var(--sv-color-border);
40
40
  cursor: row-resize;
41
41
  }
42
42
 
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react';
2
+ import { useResponsiveLayout } from '../../hooks/useResponsiveLayout';
3
+
4
+ export interface ResponsiveSurfaceProps {
5
+ web: ReactNode;
6
+ mobile: ReactNode;
7
+ breakpointPx?: number;
8
+ }
9
+
10
+ /**
11
+ * ResponsiveSurface — thin JSX convenience wrapper around
12
+ * `useResponsiveLayout` for the common two-tree case: render an entirely
13
+ * different component tree below the breakpoint, not a CSS squeeze of the
14
+ * same one. Renders only whichever side is active — the other tree is never
15
+ * mounted.
16
+ *
17
+ * This does not replace a plugin's own shell component (e.g. a
18
+ * `MobileAwareShell` that also threads plugin-specific props like a refresh
19
+ * signal) — those stay a thin per-plugin wrapper around this.
20
+ */
21
+ export function ResponsiveSurface({ web, mobile, breakpointPx }: ResponsiveSurfaceProps) {
22
+ const { value } = useResponsiveLayout({ web, mobile, breakpointPx });
23
+ return <>{value}</>;
24
+ }
@@ -0,0 +1,73 @@
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 { ResponsiveSurface } from '../ResponsiveSurface';
5
+
6
+ let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
7
+ let matches = false;
8
+
9
+ function installMatchMedia() {
10
+ changeHandler = null;
11
+ vi.stubGlobal(
12
+ 'matchMedia',
13
+ vi.fn().mockImplementation((query: string) => ({
14
+ matches,
15
+ media: query,
16
+ addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
17
+ changeHandler = cb;
18
+ },
19
+ removeEventListener: vi.fn(),
20
+ addListener: vi.fn(),
21
+ removeListener: vi.fn(),
22
+ dispatchEvent: vi.fn(),
23
+ })),
24
+ );
25
+ }
26
+
27
+ describe('ResponsiveSurface', () => {
28
+ beforeEach(() => {
29
+ matches = false;
30
+ installMatchMedia();
31
+ });
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals();
34
+ cleanup();
35
+ });
36
+
37
+ it('renders the web tree by default and never mounts the mobile tree', () => {
38
+ render(
39
+ <ResponsiveSurface
40
+ web={<div data-testid="web">web</div>}
41
+ mobile={<div data-testid="mobile">mobile</div>}
42
+ />,
43
+ );
44
+ expect(screen.getByTestId('web')).toBeDefined();
45
+ expect(screen.queryByTestId('mobile')).toBeNull();
46
+ });
47
+
48
+ it('renders only the mobile tree below the breakpoint', () => {
49
+ matches = true;
50
+ installMatchMedia();
51
+ render(
52
+ <ResponsiveSurface
53
+ web={<div data-testid="web">web</div>}
54
+ mobile={<div data-testid="mobile">mobile</div>}
55
+ />,
56
+ );
57
+ expect(screen.getByTestId('mobile')).toBeDefined();
58
+ expect(screen.queryByTestId('web')).toBeNull();
59
+ });
60
+
61
+ it('swaps trees live when the viewport crosses the breakpoint', () => {
62
+ render(
63
+ <ResponsiveSurface
64
+ web={<div data-testid="web">web</div>}
65
+ mobile={<div data-testid="mobile">mobile</div>}
66
+ />,
67
+ );
68
+ expect(screen.getByTestId('web')).toBeDefined();
69
+ act(() => changeHandler?.({ matches: true } as MediaQueryListEvent));
70
+ expect(screen.getByTestId('mobile')).toBeDefined();
71
+ expect(screen.queryByTestId('web')).toBeNull();
72
+ });
73
+ });
@@ -0,0 +1,12 @@
1
+ /* A definite height, not min-height: ThreeColumnLayout/HeaderFooterLayout's
2
+ own .shell use height: 100% internally (fixed box, independently-
3
+ scrolling children via overflow-y: auto) — a percentage height only
4
+ resolves against an ancestor with a definite (not min-constrained)
5
+ height, so min-height here would leave their .shell unable to resolve
6
+ and collapse to content size. This doesn't clip taller content on its
7
+ own — nothing here sets overflow: hidden, so content taller than the
8
+ box simply overflows past it rather than being cut off. */
9
+ .root {
10
+ width: 100%;
11
+ height: 100%;
12
+ }
@@ -0,0 +1,125 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { RootLayout } from './RootLayout';
3
+
4
+ function Block({ label }: { label: string }) {
5
+ return (
6
+ <div
7
+ style={{
8
+ display: 'flex',
9
+ alignItems: 'center',
10
+ justifyContent: 'center',
11
+ height: '100%',
12
+ padding: 'var(--sv-space-4)',
13
+ fontFamily: 'var(--sv-font-family)',
14
+ fontSize: 'var(--sv-font-size-sm)',
15
+ fontWeight: 600,
16
+ color: 'var(--sv-color-text-primary)',
17
+ background: 'var(--sv-color-surface-raised)',
18
+ }}
19
+ >
20
+ {label}
21
+ </div>
22
+ );
23
+ }
24
+
25
+ const meta = {
26
+ title: 'Components/RootLayout',
27
+ component: RootLayout,
28
+ parameters: {
29
+ layout: 'fullscreen',
30
+ docs: {
31
+ description: {
32
+ component:
33
+ "The root-level layout a plugin's page tree sits in. Enforces only structure and dimensions — content always comes from children. Each variant is a fixed web+mobile pairing, not two independent axes; resize the viewport (or check the Mobile story) to see the fork. 'header' keeps the same header+main structure on both breakpoints, but headerHeight (48px, web) and mobileHeaderHeight (60px, mobile) are deliberately different — matching how real shell: minimal plugins do this in production.",
34
+ },
35
+ },
36
+ },
37
+ args: {
38
+ children: <Block label="Main" />,
39
+ },
40
+ } satisfies Meta<typeof RootLayout>;
41
+
42
+ export default meta;
43
+ type Story = StoryObj<typeof meta>;
44
+
45
+ /** variant="plain" is the prop's own default — a single child, no chrome. */
46
+ export const Default: Story = {
47
+ render: () => (
48
+ <div style={{ height: '100vh' }}>
49
+ <RootLayout variant="plain">
50
+ <Block label="Main" />
51
+ </RootLayout>
52
+ </div>
53
+ ),
54
+ };
55
+
56
+ export const Sidebar: Story = {
57
+ render: () => (
58
+ <div style={{ height: '100vh' }}>
59
+ <RootLayout variant="sidebar">
60
+ <Block label="Sidebar" />
61
+ <Block label="Main" />
62
+ </RootLayout>
63
+ </div>
64
+ ),
65
+ };
66
+
67
+ export const SidebarMobile: Story = {
68
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
69
+ render: () => (
70
+ <div style={{ height: '100vh' }}>
71
+ <RootLayout variant="sidebar">
72
+ <Block label="Sidebar (hidden on mobile)" />
73
+ <Block label="Main" />
74
+ </RootLayout>
75
+ </div>
76
+ ),
77
+ };
78
+
79
+ export const Header: Story = {
80
+ render: () => (
81
+ <div style={{ height: '100vh' }}>
82
+ <RootLayout variant="header">
83
+ <Block label="Header (48px)" />
84
+ <Block label="Main" />
85
+ </RootLayout>
86
+ </div>
87
+ ),
88
+ };
89
+
90
+ export const HeaderMobile: Story = {
91
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
92
+ render: () => (
93
+ <div style={{ height: '100vh' }}>
94
+ <RootLayout variant="header">
95
+ <Block label="Header (60px)" />
96
+ <Block label="Main" />
97
+ </RootLayout>
98
+ </div>
99
+ ),
100
+ };
101
+
102
+ export const Shell: Story = {
103
+ render: () => (
104
+ <div style={{ height: '100vh' }}>
105
+ <RootLayout variant="shell">
106
+ <Block label="Header (mobile only)" />
107
+ <Block label="Main" />
108
+ <Block label="Footer (mobile only)" />
109
+ </RootLayout>
110
+ </div>
111
+ ),
112
+ };
113
+
114
+ export const ShellMobile: Story = {
115
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
116
+ render: () => (
117
+ <div style={{ height: '100vh' }}>
118
+ <RootLayout variant="shell">
119
+ <Block label="Header" />
120
+ <Block label="Main" />
121
+ <Block label="Footer" />
122
+ </RootLayout>
123
+ </div>
124
+ ),
125
+ };
@@ -0,0 +1,185 @@
1
+ 'use client';
2
+
3
+ import { Children } from 'react';
4
+ import type { ReactNode } from 'react';
5
+ import { HeaderFooterLayout } from '../HeaderFooterLayout/HeaderFooterLayout';
6
+ import { ResponsiveSurface } from '../ResponsiveSurface/ResponsiveSurface';
7
+ import { ThreeColumnLayout } from '../ThreeColumnLayout/ThreeColumnLayout';
8
+ import styles from './RootLayout.module.css';
9
+
10
+ export type RootLayoutVariant = 'plain' | 'sidebar' | 'header' | 'shell';
11
+
12
+ export interface RootLayoutProps {
13
+ /**
14
+ * Which chrome shape this page uses. Children are positional and their
15
+ * count is fixed per variant:
16
+ * - `'plain'` — 1 child: `[main]`. No chrome on either breakpoint.
17
+ * - `'sidebar'` — 2 children: `[sidebar, main]`. Sidebar + main on web
18
+ * only — on mobile, sidebar is dropped and only main renders.
19
+ * - `'header'` — 2 children: `[header, main]`. Header + main on both
20
+ * breakpoints — same structure, but the header's own height differs
21
+ * per breakpoint (`headerHeight` on web, `mobileHeaderHeight` on
22
+ * mobile), matching how real `shell: minimal` plugins actually do
23
+ * this (a compact web top bar vs. the platform's mobile chrome
24
+ * height).
25
+ * - `'shell'` — 3 children: `[header, main, footer]`. Header + main +
26
+ * footer on mobile only — on web, header/footer are dropped and only
27
+ * main renders.
28
+ * Default `'plain'`.
29
+ */
30
+ variant?: RootLayoutVariant;
31
+ children: ReactNode;
32
+ /** px. Default 64 — matches the platform's own persistent nav rail
33
+ * (`--sv-shell-sidebar-width`, runtime/app/(platform)/shell.module.css).
34
+ * Only used by `'sidebar'`. */
35
+ sidebarWidth?: number;
36
+ /** px. Default 48 — matches a real `shell: minimal` plugin's own compact
37
+ * web header (e.g. Kanban's `KanbanHeader`, deliberately not the
38
+ * platform's 60px shell header since a `shell: minimal` plugin owns its
39
+ * whole viewport). Web only — used by `'header'` on web. */
40
+ headerHeight?: number;
41
+ /** px. Default 60 — matches the platform's own mobile chrome
42
+ * (`--sv-shell-header-height`, `MobileHeader`'s default). Mobile only —
43
+ * used by `'header'` and `'shell'` on mobile. */
44
+ mobileHeaderHeight?: number;
45
+ /** px. Default 60 — matches the platform's own mobile chrome
46
+ * (`--sv-shell-footer-height`, `MobileFooter`'s default). Mobile only —
47
+ * used by `'shell'`. */
48
+ footerHeight?: number;
49
+ className?: string;
50
+ }
51
+
52
+ const EXPECTED_CHILD_COUNT: Record<RootLayoutVariant, number> = {
53
+ plain: 1,
54
+ sidebar: 2,
55
+ header: 2,
56
+ shell: 3,
57
+ };
58
+
59
+ interface RootLayoutWebProps {
60
+ variant: RootLayoutVariant;
61
+ items: ReactNode[];
62
+ sidebarWidth: number;
63
+ headerHeight: number;
64
+ }
65
+
66
+ // internal — not exported
67
+ function RootLayoutWeb({ variant, items, sidebarWidth, headerHeight }: RootLayoutWebProps) {
68
+ if (variant === 'sidebar') {
69
+ const [sidebar, main] = items;
70
+ return (
71
+ <ThreeColumnLayout sidebarWidth={sidebarWidth}>
72
+ {sidebar}
73
+ {main}
74
+ </ThreeColumnLayout>
75
+ );
76
+ }
77
+ if (variant === 'header') {
78
+ const [header, main] = items;
79
+ return (
80
+ <HeaderFooterLayout header={header} headerHeight={headerHeight}>
81
+ {main}
82
+ </HeaderFooterLayout>
83
+ );
84
+ }
85
+ // 'plain' and 'shell' both render plain on web — shell's chrome is mobile-only.
86
+ const main = variant === 'shell' ? items[1] : items[0];
87
+ return <>{main}</>;
88
+ }
89
+
90
+ interface RootLayoutMobileProps {
91
+ variant: RootLayoutVariant;
92
+ items: ReactNode[];
93
+ mobileHeaderHeight: number;
94
+ footerHeight: number;
95
+ }
96
+
97
+ // internal — not exported
98
+ function RootLayoutMobile({
99
+ variant,
100
+ items,
101
+ mobileHeaderHeight,
102
+ footerHeight,
103
+ }: RootLayoutMobileProps) {
104
+ if (variant === 'header') {
105
+ const [header, main] = items;
106
+ return (
107
+ <HeaderFooterLayout header={header} headerHeight={mobileHeaderHeight}>
108
+ {main}
109
+ </HeaderFooterLayout>
110
+ );
111
+ }
112
+ if (variant === 'shell') {
113
+ const [header, main, footer] = items;
114
+ return (
115
+ <HeaderFooterLayout
116
+ header={header}
117
+ footer={footer}
118
+ headerHeight={mobileHeaderHeight}
119
+ footerHeight={footerHeight}
120
+ >
121
+ {main}
122
+ </HeaderFooterLayout>
123
+ );
124
+ }
125
+ // 'plain' and 'sidebar' both render plain on mobile — sidebar's chrome is web-only.
126
+ const main = variant === 'sidebar' ? items[1] : items[0];
127
+ return <>{main}</>;
128
+ }
129
+
130
+ /**
131
+ * RootLayout — the root-level layout a plugin's page tree sits in. Enforces
132
+ * only structure and dimensions (`width: 100%; height: 100%`, filling
133
+ * whatever real height the platform shell's own ancestor chain already
134
+ * establishes — not viewport units, since a plugin isn't alone on the
135
+ * page). A definite height, not min-height: ThreeColumnLayout/
136
+ * HeaderFooterLayout's own .shell need one to resolve their own height:
137
+ * 100% against (see RootLayout.module.css's comment). All content comes
138
+ * from children; RootLayout has no opinion on what's inside any region.
139
+ *
140
+ * The web/mobile fork is `ResponsiveSurface` — only one of
141
+ * `RootLayoutWeb`/`RootLayoutMobile` is ever mounted, never a CSS squeeze of
142
+ * the same tree. Each variant's actual shape is delegated entirely to
143
+ * `ThreeColumnLayout`/`HeaderFooterLayout`; RootLayout itself contains no
144
+ * media queries.
145
+ */
146
+ export function RootLayout({
147
+ variant = 'plain',
148
+ children,
149
+ sidebarWidth = 64,
150
+ headerHeight = 48,
151
+ mobileHeaderHeight = 60,
152
+ footerHeight = 60,
153
+ className,
154
+ }: RootLayoutProps) {
155
+ const items = Children.toArray(children);
156
+
157
+ if (process.env.NODE_ENV !== 'production' && items.length !== EXPECTED_CHILD_COUNT[variant]) {
158
+ console.warn(
159
+ `[RootLayout] variant="${variant}" expects ${EXPECTED_CHILD_COUNT[variant]} children; received ${items.length}.`,
160
+ );
161
+ }
162
+
163
+ return (
164
+ <div className={[styles.root, className].filter(Boolean).join(' ')}>
165
+ <ResponsiveSurface
166
+ web={
167
+ <RootLayoutWeb
168
+ variant={variant}
169
+ items={items}
170
+ sidebarWidth={sidebarWidth}
171
+ headerHeight={headerHeight}
172
+ />
173
+ }
174
+ mobile={
175
+ <RootLayoutMobile
176
+ variant={variant}
177
+ items={items}
178
+ mobileHeaderHeight={mobileHeaderHeight}
179
+ footerHeight={footerHeight}
180
+ />
181
+ }
182
+ />
183
+ </div>
184
+ );
185
+ }