@fragments-sdk/cli 0.9.0 → 0.9.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.
Files changed (123) hide show
  1. package/dist/bin.js +83 -33
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-WI6SLMSO.js → chunk-5GT62FCB.js} +2 -2
  4. package/dist/{chunk-CJEGT3WD.js → chunk-BW3ZATBW.js} +20 -3
  5. package/dist/chunk-BW3ZATBW.js.map +1 -0
  6. package/dist/{chunk-2JIKCJX3.js → chunk-D7372LQX.js} +13 -6
  7. package/dist/chunk-D7372LQX.js.map +1 -0
  8. package/dist/chunk-EZYXYWNF.js +131 -0
  9. package/dist/chunk-EZYXYWNF.js.map +1 -0
  10. package/dist/{chunk-NGIMCIK2.js → chunk-GF6OVPIN.js} +2 -2
  11. package/dist/{chunk-GOVI6COW.js → chunk-NVSPGSKB.js} +12 -4
  12. package/dist/chunk-NVSPGSKB.js.map +1 -0
  13. package/dist/core/index.d.ts +105 -3
  14. package/dist/core/index.js +12 -2
  15. package/dist/{defineFragment-D0UTve-I.d.ts → defineFragment-CBMS7Bab.d.ts} +21 -1
  16. package/dist/generate-LQA2R7FN.js +461 -0
  17. package/dist/generate-LQA2R7FN.js.map +1 -0
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +5 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/{init-KSAAS7X3.js → init-2GEGVIUQ.js} +13 -75
  22. package/dist/init-2GEGVIUQ.js.map +1 -0
  23. package/dist/mcp-bin.js +4 -3
  24. package/dist/mcp-bin.js.map +1 -1
  25. package/dist/{scan-65RH3QMM.js → scan-JGS65S7P.js} +6 -5
  26. package/dist/{service-A5GIGGGK.js → service-XP2EAJXD.js} +4 -3
  27. package/dist/{static-viewer-NSODM5VX.js → static-viewer-XCS7UJTO.js} +4 -3
  28. package/dist/storyFilters-3LUYAFZF.js +15 -0
  29. package/dist/storyFilters-3LUYAFZF.js.map +1 -0
  30. package/dist/{test-RPWZAYSJ.js → test-TD6TJNVY.js} +3 -3
  31. package/dist/{tokens-NIXSZRX7.js → tokens-2EXPCVP3.js} +5 -4
  32. package/dist/{tokens-NIXSZRX7.js.map → tokens-2EXPCVP3.js.map} +1 -1
  33. package/dist/{viewer-SBTJDMP7.js → viewer-RFA2KVBG.js} +243 -18
  34. package/dist/viewer-RFA2KVBG.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/build.ts +12 -2
  37. package/src/commands/build.ts +16 -2
  38. package/src/commands/generate.ts +383 -68
  39. package/src/commands/init.ts +9 -51
  40. package/src/core/config.ts +15 -2
  41. package/src/core/generators/typescript-extractor.ts +10 -0
  42. package/src/core/index.ts +15 -0
  43. package/src/core/schema.ts +10 -2
  44. package/src/core/storyFilters.test.ts +350 -0
  45. package/src/core/storyFilters.ts +253 -0
  46. package/src/core/types.ts +22 -0
  47. package/src/migrate/converter.ts +9 -1
  48. package/src/migrate/parser.ts +2 -0
  49. package/src/migrate/types.ts +2 -0
  50. package/src/setup.ts +69 -24
  51. package/src/viewer/__tests__/viewer-integration.test.ts +1 -1
  52. package/src/viewer/components/AccessibilityPanel.tsx +305 -312
  53. package/src/viewer/components/ActionsPanel.tsx +31 -29
  54. package/src/viewer/components/AllVariantsPreview.tsx +78 -0
  55. package/src/viewer/components/App.tsx +187 -740
  56. package/src/viewer/components/BottomPanel.tsx +228 -132
  57. package/src/viewer/components/CodePanel.tsx +1 -1
  58. package/src/viewer/components/CommandPalette.tsx +7 -10
  59. package/src/viewer/components/ComponentDocView.tsx +164 -0
  60. package/src/viewer/components/ComponentGraph.tsx +111 -142
  61. package/src/viewer/components/ContractPanel.tsx +6 -6
  62. package/src/viewer/components/EmptyVariantMessage.tsx +54 -0
  63. package/src/viewer/components/FigmaEmbed.tsx +20 -18
  64. package/src/viewer/components/FragmentEditor.tsx +92 -115
  65. package/src/viewer/components/HeaderSearch.tsx +24 -0
  66. package/src/viewer/components/HealthDashboard.tsx +16 -2
  67. package/src/viewer/components/Icons.tsx +9 -0
  68. package/src/viewer/components/InteractionsPanel.tsx +101 -117
  69. package/src/viewer/components/IsolatedPreviewFrame.tsx +1 -0
  70. package/src/viewer/components/LandingPage.tsx +3 -3
  71. package/src/viewer/components/LeftSidebar.tsx +141 -63
  72. package/src/viewer/components/LoadErrorMessage.tsx +102 -0
  73. package/src/viewer/components/MultiViewportPreview.tsx +61 -142
  74. package/src/viewer/components/NoVariantsMessage.tsx +59 -0
  75. package/src/viewer/components/PanelShell.tsx +161 -0
  76. package/src/viewer/components/PerformancePanel.tsx +31 -28
  77. package/src/viewer/components/PreviewArea.tsx +1 -1
  78. package/src/viewer/components/PreviewAside.tsx +168 -0
  79. package/src/viewer/components/PreviewFrameHost.tsx +3 -3
  80. package/src/viewer/components/PropsEditor.tsx +70 -156
  81. package/src/viewer/components/ResizablePanel.tsx +103 -263
  82. package/src/viewer/components/RightSidebar.tsx +3 -9
  83. package/src/viewer/components/SkeletonLoader.tsx +13 -13
  84. package/src/viewer/components/TokenStylePanel.tsx +182 -209
  85. package/src/viewer/components/TopToolbar.tsx +159 -0
  86. package/src/viewer/components/VariantMatrix.tsx +42 -86
  87. package/src/viewer/components/VariantTabs.tsx +3 -3
  88. package/src/viewer/components/ViewerHeader.tsx +69 -0
  89. package/src/viewer/components/WebMCPDevTools.tsx +17 -23
  90. package/src/viewer/components/viewer-utils.ts +16 -0
  91. package/src/viewer/entry.tsx +5 -0
  92. package/src/viewer/hooks/useAppState.ts +27 -4
  93. package/src/viewer/hooks/usePreviewBridge.ts +2 -2
  94. package/src/viewer/preview-frame.html +6 -12
  95. package/src/viewer/server.ts +169 -2
  96. package/src/viewer/vendor/shared/src/ComponentDocContent.module.scss +10 -0
  97. package/src/viewer/vendor/shared/src/ComponentDocContent.module.scss.d.ts +2 -0
  98. package/src/viewer/vendor/shared/src/ComponentDocContent.tsx +274 -0
  99. package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +6 -18
  100. package/src/viewer/vendor/shared/src/DocsPageShell.tsx +5 -0
  101. package/src/viewer/vendor/shared/src/DocsSidebarNav.tsx +5 -16
  102. package/src/viewer/vendor/shared/src/PropsTable.module.scss +68 -0
  103. package/src/viewer/vendor/shared/src/PropsTable.module.scss.d.ts +2 -0
  104. package/src/viewer/vendor/shared/src/PropsTable.tsx +76 -0
  105. package/src/viewer/vendor/shared/src/VariantPreviewCard.module.scss +122 -0
  106. package/src/viewer/vendor/shared/src/VariantPreviewCard.module.scss.d.ts +2 -0
  107. package/src/viewer/vendor/shared/src/VariantPreviewCard.tsx +134 -0
  108. package/src/viewer/vendor/shared/src/index.ts +8 -0
  109. package/src/viewer/vendor/shared/src/types.ts +12 -0
  110. package/src/viewer/vite-plugin.ts +109 -4
  111. package/dist/chunk-2JIKCJX3.js.map +0 -1
  112. package/dist/chunk-CJEGT3WD.js.map +0 -1
  113. package/dist/chunk-GOVI6COW.js.map +0 -1
  114. package/dist/generate-35OIMW4Y.js +0 -252
  115. package/dist/generate-35OIMW4Y.js.map +0 -1
  116. package/dist/init-KSAAS7X3.js.map +0 -1
  117. package/dist/viewer-SBTJDMP7.js.map +0 -1
  118. /package/dist/{chunk-WI6SLMSO.js.map → chunk-5GT62FCB.js.map} +0 -0
  119. /package/dist/{chunk-NGIMCIK2.js.map → chunk-GF6OVPIN.js.map} +0 -0
  120. /package/dist/{scan-65RH3QMM.js.map → scan-JGS65S7P.js.map} +0 -0
  121. /package/dist/{service-A5GIGGGK.js.map → service-XP2EAJXD.js.map} +0 -0
  122. /package/dist/{static-viewer-NSODM5VX.js.map → static-viewer-XCS7UJTO.js.map} +0 -0
  123. /package/dist/{test-RPWZAYSJ.js.map → test-TD6TJNVY.js.map} +0 -0
@@ -10,8 +10,9 @@
10
10
  */
11
11
 
12
12
  import { useState, useCallback, useRef, useEffect } from "react";
13
- import { Button, Stack, Text, Badge, EmptyState, CodeBlock, Alert } from "@fragments-sdk/ui";
13
+ import { Button, Stack, Text, Badge, CodeBlock, Alert, Box } from "@fragments-sdk/ui";
14
14
  import type { PlayFunction, PlayFunctionContext, FragmentVariant } from "../../core/index.js";
15
+ import { Play } from "@phosphor-icons/react";
15
16
  import {
16
17
  PlayIcon,
17
18
  CheckIcon,
@@ -27,6 +28,7 @@ import {
27
28
  BreakpointIcon,
28
29
  BreakpointEmptyIcon,
29
30
  } from "./Icons.js";
31
+ import { PanelShell } from "./PanelShell.js";
30
32
 
31
33
  // Step execution state
32
34
  interface StepResult {
@@ -424,27 +426,17 @@ export function InteractionsPanel({
424
426
  return `${(ms / 1000).toFixed(2)}s`;
425
427
  };
426
428
 
427
- // No play function available
428
- if (!hasPlayFunction) {
429
- return (
430
- <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
431
- <div style={{ padding: '16px', borderBottom: '1px solid var(--border)' }}>
432
- <Stack direction="row" align="center" gap="sm">
433
- <PlayIcon style={{ width: 16, height: 16 }} />
434
- <Text weight="medium">Interactions</Text>
435
- </Stack>
436
- </div>
437
- <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px' }}>
438
- <EmptyState>
439
- <EmptyState.Icon>
440
- <PlayIcon style={{ width: 24, height: 24 }} />
441
- </EmptyState.Icon>
442
- <EmptyState.Title>No interactions defined</EmptyState.Title>
443
- <EmptyState.Description>
444
- This variant doesn't have a play function. Add a <code style={{ padding: '2px 4px', background: 'var(--bg-secondary)', borderRadius: '4px', fontSize: '12px' }}>play</code> function to your Storybook story to enable interaction testing.
445
- </EmptyState.Description>
446
- <div style={{ marginTop: '16px', width: '100%' }}>
447
- <CodeBlock language="typescript">{`export const Default = {
429
+ // Empty state for no play function
430
+ const emptyConfig = !hasPlayFunction ? {
431
+ icon: <Play size={24} weight="regular" style={{ color: 'var(--text-tertiary)' }} />,
432
+ title: "No interactions defined",
433
+ description: (
434
+ <>
435
+ This variant doesn't have a play function. Add a <Box as="code" padding="xs" background="secondary" rounded="sm" style={{ fontSize: '12px', display: 'inline' }}>play</Box> function to your Storybook story to enable interaction testing.
436
+ </>
437
+ ),
438
+ action: (
439
+ <CodeBlock language="typescript">{`export const Default = {
448
440
  play: async ({ canvasElement, step }) => {
449
441
  const canvas = within(canvasElement);
450
442
 
@@ -459,110 +451,102 @@ export function InteractionsPanel({
459
451
  ).toBeInTheDocument();
460
452
  }
461
453
  };`}</CodeBlock>
462
- </div>
463
- </EmptyState>
464
- </div>
465
- </div>
466
- );
467
- }
468
-
469
- return (
470
- <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }} data-interactions-panel>
471
- {/* Header */}
472
- <div style={{ padding: '16px', borderBottom: '1px solid var(--border)' }}>
473
- <Stack direction="row" align="center" justify="between">
474
- <Stack direction="row" align="center" gap="sm">
475
- <PlayIcon style={{ width: 16, height: 16 }} />
476
- <Text weight="medium">Interactions</Text>
477
- </Stack>
478
- <Stack direction="row" align="center" gap="sm">
479
- {result.duration !== undefined && (
480
- <Text size="xs" color="tertiary">{formatDuration(result.duration)}</Text>
481
- )}
482
-
483
- {/* Debug mode toggle */}
454
+ ),
455
+ } : undefined;
456
+
457
+ // Toolbar with duration, debug controls, and run button
458
+ const toolbar = hasPlayFunction ? (
459
+ <Stack direction="row" align="center" justify="between" style={{ width: '100%' }}>
460
+ <Stack direction="row" align="center" gap="sm">
461
+ {result.duration !== undefined && (
462
+ <Text size="xs" color="tertiary">{formatDuration(result.duration)}</Text>
463
+ )}
464
+ </Stack>
465
+ <Stack direction="row" align="center" gap="sm">
466
+ {/* Debug mode toggle */}
467
+ <Button
468
+ onClick={toggleDebugMode}
469
+ variant="ghost"
470
+ size="sm"
471
+ title={debugState.mode === 'debug' ? "Exit debug mode" : "Enable debug mode (F5 to run with debugger)"}
472
+ style={{
473
+ color: debugState.mode === 'debug' ? '#ea580c' : undefined,
474
+ background: debugState.mode === 'debug' ? 'color-mix(in srgb, #f97316 10%, transparent)' : undefined,
475
+ }}
476
+ >
477
+ <BugIcon style={{ width: 16, height: 16 }} />
478
+ </Button>
479
+
480
+ {/* Debug controls when paused */}
481
+ {debugState.isPaused && (
482
+ <>
484
483
  <Button
485
- onClick={toggleDebugMode}
484
+ onClick={handleResume}
486
485
  variant="ghost"
487
486
  size="sm"
488
- title={debugState.mode === 'debug' ? "Exit debug mode" : "Enable debug mode (F5 to run with debugger)"}
489
- style={{
490
- color: debugState.mode === 'debug' ? '#ea580c' : undefined,
491
- background: debugState.mode === 'debug' ? 'color-mix(in srgb, #f97316 10%, transparent)' : undefined,
492
- }}
487
+ title="Continue (F8)"
488
+ style={{ color: '#16a34a' }}
493
489
  >
494
- <BugIcon style={{ width: 16, height: 16 }} />
490
+ <ContinueIcon style={{ width: 16, height: 16 }} />
495
491
  </Button>
496
-
497
- {/* Debug controls when paused */}
498
- {debugState.isPaused && (
499
- <>
500
- <Button
501
- onClick={handleResume}
502
- variant="ghost"
503
- size="sm"
504
- title="Continue (F8)"
505
- style={{ color: '#16a34a' }}
506
- >
507
- <ContinueIcon style={{ width: 16, height: 16 }} />
508
- </Button>
509
- <Button
510
- onClick={handleStepOver}
511
- variant="ghost"
512
- size="sm"
513
- title="Step over (F10)"
514
- style={{ color: '#2563eb' }}
515
- >
516
- <StepOverIcon style={{ width: 16, height: 16 }} />
517
- </Button>
518
- </>
519
- )}
520
-
521
492
  <Button
522
- onClick={() => runInteractions(debugState.mode === 'debug')}
523
- disabled={result.status === "running"}
524
- variant={result.status === "running" || result.status === "paused" ? "outline" : "solid"}
493
+ onClick={handleStepOver}
494
+ variant="ghost"
525
495
  size="sm"
526
- style={
527
- result.status === "running" || result.status === "paused"
528
- ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)', cursor: 'not-allowed' }
529
- : { background: 'var(--color-accent)', color: '#fff' }
530
- }
496
+ title="Step over (F10)"
497
+ style={{ color: '#2563eb' }}
531
498
  >
532
- {result.status === "running" ? (
533
- <>
534
- <LoadingIcon style={{ width: 16, height: 16, animation: 'spin 1s linear infinite' }} />
535
- Running...
536
- </>
537
- ) : result.status === "paused" ? (
538
- <>
539
- <PauseIcon style={{ width: 16, height: 16 }} />
540
- Paused
541
- </>
542
- ) : result.status === "idle" ? (
543
- <>
544
- <PlayIcon style={{ width: 16, height: 16 }} />
545
- {debugState.mode === 'debug' ? 'Debug' : 'Run'}
546
- </>
547
- ) : (
548
- <>
549
- <RefreshIcon style={{ width: 16, height: 16 }} />
550
- Rerun
551
- </>
552
- )}
499
+ <StepOverIcon style={{ width: 16, height: 16 }} />
553
500
  </Button>
554
- </Stack>
555
- </Stack>
556
- </div>
501
+ </>
502
+ )}
503
+
504
+ <Button
505
+ onClick={() => runInteractions(debugState.mode === 'debug')}
506
+ disabled={result.status === "running"}
507
+ variant={result.status === "running" || result.status === "paused" ? "outline" : "solid"}
508
+ size="sm"
509
+ style={
510
+ result.status === "running" || result.status === "paused"
511
+ ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)', cursor: 'not-allowed' }
512
+ : { background: 'var(--color-accent)', color: '#fff' }
513
+ }
514
+ >
515
+ {result.status === "running" ? (
516
+ <>
517
+ <LoadingIcon style={{ width: 16, height: 16, animation: 'spin 1s linear infinite' }} />
518
+ Running...
519
+ </>
520
+ ) : result.status === "paused" ? (
521
+ <>
522
+ <PauseIcon style={{ width: 16, height: 16 }} />
523
+ Paused
524
+ </>
525
+ ) : result.status === "idle" ? (
526
+ <>
527
+ <PlayIcon style={{ width: 16, height: 16 }} />
528
+ {debugState.mode === 'debug' ? 'Debug' : 'Run'}
529
+ </>
530
+ ) : (
531
+ <>
532
+ <RefreshIcon style={{ width: 16, height: 16 }} />
533
+ Rerun
534
+ </>
535
+ )}
536
+ </Button>
537
+ </Stack>
538
+ </Stack>
539
+ ) : undefined;
557
540
 
558
- {/* Results */}
559
- <div style={{ flex: 1, overflowY: 'auto' }}>
541
+ return (
542
+ <div data-interactions-panel style={{ height: '100%' }}>
543
+ <PanelShell toolbar={toolbar} empty={emptyConfig} bodyPadding="none">
560
544
  {result.status === "idle" ? (
561
- <div style={{ padding: '32px', textAlign: 'center' }}>
545
+ <Box padding="lg" style={{ textAlign: 'center' }}>
562
546
  <Text size="sm" color="tertiary">
563
547
  Click "Run" to execute the interaction tests
564
548
  </Text>
565
- </div>
549
+ </Box>
566
550
  ) : (
567
551
  <Stack direction="column" gap="sm" style={{ padding: '16px' }}>
568
552
  {/* Overall status */}
@@ -743,7 +727,7 @@ export function InteractionsPanel({
743
727
 
744
728
  {/* Keyboard shortcuts help in debug mode */}
745
729
  {debugState.mode === 'debug' && (
746
- <div style={{ marginTop: '16px', padding: '12px', background: 'var(--bg-secondary)', borderRadius: '8px' }}>
730
+ <Box padding="sm" background="secondary" rounded="lg" style={{ marginTop: '16px' }}>
747
731
  <Text size="xs" weight="medium" color="tertiary" style={{ marginBottom: '4px' }}>Keyboard shortcuts:</Text>
748
732
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}>
749
733
  <Text size="xs" color="tertiary"><Badge size="sm" variant="default">F5</Badge> Run with debugger</Text>
@@ -751,23 +735,23 @@ export function InteractionsPanel({
751
735
  <Text size="xs" color="tertiary"><Badge size="sm" variant="default">F9</Badge> Toggle breakpoint</Text>
752
736
  <Text size="xs" color="tertiary"><Badge size="sm" variant="default">F10</Badge> Step over</Text>
753
737
  </div>
754
- </div>
738
+ </Box>
755
739
  )}
756
740
 
757
741
  {/* Top-level error (if no steps failed) */}
758
742
  {result.error && !result.steps.some((s) => s.status === "failed") && (
759
- <div style={{ marginTop: '16px' }}>
743
+ <Box style={{ marginTop: '16px' }}>
760
744
  <Text size="xs" weight="medium" color="tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px' }}>
761
745
  Error
762
746
  </Text>
763
747
  <pre style={{ fontSize: '12px', color: '#dc2626', background: 'color-mix(in srgb, #ef4444 10%, transparent)', padding: '12px', borderRadius: '4px', overflowX: 'auto', whiteSpace: 'pre-wrap' }}>
764
748
  {result.error}
765
749
  </pre>
766
- </div>
750
+ </Box>
767
751
  )}
768
752
  </Stack>
769
753
  )}
770
- </div>
754
+ </PanelShell>
771
755
  </div>
772
756
  );
773
757
  }
@@ -258,6 +258,7 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
258
258
  style={{
259
259
  width: '100%',
260
260
  height: '100%',
261
+ minHeight: frameMinHeight,
261
262
  border: 'none',
262
263
  display: 'block',
263
264
  background: 'transparent',
@@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
3
3
  import { z } from 'zod';
4
4
  import { useState, useRef, useEffect } from 'react';
5
5
  import { HexColorPicker } from 'react-colorful';
6
- import { Button, Input, Toggle, Stack, Text, Card, Field, Separator, Select } from '@fragments-sdk/ui';
6
+ import { Box, Button, Input, Toggle, Stack, Text, Card, Field, Separator, Select } from '@fragments-sdk/ui';
7
7
  import { generateColorScheme, type ColorSchemeType } from '../utils/colorSchemes.js';
8
8
  import { ChevronDownIcon } from './Icons.js';
9
9
 
@@ -106,7 +106,7 @@ export function LandingPage({ onSubmit }: LandingPageProps) {
106
106
  };
107
107
 
108
108
  return (
109
- <div style={{ minHeight: '100vh', backgroundColor: 'var(--bg-primary)' }}>
109
+ <Box background="primary" style={{ minHeight: '100vh' }}>
110
110
  <div style={{ maxWidth: '672px', margin: '0 auto', padding: '64px 24px' }}>
111
111
  {/* Header */}
112
112
  <Stack direction="column" gap="sm" style={{ marginBottom: '48px' }}>
@@ -416,6 +416,6 @@ export function LandingPage({ onSubmit }: LandingPageProps) {
416
416
  </Stack>
417
417
  </form>
418
418
  </div>
419
- </div>
419
+ </Box>
420
420
  );
421
421
  }
@@ -1,6 +1,6 @@
1
1
  import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
2
2
  import type { FragmentDefinition } from '../../core/index.js';
3
- import { Sidebar, useSidebar, Text, Menu } from '@fragments-sdk/ui';
3
+ import { Sidebar, useSidebar, Text, Menu, Button, Box, Stack } from '@fragments-sdk/ui';
4
4
 
5
5
  // Fuzzy matching utility
6
6
  interface FuzzyMatch {
@@ -160,6 +160,32 @@ const FilterIcon = ({ active }: { active?: boolean }) => (
160
160
 
161
161
  const isInstalled = (path: string) => path.startsWith('@');
162
162
 
163
+ /** Normalize category names to Title Case: "form" → "Form", "Data Display" stays */
164
+ function titleCase(str: string): string {
165
+ return str.replace(/\b\w/g, (c) => c.toUpperCase());
166
+ }
167
+
168
+ /** Normalize category to a canonical key so "Form" and "Forms" merge into one group.
169
+ * Returns the singular form as grouping key. */
170
+ function categoryKey(raw: string): string {
171
+ const tc = titleCase(raw.trim());
172
+ // Strip trailing "s" plurals (Forms→Form, Buttons→Button) but not short words like "Tabs"
173
+ if (tc.length > 4 && tc.endsWith('s') && !tc.endsWith('ss')) {
174
+ return tc.slice(0, -1);
175
+ }
176
+ return tc;
177
+ }
178
+
179
+ /** Pluralize a category name for display: "Form" → "Forms", "General" stays */
180
+ function pluralizeCategory(key: string): string {
181
+ // Already plural or naturally non-plural categories
182
+ const nonPlural = new Set(['General', 'Feedback', 'Layout', 'Navigation', 'Typography', 'Deprecated', 'Data Display']);
183
+ if (nonPlural.has(key)) return key;
184
+ // Already ends in s (edge case)
185
+ if (key.endsWith('s')) return key;
186
+ return key + 's';
187
+ }
188
+
163
189
  export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect, showHealth, onHealthClick }: LeftSidebarProps) {
164
190
  const [focusedIndex, setFocusedIndex] = useState(-1);
165
191
  const [showCustom, setShowCustom] = useState(true);
@@ -229,6 +255,22 @@ export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect,
229
255
  );
230
256
  }, [sourceFiltered, searchResults, isFilterActive, showLibrary]);
231
257
 
258
+ // Group items by category when not searching (for large lists)
259
+ const categoryGroups = useMemo(() => {
260
+ if (searchResults || flatItems.length < 30) return null; // Flat list for small sets or search
261
+ const groups = new Map<string, typeof flatItems>();
262
+ for (const item of flatItems) {
263
+ const raw = item.fragment.meta.category || 'Uncategorized';
264
+ const key = categoryKey(raw);
265
+ if (!groups.has(key)) groups.set(key, []);
266
+ groups.get(key)!.push(item);
267
+ }
268
+ // Sort categories alphabetically, map key → display label
269
+ return [...groups.entries()]
270
+ .sort((a, b) => a[0].toLowerCase().localeCompare(b[0].toLowerCase()))
271
+ .map(([key, items]) => [pluralizeCategory(key), items] as const);
272
+ }, [flatItems, searchResults]);
273
+
232
274
  const keyboardItems = useMemo(() => {
233
275
  const componentItems = flatItems.map((item) => ({ type: 'component' as const, path: item.path }));
234
276
  if (!onHealthClick) return componentItems;
@@ -245,6 +287,19 @@ export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect,
245
287
  }
246
288
  }, [focusedIndex, keyboardItems.length]);
247
289
 
290
+ // Scroll the active sidebar item into view on mount and when activeFragment changes
291
+ useEffect(() => {
292
+ if (!activeFragment || !navRef.current) return;
293
+ // Small delay to let the DOM render after category grouping
294
+ const timer = setTimeout(() => {
295
+ const activeButton = navRef.current?.querySelector<HTMLButtonElement>('[aria-current="page"]');
296
+ if (activeButton) {
297
+ activeButton.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
298
+ }
299
+ }, 100);
300
+ return () => clearTimeout(timer);
301
+ }, [activeFragment]);
302
+
248
303
  useEffect(() => {
249
304
  setFocusedIndex(-1);
250
305
  }, [searchQuery]);
@@ -312,82 +367,105 @@ export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect,
312
367
  </Sidebar.Section>
313
368
  )}
314
369
 
315
- <Sidebar.Section
316
- label="Components"
317
- action={hasBothSources ? (
318
- <Menu modal={false}>
319
- <Menu.Trigger asChild>
320
- <button
321
- type="button"
322
- aria-label="Filter components by source"
323
- style={{
324
- background: 'none',
325
- border: 'none',
326
- cursor: 'pointer',
327
- padding: '2px',
328
- display: 'flex',
329
- alignItems: 'center',
330
- color: 'var(--text-tertiary)',
331
- borderRadius: '4px',
332
- }}
333
- >
334
- <FilterIcon active={isFilterActive} />
335
- </button>
336
- </Menu.Trigger>
337
- <Menu.Content>
338
- <Menu.CheckboxItem checked={showCustom} onCheckedChange={setShowCustom}>
339
- Custom
340
- </Menu.CheckboxItem>
341
- <Menu.CheckboxItem checked={showLibrary} onCheckedChange={setShowLibrary}>
342
- Fragments UI
343
- </Menu.CheckboxItem>
344
- </Menu.Content>
345
- </Menu>
346
- ) : undefined}
347
- >
348
- {flatItems.map((item) => {
349
- const hasError = !!(item.fragment as any)?._loadError;
350
- return (
351
- <Sidebar.Item
352
- key={item.path}
353
- active={activeFragment === item.path}
354
- onClick={() => handleSelect(item.path)}
355
- >
356
- <span style={{ display: 'flex', alignItems: 'center', gap: '6px', width: '100%' }}>
357
- <HighlightedText
358
- text={item.fragment.meta.name}
359
- indices={highlightMap.get(item.path) || []}
360
- />
361
- {hasError && (
362
- <span
363
- title="Missing dependencies"
364
- style={{ color: 'var(--color-warning, #f59e0b)', fontSize: '14px', lineHeight: 1, flexShrink: 0 }}
370
+ {categoryGroups ? (
371
+ // Grouped by category for large component lists
372
+ <>
373
+ {categoryGroups.map(([category, items]) => (
374
+ <Sidebar.Section key={category} label={`${category} (${items.length})`}>
375
+ {items.map((item) => {
376
+ const hasError = !!(item.fragment as any)?._loadError;
377
+ return (
378
+ <Sidebar.Item
379
+ key={item.path}
380
+ active={activeFragment === item.path}
381
+ onClick={() => handleSelect(item.path)}
365
382
  >
366
- &#9888;
367
- </span>
368
- )}
369
- </span>
370
- </Sidebar.Item>
371
- );
372
- })}
373
- </Sidebar.Section>
383
+ <span style={{ display: 'flex', alignItems: 'center', gap: '6px', width: '100%' }}>
384
+ <HighlightedText
385
+ text={item.fragment.meta.name}
386
+ indices={highlightMap.get(item.path) || []}
387
+ />
388
+ {hasError && (
389
+ <span
390
+ title="Missing dependencies"
391
+ style={{ color: 'var(--color-warning, #f59e0b)', fontSize: '14px', lineHeight: 1, flexShrink: 0 }}
392
+ >
393
+ &#9888;
394
+ </span>
395
+ )}
396
+ </span>
397
+ </Sidebar.Item>
398
+ );
399
+ })}
400
+ </Sidebar.Section>
401
+ ))}
402
+ </>
403
+ ) : (
404
+ // Flat list for search results or small component sets
405
+ <Sidebar.Section
406
+ label="Components"
407
+ action={hasBothSources ? (
408
+ <Menu modal={false}>
409
+ <Menu.Trigger asChild>
410
+ <Button variant="ghost" size="sm" icon aria-label="Filter components by source">
411
+ <FilterIcon active={isFilterActive} />
412
+ </Button>
413
+ </Menu.Trigger>
414
+ <Menu.Content>
415
+ <Menu.CheckboxItem checked={showCustom} onCheckedChange={setShowCustom}>
416
+ Custom
417
+ </Menu.CheckboxItem>
418
+ <Menu.CheckboxItem checked={showLibrary} onCheckedChange={setShowLibrary}>
419
+ Fragments UI
420
+ </Menu.CheckboxItem>
421
+ </Menu.Content>
422
+ </Menu>
423
+ ) : undefined}
424
+ >
425
+ {flatItems.map((item) => {
426
+ const hasError = !!(item.fragment as any)?._loadError;
427
+ return (
428
+ <Sidebar.Item
429
+ key={item.path}
430
+ active={activeFragment === item.path}
431
+ onClick={() => handleSelect(item.path)}
432
+ >
433
+ <span style={{ display: 'flex', alignItems: 'center', gap: '6px', width: '100%' }}>
434
+ <HighlightedText
435
+ text={item.fragment.meta.name}
436
+ indices={highlightMap.get(item.path) || []}
437
+ />
438
+ {hasError && (
439
+ <span
440
+ title="Missing dependencies"
441
+ style={{ color: 'var(--color-warning, #f59e0b)', fontSize: '14px', lineHeight: 1, flexShrink: 0 }}
442
+ >
443
+ &#9888;
444
+ </span>
445
+ )}
446
+ </span>
447
+ </Sidebar.Item>
448
+ );
449
+ })}
450
+ </Sidebar.Section>
451
+ )}
374
452
 
375
453
  {flatItems.length === 0 && (
376
- <div style={{ padding: '8px 32px', textAlign: 'center' }}>
454
+ <Box paddingX="lg" paddingY="sm" style={{ textAlign: 'center' }}>
377
455
  <Text size="sm" color="tertiary">No results</Text>
378
- </div>
456
+ </Box>
379
457
  )}
380
458
  </div>
381
459
  </Sidebar.Nav>
382
460
 
383
461
  {/* Footer */}
384
462
  <Sidebar.Footer>
385
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
463
+ <Stack direction="row" align="center" justify="between" style={{ width: '100%' }}>
386
464
  <Text size="xs" color="tertiary">
387
465
  {isFilterActive || searchResults ? `${flatItems.length} of ${fragments.length}` : fragments.length} components
388
466
  </Text>
389
467
  <Sidebar.CollapseToggle />
390
- </div>
468
+ </Stack>
391
469
  </Sidebar.Footer>
392
470
  </>
393
471
  );