@fragments-sdk/cli 0.6.0 → 0.7.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 (49) hide show
  1. package/dist/bin.js +294 -50
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-D35RGPAG.js → chunk-7OPWMLOE.js} +435 -19
  4. package/dist/chunk-7OPWMLOE.js.map +1 -0
  5. package/dist/{chunk-SSLQXHNX.js → chunk-CVXKXVOY.js} +1 -1
  6. package/dist/{chunk-SSLQXHNX.js.map → chunk-CVXKXVOY.js.map} +1 -1
  7. package/dist/{chunk-Q7GOHVOK.js → chunk-TJ34N7C7.js} +39 -2
  8. package/dist/{chunk-Q7GOHVOK.js.map → chunk-TJ34N7C7.js.map} +1 -1
  9. package/dist/{chunk-F7ITZPDJ.js → chunk-XHUDJNN3.js} +2 -2
  10. package/dist/{core-SKRPJQZG.js → core-W2HYIQW6.js} +2 -2
  11. package/dist/{generate-7AF7WRVK.js → generate-LMTISDIJ.js} +3 -3
  12. package/dist/index.js +3 -3
  13. package/dist/{init-WKGDPYI4.js → init-7CHRKQ7P.js} +3 -3
  14. package/dist/mcp-bin.js +2 -2
  15. package/dist/{scan-K6JNMCGM.js → scan-WY23TJCP.js} +4 -4
  16. package/dist/{service-F3E4JJM7.js → service-T2L7VLTE.js} +2 -2
  17. package/dist/{static-viewer-4LQZ5AGA.js → static-viewer-GBR7YNF3.js} +2 -2
  18. package/dist/{test-CJDNJTPZ.js → test-OJRXNDO2.js} +2 -2
  19. package/dist/{tokens-JAJABYXP.js → tokens-3BWDESVM.js} +3 -3
  20. package/dist/{viewer-R3Q6WAMJ.js → viewer-SUFOISZM.js} +12 -12
  21. package/package.json +2 -2
  22. package/src/bin.ts +23 -0
  23. package/src/build.ts +43 -0
  24. package/src/commands/graph.ts +274 -0
  25. package/src/core/composition.ts +64 -1
  26. package/src/core/graph-extractor.test.ts +542 -0
  27. package/src/core/graph-extractor.ts +601 -0
  28. package/src/core/importAnalyzer.ts +5 -0
  29. package/src/viewer/components/App.tsx +128 -30
  30. package/src/viewer/components/Icons.tsx +53 -1
  31. package/src/viewer/components/Layout.tsx +7 -3
  32. package/src/viewer/components/LeftSidebar.tsx +65 -87
  33. package/src/viewer/components/PreviewFrameHost.tsx +30 -1
  34. package/src/viewer/components/PreviewToolbar.tsx +57 -10
  35. package/src/viewer/components/ViewportSelector.tsx +56 -45
  36. package/src/viewer/constants/ui.ts +4 -4
  37. package/src/viewer/preview-frame.html +22 -13
  38. package/src/viewer/styles/globals.css +42 -81
  39. package/dist/chunk-D35RGPAG.js.map +0 -1
  40. /package/dist/{chunk-F7ITZPDJ.js.map → chunk-XHUDJNN3.js.map} +0 -0
  41. /package/dist/{core-SKRPJQZG.js.map → core-W2HYIQW6.js.map} +0 -0
  42. /package/dist/{generate-7AF7WRVK.js.map → generate-LMTISDIJ.js.map} +0 -0
  43. /package/dist/{init-WKGDPYI4.js.map → init-7CHRKQ7P.js.map} +0 -0
  44. /package/dist/{scan-K6JNMCGM.js.map → scan-WY23TJCP.js.map} +0 -0
  45. /package/dist/{service-F3E4JJM7.js.map → service-T2L7VLTE.js.map} +0 -0
  46. /package/dist/{static-viewer-4LQZ5AGA.js.map → static-viewer-GBR7YNF3.js.map} +0 -0
  47. /package/dist/{test-CJDNJTPZ.js.map → test-OJRXNDO2.js.map} +0 -0
  48. /package/dist/{tokens-JAJABYXP.js.map → tokens-3BWDESVM.js.map} +0 -0
  49. /package/dist/{viewer-R3Q6WAMJ.js.map → viewer-SUFOISZM.js.map} +0 -0
@@ -3,8 +3,8 @@
3
3
  * Refactored for better performance and maintainability.
4
4
  */
5
5
 
6
- import { useState, useMemo, useEffect, useCallback, useRef } from "react";
7
- import type { SegmentDefinition } from "../../core/index.js";
6
+ import { useState, useMemo, useEffect, useCallback, useRef, type RefObject } from "react";
7
+ import { BRAND, type SegmentDefinition } from "../../core/index.js";
8
8
 
9
9
  // Layout & Navigation
10
10
  import { Layout } from "./Layout.js";
@@ -28,10 +28,10 @@ import { useAllFigmaUrls } from "./FigmaEmbed.js";
28
28
  import { ActionCapture } from "./ActionCapture.js";
29
29
 
30
30
  // Fragments UI
31
- import { Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert } from "@fragments/ui";
31
+ import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, ScrollArea, Input } from "@fragments/ui";
32
32
 
33
33
  // Icons
34
- import { EmptyIcon, ExternalLinkIcon, CameraIcon, FigmaIcon, CompareIcon, CheckIcon, LinkIcon, GridIcon, DevicesIcon } from "./Icons.js";
34
+ import { EmptyIcon, ExternalLinkIcon, FigmaIcon, CompareIcon, CheckIcon, LinkIcon, GridIcon, DevicesIcon } from "./Icons.js";
35
35
 
36
36
  // Hooks
37
37
  import { useAppState } from "../hooks/useAppState.js";
@@ -81,7 +81,7 @@ export function App({ segments }: AppProps) {
81
81
  const { resolvedTheme } = useTheme();
82
82
 
83
83
  // Toast notifications (via Fragments UI ToastProvider)
84
- const { toast, info, success } = useToast();
84
+ const { info, success } = useToast();
85
85
 
86
86
  // Navigation state
87
87
  const [activeSegmentPath, setActiveSegmentPath] = useState<string | null>(() => {
@@ -99,6 +99,8 @@ export function App({ segments }: AppProps) {
99
99
  }
100
100
  return 0;
101
101
  });
102
+ const [searchQuery, setSearchQuery] = useState('');
103
+ const searchInputRef = useRef<HTMLInputElement>(null);
102
104
 
103
105
  // Derived values
104
106
  const activeSegment = useMemo(
@@ -192,6 +194,11 @@ export function App({ segments }: AppProps) {
192
194
  }
193
195
  }, [copyUrl, success, uiActions]);
194
196
 
197
+ const focusSearchInput = useCallback(() => {
198
+ searchInputRef.current?.focus();
199
+ searchInputRef.current?.select();
200
+ }, []);
201
+
195
202
  // Sorted segment paths for keyboard navigation
196
203
  const sortedSegmentPaths = useMemo(() => {
197
204
  return [...segments]
@@ -221,8 +228,18 @@ export function App({ segments }: AppProps) {
221
228
  togglePanel: uiActions.togglePanel,
222
229
  copyLink: handleCopyLink,
223
230
  showHelp: uiActions.toggleShortcutsHelp,
224
- openSearch: () => uiActions.setCommandPalette(true),
225
- escape: uiActions.closeAllModals,
231
+ openSearch: focusSearchInput,
232
+ escape: () => {
233
+ if (document.activeElement === searchInputRef.current) {
234
+ if (searchQuery) {
235
+ setSearchQuery('');
236
+ } else {
237
+ searchInputRef.current.blur();
238
+ }
239
+ return;
240
+ }
241
+ uiActions.closeAllModals();
242
+ },
226
243
  },
227
244
  { enabled: !uiState.showShortcutsHelp, variantCount }
228
245
  );
@@ -270,10 +287,35 @@ export function App({ segments }: AppProps) {
270
287
  />
271
288
 
272
289
  <Layout
290
+ header={
291
+ activeSegment && !uiState.showHealthDashboard ? (
292
+ <TopToolbar
293
+ segment={activeSegment}
294
+ variant={activeVariant}
295
+ viewSettings={viewSettings}
296
+ uiState={uiState}
297
+ uiActions={uiActions}
298
+ figmaUrl={figmaUrl}
299
+ linkCopied={uiState.linkCopied}
300
+ onCopyLink={handleCopyLink}
301
+ searchQuery={searchQuery}
302
+ onSearchChange={setSearchQuery}
303
+ searchInputRef={searchInputRef}
304
+ />
305
+ ) : (
306
+ <ViewerHeader
307
+ showHealth={uiState.showHealthDashboard}
308
+ searchQuery={searchQuery}
309
+ onSearchChange={setSearchQuery}
310
+ searchInputRef={searchInputRef}
311
+ />
312
+ )
313
+ }
273
314
  leftSidebar={
274
315
  <LeftSidebar
275
316
  segments={segments}
276
317
  activeSegment={uiState.showHealthDashboard ? null : activeSegmentPath}
318
+ searchQuery={searchQuery}
277
319
  onSelect={handleSelectSegment}
278
320
  showHealth={uiState.showHealthDashboard}
279
321
  onHealthClick={() => {
@@ -302,18 +344,6 @@ export function App({ segments }: AppProps) {
302
344
  <div style={{ display: 'flex', height: '100%', flexDirection: panelDock === "bottom" ? 'column' : 'row' }}>
303
345
  {/* Main Content Area */}
304
346
  <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
305
- {/* Top Toolbar */}
306
- <TopToolbar
307
- segment={activeSegment}
308
- variant={activeVariant}
309
- viewSettings={viewSettings}
310
- uiState={uiState}
311
- uiActions={uiActions}
312
- figmaUrl={figmaUrl}
313
- linkCopied={uiState.linkCopied}
314
- onCopyLink={handleCopyLink}
315
- />
316
-
317
347
  {/* Variant Tabs */}
318
348
  {activeSegment.segment.variants && activeSegment.segment.variants.length > 0 && (
319
349
  <VariantTabsBar
@@ -416,16 +446,82 @@ interface TopToolbarProps {
416
446
  figmaUrl?: string;
417
447
  linkCopied: boolean;
418
448
  onCopyLink: () => void;
449
+ searchQuery: string;
450
+ onSearchChange: (value: string) => void;
451
+ searchInputRef: RefObject<HTMLInputElement>;
452
+ }
453
+
454
+ interface ViewerHeaderProps {
455
+ showHealth: boolean;
456
+ searchQuery: string;
457
+ onSearchChange: (value: string) => void;
458
+ searchInputRef: RefObject<HTMLInputElement>;
459
+ }
460
+
461
+ interface HeaderSearchProps {
462
+ value: string;
463
+ onChange: (value: string) => void;
464
+ inputRef: RefObject<HTMLInputElement>;
419
465
  }
420
466
 
421
- function TopToolbar({ segment, variant, viewSettings, uiState, uiActions, figmaUrl, linkCopied, onCopyLink }: TopToolbarProps) {
467
+ function HeaderSearch({ value, onChange, inputRef }: HeaderSearchProps) {
422
468
  return (
423
- <Stack direction="row" align="center" justify="between" style={{ padding: '8px 16px', borderBottom: '1px solid var(--border)', backgroundColor: 'var(--bg-secondary)', flexShrink: 0 }}>
424
- <Stack direction="row" align="center" gap="sm">
425
- <Text weight="medium" size="sm">{segment.segment.meta.name}</Text>
426
- <Text size="xs" color="tertiary">{segment.segment.meta.category}</Text>
427
- </Stack>
428
- <Stack direction="row" align="center" gap="sm">
469
+ <Header.Search expandable>
470
+ <Input
471
+ ref={inputRef}
472
+ value={value}
473
+ onChange={onChange}
474
+ placeholder="Search components"
475
+ aria-label="Search components"
476
+ size="sm"
477
+ shortcut="⌘K"
478
+ style={{ width: '220px' }}
479
+ />
480
+ </Header.Search>
481
+ );
482
+ }
483
+
484
+ function ViewerHeader({ showHealth, searchQuery, onSearchChange, searchInputRef }: ViewerHeaderProps) {
485
+ return (
486
+ <Header aria-label="Fragments viewer header">
487
+ <Header.Trigger />
488
+ <Header.Brand>
489
+ <Stack direction="row" gap="sm" align="center">
490
+ <Text weight="medium" size="sm">{BRAND.name}</Text>
491
+ <Text size="xs" color="tertiary">{showHealth ? 'health dashboard' : 'preview'}</Text>
492
+ </Stack>
493
+ </Header.Brand>
494
+ <HeaderSearch value={searchQuery} onChange={onSearchChange} inputRef={searchInputRef} />
495
+ <Header.Spacer />
496
+ </Header>
497
+ );
498
+ }
499
+
500
+ function TopToolbar({
501
+ segment,
502
+ variant,
503
+ viewSettings,
504
+ uiState,
505
+ uiActions,
506
+ figmaUrl,
507
+ linkCopied,
508
+ onCopyLink,
509
+ searchQuery,
510
+ onSearchChange,
511
+ searchInputRef,
512
+ }: TopToolbarProps) {
513
+ return (
514
+ <Header aria-label="Component preview toolbar">
515
+ <Header.Trigger />
516
+ <Header.Brand>
517
+ <Stack direction="row" align="center" gap="sm">
518
+ <Text weight="medium" size="sm">{segment.segment.meta.name}</Text>
519
+ <Text size="xs" color="tertiary">{segment.segment.meta.category}</Text>
520
+ </Stack>
521
+ </Header.Brand>
522
+ <HeaderSearch value={searchQuery} onChange={onSearchChange} inputRef={searchInputRef} />
523
+ <Header.Spacer />
524
+ <Header.Actions>
429
525
  <PreviewToolbar
430
526
  zoom={viewSettings.zoom}
431
527
  background={viewSettings.background}
@@ -499,8 +595,8 @@ function TopToolbar({ segment, variant, viewSettings, uiState, uiActions, figmaU
499
595
  </Tooltip>
500
596
  </>
501
597
  )}
502
- </Stack>
503
- </Stack>
598
+ </Header.Actions>
599
+ </Header>
504
600
  );
505
601
  }
506
602
 
@@ -519,11 +615,13 @@ function VariantTabsBar({ variants, activeIndex, onSelect, showMatrixView, showM
519
615
  return (
520
616
  <Stack direction="row" align="center" justify="between" style={{ padding: '8px 16px', borderBottom: '1px solid var(--border)', backgroundColor: 'var(--bg-primary)', flexShrink: 0 }}>
521
617
  {!showMatrixView ? (
522
- <VariantTabs variants={variants} activeIndex={activeIndex} onSelect={onSelect} />
618
+ <ScrollArea orientation="horizontal" showFades style={{ flex: 1, minWidth: 0 }}>
619
+ <VariantTabs variants={variants} activeIndex={activeIndex} onSelect={onSelect} />
620
+ </ScrollArea>
523
621
  ) : (
524
622
  <Text size="sm" color="secondary">Showing all {variants.length} variants</Text>
525
623
  )}
526
- <Stack direction="row" align="center" gap="sm" style={{ marginLeft: '16px' }}>
624
+ <Stack direction="row" align="center" gap="sm" style={{ marginLeft: '16px', flexShrink: 0 }}>
527
625
  {variants.length > 1 && (
528
626
  <Button
529
627
  onClick={onToggleMatrix}
@@ -199,6 +199,49 @@ export function ViewportIcon({ className, style }: IconProps) {
199
199
  );
200
200
  }
201
201
 
202
+ export function ResponsiveIcon({ className, style }: IconProps) {
203
+ return (
204
+ <svg className={className} style={style} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.7}>
205
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7 12h10m0 0l-2.5-2.5M17 12l-2.5 2.5M7 12l2.5-2.5M7 12l2.5 2.5" />
206
+ </svg>
207
+ );
208
+ }
209
+
210
+ export function DesktopIcon({ className, style }: IconProps) {
211
+ return (
212
+ <svg className={className} style={style} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
213
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 5.25A2.25 2.25 0 016 3h12a2.25 2.25 0 012.25 2.25v8.25A2.25 2.25 0 0118 15.75H6a2.25 2.25 0 01-2.25-2.25V5.25zM9.75 20.25h4.5M12 15.75v4.5" />
214
+ </svg>
215
+ );
216
+ }
217
+
218
+ export function TabletIcon({ className, style }: IconProps) {
219
+ return (
220
+ <svg className={className} style={style} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
221
+ <rect x="6.75" y="3" width="10.5" height="18" rx="2.25" />
222
+ <circle cx="12" cy="17.25" r="0.85" fill="currentColor" stroke="none" />
223
+ </svg>
224
+ );
225
+ }
226
+
227
+ export function MobileIcon({ className, style }: IconProps) {
228
+ return (
229
+ <svg className={className} style={style} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
230
+ <rect x="8.25" y="2.25" width="7.5" height="19.5" rx="2.25" />
231
+ <circle cx="12" cy="18.75" r="0.8" fill="currentColor" stroke="none" />
232
+ </svg>
233
+ );
234
+ }
235
+
236
+ export function SettingsIcon({ className, style }: IconProps) {
237
+ return (
238
+ <svg className={className} style={style} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
239
+ <path strokeLinecap="round" strokeLinejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.094c.55 0 1.02.398 1.11.94l.18 1.09c.06.36.29.668.62.84l.94.5c.318.168.7.18 1.028.03l.998-.455c.5-.23 1.09-.048 1.38.42l.546.882c.29.468.19 1.074-.24 1.424l-.858.698a1.26 1.26 0 00-.456.985v1.046c0 .38.17.738.456.984l.858.699c.43.35.53.956.24 1.424l-.545.882a1.1 1.1 0 01-1.381.42l-.998-.455a1.26 1.26 0 00-1.028.03l-.94.5a1.26 1.26 0 00-.62.84l-.18 1.09a1.125 1.125 0 01-1.11.94h-1.094a1.124 1.124 0 01-1.11-.94l-.18-1.09a1.26 1.26 0 00-.62-.84l-.94-.5a1.26 1.26 0 00-1.028-.03l-.998.455a1.1 1.1 0 01-1.381-.42l-.545-.882a1.125 1.125 0 01.24-1.424l.858-.699a1.26 1.26 0 00.456-.984V9.878c0-.38-.17-.738-.456-.985l-.858-.698a1.125 1.125 0 01-.24-1.424l.545-.882c.29-.468.88-.65 1.381-.42l.998.455c.33.15.71.138 1.028-.03l.94-.5c.33-.172.56-.48.62-.84l.18-1.09z" />
240
+ <circle cx="12" cy="12" r="3" />
241
+ </svg>
242
+ );
243
+ }
244
+
202
245
  // Misc
203
246
  export function ControlsIcon({ className, style }: IconProps) {
204
247
  return (
@@ -235,7 +278,16 @@ export function HeartPulseIcon({ className, style }: IconProps) {
235
278
 
236
279
  export function DashboardIcon({ className, style }: IconProps) {
237
280
  return (
238
- <svg className={className} style={style} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
281
+ <svg
282
+ className={className}
283
+ style={style}
284
+ width="20"
285
+ height="20"
286
+ fill="none"
287
+ viewBox="0 0 24 24"
288
+ stroke="currentColor"
289
+ strokeWidth={1.5}
290
+ >
239
291
  <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
240
292
  </svg>
241
293
  );
@@ -3,13 +3,17 @@ import { AppShell } from '@fragments/ui';
3
3
 
4
4
  interface LayoutProps {
5
5
  leftSidebar: ReactNode;
6
+ header: ReactNode;
6
7
  children: ReactNode;
7
8
  }
8
9
 
9
- export function Layout({ leftSidebar, children }: LayoutProps) {
10
+ export function Layout({ leftSidebar, header, children }: LayoutProps) {
10
11
  return (
11
- <AppShell layout="sidebar-inset">
12
- <AppShell.Sidebar width="256px">
12
+ <AppShell layout="inset">
13
+ <AppShell.Header>
14
+ {header}
15
+ </AppShell.Header>
16
+ <AppShell.Sidebar width="256px" collapsible="icon">
13
17
  {leftSidebar}
14
18
  </AppShell.Sidebar>
15
19
  <AppShell.Main padding="none">
@@ -2,8 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
2
2
  import type { SegmentDefinition } from '../../core/index.js';
3
3
  import { BRAND } from '../../core/index.js';
4
4
  import { useTheme } from './ThemeProvider.js';
5
- import { SunIcon, MoonIcon, DashboardIcon } from './Icons.js';
6
- import { Sidebar, Input, Button, Badge, Text } from '@fragments/ui';
5
+ import { Sidebar, useSidebar, Badge, Text, ThemeToggle } from '@fragments/ui';
7
6
 
8
7
  // Fuzzy matching utility
9
8
  interface FuzzyMatch {
@@ -122,19 +121,19 @@ function HighlightedText({ text, indices }: { text: string; indices: number[] })
122
121
  interface LeftSidebarProps {
123
122
  segments: Array<{ path: string; segment: SegmentDefinition }>;
124
123
  activeSegment: string | null;
124
+ searchQuery: string;
125
125
  onSelect: (path: string) => void;
126
126
  showHealth?: boolean;
127
127
  onHealthClick?: () => void;
128
128
  }
129
129
 
130
- export function LeftSidebar({ segments, activeSegment, onSelect, showHealth, onHealthClick }: LeftSidebarProps) {
131
- const [search, setSearch] = useState('');
130
+ export function LeftSidebar({ segments, activeSegment, searchQuery, onSelect, showHealth, onHealthClick }: LeftSidebarProps) {
132
131
  const [focusedIndex, setFocusedIndex] = useState(-1);
133
- const { theme, setTheme, resolvedTheme } = useTheme();
134
- const searchInputRef = useRef<HTMLInputElement>(null);
132
+ const { setTheme, resolvedTheme } = useTheme();
133
+ const { isMobile, setOpen } = useSidebar();
135
134
  const navRef = useRef<HTMLDivElement>(null);
136
135
 
137
- const debouncedSearch = useDebounce(search, 150);
136
+ const debouncedSearch = useDebounce(searchQuery, 150);
138
137
 
139
138
  const searchResults = useMemo(() => {
140
139
  if (!debouncedSearch) return null;
@@ -175,11 +174,6 @@ export function LeftSidebar({ segments, activeSegment, onSelect, showHealth, onH
175
174
  return groups;
176
175
  }, [segments, searchResults]);
177
176
 
178
- const toggleTheme = () => {
179
- // Simple toggle between light and dark
180
- setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
181
- };
182
-
183
177
  const flatItems = useMemo(() => {
184
178
  const items: Array<{ path: string; segment: SegmentDefinition }> = [];
185
179
  const sortedEntries = Object.entries(grouped).sort(([a], [b]) =>
@@ -196,60 +190,67 @@ export function LeftSidebar({ segments, activeSegment, onSelect, showHealth, onH
196
190
  return items;
197
191
  }, [grouped]);
198
192
 
193
+ const keyboardItems = useMemo(() => {
194
+ const componentItems = flatItems.map((item) => ({ type: 'component' as const, path: item.path }));
195
+ if (!onHealthClick) return componentItems;
196
+ return [{ type: 'dashboard' as const }, ...componentItems];
197
+ }, [flatItems, onHealthClick]);
198
+
199
199
  useEffect(() => {
200
- if (focusedIndex >= 0 && focusedIndex < flatItems.length && navRef.current) {
200
+ if (focusedIndex >= 0 && focusedIndex < keyboardItems.length && navRef.current) {
201
201
  // Query all nav item buttons rendered by Sidebar.Item inside the nav
202
202
  const buttons = navRef.current.querySelectorAll<HTMLButtonElement>('li > button[type="button"]');
203
203
  if (buttons[focusedIndex]) {
204
204
  buttons[focusedIndex].focus();
205
205
  }
206
206
  }
207
- }, [focusedIndex, flatItems.length]);
207
+ }, [focusedIndex, keyboardItems.length]);
208
208
 
209
209
  useEffect(() => {
210
210
  setFocusedIndex(-1);
211
- }, [search]);
211
+ }, [searchQuery]);
212
212
 
213
- const handleKeyDown = useCallback((e: KeyboardEvent) => {
214
- const target = e.target as HTMLElement;
215
- if ((target.tagName === 'INPUT' && target !== searchInputRef.current) ||
216
- target.tagName === 'TEXTAREA' || target.isContentEditable) {
217
- return;
213
+ const handleSelect = (path: string) => {
214
+ onSelect(path);
215
+ if (isMobile) {
216
+ setOpen(false);
218
217
  }
218
+ };
219
219
 
220
- if ((e.key === '/' && !e.metaKey && !e.ctrlKey) ||
221
- (e.key === 'k' && (e.metaKey || e.ctrlKey))) {
222
- e.preventDefault();
223
- searchInputRef.current?.focus();
224
- searchInputRef.current?.select();
220
+ const handleHealthClick = () => {
221
+ onHealthClick?.();
222
+ if (isMobile) {
223
+ setOpen(false);
224
+ }
225
+ };
226
+
227
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
228
+ const target = e.target as HTMLElement;
229
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
225
230
  return;
226
231
  }
227
232
 
228
233
  if (e.key === 'Escape') {
229
- if (document.activeElement === searchInputRef.current) {
230
- setSearch('');
231
- searchInputRef.current?.blur();
232
- if (flatItems.length > 0) setFocusedIndex(0);
233
- } else {
234
- setSearch('');
235
- setFocusedIndex(-1);
236
- }
234
+ setFocusedIndex(-1);
237
235
  return;
238
236
  }
239
237
 
240
- if (document.activeElement === searchInputRef.current) return;
241
-
242
238
  if (e.key === 'ArrowDown') {
243
239
  e.preventDefault();
244
- setFocusedIndex(prev => (prev + 1) >= flatItems.length ? 0 : prev + 1);
240
+ setFocusedIndex(prev => (prev + 1) >= keyboardItems.length ? 0 : prev + 1);
245
241
  } else if (e.key === 'ArrowUp') {
246
242
  e.preventDefault();
247
- setFocusedIndex(prev => (prev - 1) < 0 ? flatItems.length - 1 : prev - 1);
248
- } else if (e.key === 'Enter' && focusedIndex >= 0 && focusedIndex < flatItems.length) {
243
+ setFocusedIndex(prev => (prev - 1) < 0 ? keyboardItems.length - 1 : prev - 1);
244
+ } else if (e.key === 'Enter' && focusedIndex >= 0 && focusedIndex < keyboardItems.length) {
249
245
  e.preventDefault();
250
- onSelect(flatItems[focusedIndex].path);
246
+ const currentItem = keyboardItems[focusedIndex];
247
+ if (currentItem.type === 'dashboard') {
248
+ handleHealthClick();
249
+ } else {
250
+ handleSelect(currentItem.path);
251
+ }
251
252
  }
252
- }, [flatItems, focusedIndex, onSelect]);
253
+ }, [keyboardItems, focusedIndex, handleHealthClick, handleSelect]);
253
254
 
254
255
  useEffect(() => {
255
256
  document.addEventListener('keydown', handleKeyDown);
@@ -261,60 +262,37 @@ export function LeftSidebar({ segments, activeSegment, onSelect, showHealth, onH
261
262
  );
262
263
 
263
264
  return (
264
- <Sidebar collapsible="none" style={{ height: '100%', backgroundColor: 'var(--bg-secondary)' }}>
265
+ <>
265
266
  {/* Header */}
266
267
  <Sidebar.Header>
267
268
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
268
269
  <Text size="sm" weight="medium">{BRAND.name}</Text>
269
- <Button
270
- variant="ghost"
271
- size="sm"
272
- icon
273
- onClick={toggleTheme}
274
- aria-label={`Theme: ${resolvedTheme}`}
275
- >
276
- {resolvedTheme === 'dark' ? (
277
- <MoonIcon />
278
- ) : (
279
- <SunIcon />
280
- )}
281
- </Button>
270
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
271
+ <ThemeToggle
272
+ size="sm"
273
+ value={resolvedTheme}
274
+ onValueChange={(value) => setTheme(value)}
275
+ aria-label={`Theme: ${resolvedTheme}`}
276
+ />
277
+ <Sidebar.CollapseToggle />
278
+ </div>
282
279
  </div>
283
280
  </Sidebar.Header>
284
281
 
285
- {/* Search */}
286
- <div style={{ padding: '12px' }}>
287
- <Input
288
- ref={searchInputRef}
289
- value={search}
290
- onChange={(value: string) => setSearch(value)}
291
- placeholder="Search"
292
- size="sm"
293
- />
294
- </div>
295
-
296
- {/* Dashboard Link */}
297
- {onHealthClick && (
298
- <div style={{ padding: '0 8px 12px' }}>
299
- <Button
300
- variant="ghost"
301
- size="sm"
302
- onClick={onHealthClick}
303
- style={{
304
- width: '100%',
305
- justifyContent: 'flex-start',
306
- ...(showHealth ? { backgroundColor: 'var(--bg-hover)', color: 'var(--text-primary)' } : {}),
307
- }}
308
- >
309
- <DashboardIcon />
310
- <span style={{ marginLeft: '8px' }}>Dashboard</span>
311
- </Button>
312
- </div>
313
- )}
314
-
315
282
  {/* Component list */}
316
- <div ref={navRef}>
283
+ <div ref={navRef} style={{ flex: 1, minHeight: 0, display: 'flex', overflow: 'hidden' }}>
317
284
  <Sidebar.Nav aria-label="Components">
285
+ {onHealthClick && (
286
+ <Sidebar.Section>
287
+ <Sidebar.Item
288
+ active={!!showHealth}
289
+ onClick={handleHealthClick}
290
+ >
291
+ Dashboard
292
+ </Sidebar.Item>
293
+ </Sidebar.Section>
294
+ )}
295
+
318
296
  {sortedEntries.map(([category, items]) => {
319
297
  const sortedItems = [...items]
320
298
  .filter(item => item.segment?.meta?.name)
@@ -331,7 +309,7 @@ export function LeftSidebar({ segments, activeSegment, onSelect, showHealth, onH
331
309
  <Sidebar.Item
332
310
  key={item.path}
333
311
  active={activeSegment === item.path}
334
- onClick={() => onSelect(item.path)}
312
+ onClick={() => handleSelect(item.path)}
335
313
  >
336
314
  <HighlightedText
337
315
  text={item.segment.meta.name}
@@ -356,6 +334,6 @@ export function LeftSidebar({ segments, activeSegment, onSelect, showHealth, onH
356
334
  <Sidebar.Footer>
357
335
  <Badge size="sm">{segments.length} components</Badge>
358
336
  </Sidebar.Footer>
359
- </Sidebar>
337
+ </>
360
338
  );
361
339
  }
@@ -24,6 +24,7 @@ interface SegmentDefinition {
24
24
  meta: {
25
25
  name: string;
26
26
  description?: string;
27
+ category?: string;
27
28
  };
28
29
  variants?: SegmentVariant[];
29
30
  }
@@ -82,6 +83,23 @@ function findVariant(segment: SegmentDefinition, variantName: string): SegmentVa
82
83
  return segment.variants?.find(v => v.name === variantName);
83
84
  }
84
85
 
86
+ type PreviewMode = 'centered' | 'full-bleed';
87
+
88
+ function resolvePreviewMode(segment: SegmentDefinition): PreviewMode {
89
+ const name = segment.meta.name.toLowerCase();
90
+ const category = (segment.meta.category || '').toLowerCase();
91
+
92
+ if (category === 'layout' || category === 'navigation') {
93
+ return 'full-bleed';
94
+ }
95
+
96
+ if (name.includes('appshell') || name.includes('sidebar') || name.includes('header') || name.includes('layout')) {
97
+ return 'full-bleed';
98
+ }
99
+
100
+ return 'centered';
101
+ }
102
+
85
103
  /**
86
104
  * Error boundary for catching render errors
87
105
  */
@@ -118,11 +136,13 @@ function LoadingIndicator() {
118
136
  function VariantRenderer({
119
137
  variant,
120
138
  props,
139
+ mode,
121
140
  onRendered,
122
141
  onError,
123
142
  }: {
124
143
  variant: SegmentVariant;
125
144
  props?: Record<string, unknown>;
145
+ mode: PreviewMode;
126
146
  onRendered: (width: number, height: number) => void;
127
147
  onError: (message: string, stack?: string) => void;
128
148
  }) {
@@ -208,7 +228,9 @@ function VariantRenderer({
208
228
  <div
209
229
  ref={containerRef}
210
230
  style={{
211
- display: 'inline-block',
231
+ display: mode === 'full-bleed' ? 'block' : 'inline-block',
232
+ width: mode === 'full-bleed' ? '100%' : undefined,
233
+ minHeight: mode === 'full-bleed' ? '100vh' : undefined,
212
234
  transition: 'opacity 150ms',
213
235
  opacity: content ? 1 : 0,
214
236
  }}
@@ -227,6 +249,7 @@ export function PreviewFrameHost() {
227
249
  const [loadError, setLoadError] = useState<string | null>(null);
228
250
  const [currentVariant, setCurrentVariant] = useState<SegmentVariant | null>(null);
229
251
  const [currentProps, setCurrentProps] = useState<Record<string, unknown> | undefined>(undefined);
252
+ const [previewMode, setPreviewMode] = useState<PreviewMode>('centered');
230
253
 
231
254
  // Apply theme to document
232
255
  useEffect(() => {
@@ -237,6 +260,10 @@ export function PreviewFrameHost() {
237
260
  }
238
261
  }, [theme]);
239
262
 
263
+ useEffect(() => {
264
+ document.body.setAttribute('data-preview-mode', previewMode);
265
+ }, [previewMode]);
266
+
240
267
  // Load segments on mount
241
268
  useEffect(() => {
242
269
  loadSegments()
@@ -273,6 +300,7 @@ export function PreviewFrameHost() {
273
300
  return;
274
301
  }
275
302
 
303
+ setPreviewMode(resolvePreviewMode(segmentItem.segment));
276
304
  setCurrentVariant(variant);
277
305
  setCurrentProps(props);
278
306
  }, [renderRequest, segments, notifyError]);
@@ -302,6 +330,7 @@ export function PreviewFrameHost() {
302
330
  key={`${renderRequest?.segmentPath}-${renderRequest?.variantName}`}
303
331
  variant={currentVariant}
304
332
  props={currentProps}
333
+ mode={previewMode}
305
334
  onRendered={notifyRendered}
306
335
  onError={notifyError}
307
336
  />