@fragments-sdk/cli 0.7.10 → 0.7.11

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 () => {
@@ -216,7 +284,6 @@ export function App({ fragments }: AppProps) {
216
284
  }, [fragments]);
217
285
 
218
286
  const currentFragmentIndex = sortedFragmentPaths.indexOf(activeFragmentPath || '');
219
- const variantCount = activeFragment?.fragment.variants?.length || 0;
220
287
 
221
288
  // Keyboard shortcuts
222
289
  useKeyboardShortcuts(
@@ -229,9 +296,32 @@ export function App({ fragments }: AppProps) {
229
296
  const prevIndex = currentFragmentIndex > 0 ? currentFragmentIndex - 1 : sortedFragmentPaths.length - 1;
230
297
  handleSelectFragment(sortedFragmentPaths[prevIndex]);
231
298
  },
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),
299
+ nextVariant: () => {
300
+ if (variantCount === 0) return;
301
+ const nextIndex = activeVariantIndex < variantCount - 1 ? activeVariantIndex + 1 : 0;
302
+ if (isAllVariantsMode) {
303
+ focusVariantInAllMode(nextIndex, true);
304
+ return;
305
+ }
306
+ handleSelectVariant(nextIndex);
307
+ },
308
+ prevVariant: () => {
309
+ if (variantCount === 0) return;
310
+ const prevIndex = activeVariantIndex > 0 ? activeVariantIndex - 1 : variantCount - 1;
311
+ if (isAllVariantsMode) {
312
+ focusVariantInAllMode(prevIndex, true);
313
+ return;
314
+ }
315
+ handleSelectVariant(prevIndex);
316
+ },
317
+ goToVariant: (index) => {
318
+ if (index >= variantCount) return;
319
+ if (isAllVariantsMode) {
320
+ focusVariantInAllMode(index, true);
321
+ return;
322
+ }
323
+ handleSelectVariant(index);
324
+ },
235
325
  toggleTheme: viewSettings.toggleTheme,
236
326
  togglePanel: uiActions.togglePanel,
237
327
  toggleMatrix: () => uiActions.setMatrixView(!uiState.showMatrixView),
@@ -255,22 +345,22 @@ export function App({ fragments }: AppProps) {
255
345
  );
256
346
 
257
347
  // Render variant with action logging via DOM event capture
258
- const renderVariantWithProps = useCallback(() => {
259
- if (!activeVariant) return null;
348
+ const renderVariantWithProps = useCallback((variant: FragmentVariant | undefined) => {
349
+ if (!variant) return null;
260
350
 
261
351
  return (
262
352
  <ActionCapture onAction={useActionsRef.current.logAction}>
263
- <StoryRenderer variant={activeVariant}>
353
+ <StoryRenderer variant={variant}>
264
354
  {(content, isLoading, error) => {
265
355
  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." />;
356
+ if (error) return <EmptyVariantMessage reason={`Error: ${error.message}`} variantName={variant.name} hint="Check the console for the full error stack trace." />;
357
+ 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
358
  return content;
269
359
  }}
270
360
  </StoryRenderer>
271
361
  </ActionCapture>
272
362
  );
273
- }, [activeVariant]);
363
+ }, []);
274
364
 
275
365
  // Check if isolated mode
276
366
  const isIsolated = useMemo(() => {
@@ -301,24 +391,12 @@ export function App({ fragments }: AppProps) {
301
391
  activeFragment && !uiState.showHealthDashboard ? (
302
392
  <TopToolbar
303
393
  fragment={activeFragment}
304
- variant={activeVariant}
305
- viewSettings={viewSettings}
306
394
  uiState={uiState}
307
395
  uiActions={uiActions}
308
396
  figmaUrl={figmaUrl}
309
397
  searchQuery={searchQuery}
310
398
  onSearchChange={setSearchQuery}
311
399
  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
400
  />
323
401
  ) : (
324
402
  <ViewerHeader
@@ -342,6 +420,21 @@ export function App({ fragments }: AppProps) {
342
420
  }}
343
421
  />
344
422
  }
423
+ aside={
424
+ activeFragment && !uiState.showHealthDashboard ? (
425
+ <PreviewAside
426
+ fragment={activeFragment.fragment}
427
+ variants={variants}
428
+ focusedVariantIndex={safeVariantIndex}
429
+ isAllVariantsMode={isAllVariantsMode}
430
+ activePanel={uiState.activePanel}
431
+ onSelectAllVariants={handleSelectAllVariants}
432
+ onSelectVariant={handleSelectVariantLink}
433
+ onCopyLink={handleCopyLink}
434
+ onShowShortcuts={uiActions.toggleShortcutsHelp}
435
+ />
436
+ ) : null
437
+ }
345
438
  >
346
439
  {uiState.showHealthDashboard ? (
347
440
  <div style={{ height: '100%', overflow: 'auto', backgroundColor: 'var(--bg-primary)' }}>
@@ -359,21 +452,25 @@ export function App({ fragments }: AppProps) {
359
452
  </Box>
360
453
  </div>
361
454
  ) : activeFragment ? (
362
- <div style={{ display: 'flex', height: '100%', flexDirection: panelDock === "bottom" ? 'column' : 'row' }}>
455
+ <div id="preview-layout" style={{ display: 'flex', height: '100%', flexDirection: panelDock === "bottom" ? 'column' : 'row' }}>
363
456
  {/* Main Content Area */}
364
457
  <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
- )}
458
+ <PreviewControlsBar
459
+ zoom={viewSettings.zoom}
460
+ background={viewSettings.background}
461
+ onZoomChange={viewSettings.setZoom}
462
+ onBackgroundChange={viewSettings.setBackground}
463
+ showMatrixView={uiState.showMatrixView}
464
+ showMultiViewport={uiState.showMultiViewport}
465
+ panelOpen={uiState.panelOpen}
466
+ onToggleMatrix={() => uiActions.setMatrixView(!uiState.showMatrixView)}
467
+ onToggleMultiViewport={() => uiActions.setMultiViewport(!uiState.showMultiViewport)}
468
+ onTogglePanel={uiActions.togglePanel}
469
+ />
374
470
 
375
471
  {/* Preview Area */}
376
472
  <div
473
+ id="preview-canvas"
377
474
  style={{
378
475
  flex: 1,
379
476
  overflow: 'auto',
@@ -381,12 +478,32 @@ export function App({ fragments }: AppProps) {
381
478
  ...(uiState.showMatrixView ? {} : getBackgroundStyle(viewSettings.background)),
382
479
  }}
383
480
  >
384
- {activeVariant ? (
481
+ {variantCount === 0 ? (
482
+ <NoVariantsMessage fragment={activeFragment?.fragment} />
483
+ ) : isAllVariantsMode && !uiState.showMatrixView && !uiState.showMultiViewport ? (
484
+ <AllVariantsPreview
485
+ componentName={activeFragment.fragment.meta.name}
486
+ fragmentPath={activeFragment.path}
487
+ variants={variants}
488
+ focusedVariantIndex={safeVariantIndex}
489
+ zoom={viewSettings.zoom}
490
+ background={viewSettings.background}
491
+ viewport={viewSettings.viewport}
492
+ customSize={viewSettings.customSize}
493
+ previewTheme={resolvedTheme}
494
+ showComparison={uiState.showComparison}
495
+ allFigmaUrls={allFigmaUrls}
496
+ fallbackFigmaUrl={activeFragment.fragment.meta.figma}
497
+ onRetry={uiActions.incrementPreviewKey}
498
+ renderVariantContent={renderVariantWithProps}
499
+ previewKeyBase={`${activeFragmentPath}-${uiState.previewKey}`}
500
+ />
501
+ ) : (
385
502
  <PreviewArea
386
503
  componentName={activeFragment.fragment.meta.name}
387
504
  fragmentPath={activeFragment.path}
388
505
  variant={activeVariant}
389
- variants={activeFragment.fragment.variants}
506
+ variants={variants}
390
507
  zoom={viewSettings.zoom}
391
508
  background={viewSettings.background}
392
509
  viewport={viewSettings.viewport}
@@ -402,40 +519,40 @@ export function App({ fragments }: AppProps) {
402
519
  handleSelectVariant(index);
403
520
  }}
404
521
  onRetry={uiActions.incrementPreviewKey}
405
- renderContent={renderVariantWithProps}
406
- previewKey={`${activeFragmentPath}-${activeVariantIndex}-${uiState.previewKey}`}
522
+ renderContent={() => renderVariantWithProps(activeVariant)}
523
+ previewKey={`${activeFragmentPath}-${safeVariantIndex}-${uiState.previewKey}`}
407
524
  />
408
- ) : (
409
- <NoVariantsMessage fragment={activeFragment?.fragment} />
410
525
  )}
411
526
  </div>
412
527
  </div>
413
528
 
414
529
  {/* 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
- )}
530
+ <div id="preview-tools">
531
+ {uiState.panelOpen && activeVariant && (
532
+ <BottomPanel
533
+ fragment={activeFragment.fragment}
534
+ variant={activeVariant}
535
+ fragments={fragments}
536
+ activePanel={uiState.activePanel}
537
+ onPanelChange={uiActions.setActivePanel}
538
+ figmaUrl={figmaUrl}
539
+ figmaStyles={figmaIntegration.figmaStyles.status === 'success' ? figmaIntegration.figmaStyles.styles || null : null}
540
+ renderedStyles={figmaIntegration.renderedStyles}
541
+ figmaLoading={figmaIntegration.isLoading}
542
+ figmaError={figmaIntegration.errorMessage}
543
+ onFetchFigma={figmaIntegration.fetchFigmaStyles}
544
+ onRefreshRendered={figmaIntegration.extractRenderedStyles}
545
+ actionLogs={actionLogs}
546
+ onClearActionLogs={clearActionLogs}
547
+ onNavigateToComponent={(name) => {
548
+ const target = fragments.find(s => s.fragment.meta.name === name);
549
+ if (target) handleSelectFragment(target.path);
550
+ }}
551
+ previewKey={uiState.previewKey}
552
+ fragmentKey={`${activeFragmentPath}-${safeVariantIndex}`}
553
+ />
554
+ )}
555
+ </div>
439
556
  </div>
440
557
  ) : (
441
558
  <EmptyState style={{ height: '100%' }}>
@@ -454,18 +571,12 @@ export function App({ fragments }: AppProps) {
454
571
  // Top Toolbar Component
455
572
  interface TopToolbarProps {
456
573
  fragment: { path: string; fragment: FragmentDefinition };
457
- variant: any;
458
- viewSettings: ReturnType<typeof useViewSettings>;
459
574
  uiState: ReturnType<typeof useAppState>['state'];
460
575
  uiActions: ReturnType<typeof useAppState>['actions'];
461
576
  figmaUrl?: string;
462
577
  searchQuery: string;
463
578
  onSearchChange: (value: string) => void;
464
579
  searchInputRef: RefObject<HTMLInputElement>;
465
- onPrevComponent: () => void;
466
- onNextComponent: () => void;
467
- onPrevVariant: () => void;
468
- onNextVariant: () => void;
469
580
  }
470
581
 
471
582
  interface ViewerHeaderProps {
@@ -481,6 +592,18 @@ interface HeaderSearchProps {
481
592
  inputRef: RefObject<HTMLInputElement>;
482
593
  }
483
594
 
595
+ interface PreviewAsideProps {
596
+ fragment: FragmentDefinition;
597
+ variants: FragmentVariant[];
598
+ focusedVariantIndex: number;
599
+ isAllVariantsMode: boolean;
600
+ activePanel: string;
601
+ onSelectAllVariants: () => void;
602
+ onSelectVariant: (index: number) => void;
603
+ onCopyLink: () => void;
604
+ onShowShortcuts: () => void;
605
+ }
606
+
484
607
  function HeaderSearch({ value, onChange, inputRef }: HeaderSearchProps) {
485
608
  return (
486
609
  <Header.Search expandable>
@@ -542,43 +665,121 @@ function ViewerHeader({ showHealth, searchQuery, onSearchChange, searchInputRef
542
665
  );
543
666
  }
544
667
 
668
+ function PreviewAside({
669
+ fragment,
670
+ variants,
671
+ focusedVariantIndex,
672
+ isAllVariantsMode,
673
+ activePanel,
674
+ onSelectAllVariants,
675
+ onSelectVariant,
676
+ onCopyLink,
677
+ onShowShortcuts,
678
+ }: PreviewAsideProps) {
679
+ const focusedVariant = variants[focusedVariantIndex] || null;
680
+
681
+ const baseLinkStyle: CSSProperties = {
682
+ color: 'var(--text-secondary)',
683
+ textDecoration: 'none',
684
+ fontSize: '13px',
685
+ borderRadius: '6px',
686
+ padding: '4px 8px',
687
+ display: 'block',
688
+ };
689
+
690
+ const getLinkStyle = (isActive = false): CSSProperties => (
691
+ isActive
692
+ ? { ...baseLinkStyle, color: 'var(--text-primary)', backgroundColor: 'var(--bg-hover)' }
693
+ : baseLinkStyle
694
+ );
695
+
696
+ return (
697
+ <Box padding="md" style={{ position: 'sticky', top: '80px' }}>
698
+ <Stack gap="md">
699
+ <Stack gap="xs">
700
+ <Text size="xs" color="tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.08em' }}>
701
+ On this page
702
+ </Text>
703
+ <a href="#preview-canvas" style={baseLinkStyle}>
704
+ Preview
705
+ </a>
706
+ <a href="#preview-tools" style={baseLinkStyle}>
707
+ Panels
708
+ </a>
709
+ {variants.length > 0 && (
710
+ <>
711
+ <Text size="xs" color="tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: '8px' }}>
712
+ Variants
713
+ </Text>
714
+ <a
715
+ href="#preview-canvas"
716
+ style={getLinkStyle(isAllVariantsMode)}
717
+ onClick={(event) => {
718
+ event.preventDefault();
719
+ onSelectAllVariants();
720
+ }}
721
+ >
722
+ All
723
+ </a>
724
+ {variants.map((variant, index) => {
725
+ const active = index === focusedVariantIndex;
726
+
727
+ return (
728
+ <a
729
+ key={variant.name}
730
+ href="#preview-canvas"
731
+ style={getLinkStyle(active)}
732
+ onClick={(event) => {
733
+ event.preventDefault();
734
+ onSelectVariant(index);
735
+ }}
736
+ >
737
+ {variant.name}
738
+ </a>
739
+ );
740
+ })}
741
+ </>
742
+ )}
743
+ </Stack>
744
+ <Separator />
745
+ <Stack gap="xs">
746
+ <Text size="sm" weight="medium">{fragment.meta.name}</Text>
747
+ <Text size="xs" color="secondary">
748
+ {isAllVariantsMode
749
+ ? `Variant: All${focusedVariant ? ` (focused: ${focusedVariant.name})` : ''}`
750
+ : focusedVariant ? `Variant: ${focusedVariant.name}` : 'Select a variant'}
751
+ </Text>
752
+ <Text size="xs" color="tertiary">
753
+ Active panel: {activePanel}
754
+ </Text>
755
+ </Stack>
756
+ <Separator />
757
+ <Stack gap="xs">
758
+ <Button variant="ghost" size="sm" onClick={onCopyLink}>
759
+ Copy Link
760
+ </Button>
761
+ <Button variant="ghost" size="sm" onClick={onShowShortcuts}>
762
+ Keyboard Shortcuts
763
+ </Button>
764
+ </Stack>
765
+ </Stack>
766
+ </Box>
767
+ );
768
+ }
769
+
545
770
  function TopToolbar({
546
771
  fragment,
547
- variant,
548
- viewSettings,
549
772
  uiState,
550
773
  uiActions,
551
774
  figmaUrl,
552
775
  searchQuery,
553
776
  onSearchChange,
554
777
  searchInputRef,
555
- onPrevComponent,
556
- onNextComponent,
557
- onPrevVariant,
558
- onNextVariant,
559
778
  }: TopToolbarProps) {
560
779
  const { setTheme, resolvedTheme } = useTheme();
561
780
  return (
562
781
  <Header aria-label="Component preview toolbar">
563
782
  <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
783
  <Header.Brand>
583
784
  <Stack direction="row" align="center" gap="sm">
584
785
  <img src={fragmentsLogo} alt="" width={20} height={20} style={{ display: 'block' }} />
@@ -642,24 +843,179 @@ function TopToolbar({
642
843
  );
643
844
  }
644
845
 
645
- // Variant Tabs Bar Component
646
- interface VariantTabsBarProps {
647
- variants: any[];
648
- activeIndex: number;
649
- onSelect: (index: number) => void;
846
+ function normalizeAnchorSegment(value: string): string {
847
+ const normalized = value
848
+ .toLowerCase()
849
+ .trim()
850
+ .replace(/[^a-z0-9]+/g, "-")
851
+ .replace(/^-+|-+$/g, "");
852
+ return normalized || "variant";
853
+ }
854
+
855
+ function getVariantSectionId(componentName: string, variantName: string): string {
856
+ return `preview-${normalizeAnchorSegment(componentName)}-${normalizeAnchorSegment(variantName)}`;
857
+ }
858
+
859
+ interface PreviewControlsBarProps {
860
+ zoom: ReturnType<typeof useViewSettings>["zoom"];
861
+ background: ReturnType<typeof useViewSettings>["background"];
862
+ onZoomChange: ReturnType<typeof useViewSettings>["setZoom"];
863
+ onBackgroundChange: ReturnType<typeof useViewSettings>["setBackground"];
650
864
  showMatrixView: boolean;
865
+ showMultiViewport: boolean;
866
+ panelOpen: boolean;
867
+ onToggleMatrix: () => void;
868
+ onToggleMultiViewport: () => void;
869
+ onTogglePanel: () => void;
651
870
  }
652
871
 
653
- function VariantTabsBar({ variants, activeIndex, onSelect, showMatrixView }: VariantTabsBarProps) {
872
+ function PreviewControlsBar({
873
+ zoom,
874
+ background,
875
+ onZoomChange,
876
+ onBackgroundChange,
877
+ showMatrixView,
878
+ showMultiViewport,
879
+ panelOpen,
880
+ onToggleMatrix,
881
+ onToggleMultiViewport,
882
+ onTogglePanel,
883
+ }: PreviewControlsBarProps) {
884
+ const toggleButtonStyle = (active: boolean): CSSProperties => (
885
+ active ? { color: 'var(--color-accent)', backgroundColor: 'var(--bg-hover)' } : {}
886
+ );
887
+
654
888
  return (
655
889
  <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
- )}
890
+ <Stack direction="row" gap="sm" align="center" justify="end">
891
+ <PreviewToolbar
892
+ zoom={zoom}
893
+ background={background}
894
+ onZoomChange={onZoomChange}
895
+ onBackgroundChange={onBackgroundChange}
896
+ />
897
+ <Separator orientation="vertical" style={{ height: '16px' }} />
898
+ <Tooltip content={showMatrixView ? "Disable matrix view" : "Enable matrix view"}>
899
+ <Button
900
+ variant="ghost"
901
+ size="sm"
902
+ aria-pressed={showMatrixView}
903
+ aria-label="Toggle matrix view"
904
+ onClick={onToggleMatrix}
905
+ style={toggleButtonStyle(showMatrixView)}
906
+ >
907
+ <GridFour size={16} />
908
+ </Button>
909
+ </Tooltip>
910
+ <Tooltip content={showMultiViewport ? "Disable responsive view" : "Enable responsive view"}>
911
+ <Button
912
+ variant="ghost"
913
+ size="sm"
914
+ aria-pressed={showMultiViewport}
915
+ aria-label="Toggle responsive view"
916
+ onClick={onToggleMultiViewport}
917
+ style={toggleButtonStyle(showMultiViewport)}
918
+ >
919
+ <DeviceMobile size={16} />
920
+ </Button>
921
+ </Tooltip>
922
+ <Tooltip content={panelOpen ? "Hide addons panel" : "Show addons panel"}>
923
+ <Button
924
+ variant="ghost"
925
+ size="sm"
926
+ aria-pressed={panelOpen}
927
+ aria-label="Toggle addons panel"
928
+ onClick={onTogglePanel}
929
+ style={toggleButtonStyle(panelOpen)}
930
+ >
931
+ <Rows size={16} />
932
+ </Button>
933
+ </Tooltip>
934
+ </Stack>
935
+ </div>
936
+ );
937
+ }
938
+
939
+ interface AllVariantsPreviewProps {
940
+ componentName: string;
941
+ fragmentPath: string;
942
+ variants: FragmentVariant[];
943
+ focusedVariantIndex: number;
944
+ zoom: ReturnType<typeof useViewSettings>["zoom"];
945
+ background: ReturnType<typeof useViewSettings>["background"];
946
+ viewport: ReturnType<typeof useViewSettings>["viewport"];
947
+ customSize: ReturnType<typeof useViewSettings>["customSize"];
948
+ previewTheme: ReturnType<typeof useTheme>["resolvedTheme"];
949
+ showComparison: boolean;
950
+ allFigmaUrls: string[];
951
+ fallbackFigmaUrl?: string;
952
+ onRetry: () => void;
953
+ renderVariantContent: (variant: FragmentVariant) => ReactNode;
954
+ previewKeyBase: string;
955
+ }
956
+
957
+ function AllVariantsPreview({
958
+ componentName,
959
+ fragmentPath,
960
+ variants,
961
+ focusedVariantIndex,
962
+ zoom,
963
+ background,
964
+ viewport,
965
+ customSize,
966
+ previewTheme,
967
+ showComparison,
968
+ allFigmaUrls,
969
+ fallbackFigmaUrl,
970
+ onRetry,
971
+ renderVariantContent,
972
+ previewKeyBase,
973
+ }: AllVariantsPreviewProps) {
974
+ return (
975
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '20px' }}>
976
+ {variants.map((variant, index) => {
977
+ const isFocused = index === focusedVariantIndex;
978
+
979
+ return (
980
+ <section
981
+ id={getVariantSectionId(componentName, variant.name)}
982
+ key={variant.name}
983
+ style={{
984
+ border: '1px solid var(--border)',
985
+ borderColor: isFocused ? 'var(--color-accent)' : 'var(--border)',
986
+ borderRadius: '10px',
987
+ overflow: 'hidden',
988
+ backgroundColor: 'var(--bg-primary)',
989
+ boxShadow: isFocused ? '0 0 0 1px color-mix(in srgb, var(--color-accent) 40%, transparent)' : undefined,
990
+ }}
991
+ >
992
+ <Stack direction="row" align="baseline" gap="sm" style={{ padding: '12px 14px', borderBottom: '1px solid var(--border-subtle)', backgroundColor: 'var(--bg-secondary)' }}>
993
+ <Text size="sm" weight="medium">{variant.name}</Text>
994
+ <Text size="xs" color="secondary">{variant.description}</Text>
995
+ </Stack>
996
+ <PreviewArea
997
+ componentName={componentName}
998
+ fragmentPath={fragmentPath}
999
+ variant={variant}
1000
+ variants={variants}
1001
+ zoom={zoom}
1002
+ background={background}
1003
+ viewport={viewport}
1004
+ customSize={customSize}
1005
+ previewTheme={previewTheme}
1006
+ showMatrixView={false}
1007
+ showMultiViewport={false}
1008
+ showComparison={showComparison}
1009
+ figmaUrl={variant.figma || fallbackFigmaUrl}
1010
+ allFigmaUrls={allFigmaUrls}
1011
+ onSelectVariant={() => {}}
1012
+ onRetry={onRetry}
1013
+ renderContent={() => renderVariantContent(variant)}
1014
+ previewKey={`${previewKeyBase}-${index}`}
1015
+ />
1016
+ </section>
1017
+ );
1018
+ })}
663
1019
  </div>
664
1020
  );
665
1021
  }