@dfosco/storyboard-core 3.3.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/storyboard-ui.css +9 -1
  2. package/dist/storyboard-ui.js +14701 -11431
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +1 -1
  6. package/scaffold/toolbar.config.json +2 -2
  7. package/src/CanvasCreateMenu.svelte +1 -1
  8. package/src/CanvasZoomControl.svelte +105 -0
  9. package/src/CommandMenu.svelte +87 -25
  10. package/src/CoreUIBar.svelte +352 -346
  11. package/src/CreateMenuButton.svelte +6 -2
  12. package/src/InspectorPanel.svelte +87 -37
  13. package/src/SidePanel.svelte +1 -1
  14. package/src/ThemeMenuButton.svelte +35 -3
  15. package/src/commandActions.js +14 -0
  16. package/src/core-ui-colors.css +30 -2
  17. package/src/devtools.js +7 -1
  18. package/src/index.js +10 -1
  19. package/src/inspector/fiberWalker.js +49 -6
  20. package/src/inspector/highlighter.js +145 -29
  21. package/src/lib/components/ui/button/button.svelte +1 -1
  22. package/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +1 -1
  23. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +1 -1
  24. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +1 -1
  25. package/src/lib/components/ui/trigger-button/trigger-button.svelte +31 -3
  26. package/src/modes.css +8 -0
  27. package/src/mountStoryboardCore.js +15 -1
  28. package/src/stores/themeStore.ts +66 -0
  29. package/src/svelte-plugin-ui/components/Viewfinder.svelte +16 -11
  30. package/src/toolRegistry.js +226 -0
  31. package/src/toolStateStore.js +180 -0
  32. package/src/toolStateStore.test.js +204 -0
  33. package/src/toolbarConfigStore.js +135 -0
  34. package/src/tools/handlers/canvasAddWidget.js +11 -0
  35. package/src/tools/handlers/canvasZoom.js +34 -0
  36. package/src/tools/handlers/comments.js +16 -0
  37. package/src/tools/handlers/create.js +39 -0
  38. package/src/tools/handlers/devtools.js +80 -0
  39. package/src/tools/handlers/docs.js +11 -0
  40. package/src/tools/handlers/featureFlags.js +21 -0
  41. package/src/tools/handlers/flows.js +59 -0
  42. package/src/tools/handlers/inspector.js +19 -0
  43. package/src/tools/handlers/theme.js +9 -0
  44. package/src/tools/registry.js +21 -0
  45. package/src/tools/surfaces/canvasToolbar.js +10 -0
  46. package/src/tools/surfaces/commandList.js +10 -0
  47. package/src/tools/surfaces/mainToolbar.js +11 -0
  48. package/src/tools/surfaces/registry.js +19 -0
  49. package/src/vite/server-plugin.js +54 -5
  50. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  51. package/src/workshop/features/createPrototype/server.js +10 -15
  52. package/toolbar.config.json +107 -48
@@ -33,11 +33,15 @@
33
33
 
34
34
  interface Props {
35
35
  features?: CreateMenuFeature[]
36
+ data?: { features?: CreateMenuFeature[] }
36
37
  config?: CreateMenuConfig
37
38
  tabindex?: number
38
39
  }
39
40
 
40
- let { features = [], config = { label: 'Create' }, tabindex }: Props = $props()
41
+ let { features: featuresProp = [], data, config = { label: 'Create' }, tabindex }: Props = $props()
42
+
43
+ // Support both direct `features` prop (legacy) and `data.features` (generic toolbar)
44
+ const features = $derived(featuresProp.length > 0 ? featuresProp : (data?.features || []))
41
45
 
42
46
  const menuWidth = $derived((config as any).menuWidth || null)
43
47
 
@@ -110,7 +114,7 @@
110
114
  <DropdownMenu.Separator />
111
115
  {:else if action.type === 'footer'}
112
116
  <DropdownMenu.Separator />
113
- <div class="px-2 py-1.5 text-xs text-muted-foreground">{action.label}</div>
117
+ <div class="px-2 py-1.5 text-xs text-muted-foreground flex flex-row items-baseline"><span class="inline-flex w-2 h-2 rounded-full mr-1.5" style="background: hsl(137, 66%, 30%)"></span>Only available in dev environment</div>
114
118
  {:else if action._feature}
115
119
  <DropdownMenu.Item onclick={() => showOverlay(action._feature.overlayId)}>
116
120
  {action.label || action._feature.label}
@@ -109,21 +109,23 @@
109
109
  return null
110
110
  }
111
111
 
112
+ const _isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
113
+
112
114
  /**
113
115
  * Fetch source file content — uses dev middleware in dev, static JSON in prod.
114
- * Uses runtime detection (not import.meta.env.DEV) so this works in the
115
- * pre-compiled UI bundle where compile-time env vars are baked in.
116
116
  */
117
117
  async function fetchSourceContent(filePath) {
118
- // Try the dev middleware first works when Vite server plugin is active
119
- try {
120
- const res = await fetch(`/_storyboard/docs/source?path=${encodeURIComponent(filePath)}`)
121
- if (res.ok) {
122
- const json = await res.json()
123
- return json?.content || ''
124
- }
125
- } catch {}
126
- // Fall back to static inspector JSON for deployed/production builds
118
+ // In local dev, use the live middleware (reads from disk)
119
+ if (_isLocalDev) {
120
+ try {
121
+ const res = await fetch(`/_storyboard/docs/source?path=${encodeURIComponent(filePath)}`)
122
+ if (res.ok) {
123
+ const json = await res.json()
124
+ return json?.content || ''
125
+ }
126
+ } catch {}
127
+ }
128
+ // In production (or if dev middleware failed), use static build-time JSON
127
129
  const data = await loadStaticData()
128
130
  return data?.sources?.[filePath] || ''
129
131
  }
@@ -343,7 +345,7 @@
343
345
  lang: getLang(path),
344
346
  theme: 'github-dark',
345
347
  decorations: matchedLine > 0
346
- ? [{ start: { line: matchedLine - 1, character: 0 }, end: { line: matchedLine, character: 0 }, properties: { class: 'highlighted-line' } }]
348
+ ? [{ start: { line: matchedLine - 1, character: 0 }, end: { line: matchedLine - 1, character: Infinity }, properties: { class: 'highlighted-line' } }]
347
349
  : [],
348
350
  })
349
351
  } catch {
@@ -427,26 +429,28 @@
427
429
  onDeactivate: handleDeactivate,
428
430
  })
429
431
 
430
- // Pre-fetch file list and repo info FIRST (needed for source resolution)
431
- // Try dev middleware first, fall back to static JSON
432
+ // Pre-fetch file list and repo info
433
+ // In local dev, try dev middleware; in production, go straight to static JSON
432
434
  let filesLoaded = false
433
- try {
434
- const [filesRes, repoRes] = await Promise.all([
435
- fetch('/_storyboard/docs/files'),
436
- fetch('/_storyboard/docs/repo'),
437
- ])
438
- if (filesRes.ok) {
439
- const data = await filesRes.json()
440
- knownFiles = data.files || []
441
- filesLoaded = true
442
- }
443
- if (repoRes.ok) {
444
- repoInfo = await repoRes.json()
445
- }
446
- } catch {}
435
+ if (_isLocalDev) {
436
+ try {
437
+ const [filesRes, repoRes] = await Promise.all([
438
+ fetch('/_storyboard/docs/files'),
439
+ fetch('/_storyboard/docs/repo'),
440
+ ])
441
+ if (filesRes.ok) {
442
+ const data = await filesRes.json()
443
+ knownFiles = data.files || []
444
+ filesLoaded = true
445
+ }
446
+ if (repoRes.ok) {
447
+ repoInfo = await repoRes.json()
448
+ }
449
+ } catch {}
450
+ }
447
451
 
448
452
  if (!filesLoaded) {
449
- // Fall back to static build-time JSON
453
+ // Use static build-time JSON
450
454
  const data = await loadStaticData()
451
455
  if (data) {
452
456
  knownFiles = data.files || []
@@ -572,9 +576,9 @@
572
576
  {:else if sourceCode}
573
577
  <div class="flex-1 min-h-0 overflow-y-auto source-scroll-container" bind:this={sourceContainer}>
574
578
  {#if highlightedHtml}
575
- <div class="shiki-wrapper">{@html highlightedHtml}</div>
579
+ <div class="code-wrapper line-numbers">{@html highlightedHtml}</div>
576
580
  {:else}
577
- <pre class="m-0 text-xs leading-relaxed inspector-mono source-pre" style:color="#c9d1d9">{sourceCode}</pre>
581
+ <pre class="m-0 text-xs leading-relaxed inspector-mono source-pre line-numbers"><code>{#each sourceCode.split('\n') as line, i}<span class="line{matchedLine > 0 && i + 1 === matchedLine ? ' highlighted-line' : ''}">{line}</span>{#if i < sourceCode.split('\n').length - 1}{'\n'}{/if}{/each}</code></pre>
578
582
  {/if}
579
583
  </div>
580
584
  {:else}
@@ -627,9 +631,54 @@
627
631
  .source-pre {
628
632
  background: transparent;
629
633
  tab-size: 2;
634
+ padding: 12px 0;
635
+ color: #c9d1d9;
636
+ overflow-x: auto;
637
+ }
638
+
639
+ .source-pre code {
640
+ font-family: inherit;
641
+ display: block;
642
+ }
643
+
644
+ .source-pre .line {
645
+ padding: 0 12px 0 0;
646
+ display: inline-block;
647
+ width: 100%;
648
+ min-height: 1.5em;
649
+ }
650
+
651
+ .source-pre .line:hover {
652
+ background: rgba(255, 255, 255, 0.04);
653
+ }
654
+
655
+ .source-pre :global(.highlighted-line) {
656
+ background: color-mix(in srgb, var(--color-purple, #7655a4) 20%, transparent);
657
+ border-left: 2px solid var(--color-purple, #7655a4);
658
+ padding-left: 10px;
659
+ }
660
+
661
+ /* Line numbers via CSS counters — works for both highlight.js and plain-text */
662
+ .line-numbers :global(code) {
663
+ counter-reset: line;
664
+ }
665
+
666
+ .line-numbers :global(.line) {
667
+ padding-left: 0 !important;
668
+ }
669
+
670
+ .line-numbers :global(.line::before) {
671
+ counter-increment: line;
672
+ content: counter(line);
673
+ display: inline-block;
674
+ width: 3.5ch;
675
+ margin-right: 1.5ch;
676
+ text-align: right;
677
+ color: #484f58;
678
+ user-select: none;
630
679
  }
631
680
 
632
- .shiki-wrapper :global(pre) {
681
+ .code-wrapper :global(pre) {
633
682
  margin: 0;
634
683
  padding: 12px 0;
635
684
  font-size: 12px;
@@ -640,25 +689,26 @@
640
689
  overflow-x: auto;
641
690
  }
642
691
 
643
- .shiki-wrapper :global(code) {
692
+ .code-wrapper :global(code) {
644
693
  font-family: inherit;
645
694
  display: block;
646
695
  }
647
696
 
648
- .shiki-wrapper :global(.line) {
649
- padding: 0 12px;
697
+ .code-wrapper :global(.line) {
698
+ padding: 0 12px 0 0;
650
699
  display: inline-block;
651
700
  width: 100%;
652
701
  min-height: 1.5em;
653
702
  }
654
703
 
655
- .shiki-wrapper :global(.line:hover) {
704
+ .code-wrapper :global(.line:hover) {
656
705
  background: rgba(255, 255, 255, 0.04);
657
706
  }
658
707
 
659
- .shiki-wrapper :global(.highlighted-line) {
708
+ .code-wrapper :global(.highlighted-line) {
660
709
  background: color-mix(in srgb, var(--color-purple, #7655a4) 20%, transparent);
661
710
  border-left: 2px solid var(--color-purple, #7655a4);
711
+ padding-left: 10px;
662
712
  }
663
713
 
664
714
  /* Force dark chrome on the code block — independent of page theme */
@@ -320,7 +320,7 @@
320
320
  left: 0;
321
321
  right: 0;
322
322
  height: 3px;
323
- background: var(--mode-color, var(--borderColor-default, var(--color-border, #d0d7de)));
323
+ /* background: var(--mode-color, var(--borderColor-default, var(--color-border, #d0d7de))); */
324
324
  }
325
325
 
326
326
  /* Drag handle — side mode (left edge, vertical) */
@@ -1,15 +1,15 @@
1
1
  <!--
2
2
  ThemeMenuButton — toolbar dropdown for switching the app color scheme.
3
3
 
4
- Renders a radio group of theme options (System, Light, Dark, etc.)
5
- and persists the selection via the themeStore.
4
+ Renders a radio group of theme options (System, Light, Dark, etc.),
5
+ followed by a separator and "Theme settings" submenu with sync toggles.
6
6
  -->
7
7
 
8
8
  <script lang="ts">
9
9
  import { TriggerButton } from './lib/components/ui/trigger-button/index.js'
10
10
  import * as DropdownMenu from './lib/components/ui/dropdown-menu/index.js'
11
11
  import Icon from './svelte-plugin-ui/components/Icon.svelte'
12
- import { themeState, setTheme, THEMES, type ThemeValue } from './stores/themeStore.js'
12
+ import { themeState, setTheme, THEMES, type ThemeValue, themeSyncState, setThemeSyncTarget, type ThemeSyncTargets } from './stores/themeStore.js'
13
13
 
14
14
  interface Props {
15
15
  config?: {
@@ -30,6 +30,11 @@
30
30
  setTheme(value)
31
31
  menuOpen = false
32
32
  }
33
+
34
+ function handleSyncToggle(e: Event, target: keyof ThemeSyncTargets) {
35
+ e.preventDefault()
36
+ setThemeSyncTarget(target, !$themeSyncState[target])
37
+ }
33
38
  </script>
34
39
 
35
40
  <DropdownMenu.Root bind:open={menuOpen}>
@@ -62,5 +67,32 @@
62
67
  </DropdownMenu.RadioItem>
63
68
  {/each}
64
69
  </DropdownMenu.RadioGroup>
70
+
71
+ <DropdownMenu.Separator />
72
+
73
+ <DropdownMenu.Sub>
74
+ <DropdownMenu.SubTrigger>Theme settings</DropdownMenu.SubTrigger>
75
+ <DropdownMenu.SubContent class="min-w-[180px]">
76
+ <DropdownMenu.Label>Apply theme to</DropdownMenu.Label>
77
+ <DropdownMenu.CheckboxItem
78
+ checked={$themeSyncState.prototype}
79
+ onSelect={(e) => handleSyncToggle(e, 'prototype')}
80
+ >
81
+ Prototype
82
+ </DropdownMenu.CheckboxItem>
83
+ <DropdownMenu.CheckboxItem
84
+ checked={$themeSyncState.toolbar}
85
+ onSelect={(e) => handleSyncToggle(e, 'toolbar')}
86
+ >
87
+ Toolbar
88
+ </DropdownMenu.CheckboxItem>
89
+ <DropdownMenu.CheckboxItem
90
+ checked={$themeSyncState.codeBoxes}
91
+ onSelect={(e) => handleSyncToggle(e, 'codeBoxes')}
92
+ >
93
+ Code boxes
94
+ </DropdownMenu.CheckboxItem>
95
+ </DropdownMenu.SubContent>
96
+ </DropdownMenu.Sub>
65
97
  </DropdownMenu.Content>
66
98
  </DropdownMenu.Root>
@@ -192,6 +192,8 @@ export function getActionsForMode(mode) {
192
192
  label: a.label,
193
193
  type: a.type || 'default',
194
194
  url: a.url || null,
195
+ toolKey: a.toolKey || null,
196
+ localOnly: a.localOnly || false,
195
197
  handler,
196
198
  active,
197
199
  }
@@ -224,6 +226,18 @@ export function getActionChildren(id) {
224
226
  return handler.getChildren()
225
227
  }
226
228
 
229
+ /**
230
+ * Check if a handler provides dynamic children (getChildren).
231
+ * Used by CoreUIBar to distinguish action-menu tools (gate on children count)
232
+ * from custom-component menus (always visible, render their own content).
233
+ * @param {string} id
234
+ * @returns {boolean}
235
+ */
236
+ export function hasChildrenProvider(id) {
237
+ const handler = _handlers.get(id)
238
+ return !!handler?.getChildren
239
+ }
240
+
227
241
  // ---------------------------------------------------------------------------
228
242
  // Reactivity
229
243
  // ---------------------------------------------------------------------------
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Core UI Colors — always light-mode.
2
+ * Core UI Colors — toolbar theme tokens.
3
3
  *
4
4
  * The CoreUIBar and its children (trigger buttons, dropdowns, menus)
5
- * are locked to light-mode colors regardless of the page's theme.
5
+ * default to light-mode colors. When the user enables toolbar theme sync,
6
+ * `data-sb-toolbar-theme` switches to the active theme and dark tokens
7
+ * are applied.
6
8
  *
7
9
  * These override the shadcn/Tailwind design tokens within the
8
10
  * [data-core-ui-bar] scope so that CSS custom-property dark-mode
@@ -16,6 +18,7 @@
16
18
  * classes) so its scoped CSS has full control over background/color.
17
19
  */
18
20
 
21
+ /* Default: light tokens */
19
22
  [data-core-ui-bar],
20
23
  [data-slot="dropdown-menu-content"],
21
24
  [data-slot="dropdown-menu-sub-content"] {
@@ -37,3 +40,28 @@
37
40
  --color-border: hsl(214.3 31.8% 91.4%);
38
41
  --color-input: hsl(214.3 31.8% 91.4%);
39
42
  }
43
+
44
+ /* Dark tokens — applied when toolbar syncs with a dark theme.
45
+ * Uses Primer-aligned colors (matching base.css --sb-* dark tokens)
46
+ * instead of generic shadcn dark values.
47
+ */
48
+ :root[data-sb-toolbar-theme^="dark"] [data-core-ui-bar],
49
+ :root[data-sb-toolbar-theme^="dark"] [data-slot="dropdown-menu-content"],
50
+ :root[data-sb-toolbar-theme^="dark"] [data-slot="dropdown-menu-sub-content"] {
51
+ color-scheme: dark;
52
+
53
+ --color-background: #161b22;
54
+ --color-foreground: #e6edf3;
55
+ --color-popover: #161b22;
56
+ --color-popover-foreground: #e6edf3;
57
+ --color-primary: #e6edf3;
58
+ --color-primary-foreground: #161b22;
59
+ --color-secondary: #21262d;
60
+ --color-secondary-foreground: #e6edf3;
61
+ --color-muted: #21262d;
62
+ --color-muted-foreground: #8b949e;
63
+ --color-accent: #21262d;
64
+ --color-accent-foreground: #e6edf3;
65
+ --color-border: #30363d;
66
+ --color-input: #30363d;
67
+ }
package/src/devtools.js CHANGED
@@ -22,6 +22,8 @@ let skipLink = null
22
22
  * @param {object} [options]
23
23
  * @param {HTMLElement} [options.container=document.body] - Where to mount
24
24
  * @param {string} [options.basePath='/'] - Base URL path
25
+ * @param {object} [options.toolbarConfig] - Merged toolbar config
26
+ * @param {Record<string, () => Promise<any>>} [options.customHandlers] - Custom tool handlers
25
27
  */
26
28
  export async function mountDevTools(options = {}) {
27
29
  const container = options.container || document.body
@@ -108,7 +110,11 @@ export async function mountDevTools(options = {}) {
108
110
 
109
111
  instance = mount(CoreUIBar, {
110
112
  target: wrapper,
111
- props: { basePath, toolbarConfig: options.toolbarConfig },
113
+ props: {
114
+ basePath,
115
+ toolbarConfig: options.toolbarConfig,
116
+ customHandlers: options.customHandlers,
117
+ },
112
118
  })
113
119
  }
114
120
 
package/src/index.js CHANGED
@@ -68,7 +68,7 @@ export { resolveSceneRoute, getSceneMeta } from './viewfinder.js'
68
68
  export { initFeatureFlags, getFlag, setFlag, toggleFlag, getAllFlags, resetFlags, getFlagKeys, syncFlagBodyClasses } from './featureFlags.js'
69
69
 
70
70
  // Command actions (config-driven command menu entries)
71
- export { initCommandActions, registerCommandAction, unregisterCommandAction, setDynamicActions, clearDynamicActions, getActionsForMode, executeAction, getActionChildren, subscribeToCommandActions, getCommandActionsSnapshot, setRoutingBasePath, isExcludedByRoute } from './commandActions.js'
71
+ export { initCommandActions, registerCommandAction, unregisterCommandAction, setDynamicActions, clearDynamicActions, getActionsForMode, executeAction, getActionChildren, hasChildrenProvider, subscribeToCommandActions, getCommandActionsSnapshot, setRoutingBasePath, isExcludedByRoute } from './commandActions.js'
72
72
 
73
73
  // Plugin configuration
74
74
  export { initPlugins, isPluginEnabled, getPluginsConfig } from './plugins.js'
@@ -76,5 +76,14 @@ export { initPlugins, isPluginEnabled, getPluginsConfig } from './plugins.js'
76
76
  // UI config (project-level chrome overrides)
77
77
  export { initUIConfig, isMenuHidden, getHiddenItems } from './uiConfig.js'
78
78
 
79
+ // Tool registry (declarative tool system)
80
+ export { initToolRegistry, registerToolModule, setToolComponent, setToolGuardResult, getToolComponent, getToolModule, getToolsForToolbar, getToolConfig, getAllToolConfigs, subscribeToToolRegistry, getToolRegistrySnapshot } from './toolRegistry.js'
81
+
82
+ // Toolbar config store (reactive layered overrides: core → custom → prototype → user)
83
+ export { initToolbarConfig, setPrototypeToolbarConfig, clearPrototypeToolbarConfig, getToolbarConfig, subscribeToToolbarConfig, getToolbarConfigSnapshot } from './toolbarConfigStore.js'
84
+
85
+ // Toolbar tool state management (runtime state for toolbar tools)
86
+ export { TOOL_STATES, initToolbarToolStates, setToolbarToolState, getToolbarToolState, isToolbarToolLocalOnly, subscribeToToolbarToolStates, getToolbarToolStatesSnapshot } from './toolStateStore.js'
87
+
79
88
  // Comments system
80
89
  export { initCommentsConfig, getCommentsConfig, isCommentsEnabled } from './comments/config.js'
@@ -50,6 +50,8 @@ function isUserComponent(fiber) {
50
50
  /**
51
51
  * Derive a human-readable name from a fiber's type.
52
52
  * Handles plain components, forwardRef, and memo wrappers.
53
+ * Skips minified names (single-char or generic) in favor of 'ForwardRef'/'Memo'
54
+ * so the caller can try walking up the tree for a better name.
53
55
  *
54
56
  * @param {object} fiber
55
57
  * @returns {string}
@@ -59,20 +61,43 @@ function getComponentName(fiber) {
59
61
  const t = fiber.type
60
62
  // Plain function/class
61
63
  if (typeof t === 'function') return t.displayName || t.name || 'Anonymous'
62
- // forwardRef
64
+ // forwardRef / memo wrapper objects
63
65
  if (typeof t === 'object' && t !== null) {
64
66
  if (t.displayName) return t.displayName
65
- if (typeof t.render === 'function') return t.render.displayName || t.render.name || 'ForwardRef'
66
- // memo
67
+ // forwardRef: { render: fn }
68
+ if (typeof t.render === 'function') {
69
+ const name = t.render.displayName || t.render.name
70
+ return isUsableName(name) ? name : 'ForwardRef'
71
+ }
72
+ // memo: { type: ... }
67
73
  if (t.type) {
68
74
  const inner = t.type
69
- if (typeof inner === 'function') return inner.displayName || inner.name || 'Memo'
70
- if (typeof inner === 'object' && inner.render) return inner.render.displayName || inner.render.name || 'Memo'
75
+ if (typeof inner === 'function') {
76
+ const name = inner.displayName || inner.name
77
+ return isUsableName(name) ? name : 'Memo'
78
+ }
79
+ // memo(forwardRef)
80
+ if (typeof inner === 'object' && inner.render) {
81
+ if (inner.displayName) return inner.displayName
82
+ const name = inner.render.displayName || inner.render.name
83
+ return isUsableName(name) ? name : 'ForwardRef'
84
+ }
71
85
  }
72
86
  }
73
87
  return 'Unknown'
74
88
  }
75
89
 
90
+ /**
91
+ * Check if a resolved component name is usable (not minified).
92
+ * Minified names are typically 1-2 chars or all lowercase short strings.
93
+ */
94
+ function isUsableName(name) {
95
+ if (!name) return false
96
+ // Single-char names (e, t, r, n) are almost certainly minified
97
+ if (name.length <= 2) return false
98
+ return true
99
+ }
100
+
76
101
  /**
77
102
  * Extract debug source info from a fiber (dev builds only).
78
103
  *
@@ -133,10 +158,28 @@ export function getComponentInfo(fiber) {
133
158
 
134
159
  if (!current) return null
135
160
 
161
+ let name = getComponentName(current)
162
+
163
+ // If we got a generic name (ForwardRef, Memo), try walking up
164
+ // to find the nearest ancestor with a real component name
165
+ if (name === 'ForwardRef' || name === 'Memo') {
166
+ let ancestor = current.return
167
+ while (ancestor) {
168
+ if (isUserComponent(ancestor)) {
169
+ const ancestorName = getComponentName(ancestor)
170
+ if (ancestorName !== 'ForwardRef' && ancestorName !== 'Memo' && ancestorName !== 'Unknown') {
171
+ name = ancestorName
172
+ break
173
+ }
174
+ }
175
+ ancestor = ancestor.return
176
+ }
177
+ }
178
+
136
179
  const ownerFiber = current._debugOwner ?? null
137
180
 
138
181
  return {
139
- name: getComponentName(current),
182
+ name,
140
183
  props: current.memoizedProps ?? {},
141
184
  source: getDebugSource(current),
142
185
  owner: ownerFiber ? getComponentName(ownerFiber) : null,