@sovereignfs/ui 0.41.0 → 0.54.0

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 (256) hide show
  1. package/dist/Accordion.module.css +3 -0
  2. package/dist/Alert.module.css +62 -0
  3. package/dist/AspectRatio.module.css +15 -0
  4. package/dist/Badge.module.css +75 -9
  5. package/dist/Breadcrumb.module.css +49 -0
  6. package/dist/Button.module.css +12 -3
  7. package/dist/ButtonGroup.module.css +29 -0
  8. package/dist/Card.module.css +6 -0
  9. package/dist/Checkbox.module.css +3 -1
  10. package/dist/Collapsible.module.css +70 -0
  11. package/dist/Combobox.module.css +122 -0
  12. package/dist/Command.module.css +80 -0
  13. package/dist/ConfirmDialog.module.css +5 -1
  14. package/dist/DataTable.module.css +28 -0
  15. package/dist/DatePicker.module.css +6 -2
  16. package/dist/Dialog.module.css +3 -0
  17. package/dist/HoverCard.module.css +3 -0
  18. package/dist/Input.module.css +6 -1
  19. package/dist/Item.module.css +70 -0
  20. package/dist/Kbd.module.css +14 -0
  21. package/dist/Label.module.css +11 -0
  22. package/dist/LegalLinks.module.css +24 -0
  23. package/dist/Markdown.module.css +49 -0
  24. package/dist/Marker.module.css +33 -0
  25. package/dist/Menu.module.css +8 -1
  26. package/dist/Message.module.css +99 -0
  27. package/dist/MessageScroller.module.css +54 -0
  28. package/dist/MobileAppsDrawer.module.css +81 -0
  29. package/dist/MobileFooter.module.css +73 -0
  30. package/dist/MobileHeader.module.css +63 -0
  31. package/dist/NavigationMenu.module.css +53 -0
  32. package/dist/PageContainer.module.css +20 -0
  33. package/dist/Pagination.module.css +98 -0
  34. package/dist/Progress.module.css +20 -0
  35. package/dist/RadioGroup.module.css +88 -0
  36. package/dist/Resizable.module.css +70 -0
  37. package/dist/ScrollArea.module.css +20 -0
  38. package/dist/Select.module.css +7 -3
  39. package/dist/Slider.module.css +95 -0
  40. package/dist/SwipableMobileCarousel.module.css +55 -0
  41. package/dist/SwipableMobileCarouselDots.module.css +49 -0
  42. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  43. package/dist/Table.module.css +45 -0
  44. package/dist/Tabs.module.css +6 -1
  45. package/dist/Toggle.module.css +6 -1
  46. package/dist/Typography.module.css +47 -0
  47. package/dist/index.d.ts +1268 -8
  48. package/dist/index.js +3046 -975
  49. package/dist/tokens/primitives.css +47 -8
  50. package/package.json +13 -9
  51. package/src/components/Accordion/Accordion.module.css +3 -0
  52. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  53. package/src/components/Accordion/Accordion.tsx +53 -0
  54. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  55. package/src/components/Alert/Alert.module.css +62 -0
  56. package/src/components/Alert/Alert.stories.tsx +96 -0
  57. package/src/components/Alert/Alert.tsx +58 -0
  58. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  59. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  60. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  61. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  62. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  63. package/src/components/Badge/Badge.module.css +75 -9
  64. package/src/components/Badge/Badge.stories.tsx +109 -1
  65. package/src/components/Badge/Badge.tsx +31 -9
  66. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  67. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  68. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  69. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  70. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  71. package/src/components/Button/Button.module.css +12 -3
  72. package/src/components/Button/Button.stories.tsx +3 -2
  73. package/src/components/Button/Button.tsx +1 -1
  74. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  75. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  76. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  77. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  78. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  79. package/src/components/Card/Card.module.css +6 -0
  80. package/src/components/Checkbox/Checkbox.module.css +3 -1
  81. package/src/components/Collapsible/Collapsible.module.css +70 -0
  82. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  83. package/src/components/Collapsible/Collapsible.tsx +66 -0
  84. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  85. package/src/components/Combobox/Combobox.module.css +122 -0
  86. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  87. package/src/components/Combobox/Combobox.tsx +186 -0
  88. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  89. package/src/components/Command/Command.module.css +80 -0
  90. package/src/components/Command/Command.stories.tsx +104 -0
  91. package/src/components/Command/Command.tsx +168 -0
  92. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  93. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  94. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  95. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  96. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  97. package/src/components/DataTable/DataTable.module.css +28 -0
  98. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  99. package/src/components/DataTable/DataTable.tsx +154 -0
  100. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  101. package/src/components/DatePicker/DatePicker.module.css +6 -2
  102. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  103. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  104. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  105. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  106. package/src/components/Dialog/Dialog.module.css +3 -0
  107. package/src/components/Dialog/Dialog.tsx +8 -59
  108. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  109. package/src/components/Drawer/Drawer.tsx +18 -56
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
  111. package/src/components/HoverCard/HoverCard.module.css +3 -0
  112. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  113. package/src/components/HoverCard/HoverCard.tsx +111 -0
  114. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  115. package/src/components/Icon/icons/circle-check.tsx +21 -0
  116. package/src/components/Icon/icons/circle-x.tsx +22 -0
  117. package/src/components/Icon/icons/index.ts +16 -0
  118. package/src/components/Icon/icons/menu.tsx +22 -0
  119. package/src/components/Icon/icons/smartphone.tsx +21 -0
  120. package/src/components/Input/Input.module.css +6 -1
  121. package/src/components/Item/Item.module.css +70 -0
  122. package/src/components/Item/Item.stories.tsx +62 -0
  123. package/src/components/Item/Item.tsx +54 -0
  124. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  125. package/src/components/Kbd/Kbd.module.css +14 -0
  126. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  127. package/src/components/Kbd/Kbd.tsx +12 -0
  128. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  129. package/src/components/Label/Label.module.css +11 -0
  130. package/src/components/Label/Label.stories.tsx +40 -0
  131. package/src/components/Label/Label.tsx +24 -0
  132. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  133. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  134. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  135. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  136. package/src/components/Markdown/Markdown.module.css +49 -0
  137. package/src/components/Markdown/Markdown.stories.tsx +30 -0
  138. package/src/components/Markdown/Markdown.tsx +168 -0
  139. package/src/components/Marker/Marker.module.css +33 -0
  140. package/src/components/Marker/Marker.stories.tsx +39 -0
  141. package/src/components/Marker/Marker.tsx +34 -0
  142. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  143. package/src/components/Menu/Menu.module.css +8 -1
  144. package/src/components/Menu/Menu.tsx +30 -8
  145. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  146. package/src/components/Menubar/Menubar.tsx +42 -0
  147. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  148. package/src/components/Message/Message.module.css +99 -0
  149. package/src/components/Message/Message.stories.tsx +79 -0
  150. package/src/components/Message/Message.tsx +41 -0
  151. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  152. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  153. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  154. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  155. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  156. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
  157. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  158. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  159. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  160. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  161. package/src/components/MobileFooter/MobileFooter.tsx +109 -0
  162. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
  163. package/src/components/MobileHeader/MobileHeader.module.css +63 -0
  164. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  165. package/src/components/MobileHeader/MobileHeader.tsx +77 -0
  166. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
  167. package/src/components/NavTabs/NavTabs.tsx +39 -11
  168. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  169. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  170. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  171. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  172. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  173. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  174. package/src/components/PageContainer/PageContainer.module.css +20 -0
  175. package/src/components/PageContainer/PageContainer.tsx +31 -0
  176. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
  177. package/src/components/PageHeader/PageHeader.tsx +16 -2
  178. package/src/components/Pagination/Pagination.module.css +98 -0
  179. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  180. package/src/components/Pagination/Pagination.tsx +87 -0
  181. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  182. package/src/components/Progress/Progress.module.css +20 -0
  183. package/src/components/Progress/Progress.stories.tsx +40 -0
  184. package/src/components/Progress/Progress.tsx +35 -0
  185. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  186. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  187. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  188. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  189. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  190. package/src/components/Resizable/Resizable.module.css +70 -0
  191. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  192. package/src/components/Resizable/Resizable.tsx +313 -0
  193. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  194. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  195. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  196. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  197. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  198. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  199. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  200. package/src/components/Select/Select.module.css +7 -3
  201. package/src/components/Sheet/Sheet.tsx +9 -51
  202. package/src/components/Slider/Slider.module.css +95 -0
  203. package/src/components/Slider/Slider.stories.tsx +64 -0
  204. package/src/components/Slider/Slider.tsx +77 -0
  205. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  206. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
  207. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
  208. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  209. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  210. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  211. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  212. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  213. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
  214. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  215. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  216. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
  217. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
  218. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  219. package/src/components/Table/Table.module.css +45 -0
  220. package/src/components/Table/Table.stories.tsx +125 -0
  221. package/src/components/Table/Table.tsx +51 -0
  222. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  223. package/src/components/Tabs/Tabs.module.css +6 -1
  224. package/src/components/Toggle/Toggle.module.css +6 -1
  225. package/src/components/Typography/Typography.module.css +47 -0
  226. package/src/components/Typography/Typography.stories.tsx +62 -0
  227. package/src/components/Typography/Typography.tsx +30 -0
  228. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  229. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  230. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  231. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  232. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  233. package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
  234. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  235. package/src/hooks/index.ts +18 -0
  236. package/src/hooks/useCarouselRouteSync.ts +92 -0
  237. package/src/hooks/useIsOffline.ts +41 -0
  238. package/src/hooks/useOfflineTileState.ts +45 -0
  239. package/src/hooks/useResponsiveLayout.ts +36 -0
  240. package/src/hooks/useSnapCarousel.ts +79 -0
  241. package/src/hooks/useSwipeReveal.ts +107 -0
  242. package/src/index.ts +141 -3
  243. package/src/overlay-shell.ts +94 -0
  244. package/src/stories/DesignSystemOverview.stories.tsx +936 -3
  245. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  246. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  247. package/src/stories/InteractionHooks.stories.tsx +483 -0
  248. package/src/stories/MobilePatterns.stories.tsx +255 -4
  249. package/src/stories/NavTabs.stories.tsx +18 -0
  250. package/src/stories/OfflineGate.stories.tsx +42 -0
  251. package/src/stories/PageContainer.stories.tsx +37 -0
  252. package/src/stories/PageHeader.stories.tsx +11 -0
  253. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  254. package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
  255. package/src/stories/TokenGallery.stories.tsx +177 -16
  256. package/src/tokens/primitives.css +47 -8
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { EmptyState } from '../EmptyState/EmptyState';
5
+
6
+ /**
7
+ * `'checking'` covers the moment before the caller's own async status check
8
+ * resolves — same transitional-state pattern `EncryptionSection`'s local
9
+ * `LocalState` uses, so this gate never flashes an empty state before the
10
+ * real answer is known. `'no-device-auth'` is the hard-block case (RFC 0093
11
+ * §5, epic task 1.22): the environment supports the tier, but this device
12
+ * has no passcode, fingerprint, or face unlock configured, so a key cannot
13
+ * be created here at all — distinct from `'not-set-up'`, where it can.
14
+ */
15
+ export type DeviceStorageKeyGateStatus =
16
+ 'checking' | 'unsupported' | 'no-device-auth' | 'not-set-up' | 'set-up';
17
+
18
+ export interface DeviceStorageKeyGateProps {
19
+ children: ReactNode;
20
+ /** Defaults to a generic "This app" phrasing — pass the plugin's own name. */
21
+ surfaceName?: string;
22
+ /**
23
+ * The caller's own `getDeviceStorageKeyStatus()` result (from
24
+ * `@sovereignfs/sdk/device-only-storage`) — taken as a plain status rather
25
+ * than computed inside this component, so `@sovereignfs/ui` never depends
26
+ * on `@sovereignfs/sdk` (same boundary `DeviceOnlyGate`'s `available` prop
27
+ * draws — this package must stay usable standalone, outside the plugin
28
+ * runtime — RFC 0073).
29
+ */
30
+ status: DeviceStorageKeyGateStatus;
31
+ /**
32
+ * Rendered under the "not set up" message — typically a link to Account →
33
+ * Security. Not built in here: this package depends on no router, so the
34
+ * caller supplies its own navigable element (a plain `<a>`, a framework
35
+ * `<Link>`, whatever the host app uses).
36
+ */
37
+ setupAction?: ReactNode;
38
+ className?: string;
39
+ }
40
+
41
+ /**
42
+ * DeviceStorageKeyGate — blocks a `device-only`-tier plugin's content until
43
+ * the user has set up their Device Storage Key (RFC 0093 §2, epic task
44
+ * 1.22). Distinct from `DeviceOnlyGate`: that one checks whether the *tier
45
+ * itself* is available on this surface (native shell, secure-storage bridge
46
+ * present); this one checks whether the user has actually completed the
47
+ * one-time, per-device enrollment that tier depends on. A `device-only`
48
+ * plugin typically wants both — `DeviceOnlyGate` outermost (no point
49
+ * checking enrollment on a surface that cannot offer the tier at all), this
50
+ * gate inside it.
51
+ *
52
+ * Enrollment is centralized and decoupled from any single plugin (RFC 0093
53
+ * §2): a plugin that finds no key set up does not run its own enrollment
54
+ * ceremony inline. It stops here and points the user at Account → Security —
55
+ * the one place enrollment happens for every `device-only` plugin on this
56
+ * device — via `setupAction`, the same opt-in, self-applied pattern
57
+ * `OfflineGate`/`DeviceOnlyGate` already use for their own surfaces.
58
+ */
59
+ export function DeviceStorageKeyGate({
60
+ children,
61
+ surfaceName,
62
+ status,
63
+ setupAction,
64
+ className,
65
+ }: DeviceStorageKeyGateProps) {
66
+ if (status === 'checking') return null;
67
+ if (status === 'set-up') return <>{children}</>;
68
+
69
+ if (status === 'unsupported') {
70
+ return (
71
+ <EmptyState
72
+ icon="smartphone"
73
+ heading="Phone only"
74
+ description={`${surfaceName ?? 'This app'} needs a security feature (WebAuthn PRF and Origin Private File System support) that isn't available here.`}
75
+ className={className}
76
+ />
77
+ );
78
+ }
79
+
80
+ if (status === 'no-device-auth') {
81
+ return (
82
+ <EmptyState
83
+ icon="alert-triangle"
84
+ heading="Set up a device passcode"
85
+ description={`${surfaceName ?? 'This app'} needs a passcode, fingerprint, or face unlock set up on this device before its Device Storage Key can be created. Set one up in your device's system settings, then come back.`}
86
+ className={className}
87
+ />
88
+ );
89
+ }
90
+
91
+ return (
92
+ <EmptyState
93
+ icon="lock"
94
+ heading="Set up your Device Storage Key"
95
+ description={`${surfaceName ?? 'This app'} keeps its data only on this device, protected by a Device Storage Key you set up once in Account → Security.`}
96
+ action={setupAction}
97
+ className={className}
98
+ />
99
+ );
100
+ }
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { DeviceStorageKeyGate } from '../DeviceStorageKeyGate';
5
+
6
+ describe('DeviceStorageKeyGate', () => {
7
+ afterEach(cleanup);
8
+
9
+ it('renders nothing while checking', () => {
10
+ const { container } = render(
11
+ <DeviceStorageKeyGate status="checking">
12
+ <p>Notes content</p>
13
+ </DeviceStorageKeyGate>,
14
+ );
15
+ expect(container.firstChild).toBeNull();
16
+ });
17
+
18
+ it('renders children unchanged once set up', () => {
19
+ render(
20
+ <DeviceStorageKeyGate status="set-up">
21
+ <p>Notes content</p>
22
+ </DeviceStorageKeyGate>,
23
+ );
24
+ expect(screen.getByText('Notes content')).toBeDefined();
25
+ });
26
+
27
+ it('shows a "Phone only"-style message when the environment is unsupported', () => {
28
+ render(
29
+ <DeviceStorageKeyGate status="unsupported">
30
+ <p>Notes content</p>
31
+ </DeviceStorageKeyGate>,
32
+ );
33
+ expect(screen.queryByText('Notes content')).toBeNull();
34
+ expect(screen.getByText('Phone only')).toBeDefined();
35
+ });
36
+
37
+ it('shows the setup message and prompt when not-set-up', () => {
38
+ render(
39
+ <DeviceStorageKeyGate status="not-set-up">
40
+ <p>Notes content</p>
41
+ </DeviceStorageKeyGate>,
42
+ );
43
+ expect(screen.queryByText('Notes content')).toBeNull();
44
+ expect(screen.getByText('Set up your Device Storage Key')).toBeDefined();
45
+ });
46
+
47
+ it('names the surface in the not-set-up message when provided', () => {
48
+ render(
49
+ <DeviceStorageKeyGate status="not-set-up" surfaceName="Notes">
50
+ <p>Notes content</p>
51
+ </DeviceStorageKeyGate>,
52
+ );
53
+ expect(screen.getByText(/Notes keeps its data only on this device/)).toBeDefined();
54
+ });
55
+
56
+ it('falls back to generic phrasing with no surface name', () => {
57
+ render(
58
+ <DeviceStorageKeyGate status="not-set-up">
59
+ <p>Notes content</p>
60
+ </DeviceStorageKeyGate>,
61
+ );
62
+ expect(screen.getByText(/This app keeps its data only on this device/)).toBeDefined();
63
+ });
64
+
65
+ it('renders the caller-supplied setupAction when not-set-up', () => {
66
+ render(
67
+ <DeviceStorageKeyGate
68
+ status="not-set-up"
69
+ setupAction={<a href="/account/security">Go to Account → Security</a>}
70
+ >
71
+ <p>Notes content</p>
72
+ </DeviceStorageKeyGate>,
73
+ );
74
+ expect(screen.getByRole('link', { name: 'Go to Account → Security' })).toBeDefined();
75
+ });
76
+
77
+ it('renders no setupAction when unsupported, even if one is passed', () => {
78
+ render(
79
+ <DeviceStorageKeyGate
80
+ status="unsupported"
81
+ setupAction={<a href="/account/security">Go to Account → Security</a>}
82
+ >
83
+ <p>Notes content</p>
84
+ </DeviceStorageKeyGate>,
85
+ );
86
+ expect(screen.queryByRole('link')).toBeNull();
87
+ });
88
+
89
+ it('shows the hard-block message when no-device-auth', () => {
90
+ render(
91
+ <DeviceStorageKeyGate status="no-device-auth">
92
+ <p>Notes content</p>
93
+ </DeviceStorageKeyGate>,
94
+ );
95
+ expect(screen.queryByText('Notes content')).toBeNull();
96
+ expect(screen.getByText('Set up a device passcode')).toBeDefined();
97
+ });
98
+
99
+ it('names the surface in the no-device-auth message when provided', () => {
100
+ render(
101
+ <DeviceStorageKeyGate status="no-device-auth" surfaceName="Notes">
102
+ <p>Notes content</p>
103
+ </DeviceStorageKeyGate>,
104
+ );
105
+ expect(screen.getByText(/Notes needs a passcode, fingerprint, or face unlock/)).toBeDefined();
106
+ });
107
+
108
+ it('renders no setupAction when no-device-auth, even if one is passed — fixing this is a device settings change, not a Sovereign link', () => {
109
+ render(
110
+ <DeviceStorageKeyGate
111
+ status="no-device-auth"
112
+ setupAction={<a href="/account/security">Go to Account → Security</a>}
113
+ >
114
+ <p>Notes content</p>
115
+ </DeviceStorageKeyGate>,
116
+ );
117
+ expect(screen.queryByRole('link')).toBeNull();
118
+ });
119
+ });
@@ -79,6 +79,9 @@
79
79
  }
80
80
  }
81
81
 
82
+ /* Sizing audit: reviewed padding/radius against this scale's other
83
+ surfaces. --sv-radius-lg (shared with Card via .panel below) left
84
+ unchanged for the same blast-radius reason as Card.module.css. */
82
85
  .content {
83
86
  flex: 1 1 auto;
84
87
  overflow-y: auto;
@@ -1,8 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react';
4
- import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
5
4
  import { useMountTransition, usePrefersReducedMotion } from '../../motion';
5
+ import {
6
+ useOverlayFocusCapture,
7
+ useOverlayKeyboardTrap,
8
+ useOverlayScrollLock,
9
+ } from '../../overlay-shell';
6
10
  import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
7
11
  import styles from './Dialog.module.css';
8
12
 
@@ -69,9 +73,6 @@ export interface DialogProps {
69
73
  children: ReactNode;
70
74
  }
71
75
 
72
- const FOCUSABLE =
73
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
74
-
75
76
  /**
76
77
  * Dialog — a modal surface (scrim + panel) for overlay-shell plugins and any
77
78
  * plugin that needs a dismissable layer. Router-agnostic: the caller decides
@@ -97,65 +98,13 @@ export function Dialog({
97
98
  children,
98
99
  }: DialogProps) {
99
100
  const panelRef = useRef<HTMLDivElement>(null);
100
- const previouslyFocused = useRef<HTMLElement | null>(null);
101
101
  const reducedMotion = usePrefersReducedMotion();
102
102
  const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
103
103
  const [secondRow, setSecondRow] = useState<ReactNode | null>(null);
104
104
 
105
- // Prevent document-level scroll for the whole mounted lifetime, including
106
- // the exit animation — not just while `open` — so the background can't
107
- // scroll while the panel is still visibly sliding/fading away. Ref-counted
108
- // so nested overlays (e.g. a confirmation dialog inside an overlay plugin)
109
- // don't release the lock while a sibling is still open.
110
- useEffect(() => {
111
- if (!mounted) return;
112
- lockBodyScroll();
113
- return unlockBodyScroll;
114
- }, [mounted]);
115
-
116
- // Capture focus on open; restore it on close/unmount.
117
- useEffect(() => {
118
- if (!open) return;
119
- previouslyFocused.current = document.activeElement as HTMLElement | null;
120
- const panel = panelRef.current;
121
- const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
122
- (first ?? panel)?.focus();
123
- return () => previouslyFocused.current?.focus();
124
- }, [open]);
125
-
126
- // Keyboard handling: Escape closes, Tab cycles within the panel (focus trap).
127
- // Attached at document level so no keyboard listener is needed on the dialog
128
- // div itself (which would trigger jsx-a11y/no-noninteractive-element-interactions).
129
- useEffect(() => {
130
- if (!open) return;
131
- function handleKeyDown(e: KeyboardEvent) {
132
- if (e.key === 'Escape') {
133
- e.stopPropagation();
134
- onClose();
135
- return;
136
- }
137
- if (e.key !== 'Tab') return;
138
- const panel = panelRef.current;
139
- if (!panel) return;
140
- const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
141
- const first = focusable[0];
142
- const last = focusable.at(-1);
143
- if (!first || !last) {
144
- e.preventDefault();
145
- return;
146
- }
147
- const active = document.activeElement;
148
- if (e.shiftKey && active === first) {
149
- e.preventDefault();
150
- last.focus();
151
- } else if (!e.shiftKey && active === last) {
152
- e.preventDefault();
153
- first.focus();
154
- }
155
- }
156
- document.addEventListener('keydown', handleKeyDown);
157
- return () => document.removeEventListener('keydown', handleKeyDown);
158
- }, [open, onClose]);
105
+ useOverlayScrollLock(mounted);
106
+ useOverlayFocusCapture(panelRef, open);
107
+ useOverlayKeyboardTrap(panelRef, open, onClose);
159
108
 
160
109
  if (!mounted) return null;
161
110
  const isOpenPhase = phase === 'open';
@@ -75,6 +75,50 @@ export const Closed: Story = {
75
75
  ),
76
76
  };
77
77
 
78
+ function DrawerWithTitleDemo() {
79
+ const [open, setOpen] = useState(false);
80
+ return (
81
+ <>
82
+ <Button onClick={() => setOpen(true)}>Open drawer</Button>
83
+ <Drawer open={open} onClose={() => setOpen(false)} title="Apps">
84
+ <ul style={{ listStyle: 'none', margin: 0, padding: '8px 0' }}>
85
+ {(['house', 'grid-2x2', 'settings', 'user'] as const).map((icon) => (
86
+ <li key={icon}>
87
+ <button
88
+ onClick={() => setOpen(false)}
89
+ style={{
90
+ display: 'flex',
91
+ alignItems: 'center',
92
+ gap: 12,
93
+ width: '100%',
94
+ padding: '12px 20px',
95
+ background: 'none',
96
+ border: 'none',
97
+ cursor: 'pointer',
98
+ fontSize: 15,
99
+ color: 'var(--sv-color-text-primary)',
100
+ fontFamily: 'system-ui',
101
+ }}
102
+ >
103
+ <Icon name={icon} size="md" aria-hidden />
104
+ {icon.replace(/-/g, ' ')}
105
+ </button>
106
+ </li>
107
+ ))}
108
+ </ul>
109
+ </Drawer>
110
+ </>
111
+ );
112
+ }
113
+
114
+ /** Opt-in `title` prop renders a built-in `OverlayHeader` (title + close)
115
+ * below the grab handle — hidden by default (see the `Default` story), for
116
+ * consumers that want one instead of building their own header markup. */
117
+ export const WithTitle: Story = {
118
+ args: { open: false, onClose: () => {}, children: null },
119
+ render: (_args) => <DrawerWithTitleDemo />,
120
+ };
121
+
78
122
  /** Play function opens the drawer and asserts its list items are visible. */
79
123
  export const OpenViaInteraction: Story = {
80
124
  args: { open: false, onClose: () => {}, children: null },
@@ -1,8 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, useRef } from 'react';
4
- import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
3
+ import { type PointerEvent as ReactPointerEvent, type ReactNode, useRef } from 'react';
5
4
  import { useMountTransition, usePrefersReducedMotion } from '../../motion';
5
+ import {
6
+ useOverlayFocusCapture,
7
+ useOverlayKeyboardTrap,
8
+ useOverlayScrollLock,
9
+ } from '../../overlay-shell';
10
+ import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
6
11
  import styles from './Drawer.module.css';
7
12
 
8
13
  // Matches --sv-motion-duration-base (Drawer.module.css) — see Dialog.tsx's
@@ -22,12 +27,15 @@ export interface DrawerProps {
22
27
  * "half screen or less" convention for Drawer, this is the only other
23
28
  * supported size; taller content should use `Sheet` instead. */
24
29
  snapHeight?: 'content' | 'half';
30
+ /** Renders a built-in `OverlayHeader` (title + close) below the grab
31
+ * handle. Omit when the content supplies its own header, or none is
32
+ * needed at all — e.g. the mobile shell's Apps drawer, whose grab handle
33
+ * and swipe-down/scrim-tap dismissal are enough on their own. Mirrors
34
+ * `Sheet`'s identical `title` prop. */
35
+ title?: string;
25
36
  children: ReactNode;
26
37
  }
27
38
 
28
- const FOCUSABLE =
29
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
30
-
31
39
  // Downward drag distance (px) past which releasing dismisses the drawer
32
40
  // instead of snapping back open. Distance-only, no velocity tracking — matches
33
41
  // the swipe-to-reveal gesture already established in consuming plugins (see
@@ -61,10 +69,10 @@ export function Drawer({
61
69
  onClose,
62
70
  'aria-label': ariaLabel,
63
71
  snapHeight = 'content',
72
+ title,
64
73
  children,
65
74
  }: DrawerProps) {
66
75
  const panelRef = useRef<HTMLDivElement>(null);
67
- const previouslyFocused = useRef<HTMLElement | null>(null);
68
76
  const reducedMotion = usePrefersReducedMotion();
69
77
  const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
70
78
  // Tracks an in-progress handle drag. Kept in a ref (not state) so pointermove
@@ -72,56 +80,9 @@ export function Drawer({
72
80
  // same technique as the tasks plugin's swipe-to-reveal rows.
73
81
  const dragStartY = useRef<number | null>(null);
74
82
 
75
- // Locked for the whole mounted lifetime (including the exit animation), not
76
- // just while `open` — see Dialog.tsx's identical comment for why.
77
- useEffect(() => {
78
- if (!mounted) return;
79
- lockBodyScroll();
80
- return unlockBodyScroll;
81
- }, [mounted]);
82
-
83
- useEffect(() => {
84
- if (!open) return;
85
- previouslyFocused.current = document.activeElement as HTMLElement | null;
86
- const panel = panelRef.current;
87
- const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
88
- (first ?? panel)?.focus();
89
- return () => previouslyFocused.current?.focus();
90
- }, [open]);
91
-
92
- // Keyboard handling: Escape closes, Tab cycles within the panel (focus trap).
93
- // Attached at document level so no keyboard listener is needed on the dialog
94
- // div itself (which would trigger jsx-a11y/no-noninteractive-element-interactions).
95
- useEffect(() => {
96
- if (!open) return;
97
- function handleKeyDown(e: KeyboardEvent) {
98
- if (e.key === 'Escape') {
99
- e.stopPropagation();
100
- onClose();
101
- return;
102
- }
103
- if (e.key !== 'Tab') return;
104
- const panel = panelRef.current;
105
- if (!panel) return;
106
- const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
107
- const first = focusable[0];
108
- const last = focusable.at(-1);
109
- if (!first || !last) {
110
- e.preventDefault();
111
- return;
112
- }
113
- const active = document.activeElement;
114
- if (e.shiftKey && active === first) {
115
- e.preventDefault();
116
- last.focus();
117
- } else if (!e.shiftKey && active === last) {
118
- e.preventDefault();
119
- first.focus();
120
- }
121
- }
122
- document.addEventListener('keydown', handleKeyDown);
123
- return () => document.removeEventListener('keydown', handleKeyDown);
124
- }, [open, onClose]);
83
+ useOverlayScrollLock(mounted);
84
+ useOverlayFocusCapture(panelRef, open);
85
+ useOverlayKeyboardTrap(panelRef, open, onClose);
125
86
 
126
87
  // Swipe-down-to-dismiss, initiated only from the grab handle (see the
127
88
  // component doc comment for why not the whole panel). Drags the panel
@@ -207,6 +168,7 @@ export function Drawer({
207
168
  onPointerUp={(e) => releaseDrag(e, true)}
208
169
  onPointerCancel={(e) => releaseDrag(e, false)}
209
170
  />
171
+ {title && <OverlayHeader title={title} onClose={onClose} />}
210
172
  {children}
211
173
  </div>
212
174
  </div>
@@ -110,6 +110,28 @@ describe('Drawer', () => {
110
110
  expect(screen.getByRole('dialog').className).not.toMatch(/panelHalf/);
111
111
  });
112
112
 
113
+ it('does not render OverlayHeader when title is omitted (hidden by default)', () => {
114
+ render(
115
+ <Drawer open onClose={() => {}} aria-label="Nav">
116
+ <button type="button">Own action</button>
117
+ </Drawer>,
118
+ );
119
+ // Only the content's own button — no OverlayHeader close button.
120
+ expect(screen.getAllByRole('button')).toHaveLength(1);
121
+ });
122
+
123
+ it('renders OverlayHeader (title + close) when title is provided', () => {
124
+ const onClose = vi.fn();
125
+ render(
126
+ <Drawer open onClose={onClose} aria-label="Nav" title="Apps">
127
+ Body
128
+ </Drawer>,
129
+ );
130
+ expect(screen.getByText('Apps')).toBeTruthy();
131
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
132
+ expect(onClose).toHaveBeenCalledOnce();
133
+ });
134
+
113
135
  describe('swipe-down-to-dismiss (grab handle)', () => {
114
136
  // The handle is the panel's first child, aria-hidden and unlabeled —
115
137
  // same "select via DOM position" approach the scrim-click test above
@@ -0,0 +1,3 @@
1
+ .root {
2
+ display: inline-flex;
3
+ }
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { HoverCard } from './HoverCard';
3
+ import { Avatar } from '../Avatar/Avatar';
4
+
5
+ const meta = {
6
+ title: 'Components/HoverCard',
7
+ component: HoverCard,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Hover-triggered popover on desktop, tap-to-toggle on touch. Built on Popover for positioning/collision detection — this only adds hover-intent timing and the touch fallback. Opens on keyboard focus too.',
14
+ },
15
+ },
16
+ },
17
+ args: { trigger: <span />, children: null, 'aria-label': 'Preview' },
18
+ } satisfies Meta<typeof HoverCard>;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export const Default: Story = {
26
+ render: () => (
27
+ <HoverCard
28
+ aria-label="User preview"
29
+ trigger={
30
+ <button
31
+ type="button"
32
+ style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
33
+ >
34
+ <Avatar name="Jane Smith" size="sm" />
35
+ </button>
36
+ }
37
+ >
38
+ <div style={{ padding: 'var(--sv-space-3)', fontSize: 14 }}>
39
+ <div style={{ fontWeight: 600 }}>Jane Smith</div>
40
+ <div style={{ color: 'var(--sv-color-text-muted)' }}>jane@example.com</div>
41
+ </div>
42
+ </HoverCard>
43
+ ),
44
+ };
45
+
46
+ export const WithLink: Story = {
47
+ render: () => (
48
+ <p style={{ fontSize: 14 }}>
49
+ Assigned to{' '}
50
+ <HoverCard
51
+ aria-label="Assignee preview"
52
+ trigger={
53
+ <a href="/console/users/jane-smith" style={{ color: 'var(--sv-color-text-primary)' }}>
54
+ Jane Smith
55
+ </a>
56
+ }
57
+ >
58
+ <div style={{ padding: 'var(--sv-space-3)', fontSize: 14 }}>
59
+ <div style={{ fontWeight: 600 }}>Jane Smith</div>
60
+ <div style={{ color: 'var(--sv-color-text-muted)' }}>12 tasks completed this month</div>
61
+ </div>
62
+ </HoverCard>
63
+ .
64
+ </p>
65
+ ),
66
+ };