@fragments-sdk/cli 0.7.10 → 0.7.12

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.
@@ -3,19 +3,18 @@
3
3
  * Refactored for better performance and maintainability.
4
4
  */
5
5
 
6
- import { useState, useMemo, useEffect, useCallback, useRef, type RefObject } from "react";
7
- import { BRAND, type FragmentDefinition } from "../../core/index.js";
6
+ import { useState, useMemo, useEffect, useCallback, useRef, type CSSProperties, type ReactNode, type RefObject } from "react";
7
+ import { BRAND, type FragmentDefinition, type FragmentVariant } from "../../core/index.js";
8
8
 
9
9
  // Layout & Navigation
10
10
  import { Layout } from "./Layout.js";
11
11
  import { LeftSidebar } from "./LeftSidebar.js";
12
- import { VariantTabs } from "./VariantTabs.js";
13
12
  import { CommandPalette } from "./CommandPalette.js";
14
13
  import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp.js";
15
14
  import { useToast } from "./Toast.js";
16
15
 
17
16
  // Toolbar
18
- import { PreviewMenu } from "./PreviewMenu.js";
17
+ import { PreviewToolbar } from "./PreviewToolbar.js";
19
18
  import { getBackgroundStyle } from "../constants/ui.js";
20
19
 
21
20
  // Preview & Rendering
@@ -28,7 +27,8 @@ import { useAllFigmaUrls } from "./FigmaEmbed.js";
28
27
  import { ActionCapture } from "./ActionCapture.js";
29
28
 
30
29
  // Fragments UI
31
- import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, ScrollArea, Input, ThemeToggle } from "@fragments-sdk/ui";
30
+ import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, Input, ThemeToggle } from "@fragments-sdk/ui";
31
+ import { DeviceMobile, GridFour, Rows } from "@phosphor-icons/react";
32
32
 
33
33
  // Icons
34
34
  import { EmptyIcon, FigmaIcon, CompareIcon } from "./Icons.js";
@@ -99,6 +99,8 @@ export function App({ fragments }: AppProps) {
99
99
  }
100
100
  return fragments[0]?.path ?? null;
101
101
  });
102
+ const activeFragmentPathRef = useRef(activeFragmentPath);
103
+ activeFragmentPathRef.current = activeFragmentPath;
102
104
 
103
105
  const [activeVariantIndex, setActiveVariantIndex] = useState<number>(() => {
104
106
  const fragment = fragments.find(s => s.path === activeFragmentPath);
@@ -115,7 +117,11 @@ export function App({ fragments }: AppProps) {
115
117
  () => fragments.find((s) => s.path === activeFragmentPath),
116
118
  [fragments, activeFragmentPath]
117
119
  );
118
- const activeVariant = activeFragment?.fragment.variants?.[activeVariantIndex];
120
+ const variants = activeFragment?.fragment.variants ?? [];
121
+ const variantCount = variants.length;
122
+ const safeVariantIndex = variantCount > 0 ? Math.min(activeVariantIndex, variantCount - 1) : 0;
123
+ const activeVariant = variants[safeVariantIndex];
124
+ const isAllVariantsMode = !urlState.variant;
119
125
  const figmaUrl = activeVariant?.figma || activeFragment?.fragment.meta.figma;
120
126
 
121
127
  // Figma integration
@@ -146,17 +152,34 @@ export function App({ fragments }: AppProps) {
146
152
  }
147
153
  }, [uiState.showComparison, activeVariant, figmaIntegration.extractRenderedStyles, uiState.previewKey]);
148
154
 
155
+ // Keep focused variant index in range when variant lists change.
156
+ useEffect(() => {
157
+ if (variantCount === 0) {
158
+ setActiveVariantIndex(0);
159
+ return;
160
+ }
161
+ if (activeVariantIndex >= variantCount) {
162
+ setActiveVariantIndex(variantCount - 1);
163
+ }
164
+ }, [activeVariantIndex, variantCount]);
165
+
149
166
  // Sync URL state on browser navigation
150
167
  useEffect(() => {
151
168
  if (urlState.component) {
152
169
  const found = findFragmentByName(fragments, urlState.component);
153
- if (found && found.path !== activeFragmentPath) {
154
- setActiveFragmentPath(found.path);
170
+ if (!found) return;
171
+
172
+ const pathChanged = found.path !== activeFragmentPathRef.current;
173
+ setActiveFragmentPath(found.path);
174
+ uiActions.setHealthDashboard(false);
175
+
176
+ // Keep focused variant when entering "All" on the same component.
177
+ if (urlState.variant || pathChanged) {
155
178
  const variantIndex = findVariantIndex(found.fragment.variants, urlState.variant);
156
179
  setActiveVariantIndex(variantIndex);
157
180
  }
158
181
  }
159
- }, [urlState.component, urlState.variant, fragments, activeFragmentPath]);
182
+ }, [urlState.component, urlState.variant, fragments, uiActions]);
160
183
 
161
184
  // HMR toast notifications
162
185
  useEffect(() => {
@@ -178,19 +201,64 @@ export function App({ fragments }: AppProps) {
178
201
  const handleSelectFragment = useCallback((path: string) => {
179
202
  const fragment = fragments.find((s) => s.path === path);
180
203
  const componentName = fragment?.fragment.meta.name || path;
181
- const firstVariant = fragment?.fragment.variants?.[0]?.name;
182
204
 
183
205
  setActiveFragmentPath(path);
184
206
  setActiveVariantIndex(0);
185
207
  uiActions.setHealthDashboard(false);
186
- setUrlComponent(componentName, firstVariant);
208
+ setUrlComponent(componentName, null);
187
209
  }, [fragments, setUrlComponent, uiActions]);
188
210
 
211
+ const scrollToVariantSection = useCallback(
212
+ (index: number, behavior: ScrollBehavior = "smooth") => {
213
+ if (!activeFragment || variantCount === 0) return;
214
+ const normalizedIndex = ((index % variantCount) + variantCount) % variantCount;
215
+ const targetVariant = variants[normalizedIndex];
216
+ if (!targetVariant) return;
217
+
218
+ const sectionId = getVariantSectionId(activeFragment.fragment.meta.name, targetVariant.name);
219
+ document.getElementById(sectionId)?.scrollIntoView({ behavior, block: "start" });
220
+ },
221
+ [activeFragment, variantCount, variants]
222
+ );
223
+
224
+ const focusVariantInAllMode = useCallback(
225
+ (index: number, shouldScroll = false) => {
226
+ if (variantCount === 0) return;
227
+ const normalizedIndex = ((index % variantCount) + variantCount) % variantCount;
228
+ setActiveVariantIndex(normalizedIndex);
229
+ if (shouldScroll) {
230
+ scrollToVariantSection(normalizedIndex);
231
+ }
232
+ },
233
+ [variantCount, scrollToVariantSection]
234
+ );
235
+
189
236
  const handleSelectVariant = useCallback((index: number) => {
190
- const variantName = activeFragment?.fragment.variants?.[index]?.name;
191
- setActiveVariantIndex(index);
237
+ if (variantCount === 0) return;
238
+ const normalizedIndex = ((index % variantCount) + variantCount) % variantCount;
239
+ const variantName = variants[normalizedIndex]?.name;
240
+ setActiveVariantIndex(normalizedIndex);
192
241
  setUrlVariant(variantName || null);
193
- }, [activeFragment, setUrlVariant]);
242
+ }, [variantCount, variants, setUrlVariant]);
243
+
244
+ const handleSelectAllVariants = useCallback(() => {
245
+ setUrlVariant(null);
246
+ requestAnimationFrame(() => {
247
+ const previewCanvas = document.getElementById("preview-canvas");
248
+ if (previewCanvas instanceof HTMLElement) {
249
+ previewCanvas.scrollTo({ top: 0, behavior: "smooth" });
250
+ }
251
+ });
252
+ }, [setUrlVariant]);
253
+
254
+ const handleSelectVariantLink = useCallback((index: number) => {
255
+ if (isAllVariantsMode) {
256
+ // In All mode, selecting a variant link exits All and opens that single variant.
257
+ handleSelectVariant(index);
258
+ return;
259
+ }
260
+ handleSelectVariant(index);
261
+ }, [handleSelectVariant, isAllVariantsMode]);
194
262
 
195
263
  // Copy link handler
196
264
  const handleCopyLink = useCallback(async () => {
@@ -202,11 +270,6 @@ export function App({ fragments }: AppProps) {
202
270
  }
203
271
  }, [copyUrl, success, uiActions]);
204
272
 
205
- const focusSearchInput = useCallback(() => {
206
- searchInputRef.current?.focus();
207
- searchInputRef.current?.select();
208
- }, []);
209
-
210
273
  // Sorted fragment paths for keyboard navigation
211
274
  const sortedFragmentPaths = useMemo(() => {
212
275
  return [...fragments]
@@ -216,7 +279,6 @@ export function App({ fragments }: AppProps) {
216
279
  }, [fragments]);
217
280
 
218
281
  const currentFragmentIndex = sortedFragmentPaths.indexOf(activeFragmentPath || '');
219
- const variantCount = activeFragment?.fragment.variants?.length || 0;
220
282
 
221
283
  // Keyboard shortcuts
222
284
  useKeyboardShortcuts(
@@ -229,16 +291,39 @@ export function App({ fragments }: AppProps) {
229
291
  const prevIndex = currentFragmentIndex > 0 ? currentFragmentIndex - 1 : sortedFragmentPaths.length - 1;
230
292
  handleSelectFragment(sortedFragmentPaths[prevIndex]);
231
293
  },
232
- nextVariant: () => handleSelectVariant(activeVariantIndex < variantCount - 1 ? activeVariantIndex + 1 : 0),
233
- prevVariant: () => handleSelectVariant(activeVariantIndex > 0 ? activeVariantIndex - 1 : variantCount - 1),
234
- goToVariant: (index) => index < variantCount && handleSelectVariant(index),
294
+ nextVariant: () => {
295
+ if (variantCount === 0) return;
296
+ const nextIndex = activeVariantIndex < variantCount - 1 ? activeVariantIndex + 1 : 0;
297
+ if (isAllVariantsMode) {
298
+ focusVariantInAllMode(nextIndex, true);
299
+ return;
300
+ }
301
+ handleSelectVariant(nextIndex);
302
+ },
303
+ prevVariant: () => {
304
+ if (variantCount === 0) return;
305
+ const prevIndex = activeVariantIndex > 0 ? activeVariantIndex - 1 : variantCount - 1;
306
+ if (isAllVariantsMode) {
307
+ focusVariantInAllMode(prevIndex, true);
308
+ return;
309
+ }
310
+ handleSelectVariant(prevIndex);
311
+ },
312
+ goToVariant: (index) => {
313
+ if (index >= variantCount) return;
314
+ if (isAllVariantsMode) {
315
+ focusVariantInAllMode(index, true);
316
+ return;
317
+ }
318
+ handleSelectVariant(index);
319
+ },
235
320
  toggleTheme: viewSettings.toggleTheme,
236
321
  togglePanel: uiActions.togglePanel,
237
322
  toggleMatrix: () => uiActions.setMatrixView(!uiState.showMatrixView),
238
323
  toggleResponsive: () => uiActions.setMultiViewport(!uiState.showMultiViewport),
239
324
  copyLink: handleCopyLink,
240
325
  showHelp: uiActions.toggleShortcutsHelp,
241
- openSearch: focusSearchInput,
326
+ openSearch: () => uiActions.setCommandPalette(true),
242
327
  escape: () => {
243
328
  if (document.activeElement === searchInputRef.current) {
244
329
  if (searchQuery) {
@@ -255,22 +340,22 @@ export function App({ fragments }: AppProps) {
255
340
  );
256
341
 
257
342
  // Render variant with action logging via DOM event capture
258
- const renderVariantWithProps = useCallback(() => {
259
- if (!activeVariant) return null;
343
+ const renderVariantWithProps = useCallback((variant: FragmentVariant | undefined) => {
344
+ if (!variant) return null;
260
345
 
261
346
  return (
262
347
  <ActionCapture onAction={useActionsRef.current.logAction}>
263
- <StoryRenderer variant={activeVariant}>
348
+ <StoryRenderer variant={variant}>
264
349
  {(content, isLoading, error) => {
265
350
  if (isLoading) return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px' }}><LoaderIndicator /></div>;
266
- if (error) return <EmptyVariantMessage reason={`Error: ${error.message}`} variantName={activeVariant.name} hint="Check the console for the full error stack trace." />;
267
- if (content === null || content === undefined) return <EmptyVariantMessage reason="render() returned null or undefined" variantName={activeVariant.name} hint="The variant's render function didn't return any JSX." />;
351
+ if (error) return <EmptyVariantMessage reason={`Error: ${error.message}`} variantName={variant.name} hint="Check the console for the full error stack trace." />;
352
+ if (content === null || content === undefined) return <EmptyVariantMessage reason="render() returned null or undefined" variantName={variant.name} hint="The variant's render function didn't return any JSX." />;
268
353
  return content;
269
354
  }}
270
355
  </StoryRenderer>
271
356
  </ActionCapture>
272
357
  );
273
- }, [activeVariant]);
358
+ }, []);
274
359
 
275
360
  // Check if isolated mode
276
361
  const isIsolated = useMemo(() => {
@@ -301,24 +386,12 @@ export function App({ fragments }: AppProps) {
301
386
  activeFragment && !uiState.showHealthDashboard ? (
302
387
  <TopToolbar
303
388
  fragment={activeFragment}
304
- variant={activeVariant}
305
- viewSettings={viewSettings}
306
389
  uiState={uiState}
307
390
  uiActions={uiActions}
308
391
  figmaUrl={figmaUrl}
309
392
  searchQuery={searchQuery}
310
393
  onSearchChange={setSearchQuery}
311
394
  searchInputRef={searchInputRef}
312
- onPrevComponent={() => {
313
- const prevIndex = currentFragmentIndex > 0 ? currentFragmentIndex - 1 : sortedFragmentPaths.length - 1;
314
- handleSelectFragment(sortedFragmentPaths[prevIndex]);
315
- }}
316
- onNextComponent={() => {
317
- const nextIndex = currentFragmentIndex < sortedFragmentPaths.length - 1 ? currentFragmentIndex + 1 : 0;
318
- handleSelectFragment(sortedFragmentPaths[nextIndex]);
319
- }}
320
- onPrevVariant={() => handleSelectVariant(activeVariantIndex > 0 ? activeVariantIndex - 1 : variantCount - 1)}
321
- onNextVariant={() => handleSelectVariant(activeVariantIndex < variantCount - 1 ? activeVariantIndex + 1 : 0)}
322
395
  />
323
396
  ) : (
324
397
  <ViewerHeader
@@ -342,6 +415,21 @@ export function App({ fragments }: AppProps) {
342
415
  }}
343
416
  />
344
417
  }
418
+ aside={
419
+ activeFragment && !uiState.showHealthDashboard ? (
420
+ <PreviewAside
421
+ fragment={activeFragment.fragment}
422
+ variants={variants}
423
+ focusedVariantIndex={safeVariantIndex}
424
+ isAllVariantsMode={isAllVariantsMode}
425
+ activePanel={uiState.activePanel}
426
+ onSelectAllVariants={handleSelectAllVariants}
427
+ onSelectVariant={handleSelectVariantLink}
428
+ onCopyLink={handleCopyLink}
429
+ onShowShortcuts={uiActions.toggleShortcutsHelp}
430
+ />
431
+ ) : null
432
+ }
345
433
  >
346
434
  {uiState.showHealthDashboard ? (
347
435
  <div style={{ height: '100%', overflow: 'auto', backgroundColor: 'var(--bg-primary)' }}>
@@ -359,21 +447,25 @@ export function App({ fragments }: AppProps) {
359
447
  </Box>
360
448
  </div>
361
449
  ) : activeFragment ? (
362
- <div style={{ display: 'flex', height: '100%', flexDirection: panelDock === "bottom" ? 'column' : 'row' }}>
450
+ <div id="preview-layout" style={{ display: 'flex', height: '100%', flexDirection: panelDock === "bottom" ? 'column' : 'row' }}>
363
451
  {/* Main Content Area */}
364
452
  <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
365
- {/* Variant Tabs */}
366
- {activeFragment.fragment.variants && activeFragment.fragment.variants.length > 0 && (
367
- <VariantTabsBar
368
- variants={activeFragment.fragment.variants}
369
- activeIndex={activeVariantIndex}
370
- onSelect={handleSelectVariant}
371
- showMatrixView={uiState.showMatrixView}
372
- />
373
- )}
453
+ <PreviewControlsBar
454
+ zoom={viewSettings.zoom}
455
+ background={viewSettings.background}
456
+ onZoomChange={viewSettings.setZoom}
457
+ onBackgroundChange={viewSettings.setBackground}
458
+ showMatrixView={uiState.showMatrixView}
459
+ showMultiViewport={uiState.showMultiViewport}
460
+ panelOpen={uiState.panelOpen}
461
+ onToggleMatrix={() => uiActions.setMatrixView(!uiState.showMatrixView)}
462
+ onToggleMultiViewport={() => uiActions.setMultiViewport(!uiState.showMultiViewport)}
463
+ onTogglePanel={uiActions.togglePanel}
464
+ />
374
465
 
375
466
  {/* Preview Area */}
376
467
  <div
468
+ id="preview-canvas"
377
469
  style={{
378
470
  flex: 1,
379
471
  overflow: 'auto',
@@ -381,12 +473,32 @@ export function App({ fragments }: AppProps) {
381
473
  ...(uiState.showMatrixView ? {} : getBackgroundStyle(viewSettings.background)),
382
474
  }}
383
475
  >
384
- {activeVariant ? (
476
+ {variantCount === 0 ? (
477
+ <NoVariantsMessage fragment={activeFragment?.fragment} />
478
+ ) : isAllVariantsMode && !uiState.showMatrixView && !uiState.showMultiViewport ? (
479
+ <AllVariantsPreview
480
+ componentName={activeFragment.fragment.meta.name}
481
+ fragmentPath={activeFragment.path}
482
+ variants={variants}
483
+ focusedVariantIndex={safeVariantIndex}
484
+ zoom={viewSettings.zoom}
485
+ background={viewSettings.background}
486
+ viewport={viewSettings.viewport}
487
+ customSize={viewSettings.customSize}
488
+ previewTheme={resolvedTheme}
489
+ showComparison={uiState.showComparison}
490
+ allFigmaUrls={allFigmaUrls}
491
+ fallbackFigmaUrl={activeFragment.fragment.meta.figma}
492
+ onRetry={uiActions.incrementPreviewKey}
493
+ renderVariantContent={renderVariantWithProps}
494
+ previewKeyBase={`${activeFragmentPath}-${uiState.previewKey}`}
495
+ />
496
+ ) : (
385
497
  <PreviewArea
386
498
  componentName={activeFragment.fragment.meta.name}
387
499
  fragmentPath={activeFragment.path}
388
500
  variant={activeVariant}
389
- variants={activeFragment.fragment.variants}
501
+ variants={variants}
390
502
  zoom={viewSettings.zoom}
391
503
  background={viewSettings.background}
392
504
  viewport={viewSettings.viewport}
@@ -402,40 +514,40 @@ export function App({ fragments }: AppProps) {
402
514
  handleSelectVariant(index);
403
515
  }}
404
516
  onRetry={uiActions.incrementPreviewKey}
405
- renderContent={renderVariantWithProps}
406
- previewKey={`${activeFragmentPath}-${activeVariantIndex}-${uiState.previewKey}`}
517
+ renderContent={() => renderVariantWithProps(activeVariant)}
518
+ previewKey={`${activeFragmentPath}-${safeVariantIndex}-${uiState.previewKey}`}
407
519
  />
408
- ) : (
409
- <NoVariantsMessage fragment={activeFragment?.fragment} />
410
520
  )}
411
521
  </div>
412
522
  </div>
413
523
 
414
524
  {/* Bottom Panel */}
415
- {activeVariant && (
416
- <BottomPanel
417
- fragment={activeFragment.fragment}
418
- variant={activeVariant}
419
- fragments={fragments}
420
- activePanel={uiState.activePanel}
421
- onPanelChange={uiActions.setActivePanel}
422
- figmaUrl={figmaUrl}
423
- figmaStyles={figmaIntegration.figmaStyles.status === 'success' ? figmaIntegration.figmaStyles.styles || null : null}
424
- renderedStyles={figmaIntegration.renderedStyles}
425
- figmaLoading={figmaIntegration.isLoading}
426
- figmaError={figmaIntegration.errorMessage}
427
- onFetchFigma={figmaIntegration.fetchFigmaStyles}
428
- onRefreshRendered={figmaIntegration.extractRenderedStyles}
429
- actionLogs={actionLogs}
430
- onClearActionLogs={clearActionLogs}
431
- onNavigateToComponent={(name) => {
432
- const target = fragments.find(s => s.fragment.meta.name === name);
433
- if (target) handleSelectFragment(target.path);
434
- }}
435
- previewKey={uiState.previewKey}
436
- fragmentKey={`${activeFragmentPath}-${activeVariantIndex}`}
437
- />
438
- )}
525
+ <div id="preview-tools">
526
+ {uiState.panelOpen && activeVariant && (
527
+ <BottomPanel
528
+ fragment={activeFragment.fragment}
529
+ variant={activeVariant}
530
+ fragments={fragments}
531
+ activePanel={uiState.activePanel}
532
+ onPanelChange={uiActions.setActivePanel}
533
+ figmaUrl={figmaUrl}
534
+ figmaStyles={figmaIntegration.figmaStyles.status === 'success' ? figmaIntegration.figmaStyles.styles || null : null}
535
+ renderedStyles={figmaIntegration.renderedStyles}
536
+ figmaLoading={figmaIntegration.isLoading}
537
+ figmaError={figmaIntegration.errorMessage}
538
+ onFetchFigma={figmaIntegration.fetchFigmaStyles}
539
+ onRefreshRendered={figmaIntegration.extractRenderedStyles}
540
+ actionLogs={actionLogs}
541
+ onClearActionLogs={clearActionLogs}
542
+ onNavigateToComponent={(name) => {
543
+ const target = fragments.find(s => s.fragment.meta.name === name);
544
+ if (target) handleSelectFragment(target.path);
545
+ }}
546
+ previewKey={uiState.previewKey}
547
+ fragmentKey={`${activeFragmentPath}-${safeVariantIndex}`}
548
+ />
549
+ )}
550
+ </div>
439
551
  </div>
440
552
  ) : (
441
553
  <EmptyState style={{ height: '100%' }}>
@@ -454,18 +566,12 @@ export function App({ fragments }: AppProps) {
454
566
  // Top Toolbar Component
455
567
  interface TopToolbarProps {
456
568
  fragment: { path: string; fragment: FragmentDefinition };
457
- variant: any;
458
- viewSettings: ReturnType<typeof useViewSettings>;
459
569
  uiState: ReturnType<typeof useAppState>['state'];
460
570
  uiActions: ReturnType<typeof useAppState>['actions'];
461
571
  figmaUrl?: string;
462
572
  searchQuery: string;
463
573
  onSearchChange: (value: string) => void;
464
574
  searchInputRef: RefObject<HTMLInputElement>;
465
- onPrevComponent: () => void;
466
- onNextComponent: () => void;
467
- onPrevVariant: () => void;
468
- onNextVariant: () => void;
469
575
  }
470
576
 
471
577
  interface ViewerHeaderProps {
@@ -481,6 +587,18 @@ interface HeaderSearchProps {
481
587
  inputRef: RefObject<HTMLInputElement>;
482
588
  }
483
589
 
590
+ interface PreviewAsideProps {
591
+ fragment: FragmentDefinition;
592
+ variants: FragmentVariant[];
593
+ focusedVariantIndex: number;
594
+ isAllVariantsMode: boolean;
595
+ activePanel: string;
596
+ onSelectAllVariants: () => void;
597
+ onSelectVariant: (index: number) => void;
598
+ onCopyLink: () => void;
599
+ onShowShortcuts: () => void;
600
+ }
601
+
484
602
  function HeaderSearch({ value, onChange, inputRef }: HeaderSearchProps) {
485
603
  return (
486
604
  <Header.Search expandable>
@@ -491,7 +609,6 @@ function HeaderSearch({ value, onChange, inputRef }: HeaderSearchProps) {
491
609
  placeholder="Search components"
492
610
  aria-label="Search components"
493
611
  size="sm"
494
- shortcut="⌘K"
495
612
  style={{ width: '240px' }}
496
613
  />
497
614
  </Header.Search>
@@ -542,43 +659,122 @@ function ViewerHeader({ showHealth, searchQuery, onSearchChange, searchInputRef
542
659
  );
543
660
  }
544
661
 
662
+ function PreviewAside({
663
+ fragment,
664
+ variants,
665
+ focusedVariantIndex,
666
+ isAllVariantsMode,
667
+ activePanel,
668
+ onSelectAllVariants,
669
+ onSelectVariant,
670
+ onCopyLink,
671
+ onShowShortcuts,
672
+ }: PreviewAsideProps) {
673
+ const focusedVariant = variants[focusedVariantIndex] || null;
674
+
675
+ const baseLinkStyle: CSSProperties = {
676
+ color: 'var(--text-secondary)',
677
+ textDecoration: 'none',
678
+ fontSize: '13px',
679
+ borderRadius: '6px',
680
+ padding: '4px 8px',
681
+ display: 'block',
682
+ };
683
+
684
+ const getLinkStyle = (isActive = false): CSSProperties => (
685
+ isActive
686
+ ? { ...baseLinkStyle, color: 'var(--text-primary)', backgroundColor: 'var(--bg-hover)' }
687
+ : baseLinkStyle
688
+ );
689
+
690
+ return (
691
+ <Box padding="md" style={{ position: 'sticky', top: '80px' }}>
692
+ <Stack gap="md">
693
+ <Stack gap="xs">
694
+ <Text size="xs" color="tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.08em' }}>
695
+ On this page
696
+ </Text>
697
+ <a href="#preview-canvas" style={baseLinkStyle}>
698
+ Preview
699
+ </a>
700
+ <a href="#preview-tools" style={baseLinkStyle}>
701
+ Panels
702
+ </a>
703
+ {variants.length > 0 && (
704
+ <>
705
+ <Text size="xs" color="tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: '8px' }}>
706
+ Variants
707
+ </Text>
708
+ <a
709
+ href="#preview-canvas"
710
+ style={getLinkStyle(isAllVariantsMode)}
711
+ onClick={(event) => {
712
+ event.preventDefault();
713
+ onSelectAllVariants();
714
+ }}
715
+ >
716
+ All
717
+ </a>
718
+ {variants.map((variant, index) => {
719
+ const active = index === focusedVariantIndex;
720
+ const anchorId = getVariantSectionId(fragment.meta.name, variant.name);
721
+
722
+ return (
723
+ <a
724
+ key={variant.name}
725
+ href={`#${anchorId}`}
726
+ style={getLinkStyle(active)}
727
+ onClick={(event) => {
728
+ event.preventDefault();
729
+ onSelectVariant(index);
730
+ }}
731
+ >
732
+ {variant.name}
733
+ </a>
734
+ );
735
+ })}
736
+ </>
737
+ )}
738
+ </Stack>
739
+ <Separator />
740
+ <Stack gap="xs">
741
+ <Text size="sm" weight="medium">{fragment.meta.name}</Text>
742
+ <Text size="xs" color="secondary">
743
+ {isAllVariantsMode
744
+ ? `Variant: All${focusedVariant ? ` (focused: ${focusedVariant.name})` : ''}`
745
+ : focusedVariant ? `Variant: ${focusedVariant.name}` : 'Select a variant'}
746
+ </Text>
747
+ <Text size="xs" color="tertiary">
748
+ Active panel: {activePanel}
749
+ </Text>
750
+ </Stack>
751
+ <Separator />
752
+ <Stack gap="xs">
753
+ <Button variant="ghost" size="sm" onClick={onCopyLink}>
754
+ Copy Link
755
+ </Button>
756
+ <Button variant="ghost" size="sm" onClick={onShowShortcuts}>
757
+ Keyboard Shortcuts
758
+ </Button>
759
+ </Stack>
760
+ </Stack>
761
+ </Box>
762
+ );
763
+ }
764
+
545
765
  function TopToolbar({
546
766
  fragment,
547
- variant,
548
- viewSettings,
549
767
  uiState,
550
768
  uiActions,
551
769
  figmaUrl,
552
770
  searchQuery,
553
771
  onSearchChange,
554
772
  searchInputRef,
555
- onPrevComponent,
556
- onNextComponent,
557
- onPrevVariant,
558
- onNextVariant,
559
773
  }: TopToolbarProps) {
560
774
  const { setTheme, resolvedTheme } = useTheme();
561
775
  return (
562
776
  <Header aria-label="Component preview toolbar">
563
777
  <Header.Trigger />
564
- <PreviewMenu
565
- zoom={viewSettings.zoom}
566
- background={viewSettings.background}
567
- viewport={viewSettings.viewport}
568
- showMatrixView={uiState.showMatrixView}
569
- showMultiViewport={uiState.showMultiViewport}
570
- panelOpen={uiState.panelOpen}
571
- onZoomChange={viewSettings.setZoom}
572
- onBackgroundChange={viewSettings.setBackground}
573
- onViewportChange={viewSettings.setViewport}
574
- onToggleMatrix={() => uiActions.setMatrixView(!uiState.showMatrixView)}
575
- onToggleMultiViewport={() => uiActions.setMultiViewport(!uiState.showMultiViewport)}
576
- onTogglePanel={uiActions.togglePanel}
577
- onPrevComponent={onPrevComponent}
578
- onNextComponent={onNextComponent}
579
- onPrevVariant={onPrevVariant}
580
- onNextVariant={onNextVariant}
581
- />
582
778
  <Header.Brand>
583
779
  <Stack direction="row" align="center" gap="sm">
584
780
  <img src={fragmentsLogo} alt="" width={20} height={20} style={{ display: 'block' }} />
@@ -642,24 +838,179 @@ function TopToolbar({
642
838
  );
643
839
  }
644
840
 
645
- // Variant Tabs Bar Component
646
- interface VariantTabsBarProps {
647
- variants: any[];
648
- activeIndex: number;
649
- onSelect: (index: number) => void;
841
+ function normalizeAnchorSegment(value: string): string {
842
+ const normalized = value
843
+ .toLowerCase()
844
+ .trim()
845
+ .replace(/[^a-z0-9]+/g, "-")
846
+ .replace(/^-+|-+$/g, "");
847
+ return normalized || "variant";
848
+ }
849
+
850
+ function getVariantSectionId(componentName: string, variantName: string): string {
851
+ return `preview-${normalizeAnchorSegment(componentName)}-${normalizeAnchorSegment(variantName)}`;
852
+ }
853
+
854
+ interface PreviewControlsBarProps {
855
+ zoom: ReturnType<typeof useViewSettings>["zoom"];
856
+ background: ReturnType<typeof useViewSettings>["background"];
857
+ onZoomChange: ReturnType<typeof useViewSettings>["setZoom"];
858
+ onBackgroundChange: ReturnType<typeof useViewSettings>["setBackground"];
650
859
  showMatrixView: boolean;
860
+ showMultiViewport: boolean;
861
+ panelOpen: boolean;
862
+ onToggleMatrix: () => void;
863
+ onToggleMultiViewport: () => void;
864
+ onTogglePanel: () => void;
651
865
  }
652
866
 
653
- function VariantTabsBar({ variants, activeIndex, onSelect, showMatrixView }: VariantTabsBarProps) {
867
+ function PreviewControlsBar({
868
+ zoom,
869
+ background,
870
+ onZoomChange,
871
+ onBackgroundChange,
872
+ showMatrixView,
873
+ showMultiViewport,
874
+ panelOpen,
875
+ onToggleMatrix,
876
+ onToggleMultiViewport,
877
+ onTogglePanel,
878
+ }: PreviewControlsBarProps) {
879
+ const toggleButtonStyle = (active: boolean): CSSProperties => (
880
+ active ? { color: 'var(--color-accent)', backgroundColor: 'var(--bg-hover)' } : {}
881
+ );
882
+
654
883
  return (
655
884
  <div style={{ padding: '8px 16px', borderBottom: '1px solid var(--border)', backgroundColor: 'var(--bg-primary)', flexShrink: 0 }}>
656
- {!showMatrixView ? (
657
- <ScrollArea orientation="horizontal" showFades style={{ flex: 1, minWidth: 0 }}>
658
- <VariantTabs variants={variants} activeIndex={activeIndex} onSelect={onSelect} />
659
- </ScrollArea>
660
- ) : (
661
- <Text size="sm" color="secondary">Showing all {variants.length} variants</Text>
662
- )}
885
+ <Stack direction="row" gap="sm" align="center" justify="end">
886
+ <PreviewToolbar
887
+ zoom={zoom}
888
+ background={background}
889
+ onZoomChange={onZoomChange}
890
+ onBackgroundChange={onBackgroundChange}
891
+ />
892
+ <Separator orientation="vertical" style={{ height: '16px' }} />
893
+ <Tooltip content={showMatrixView ? "Disable matrix view" : "Enable matrix view"}>
894
+ <Button
895
+ variant="ghost"
896
+ size="sm"
897
+ aria-pressed={showMatrixView}
898
+ aria-label="Toggle matrix view"
899
+ onClick={onToggleMatrix}
900
+ style={toggleButtonStyle(showMatrixView)}
901
+ >
902
+ <GridFour size={16} />
903
+ </Button>
904
+ </Tooltip>
905
+ <Tooltip content={showMultiViewport ? "Disable responsive view" : "Enable responsive view"}>
906
+ <Button
907
+ variant="ghost"
908
+ size="sm"
909
+ aria-pressed={showMultiViewport}
910
+ aria-label="Toggle responsive view"
911
+ onClick={onToggleMultiViewport}
912
+ style={toggleButtonStyle(showMultiViewport)}
913
+ >
914
+ <DeviceMobile size={16} />
915
+ </Button>
916
+ </Tooltip>
917
+ <Tooltip content={panelOpen ? "Hide addons panel" : "Show addons panel"}>
918
+ <Button
919
+ variant="ghost"
920
+ size="sm"
921
+ aria-pressed={panelOpen}
922
+ aria-label="Toggle addons panel"
923
+ onClick={onTogglePanel}
924
+ style={toggleButtonStyle(panelOpen)}
925
+ >
926
+ <Rows size={16} />
927
+ </Button>
928
+ </Tooltip>
929
+ </Stack>
930
+ </div>
931
+ );
932
+ }
933
+
934
+ interface AllVariantsPreviewProps {
935
+ componentName: string;
936
+ fragmentPath: string;
937
+ variants: FragmentVariant[];
938
+ focusedVariantIndex: number;
939
+ zoom: ReturnType<typeof useViewSettings>["zoom"];
940
+ background: ReturnType<typeof useViewSettings>["background"];
941
+ viewport: ReturnType<typeof useViewSettings>["viewport"];
942
+ customSize: ReturnType<typeof useViewSettings>["customSize"];
943
+ previewTheme: ReturnType<typeof useTheme>["resolvedTheme"];
944
+ showComparison: boolean;
945
+ allFigmaUrls: string[];
946
+ fallbackFigmaUrl?: string;
947
+ onRetry: () => void;
948
+ renderVariantContent: (variant: FragmentVariant) => ReactNode;
949
+ previewKeyBase: string;
950
+ }
951
+
952
+ function AllVariantsPreview({
953
+ componentName,
954
+ fragmentPath,
955
+ variants,
956
+ focusedVariantIndex,
957
+ zoom,
958
+ background,
959
+ viewport,
960
+ customSize,
961
+ previewTheme,
962
+ showComparison,
963
+ allFigmaUrls,
964
+ fallbackFigmaUrl,
965
+ onRetry,
966
+ renderVariantContent,
967
+ previewKeyBase,
968
+ }: AllVariantsPreviewProps) {
969
+ return (
970
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '20px' }}>
971
+ {variants.map((variant, index) => {
972
+ const isFocused = index === focusedVariantIndex;
973
+
974
+ return (
975
+ <section
976
+ id={getVariantSectionId(componentName, variant.name)}
977
+ key={variant.name}
978
+ style={{
979
+ border: '1px solid var(--border)',
980
+ borderColor: isFocused ? 'var(--color-accent)' : 'var(--border)',
981
+ borderRadius: '10px',
982
+ overflow: 'hidden',
983
+ backgroundColor: 'var(--bg-primary)',
984
+ boxShadow: isFocused ? '0 0 0 1px color-mix(in srgb, var(--color-accent) 40%, transparent)' : undefined,
985
+ }}
986
+ >
987
+ <Stack direction="row" align="baseline" gap="sm" style={{ padding: '12px 14px', borderBottom: '1px solid var(--border-subtle)', backgroundColor: 'var(--bg-secondary)' }}>
988
+ <Text size="sm" weight="medium">{variant.name}</Text>
989
+ <Text size="xs" color="secondary">{variant.description}</Text>
990
+ </Stack>
991
+ <PreviewArea
992
+ componentName={componentName}
993
+ fragmentPath={fragmentPath}
994
+ variant={variant}
995
+ variants={variants}
996
+ zoom={zoom}
997
+ background={background}
998
+ viewport={viewport}
999
+ customSize={customSize}
1000
+ previewTheme={previewTheme}
1001
+ showMatrixView={false}
1002
+ showMultiViewport={false}
1003
+ showComparison={showComparison}
1004
+ figmaUrl={variant.figma || fallbackFigmaUrl}
1005
+ allFigmaUrls={allFigmaUrls}
1006
+ onSelectVariant={() => {}}
1007
+ onRetry={onRetry}
1008
+ renderContent={() => renderVariantContent(variant)}
1009
+ previewKey={`${previewKeyBase}-${index}`}
1010
+ />
1011
+ </section>
1012
+ );
1013
+ })}
663
1014
  </div>
664
1015
  );
665
1016
  }