@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
@@ -8,15 +8,16 @@
8
8
  .grid {
9
9
  list-style: none;
10
10
  margin: 0;
11
- /* Drawer's own grab handle already reserves a 44px touch target above this
12
- — no extra top padding needed beyond a touch of breathing room. Bottom
13
- gets noticeably more: with no header row to separate the grid from the
14
- handle, a tight top/bottom balance read as top-heavy (grid crowding the
15
- handle isn't the issue; the *last* row crowding the panel's bottom
16
- edge/safe-area is). Drawer's own panel already adds
17
- env(safe-area-inset-bottom) below everything, so this is on top of that,
18
- not instead of it. */
19
- padding: var(--sv-space-1) var(--sv-space-4) var(--sv-space-10);
11
+ /* Matches the reference `.drawerGrid`'s own padding exactly — its
12
+ surrounding `Drawer` also has no header row (no `title` passed either),
13
+ so "no header row" can't actually explain padding this component used
14
+ to carry well past the reference's own (`--sv-space-1`/`--sv-space-10`
15
+ vs the reference's `--sv-space-2`/`--sv-space-6`) — found live, this
16
+ component's grid visibly read as more tightly hugging the handle and
17
+ noticeably more padded at the bottom than the reference's side by side.
18
+ Drawer's own panel already adds env(safe-area-inset-bottom) below
19
+ everything, so this is on top of that, not instead of it. */
20
+ padding: var(--sv-space-2) var(--sv-space-4) var(--sv-space-6);
20
21
  display: grid;
21
22
  grid-template-columns: repeat(3, 1fr);
22
23
  row-gap: var(--sv-space-5);
@@ -12,7 +12,7 @@
12
12
  padding: 0 var(--sv-space-4);
13
13
  padding-bottom: max(var(--sv-space-2), env(safe-area-inset-bottom));
14
14
  background-color: var(--sv-color-surface);
15
- border-top: 1px solid var(--sv-color-border);
15
+ border-top: var(--sv-border-width-hairline) solid var(--sv-color-border);
16
16
  position: relative;
17
17
  z-index: 101;
18
18
  }
@@ -1,5 +1,8 @@
1
- import type { HTMLAttributes, ReactNode } from 'react';
1
+ 'use client';
2
+
3
+ import { useRef, type HTMLAttributes, type ReactNode } from 'react';
2
4
  import { Icon } from '../Icon/Icon';
5
+ import { usePublishShellChromeHeight } from '../../hooks/usePublishShellChromeHeight';
3
6
  import styles from './MobileFooter.module.css';
4
7
 
5
8
  export interface FooterIcon {
@@ -60,6 +63,13 @@ function FooterNavItem({ icon, label, href, onClick, active }: FooterIcon) {
60
63
  * Presentational only — no data fetching; the consumer owns the
61
64
  * drawer/overlay `onOpenApps` opens. See RFC 0088 for the
62
65
  * immutable/overridable boundary this enforces.
66
+ *
67
+ * Self-measures its own rendered height and publishes it as
68
+ * `--sv-shell-footer-height` on the shell root — see
69
+ * `usePublishShellChromeHeight`'s own doc comment for why this exists (it's
70
+ * what makes a self-rendered footer, the `shellConfig.mobileFooter: false`
71
+ * pattern, correctly clear `Sheet`/`Drawer`/`Dialog` instead of being
72
+ * overlapped by them).
63
73
  */
64
74
  export function MobileFooter({
65
75
  onOpenApps,
@@ -77,6 +87,9 @@ export function MobileFooter({
77
87
  );
78
88
  }
79
89
 
90
+ const navRef = useRef<HTMLElement>(null);
91
+ usePublishShellChromeHeight(navRef, '--sv-shell-footer-height');
92
+
80
93
  const cls = [styles.footer, className].filter(Boolean).join(' ');
81
94
  const launcherClassName = [
82
95
  styles.navItem,
@@ -87,7 +100,7 @@ export function MobileFooter({
87
100
  .join(' ');
88
101
 
89
102
  return (
90
- <nav {...rest} className={cls} aria-label="App navigation">
103
+ <nav {...rest} ref={navRef} className={cls} aria-label="App navigation">
91
104
  {leftIcons.map((item) => (
92
105
  <FooterNavItem key={item.label} {...item} />
93
106
  ))}
@@ -23,6 +23,64 @@ const rightTwo = [
23
23
  { icon: <span>icon</span>, label: 'Activity' },
24
24
  ];
25
25
 
26
+ describe('MobileFooter self-measured height', () => {
27
+ afterEach(() => {
28
+ document.getElementById('sv-app-shell')?.remove();
29
+ });
30
+
31
+ it('publishes its rendered height as --sv-shell-footer-height on #sv-app-shell', () => {
32
+ const shell = document.createElement('div');
33
+ shell.id = 'sv-app-shell';
34
+ document.body.appendChild(shell);
35
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
36
+ height: 64,
37
+ width: 0,
38
+ top: 0,
39
+ left: 0,
40
+ right: 0,
41
+ bottom: 0,
42
+ x: 0,
43
+ y: 0,
44
+ toJSON() {},
45
+ } as DOMRect);
46
+
47
+ render(<MobileFooter onOpenApps={() => {}} leftIcons={oneIcon} rightIcons={oneIcon} />);
48
+
49
+ expect(shell.style.getPropertyValue('--sv-shell-footer-height')).toBe('64px');
50
+ });
51
+
52
+ it('removes the override on unmount', () => {
53
+ const shell = document.createElement('div');
54
+ shell.id = 'sv-app-shell';
55
+ document.body.appendChild(shell);
56
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
57
+ height: 64,
58
+ width: 0,
59
+ top: 0,
60
+ left: 0,
61
+ right: 0,
62
+ bottom: 0,
63
+ x: 0,
64
+ y: 0,
65
+ toJSON() {},
66
+ } as DOMRect);
67
+
68
+ const { unmount } = render(
69
+ <MobileFooter onOpenApps={() => {}} leftIcons={oneIcon} rightIcons={oneIcon} />,
70
+ );
71
+ expect(shell.style.getPropertyValue('--sv-shell-footer-height')).toBe('64px');
72
+
73
+ unmount();
74
+ expect(shell.style.getPropertyValue('--sv-shell-footer-height')).toBe('');
75
+ });
76
+
77
+ it('never throws when #sv-app-shell is absent (Storybook, unit tests, other hosts)', () => {
78
+ expect(() =>
79
+ render(<MobileFooter onOpenApps={() => {}} leftIcons={oneIcon} rightIcons={oneIcon} />),
80
+ ).not.toThrow();
81
+ });
82
+ });
83
+
26
84
  describe('MobileFooter', () => {
27
85
  it('always renders the centered launcher button', () => {
28
86
  render(<MobileFooter onOpenApps={() => {}} leftIcons={oneIcon} rightIcons={oneIcon} />);
@@ -16,10 +16,23 @@
16
16
  padding-right: max(var(--sv-space-4), env(safe-area-inset-right));
17
17
  padding-left: max(var(--sv-space-4), env(safe-area-inset-left));
18
18
  background-color: var(--sv-color-surface);
19
- border-bottom: 1px solid var(--sv-color-border);
19
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
20
20
  position: sticky;
21
21
  top: 0;
22
22
  z-index: 101;
23
+ /* WebKit momentum-scroll re-tiling quirk: a position: sticky element with
24
+ no compositing layer of its own can go stale/blank for a stretch of
25
+ frames during a fast scroll on iOS Safari instead of repainting at its
26
+ stuck position — only reachable when a consumer mounts this component
27
+ inside its own scrolling container (the platform shell's own mobile
28
+ header sits in a separate, non-scrolling grid row, so this is a no-op
29
+ there; a plugin self-rendering this via shellConfig.mobileHeader: false
30
+ inside a scrollable page is exactly the case this protects). Found and
31
+ fixed across three sticky elements in sovereign-tasks before being
32
+ applied here proactively — see docs/architecture-rules.md.
33
+ translateZ(0) promotes it to its own layer, rasterized independently of
34
+ the scroller's tile grid. */
35
+ transform: translateZ(0);
23
36
  }
24
37
 
25
38
  .brandGroup {
@@ -1,4 +1,7 @@
1
- import type { HTMLAttributes, ReactNode } from 'react';
1
+ 'use client';
2
+
3
+ import { useRef, type HTMLAttributes, type ReactNode } from 'react';
4
+ import { usePublishShellChromeHeight } from '../../hooks/usePublishShellChromeHeight';
2
5
  import styles from './MobileHeader.module.css';
3
6
 
4
7
  export interface MobileHeaderProps extends HTMLAttributes<HTMLDivElement> {
@@ -51,6 +54,13 @@ function DefaultLogo() {
51
54
  * consumer's responsibility, matching the reference implementation in the
52
55
  * runtime shell). See RFC 0088 for the immutable/overridable boundary this
53
56
  * enforces.
57
+ *
58
+ * Self-measures its own rendered height and publishes it as
59
+ * `--sv-shell-header-height` on the shell root — see
60
+ * `usePublishShellChromeHeight`'s own doc comment for why this exists (it's
61
+ * what makes a self-rendered header, the `shellConfig.mobileHeader: false`
62
+ * pattern, correctly position `Dialog`'s inset and any other consumer of
63
+ * that variable instead of being overlapped by it).
54
64
  */
55
65
  export function MobileHeader({
56
66
  logo,
@@ -60,10 +70,13 @@ export function MobileHeader({
60
70
  className,
61
71
  ...rest
62
72
  }: MobileHeaderProps) {
73
+ const headerRef = useRef<HTMLDivElement>(null);
74
+ usePublishShellChromeHeight(headerRef, '--sv-shell-header-height');
75
+
63
76
  const cls = [styles.header, className].filter(Boolean).join(' ');
64
77
 
65
78
  return (
66
- <div className={cls} {...rest}>
79
+ <div className={cls} {...rest} ref={headerRef}>
67
80
  <div className={styles.brandGroup}>
68
81
  {logo ?? <DefaultLogo />}
69
82
  {title && <span className={styles.title}>{title}</span>}
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { afterEach, describe, expect, it } from 'vitest';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import { cleanup, render, screen } from '@testing-library/react';
4
4
  import { MobileHeader } from '../MobileHeader';
5
5
 
@@ -7,6 +7,61 @@ afterEach(() => {
7
7
  cleanup();
8
8
  });
9
9
 
10
+ describe('MobileHeader self-measured height', () => {
11
+ afterEach(() => {
12
+ document.getElementById('sv-app-shell')?.remove();
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ it('publishes its rendered height as --sv-shell-header-height on #sv-app-shell', () => {
17
+ const shell = document.createElement('div');
18
+ shell.id = 'sv-app-shell';
19
+ document.body.appendChild(shell);
20
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
21
+ height: 60,
22
+ width: 0,
23
+ top: 0,
24
+ left: 0,
25
+ right: 0,
26
+ bottom: 0,
27
+ x: 0,
28
+ y: 0,
29
+ toJSON() {},
30
+ } as DOMRect);
31
+
32
+ render(<MobileHeader bell={<span />} avatarMenu={<span />} />);
33
+
34
+ expect(shell.style.getPropertyValue('--sv-shell-header-height')).toBe('60px');
35
+ });
36
+
37
+ it('removes the override on unmount', () => {
38
+ const shell = document.createElement('div');
39
+ shell.id = 'sv-app-shell';
40
+ document.body.appendChild(shell);
41
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
42
+ height: 60,
43
+ width: 0,
44
+ top: 0,
45
+ left: 0,
46
+ right: 0,
47
+ bottom: 0,
48
+ x: 0,
49
+ y: 0,
50
+ toJSON() {},
51
+ } as DOMRect);
52
+
53
+ const { unmount } = render(<MobileHeader bell={<span />} avatarMenu={<span />} />);
54
+ expect(shell.style.getPropertyValue('--sv-shell-header-height')).toBe('60px');
55
+
56
+ unmount();
57
+ expect(shell.style.getPropertyValue('--sv-shell-header-height')).toBe('');
58
+ });
59
+
60
+ it('never throws when #sv-app-shell is absent (Storybook, unit tests, other hosts)', () => {
61
+ expect(() => render(<MobileHeader bell={<span />} avatarMenu={<span />} />)).not.toThrow();
62
+ });
63
+ });
64
+
10
65
  describe('MobileHeader', () => {
11
66
  it('always renders the logo, bell, and avatar menu slots', () => {
12
67
  render(
@@ -0,0 +1,130 @@
1
+ .nav {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--sv-space-5);
5
+ width: 100%;
6
+ }
7
+
8
+ .group {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: 2px;
12
+ }
13
+
14
+ .groupLabel {
15
+ padding: 0 var(--sv-space-3);
16
+ margin-bottom: var(--sv-space-1);
17
+ font-size: var(--sv-font-size-xs);
18
+ font-weight: var(--sv-font-weight-semibold);
19
+ letter-spacing: 0.04em;
20
+ text-transform: uppercase;
21
+ color: var(--sv-color-text-muted);
22
+ }
23
+
24
+ .row {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: var(--sv-space-3);
28
+ min-height: var(--sv-touch-target-min);
29
+ padding: var(--sv-space-2) var(--sv-space-3);
30
+ border-radius: var(--sv-radius-md);
31
+ color: var(--sv-color-text-muted);
32
+ text-decoration: none;
33
+ font-size: var(--sv-font-size-sm);
34
+ font-weight: var(--sv-font-weight-medium);
35
+ }
36
+
37
+ .icon {
38
+ flex-shrink: 0;
39
+ }
40
+
41
+ .label {
42
+ flex: 1;
43
+ min-width: 0;
44
+ overflow: hidden;
45
+ text-overflow: ellipsis;
46
+ white-space: nowrap;
47
+ }
48
+
49
+ .badge {
50
+ flex-shrink: 0;
51
+ color: var(--sv-color-text-muted);
52
+ font-size: var(--sv-font-size-xs);
53
+ }
54
+
55
+ .chevron {
56
+ flex-shrink: 0;
57
+ color: var(--sv-color-text-muted);
58
+ }
59
+
60
+ /* ── static (desktop sidebar) ─────────────────────────────────────────── */
61
+
62
+ .static .row {
63
+ transition:
64
+ background-color 0.1s ease,
65
+ color 0.1s ease;
66
+ }
67
+
68
+ @media (hover: hover) {
69
+ .static .row:hover {
70
+ background-color: var(--sv-color-surface-sunken);
71
+ color: var(--sv-color-text-primary);
72
+ }
73
+ }
74
+
75
+ .static .row:focus-visible {
76
+ outline: 2px solid var(--sv-color-focus-ring);
77
+ outline-offset: -2px;
78
+ }
79
+
80
+ .static .rowActive {
81
+ background-color: var(--sv-color-surface-sunken);
82
+ color: var(--sv-color-text-primary);
83
+ font-weight: var(--sv-font-weight-semibold);
84
+ }
85
+
86
+ .static .rowActive .icon {
87
+ color: var(--sv-color-text-primary);
88
+ }
89
+
90
+ /* Only meaningful combined with .static — see NavListProps.density's own
91
+ doc comment for why .drilldown rows never shrink. */
92
+ .compact.static .row {
93
+ min-height: 0;
94
+ padding: var(--sv-space-2) var(--sv-space-3);
95
+ }
96
+
97
+ /* ── drilldown (mobile index) — grouped card, native-Settings-app rows ──── */
98
+
99
+ .drilldown .group {
100
+ gap: 0;
101
+ border: 1px solid var(--sv-color-border);
102
+ border-radius: var(--sv-radius-lg);
103
+ overflow: hidden;
104
+ background-color: var(--sv-color-surface-raised);
105
+ }
106
+
107
+ .drilldown .row {
108
+ border-radius: 0;
109
+ padding: var(--sv-space-3) var(--sv-space-4);
110
+ color: var(--sv-color-text-primary);
111
+ }
112
+
113
+ .drilldown .row:not(:last-child) {
114
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
115
+ }
116
+
117
+ @media (hover: hover) {
118
+ .drilldown .row:hover {
119
+ background-color: var(--sv-color-surface-sunken);
120
+ }
121
+ }
122
+
123
+ .drilldown .row:focus-visible {
124
+ outline: 2px solid var(--sv-color-focus-ring);
125
+ outline-offset: -2px;
126
+ }
127
+
128
+ .drilldown .icon {
129
+ color: var(--sv-color-text-muted);
130
+ }
@@ -0,0 +1,136 @@
1
+ import { Fragment, type ReactNode } from 'react';
2
+ import { Icon } from '../Icon/Icon';
3
+ import type { IconName } from '../Icon/Icon';
4
+ import styles from './NavList.module.css';
5
+
6
+ export interface NavListItem {
7
+ id: string;
8
+ label: string;
9
+ href: string;
10
+ icon: IconName;
11
+ /** Trailing content, e.g. a count — reserved for a future consumer, not used by `variant="drilldown"`'s own chevron slot. */
12
+ badge?: ReactNode;
13
+ /**
14
+ * Only meaningful for `variant="static"` — whether this row represents the
15
+ * current page. Computed by the consumer (e.g. via `usePathname()` plus a
16
+ * longest-prefix match against every item's `href`), since this package
17
+ * doesn't depend on `next/navigation` — same reasoning as `NavTabs`.
18
+ */
19
+ active?: boolean;
20
+ }
21
+
22
+ export interface NavListGroup {
23
+ id: string;
24
+ /** Omit for an ungrouped leading item/group — e.g. a pinned "Overview" row above the grouped sections. */
25
+ label?: string;
26
+ items: NavListItem[];
27
+ }
28
+
29
+ /** Props to spread onto a consumer-supplied link element — mirrors `NavTabs`' `NavTabLinkProps`. */
30
+ export interface NavListLinkProps {
31
+ href: string;
32
+ className: string;
33
+ 'aria-current': 'page' | undefined;
34
+ children: ReactNode;
35
+ }
36
+
37
+ export interface NavListProps {
38
+ groups: NavListGroup[];
39
+ /**
40
+ * `"static"`: active row highlighted via `item.active`, no chevron — a
41
+ * persistent sidebar nav. `"drilldown"`: every row gets a trailing chevron
42
+ * and no active-state, tapping navigates to a full-screen section — a
43
+ * native Settings-app style index.
44
+ */
45
+ variant: 'static' | 'drilldown';
46
+ /**
47
+ * `"default"`: each row is a full `--sv-touch-target-min` tall tap target
48
+ * — the right choice for `variant="drilldown"`'s mobile index, and for any
49
+ * `static` sidebar list long enough that scanability matters more than
50
+ * density. `"compact"` shrinks row height/padding for a short, always-
51
+ * visible `static` list (e.g. 2-4 items above a longer scrollable list of
52
+ * something else) where the default's touch-target height reads as
53
+ * needlessly spaced out. Only meaningful for `variant="static"` —
54
+ * `drilldown` rows stay full-height regardless, since that variant is
55
+ * mobile-only and touch-target size there isn't optional. Defaults to
56
+ * `"default"` so every existing consumer (e.g. Console's own sidebar) is
57
+ * visually unchanged.
58
+ */
59
+ density?: 'default' | 'compact';
60
+ 'aria-label': string;
61
+ className?: string;
62
+ /**
63
+ * Render a row as something other than a plain `<a href>` — e.g. Next's
64
+ * `<Link>`. Receives the item plus the class name/aria attributes NavList
65
+ * would otherwise apply, matching `NavTabs`' `renderLink` contract.
66
+ */
67
+ renderLink?: (item: NavListItem, linkProps: NavListLinkProps) => ReactNode;
68
+ }
69
+
70
+ /**
71
+ * NavList — a vertical list of icon+label rows, optionally grouped under
72
+ * section headers. Two presentations of the same `groups` data: `static` (a
73
+ * persistent sidebar, active item highlighted) and `drilldown` (a
74
+ * native-Settings-app style index, every row navigates to a full section).
75
+ *
76
+ * Framework-agnostic like `NavTabs` — no `next/navigation`/`next/link`
77
+ * import, no internal pathname reading. Active state and navigation behavior
78
+ * are supplied by the consumer via `item.active` and `renderLink`.
79
+ */
80
+ export function NavList({
81
+ groups,
82
+ variant,
83
+ density = 'default',
84
+ 'aria-label': ariaLabel,
85
+ className,
86
+ renderLink,
87
+ }: NavListProps) {
88
+ return (
89
+ <nav
90
+ className={[styles.nav, styles[variant], density === 'compact' && styles.compact, className]
91
+ .filter(Boolean)
92
+ .join(' ')}
93
+ aria-label={ariaLabel}
94
+ >
95
+ {groups.map((group) => (
96
+ <div key={group.id} className={styles.group}>
97
+ {group.label && <div className={styles.groupLabel}>{group.label}</div>}
98
+ {group.items.map((item) => {
99
+ const isActive = variant === 'static' && !!item.active;
100
+ const rowContent = (
101
+ <>
102
+ <Icon name={item.icon} size="sm" aria-hidden className={styles.icon} />
103
+ <span className={styles.label}>{item.label}</span>
104
+ {item.badge && <span className={styles.badge}>{item.badge}</span>}
105
+ {variant === 'drilldown' && (
106
+ <Icon name="chevron-right" size="sm" aria-hidden className={styles.chevron} />
107
+ )}
108
+ </>
109
+ );
110
+ const linkProps: NavListLinkProps = {
111
+ href: item.href,
112
+ className: [styles.row, isActive && styles.rowActive].filter(Boolean).join(' '),
113
+ 'aria-current': isActive ? 'page' : undefined,
114
+ children: rowContent,
115
+ };
116
+ return (
117
+ <Fragment key={item.id}>
118
+ {renderLink ? (
119
+ renderLink(item, linkProps)
120
+ ) : (
121
+ <a
122
+ href={linkProps.href}
123
+ className={linkProps.className}
124
+ aria-current={linkProps['aria-current']}
125
+ >
126
+ {linkProps.children}
127
+ </a>
128
+ )}
129
+ </Fragment>
130
+ );
131
+ })}
132
+ </div>
133
+ ))}
134
+ </nav>
135
+ );
136
+ }
@@ -0,0 +1,109 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { NavList, type NavListGroup } from '../NavList';
5
+ import styles from '../NavList.module.css';
6
+
7
+ afterEach(cleanup);
8
+
9
+ const groups: NavListGroup[] = [
10
+ {
11
+ id: 'overview',
12
+ items: [{ id: 'overview', label: 'Overview', href: '/console', icon: 'layout-dashboard' }],
13
+ },
14
+ {
15
+ id: 'people',
16
+ label: 'People',
17
+ items: [
18
+ { id: 'users', label: 'Users', href: '/console/users', icon: 'users', active: true },
19
+ { id: 'groups', label: 'Groups', href: '/console/groups', icon: 'layers' },
20
+ ],
21
+ },
22
+ ];
23
+
24
+ describe('NavList', () => {
25
+ it('renders every item as a link with the correct href', () => {
26
+ render(<NavList groups={groups} variant="static" aria-label="Sections" />);
27
+ expect(screen.getByRole('link', { name: /overview/i }).getAttribute('href')).toBe('/console');
28
+ expect(screen.getByRole('link', { name: /users/i }).getAttribute('href')).toBe(
29
+ '/console/users',
30
+ );
31
+ expect(screen.getByRole('link', { name: /groups/i }).getAttribute('href')).toBe(
32
+ '/console/groups',
33
+ );
34
+ });
35
+
36
+ it('renders a label for a named group and no label for an ungrouped one', () => {
37
+ render(<NavList groups={groups} variant="static" aria-label="Sections" />);
38
+ // "People" is the only group.label in the fixture — it must appear exactly
39
+ // once. The ungrouped "overview" group renders its item with no group
40
+ // header text alongside it (nothing else in the fixture could produce a
41
+ // second match).
42
+ expect(screen.getAllByText('People')).toHaveLength(1);
43
+ });
44
+
45
+ describe('variant="static"', () => {
46
+ it('marks the active item with aria-current="page"', () => {
47
+ render(<NavList groups={groups} variant="static" aria-label="Sections" />);
48
+ expect(screen.getByRole('link', { name: /users/i }).getAttribute('aria-current')).toBe(
49
+ 'page',
50
+ );
51
+ expect(screen.getByRole('link', { name: /groups/i }).getAttribute('aria-current')).toBeNull();
52
+ });
53
+
54
+ it('renders no chevron icons', () => {
55
+ const { container } = render(
56
+ <NavList groups={groups} variant="static" aria-label="Sections" />,
57
+ );
58
+ expect(container.querySelectorAll('svg').length).toBe(groups.flatMap((g) => g.items).length);
59
+ });
60
+ });
61
+
62
+ describe('variant="drilldown"', () => {
63
+ it('never sets aria-current, even on an item marked active', () => {
64
+ render(<NavList groups={groups} variant="drilldown" aria-label="Sections" />);
65
+ expect(screen.getByRole('link', { name: /users/i }).getAttribute('aria-current')).toBeNull();
66
+ });
67
+
68
+ it('renders a trailing chevron for every item', () => {
69
+ const { container } = render(
70
+ <NavList groups={groups} variant="drilldown" aria-label="Sections" />,
71
+ );
72
+ const itemCount = groups.flatMap((g) => g.items).length;
73
+ // One icon svg + one chevron svg per item.
74
+ expect(container.querySelectorAll('svg').length).toBe(itemCount * 2);
75
+ });
76
+ });
77
+
78
+ describe('density', () => {
79
+ it('defaults to no compact class', () => {
80
+ const { container } = render(
81
+ <NavList groups={groups} variant="static" aria-label="Sections" />,
82
+ );
83
+ expect(container.querySelector('nav')?.className).not.toContain(styles.compact);
84
+ });
85
+
86
+ it('applies the compact class when density="compact"', () => {
87
+ const { container } = render(
88
+ <NavList groups={groups} variant="static" density="compact" aria-label="Sections" />,
89
+ );
90
+ expect(container.querySelector('nav')?.className).toContain(styles.compact);
91
+ });
92
+ });
93
+
94
+ it('calls renderLink with the item and computed link props instead of rendering a plain anchor', () => {
95
+ const renderLink = vi.fn((_item, linkProps) => (
96
+ <button type="button" aria-current={linkProps['aria-current']}>
97
+ {linkProps.children}
98
+ </button>
99
+ ));
100
+ render(
101
+ <NavList groups={groups} variant="static" aria-label="Sections" renderLink={renderLink} />,
102
+ );
103
+ expect(renderLink).toHaveBeenCalledTimes(3);
104
+ expect(screen.queryAllByRole('link').length).toBe(0);
105
+ expect(screen.getByRole('button', { name: /users/i }).getAttribute('aria-current')).toBe(
106
+ 'page',
107
+ );
108
+ });
109
+ });
@@ -2,7 +2,7 @@
2
2
  display: flex;
3
3
  flex-wrap: wrap;
4
4
  gap: 0;
5
- border-bottom: 1px solid var(--sv-color-border);
5
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
6
6
  }
7
7
 
8
8
  .link {