@dfosco/storyboard-react 4.0.0-beta.26 → 4.0.0-beta.28

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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/canvas/CanvasPage.bridge.test.jsx +87 -2
  3. package/src/canvas/CanvasPage.jsx +161 -18
  4. package/src/canvas/CanvasPage.module.css +54 -0
  5. package/src/canvas/CanvasPage.multiselect.test.jsx +2 -0
  6. package/src/canvas/canvasApi.js +8 -0
  7. package/src/canvas/widgets/FigmaEmbed.jsx +42 -7
  8. package/src/canvas/widgets/FigmaEmbed.module.css +21 -0
  9. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  10. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  11. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  12. package/src/canvas/widgets/MarkdownBlock.jsx +2 -1
  13. package/src/canvas/widgets/MarkdownBlock.module.css +34 -11
  14. package/src/canvas/widgets/PrototypeEmbed.jsx +101 -44
  15. package/src/canvas/widgets/PrototypeEmbed.module.css +1 -0
  16. package/src/canvas/widgets/StoryWidget.jsx +86 -42
  17. package/src/canvas/widgets/StoryWidget.module.css +1 -0
  18. package/src/canvas/widgets/WidgetChrome.jsx +20 -1
  19. package/src/canvas/widgets/embedInteraction.test.jsx +16 -18
  20. package/src/canvas/widgets/embedTheme.js +37 -1
  21. package/src/canvas/widgets/githubUrl.js +82 -0
  22. package/src/canvas/widgets/githubUrl.test.js +74 -0
  23. package/src/canvas/widgets/refreshQueue.js +108 -0
  24. package/src/canvas/widgets/snapshotDisplay.test.jsx +60 -90
  25. package/src/canvas/widgets/useSnapshotCapture.js +38 -139
  26. package/src/canvas/widgets/useSnapshotCapture.test.jsx +30 -91
  27. package/src/canvas/widgets/widgetConfig.test.js +1 -1
  28. package/src/story/StoryPage.jsx +25 -60
  29. package/src/story/StoryPage.module.css +0 -55
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.26",
3
+ "version": "4.0.0-beta.28",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.26",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.26",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.28",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.28",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -1,7 +1,7 @@
1
- import { fireEvent, render, screen, act } from '@testing-library/react'
1
+ import { fireEvent, render, screen, act, waitFor } from '@testing-library/react'
2
2
  import CanvasPage from './CanvasPage.jsx'
3
3
  import { getCanvasPrimerAttrs, getCanvasThemeVars } from './canvasTheme.js'
4
- import { updateCanvas } from './canvasApi.js'
4
+ import { addWidget, checkGitHubCliAvailable, fetchGitHubEmbed, updateCanvas } from './canvasApi.js'
5
5
 
6
6
  vi.mock('@dfosco/tiny-canvas', () => ({
7
7
  Canvas: ({ children, onDragEnd }) => (
@@ -77,6 +77,8 @@ vi.mock('./widgets/figmaUrl.js', () => ({
77
77
 
78
78
  vi.mock('./canvasApi.js', () => ({
79
79
  addWidget: vi.fn(),
80
+ checkGitHubCliAvailable: vi.fn(),
81
+ fetchGitHubEmbed: vi.fn(),
80
82
  updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
81
83
  removeWidget: vi.fn(),
82
84
  uploadImage: vi.fn(),
@@ -94,9 +96,26 @@ vi.mock('./useUndoRedo.js', () => ({
94
96
  }))
95
97
 
96
98
  describe('CanvasPage canvas bridge', () => {
99
+ function dispatchTextPaste(text) {
100
+ const event = new Event('paste', { bubbles: true, cancelable: true })
101
+ Object.defineProperty(event, 'clipboardData', {
102
+ value: {
103
+ getData: (type) => (type === 'text/plain' ? text : ''),
104
+ items: [],
105
+ },
106
+ })
107
+ document.dispatchEvent(event)
108
+ }
109
+
97
110
  beforeEach(() => {
98
111
  delete window.__storyboardCanvasBridgeState
99
112
  vi.clearAllMocks()
113
+ addWidget.mockResolvedValue({
114
+ success: true,
115
+ widget: { id: 'widget-link', type: 'link-preview', position: { x: 0, y: 0 }, props: {} },
116
+ })
117
+ checkGitHubCliAvailable.mockResolvedValue({ available: true })
118
+ fetchGitHubEmbed.mockResolvedValue({ success: false })
100
119
  })
101
120
 
102
121
  it('publishes bridge state and responds to status requests', () => {
@@ -145,6 +164,72 @@ describe('CanvasPage canvas bridge', () => {
145
164
  document.removeEventListener('storyboard:canvas:unmounted', unmountedHandler)
146
165
  })
147
166
 
167
+ it('shows gh install banner when gh is unavailable during GitHub URL paste', async () => {
168
+ checkGitHubCliAvailable.mockResolvedValue({
169
+ available: false,
170
+ installUrl: 'https://github.com/cli/cli',
171
+ })
172
+
173
+ render(<CanvasPage name="design-overview" />)
174
+
175
+ await act(async () => {
176
+ dispatchTextPaste('https://github.com/dfosco/storyboard/issues/42')
177
+ await Promise.resolve()
178
+ })
179
+
180
+ await waitFor(() => {
181
+ expect(addWidget).toHaveBeenCalled()
182
+ })
183
+ expect(fetchGitHubEmbed).not.toHaveBeenCalled()
184
+ expect(screen.getByRole('link', { name: 'Install GitHub CLI' })).toHaveAttribute(
185
+ 'href',
186
+ 'https://github.com/cli/cli',
187
+ )
188
+ })
189
+
190
+ it('hydrates GitHub metadata when gh is available during paste', async () => {
191
+ checkGitHubCliAvailable.mockResolvedValue({ available: true })
192
+ fetchGitHubEmbed.mockResolvedValue({
193
+ success: true,
194
+ snapshot: {
195
+ kind: 'issue',
196
+ parentKind: 'issue',
197
+ context: 'GitHub · dfosco/storyboard · Issue #42',
198
+ title: '#42 Ship GitHub embeds',
199
+ body: 'Details from GitHub',
200
+ authors: ['dfosco'],
201
+ createdAt: '2026-01-01T00:00:00Z',
202
+ updatedAt: '2026-01-02T00:00:00Z',
203
+ },
204
+ })
205
+
206
+ render(<CanvasPage name="design-overview" />)
207
+
208
+ await act(async () => {
209
+ dispatchTextPaste('https://github.com/dfosco/storyboard/issues/42')
210
+ await Promise.resolve()
211
+ })
212
+
213
+ await waitFor(() => {
214
+ expect(fetchGitHubEmbed).toHaveBeenCalledWith('https://github.com/dfosco/storyboard/issues/42')
215
+ })
216
+ expect(addWidget).toHaveBeenCalledWith(
217
+ 'design-overview',
218
+ expect.objectContaining({
219
+ type: 'link-preview',
220
+ props: expect.objectContaining({
221
+ title: '#42 Ship GitHub embeds',
222
+ width: 580,
223
+ height: 400,
224
+ github: expect.objectContaining({
225
+ context: 'GitHub · dfosco/storyboard · Issue #42',
226
+ body: 'Details from GitHub',
227
+ }),
228
+ }),
229
+ }),
230
+ )
231
+ })
232
+
148
233
  it.skip('persists dragged JSON widgets and JSX sources to canvas JSONL via update API', async () => {
149
234
  render(<CanvasPage canvasId="design-overview" />)
150
235
 
@@ -9,10 +9,19 @@ import { schemas, getDefaults } from './widgets/widgetProps.js'
9
9
  import { getFeatures, isResizable } from './widgets/widgetConfig.js'
10
10
  import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
11
  import { getPasteRules } from '@dfosco/storyboard-core'
12
+ import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
12
13
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
14
  import ComponentWidget from './widgets/ComponentWidget.jsx'
14
15
  import useUndoRedo from './useUndoRedo.js'
15
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage, getCanvas as getCanvasApi } from './canvasApi.js'
16
+ import {
17
+ addWidget as addWidgetApi,
18
+ checkGitHubCliAvailable,
19
+ fetchGitHubEmbed,
20
+ getCanvas as getCanvasApi,
21
+ removeWidget as removeWidgetApi,
22
+ updateCanvas,
23
+ uploadImage,
24
+ } from './canvasApi.js'
16
25
  import PageSelector from './PageSelector.jsx'
17
26
  import { stories as storyIndex } from 'virtual:storyboard-data-index'
18
27
  import styles from './CanvasPage.module.css'
@@ -24,6 +33,7 @@ const ZOOM_MAX = 200
24
33
  const VIEWPORT_TTL_MS = 15 * 60 * 1000
25
34
 
26
35
  const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
36
+ const GH_INSTALL_URL = 'https://github.com/cli/cli'
27
37
 
28
38
  /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
29
39
  const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
@@ -42,7 +52,7 @@ function getToolbarColorMode(theme) {
42
52
 
43
53
  function resolveCanvasThemeFromStorage() {
44
54
  if (typeof localStorage === 'undefined') return 'light'
45
- let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
55
+ let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
46
56
  try {
47
57
  const rawSync = localStorage.getItem('sb-theme-sync')
48
58
  if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
@@ -248,7 +258,7 @@ function computeCanvasBounds(widgets, componentEntries) {
248
258
  }
249
259
 
250
260
  /** Renders a single JSON-defined widget by type lookup. */
251
- function WidgetRenderer({ widget, onUpdate, widgetRef }) {
261
+ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
252
262
  const Component = getWidgetComponent(widget.type)
253
263
  if (!Component) {
254
264
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
@@ -256,7 +266,14 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
256
266
  }
257
267
  const resizable = isResizable(widget.type) && !!onUpdate
258
268
  // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
259
- const elementProps = { id: widget.id, props: widget.props, onUpdate, resizable }
269
+ const elementProps = {
270
+ id: widget.id,
271
+ props: widget.props,
272
+ onUpdate,
273
+ resizable,
274
+ onRefreshGitHub,
275
+ canRefreshGitHub,
276
+ }
260
277
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
261
278
  elementProps.ref = widgetRef
262
279
  }
@@ -278,10 +295,31 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
278
295
  onUpdate,
279
296
  onRemove,
280
297
  onCopy,
298
+ onRefreshGitHub,
299
+ canRefreshGitHub,
281
300
  readOnly,
282
301
  }) {
283
302
  const widgetRef = useRef(null)
284
- const features = getFeatures(widget.type, { isLocalDev: !readOnly })
303
+ const rawFeatures = getFeatures(widget.type, { isLocalDev: !readOnly })
304
+
305
+ // Dynamically adjust features based on widget state
306
+ const features = useMemo(() => {
307
+ const isGitHub = !!widget.props?.github
308
+ return rawFeatures.map((f) => {
309
+ // Toggle collapse label and hide when content is short (no github = no collapse)
310
+ if (f.action === 'toggle-collapse') {
311
+ if (!isGitHub) return null
312
+ return {
313
+ ...f,
314
+ label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
315
+ icon: widget.props?.collapsed ? 'unfold' : 'fold',
316
+ }
317
+ }
318
+ // Hide refresh-github for non-GitHub link previews
319
+ if (f.action === 'refresh-github' && !isGitHub) return null
320
+ return f
321
+ }).filter(Boolean)
322
+ }, [rawFeatures, widget.props?.github, widget.props?.collapsed])
285
323
 
286
324
  const handleAction = useCallback((actionId) => {
287
325
  if (actionId === 'delete') {
@@ -289,10 +327,28 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
289
327
  } else if (actionId === 'copy') {
290
328
  onCopy?.(widget)
291
329
  } else if (actionId === 'copy-text') {
292
- const text = widget.props?.text || widget.props?.content || ''
330
+ const title = widget.props?.title || ''
331
+ const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
332
+ const text = title && body ? `# ${title}\n\n${body}` : title || body
293
333
  navigator.clipboard?.writeText(text).catch(() => {})
334
+ } else if (actionId === 'open-external') {
335
+ const url = widget.props?.url || widget.props?.src
336
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
337
+ } else if (actionId === 'refresh-github') {
338
+ const url = widget.props?.url
339
+ if (url && onRefreshGitHub) onRefreshGitHub(widget.id, url)
340
+ } else if (actionId === 'toggle-collapse') {
341
+ const wasCollapsed = !!widget.props?.collapsed
342
+ onUpdate?.(widget.id, { collapsed: !wasCollapsed })
343
+ // When collapsing, pan viewport to center the widget
344
+ if (!wasCollapsed) {
345
+ requestAnimationFrame(() => {
346
+ const el = document.getElementById(widget.id)
347
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
348
+ })
349
+ }
294
350
  }
295
- }, [widget, onRemove, onCopy])
351
+ }, [widget, onRemove, onCopy, onRefreshGitHub])
296
352
 
297
353
  const handleWidgetFieldUpdate = useCallback((updates) => {
298
354
  onUpdate?.(widget.id, updates)
@@ -317,6 +373,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
317
373
  widget={widget}
318
374
  onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
319
375
  widgetRef={widgetRef}
376
+ onRefreshGitHub={onRefreshGitHub}
377
+ canRefreshGitHub={canRefreshGitHub}
320
378
  />
321
379
  </WidgetChrome>
322
380
  )
@@ -340,7 +398,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
340
398
  *
341
399
  * @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
342
400
  */
343
- export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = null }) {
401
+ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages = [], canvasMeta = null }) {
402
+ const canvasId = canvasIdProp || name || ''
344
403
  const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
345
404
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
346
405
 
@@ -364,6 +423,7 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
364
423
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
365
424
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
366
425
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
426
+ const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
367
427
 
368
428
  // Refs for snap settings (used by drop handler inside effect closure)
369
429
  const snapEnabledRef = useRef(snapEnabled)
@@ -574,6 +634,58 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
574
634
  }
575
635
  }, [canvasId, localWidgets, undoRedo])
576
636
 
637
+ const showMissingGhBanner = useCallback(() => {
638
+ setShowGhInstallBanner(true)
639
+ }, [])
640
+
641
+ const buildGitHubPreviewUpdates = useCallback(async (url) => {
642
+ try {
643
+ const availability = await checkGitHubCliAvailable()
644
+ if (!availability?.available) {
645
+ showMissingGhBanner()
646
+ return null
647
+ }
648
+
649
+ const result = await fetchGitHubEmbed(url)
650
+ if (result?.code === 'gh_unavailable') {
651
+ showMissingGhBanner()
652
+ return null
653
+ }
654
+ if (!result?.success || !result?.snapshot) return null
655
+
656
+ const snapshot = result.snapshot
657
+ return {
658
+ title: snapshot.title || '',
659
+ width: 580,
660
+ height: 400,
661
+ github: {
662
+ kind: snapshot.kind || 'issue',
663
+ parentKind: snapshot.parentKind || snapshot.kind || 'issue',
664
+ context: snapshot.context || '',
665
+ body: snapshot.body || '',
666
+ bodyHtml: snapshot.bodyHtml || '',
667
+ authors: Array.isArray(snapshot.authors)
668
+ ? snapshot.authors.filter((author) => typeof author === 'string' && author.trim())
669
+ : [],
670
+ createdAt: snapshot.createdAt ?? null,
671
+ updatedAt: snapshot.updatedAt ?? null,
672
+ fetchedAt: new Date().toISOString(),
673
+ },
674
+ }
675
+ } catch (err) {
676
+ console.error('[canvas] Failed to fetch GitHub embed metadata:', err)
677
+ return null
678
+ }
679
+ }, [showMissingGhBanner])
680
+
681
+ const handleRefreshGitHubWidget = useCallback(async (widgetId, url) => {
682
+ if (!widgetId || !url) return { updated: false }
683
+ const updates = await buildGitHubPreviewUpdates(url)
684
+ if (!updates) return { updated: false }
685
+ handleWidgetUpdate(widgetId, updates)
686
+ return { updated: true }
687
+ }, [buildGitHubPreviewUpdates, handleWidgetUpdate])
688
+
577
689
  const debouncedSourceSave = useRef(
578
690
  debounce((canvasId, sources) => {
579
691
  updateCanvas(canvasId, { sources }).catch((err) =>
@@ -1371,7 +1483,13 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1371
1483
  e.preventDefault()
1372
1484
  const resolved = resolvePaste(text, pasteCtx, getPasteRules())
1373
1485
  if (!resolved) return
1374
- const { type, props } = resolved
1486
+ const { type } = resolved
1487
+ let props = resolved.props
1488
+
1489
+ if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
1490
+ const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
1491
+ if (githubUpdates) props = { ...props, ...githubUpdates }
1492
+ }
1375
1493
 
1376
1494
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1377
1495
  const pos = centerPositionForWidget(center, type, props)
@@ -1392,6 +1510,7 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1392
1510
 
1393
1511
  document.addEventListener('paste', handlePaste)
1394
1512
  return () => document.removeEventListener('paste', handlePaste)
1513
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1395
1514
  }, [canvasId, undoRedo, localWidgets])
1396
1515
 
1397
1516
  // --- Drag and drop handlers for images from Finder/file manager ---
@@ -1652,6 +1771,15 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1652
1771
  document.addEventListener('mouseup', handlePanEnd)
1653
1772
  }, [spaceHeld])
1654
1773
 
1774
+ // Stable callback for deselecting all widgets
1775
+ const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
1776
+
1777
+ // Stable callback for widget removal + deselect
1778
+ const handleWidgetRemoveAndDeselect = useCallback((id) => {
1779
+ handleWidgetRemove(id)
1780
+ setSelectedWidgetIds(new Set())
1781
+ }, [handleWidgetRemove])
1782
+
1655
1783
  if (!canvas) {
1656
1784
  return (
1657
1785
  <div className={styles.empty}>
@@ -1683,15 +1811,6 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1683
1811
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
1684
1812
  const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
1685
1813
 
1686
- // Stable callback for deselecting all widgets
1687
- const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
1688
-
1689
- // Stable callback for widget removal + deselect
1690
- const handleWidgetRemoveAndDeselect = useCallback((id) => {
1691
- handleWidgetRemove(id)
1692
- setSelectedWidgetIds(new Set())
1693
- }, [handleWidgetRemove])
1694
-
1695
1814
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1696
1815
  const allChildren = []
1697
1816
 
@@ -1768,6 +1887,8 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1768
1887
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1769
1888
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
1770
1889
  onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
1890
+ onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
1891
+ canRefreshGitHub={isLocalDev}
1771
1892
  readOnly={!isLocalDev}
1772
1893
  />
1773
1894
  </div>
@@ -1816,6 +1937,28 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
1816
1937
  </Canvas>
1817
1938
  </div>
1818
1939
  </div>
1940
+ {showGhInstallBanner && (
1941
+ <aside className={styles.ghInstallBanner} role="status" aria-live="polite">
1942
+ <span className={styles.ghInstallBannerText}>
1943
+ GitHub embeds require local <code>gh</code> CLI access.
1944
+ </span>
1945
+ <a
1946
+ href={GH_INSTALL_URL}
1947
+ target="_blank"
1948
+ rel="noopener noreferrer"
1949
+ className={styles.ghInstallBannerLink}
1950
+ >
1951
+ Install GitHub CLI
1952
+ </a>
1953
+ <button
1954
+ type="button"
1955
+ className={styles.ghInstallBannerDismiss}
1956
+ onClick={() => setShowGhInstallBanner(false)}
1957
+ >
1958
+ Dismiss
1959
+ </button>
1960
+ </aside>
1961
+ )}
1819
1962
  </>
1820
1963
  )
1821
1964
  }
@@ -86,3 +86,57 @@
86
86
  pointer-events: none;
87
87
  user-select: none;
88
88
  }
89
+
90
+ .ghInstallBanner {
91
+ position: fixed;
92
+ left: 50%;
93
+ bottom: 12px;
94
+ transform: translateX(-50%);
95
+ z-index: 15;
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 10px;
99
+ padding: 8px 12px;
100
+ border-radius: 8px;
101
+ border: 1px solid var(--borderColor-default, #d1d9e0);
102
+ background: color-mix(in srgb, var(--bgColor-default, #ffffff) 88%, transparent);
103
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16);
104
+ backdrop-filter: blur(8px);
105
+ font-size: 12px;
106
+ color: var(--fgColor-default, #1f2328);
107
+ }
108
+
109
+ .ghInstallBannerText {
110
+ white-space: nowrap;
111
+ }
112
+
113
+ .ghInstallBannerText code {
114
+ font-size: 11px;
115
+ padding: 1px 4px;
116
+ border-radius: 4px;
117
+ background: var(--bgColor-muted, #f6f8fa);
118
+ }
119
+
120
+ .ghInstallBannerLink {
121
+ color: var(--fgColor-accent, #0969da);
122
+ font-weight: 600;
123
+ text-decoration: none;
124
+ }
125
+
126
+ .ghInstallBannerLink:hover {
127
+ text-decoration: underline;
128
+ }
129
+
130
+ .ghInstallBannerDismiss {
131
+ border: 1px solid var(--borderColor-default, #d1d9e0);
132
+ background: var(--bgColor-default, #ffffff);
133
+ color: var(--fgColor-default, #1f2328);
134
+ border-radius: 6px;
135
+ font-size: 11px;
136
+ padding: 4px 8px;
137
+ cursor: pointer;
138
+ }
139
+
140
+ .ghInstallBannerDismiss:hover {
141
+ background: var(--bgColor-muted, #f6f8fa);
142
+ }
@@ -103,6 +103,8 @@ vi.mock('./widgets/figmaUrl.js', () => ({
103
103
 
104
104
  vi.mock('./canvasApi.js', () => ({
105
105
  addWidget: vi.fn(),
106
+ checkGitHubCliAvailable: vi.fn(() => Promise.resolve({ available: true })),
107
+ fetchGitHubEmbed: vi.fn(() => Promise.resolve({ success: false })),
106
108
  updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
107
109
  removeWidget: vi.fn(() => Promise.resolve({ success: true })),
108
110
  uploadImage: vi.fn(),
@@ -53,3 +53,11 @@ export function toggleImagePrivacy(filename) {
53
53
  export function getCanvas(canvasId) {
54
54
  return request(`/read?name=${encodeURIComponent(canvasId)}`, 'GET')
55
55
  }
56
+
57
+ export function checkGitHubCliAvailable() {
58
+ return request('/github/available', 'GET')
59
+ }
60
+
61
+ export function fetchGitHubEmbed(url) {
62
+ return request('/github/embed', 'POST', { url })
63
+ }
@@ -10,6 +10,30 @@ import overlayStyles from './embedOverlay.module.css'
10
10
 
11
11
  const figmaEmbedSchema = schemas['figma-embed']
12
12
 
13
+ /** Feather-icons figma icon (monochrome, stroke-based) */
14
+ function FigmaIcon({ size = 32, className }) {
15
+ return (
16
+ <svg
17
+ className={className}
18
+ width={size}
19
+ height={size}
20
+ viewBox="0 0 24 24"
21
+ fill="none"
22
+ stroke="currentColor"
23
+ strokeWidth="2"
24
+ strokeLinecap="round"
25
+ strokeLinejoin="round"
26
+ aria-hidden="true"
27
+ >
28
+ <path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z" />
29
+ <path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z" />
30
+ <path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z" />
31
+ <path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z" />
32
+ <path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z" />
33
+ </svg>
34
+ )
35
+ }
36
+
13
37
  /** Inline Figma logo SVG */
14
38
  function FigmaLogo() {
15
39
  return (
@@ -31,13 +55,15 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
31
55
  const height = readProp(props, 'height', figmaEmbedSchema)
32
56
 
33
57
  const [interactive, setInteractive] = useState(false)
34
- const [showIframe, setShowIframe] = useState(false)
58
+ const [showIframe, setShowIframe] = useState(true)
35
59
  const [expanded, setExpanded] = useState(false)
36
60
 
37
61
  const iframeRef = useRef(null)
38
62
  const embedRef = useRef(null)
39
63
  const inlineContainerRef = useRef(null)
40
64
  const modalContainerRef = useRef(null)
65
+ const teardownTimerRef = useRef(null)
66
+ const exitSessionRef = useRef(0)
41
67
 
42
68
  // Validate URL at render time — only embed known Figma URLs
43
69
  const isValid = useMemo(() => isFigmaUrl(url), [url])
@@ -53,6 +79,8 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
53
79
  })
54
80
 
55
81
  const enterInteractive = useCallback(() => {
82
+ exitSessionRef.current++
83
+ clearTimeout(teardownTimerRef.current)
56
84
  setShowIframe(true)
57
85
  setInteractive(true)
58
86
  }, [])
@@ -62,13 +90,21 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
62
90
  function handlePointerDown(e) {
63
91
  if (embedRef.current && !embedRef.current.contains(e.target)) {
64
92
  setInteractive(false)
65
- setShowIframe(false)
93
+ // Keep iframe alive for 5 min — Figma is slow to reload
94
+ const session = ++exitSessionRef.current
95
+ clearTimeout(teardownTimerRef.current)
96
+ teardownTimerRef.current = setTimeout(() => {
97
+ if (exitSessionRef.current !== session) return
98
+ setShowIframe(false)
99
+ }, 5 * 60 * 1000)
66
100
  }
67
101
  }
68
102
  document.addEventListener('pointerdown', handlePointerDown)
69
103
  return () => document.removeEventListener('pointerdown', handlePointerDown)
70
104
  }, [interactive, expanded])
71
105
 
106
+ useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
107
+
72
108
  // Close expanded modal on Escape
73
109
  useEffect(() => {
74
110
  if (!expanded) return
@@ -178,11 +214,10 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
178
214
  )}
179
215
  </>
180
216
  ) : (
181
- <div className={styles.iframeContainer} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
182
- <p style={{ color: 'var(--fgColor-muted, #656d76)', fontSize: 14, fontStyle: 'italic' }}>
183
- No Figma URL
184
- </p>
185
- </div>
217
+ <div className={styles.emptyState}>
218
+ <FigmaIcon size={32} className={styles.emptyIcon} />
219
+ <span className={styles.emptyLabel}>No Figma URL</span>
220
+ </div>
186
221
  )}
187
222
  </div>
188
223
  {resizable && (
@@ -138,3 +138,24 @@
138
138
  .expandClose:hover {
139
139
  background: rgba(0, 0, 0, 0.7);
140
140
  }
141
+
142
+ .emptyState {
143
+ width: 100%;
144
+ height: calc(100% - 10px);
145
+ display: flex;
146
+ flex-direction: column;
147
+ align-items: center;
148
+ justify-content: center;
149
+ gap: 8px;
150
+ }
151
+
152
+ .emptyIcon {
153
+ color: var(--fgColor-muted, #656d76);
154
+ opacity: 0.5;
155
+ }
156
+
157
+ .emptyLabel {
158
+ color: var(--fgColor-muted, #656d76);
159
+ font-size: 13px;
160
+ font-style: italic;
161
+ }