@sovereignfs/ui 0.54.0 → 0.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/dist/Accordion.module.css +1 -1
  2. package/dist/Alert.module.css +1 -1
  3. package/dist/AppsLauncher.module.css +96 -0
  4. package/dist/Avatar.module.css +1 -1
  5. package/dist/Badge.module.css +30 -7
  6. package/dist/BalanceChip.module.css +1 -1
  7. package/dist/Button.module.css +18 -2
  8. package/dist/Card.module.css +1 -1
  9. package/dist/CardTile.module.css +181 -0
  10. package/dist/CodeTextarea.module.css +1 -1
  11. package/dist/Collapsible.module.css +1 -1
  12. package/dist/ColorPicker.module.css +132 -0
  13. package/dist/Combobox.module.css +2 -2
  14. package/dist/Command.module.css +1 -1
  15. package/dist/ConfirmDialog.module.css +2 -2
  16. package/dist/ConsentPrompt.module.css +21 -0
  17. package/dist/DatePicker.module.css +1 -1
  18. package/dist/Dialog.module.css +130 -21
  19. package/dist/Drawer.module.css +36 -7
  20. package/dist/Header.module.css +62 -0
  21. package/dist/HeaderFooterLayout.module.css +21 -0
  22. package/dist/IconPicker.module.css +1 -1
  23. package/dist/Input.module.css +1 -1
  24. package/dist/Kbd.module.css +1 -1
  25. package/dist/Markdown.module.css +24 -0
  26. package/dist/Menu.module.css +1 -0
  27. package/dist/Message.module.css +1 -1
  28. package/dist/MessageScroller.module.css +12 -1
  29. package/dist/MobileAppsDrawer.module.css +10 -9
  30. package/dist/MobileFooter.module.css +1 -1
  31. package/dist/MobileHeader.module.css +14 -1
  32. package/dist/NavList.module.css +130 -0
  33. package/dist/NavTabs.module.css +1 -1
  34. package/dist/NotificationsPanel.module.css +258 -0
  35. package/dist/OverlayHeader.module.css +1 -1
  36. package/dist/PageContainer.module.css +62 -0
  37. package/dist/PageHeader.module.css +54 -0
  38. package/dist/PageLayout.module.css +51 -0
  39. package/dist/Popover.module.css +1 -1
  40. package/dist/QuantityStepper.module.css +1 -1
  41. package/dist/Resizable.module.css +3 -3
  42. package/dist/RootLayout.module.css +12 -0
  43. package/dist/SegmentedControl.module.css +1 -1
  44. package/dist/Select.module.css +1 -1
  45. package/dist/Sheet.module.css +4 -1
  46. package/dist/SplitPane.module.css +4 -4
  47. package/dist/StatusBadge.module.css +1 -1
  48. package/dist/StepStrip.module.css +53 -0
  49. package/dist/SuggestionInput.module.css +2 -2
  50. package/dist/SwipableMobileCarousel.module.css +12 -3
  51. package/dist/SwipableMobileCarouselDots.module.css +15 -0
  52. package/dist/SwipeStack.module.css +101 -0
  53. package/dist/SystemBanner.module.css +10 -0
  54. package/dist/Table.module.css +2 -2
  55. package/dist/Tabs.module.css +1 -1
  56. package/dist/TagInput.module.css +2 -2
  57. package/dist/Textarea.module.css +9 -1
  58. package/dist/ThreeColumnLayout.module.css +35 -0
  59. package/dist/Toast.module.css +62 -3
  60. package/dist/Tooltip.module.css +1 -1
  61. package/dist/UserMenu.module.css +90 -0
  62. package/dist/index.d.ts +1047 -45
  63. package/dist/index.js +3908 -1456
  64. package/dist/tokens/primitives.css +11 -0
  65. package/dist/tokens/semantic.css +32 -0
  66. package/dist/tokens/theme-presets.css +33 -0
  67. package/package.json +25 -9
  68. package/src/__tests__/package-json.test.ts +8 -0
  69. package/src/components/Accordion/Accordion.module.css +1 -1
  70. package/src/components/Alert/Alert.module.css +1 -1
  71. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  72. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  73. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  74. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  75. package/src/components/Avatar/Avatar.module.css +1 -1
  76. package/src/components/Badge/Badge.module.css +30 -7
  77. package/src/components/Badge/Badge.stories.tsx +16 -4
  78. package/src/components/Badge/Badge.tsx +7 -4
  79. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  80. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  81. package/src/components/Button/Button.module.css +18 -2
  82. package/src/components/Card/Card.module.css +1 -1
  83. package/src/components/CardTile/CardTile.module.css +181 -0
  84. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  85. package/src/components/CardTile/CardTile.tsx +128 -0
  86. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  87. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  88. package/src/components/Collapsible/Collapsible.module.css +1 -1
  89. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  90. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  91. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  92. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  93. package/src/components/Combobox/Combobox.module.css +2 -2
  94. package/src/components/Command/Command.module.css +1 -1
  95. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  96. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  97. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  98. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  99. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  100. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  101. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  102. package/src/components/DatePicker/DatePicker.module.css +1 -1
  103. package/src/components/Dialog/Dialog.module.css +130 -21
  104. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  105. package/src/components/Dialog/Dialog.tsx +129 -35
  106. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  107. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  108. package/src/components/Drawer/Drawer.module.css +36 -7
  109. package/src/components/Drawer/Drawer.tsx +9 -10
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +33 -6
  111. package/src/components/GripIcon/GripIcon.tsx +27 -0
  112. package/src/components/Header/Header.module.css +62 -0
  113. package/src/components/Header/Header.stories.tsx +452 -0
  114. package/src/components/Header/Header.tsx +102 -0
  115. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  116. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  117. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  118. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  119. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  120. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  121. package/src/components/Icon/icons/bold.tsx +20 -0
  122. package/src/components/Icon/icons/calendar.tsx +4 -4
  123. package/src/components/Icon/icons/carrot.tsx +3 -3
  124. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  125. package/src/components/Icon/icons/code.tsx +21 -0
  126. package/src/components/Icon/icons/copy.tsx +21 -0
  127. package/src/components/Icon/icons/credit-card.tsx +21 -0
  128. package/src/components/Icon/icons/database.tsx +22 -0
  129. package/src/components/Icon/icons/download.tsx +22 -0
  130. package/src/components/Icon/icons/file-text.tsx +24 -0
  131. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  132. package/src/components/Icon/icons/folder-open.tsx +20 -0
  133. package/src/components/Icon/icons/folder.tsx +20 -0
  134. package/src/components/Icon/icons/folders.tsx +21 -0
  135. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  136. package/src/components/Icon/icons/history.tsx +22 -0
  137. package/src/components/Icon/icons/inbox.tsx +21 -0
  138. package/src/components/Icon/icons/index.ts +161 -1
  139. package/src/components/Icon/icons/italic.tsx +22 -0
  140. package/src/components/Icon/icons/layers.tsx +22 -0
  141. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  142. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  143. package/src/components/Icon/icons/link.tsx +21 -0
  144. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  145. package/src/components/Icon/icons/list.tsx +25 -0
  146. package/src/components/Icon/icons/luggage.tsx +24 -0
  147. package/src/components/Icon/icons/map-pin.tsx +21 -0
  148. package/src/components/Icon/icons/minus.tsx +20 -0
  149. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  150. package/src/components/Icon/icons/panel-left.tsx +21 -0
  151. package/src/components/Icon/icons/pin.tsx +21 -0
  152. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  153. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  154. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  155. package/src/components/Icon/icons/route.tsx +22 -0
  156. package/src/components/Icon/icons/send.tsx +21 -0
  157. package/src/components/Icon/icons/share-2.tsx +24 -0
  158. package/src/components/Icon/icons/sheet.tsx +24 -0
  159. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  160. package/src/components/Icon/icons/table.tsx +23 -0
  161. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  162. package/src/components/Icon/icons/users.tsx +23 -0
  163. package/src/components/IconPicker/IconPicker.module.css +1 -1
  164. package/src/components/Input/Input.module.css +1 -1
  165. package/src/components/Input/Input.tsx +8 -2
  166. package/src/components/Kbd/Kbd.module.css +1 -1
  167. package/src/components/Markdown/Markdown.module.css +24 -0
  168. package/src/components/Markdown/Markdown.stories.tsx +10 -0
  169. package/src/components/Markdown/Markdown.tsx +166 -29
  170. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  171. package/src/components/Menu/Menu.module.css +1 -0
  172. package/src/components/Menu/Menu.tsx +72 -22
  173. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  174. package/src/components/Menubar/Menubar.tsx +1 -1
  175. package/src/components/Message/Message.module.css +1 -1
  176. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  177. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  178. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +10 -9
  179. package/src/components/MobileFooter/MobileFooter.module.css +1 -1
  180. package/src/components/MobileFooter/MobileFooter.tsx +15 -2
  181. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +58 -0
  182. package/src/components/MobileHeader/MobileHeader.module.css +14 -1
  183. package/src/components/MobileHeader/MobileHeader.tsx +15 -2
  184. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +56 -1
  185. package/src/components/NavList/NavList.module.css +130 -0
  186. package/src/components/NavList/NavList.tsx +136 -0
  187. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  188. package/src/components/NavTabs/NavTabs.module.css +1 -1
  189. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  190. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  191. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  192. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  193. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  194. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  195. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  196. package/src/components/PageContainer/PageContainer.module.css +62 -0
  197. package/src/components/PageContainer/PageContainer.tsx +37 -4
  198. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +41 -8
  199. package/src/components/PageHeader/PageHeader.module.css +54 -0
  200. package/src/components/PageHeader/PageHeader.tsx +146 -5
  201. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  202. package/src/components/PageLayout/PageLayout.module.css +51 -0
  203. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  204. package/src/components/PageLayout/PageLayout.tsx +51 -0
  205. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  206. package/src/components/Popover/Popover.module.css +1 -1
  207. package/src/components/Popover/Popover.tsx +86 -4
  208. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  209. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  210. package/src/components/Resizable/Resizable.module.css +3 -3
  211. package/src/components/RootLayout/RootLayout.module.css +12 -0
  212. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  213. package/src/components/RootLayout/RootLayout.tsx +185 -0
  214. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  215. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  216. package/src/components/Select/Select.module.css +1 -1
  217. package/src/components/Sheet/Sheet.module.css +4 -1
  218. package/src/components/Sheet/Sheet.tsx +5 -5
  219. package/src/components/SplitPane/SplitPane.module.css +4 -4
  220. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  221. package/src/components/StepStrip/StepStrip.module.css +53 -0
  222. package/src/components/StepStrip/StepStrip.tsx +74 -0
  223. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  224. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +12 -3
  225. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +25 -9
  226. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +81 -0
  227. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +15 -0
  228. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +12 -1
  229. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  230. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  231. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  232. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  233. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  234. package/src/components/Table/Table.module.css +2 -2
  235. package/src/components/Tabs/Tabs.module.css +1 -1
  236. package/src/components/TagInput/TagInput.module.css +2 -2
  237. package/src/components/Textarea/Textarea.module.css +9 -1
  238. package/src/components/Textarea/Textarea.tsx +28 -5
  239. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  240. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  241. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  242. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  243. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  244. package/src/components/Toast/Toast.module.css +62 -3
  245. package/src/components/Toast/Toast.stories.tsx +1 -1
  246. package/src/components/Tooltip/Tooltip.module.css +1 -1
  247. package/src/components/UserMenu/UserMenu.module.css +90 -0
  248. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  249. package/src/components/UserMenu/UserMenu.tsx +132 -0
  250. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  251. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  252. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  253. package/src/hooks/__tests__/useSnapCarousel.test.tsx +82 -0
  254. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  255. package/src/hooks/index.ts +9 -0
  256. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  257. package/src/hooks/useReorderSensors.ts +88 -0
  258. package/src/hooks/useSnapCarousel.ts +211 -6
  259. package/src/hooks/useSwipeStack.ts +235 -0
  260. package/src/index.ts +61 -1
  261. package/src/overlay-shell.ts +65 -0
  262. package/src/stories/DesignSystemOverview.stories.tsx +617 -11
  263. package/src/stories/GripIcon.stories.tsx +29 -0
  264. package/src/stories/LayoutSystem.stories.tsx +639 -0
  265. package/src/stories/MenuEntries.stories.tsx +86 -0
  266. package/src/stories/NavList.stories.tsx +147 -0
  267. package/src/stories/PageContainer.stories.tsx +52 -7
  268. package/src/stories/PageHeader.stories.tsx +54 -1
  269. package/src/stories/StepStrip.stories.tsx +76 -0
  270. package/src/stories/SwipableMobileCarousel.stories.tsx +58 -0
  271. package/src/stories/SwipeStack.stories.tsx +200 -0
  272. package/src/stories/Textarea.stories.tsx +12 -0
  273. package/src/stories/TokenGallery.stories.tsx +50 -0
  274. package/src/tokens/primitives.css +11 -0
  275. package/src/tokens/semantic.css +32 -0
  276. package/src/tokens/theme-presets.css +33 -0
  277. package/src/tokens/theme-presets.ts +72 -0
@@ -1,8 +1,20 @@
1
1
  .container {
2
2
  width: 100%;
3
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;
4
11
  }
5
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
+
6
18
  .sm {
7
19
  max-width: 640px;
8
20
  }
@@ -18,3 +30,53 @@
18
30
  .full {
19
31
  max-width: none;
20
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
+ }
@@ -2,9 +2,23 @@ import type { HTMLAttributes, ReactNode } from 'react';
2
2
  import styles from './PageContainer.module.css';
3
3
 
4
4
  export interface PageContainerProps extends HTMLAttributes<HTMLDivElement> {
5
- /** Constrains and centers content width. Adds no padding of its own — the
6
- * runtime shell already pads plugin content. Defaults to 'md'. */
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
+ */
7
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';
8
22
  children: ReactNode;
9
23
  }
10
24
 
@@ -15,13 +29,32 @@ const maxWidthClass: Record<NonNullable<PageContainerProps['maxWidth']>, string>
15
29
  full: styles.full as string,
16
30
  };
17
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
+ */
18
48
  export function PageContainer({
19
- maxWidth = 'md',
49
+ maxWidth = 'full',
50
+ padding = 'md',
20
51
  className,
21
52
  children,
22
53
  ...rest
23
54
  }: PageContainerProps) {
24
- const cls = [styles.container, maxWidthClass[maxWidth], className].filter(Boolean).join(' ');
55
+ const cls = [styles.container, maxWidthClass[maxWidth], paddingClass[padding], className]
56
+ .filter(Boolean)
57
+ .join(' ');
25
58
 
26
59
  return (
27
60
  <div className={cls} {...rest}>
@@ -5,24 +5,57 @@ import { PageContainer } from '../PageContainer';
5
5
 
6
6
  afterEach(cleanup);
7
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
+
8
15
  describe('PageContainer', () => {
9
16
  it('renders its children', () => {
10
17
  render(<PageContainer>content</PageContainer>);
11
18
  expect(screen.getByText('content')).toBeDefined();
12
19
  });
13
20
 
14
- it('defaults to md max-width', () => {
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', () => {
15
27
  render(<PageContainer>content</PageContainer>);
16
- expect(screen.getByText('content').className).toContain('md');
28
+ expect(classes(screen.getByText('content'))).toContain('padMd');
17
29
  });
18
30
 
19
- it('applies sm, lg, and full max-width classes', () => {
31
+ it('applies sm, md, and lg max-width classes', () => {
20
32
  const { rerender } = render(<PageContainer maxWidth="sm">content</PageContainer>);
21
- expect(screen.getByText('content').className).toContain('sm');
33
+ expect(classes(screen.getByText('content'))).toContain('sm');
34
+ rerender(<PageContainer maxWidth="md">content</PageContainer>);
35
+ expect(classes(screen.getByText('content'))).toContain('md');
22
36
  rerender(<PageContainer maxWidth="lg">content</PageContainer>);
23
- expect(screen.getByText('content').className).toContain('lg');
24
- rerender(<PageContainer maxWidth="full">content</PageContainer>);
25
- expect(screen.getByText('content').className).toContain('full');
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');
26
59
  });
27
60
 
28
61
  it('forwards className and other div props', () => {
@@ -32,6 +65,6 @@ describe('PageContainer', () => {
32
65
  </PageContainer>,
33
66
  );
34
67
  const el = screen.getByTestId('container');
35
- expect(el.className).toContain('custom');
68
+ expect(classes(el)).toContain('custom');
36
69
  });
37
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
+ }
@@ -1,35 +1,176 @@
1
+ 'use client';
2
+
1
3
  import type { ReactNode } from 'react';
4
+ import { Button } from '../Button/Button';
5
+ import { Icon } from '../Icon/Icon';
6
+ import type { IconName } from '../Icon/Icon';
7
+ import { ResponsiveSurface } from '../ResponsiveSurface/ResponsiveSurface';
2
8
  import styles from './PageHeader.module.css';
3
9
 
4
10
  export interface PageHeaderProps {
5
11
  title: string;
6
12
  description?: string;
13
+ /** Optional icon rendered inline before the title text, on both breakpoints — e.g. `folder-open` next to a folder's name. */
14
+ icon?: IconName;
15
+ /**
16
+ * Page-level controls — anywhere from one button to a whole toolbar.
17
+ * Renders on both breakpoints unchanged; if it needs to look different on
18
+ * mobile (e.g. collapsing into its own "⋮" trigger), that's the action
19
+ * content's own responsibility — same pattern Shopper's `ListHeaderActions`
20
+ * already follows (it calls `useIsMobile()` internally). `PageHeader`
21
+ * itself has no opinion on what's inside this slot.
22
+ */
7
23
  action?: ReactNode;
8
24
  className?: string;
9
25
  /** Heading level for the title. Defaults to `1` for standalone use; pass
10
26
  * `2` or `3` when the page already sits under a shell/plugin `<h1>`, to
11
27
  * avoid a duplicate top-level heading. Visual style is unchanged at every
12
- * level — only the rendered tag changes. */
28
+ * level — only the rendered tag changes. Applies on both breakpoints. */
13
29
  headingLevel?: 1 | 2 | 3;
30
+ /** Mobile only — shows a back button (calling this) to the left of the
31
+ * title when provided. Omit it to render without one. */
32
+ onBack?: () => void;
33
+ /** Mobile only — shows a vertical-ellipsis icon button (calling this) at
34
+ * the far right when provided, typically to open a drawer/menu. PageHeader
35
+ * has no opinion on what that menu contains — it only renders the trigger
36
+ * and calls back. Separate from `action`, which renders identically on
37
+ * both breakpoints; this is a mobile-only affordance with no web
38
+ * equivalent. */
39
+ onMenuClick?: () => void;
14
40
  }
15
41
 
16
42
  const HEADING_TAG = { 1: 'h1', 2: 'h2', 3: 'h3' } as const;
17
43
 
18
- export function PageHeader({
44
+ interface PageHeaderWebProps {
45
+ title: string;
46
+ description?: string;
47
+ icon?: IconName;
48
+ action?: ReactNode;
49
+ headingLevel: 1 | 2 | 3;
50
+ className?: string;
51
+ }
52
+
53
+ // internal — not exported
54
+ function PageHeaderWeb({
19
55
  title,
20
56
  description,
57
+ icon,
21
58
  action,
59
+ headingLevel,
22
60
  className,
23
- headingLevel = 1,
24
- }: PageHeaderProps) {
61
+ }: PageHeaderWebProps) {
25
62
  const Heading = HEADING_TAG[headingLevel];
26
63
  return (
27
64
  <header className={[styles.header, className].filter(Boolean).join(' ')}>
28
65
  <div className={styles.text}>
29
- <Heading className={styles.title}>{title}</Heading>
66
+ <div className={styles.titleRow}>
67
+ {icon && <Icon name={icon} size="lg" aria-hidden={true} />}
68
+ <Heading className={styles.title}>{title}</Heading>
69
+ </div>
30
70
  {description && <p className={styles.description}>{description}</p>}
31
71
  </div>
32
72
  {action && <div className={styles.action}>{action}</div>}
33
73
  </header>
34
74
  );
35
75
  }
76
+
77
+ interface PageHeaderMobileProps {
78
+ title: string;
79
+ icon?: IconName;
80
+ action?: ReactNode;
81
+ headingLevel: 1 | 2 | 3;
82
+ onBack?: () => void;
83
+ onMenuClick?: () => void;
84
+ className?: string;
85
+ }
86
+
87
+ // internal — not exported
88
+ // No `description` here, deliberately — matches the one real mobile
89
+ // page-header precedent this was generalized from (Kanban's own
90
+ // MobileBoardHeader), which has no subtitle handling either. A single
91
+ // fixed-shape row, consistent with every other mobile chrome bar in this
92
+ // design system rather than a variable-height two-line header.
93
+ function PageHeaderMobile({
94
+ title,
95
+ icon,
96
+ action,
97
+ headingLevel,
98
+ onBack,
99
+ onMenuClick,
100
+ className,
101
+ }: PageHeaderMobileProps) {
102
+ const Heading = HEADING_TAG[headingLevel];
103
+ return (
104
+ <header className={[styles.mobileHeader, className].filter(Boolean).join(' ')}>
105
+ {onBack && (
106
+ <Button
107
+ variant="ghost"
108
+ size="sm"
109
+ aria-label="Back"
110
+ onClick={onBack}
111
+ className={styles.mobileBack}
112
+ >
113
+ <Icon name="circle-chevron-left" size="md" aria-hidden />
114
+ </Button>
115
+ )}
116
+ {icon && <Icon name={icon} size="md" aria-hidden={true} className={styles.mobileTitleIcon} />}
117
+ <Heading className={styles.mobileTitle}>{title}</Heading>
118
+ {action && <div className={styles.mobileAction}>{action}</div>}
119
+ {onMenuClick && (
120
+ <Button
121
+ variant="ghost"
122
+ size="sm"
123
+ aria-label="Menu"
124
+ onClick={onMenuClick}
125
+ className={styles.mobileMenu}
126
+ >
127
+ <Icon name="ellipsis-vertical" size="md" aria-hidden />
128
+ </Button>
129
+ )}
130
+ </header>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * PageHeader — a page's title section. Web: title + description on the
136
+ * left, action on the right. Mobile: an optional back button, a single-line
137
+ * title, and the same action slot — a compact single row, matching Kanban's
138
+ * own hand-rolled MobileBoardHeader this was generalized from. The
139
+ * web/mobile fork is `ResponsiveSurface` — only one tree is ever mounted.
140
+ */
141
+ export function PageHeader({
142
+ title,
143
+ description,
144
+ icon,
145
+ action,
146
+ className,
147
+ headingLevel = 1,
148
+ onBack,
149
+ onMenuClick,
150
+ }: PageHeaderProps) {
151
+ return (
152
+ <ResponsiveSurface
153
+ web={
154
+ <PageHeaderWeb
155
+ title={title}
156
+ description={description}
157
+ icon={icon}
158
+ action={action}
159
+ headingLevel={headingLevel}
160
+ className={className}
161
+ />
162
+ }
163
+ mobile={
164
+ <PageHeaderMobile
165
+ title={title}
166
+ icon={icon}
167
+ action={action}
168
+ headingLevel={headingLevel}
169
+ onBack={onBack}
170
+ onMenuClick={onMenuClick}
171
+ className={className}
172
+ />
173
+ }
174
+ />
175
+ );
176
+ }
@@ -0,0 +1,103 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { PageHeader } from '../PageHeader';
5
+
6
+ let matches = false;
7
+
8
+ function installMatchMedia() {
9
+ vi.stubGlobal(
10
+ 'matchMedia',
11
+ vi.fn().mockImplementation((query: string) => ({
12
+ matches,
13
+ media: query,
14
+ addEventListener: vi.fn(),
15
+ removeEventListener: vi.fn(),
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ dispatchEvent: vi.fn(),
19
+ })),
20
+ );
21
+ }
22
+
23
+ describe('PageHeader', () => {
24
+ beforeEach(() => {
25
+ matches = false;
26
+ installMatchMedia();
27
+ });
28
+ afterEach(() => {
29
+ vi.unstubAllGlobals();
30
+ cleanup();
31
+ });
32
+
33
+ it('web: renders title, description, and action', () => {
34
+ render(
35
+ <PageHeader
36
+ title="Users"
37
+ description="Manage who has access to this instance."
38
+ action={<button type="button">Invite user</button>}
39
+ />,
40
+ );
41
+
42
+ expect(screen.getByRole('heading', { name: 'Users' })).toBeDefined();
43
+ expect(screen.getByText('Manage who has access to this instance.')).toBeDefined();
44
+ expect(screen.getByRole('button', { name: 'Invite user' })).toBeDefined();
45
+ });
46
+
47
+ it('web: renders no back button even when onBack is given', () => {
48
+ render(<PageHeader title="Users" onBack={() => undefined} />);
49
+
50
+ expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
51
+ });
52
+
53
+ it('mobile: renders no description, no back button, no menu button by default', () => {
54
+ matches = true;
55
+ installMatchMedia();
56
+
57
+ render(<PageHeader title="Users" description="Manage who has access to this instance." />);
58
+
59
+ expect(screen.getByRole('heading', { name: 'Users' })).toBeDefined();
60
+ expect(screen.queryByText('Manage who has access to this instance.')).toBeNull();
61
+ expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
62
+ expect(screen.queryByRole('button', { name: 'Menu' })).toBeNull();
63
+ });
64
+
65
+ it('mobile: renders a back button that calls onBack when given', () => {
66
+ matches = true;
67
+ installMatchMedia();
68
+ const onBack = vi.fn();
69
+
70
+ render(<PageHeader title="Users" onBack={onBack} />);
71
+
72
+ const back = screen.getByRole('button', { name: 'Back' });
73
+ fireEvent.click(back);
74
+ expect(onBack).toHaveBeenCalledOnce();
75
+ });
76
+
77
+ it('mobile: renders a menu button that calls onMenuClick when given', () => {
78
+ matches = true;
79
+ installMatchMedia();
80
+ const onMenuClick = vi.fn();
81
+
82
+ render(<PageHeader title="Users" onMenuClick={onMenuClick} />);
83
+
84
+ const menu = screen.getByRole('button', { name: 'Menu' });
85
+ fireEvent.click(menu);
86
+ expect(onMenuClick).toHaveBeenCalledOnce();
87
+ });
88
+
89
+ it('web: renders no menu button even when onMenuClick is given', () => {
90
+ render(<PageHeader title="Users" onMenuClick={() => undefined} />);
91
+
92
+ expect(screen.queryByRole('button', { name: 'Menu' })).toBeNull();
93
+ });
94
+
95
+ it('mobile: renders the same action content as web', () => {
96
+ matches = true;
97
+ installMatchMedia();
98
+
99
+ render(<PageHeader title="Users" action={<button type="button">Invite user</button>} />);
100
+
101
+ expect(screen.getByRole('button', { name: 'Invite user' })).toBeDefined();
102
+ });
103
+ });
@@ -0,0 +1,51 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ min-width: 100%;
5
+ min-height: 100%;
6
+ }
7
+
8
+ .header {
9
+ flex-shrink: 0;
10
+ }
11
+
12
+ .content {
13
+ flex: 1;
14
+ }
15
+
16
+ /* ── Padding ────────────────────────────────────────────────────────────── */
17
+ /* Same four-step scale and --sv-page-gutter stand-down hook as
18
+ PageContainer (see that component's own CSS comment for the full
19
+ rationale) — kept identical so this can absorb PageContainer's role
20
+ later without a behavior change for existing consumers. */
21
+
22
+ .padNone {
23
+ padding: 0;
24
+ }
25
+
26
+ .padSm {
27
+ padding: var(--sv-page-gutter, var(--sv-space-4));
28
+ }
29
+
30
+ .padMd {
31
+ padding: var(--sv-page-gutter, var(--sv-space-8));
32
+ }
33
+
34
+ .padLg {
35
+ padding: var(--sv-page-gutter, var(--sv-space-12));
36
+ }
37
+
38
+ /* Matches the breakpoint Dialog, MobileHeader, and MobileFooter already use. */
39
+ @media (max-width: 768px) {
40
+ .padSm {
41
+ padding: var(--sv-page-gutter, var(--sv-space-3));
42
+ }
43
+
44
+ .padMd {
45
+ padding: var(--sv-page-gutter, var(--sv-space-4));
46
+ }
47
+
48
+ .padLg {
49
+ padding: var(--sv-page-gutter, var(--sv-space-6));
50
+ }
51
+ }
@@ -0,0 +1,73 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { PageLayout } from './PageLayout';
3
+
4
+ function Content() {
5
+ return (
6
+ <div
7
+ style={{
8
+ fontFamily: 'var(--sv-font-family)',
9
+ fontSize: 'var(--sv-font-size-sm)',
10
+ color: 'var(--sv-color-text-primary)',
11
+ }}
12
+ >
13
+ {Array.from({ length: 6 }, (_, index) => (
14
+ <p key={index}>Page content row {index + 1}.</p>
15
+ ))}
16
+ </div>
17
+ );
18
+ }
19
+
20
+ function PageHeader() {
21
+ return (
22
+ <div
23
+ style={{
24
+ display: 'flex',
25
+ alignItems: 'center',
26
+ justifyContent: 'space-between',
27
+ padding: 'var(--sv-space-3) var(--sv-space-4)',
28
+ borderBottom: '1px solid var(--sv-color-border)',
29
+ fontFamily: 'var(--sv-font-family)',
30
+ fontSize: 'var(--sv-font-size-md)',
31
+ fontWeight: 600,
32
+ color: 'var(--sv-color-text-primary)',
33
+ }}
34
+ >
35
+ Sv Wallet
36
+ </div>
37
+ );
38
+ }
39
+
40
+ const meta = {
41
+ title: 'Components/PageLayout',
42
+ component: PageLayout,
43
+ parameters: {
44
+ layout: 'padded',
45
+ docs: {
46
+ description: {
47
+ component:
48
+ "A single page's content area, nested inside RootLayout's main slot — not a replacement for RootLayout. No padding by default; opt in via the padding prop (same scale as PageContainer). The optional header is page-specific (e.g. a board title/toolbar), not the app-level header RootLayout already renders.",
49
+ },
50
+ },
51
+ },
52
+ args: {
53
+ children: <Content />,
54
+ },
55
+ } satisfies Meta<typeof PageLayout>;
56
+
57
+ export default meta;
58
+ type Story = StoryObj<typeof meta>;
59
+
60
+ export const Default: Story = {};
61
+
62
+ export const WithPadding: Story = {
63
+ args: {
64
+ padding: 'lg',
65
+ },
66
+ };
67
+
68
+ export const WithHeader: Story = {
69
+ args: {
70
+ header: <PageHeader />,
71
+ padding: 'md',
72
+ },
73
+ };