@sovereignfs/ui 0.45.0 → 0.83.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Accordion.module.css +1 -1
- package/dist/Alert.module.css +1 -1
- package/dist/AppsLauncher.module.css +96 -0
- package/dist/Avatar.module.css +1 -1
- package/dist/Badge.module.css +30 -7
- package/dist/BalanceChip.module.css +1 -1
- package/dist/Button.module.css +18 -2
- package/dist/Card.module.css +1 -1
- package/dist/CardTile.module.css +181 -0
- package/dist/CodeTextarea.module.css +1 -1
- package/dist/Collapsible.module.css +1 -1
- package/dist/ColorPicker.module.css +132 -0
- package/dist/Combobox.module.css +2 -2
- package/dist/Command.module.css +1 -1
- package/dist/ConfirmDialog.module.css +2 -2
- package/dist/ConsentPrompt.module.css +21 -0
- package/dist/DatePicker.module.css +1 -1
- package/dist/Dialog.module.css +130 -21
- package/dist/Drawer.module.css +36 -7
- package/dist/Header.module.css +62 -0
- package/dist/HeaderFooterLayout.module.css +21 -0
- package/dist/IconPicker.module.css +1 -1
- package/dist/Input.module.css +1 -1
- package/dist/Kbd.module.css +1 -1
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +73 -0
- package/dist/Menu.module.css +1 -0
- package/dist/Message.module.css +1 -1
- package/dist/MessageScroller.module.css +12 -1
- package/dist/MobileAppsDrawer.module.css +82 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +76 -0
- package/dist/NavList.module.css +130 -0
- package/dist/NavTabs.module.css +1 -1
- package/dist/NotificationsPanel.module.css +258 -0
- package/dist/OverlayHeader.module.css +1 -1
- package/dist/PageContainer.module.css +82 -0
- package/dist/PageHeader.module.css +54 -0
- package/dist/PageLayout.module.css +51 -0
- package/dist/Popover.module.css +1 -1
- package/dist/QuantityStepper.module.css +1 -1
- package/dist/Resizable.module.css +3 -3
- package/dist/RootLayout.module.css +12 -0
- package/dist/SegmentedControl.module.css +1 -1
- package/dist/Select.module.css +1 -1
- package/dist/Sheet.module.css +4 -1
- package/dist/SplitPane.module.css +4 -4
- package/dist/StatusBadge.module.css +1 -1
- package/dist/StepStrip.module.css +53 -0
- package/dist/SuggestionInput.module.css +2 -2
- package/dist/SwipableMobileCarousel.module.css +64 -0
- package/dist/SwipableMobileCarouselDots.module.css +64 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/SwipeStack.module.css +101 -0
- package/dist/SystemBanner.module.css +10 -0
- package/dist/Table.module.css +2 -2
- package/dist/Tabs.module.css +1 -1
- package/dist/TagInput.module.css +2 -2
- package/dist/Textarea.module.css +9 -1
- package/dist/ThreeColumnLayout.module.css +35 -0
- package/dist/Toast.module.css +62 -3
- package/dist/Tooltip.module.css +1 -1
- package/dist/UserMenu.module.css +90 -0
- package/dist/index.d.ts +1680 -30
- package/dist/index.js +4618 -1443
- package/dist/tokens/primitives.css +36 -0
- package/dist/tokens/semantic.css +32 -0
- package/dist/tokens/theme-presets.css +33 -0
- package/package.json +32 -12
- package/src/__tests__/package-json.test.ts +8 -0
- package/src/components/Accordion/Accordion.module.css +1 -1
- package/src/components/Alert/Alert.module.css +1 -1
- package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
- package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
- package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
- package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
- package/src/components/Avatar/Avatar.module.css +1 -1
- package/src/components/Badge/Badge.module.css +30 -7
- package/src/components/Badge/Badge.stories.tsx +16 -4
- package/src/components/Badge/Badge.tsx +8 -11
- package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
- package/src/components/BalanceChip/BalanceChip.module.css +1 -1
- package/src/components/Button/Button.module.css +18 -2
- package/src/components/Card/Card.module.css +1 -1
- package/src/components/CardTile/CardTile.module.css +181 -0
- package/src/components/CardTile/CardTile.stories.tsx +86 -0
- package/src/components/CardTile/CardTile.tsx +128 -0
- package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
- package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
- package/src/components/Collapsible/Collapsible.module.css +1 -1
- package/src/components/ColorPicker/ColorPicker.module.css +132 -0
- package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
- package/src/components/ColorPicker/ColorPicker.tsx +157 -0
- package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
- package/src/components/Combobox/Combobox.module.css +2 -2
- package/src/components/Command/Command.module.css +1 -1
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
- package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
- package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
- package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
- package/src/components/ContextMenu/ContextMenu.tsx +1 -1
- package/src/components/DatePicker/DatePicker.module.css +1 -1
- package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
- package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
- package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
- package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
- package/src/components/Dialog/Dialog.module.css +130 -21
- package/src/components/Dialog/Dialog.stories.tsx +179 -2
- package/src/components/Dialog/Dialog.tsx +137 -94
- package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
- package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
- package/src/components/Drawer/Drawer.module.css +36 -7
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +27 -66
- package/src/components/Drawer/__tests__/Drawer.test.tsx +55 -6
- package/src/components/GripIcon/GripIcon.tsx +27 -0
- package/src/components/Header/Header.module.css +62 -0
- package/src/components/Header/Header.stories.tsx +452 -0
- package/src/components/Header/Header.tsx +102 -0
- package/src/components/Header/__tests__/Header.test.tsx +104 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
- package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
- package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
- package/src/components/Icon/icons/bold.tsx +20 -0
- package/src/components/Icon/icons/calendar.tsx +4 -4
- package/src/components/Icon/icons/carrot.tsx +3 -3
- package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
- package/src/components/Icon/icons/code.tsx +21 -0
- package/src/components/Icon/icons/copy.tsx +21 -0
- package/src/components/Icon/icons/credit-card.tsx +21 -0
- package/src/components/Icon/icons/database.tsx +22 -0
- package/src/components/Icon/icons/download.tsx +22 -0
- package/src/components/Icon/icons/file-text.tsx +24 -0
- package/src/components/Icon/icons/folder-closed.tsx +21 -0
- package/src/components/Icon/icons/folder-open.tsx +20 -0
- package/src/components/Icon/icons/folder.tsx +20 -0
- package/src/components/Icon/icons/folders.tsx +21 -0
- package/src/components/Icon/icons/hat-glasses.tsx +24 -0
- package/src/components/Icon/icons/history.tsx +22 -0
- package/src/components/Icon/icons/inbox.tsx +21 -0
- package/src/components/Icon/icons/index.ts +169 -1
- package/src/components/Icon/icons/italic.tsx +22 -0
- package/src/components/Icon/icons/layers.tsx +22 -0
- package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
- package/src/components/Icon/icons/layout-grid.tsx +23 -0
- package/src/components/Icon/icons/link.tsx +21 -0
- package/src/components/Icon/icons/list-ordered.tsx +25 -0
- package/src/components/Icon/icons/list.tsx +25 -0
- package/src/components/Icon/icons/luggage.tsx +24 -0
- package/src/components/Icon/icons/map-pin.tsx +21 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/minus.tsx +20 -0
- package/src/components/Icon/icons/paint-bucket.tsx +23 -0
- package/src/components/Icon/icons/panel-left.tsx +21 -0
- package/src/components/Icon/icons/pin.tsx +21 -0
- package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
- package/src/components/Icon/icons/refresh-cw.tsx +23 -0
- package/src/components/Icon/icons/rotate-cw.tsx +21 -0
- package/src/components/Icon/icons/route.tsx +22 -0
- package/src/components/Icon/icons/send.tsx +21 -0
- package/src/components/Icon/icons/share-2.tsx +24 -0
- package/src/components/Icon/icons/sheet.tsx +24 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- package/src/components/Icon/icons/square-kanban.tsx +23 -0
- package/src/components/Icon/icons/table.tsx +23 -0
- package/src/components/Icon/icons/user-round-plus.tsx +23 -0
- package/src/components/Icon/icons/users.tsx +23 -0
- package/src/components/IconPicker/IconPicker.module.css +1 -1
- package/src/components/Input/Input.module.css +1 -1
- package/src/components/Input/Input.tsx +8 -2
- package/src/components/Kbd/Kbd.module.css +1 -1
- package/src/components/LegalLinks/LegalLinks.module.css +24 -0
- package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
- package/src/components/LegalLinks/LegalLinks.tsx +40 -0
- package/src/components/Markdown/Markdown.module.css +73 -0
- package/src/components/Markdown/Markdown.stories.tsx +40 -0
- package/src/components/Markdown/Markdown.tsx +305 -0
- package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
- package/src/components/Menu/Menu.module.css +1 -0
- package/src/components/Menu/Menu.tsx +72 -22
- package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
- package/src/components/Menubar/Menubar.tsx +1 -1
- package/src/components/Message/Message.module.css +1 -1
- package/src/components/MessageScroller/MessageScroller.module.css +12 -1
- package/src/components/MessageScroller/MessageScroller.tsx +12 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +82 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
- package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
- package/src/components/MobileFooter/MobileFooter.module.css +73 -0
- package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
- package/src/components/MobileFooter/MobileFooter.tsx +122 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +129 -0
- package/src/components/MobileHeader/MobileHeader.module.css +76 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +90 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +113 -0
- package/src/components/NavList/NavList.module.css +130 -0
- package/src/components/NavList/NavList.tsx +136 -0
- package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
- package/src/components/NavTabs/NavTabs.module.css +1 -1
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
- package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
- package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
- package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
- package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
- package/src/components/PageContainer/PageContainer.module.css +82 -0
- package/src/components/PageContainer/PageContainer.tsx +64 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +70 -0
- package/src/components/PageHeader/PageHeader.module.css +54 -0
- package/src/components/PageHeader/PageHeader.tsx +157 -2
- package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
- package/src/components/PageLayout/PageLayout.module.css +51 -0
- package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
- package/src/components/PageLayout/PageLayout.tsx +51 -0
- package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
- package/src/components/Popover/Popover.module.css +1 -1
- package/src/components/Popover/Popover.tsx +86 -4
- package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
- package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
- package/src/components/Resizable/Resizable.module.css +3 -3
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/RootLayout/RootLayout.module.css +12 -0
- package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
- package/src/components/RootLayout/RootLayout.tsx +185 -0
- package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
- package/src/components/Select/Select.module.css +1 -1
- package/src/components/Sheet/Sheet.module.css +4 -1
- package/src/components/Sheet/Sheet.tsx +14 -56
- package/src/components/SplitPane/SplitPane.module.css +4 -4
- package/src/components/StatusBadge/StatusBadge.module.css +1 -1
- package/src/components/StepStrip/StepStrip.module.css +53 -0
- package/src/components/StepStrip/StepStrip.tsx +74 -0
- package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +64 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +243 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +214 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
- package/src/components/SwipableMobileCarousel/context.ts +13 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +64 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +73 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
- package/src/components/SwipeStack/SwipeStack.module.css +101 -0
- package/src/components/SwipeStack/SwipeStack.tsx +199 -0
- package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
- package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
- package/src/components/SystemBanner/SystemBanner.module.css +10 -0
- package/src/components/Table/Table.module.css +2 -2
- package/src/components/Tabs/Tabs.module.css +1 -1
- package/src/components/TagInput/TagInput.module.css +2 -2
- package/src/components/Textarea/Textarea.module.css +9 -1
- package/src/components/Textarea/Textarea.tsx +28 -5
- package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
- package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
- package/src/components/Toast/Toast.module.css +62 -3
- package/src/components/Toast/Toast.stories.tsx +1 -1
- package/src/components/Tooltip/Tooltip.module.css +1 -1
- package/src/components/UserMenu/UserMenu.module.css +90 -0
- package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
- package/src/components/UserMenu/UserMenu.tsx +132 -0
- package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
- package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
- package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
- package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
- package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
- package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +175 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
- package/src/hooks/index.ts +27 -0
- package/src/hooks/useCarouselRouteSync.ts +92 -0
- package/src/hooks/useIsOffline.ts +41 -0
- package/src/hooks/useOfflineTileState.ts +45 -0
- package/src/hooks/usePublishShellChromeHeight.ts +96 -0
- package/src/hooks/useReorderSensors.ts +88 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +284 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/hooks/useSwipeStack.ts +235 -0
- package/src/index.ts +124 -3
- package/src/overlay-shell.ts +159 -0
- package/src/stories/DesignSystemOverview.stories.tsx +933 -6
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/GripIcon.stories.tsx +29 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/LayoutSystem.stories.tsx +639 -0
- package/src/stories/MenuEntries.stories.tsx +86 -0
- package/src/stories/MobilePatterns.stories.tsx +247 -2
- package/src/stories/NavList.stories.tsx +147 -0
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +82 -0
- package/src/stories/PageHeader.stories.tsx +65 -1
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/StepStrip.stories.tsx +76 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +362 -0
- package/src/stories/SwipeStack.stories.tsx +200 -0
- package/src/stories/Textarea.stories.tsx +12 -0
- package/src/stories/TokenGallery.stories.tsx +115 -0
- package/src/tokens/primitives.css +36 -0
- package/src/tokens/semantic.css +32 -0
- package/src/tokens/theme-presets.css +33 -0
- package/src/tokens/theme-presets.ts +72 -0
|
@@ -1,22 +1,47 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
5
|
import { ConfirmDialog } from '../ConfirmDialog';
|
|
5
6
|
|
|
6
7
|
// jsdom does not implement HTMLDialogElement.showModal()/close() (a
|
|
7
8
|
// long-standing, documented gap) — polyfill the minimum behavior the
|
|
8
9
|
// component depends on: the `open` attribute reflecting visibility, and
|
|
9
10
|
// close() dispatching the native 'close' event ConfirmDialog listens for.
|
|
11
|
+
//
|
|
12
|
+
// Two details matter here beyond the minimum, both load-bearing for the
|
|
13
|
+
// double-onClose regression tests below: (1) a no-op when already closed,
|
|
14
|
+
// matching the HTML spec's own early return, and (2) the 'close' event is
|
|
15
|
+
// queued rather than dispatched synchronously ("queue an element task ... to
|
|
16
|
+
// fire an event named close" per spec) — deferring it means a listener
|
|
17
|
+
// (re-)attached during the SAME synchronous React commit that triggered this
|
|
18
|
+
// close() call is still reachable once the event actually fires, exactly as
|
|
19
|
+
// in a real browser. Dispatching synchronously instead (the simpler version
|
|
20
|
+
// this replaced) fires the event in the middle of React's effect
|
|
21
|
+
// cleanup/setup phase, when no listener happens to be attached yet — which
|
|
22
|
+
// made the double-call bug this file guards against silently unreproducible
|
|
23
|
+
// in jsdom regardless of whether the underlying component bug was present.
|
|
10
24
|
beforeEach(() => {
|
|
11
25
|
HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) {
|
|
12
26
|
this.setAttribute('open', '');
|
|
13
27
|
};
|
|
14
28
|
HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) {
|
|
29
|
+
if (!this.hasAttribute('open')) return;
|
|
15
30
|
this.removeAttribute('open');
|
|
16
|
-
this.dispatchEvent(new Event('close'));
|
|
31
|
+
queueMicrotask(() => this.dispatchEvent(new Event('close')));
|
|
17
32
|
};
|
|
18
33
|
});
|
|
19
34
|
|
|
35
|
+
// Flushes any chain of queueMicrotask-deferred dispatches and the React
|
|
36
|
+
// state updates/effects they trigger. A single real macrotask tick is
|
|
37
|
+
// enough: the microtask queue always fully drains — including microtasks
|
|
38
|
+
// newly queued from within an already-running one — before a macrotask runs.
|
|
39
|
+
async function flushDeferredDialogEvents() {
|
|
40
|
+
await act(async () => {
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
describe('ConfirmDialog', () => {
|
|
21
46
|
afterEach(cleanup);
|
|
22
47
|
|
|
@@ -51,15 +76,58 @@ describe('ConfirmDialog', () => {
|
|
|
51
76
|
expect(screen.getByText('Example').tagName).toBe('STRONG');
|
|
52
77
|
});
|
|
53
78
|
|
|
54
|
-
it('calls onClose on Cancel', () => {
|
|
79
|
+
it('calls onClose on Cancel', async () => {
|
|
55
80
|
const onClose = vi.fn();
|
|
56
81
|
render(
|
|
57
82
|
<ConfirmDialog open onClose={onClose} onConfirm={() => {}} title="Remove" message="Sure?" />,
|
|
58
83
|
);
|
|
84
|
+
// Cancel now calls dialogRef.current.close() rather than onClose
|
|
85
|
+
// directly (see ConfirmDialog.tsx) — onClose only fires once the
|
|
86
|
+
// resulting (deferred, see the polyfill above) native 'close' event
|
|
87
|
+
// reaches ConfirmDialog's own listener.
|
|
59
88
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
89
|
+
await flushDeferredDialogEvents();
|
|
60
90
|
expect(onClose).toHaveBeenCalledOnce();
|
|
61
91
|
});
|
|
62
92
|
|
|
93
|
+
// Statefully wired, unlike the test above: a real consumer's onClose flips
|
|
94
|
+
// its own `open` state to false, which re-runs ConfirmDialog's internal
|
|
95
|
+
// `if (open) showModal(); else el.close();` effect and calls el.close()
|
|
96
|
+
// itself. Pre-fix, Cancel/backdrop-click also called onClose directly,
|
|
97
|
+
// so this path fired onClose a second time; the test above's static
|
|
98
|
+
// `open` prop never changes, so it can't exercise that second re-render.
|
|
99
|
+
function StatefulConfirmDialog({ onCloseSpy }: { onCloseSpy: () => void }) {
|
|
100
|
+
const [open, setOpen] = useState(true);
|
|
101
|
+
return (
|
|
102
|
+
<ConfirmDialog
|
|
103
|
+
open={open}
|
|
104
|
+
onClose={() => {
|
|
105
|
+
onCloseSpy();
|
|
106
|
+
setOpen(false);
|
|
107
|
+
}}
|
|
108
|
+
onConfirm={() => {}}
|
|
109
|
+
title="Remove"
|
|
110
|
+
message="Sure?"
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it('calls onClose exactly once when a stateful consumer dismisses via Cancel', async () => {
|
|
116
|
+
const onCloseSpy = vi.fn();
|
|
117
|
+
render(<StatefulConfirmDialog onCloseSpy={onCloseSpy} />);
|
|
118
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
119
|
+
await flushDeferredDialogEvents();
|
|
120
|
+
expect(onCloseSpy).toHaveBeenCalledOnce();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('calls onClose exactly once when a stateful consumer dismisses via backdrop click', async () => {
|
|
124
|
+
const onCloseSpy = vi.fn();
|
|
125
|
+
render(<StatefulConfirmDialog onCloseSpy={onCloseSpy} />);
|
|
126
|
+
fireEvent.click(screen.getByRole('dialog'));
|
|
127
|
+
await flushDeferredDialogEvents();
|
|
128
|
+
expect(onCloseSpy).toHaveBeenCalledOnce();
|
|
129
|
+
});
|
|
130
|
+
|
|
63
131
|
it('calls onConfirm on the confirm action, without itself closing', () => {
|
|
64
132
|
const onConfirm = vi.fn();
|
|
65
133
|
const onClose = vi.fn();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--sv-space-2);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.title {
|
|
8
|
+
font-size: var(--sv-font-size-sm);
|
|
9
|
+
color: var(--sv-color-text-primary);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.description {
|
|
13
|
+
font-size: var(--sv-font-size-sm);
|
|
14
|
+
color: var(--sv-color-text-muted);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.actions {
|
|
18
|
+
display: flex;
|
|
19
|
+
gap: var(--sv-space-2);
|
|
20
|
+
margin-top: var(--sv-space-1);
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ConsentPrompt } from './ConsentPrompt';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/ConsentPrompt',
|
|
6
|
+
component: ConsentPrompt,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
args: {
|
|
9
|
+
consumerName: 'Ledger',
|
|
10
|
+
providerName: 'Finance Tracker',
|
|
11
|
+
contract: 'expenses',
|
|
12
|
+
description: 'Monthly expense totals by category, no transaction-level detail.',
|
|
13
|
+
onAllow: () => {},
|
|
14
|
+
onDeny: () => {},
|
|
15
|
+
},
|
|
16
|
+
} satisfies Meta<typeof ConsentPrompt>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {};
|
|
24
|
+
|
|
25
|
+
export const NoDescription: Story = {
|
|
26
|
+
args: { description: null },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const Pending: Story = {
|
|
30
|
+
args: { pending: true },
|
|
31
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Button } from '../Button/Button';
|
|
2
|
+
import { Card } from '../Card/Card';
|
|
3
|
+
import styles from './ConsentPrompt.module.css';
|
|
4
|
+
|
|
5
|
+
export interface ConsentPromptProps {
|
|
6
|
+
/** The plugin requesting access. */
|
|
7
|
+
consumerName: string;
|
|
8
|
+
/** The plugin whose data would be read. */
|
|
9
|
+
providerName: string;
|
|
10
|
+
/** The named contract being requested (e.g. `"expenses"`). */
|
|
11
|
+
contract: string;
|
|
12
|
+
/**
|
|
13
|
+
* The provider's own manifest-declared explanation of what this contract
|
|
14
|
+
* exposes (`data.provides[].description`) — never caller-supplied at
|
|
15
|
+
* request time, so a consuming plugin can't write its own persuasive copy
|
|
16
|
+
* for what it's asking to read.
|
|
17
|
+
*/
|
|
18
|
+
description?: string | null;
|
|
19
|
+
onAllow: () => void;
|
|
20
|
+
onDeny?: () => void;
|
|
21
|
+
allowLabel?: string;
|
|
22
|
+
denyLabel?: string;
|
|
23
|
+
/** Disables both buttons and shows a busy state on Allow while a request is in flight. */
|
|
24
|
+
pending?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The consent screen shown before one plugin is allowed to read another
|
|
30
|
+
* plugin's data (RFC 0002 §4) — the step that was previously missing between
|
|
31
|
+
* a `ConsentRequiredError` and a grant actually being created. Presentational
|
|
32
|
+
* only: holds no state of its own, so it renders identically wherever it's
|
|
33
|
+
* used (currently Account → Data's pending-requests list).
|
|
34
|
+
*/
|
|
35
|
+
export function ConsentPrompt({
|
|
36
|
+
consumerName,
|
|
37
|
+
providerName,
|
|
38
|
+
contract,
|
|
39
|
+
description,
|
|
40
|
+
onAllow,
|
|
41
|
+
onDeny,
|
|
42
|
+
allowLabel = 'Allow',
|
|
43
|
+
denyLabel = 'Not now',
|
|
44
|
+
pending = false,
|
|
45
|
+
className,
|
|
46
|
+
}: ConsentPromptProps) {
|
|
47
|
+
return (
|
|
48
|
+
<Card padding="sm" className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
49
|
+
<p className={styles.title}>
|
|
50
|
+
<strong>{consumerName}</strong> wants to read <strong>{contract}</strong> from{' '}
|
|
51
|
+
<strong>{providerName}</strong>
|
|
52
|
+
</p>
|
|
53
|
+
{description ? <p className={styles.description}>{description}</p> : null}
|
|
54
|
+
<div className={styles.actions}>
|
|
55
|
+
<Button variant="primary" size="sm" onClick={onAllow} disabled={pending} loading={pending}>
|
|
56
|
+
{allowLabel}
|
|
57
|
+
</Button>
|
|
58
|
+
{onDeny ? (
|
|
59
|
+
<Button variant="ghost" size="sm" onClick={onDeny} disabled={pending}>
|
|
60
|
+
{denyLabel}
|
|
61
|
+
</Button>
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
</Card>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
line-height: 1.5;
|
|
18
18
|
color: var(--sv-color-text-primary);
|
|
19
19
|
background-color: var(--sv-color-surface);
|
|
20
|
-
border:
|
|
20
|
+
border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
|
|
21
21
|
border-radius: var(--sv-radius-md);
|
|
22
22
|
cursor: pointer;
|
|
23
23
|
transition:
|
|
@@ -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
|
+
});
|