@sovereignfs/ui 0.45.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 (93) hide show
  1. package/dist/LegalLinks.module.css +24 -0
  2. package/dist/Markdown.module.css +49 -0
  3. package/dist/MobileAppsDrawer.module.css +81 -0
  4. package/dist/MobileFooter.module.css +73 -0
  5. package/dist/MobileHeader.module.css +63 -0
  6. package/dist/PageContainer.module.css +20 -0
  7. package/dist/SwipableMobileCarousel.module.css +55 -0
  8. package/dist/SwipableMobileCarouselDots.module.css +49 -0
  9. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  10. package/dist/index.d.ts +654 -6
  11. package/dist/index.js +1856 -1133
  12. package/dist/tokens/primitives.css +25 -0
  13. package/package.json +13 -9
  14. package/src/components/Badge/Badge.tsx +1 -7
  15. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  16. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  17. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  18. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  19. package/src/components/Dialog/Dialog.tsx +8 -59
  20. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  21. package/src/components/Drawer/Drawer.tsx +18 -56
  22. package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
  23. package/src/components/Icon/icons/index.ts +8 -0
  24. package/src/components/Icon/icons/menu.tsx +22 -0
  25. package/src/components/Icon/icons/smartphone.tsx +21 -0
  26. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  27. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  28. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  29. package/src/components/Markdown/Markdown.module.css +49 -0
  30. package/src/components/Markdown/Markdown.stories.tsx +30 -0
  31. package/src/components/Markdown/Markdown.tsx +168 -0
  32. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
  33. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  34. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  35. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  36. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  37. package/src/components/MobileFooter/MobileFooter.tsx +109 -0
  38. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
  39. package/src/components/MobileHeader/MobileHeader.module.css +63 -0
  40. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  41. package/src/components/MobileHeader/MobileHeader.tsx +77 -0
  42. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
  43. package/src/components/NavTabs/NavTabs.tsx +39 -11
  44. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  45. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  46. package/src/components/PageContainer/PageContainer.module.css +20 -0
  47. package/src/components/PageContainer/PageContainer.tsx +31 -0
  48. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
  49. package/src/components/PageHeader/PageHeader.tsx +16 -2
  50. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  51. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  52. package/src/components/Sheet/Sheet.tsx +9 -51
  53. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
  54. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
  55. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  56. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  57. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  58. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  59. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  60. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
  61. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  62. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  63. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
  64. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
  65. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  66. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  67. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  68. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  69. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  70. package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
  71. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  72. package/src/hooks/index.ts +18 -0
  73. package/src/hooks/useCarouselRouteSync.ts +92 -0
  74. package/src/hooks/useIsOffline.ts +41 -0
  75. package/src/hooks/useOfflineTileState.ts +45 -0
  76. package/src/hooks/useResponsiveLayout.ts +36 -0
  77. package/src/hooks/useSnapCarousel.ts +79 -0
  78. package/src/hooks/useSwipeReveal.ts +107 -0
  79. package/src/index.ts +63 -2
  80. package/src/overlay-shell.ts +94 -0
  81. package/src/stories/DesignSystemOverview.stories.tsx +322 -1
  82. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  83. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  84. package/src/stories/InteractionHooks.stories.tsx +483 -0
  85. package/src/stories/MobilePatterns.stories.tsx +247 -2
  86. package/src/stories/NavTabs.stories.tsx +18 -0
  87. package/src/stories/OfflineGate.stories.tsx +42 -0
  88. package/src/stories/PageContainer.stories.tsx +37 -0
  89. package/src/stories/PageHeader.stories.tsx +11 -0
  90. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  91. package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
  92. package/src/stories/TokenGallery.stories.tsx +65 -0
  93. package/src/tokens/primitives.css +25 -0
@@ -77,6 +77,16 @@
77
77
  --sv-font-weight-semibold: 600;
78
78
  --sv-font-weight-bold: 700;
79
79
 
80
+ /* In-app text-size control (task 10.2), discharging the pinch-zoom-disabled
81
+ accessibility debt (runtime/app/layout.tsx sets userScalable: false).
82
+ Every --sv-font-size-* above is rem-based, so scaling the root font size
83
+ scales the whole type system with no per-component changes. Driven by
84
+ [data-text-size] on <html>, set pre-paint by runtime/src/theme-script.ts
85
+ from the `sv-text-size` cookie — same mechanism as [data-theme]. Like
86
+ --sv-radius-scale, this is a theme-stable scale token with no separate
87
+ semantic tier. */
88
+ --sv-text-size-scale: 1;
89
+
80
90
  /* Icon sizes — scale tokens used directly by the Icon component */
81
91
  --sv-icon-size-xs: 0.75rem; /* 12px — inline metadata (e.g. a due-date badge) */
82
92
  --sv-icon-size-sm: 1rem; /* 16px — inline with body text */
@@ -130,3 +140,18 @@
130
140
  1
131
141
  ); /* slight overshoot — playful emphasis, use sparingly */
132
142
  }
143
+
144
+ /* Root font size scales with --sv-text-size-scale — see the token comment
145
+ above. calc(... * 100%) keeps the browser/OS text-zoom default intact at
146
+ scale 1 rather than hardcoding a px value. */
147
+ html {
148
+ font-size: calc(var(--sv-text-size-scale) * 100%);
149
+ }
150
+
151
+ :root[data-text-size='large'] {
152
+ --sv-text-size-scale: 1.125; /* +12.5% */
153
+ }
154
+
155
+ :root[data-text-size='larger'] {
156
+ --sv-text-size-scale: 1.25; /* +25% */
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovereignfs/ui",
3
- "version": "0.45.0",
3
+ "version": "0.54.0",
4
4
  "type": "module",
5
5
  "description": "Sovereign Design System — design tokens and React components.",
6
6
  "keywords": [
@@ -36,16 +36,17 @@
36
36
  "react-dom": "^19.0.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@storybook/addon-a11y": "^10.4.6",
40
- "@storybook/addon-themes": "^10.4.6",
41
- "@storybook/react-vite": "^10.4.6",
39
+ "@playwright/test": "^1.62.1",
40
+ "@storybook/addon-a11y": "^10.5.5",
41
+ "@storybook/addon-themes": "^10.5.5",
42
+ "@storybook/react-vite": "^10.5.5",
42
43
  "@testing-library/dom": "^10.4.1",
43
44
  "@testing-library/react": "^16.3.2",
44
- "@types/react": "^19.0.0",
45
- "@types/react-dom": "^19.0.0",
46
- "react": "^19.0.0",
47
- "react-dom": "^19.0.0",
48
- "storybook": "^10.4.6",
45
+ "@types/react": "^19.2.18",
46
+ "@types/react-dom": "^19.2.4",
47
+ "react": "^19.2.8",
48
+ "react-dom": "^19.2.8",
49
+ "storybook": "^10.5.5",
49
50
  "tsup": "^8.5.1",
50
51
  "typescript": "^5.9.3",
51
52
  "@sovereignfs/tsconfig": "0.0.0"
@@ -53,7 +54,10 @@
53
54
  "scripts": {
54
55
  "build": "tsup",
55
56
  "storybook": "storybook dev -p 6006",
57
+ "storybook:visual": "storybook dev -p 6100 --ci --quiet",
56
58
  "build-storybook": "storybook build --output-dir storybook-static",
59
+ "test:visual": "playwright test --config=playwright.visual.config.ts",
60
+ "test:visual:update": "playwright test --config=playwright.visual.config.ts --update-snapshots",
57
61
  "typecheck": "tsc --noEmit"
58
62
  },
59
63
  "types": "./dist/index.d.ts"
@@ -4,13 +4,7 @@ import styles from './Badge.module.css';
4
4
  export type BadgeVariant = 'role' | 'status' | 'mono';
5
5
  export type BadgeSize = 'sm' | 'md' | 'lg';
6
6
  export type BadgeStatus =
7
- | 'active'
8
- | 'enabled'
9
- | 'deactivated'
10
- | 'failed'
11
- | 'invited'
12
- | 'pending'
13
- | 'neutral';
7
+ 'active' | 'enabled' | 'deactivated' | 'failed' | 'invited' | 'pending' | 'neutral';
14
8
 
15
9
  export interface BadgeProps {
16
10
  variant?: BadgeVariant;
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { EmptyState } from '../EmptyState/EmptyState';
5
+
6
+ export interface DeviceOnlyGateProps {
7
+ children: ReactNode;
8
+ /** Defaults to a generic "this app" phrasing — pass the plugin's own name. */
9
+ surfaceName?: string;
10
+ /**
11
+ * The caller's own `isDeviceOnlyTierAvailable()` result (from
12
+ * `@sovereignfs/sdk/device-client`) — taken as a plain boolean rather than
13
+ * computed inside this component, so `@sovereignfs/ui` never depends on
14
+ * `@sovereignfs/sdk` (this package must stay usable standalone, outside the
15
+ * plugin runtime — RFC 0073).
16
+ */
17
+ available: boolean;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * DeviceOnlyGate — blocks a `device-only`-tier plugin's content from
23
+ * rendering on a surface that cannot provide the durable, encrypted,
24
+ * device-auth-gated store that tier requires, showing an explanatory empty
25
+ * state instead of a broken screen (research 0012, epic tasks 2.33 + 3.36).
26
+ *
27
+ * A `device-only` plugin wraps its own root content in this the same way
28
+ * Console and Account wrap theirs in `OfflineGate` — an opt-in pattern each
29
+ * surface applies to itself, not a platform-level route gate. The launcher
30
+ * tile's own "Phone only" badge (`useOfflineTileState`) is advisory UI on top
31
+ * of this; this component is the actual gate, since a user can always reach a
32
+ * route directly (a bookmark, a deep link) without passing through the
33
+ * launcher tile at all.
34
+ *
35
+ * **Not a security boundary.** Availability here is a capability signal, not
36
+ * an authorization check — the real protection for `device-only` data is
37
+ * that it is encrypted and the key requires device auth to release (epic task
38
+ * 1.22), so it stays inaccessible regardless of whether this gate is present,
39
+ * bypassed, or simply never reached (e.g. a hand-crafted request against the
40
+ * plugin's own API routes).
41
+ */
42
+ export function DeviceOnlyGate({
43
+ children,
44
+ surfaceName,
45
+ available,
46
+ className,
47
+ }: DeviceOnlyGateProps) {
48
+ if (available) return <>{children}</>;
49
+
50
+ return (
51
+ <EmptyState
52
+ icon="smartphone"
53
+ heading="Phone only"
54
+ description={`${surfaceName ?? 'This app'} is only available on a phone with secure storage set up.`}
55
+ className={className}
56
+ />
57
+ );
58
+ }
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { DeviceOnlyGate } from '../DeviceOnlyGate';
5
+
6
+ describe('DeviceOnlyGate', () => {
7
+ afterEach(cleanup);
8
+
9
+ it('renders children unchanged when available', () => {
10
+ render(
11
+ <DeviceOnlyGate available={true}>
12
+ <p>Wallet content</p>
13
+ </DeviceOnlyGate>,
14
+ );
15
+ expect(screen.getByText('Wallet content')).toBeDefined();
16
+ });
17
+
18
+ it('replaces children with an explanatory empty state when unavailable', () => {
19
+ render(
20
+ <DeviceOnlyGate available={false}>
21
+ <p>Wallet content</p>
22
+ </DeviceOnlyGate>,
23
+ );
24
+ expect(screen.queryByText('Wallet content')).toBeNull();
25
+ expect(screen.getByText('Phone only')).toBeDefined();
26
+ });
27
+
28
+ it('names the surface in the message when provided', () => {
29
+ render(
30
+ <DeviceOnlyGate available={false} surfaceName="Wallet">
31
+ <p>Wallet content</p>
32
+ </DeviceOnlyGate>,
33
+ );
34
+ expect(screen.getByText(/Wallet is only available on a phone/)).toBeDefined();
35
+ });
36
+
37
+ it('falls back to generic phrasing with no surface name', () => {
38
+ render(
39
+ <DeviceOnlyGate available={false}>
40
+ <p>Wallet content</p>
41
+ </DeviceOnlyGate>,
42
+ );
43
+ expect(screen.getByText(/This app is only available on a phone/)).toBeDefined();
44
+ });
45
+ });
@@ -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
+ });
@@ -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>