@fragments-sdk/cli 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +2 -0
  2. package/dist/bin.js +25 -17
  3. package/dist/bin.js.map +1 -1
  4. package/dist/chunk-AWYCDRPG.js +272 -0
  5. package/dist/chunk-AWYCDRPG.js.map +1 -0
  6. package/dist/chunk-EKLMXTWU.js +80 -0
  7. package/dist/chunk-EKLMXTWU.js.map +1 -0
  8. package/dist/{chunk-DH4ETVSM.js → chunk-NEJ2FBTN.js} +9 -7
  9. package/dist/{chunk-DH4ETVSM.js.map → chunk-NEJ2FBTN.js.map} +1 -1
  10. package/dist/{chunk-GHYYFAQN.js → chunk-P33AKQJW.js} +1 -76
  11. package/dist/chunk-P33AKQJW.js.map +1 -0
  12. package/dist/{chunk-3T6QL7IY.js → chunk-R6IZZSE7.js} +23 -275
  13. package/dist/chunk-R6IZZSE7.js.map +1 -0
  14. package/dist/{chunk-7KUSBMI4.js → chunk-S56I5FST.js} +174 -45
  15. package/dist/chunk-S56I5FST.js.map +1 -0
  16. package/dist/{chunk-DQHWLAUV.js → chunk-TOIE7VXF.js} +2 -2
  17. package/dist/{chunk-OOGTG5FM.js → chunk-UXLGIGSX.js} +56 -2
  18. package/dist/chunk-UXLGIGSX.js.map +1 -0
  19. package/dist/{chunk-GKX2HPZ6.js → chunk-YMPGYEWK.js} +9 -3
  20. package/dist/chunk-YMPGYEWK.js.map +1 -0
  21. package/dist/chunk-Z7EY4VHE.js +50 -0
  22. package/dist/{core-UQXZTBFZ.js → core-3NMNCLFW.js} +8 -5
  23. package/dist/discovery-Z4RDDFVR.js +28 -0
  24. package/dist/{generate-GP6ZLAQB.js → generate-23VLX7QN.js} +7 -4
  25. package/dist/{generate-GP6ZLAQB.js.map → generate-23VLX7QN.js.map} +1 -1
  26. package/dist/index.js +15 -11
  27. package/dist/index.js.map +1 -1
  28. package/dist/{init-W72WBSU2.js → init-VYVYMVHH.js} +10 -6
  29. package/dist/{init-W72WBSU2.js.map → init-VYVYMVHH.js.map} +1 -1
  30. package/dist/mcp-bin.js +5 -3
  31. package/dist/mcp-bin.js.map +1 -1
  32. package/dist/sass.node-4XJK6YBF.js +130708 -0
  33. package/dist/sass.node-4XJK6YBF.js.map +1 -0
  34. package/dist/scan-FZR6YVI5.js +15 -0
  35. package/dist/{service-PVGTYUKX.js → service-CFFBHW4X.js} +6 -4
  36. package/dist/service-CFFBHW4X.js.map +1 -0
  37. package/dist/{static-viewer-KILKIVN7.js → static-viewer-VA2JXSCX.js} +6 -4
  38. package/dist/static-viewer-VA2JXSCX.js.map +1 -0
  39. package/dist/{test-3YRYQRGV.js → test-VTD7R6G2.js} +8 -4
  40. package/dist/{test-3YRYQRGV.js.map → test-VTD7R6G2.js.map} +1 -1
  41. package/dist/{tokens-IXSQHPQK.js → tokens-7JA5CPDL.js} +10 -7
  42. package/dist/{tokens-IXSQHPQK.js.map → tokens-7JA5CPDL.js.map} +1 -1
  43. package/dist/{viewer-K42REJU2.js → viewer-WXTDDQGK.js} +403 -26
  44. package/dist/viewer-WXTDDQGK.js.map +1 -0
  45. package/package.json +5 -1
  46. package/src/build.ts +57 -5
  47. package/src/commands/init.ts +6 -2
  48. package/src/core/__tests__/token-resolver.test.ts +82 -0
  49. package/src/core/discovery.ts +7 -1
  50. package/src/core/token-parser.ts +102 -0
  51. package/src/core/token-resolver.ts +155 -0
  52. package/src/migrate/detect.ts +4 -0
  53. package/src/service/__tests__/patch-generator.test.ts +2 -2
  54. package/src/service/patch-generator.ts +8 -1
  55. package/src/viewer/components/App.tsx +63 -2
  56. package/src/viewer/components/Layout.tsx +1 -1
  57. package/src/viewer/components/LeftSidebar.tsx +35 -77
  58. package/src/viewer/preview-frame.html +1 -1
  59. package/src/viewer/render-utils.ts +141 -0
  60. package/src/viewer/styles/globals.css +2 -1
  61. package/src/viewer/vite-plugin.ts +399 -24
  62. package/dist/chunk-3T6QL7IY.js.map +0 -1
  63. package/dist/chunk-7KUSBMI4.js.map +0 -1
  64. package/dist/chunk-GHYYFAQN.js.map +0 -1
  65. package/dist/chunk-GKX2HPZ6.js.map +0 -1
  66. package/dist/chunk-OOGTG5FM.js.map +0 -1
  67. package/dist/scan-V54HWRDY.js +0 -12
  68. package/dist/viewer-K42REJU2.js.map +0 -1
  69. /package/dist/{chunk-DQHWLAUV.js.map → chunk-TOIE7VXF.js.map} +0 -0
  70. /package/dist/{core-UQXZTBFZ.js.map → chunk-Z7EY4VHE.js.map} +0 -0
  71. /package/dist/{scan-V54HWRDY.js.map → core-3NMNCLFW.js.map} +0 -0
  72. /package/dist/{service-PVGTYUKX.js.map → discovery-Z4RDDFVR.js.map} +0 -0
  73. /package/dist/{static-viewer-KILKIVN7.js.map → scan-FZR6YVI5.js.map} +0 -0
@@ -62,9 +62,15 @@ export async function discoverFragmentFiles(
62
62
  config: FragmentsConfig,
63
63
  configDir: string
64
64
  ): Promise<DiscoveredFile[]> {
65
+ const defaultExcludes = [
66
+ '**/*.test.stories.*',
67
+ '**/*.stories.test.*',
68
+ '**/*.test.story.*',
69
+ '**/*.story.test.*',
70
+ ];
65
71
  const files = await fg(config.include, {
66
72
  cwd: configDir,
67
- ignore: config.exclude ?? [],
73
+ ignore: [...defaultExcludes, ...(config.exclude ?? [])],
68
74
  absolute: false,
69
75
  });
70
76
 
@@ -12,6 +12,8 @@ export interface ParsedToken {
12
12
  name: string;
13
13
  /** Raw value from the declaration (e.g., "#{$fui-space-4}" or "16px") */
14
14
  value?: string;
15
+ /** Resolved value after SCSS variable substitution (e.g., "16px") */
16
+ resolvedValue?: string;
15
17
  /** Category inferred from SCSS comment or naming convention */
16
18
  category: string;
17
19
  /** Description from inline comment, if any */
@@ -137,12 +139,90 @@ function normalizeCategory(comment: string): string {
137
139
  return mappings[text] ?? text.replace(/\s+/g, '-');
138
140
  }
139
141
 
142
+ /**
143
+ * Extract SCSS variable declarations ($name: value;) from file content.
144
+ * Returns a map of variable name → value.
145
+ */
146
+ function extractScssVariables(content: string): Map<string, string> {
147
+ const vars = new Map<string, string>();
148
+ // Match: $var-name: value; (handles multi-word values, stops at semicolon)
149
+ const scssVarRegex = /^\s*(\$[\w-]+)\s*:\s*(.+?)\s*(?:!default\s*)?;/gm;
150
+
151
+ let match: RegExpExecArray | null;
152
+ while ((match = scssVarRegex.exec(content)) !== null) {
153
+ const name = match[1];
154
+ const value = match[2].replace(/\s*\/\/.*$/, '').trim();
155
+ // Only store the first occurrence (canonical definition)
156
+ if (!vars.has(name)) {
157
+ vars.set(name, value);
158
+ }
159
+ }
160
+
161
+ return vars;
162
+ }
163
+
164
+ /**
165
+ * Resolve SCSS interpolations and variable references in a token value.
166
+ *
167
+ * Handles:
168
+ * - `#{$var}` → looks up $var in scssVars map
169
+ * - `$var` standalone → looks up in scssVars map
170
+ * - `var(--other-token, fallback)` → returns fallback if provided
171
+ * - Recursive resolution up to 5 levels deep
172
+ */
173
+ function resolveTokenValue(
174
+ rawValue: string,
175
+ scssVars: Map<string, string>,
176
+ cssVarValues: Map<string, string>,
177
+ depth = 0
178
+ ): string {
179
+ if (depth > 5) return rawValue; // Prevent infinite recursion
180
+
181
+ let resolved = rawValue;
182
+
183
+ // Resolve #{$var} interpolations
184
+ resolved = resolved.replace(/#\{(\$[\w-]+)\}/g, (_, varName) => {
185
+ const val = scssVars.get(varName);
186
+ return val !== undefined
187
+ ? resolveTokenValue(val, scssVars, cssVarValues, depth + 1)
188
+ : `#{${varName}}`;
189
+ });
190
+
191
+ // Resolve standalone $var references (not inside #{})
192
+ resolved = resolved.replace(/(?<![#\{])(\$[\w-]+)/g, (_, varName) => {
193
+ const val = scssVars.get(varName);
194
+ return val !== undefined
195
+ ? resolveTokenValue(val, scssVars, cssVarValues, depth + 1)
196
+ : varName;
197
+ });
198
+
199
+ // Resolve var(--token, fallback) — use the referenced token value or fallback
200
+ resolved = resolved.replace(
201
+ /var\((--[\w-]+)(?:\s*,\s*(.+?))?\)/g,
202
+ (original, tokenName, fallback) => {
203
+ const tokenVal = cssVarValues.get(tokenName);
204
+ if (tokenVal !== undefined) {
205
+ return resolveTokenValue(tokenVal, scssVars, cssVarValues, depth + 1);
206
+ }
207
+ if (fallback) {
208
+ return resolveTokenValue(fallback.trim(), scssVars, cssVarValues, depth + 1);
209
+ }
210
+ return original;
211
+ }
212
+ );
213
+
214
+ return resolved;
215
+ }
216
+
140
217
  /**
141
218
  * Parse a SCSS or CSS file and extract CSS custom property declarations.
142
219
  *
143
220
  * Handles two grouping strategies:
144
221
  * 1. Comment-based: Uses `// Category` comments above groups of declarations
145
222
  * 2. Naming-based: Falls back to inferring category from variable name patterns
223
+ *
224
+ * Also resolves SCSS variable interpolations (e.g., `#{$fui-space-4}` → `16px`)
225
+ * when the SCSS variable definitions are found in the same file content.
146
226
  */
147
227
  export function parseTokenFile(content: string, filePath: string): TokenParseOutput {
148
228
  const lines = content.split('\n');
@@ -151,6 +231,9 @@ export function parseTokenFile(content: string, filePath: string): TokenParseOut
151
231
  let currentCategory = 'other';
152
232
  let hasCommentCategories = false;
153
233
 
234
+ // First pass: extract SCSS variable declarations for resolution
235
+ const scssVars = extractScssVariables(content);
236
+
154
237
  // Regex for CSS custom property declarations
155
238
  // Matches: --name: value; (with optional SCSS interpolation)
156
239
  // Captures both the variable name and its value
@@ -199,6 +282,25 @@ export function parseTokenFile(content: string, filePath: string): TokenParseOut
199
282
  }
200
283
  }
201
284
 
285
+ // Second pass: build a CSS custom property → raw value map for cross-references
286
+ const cssVarValues = new Map<string, string>();
287
+ for (const token of tokens) {
288
+ if (token.value) {
289
+ cssVarValues.set(token.name, token.value);
290
+ }
291
+ }
292
+
293
+ // Third pass: resolve SCSS interpolations and var() references
294
+ for (const token of tokens) {
295
+ if (token.value) {
296
+ const resolved = resolveTokenValue(token.value, scssVars, cssVarValues);
297
+ // Only set resolvedValue if it's different from raw and doesn't still contain unresolved refs
298
+ if (resolved !== token.value && !resolved.includes('#{') && !resolved.includes('$')) {
299
+ token.resolvedValue = resolved;
300
+ }
301
+ }
302
+ }
303
+
202
304
  // Group by category
203
305
  const categories: Record<string, ParsedToken[]> = {};
204
306
  for (const token of tokens) {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Token Resolver — resolves unresolved SCSS token values using actual sass compilation.
3
+ *
4
+ * The regex-based resolver in token-parser.ts cannot handle SCSS module namespaces,
5
+ * functions (color.scale, math.div), or map lookups. This module provides a fallback
6
+ * that compiles the actual SCSS sources to extract computed CSS custom property values.
7
+ *
8
+ * Used at build time only — no runtime sass dependency in the MCP server.
9
+ */
10
+
11
+ import { resolve, dirname, basename } from 'node:path';
12
+ import { existsSync, readdirSync } from 'node:fs';
13
+
14
+ /**
15
+ * Round fractional RGB channel values produced by Sass color math.
16
+ *
17
+ * Sass functions like color.scale() and color.adjust() can produce
18
+ * fractional values like rgb(217.8, 217.8, 217.8). This normalizes
19
+ * them to clean integers: rgb(218, 218, 218).
20
+ *
21
+ * Alpha channels in rgba() are preserved as-is (they're legitimately fractional).
22
+ */
23
+ function roundRgbValues(value: string): string {
24
+ return value
25
+ .replace(
26
+ /rgb\(([^)]+)\)/g,
27
+ (_full, inner: string) => {
28
+ const parts = inner.split(',').map(p => p.trim());
29
+ const rounded = parts.map(p => {
30
+ const num = parseFloat(p);
31
+ return isNaN(num) ? p : String(Math.round(num));
32
+ });
33
+ return `rgb(${rounded.join(', ')})`;
34
+ },
35
+ )
36
+ .replace(
37
+ /rgba\(([^)]+)\)/g,
38
+ (_full, inner: string) => {
39
+ const parts = inner.split(',').map(p => p.trim());
40
+ // Round RGB channels (first 3), preserve alpha as-is
41
+ const rounded = parts.map((p, i) => {
42
+ if (i >= 3) return p; // alpha channel — don't round
43
+ const num = parseFloat(p);
44
+ return isNaN(num) ? p : String(Math.round(num));
45
+ });
46
+ return `rgba(${rounded.join(', ')})`;
47
+ },
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Resolve unresolved SCSS token values by compiling the project's token SCSS.
53
+ *
54
+ * Generates a temporary SCSS string that imports the project's _variables.scss
55
+ * and includes the fui-css-variables mixin, then compiles it with sass to extract
56
+ * the actual computed values for all CSS custom properties.
57
+ *
58
+ * @param unresolvedTokens - Tokens with values containing #{} or $ that need resolution
59
+ * @param tokensDir - Absolute path to the directory containing _variables.scss
60
+ * @returns Map of token name → resolved CSS value (only for tokens that were unresolved)
61
+ */
62
+ export async function resolveTokensWithSass(
63
+ unresolvedTokens: Array<{ name: string; value: string }>,
64
+ tokensDir: string,
65
+ ): Promise<Map<string, string>> {
66
+ const resolvedMap = new Map<string, string>();
67
+
68
+ // Filter to only tokens that actually need resolution
69
+ const needsResolution = unresolvedTokens.filter(
70
+ t => t.value.includes('#{') || t.value.includes('$'),
71
+ );
72
+
73
+ if (needsResolution.length === 0) {
74
+ return resolvedMap;
75
+ }
76
+
77
+ try {
78
+ // Dynamic import so sass is only loaded when needed (build-time only)
79
+ const sass = await import('sass');
80
+
81
+ // Find the _variables.scss file and determine the mixin name
82
+ const variablesPath = findVariablesFile(tokensDir);
83
+ if (!variablesPath) {
84
+ return resolvedMap;
85
+ }
86
+
87
+ // Determine the module name for @use (filename without _ prefix and .scss suffix)
88
+ const fileName = basename(variablesPath);
89
+ const moduleName = fileName.replace(/^_/, '').replace(/\.scss$/, '');
90
+
91
+ // Generate a SCSS string that imports the variables and includes the mixin
92
+ const scssSource = `
93
+ @use '${moduleName}' as vars;
94
+ :root { @include vars.fui-css-variables; }
95
+ `;
96
+
97
+ // Compile the SCSS with the tokens directory as a load path
98
+ const compiled = sass.compileString(scssSource, {
99
+ loadPaths: [tokensDir, dirname(tokensDir)],
100
+ style: 'expanded',
101
+ // Suppress sass deprecation warnings during build
102
+ logger: { warn() {}, debug() {} } as never,
103
+ });
104
+
105
+ // Parse the compiled CSS for --fui-* custom property declarations
106
+ const cssVarRegex = /(--[\w-]+)\s*:\s*([^;]+)/g;
107
+ let match: RegExpExecArray | null;
108
+ const allResolved = new Map<string, string>();
109
+
110
+ while ((match = cssVarRegex.exec(compiled.css)) !== null) {
111
+ allResolved.set(match[1], roundRgbValues(match[2].trim()));
112
+ }
113
+
114
+ // Only return values for the tokens that were originally unresolved
115
+ for (const token of needsResolution) {
116
+ const value = allResolved.get(token.name);
117
+ if (value !== undefined) {
118
+ resolvedMap.set(token.name, value);
119
+ }
120
+ }
121
+ } catch {
122
+ // Sass compilation failure is non-fatal — fall back to regex-resolved values
123
+ }
124
+
125
+ return resolvedMap;
126
+ }
127
+
128
+ /**
129
+ * Find the _variables.scss file in the tokens directory.
130
+ */
131
+ function findVariablesFile(tokensDir: string): string | null {
132
+ const candidates = ['_variables.scss', 'variables.scss'];
133
+ for (const name of candidates) {
134
+ const path = resolve(tokensDir, name);
135
+ if (existsSync(path)) {
136
+ return path;
137
+ }
138
+ }
139
+
140
+ // Fallback: look for any SCSS file containing 'css-variables' mixin
141
+ try {
142
+ const files = readdirSync(tokensDir).filter(f => f.endsWith('.scss'));
143
+ for (const file of files) {
144
+ const path = resolve(tokensDir, file);
145
+ // We just need to find it exists; the @use import will handle the rest
146
+ if (file.includes('variables') || file.includes('tokens')) {
147
+ return path;
148
+ }
149
+ }
150
+ } catch {
151
+ // Directory read failure
152
+ }
153
+
154
+ return null;
155
+ }
@@ -164,6 +164,10 @@ export async function discoverStoryFiles(
164
164
  "**/dist/**",
165
165
  "**/build/**",
166
166
  "**/.storybook/**",
167
+ "**/*.test.stories.*",
168
+ "**/*.stories.test.*",
169
+ "**/*.test.story.*",
170
+ "**/*.story.test.*",
167
171
  ],
168
172
  });
169
173
 
@@ -72,8 +72,8 @@ describe("patch-generator", () => {
72
72
 
73
73
  const result = generateTokenPatches("Button", styleDiffs, registry);
74
74
 
75
- // All values are already tokens
76
- expect(result.summary).toContain("compliance");
75
+ // All values are already tokens — message confirms compliant status
76
+ expect(result.summary).toContain("token-compliant");
77
77
  });
78
78
 
79
79
  it("identifies unfixable values without matching tokens", () => {
@@ -85,9 +85,16 @@ export function generateTokenPatches(
85
85
  const summary = registry.calculateUsageSummary(styleDiffs, theme);
86
86
 
87
87
  if (summary.hardcodedProperties.length === 0) {
88
+ // No hardcoded values → either fully compliant or no styles were analyzed
89
+ let message: string;
90
+ if (summary.totalProperties === 0) {
91
+ message = `No style properties analyzed for ${componentName}. Ensure the component renders visible styles.`;
92
+ } else {
93
+ message = `${componentName} is fully token-compliant — no hardcoded values found.`;
94
+ }
88
95
  return {
89
96
  patches: [],
90
- summary: `No hardcoded values found in ${componentName}. Token compliance is at ${summary.compliancePercent}%.`,
97
+ summary: message,
91
98
  fixableCount: 0,
92
99
  unfixableCount: 0,
93
100
  };
@@ -28,11 +28,19 @@ import { useAllFigmaUrls } from "./FigmaEmbed.js";
28
28
  import { ActionCapture } from "./ActionCapture.js";
29
29
 
30
30
  // Fragments UI
31
- import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, ScrollArea, Input } from "@fragments/ui";
31
+ import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, ScrollArea, Input, ThemeToggle } from "@fragments/ui";
32
32
 
33
33
  // Icons
34
34
  import { EmptyIcon, ExternalLinkIcon, FigmaIcon, CompareIcon, CheckIcon, LinkIcon, GridIcon, DevicesIcon } from "./Icons.js";
35
35
 
36
+ function GitHubIcon() {
37
+ return (
38
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
39
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
40
+ </svg>
41
+ );
42
+ }
43
+
36
44
  // Hooks
37
45
  import { useAppState } from "../hooks/useAppState.js";
38
46
  import { useViewSettings } from "../hooks/useViewSettings.js";
@@ -475,13 +483,14 @@ function HeaderSearch({ value, onChange, inputRef }: HeaderSearchProps) {
475
483
  aria-label="Search components"
476
484
  size="sm"
477
485
  shortcut="⌘K"
478
- style={{ width: '220px' }}
486
+ style={{ width: '240px' }}
479
487
  />
480
488
  </Header.Search>
481
489
  );
482
490
  }
483
491
 
484
492
  function ViewerHeader({ showHealth, searchQuery, onSearchChange, searchInputRef }: ViewerHeaderProps) {
493
+ const { setTheme, resolvedTheme } = useTheme();
485
494
  return (
486
495
  <Header aria-label="Fragments viewer header">
487
496
  <Header.Trigger />
@@ -493,6 +502,32 @@ function ViewerHeader({ showHealth, searchQuery, onSearchChange, searchInputRef
493
502
  </Header.Brand>
494
503
  <HeaderSearch value={searchQuery} onChange={onSearchChange} inputRef={searchInputRef} />
495
504
  <Header.Spacer />
505
+ <Header.Actions>
506
+ <ThemeToggle
507
+ size="sm"
508
+ value={resolvedTheme}
509
+ onValueChange={(value) => setTheme(value)}
510
+ aria-label={`Theme: ${resolvedTheme}`}
511
+ />
512
+ <a
513
+ href="https://github.com/ConanMcN/fragments"
514
+ target="_blank"
515
+ rel="noopener noreferrer"
516
+ style={{
517
+ display: 'flex',
518
+ alignItems: 'center',
519
+ justifyContent: 'center',
520
+ width: '32px',
521
+ height: '32px',
522
+ borderRadius: 'var(--radius-md, 6px)',
523
+ color: 'var(--text-secondary)',
524
+ transition: 'background-color 150ms ease, color 150ms ease',
525
+ }}
526
+ aria-label="View on GitHub"
527
+ >
528
+ <GitHubIcon />
529
+ </a>
530
+ </Header.Actions>
496
531
  </Header>
497
532
  );
498
533
  }
@@ -510,6 +545,7 @@ function TopToolbar({
510
545
  onSearchChange,
511
546
  searchInputRef,
512
547
  }: TopToolbarProps) {
548
+ const { setTheme, resolvedTheme } = useTheme();
513
549
  return (
514
550
  <Header aria-label="Component preview toolbar">
515
551
  <Header.Trigger />
@@ -595,6 +631,31 @@ function TopToolbar({
595
631
  </Tooltip>
596
632
  </>
597
633
  )}
634
+ <Separator orientation="vertical" style={{ height: '16px' }} />
635
+ <ThemeToggle
636
+ size="sm"
637
+ value={resolvedTheme}
638
+ onValueChange={(value) => setTheme(value)}
639
+ aria-label={`Theme: ${resolvedTheme}`}
640
+ />
641
+ <a
642
+ href="https://github.com/ConanMcN/fragments"
643
+ target="_blank"
644
+ rel="noopener noreferrer"
645
+ style={{
646
+ display: 'flex',
647
+ alignItems: 'center',
648
+ justifyContent: 'center',
649
+ width: '32px',
650
+ height: '32px',
651
+ borderRadius: 'var(--radius-md, 6px)',
652
+ color: 'var(--text-secondary)',
653
+ transition: 'background-color 150ms ease, color 150ms ease',
654
+ }}
655
+ aria-label="View on GitHub"
656
+ >
657
+ <GitHubIcon />
658
+ </a>
598
659
  </Header.Actions>
599
660
  </Header>
600
661
  );
@@ -13,7 +13,7 @@ export function Layout({ leftSidebar, header, children }: LayoutProps) {
13
13
  <AppShell.Header>
14
14
  {header}
15
15
  </AppShell.Header>
16
- <AppShell.Sidebar width="256px" collapsible="icon">
16
+ <AppShell.Sidebar width="260px" collapsible="icon">
17
17
  {leftSidebar}
18
18
  </AppShell.Sidebar>
19
19
  <AppShell.Main padding="none">
@@ -1,8 +1,8 @@
1
1
  import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
2
2
  import type { FragmentDefinition } from '../../core/index.js';
3
3
  import { BRAND } from '../../core/index.js';
4
- import { useTheme } from './ThemeProvider.js';
5
- import { Sidebar, useSidebar, Badge, Text, ThemeToggle } from '@fragments/ui';
4
+
5
+ import { Sidebar, useSidebar, Text, FragmentsLogo } from '@fragments/ui';
6
6
 
7
7
  // Fuzzy matching utility
8
8
  interface FuzzyMatch {
@@ -129,7 +129,6 @@ interface LeftSidebarProps {
129
129
 
130
130
  export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect, showHealth, onHealthClick }: LeftSidebarProps) {
131
131
  const [focusedIndex, setFocusedIndex] = useState(-1);
132
- const { setTheme, resolvedTheme } = useTheme();
133
132
  const { isMobile, setOpen } = useSidebar();
134
133
  const navRef = useRef<HTMLDivElement>(null);
135
134
 
@@ -158,37 +157,19 @@ export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect,
158
157
  return map;
159
158
  }, [searchResults]);
160
159
 
161
- const grouped = useMemo(() => {
162
- const source = searchResults
163
- ? searchResults.map(r => r.item)
164
- : fragments;
165
-
166
- const groups: Record<string, typeof fragments> = {};
167
- for (const item of source) {
168
- // Skip invalid fragments
169
- if (!item.fragment?.meta) continue;
170
- const category = item.fragment.meta.category || 'uncategorized';
171
- if (!groups[category]) groups[category] = [];
172
- groups[category].push(item);
173
- }
174
- return groups;
175
- }, [fragments, searchResults]);
176
-
160
+ // Flat alphabetical list (search results sorted by relevance, otherwise alphabetical)
177
161
  const flatItems = useMemo(() => {
178
- const items: Array<{ path: string; fragment: FragmentDefinition }> = [];
179
- const sortedEntries = Object.entries(grouped).sort(([a], [b]) =>
180
- a.toLowerCase().localeCompare(b.toLowerCase())
181
- );
182
- for (const [, categoryItems] of sortedEntries) {
183
- const sorted = [...categoryItems]
184
- .filter(item => item.fragment?.meta?.name)
185
- .sort((a, b) =>
186
- a.fragment.meta.name.toLowerCase().localeCompare(b.fragment.meta.name.toLowerCase())
187
- );
188
- items.push(...sorted);
162
+ if (searchResults) {
163
+ return searchResults
164
+ .map(r => r.item)
165
+ .filter(item => item.fragment?.meta?.name);
189
166
  }
190
- return items;
191
- }, [grouped]);
167
+ return [...fragments]
168
+ .filter(item => item.fragment?.meta?.name)
169
+ .sort((a, b) =>
170
+ a.fragment.meta.name.toLowerCase().localeCompare(b.fragment.meta.name.toLowerCase())
171
+ );
172
+ }, [fragments, searchResults]);
192
173
 
193
174
  const keyboardItems = useMemo(() => {
194
175
  const componentItems = flatItems.map((item) => ({ type: 'component' as const, path: item.path }));
@@ -257,25 +238,16 @@ export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect,
257
238
  return () => document.removeEventListener('keydown', handleKeyDown);
258
239
  }, [handleKeyDown]);
259
240
 
260
- const sortedEntries = Object.entries(grouped).sort(([a], [b]) =>
261
- a.toLowerCase().localeCompare(b.toLowerCase())
262
- );
263
-
264
241
  return (
265
242
  <>
266
243
  {/* Header */}
267
244
  <Sidebar.Header>
268
245
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
269
- <Text size="sm" weight="medium">{BRAND.name}</Text>
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 />
246
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
247
+ <FragmentsLogo size={20} />
248
+ <Text size="sm" weight="semibold">{BRAND.name}</Text>
278
249
  </div>
250
+ <Sidebar.CollapseToggle />
279
251
  </div>
280
252
  </Sidebar.Header>
281
253
 
@@ -283,7 +255,7 @@ export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect,
283
255
  <div ref={navRef} style={{ flex: 1, minHeight: 0, display: 'flex', overflow: 'hidden' }}>
284
256
  <Sidebar.Nav aria-label="Components">
285
257
  {onHealthClick && (
286
- <Sidebar.Section>
258
+ <Sidebar.Section label="Overview">
287
259
  <Sidebar.Item
288
260
  active={!!showHealth}
289
261
  onClick={handleHealthClick}
@@ -293,36 +265,22 @@ export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect,
293
265
  </Sidebar.Section>
294
266
  )}
295
267
 
296
- {sortedEntries.map(([category, items]) => {
297
- const sortedItems = [...items]
298
- .filter(item => item.fragment?.meta?.name)
299
- .sort((a, b) =>
300
- a.fragment.meta.name.toLowerCase().localeCompare(b.fragment.meta.name.toLowerCase())
301
- );
302
-
303
- return (
304
- <Sidebar.Section key={category} label={category}>
305
- {sortedItems.map((item) => {
306
- const nameIndices = highlightMap.get(item.path) || [];
307
-
308
- return (
309
- <Sidebar.Item
310
- key={item.path}
311
- active={activeFragment === item.path}
312
- onClick={() => handleSelect(item.path)}
313
- >
314
- <HighlightedText
315
- text={item.fragment.meta.name}
316
- indices={nameIndices}
317
- />
318
- </Sidebar.Item>
319
- );
320
- })}
321
- </Sidebar.Section>
322
- );
323
- })}
324
-
325
- {Object.keys(grouped).length === 0 && (
268
+ <Sidebar.Section label="Components">
269
+ {flatItems.map((item) => (
270
+ <Sidebar.Item
271
+ key={item.path}
272
+ active={activeFragment === item.path}
273
+ onClick={() => handleSelect(item.path)}
274
+ >
275
+ <HighlightedText
276
+ text={item.fragment.meta.name}
277
+ indices={highlightMap.get(item.path) || []}
278
+ />
279
+ </Sidebar.Item>
280
+ ))}
281
+ </Sidebar.Section>
282
+
283
+ {flatItems.length === 0 && (
326
284
  <div style={{ padding: '8px 32px', textAlign: 'center' }}>
327
285
  <Text size="sm" color="tertiary">No results</Text>
328
286
  </div>
@@ -332,7 +290,7 @@ export function LeftSidebar({ fragments, activeFragment, searchQuery, onSelect,
332
290
 
333
291
  {/* Footer */}
334
292
  <Sidebar.Footer>
335
- <Badge size="sm">{fragments.length} components</Badge>
293
+ <Text size="xs" color="tertiary">{fragments.length} components</Text>
336
294
  </Sidebar.Footer>
337
295
  </>
338
296
  );
@@ -13,7 +13,7 @@
13
13
  html, body {
14
14
  margin: 0;
15
15
  padding: 0;
16
- font-family: var(--fui-font-sans, 'Geist Sans', Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
16
+ font-family: var(--fui-font-sans, Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
17
17
  -webkit-font-smoothing: antialiased;
18
18
  -moz-osx-font-smoothing: grayscale;
19
19
  width: 100%;