@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
@@ -8,6 +8,8 @@ import { EmptyState } from '../components/EmptyState/EmptyState';
8
8
  import { FormField } from '../components/FormField/FormField';
9
9
  import { Icon } from '../components/Icon/Icon';
10
10
  import { Input } from '../components/Input/Input';
11
+ import { MobileFooter } from '../components/MobileFooter/MobileFooter';
12
+ import { MobileHeader } from '../components/MobileHeader/MobileHeader';
11
13
  import { NavTabs } from '../components/NavTabs/NavTabs';
12
14
  import { PageHeader } from '../components/PageHeader/PageHeader';
13
15
 
@@ -263,6 +265,175 @@ function DrawerDemo() {
263
265
  );
264
266
  }
265
267
 
268
+ function MobileHeaderFooterDemo() {
269
+ const [showTitle, setShowTitle] = useState(false);
270
+ const [customLogo, setCustomLogo] = useState(true);
271
+ const [wideFooter, setWideFooter] = useState(false);
272
+ const [appsOpen, setAppsOpen] = useState(false);
273
+
274
+ const logo = (
275
+ <span
276
+ aria-label="Home"
277
+ style={{
278
+ display: 'flex',
279
+ alignItems: 'center',
280
+ justifyContent: 'center',
281
+ width: 28,
282
+ height: 28,
283
+ borderRadius: 'var(--sv-radius-sm)',
284
+ background: 'var(--sv-color-accent)',
285
+ color: 'var(--sv-color-text-on-accent)',
286
+ fontWeight: 700,
287
+ fontSize: 13,
288
+ flexShrink: 0,
289
+ }}
290
+ >
291
+ S
292
+ </span>
293
+ );
294
+
295
+ const leftOne = [
296
+ { icon: <Icon name="house" size="md" aria-hidden />, label: 'Home', active: true },
297
+ ];
298
+ const rightOne = [{ icon: <Icon name="search" size="md" aria-hidden />, label: 'Search' }];
299
+ const leftTwo = [
300
+ ...leftOne,
301
+ { icon: <Icon name="calendar" size="md" aria-hidden />, label: 'Calendar' },
302
+ ];
303
+ const rightTwo = [
304
+ ...rightOne,
305
+ { icon: <Icon name="activity" size="md" aria-hidden />, label: 'Activity' },
306
+ ];
307
+
308
+ return (
309
+ <>
310
+ <div style={{ display: 'flex', gap: 'var(--sv-space-4)', marginBottom: 'var(--sv-space-4)' }}>
311
+ <label
312
+ style={{
313
+ display: 'flex',
314
+ alignItems: 'center',
315
+ gap: 'var(--sv-space-2)',
316
+ fontSize: 'var(--sv-font-size-sm)',
317
+ color: 'var(--sv-color-text-primary)',
318
+ cursor: 'pointer',
319
+ }}
320
+ >
321
+ <input
322
+ type="checkbox"
323
+ checked={showTitle}
324
+ onChange={(e) => setShowTitle(e.target.checked)}
325
+ />
326
+ Header <code>title</code> prop
327
+ </label>
328
+ <label
329
+ style={{
330
+ display: 'flex',
331
+ alignItems: 'center',
332
+ gap: 'var(--sv-space-2)',
333
+ fontSize: 'var(--sv-font-size-sm)',
334
+ color: 'var(--sv-color-text-primary)',
335
+ cursor: 'pointer',
336
+ }}
337
+ >
338
+ <input
339
+ type="checkbox"
340
+ checked={customLogo}
341
+ onChange={(e) => setCustomLogo(e.target.checked)}
342
+ />
343
+ Custom <code>logo</code> prop (unchecked = default badge)
344
+ </label>
345
+ <label
346
+ style={{
347
+ display: 'flex',
348
+ alignItems: 'center',
349
+ gap: 'var(--sv-space-2)',
350
+ fontSize: 'var(--sv-font-size-sm)',
351
+ color: 'var(--sv-color-text-primary)',
352
+ cursor: 'pointer',
353
+ }}
354
+ >
355
+ <input
356
+ type="checkbox"
357
+ checked={wideFooter}
358
+ onChange={(e) => setWideFooter(e.target.checked)}
359
+ />
360
+ Footer: 2 + 2 icons
361
+ </label>
362
+ </div>
363
+
364
+ <div
365
+ style={{
366
+ border: '1px solid var(--sv-color-border)',
367
+ borderRadius: 'var(--sv-radius-lg)',
368
+ overflow: 'hidden',
369
+ }}
370
+ >
371
+ <MobileHeader
372
+ logo={customLogo ? logo : undefined}
373
+ title={showTitle ? 'Tasks' : undefined}
374
+ bell={
375
+ <button
376
+ type="button"
377
+ aria-label="Notifications"
378
+ style={{
379
+ display: 'inline-flex',
380
+ alignItems: 'center',
381
+ justifyContent: 'center',
382
+ width: 44,
383
+ height: 44,
384
+ background: 'none',
385
+ border: 'none',
386
+ borderRadius: 'var(--sv-radius-md)',
387
+ color: 'var(--sv-color-text-muted)',
388
+ cursor: 'pointer',
389
+ }}
390
+ >
391
+ <Icon name="bell" size="lg" aria-hidden />
392
+ </button>
393
+ }
394
+ avatarMenu={
395
+ <span
396
+ aria-label="Jamie Doe"
397
+ style={{
398
+ display: 'flex',
399
+ alignItems: 'center',
400
+ justifyContent: 'center',
401
+ width: 36,
402
+ height: 36,
403
+ borderRadius: 'var(--sv-radius-full)',
404
+ background: 'var(--sv-color-accent)',
405
+ border: '1px solid var(--sv-color-accent)',
406
+ color: 'var(--sv-color-text-on-accent)',
407
+ fontSize: 'var(--sv-font-size-xs)',
408
+ fontWeight: 'var(--sv-font-weight-semibold)',
409
+ }}
410
+ >
411
+ JD
412
+ </span>
413
+ }
414
+ />
415
+ <div
416
+ style={{
417
+ background: 'var(--sv-color-surface-sunken)',
418
+ padding: 'var(--sv-space-6) var(--sv-space-4)',
419
+ textAlign: 'center',
420
+ color: 'var(--sv-color-text-muted)',
421
+ fontSize: 'var(--sv-font-size-xs)',
422
+ }}
423
+ >
424
+ Plugin content area
425
+ </div>
426
+ <MobileFooter
427
+ onOpenApps={() => setAppsOpen((v) => !v)}
428
+ launcherOpen={appsOpen}
429
+ leftIcons={wideFooter ? leftTwo : leftOne}
430
+ rightIcons={wideFooter ? rightTwo : rightOne}
431
+ />
432
+ </div>
433
+ </>
434
+ );
435
+ }
436
+
266
437
  // ---------------------------------------------------------------------------
267
438
  // Main story component
268
439
  // ---------------------------------------------------------------------------
@@ -916,11 +1087,19 @@ function MobilePatternsDoc() {
916
1087
  </div>
917
1088
  </div>
918
1089
  <Callout type="info">
919
- Plugin developers cannot customise the Launcher Drawer. Use the Drawer component from{' '}
1090
+ Plugin developers cannot customise the Launcher Drawer. Use{' '}
1091
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
1092
+ MobileAppsDrawer
1093
+ </code>{' '}
1094
+ from{' '}
920
1095
  <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
921
1096
  @sovereignfs/ui
922
1097
  </code>{' '}
923
- if you need a similar bottom-sheet pattern inside your own plugin.
1098
+ if you need this exact grid pattern inside your own plugin (e.g. a mobile-only launcher
1099
+ for the plugin's own sections) — same tile styling as the shell's own drawer (no header
1100
+ row; Drawer's own grab handle/swipe-down/scrim-tap already dismiss it), with the tile
1101
+ list supplied by you via its <code>items</code> prop. See the Component Gallery for a
1102
+ live demo.
924
1103
  </Callout>
925
1104
  </Card>
926
1105
 
@@ -1066,6 +1245,38 @@ function MobilePatternsDoc() {
1066
1245
  </Card>
1067
1246
  </section>
1068
1247
 
1248
+ {/* ── MobileHeader / MobileFooter components (RFC 0088) ── */}
1249
+ <section style={{ marginBottom: 'var(--sv-space-10)' }}>
1250
+ <SectionHeader
1251
+ title="MobileHeader / MobileFooter components"
1252
+ subtitle="The presentational components behind the shell chrome above (RFC 0088). Bell, avatar menu, and the centered launcher are always rendered by the consumer — the header's logo/title (logo defaults to an 'S' badge when omitted) and the footer's leftIcons/rightIcons are the only overridable parts."
1253
+ />
1254
+ <Card padding="md">
1255
+ <MobileHeaderFooterDemo />
1256
+ <div style={{ marginTop: 'var(--sv-space-4)' }}>
1257
+ <Callout type="info">
1258
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
1259
+ leftIcons
1260
+ </code>{' '}
1261
+ and{' '}
1262
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
1263
+ rightIcons
1264
+ </code>{' '}
1265
+ must have the same length so the launcher stays centered — a mismatch logs a
1266
+ dev-mode-only{' '}
1267
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
1268
+ console.error
1269
+ </code>{' '}
1270
+ (never thrown), the same non-fatal guard pattern used by{' '}
1271
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
1272
+ SwipableMobileCarousel
1273
+ </code>
1274
+ .
1275
+ </Callout>
1276
+ </div>
1277
+ </Card>
1278
+ </section>
1279
+
1069
1280
  {/* ── Touch targets ── */}
1070
1281
  <section style={{ marginBottom: 'var(--sv-space-10)' }}>
1071
1282
  <SectionHeader
@@ -1205,6 +1416,40 @@ function MobilePatternsDoc() {
1205
1416
  </Callout>
1206
1417
  </section>
1207
1418
 
1419
+ {/* ── Layout: swipeable route views ── */}
1420
+ <section style={{ marginBottom: 'var(--sv-space-10)' }}>
1421
+ <SectionHeader
1422
+ title="Layout: swipeable route views"
1423
+ subtitle="Some views are route-based on web but want a different mobile UX — a swipeable filmstrip of the same sibling routes, not a squeeze of a multi-column layout. This is a recurring pattern (sovereign-tasks, sovereign-shopper) — build it with the shared primitives, not a hand-rolled scroller."
1424
+ />
1425
+ <p
1426
+ style={{
1427
+ fontFamily: ff,
1428
+ fontSize: 'var(--sv-font-size-sm)',
1429
+ color: 'var(--sv-color-text-muted)',
1430
+ lineHeight: 1.6,
1431
+ }}
1432
+ >
1433
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
1434
+ SwipableMobileCarousel
1435
+ </code>{' '}
1436
+ renders full-width slides over a native <code>scroll-snap-type: x</code> container — no
1437
+ hand-rolled pointer/touch dragging. Pair it with <code>useCarouselRouteSync</code> to keep
1438
+ the active slide synced to the URL, and <code>ResponsiveSurface</code>/
1439
+ <code>useResponsiveLayout</code> for the breakpoint fork itself. See the{' '}
1440
+ <em>Interaction Hooks</em> story for the full API and live demos of both hooks.
1441
+ </p>
1442
+ <Callout type="warn">
1443
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
1444
+ SwipableMobileCarousel
1445
+ </code>{' '}
1446
+ owns rendering and mount-window mechanics only — it has no opinion on where slide data
1447
+ lives. Don&apos;t aggregate cross-slide data or mount a detail overlay (Sheet, Dialog)
1448
+ inside a Slide&apos;s children; mount overlays as a sibling of the carousel instead. See
1449
+ docs/design-system.md&apos;s carousel section for the full rationale.
1450
+ </Callout>
1451
+ </section>
1452
+
1208
1453
  {/* ── Checklist ── */}
1209
1454
  <section style={{ marginBottom: 'var(--sv-space-10)' }}>
1210
1455
  <SectionHeader title="Mobile readiness checklist" />
@@ -33,3 +33,21 @@ export const ManyTabs: Story = {
33
33
  },
34
34
  parameters: { viewport: { defaultViewport: 'mobile' } },
35
35
  };
36
+
37
+ /** Simulates a consumer swapping in a client-side router link (e.g. Next's
38
+ * `<Link replace>` inside an overlay-shell plugin) instead of a plain `<a>`. */
39
+ export const WithCustomLinkRenderer: Story = {
40
+ args: {
41
+ renderLink: (item, linkProps) => (
42
+ <button
43
+ type="button"
44
+ className={linkProps.className}
45
+ aria-current={linkProps['aria-current']}
46
+ onClick={() => console.log(`client-side navigate to ${item.href}`)}
47
+ style={{ background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}
48
+ >
49
+ {linkProps.children}
50
+ </button>
51
+ ),
52
+ },
53
+ };
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { OfflineGate } from '../components/OfflineGate/OfflineGate';
3
+ import { EmptyState } from '../components/EmptyState/EmptyState';
4
+
5
+ const sampleContent = (
6
+ <div style={{ padding: 'var(--sv-space-4)' }}>
7
+ <p style={{ margin: 0, fontFamily: 'var(--sv-font-family)' }}>
8
+ The wrapped content — a user list, a settings form, anything that shouldn&rsquo;t operate
9
+ against a stale cached snapshot.
10
+ </p>
11
+ </div>
12
+ );
13
+
14
+ const meta = {
15
+ title: 'Components/OfflineGate',
16
+ component: OfflineGate,
17
+ parameters: { layout: 'padded' },
18
+ } satisfies Meta<typeof OfflineGate>;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ // `OfflineGate` reads the live `useIsOffline()` hook, which Storybook can't
24
+ // drive via args — toggle DevTools > Network > Offline (or throttling) in this
25
+ // preview to see it swap `children` for the blocked state below.
26
+ export const Default: Story = {
27
+ args: { children: sampleContent, surfaceName: 'Console' },
28
+ };
29
+
30
+ // The offline state is not independently controllable here (same reason as
31
+ // Default), so this renders the exact EmptyState output OfflineGate produces
32
+ // when useIsOffline() is true, for visual review without a live toggle.
33
+ export const OfflineState: Story = {
34
+ args: { children: sampleContent, surfaceName: 'Console' },
35
+ render: () => (
36
+ <EmptyState
37
+ icon="alert-triangle"
38
+ heading="You're offline"
39
+ description="Console needs a connection — reconnect to continue."
40
+ />
41
+ ),
42
+ };
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { PageContainer } from '../components/PageContainer/PageContainer';
3
+
4
+ const demoBlock = (
5
+ <div
6
+ style={{
7
+ background: 'var(--sv-color-surface-raised)',
8
+ border: '1px dashed var(--sv-color-border)',
9
+ borderRadius: 'var(--sv-radius-md)',
10
+ padding: 'var(--sv-space-4)',
11
+ }}
12
+ >
13
+ Page content goes here. The runtime shell already pads this area — this component only
14
+ constrains and centers width, it adds no padding of its own.
15
+ </div>
16
+ );
17
+
18
+ const meta = {
19
+ title: 'Components/PageContainer',
20
+ component: PageContainer,
21
+ parameters: { layout: 'fullscreen' },
22
+ args: { children: demoBlock },
23
+ } satisfies Meta<typeof PageContainer>;
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof meta>;
27
+
28
+ export const Medium: Story = {};
29
+
30
+ export const Small: Story = { args: { maxWidth: 'sm' } };
31
+ export const Large: Story = { args: { maxWidth: 'lg' } };
32
+ export const Full: Story = { args: { maxWidth: 'full' } };
33
+
34
+ export const MobileViewport: Story = {
35
+ args: { maxWidth: 'md' },
36
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
37
+ };
@@ -29,3 +29,14 @@ export const WithAction: Story = {
29
29
  action: <Button size="sm">Invite user</Button>,
30
30
  },
31
31
  };
32
+
33
+ /** For use under a shell/plugin that already renders its own `<h1>` — e.g.
34
+ * Console's per-page headers sitting below `<h1>Console</h1>`. Visually
35
+ * identical to the default story; only the rendered tag changes. */
36
+ export const NestedHeadingLevel: Story = {
37
+ args: {
38
+ title: 'Users',
39
+ description: 'Manage who has access to this instance.',
40
+ headingLevel: 2,
41
+ },
42
+ };
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ResponsiveSurface } from '../components/ResponsiveSurface/ResponsiveSurface';
3
+
4
+ function demoTree(label: string) {
5
+ return (
6
+ <div
7
+ style={{
8
+ padding: 'var(--sv-space-6)',
9
+ textAlign: 'center',
10
+ fontFamily: 'var(--sv-font-family)',
11
+ fontSize: 'var(--sv-font-size-sm)',
12
+ color: 'var(--sv-color-text-primary)',
13
+ border: '1px dashed var(--sv-color-border)',
14
+ borderRadius: 'var(--sv-radius-md)',
15
+ }}
16
+ >
17
+ {label}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ const meta = {
23
+ title: 'Components/ResponsiveSurface',
24
+ component: ResponsiveSurface,
25
+ parameters: {
26
+ layout: 'padded',
27
+ docs: {
28
+ description: {
29
+ component:
30
+ 'Renders an entirely different component tree below a breakpoint, not a CSS squeeze of the same one — only the active side is ever mounted. See also the useResponsiveLayout hook for non-JSX values.',
31
+ },
32
+ },
33
+ },
34
+ args: {
35
+ web: demoTree('Web tree — e.g. a three-column layout'),
36
+ mobile: demoTree('Mobile tree — an entirely different component'),
37
+ },
38
+ } satisfies Meta<typeof ResponsiveSurface>;
39
+
40
+ export default meta;
41
+ type Story = StoryObj<typeof meta>;
42
+
43
+ export const Default: Story = {};
44
+
45
+ export const MobileViewport: Story = {
46
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
47
+ };