@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.
- package/dist/bin.js +294 -50
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-D35RGPAG.js → chunk-7OPWMLOE.js} +435 -19
- package/dist/chunk-7OPWMLOE.js.map +1 -0
- package/dist/{chunk-SSLQXHNX.js → chunk-CVXKXVOY.js} +1 -1
- package/dist/{chunk-SSLQXHNX.js.map → chunk-CVXKXVOY.js.map} +1 -1
- package/dist/{chunk-Q7GOHVOK.js → chunk-TJ34N7C7.js} +39 -2
- package/dist/{chunk-Q7GOHVOK.js.map → chunk-TJ34N7C7.js.map} +1 -1
- package/dist/{chunk-F7ITZPDJ.js → chunk-XHUDJNN3.js} +2 -2
- package/dist/{core-SKRPJQZG.js → core-W2HYIQW6.js} +2 -2
- package/dist/{generate-7AF7WRVK.js → generate-LMTISDIJ.js} +3 -3
- package/dist/index.js +3 -3
- package/dist/{init-WKGDPYI4.js → init-7CHRKQ7P.js} +3 -3
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-K6JNMCGM.js → scan-WY23TJCP.js} +4 -4
- package/dist/{service-F3E4JJM7.js → service-T2L7VLTE.js} +2 -2
- package/dist/{static-viewer-4LQZ5AGA.js → static-viewer-GBR7YNF3.js} +2 -2
- package/dist/{test-CJDNJTPZ.js → test-OJRXNDO2.js} +2 -2
- package/dist/{tokens-JAJABYXP.js → tokens-3BWDESVM.js} +3 -3
- package/dist/{viewer-R3Q6WAMJ.js → viewer-SUFOISZM.js} +12 -12
- package/package.json +2 -2
- package/src/bin.ts +23 -0
- package/src/build.ts +43 -0
- package/src/commands/graph.ts +274 -0
- package/src/core/composition.ts +64 -1
- package/src/core/graph-extractor.test.ts +542 -0
- package/src/core/graph-extractor.ts +601 -0
- package/src/core/importAnalyzer.ts +5 -0
- package/src/viewer/components/App.tsx +128 -30
- package/src/viewer/components/Icons.tsx +53 -1
- package/src/viewer/components/Layout.tsx +7 -3
- package/src/viewer/components/LeftSidebar.tsx +65 -87
- package/src/viewer/components/PreviewFrameHost.tsx +30 -1
- package/src/viewer/components/PreviewToolbar.tsx +57 -10
- package/src/viewer/components/ViewportSelector.tsx +56 -45
- package/src/viewer/constants/ui.ts +4 -4
- package/src/viewer/preview-frame.html +22 -13
- package/src/viewer/styles/globals.css +42 -81
- package/dist/chunk-D35RGPAG.js.map +0 -1
- /package/dist/{chunk-F7ITZPDJ.js.map → chunk-XHUDJNN3.js.map} +0 -0
- /package/dist/{core-SKRPJQZG.js.map → core-W2HYIQW6.js.map} +0 -0
- /package/dist/{generate-7AF7WRVK.js.map → generate-LMTISDIJ.js.map} +0 -0
- /package/dist/{init-WKGDPYI4.js.map → init-7CHRKQ7P.js.map} +0 -0
- /package/dist/{scan-K6JNMCGM.js.map → scan-WY23TJCP.js.map} +0 -0
- /package/dist/{service-F3E4JJM7.js.map → service-T2L7VLTE.js.map} +0 -0
- /package/dist/{static-viewer-4LQZ5AGA.js.map → static-viewer-GBR7YNF3.js.map} +0 -0
- /package/dist/{test-CJDNJTPZ.js.map → test-OJRXNDO2.js.map} +0 -0
- /package/dist/{tokens-JAJABYXP.js.map → tokens-3BWDESVM.js.map} +0 -0
- /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
|
|
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,
|
|
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 {
|
|
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:
|
|
225
|
-
escape:
|
|
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
|
|
467
|
+
function HeaderSearch({ value, onChange, inputRef }: HeaderSearchProps) {
|
|
422
468
|
return (
|
|
423
|
-
<
|
|
424
|
-
<
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
</
|
|
503
|
-
</
|
|
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
|
-
<
|
|
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
|
|
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="
|
|
12
|
-
<AppShell.
|
|
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 {
|
|
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 {
|
|
134
|
-
const
|
|
132
|
+
const { setTheme, resolvedTheme } = useTheme();
|
|
133
|
+
const { isMobile, setOpen } = useSidebar();
|
|
135
134
|
const navRef = useRef<HTMLDivElement>(null);
|
|
136
135
|
|
|
137
|
-
const debouncedSearch = useDebounce(
|
|
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 <
|
|
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,
|
|
207
|
+
}, [focusedIndex, keyboardItems.length]);
|
|
208
208
|
|
|
209
209
|
useEffect(() => {
|
|
210
210
|
setFocusedIndex(-1);
|
|
211
|
-
}, [
|
|
211
|
+
}, [searchQuery]);
|
|
212
212
|
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
return;
|
|
213
|
+
const handleSelect = (path: string) => {
|
|
214
|
+
onSelect(path);
|
|
215
|
+
if (isMobile) {
|
|
216
|
+
setOpen(false);
|
|
218
217
|
}
|
|
218
|
+
};
|
|
219
219
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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) >=
|
|
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 ?
|
|
248
|
-
} else if (e.key === 'Enter' && focusedIndex >= 0 && focusedIndex <
|
|
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
|
-
|
|
246
|
+
const currentItem = keyboardItems[focusedIndex];
|
|
247
|
+
if (currentItem.type === 'dashboard') {
|
|
248
|
+
handleHealthClick();
|
|
249
|
+
} else {
|
|
250
|
+
handleSelect(currentItem.path);
|
|
251
|
+
}
|
|
251
252
|
}
|
|
252
|
-
}, [
|
|
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
|
-
|
|
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
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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={() =>
|
|
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
|
-
|
|
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
|
/>
|