@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.
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +49 -0
- package/dist/MobileAppsDrawer.module.css +81 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +63 -0
- package/dist/PageContainer.module.css +20 -0
- package/dist/SwipableMobileCarousel.module.css +55 -0
- package/dist/SwipableMobileCarouselDots.module.css +49 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/index.d.ts +654 -6
- package/dist/index.js +1856 -1133
- package/dist/tokens/primitives.css +25 -0
- package/package.json +13 -9
- package/src/components/Badge/Badge.tsx +1 -7
- 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.tsx +8 -59
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +18 -56
- package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- 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 +49 -0
- package/src/components/Markdown/Markdown.stories.tsx +30 -0
- package/src/components/Markdown/Markdown.tsx +168 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -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 +109 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
- package/src/components/MobileHeader/MobileHeader.module.css +63 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +77 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/PageContainer/PageContainer.module.css +20 -0
- package/src/components/PageContainer/PageContainer.tsx +31 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
- package/src/components/PageHeader/PageHeader.tsx +16 -2
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/Sheet/Sheet.tsx +9 -51
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -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 +133 -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 +49 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -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__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/index.ts +18 -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/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +79 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/index.ts +63 -2
- package/src/overlay-shell.ts +94 -0
- package/src/stories/DesignSystemOverview.stories.tsx +322 -1
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/MobilePatterns.stories.tsx +247 -2
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +37 -0
- package/src/stories/PageHeader.stories.tsx +11 -0
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
- package/src/stories/TokenGallery.stories.tsx +65 -0
- package/src/tokens/primitives.css +25 -0
|
@@ -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
|
|
@@ -3,10 +3,12 @@ import type { ReactElement, SVGProps } from 'react';
|
|
|
3
3
|
import { HouseIcon } from './house';
|
|
4
4
|
import { SettingsIcon } from './settings';
|
|
5
5
|
import { LogOutIcon } from './log-out';
|
|
6
|
+
import { SmartphoneIcon } from './smartphone';
|
|
6
7
|
import { ChevronRightIcon } from './chevron-right';
|
|
7
8
|
import { ChevronLeftIcon } from './chevron-left';
|
|
8
9
|
import { ChevronDownIcon } from './chevron-down';
|
|
9
10
|
import { ChevronUpIcon } from './chevron-up';
|
|
11
|
+
import { MenuIcon } from './menu';
|
|
10
12
|
import { XIcon } from './x';
|
|
11
13
|
import { CheckIcon } from './check';
|
|
12
14
|
import { PlusIcon } from './plus';
|
|
@@ -59,10 +61,12 @@ export type IconName =
|
|
|
59
61
|
| 'house'
|
|
60
62
|
| 'settings'
|
|
61
63
|
| 'log-out'
|
|
64
|
+
| 'smartphone'
|
|
62
65
|
| 'chevron-right'
|
|
63
66
|
| 'chevron-left'
|
|
64
67
|
| 'chevron-down'
|
|
65
68
|
| 'chevron-up'
|
|
69
|
+
| 'menu'
|
|
66
70
|
| 'x'
|
|
67
71
|
| 'check'
|
|
68
72
|
| 'plus'
|
|
@@ -117,10 +121,12 @@ export const ICONS: Record<IconName, IconComponent> = {
|
|
|
117
121
|
house: HouseIcon,
|
|
118
122
|
settings: SettingsIcon,
|
|
119
123
|
'log-out': LogOutIcon,
|
|
124
|
+
smartphone: SmartphoneIcon,
|
|
120
125
|
'chevron-right': ChevronRightIcon,
|
|
121
126
|
'chevron-left': ChevronLeftIcon,
|
|
122
127
|
'chevron-down': ChevronDownIcon,
|
|
123
128
|
'chevron-up': ChevronUpIcon,
|
|
129
|
+
menu: MenuIcon,
|
|
124
130
|
x: XIcon,
|
|
125
131
|
check: CheckIcon,
|
|
126
132
|
plus: PlusIcon,
|
|
@@ -173,10 +179,12 @@ export const ICONS: Record<IconName, IconComponent> = {
|
|
|
173
179
|
export { HouseIcon } from './house';
|
|
174
180
|
export { SettingsIcon } from './settings';
|
|
175
181
|
export { LogOutIcon } from './log-out';
|
|
182
|
+
export { SmartphoneIcon } from './smartphone';
|
|
176
183
|
export { ChevronRightIcon } from './chevron-right';
|
|
177
184
|
export { ChevronLeftIcon } from './chevron-left';
|
|
178
185
|
export { ChevronDownIcon } from './chevron-down';
|
|
179
186
|
export { ChevronUpIcon } from './chevron-up';
|
|
187
|
+
export { MenuIcon } from './menu';
|
|
180
188
|
export { XIcon } from './x';
|
|
181
189
|
export { CheckIcon } from './check';
|
|
182
190
|
export { PlusIcon } from './plus';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Generated by scripts/generate-icons.ts — do not edit.
|
|
2
|
+
// Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
|
|
3
|
+
import type { SVGProps } from 'react';
|
|
4
|
+
|
|
5
|
+
export function MenuIcon(props: SVGProps<SVGSVGElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 0 24 24"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
strokeWidth="2"
|
|
13
|
+
strokeLinecap="round"
|
|
14
|
+
strokeLinejoin="round"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
<path d="M4 5h16" />
|
|
18
|
+
<path d="M4 12h16" />
|
|
19
|
+
<path d="M4 19h16" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Generated by scripts/generate-icons.ts — do not edit.
|
|
2
|
+
// Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
|
|
3
|
+
import type { SVGProps } from 'react';
|
|
4
|
+
|
|
5
|
+
export function SmartphoneIcon(props: SVGProps<SVGSVGElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 0 24 24"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
strokeWidth="2"
|
|
13
|
+
strokeLinecap="round"
|
|
14
|
+
strokeLinejoin="round"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
|
18
|
+
<path d="M12 18h.01" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
margin: var(--sv-space-5) 0 0;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
gap: var(--sv-space-2);
|
|
7
|
+
font-size: var(--sv-font-size-xs);
|
|
8
|
+
color: var(--sv-color-text-muted);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.link {
|
|
12
|
+
color: var(--sv-color-text-muted);
|
|
13
|
+
text-decoration: underline;
|
|
14
|
+
text-underline-offset: 2px;
|
|
15
|
+
text-decoration-color: var(--sv-color-border);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.link:hover {
|
|
19
|
+
color: var(--sv-color-text-primary);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.dot {
|
|
23
|
+
color: var(--sv-color-border);
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { LegalLinks } from './LegalLinks';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/LegalLinks',
|
|
6
|
+
component: LegalLinks,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
} satisfies Meta<typeof LegalLinks>;
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {};
|
|
16
|
+
|
|
17
|
+
export const CustomHrefs: Story = {
|
|
18
|
+
args: {
|
|
19
|
+
privacyHref: 'https://example.com/legal/privacy',
|
|
20
|
+
tosHref: 'https://example.com/legal/tos',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import styles from './LegalLinks.module.css';
|
|
3
|
+
|
|
4
|
+
export interface LegalLinksProps {
|
|
5
|
+
privacyHref?: string;
|
|
6
|
+
tosHref?: string;
|
|
7
|
+
/** Renders a linked item. Defaults to a plain `<a href>`, which causes a
|
|
8
|
+
* full page reload — pass Next's `<Link>` (or equivalent) to keep
|
|
9
|
+
* navigation client-side. Not needed for a full-reload navigation away
|
|
10
|
+
* from a standalone auth page, but matters if this is ever mounted inside
|
|
11
|
+
* an SPA-like shell. */
|
|
12
|
+
renderLink?: (href: string, label: string) => ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function LegalLinks({
|
|
17
|
+
privacyHref = '/privacy',
|
|
18
|
+
tosHref = '/tos',
|
|
19
|
+
renderLink,
|
|
20
|
+
className,
|
|
21
|
+
}: LegalLinksProps) {
|
|
22
|
+
const link = (href: string, label: string) =>
|
|
23
|
+
renderLink ? (
|
|
24
|
+
renderLink(href, label)
|
|
25
|
+
) : (
|
|
26
|
+
<a href={href} className={styles.link}>
|
|
27
|
+
{label}
|
|
28
|
+
</a>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<p className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
33
|
+
{link(privacyHref, 'Privacy Policy')}
|
|
34
|
+
<span aria-hidden="true" className={styles.dot}>
|
|
35
|
+
·
|
|
36
|
+
</span>
|
|
37
|
+
{link(tosHref, 'Terms of Service')}
|
|
38
|
+
</p>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Spacing lives on this wrapper div, not on the Typography-rendered tag
|
|
2
|
+
inside it — Typography's own variants reset margin: 0, which would tie
|
|
3
|
+
for CSS specificity against a bare `.root > * + *` sibling rule and make
|
|
4
|
+
the outcome depend on module load order. A dedicated class avoids that. */
|
|
5
|
+
.block {
|
|
6
|
+
margin-top: var(--sv-space-4);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.block:first-child {
|
|
10
|
+
margin-top: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.block[data-heading] {
|
|
14
|
+
margin-top: var(--sv-space-8);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.block[data-heading]:first-child {
|
|
18
|
+
margin-top: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.blockquote {
|
|
22
|
+
margin: 0;
|
|
23
|
+
padding: var(--sv-space-4);
|
|
24
|
+
border-left: 3px solid var(--sv-color-border);
|
|
25
|
+
background: var(--sv-color-surface-sunken);
|
|
26
|
+
border-radius: var(--sv-radius-sm);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.list {
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding-left: var(--sv-space-6);
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
gap: var(--sv-space-2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.root a {
|
|
38
|
+
color: var(--sv-color-text-primary);
|
|
39
|
+
text-decoration: underline;
|
|
40
|
+
text-underline-offset: 2px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.root code {
|
|
44
|
+
font-family: var(--sv-font-family-mono);
|
|
45
|
+
font-size: 0.9em;
|
|
46
|
+
background: var(--sv-color-surface-sunken);
|
|
47
|
+
padding: 0.1em 0.35em;
|
|
48
|
+
border-radius: var(--sv-radius-sm);
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Markdown } from './Markdown';
|
|
3
|
+
|
|
4
|
+
const sample = `# Privacy Policy
|
|
5
|
+
|
|
6
|
+
This document describes the built-in, default behavior of **Sovereign**.
|
|
7
|
+
|
|
8
|
+
> Replaced content from an operator overrides this default.
|
|
9
|
+
|
|
10
|
+
## What we collect
|
|
11
|
+
|
|
12
|
+
- **Email address** and password.
|
|
13
|
+
- *Time zone*, detected automatically at signup.
|
|
14
|
+
|
|
15
|
+
See the [project source](https://github.com/sovereignfs/sovereign) for details.
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const meta = {
|
|
19
|
+
title: 'Components/Markdown',
|
|
20
|
+
component: Markdown,
|
|
21
|
+
parameters: { layout: 'padded' },
|
|
22
|
+
args: { content: sample },
|
|
23
|
+
} satisfies Meta<typeof Markdown>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export const Default: Story = {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Typography } from '../Typography/Typography';
|
|
3
|
+
import styles from './Markdown.module.css';
|
|
4
|
+
|
|
5
|
+
export interface MarkdownProps {
|
|
6
|
+
/** Raw markdown source. Supports the subset used by Sovereign's own
|
|
7
|
+
* long-form content pages: headings (#/##/###), paragraphs, **bold**,
|
|
8
|
+
* *italic*, `code`, [links](url), unordered lists (-), and blockquotes
|
|
9
|
+
* (>), including soft-wrapped continuation lines within a paragraph or
|
|
10
|
+
* list item. Not a general-purpose CommonMark parser — no tables,
|
|
11
|
+
* images, ordered lists, or nested blocks. */
|
|
12
|
+
content: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let inlineKey = 0;
|
|
17
|
+
|
|
18
|
+
/** Parses **bold**, *italic*, `code`, and [text](url) within a line of text. */
|
|
19
|
+
function parseInline(text: string): ReactNode[] {
|
|
20
|
+
const nodes: ReactNode[] = [];
|
|
21
|
+
const pattern = /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`/;
|
|
22
|
+
let remaining = text;
|
|
23
|
+
|
|
24
|
+
while (remaining.length > 0) {
|
|
25
|
+
const match = pattern.exec(remaining);
|
|
26
|
+
if (!match) {
|
|
27
|
+
nodes.push(remaining);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (match.index > 0) nodes.push(remaining.slice(0, match.index));
|
|
31
|
+
|
|
32
|
+
const [full, linkText, linkHref, boldText, italicText, codeText] = match;
|
|
33
|
+
const key = `md-inline-${inlineKey++}`;
|
|
34
|
+
if (linkHref !== undefined) {
|
|
35
|
+
nodes.push(
|
|
36
|
+
<a key={key} href={linkHref}>
|
|
37
|
+
{linkText}
|
|
38
|
+
</a>,
|
|
39
|
+
);
|
|
40
|
+
} else if (boldText !== undefined) {
|
|
41
|
+
nodes.push(<strong key={key}>{boldText}</strong>);
|
|
42
|
+
} else if (italicText !== undefined) {
|
|
43
|
+
nodes.push(<em key={key}>{italicText}</em>);
|
|
44
|
+
} else if (codeText !== undefined) {
|
|
45
|
+
nodes.push(<code key={key}>{codeText}</code>);
|
|
46
|
+
}
|
|
47
|
+
remaining = remaining.slice(match.index + full.length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return nodes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isQuote = (l: string) => l.startsWith('> ') || l === '>';
|
|
54
|
+
const isListItem = (l: string) => l.startsWith('- ');
|
|
55
|
+
const isHeading = (l: string) => /^#{1,3}\s+/.test(l);
|
|
56
|
+
/** A line that ends the current *paragraph* run — a blank line, or the
|
|
57
|
+
* start of a heading, quote, or list block. Anything else (including an
|
|
58
|
+
* indented continuation line) is folded into the paragraph, matching how
|
|
59
|
+
* the source markdown soft-wraps prose across lines. */
|
|
60
|
+
const endsParagraph = (l: string) => l.trim() === '' || isHeading(l) || isQuote(l) || isListItem(l);
|
|
61
|
+
/** A line that ends the current *list* run — a blank line, or the start of
|
|
62
|
+
* a heading or quote. Deliberately does NOT include `isListItem`: a list
|
|
63
|
+
* item's own opening line legitimately satisfies `isListItem`, so reusing
|
|
64
|
+
* `endsParagraph` here (which does) would treat the first line of every
|
|
65
|
+
* list as ending the list before it started — the list-collection loop
|
|
66
|
+
* would then never advance, looping forever. */
|
|
67
|
+
const endsList = (l: string) => l.trim() === '' || isHeading(l) || isQuote(l);
|
|
68
|
+
|
|
69
|
+
function parseBlocks(content: string): ReactNode[] {
|
|
70
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
71
|
+
const blocks: ReactNode[] = [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
let blockKey = 0;
|
|
74
|
+
|
|
75
|
+
while (i < lines.length) {
|
|
76
|
+
const line = lines[i] ?? '';
|
|
77
|
+
|
|
78
|
+
if (line.trim() === '') {
|
|
79
|
+
i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
84
|
+
if (heading) {
|
|
85
|
+
const level = (heading[1]?.length ?? 1) as 1 | 2 | 3;
|
|
86
|
+
blocks.push(
|
|
87
|
+
<div key={blockKey++} className={styles.block} data-heading>
|
|
88
|
+
<Typography variant={`h${level}` as 'h1' | 'h2' | 'h3'}>
|
|
89
|
+
{parseInline(heading[2] ?? '')}
|
|
90
|
+
</Typography>
|
|
91
|
+
</div>,
|
|
92
|
+
);
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isQuote(line)) {
|
|
98
|
+
const quoteLines: string[] = [];
|
|
99
|
+
while (i < lines.length && isQuote(lines[i] ?? '')) {
|
|
100
|
+
quoteLines.push((lines[i] ?? '').slice(2));
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
blocks.push(
|
|
104
|
+
<div key={blockKey++} className={styles.block}>
|
|
105
|
+
<blockquote className={styles.blockquote}>
|
|
106
|
+
<Typography variant="body" as="p">
|
|
107
|
+
{parseInline(quoteLines.join(' '))}
|
|
108
|
+
</Typography>
|
|
109
|
+
</blockquote>
|
|
110
|
+
</div>,
|
|
111
|
+
);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isListItem(line)) {
|
|
116
|
+
const items: string[] = [];
|
|
117
|
+
while (i < lines.length && !endsList(lines[i] ?? '')) {
|
|
118
|
+
const current = lines[i] ?? '';
|
|
119
|
+
if (isListItem(current)) {
|
|
120
|
+
items.push(current.slice(2));
|
|
121
|
+
} else {
|
|
122
|
+
// Continuation of the previous item's wrapped text.
|
|
123
|
+
const lastIndex = items.length - 1;
|
|
124
|
+
items[lastIndex] = `${items[lastIndex] ?? ''} ${current.trim()}`;
|
|
125
|
+
}
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
blocks.push(
|
|
129
|
+
<div key={blockKey++} className={styles.block}>
|
|
130
|
+
<ul className={styles.list}>
|
|
131
|
+
{items.map((item, idx) => (
|
|
132
|
+
<Typography key={idx} variant="body" as="li">
|
|
133
|
+
{parseInline(item)}
|
|
134
|
+
</Typography>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
</div>,
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const paraLines: string[] = [];
|
|
143
|
+
while (i < lines.length && !endsParagraph(lines[i] ?? '')) {
|
|
144
|
+
paraLines.push((lines[i] ?? '').trim());
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
blocks.push(
|
|
148
|
+
<div key={blockKey++} className={styles.block}>
|
|
149
|
+
<Typography variant="body" as="p">
|
|
150
|
+
{parseInline(paraLines.join(' '))}
|
|
151
|
+
</Typography>
|
|
152
|
+
</div>,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return blocks;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Renders a constrained markdown subset (see MarkdownProps) as styled React
|
|
160
|
+
* elements built from Typography — no HTML string injection. Meant for
|
|
161
|
+
* long-form, first-party content pages (privacy policy, terms of service)
|
|
162
|
+
* sourced from a single markdown file, not for rendering arbitrary or
|
|
163
|
+
* user-supplied markdown. */
|
|
164
|
+
export function Markdown({ content, className }: MarkdownProps) {
|
|
165
|
+
return (
|
|
166
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')}>{parseBlocks(content)}</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* Matches the runtime shell's own Apps drawer grid exactly
|
|
2
|
+
(runtime/app/(platform)/_components/MobileNav.module.css's .drawerGrid/
|
|
3
|
+
.drawerGridItem/.drawerGridIcon/.drawerGridName) — this component is
|
|
4
|
+
meant to replace that hand-rolled markup for any consumer building its
|
|
5
|
+
own Apps-style launcher. No header row — see the component's own doc
|
|
6
|
+
comment for why. */
|
|
7
|
+
|
|
8
|
+
.grid {
|
|
9
|
+
list-style: none;
|
|
10
|
+
margin: 0;
|
|
11
|
+
/* Drawer's own grab handle already reserves a 44px touch target above this
|
|
12
|
+
— no extra top padding needed beyond a touch of breathing room. Bottom
|
|
13
|
+
gets noticeably more: with no header row to separate the grid from the
|
|
14
|
+
handle, a tight top/bottom balance read as top-heavy (grid crowding the
|
|
15
|
+
handle isn't the issue; the *last* row crowding the panel's bottom
|
|
16
|
+
edge/safe-area is). Drawer's own panel already adds
|
|
17
|
+
env(safe-area-inset-bottom) below everything, so this is on top of that,
|
|
18
|
+
not instead of it. */
|
|
19
|
+
padding: var(--sv-space-1) var(--sv-space-4) var(--sv-space-10);
|
|
20
|
+
display: grid;
|
|
21
|
+
grid-template-columns: repeat(3, 1fr);
|
|
22
|
+
row-gap: var(--sv-space-5);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.gridItem {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: var(--sv-space-2);
|
|
30
|
+
padding: var(--sv-space-1);
|
|
31
|
+
border: none;
|
|
32
|
+
background: none;
|
|
33
|
+
text-decoration: none;
|
|
34
|
+
color: var(--sv-color-text-primary);
|
|
35
|
+
font: inherit;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
width: 100%;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@media (hover: hover) {
|
|
41
|
+
.gridItem:hover .gridIcon {
|
|
42
|
+
background: var(--sv-color-border);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.gridItem:focus-visible {
|
|
47
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
48
|
+
outline-offset: 2px;
|
|
49
|
+
border-radius: var(--sv-radius-md);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* color: text-primary, not text-muted — the reference's own drawerGridIcon
|
|
53
|
+
uses text-muted only as its monogram-fallback text color for <img>-less
|
|
54
|
+
plugin icons (raster <img> icons don't inherit CSS color at all, so that
|
|
55
|
+
value never actually painted a real icon). Every tile here renders a real
|
|
56
|
+
stroke icon via the Icon component (currentColor), which does inherit —
|
|
57
|
+
matching the reference's own explicit override for that exact case
|
|
58
|
+
(.drawerGridIconSettings, used by its Console tile) rather than the
|
|
59
|
+
fallback default. */
|
|
60
|
+
.gridIcon {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
width: 56px;
|
|
65
|
+
height: 56px;
|
|
66
|
+
border-radius: 14px;
|
|
67
|
+
background: var(--sv-color-surface-sunken);
|
|
68
|
+
font-size: var(--sv-font-size-sm);
|
|
69
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
70
|
+
color: var(--sv-color-text-primary);
|
|
71
|
+
transition: background 0.1s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.gridName {
|
|
75
|
+
font-size: var(--sv-font-size-sm);
|
|
76
|
+
text-align: center;
|
|
77
|
+
color: var(--sv-color-text-primary);
|
|
78
|
+
line-height: 1.3;
|
|
79
|
+
word-break: break-word;
|
|
80
|
+
max-width: 80px;
|
|
81
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Drawer } from '../Drawer/Drawer';
|
|
3
|
+
import styles from './MobileAppsDrawer.module.css';
|
|
4
|
+
|
|
5
|
+
export interface MobileAppsDrawerItem {
|
|
6
|
+
/** Stable identity for this tile — used as the React key. */
|
|
7
|
+
key: string;
|
|
8
|
+
icon: ReactNode;
|
|
9
|
+
label: string;
|
|
10
|
+
/** Renders an `<a>` when set; otherwise a `<button>` calling `onClick`. */
|
|
11
|
+
href?: string;
|
|
12
|
+
onClick?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MobileAppsDrawerProps {
|
|
16
|
+
open: boolean;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
/** Accessible name for the drawer panel — required since there's no
|
|
19
|
+
* visible title to fall back to (Drawer's grab handle plus swipe-down /
|
|
20
|
+
* scrim-tap already cover dismissal, so no header row is rendered). */
|
|
21
|
+
'aria-label': string;
|
|
22
|
+
/** The grid tiles — supplied by the consumer (e.g. installed plugins,
|
|
23
|
+
* a plugin's own sections). MobileAppsDrawer owns only the drawer
|
|
24
|
+
* chrome/grid layout, never the item list itself. */
|
|
25
|
+
items: MobileAppsDrawerItem[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function DrawerGridTile({ icon, label, href, onClick }: Omit<MobileAppsDrawerItem, 'key'>) {
|
|
29
|
+
const content = (
|
|
30
|
+
<>
|
|
31
|
+
<span className={styles.gridIcon} aria-hidden="true">
|
|
32
|
+
{icon}
|
|
33
|
+
</span>
|
|
34
|
+
<span className={styles.gridName}>{label}</span>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
if (href) {
|
|
38
|
+
return (
|
|
39
|
+
<a href={href} className={styles.gridItem} onClick={onClick}>
|
|
40
|
+
{content}
|
|
41
|
+
</a>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
<button type="button" className={styles.gridItem} onClick={onClick}>
|
|
46
|
+
{content}
|
|
47
|
+
</button>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The mobile "Apps" drawer: a bottom sheet with a 3-column grid of icon
|
|
53
|
+
* tiles — same tile size (56px, radius 14px) and grid layout as the runtime
|
|
54
|
+
* shell's own Apps drawer (MobileNav's Drawer content), generalized so the
|
|
55
|
+
* tile list comes from the consumer (the shell passes installed plugins; a
|
|
56
|
+
* plugin composing its own MobileFooter launcher passes whatever its own
|
|
57
|
+
* "apps" are, e.g. a section index) rather than being hardcoded to plugins
|
|
58
|
+
* specifically. No header row (title/close button) — Drawer's own grab
|
|
59
|
+
* handle plus swipe-down/scrim-tap dismissal are enough, and a header ate
|
|
60
|
+
* into the limited vertical space a launcher grid needs most.
|
|
61
|
+
*/
|
|
62
|
+
export function MobileAppsDrawer({ open, onClose, items, ...rest }: MobileAppsDrawerProps) {
|
|
63
|
+
const ariaLabel = rest['aria-label'];
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Drawer open={open} onClose={onClose} aria-label={ariaLabel}>
|
|
67
|
+
<nav aria-label={ariaLabel}>
|
|
68
|
+
<ul className={styles.grid}>
|
|
69
|
+
{items.map(({ key, ...item }) => (
|
|
70
|
+
<li key={key}>
|
|
71
|
+
<DrawerGridTile key={key} {...item} />
|
|
72
|
+
</li>
|
|
73
|
+
))}
|
|
74
|
+
</ul>
|
|
75
|
+
</nav>
|
|
76
|
+
</Drawer>
|
|
77
|
+
);
|
|
78
|
+
}
|