@dfosco/storyboard-react 4.0.0-beta.8 → 4.0.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. package/src/vite/data-plugin.test.js +405 -5
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Renders a story at its route URL inside an iframe on canvas.
3
+ *
4
+ * Features:
5
+ * - Title bar showing story name + export
6
+ * - "Show code" action toggles between iframe and source view
7
+ * - "Copy code" action copies the story source to clipboard
8
+ *
9
+ * Props: { storyId, exportName, width, height }
10
+ */
11
+ import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
12
+ import { getStoryData } from '@dfosco/storyboard-core'
13
+ import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
14
+ import WidgetWrapper from './WidgetWrapper.jsx'
15
+ import ResizeHandle from './ResizeHandle.jsx'
16
+ import { useIframeDevLogs } from './iframeDevLogs.js'
17
+ import styles from './StoryWidget.module.css'
18
+ import overlayStyles from './embedOverlay.module.css'
19
+
20
+ function ComponentIcon({ size = 36 }) {
21
+ return (
22
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
23
+ <path d="M5.21173 15.1113L2.52473 12.4243C2.29041 12.1899 2.29041 11.8101 2.52473 11.5757L5.21173 8.88873C5.44605 8.65442 5.82595 8.65442 6.06026 8.88873L8.74727 11.5757C8.98158 11.8101 8.98158 12.1899 8.74727 12.4243L6.06026 15.1113C5.82595 15.3456 5.44605 15.3456 5.21173 15.1113Z" />
24
+ <path d="M11.5757 21.475L8.88874 18.788C8.65443 18.5537 8.65443 18.1738 8.88874 17.9395L11.5757 15.2525C11.8101 15.0182 12.19 15.0182 12.4243 15.2525L15.1113 17.9395C15.3456 18.1738 15.3456 18.5537 15.1113 18.788L12.4243 21.475C12.19 21.7094 11.8101 21.7094 11.5757 21.475Z" />
25
+ <path d="M17.9395 15.1113L15.2525 12.4243C15.0182 12.1899 15.0182 11.8101 15.2525 11.5757L17.9395 8.88873C18.1738 8.65442 18.5537 8.65442 18.788 8.88873L21.475 11.5757C21.7094 11.8101 21.7094 12.1899 21.475 12.4243L18.788 15.1113C18.5537 15.3456 18.1738 15.3456 17.9395 15.1113Z" />
26
+ <path d="M11.5757 8.74727L8.88874 6.06026C8.65443 5.82595 8.65443 5.44605 8.88874 5.21173L11.5757 2.52473C11.8101 2.29041 12.19 2.29041 12.4243 2.52473L15.1113 5.21173C15.3456 5.44605 15.3456 5.82595 15.1113 6.06026L12.4243 8.74727C12.19 8.98158 11.8101 8.98158 11.5757 8.74727Z" />
27
+ </svg>
28
+ )
29
+ }
30
+
31
+ function resolveStoryUrl(storyId, exportName) {
32
+ const story = getStoryData(storyId)
33
+ if (!story?._route) return ''
34
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
35
+ const params = new URLSearchParams()
36
+ if (exportName) params.set('export', exportName)
37
+ params.set('_sb_embed', '')
38
+ params.set('_sb_hide_branch_bar', '')
39
+ return `${base}${story._route}?${params}`
40
+ }
41
+
42
+ const _storySourcesCache = {}
43
+
44
+ async function fetchStorySource(modulePath) {
45
+ if (modulePath in _storySourcesCache) return _storySourcesCache[modulePath]
46
+ const url = modulePath.startsWith('/') ? modulePath : `/${modulePath}`
47
+ const res = await fetch(`${url}?raw`)
48
+ if (!res.ok) throw new Error(`Failed to fetch ${url}`)
49
+ const code = await res.text()
50
+ _storySourcesCache[modulePath] = code
51
+ return code
52
+ }
53
+
54
+ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
55
+ const storyId = props?.storyId || ''
56
+ const exportName = props?.exportName || ''
57
+ const width = props?.width
58
+ const height = props?.height
59
+
60
+ const containerRef = useRef(null)
61
+ const iframeRef = useRef(null)
62
+ const [interactive, setInteractive] = useState(false)
63
+ const [showCode, setShowCode] = useState(!!props?.showCode)
64
+ const [sourceCode, setSourceCode] = useState(null)
65
+ const [highlightedHtml, setHighlightedHtml] = useState(null)
66
+ const [sourceLoading, setSourceLoading] = useState(false)
67
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
68
+
69
+ // Re-resolve story URL when the story index is live-patched
70
+ useEffect(() => {
71
+ const handler = () => setStoryIndexKey((k) => k + 1)
72
+ document.addEventListener('storyboard:story-index-changed', handler)
73
+ return () => document.removeEventListener('storyboard:story-index-changed', handler)
74
+ }, [])
75
+
76
+ const toggleShowCode = useCallback(() => {
77
+ setShowCode((v) => {
78
+ const next = !v
79
+ if (onUpdate) onUpdate({ showCode: next })
80
+ return next
81
+ })
82
+ }, [onUpdate])
83
+
84
+ const enterInteractive = useCallback(() => setInteractive(true), [])
85
+
86
+ // Exit interactive mode when clicking outside
87
+ useEffect(() => {
88
+ if (!interactive) return
89
+ function handlePointerDown(e) {
90
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
91
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
92
+ if (chromeEl) return
93
+ setInteractive(false)
94
+ }
95
+ }
96
+ document.addEventListener('pointerdown', handlePointerDown)
97
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
98
+ }, [interactive, widgetId])
99
+
100
+ const handleResize = useCallback((w, h) => {
101
+ onUpdate?.({ width: w, height: h })
102
+ }, [onUpdate])
103
+
104
+ // Load source code when show-code is toggled on
105
+ useEffect(() => {
106
+ if (!showCode || sourceCode !== null) return
107
+ const story = getStoryData(storyId)
108
+ if (!story?._storyModule) {
109
+ setSourceCode('// Source not available')
110
+ return
111
+ }
112
+ let cancelled = false
113
+ setSourceLoading(true)
114
+ fetchStorySource(story._storyModule)
115
+ .then((code) => { if (!cancelled) { setSourceCode(code || '// Empty file'); setSourceLoading(false) } })
116
+ .catch(() => { if (!cancelled) { setSourceCode('// Failed to load source'); setSourceLoading(false) } })
117
+ return () => { cancelled = true; setSourceLoading(false) }
118
+ }, [showCode, sourceCode, storyId])
119
+
120
+ // Re-highlight when theme changes
121
+ const [codeThemeKey, setCodeThemeKey] = useState(0)
122
+ useEffect(() => {
123
+ function onThemeChanged() { setCodeThemeKey((k) => k + 1) }
124
+ document.addEventListener('storyboard:theme:changed', onThemeChanged)
125
+ return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
126
+ }, [])
127
+
128
+ // Syntax-highlight source code
129
+ useEffect(() => {
130
+ if (!sourceCode) return
131
+ let cancelled = false
132
+ createInspectorHighlighter().then((hl) => {
133
+ if (cancelled) return
134
+ const lang = storyId.endsWith('.tsx') ? 'tsx' : 'jsx'
135
+ setHighlightedHtml(hl.codeToHtml(sourceCode, { lang }))
136
+ })
137
+ return () => { cancelled = true }
138
+ }, [sourceCode, storyId, codeThemeKey])
139
+
140
+ const copyCode = useCallback(async () => {
141
+ if (sourceCode) { await navigator.clipboard?.writeText(sourceCode); return }
142
+ const story = getStoryData(storyId)
143
+ if (!story?._storyModule) return
144
+ try {
145
+ const code = await fetchStorySource(story._storyModule)
146
+ setSourceCode(code)
147
+ await navigator.clipboard?.writeText(code)
148
+ } catch { /* */ }
149
+ }, [sourceCode, storyId])
150
+
151
+ useImperativeHandle(ref, () => ({
152
+ getState(key) {
153
+ if (key === 'showCode') return showCode
154
+ return undefined
155
+ },
156
+ handleAction(actionId) {
157
+ if (actionId === 'show-code') toggleShowCode()
158
+ else if (actionId === 'copy-code') copyCode()
159
+ else if (actionId === 'open-external') {
160
+ const story = getStoryData(storyId)
161
+ if (story?._route) {
162
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
163
+ window.open(`${base}${story._route}`, '_blank', 'noopener')
164
+ }
165
+ }
166
+ },
167
+ }), [storyId, showCode, toggleShowCode, copyCode])
168
+
169
+ const iframeSrc = useMemo(
170
+ () => resolveStoryUrl(storyId, exportName),
171
+ [storyId, exportName, storyIndexKey],
172
+ )
173
+
174
+ useIframeDevLogs({
175
+ widget: 'StoryWidget',
176
+ loaded: interactive && !showCode && Boolean(iframeSrc),
177
+ src: iframeSrc,
178
+ })
179
+
180
+ const displayName = exportName ? `${storyId} / ${exportName}` : storyId
181
+
182
+ if (!storyId) {
183
+ return (
184
+ <WidgetWrapper>
185
+ <div className={styles.container} ref={containerRef}>
186
+ <div className={styles.error}>
187
+ <span className={styles.errorIcon}><ComponentIcon size={20} /></span>
188
+ <span className={styles.errorText}>Missing story ID</span>
189
+ </div>
190
+ </div>
191
+ </WidgetWrapper>
192
+ )
193
+ }
194
+
195
+ if (!iframeSrc) {
196
+ return (
197
+ <WidgetWrapper>
198
+ <div className={styles.container} ref={containerRef}>
199
+ <div className={styles.error}>
200
+ <span className={styles.errorIcon}><ComponentIcon size={20} /></span>
201
+ <span className={styles.errorText}>Story &ldquo;{storyId}&rdquo; not found or has no route</span>
202
+ </div>
203
+ </div>
204
+ </WidgetWrapper>
205
+ )
206
+ }
207
+
208
+ const sizeStyle = {}
209
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
210
+ if (typeof height === 'number') sizeStyle.height = `${height}px`
211
+
212
+ return (
213
+ <WidgetWrapper>
214
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
215
+ <div className={styles.header}>
216
+ <span className={styles.headerIcon}><ComponentIcon size={16} /></span>
217
+ <span className={styles.headerTitle}>{displayName}</span>
218
+ </div>
219
+ {showCode ? (
220
+ <div
221
+ className={styles.codeView}
222
+ data-canvas-allow-text-selection
223
+ onPointerDown={(e) => e.stopPropagation()}
224
+ onMouseDown={(e) => e.stopPropagation()}
225
+ onClick={(e) => e.stopPropagation()}
226
+ >
227
+ <div className={styles.codeHeader}>
228
+ <span className={styles.codeLabel}>{storyId}.story.jsx</span>
229
+ <button className={styles.codeCloseBtn} onClick={() => setShowCode(false)} aria-label="Close code view">×</button>
230
+ </div>
231
+ {sourceLoading ? (
232
+ <div className={styles.codeLoading}>Loading…</div>
233
+ ) : highlightedHtml ? (
234
+ <div className={styles.codeBlock} dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
235
+ ) : (
236
+ <pre className={styles.codeBlock}><code>{sourceCode || ''}</code></pre>
237
+ )}
238
+ </div>
239
+ ) : (
240
+ <>
241
+ <div className={styles.content}>
242
+ <iframe
243
+ ref={iframeRef}
244
+ src={iframeSrc}
245
+ className={styles.iframe}
246
+ title={displayName}
247
+ />
248
+ </div>
249
+
250
+ {!interactive && (
251
+ <div
252
+ className={overlayStyles.interactOverlay}
253
+ onClick={(e) => {
254
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
255
+ enterInteractive()
256
+ }}
257
+ role="button"
258
+ tabIndex={0}
259
+ onKeyDown={(e) => {
260
+ if (e.key === 'Enter' || e.key === ' ') {
261
+ e.preventDefault()
262
+ e.stopPropagation()
263
+ enterInteractive()
264
+ }
265
+ }}
266
+ aria-label="Click to interact"
267
+ >
268
+ <span className={overlayStyles.interactHint}>Click to interact</span>
269
+ </div>
270
+ )}
271
+ </>
272
+ )}
273
+ </div>
274
+ {resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
275
+ </WidgetWrapper>
276
+ )
277
+ })
@@ -0,0 +1,211 @@
1
+ .container {
2
+ position: relative;
3
+ overflow: hidden;
4
+ min-width: 100px;
5
+ min-height: 60px;
6
+ background: var(--bgColor-default, #ffffff);
7
+ border: 3px solid var(--borderColor-default, #d0d7de);
8
+ border-radius: 12px;
9
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
10
+ width: 100%;
11
+ height: 100%;
12
+ }
13
+
14
+ .header {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 6px;
18
+ padding: 10px 10px;
19
+ font-size: 12px;
20
+ font-weight: 500;
21
+ color: var(--fgColor-muted, #656d76);
22
+ background: var(--bgColor-muted, #f6f8fa);
23
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
24
+ white-space: nowrap;
25
+ overflow: hidden;
26
+ text-overflow: ellipsis;
27
+ user-select: none;
28
+ }
29
+
30
+ .headerIcon {
31
+ display: inline-flex;
32
+ flex-shrink: 0;
33
+ }
34
+
35
+ .headerTitle {
36
+ overflow: hidden;
37
+ text-overflow: ellipsis;
38
+ }
39
+
40
+ .content {
41
+ position: relative;
42
+ width: 100%;
43
+ height: calc(100% - 37px);
44
+ }
45
+
46
+ .placeholder {
47
+ position: absolute;
48
+ inset: 0;
49
+ display: flex;
50
+ flex-direction: column;
51
+ align-items: center;
52
+ justify-content: center;
53
+ gap: 8px;
54
+ color: var(--fgColor-muted, #656d76);
55
+ text-align: center;
56
+ }
57
+
58
+ .placeholderLabel {
59
+ font-size: 13px;
60
+ font-weight: 500;
61
+ }
62
+
63
+ .spinner {
64
+ width: 24px;
65
+ height: 24px;
66
+ border: 3px solid var(--borderColor-muted, #d0d7de);
67
+ border-top-color: var(--fgColor-accent, #2f81f7);
68
+ border-radius: 50%;
69
+ animation: spin 0.8s linear infinite;
70
+ }
71
+
72
+ @keyframes spin {
73
+ from { transform: rotate(0deg); }
74
+ to { transform: rotate(360deg); }
75
+ }
76
+
77
+ .iframe {
78
+ position: absolute;
79
+ inset: 0;
80
+ display: block;
81
+ width: 100%;
82
+ height: 100%;
83
+ border: none;
84
+ z-index: 1;
85
+ }
86
+
87
+ .snapshotImage {
88
+ position: absolute;
89
+ inset: 0;
90
+ width: 100%;
91
+ height: 100%;
92
+ object-fit: cover;
93
+ object-position: top left;
94
+ display: block;
95
+ pointer-events: none;
96
+ transition: opacity 150ms ease;
97
+ }
98
+
99
+ .codeView {
100
+ display: flex;
101
+ flex-direction: column;
102
+ height: calc(100% - 37px);
103
+ overflow: hidden;
104
+ }
105
+
106
+ .codeHeader {
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: space-between;
110
+ padding: 4px 10px;
111
+ font-size: 11px;
112
+ font-weight: 500;
113
+ color: var(--fgColor-muted, #656d76);
114
+ background: var(--bgColor-inset, #eff2f5);
115
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
116
+ user-select: none;
117
+ flex-shrink: 0;
118
+ }
119
+
120
+ .codeLabel {
121
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
122
+ }
123
+
124
+ .codeCloseBtn {
125
+ all: unset;
126
+ cursor: pointer;
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: center;
130
+ width: 20px;
131
+ height: 20px;
132
+ border-radius: 4px;
133
+ font-size: 14px;
134
+ color: var(--fgColor-muted, #656d76);
135
+ }
136
+
137
+ .codeCloseBtn:hover {
138
+ background: var(--bgColor-neutral-muted, #eaeef2);
139
+ color: var(--fgColor-default, #1f2328);
140
+ }
141
+
142
+ .codeBlock {
143
+ flex: 1;
144
+ margin: 0;
145
+ overflow: auto;
146
+ user-select: text;
147
+ cursor: text;
148
+ }
149
+
150
+ /* Style the highlighted pre from the inspector highlighter.
151
+ padding uses !important to override the inline padding:0 from codeToHtml. */
152
+ .codeBlock pre {
153
+ margin: 0;
154
+ padding: var(--base-size-8, 8px) !important;
155
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
156
+ font-size: 12px;
157
+ font-weight: 400;
158
+ line-height: 1.6;
159
+ tab-size: 2;
160
+ min-height: 100%;
161
+ box-sizing: border-box;
162
+ }
163
+
164
+ /* Fallback when no highlighted HTML (plain pre/code) */
165
+ .codeBlock > code {
166
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
167
+ font-size: 12px;
168
+ font-weight: 400;
169
+ line-height: 1.6;
170
+ white-space: pre;
171
+ tab-size: 2;
172
+ }
173
+
174
+ .codeLoading {
175
+ flex: 1;
176
+ display: flex;
177
+ align-items: center;
178
+ justify-content: center;
179
+ font-size: 12px;
180
+ color: var(--fgColor-muted, #656d76);
181
+ }
182
+
183
+ .error {
184
+ display: flex;
185
+ align-items: center;
186
+ gap: 8px;
187
+ padding: 16px;
188
+ color: var(--fgColor-danger, #cf222e);
189
+ font-family: system-ui, -apple-system, sans-serif;
190
+ font-size: 13px;
191
+ line-height: 1.5;
192
+ }
193
+
194
+ .errorIcon {
195
+ font-size: 20px;
196
+ flex-shrink: 0;
197
+ }
198
+
199
+ .errorText {
200
+ word-break: break-word;
201
+ }
202
+
203
+ .loading {
204
+ display: flex;
205
+ align-items: center;
206
+ justify-content: center;
207
+ padding: 16px;
208
+ color: var(--fgColor-muted, #656d76);
209
+ font-family: system-ui, -apple-system, sans-serif;
210
+ font-size: 13px;
211
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
2
2
  import { Tooltip } from '@primer/react'
3
- import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
3
+ import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } from '@primer/octicons-react'
4
4
  import styles from './WidgetChrome.module.css'
5
5
 
6
6
  const STICKY_NOTE_COLORS = {
@@ -60,6 +60,18 @@ function EyeClosedIcon() {
60
60
  return <OcticonEyeClosed size={12} />
61
61
  }
62
62
 
63
+ function CodeIcon() {
64
+ return <OcticonCode size={12} />
65
+ }
66
+
67
+ function UnwrapIcon() {
68
+ return <OcticonUnwrap size={12} />
69
+ }
70
+
71
+ function ImageIcon() {
72
+ return <OcticonImage size={12} />
73
+ }
74
+
63
75
  function CopyIcon() {
64
76
  return (
65
77
  <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
@@ -110,6 +122,22 @@ function ExpandIcon() {
110
122
  )
111
123
  }
112
124
 
125
+ function SyncIcon() {
126
+ return (
127
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
128
+ <path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
129
+ </svg>
130
+ )
131
+ }
132
+
133
+ function UnfoldIcon() {
134
+ return <OcticonUnfold size={12} />
135
+ }
136
+
137
+ function FoldIcon() {
138
+ return <OcticonFold size={12} />
139
+ }
140
+
113
141
  /** Icon registry — maps icon name strings from config to React components. */
114
142
  const ICON_REGISTRY = {
115
143
  'trash': DeleteIcon,
@@ -119,12 +147,18 @@ const ICON_REGISTRY = {
119
147
  'open-external': OpenExternalIcon,
120
148
  'eye': EyeIcon,
121
149
  'eye-closed': EyeClosedIcon,
150
+ 'code': CodeIcon,
151
+ 'unwrap': UnwrapIcon,
152
+ 'image': ImageIcon,
122
153
  'copy': CopyIcon,
123
154
  'link': LinkIcon,
124
155
  'more': MoreIcon,
125
156
  'chevron-down': ChevronDownIcon,
126
157
  'download': DownloadIcon,
127
158
  'expand': ExpandIcon,
159
+ 'sync': SyncIcon,
160
+ 'unfold': UnfoldIcon,
161
+ 'fold': FoldIcon,
128
162
  }
129
163
 
130
164
  /** Danger-styled actions in the overflow menu. */
@@ -189,8 +223,8 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
189
223
  url.searchParams.set('widget', widgetId)
190
224
  navigator.clipboard.writeText(url.toString()).catch(() => {})
191
225
  } else if (action === 'copy-widget-id') {
192
- const canvasName = window.location.pathname.split('/').filter(Boolean).pop() || ''
193
- navigator.clipboard.writeText(`${canvasName}/${widgetId}`).catch(() => {})
226
+ const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
227
+ navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
194
228
  } else {
195
229
  onAction?.(action)
196
230
  }
@@ -411,16 +445,22 @@ export default function WidgetChrome({
411
445
  onUpdate?.({ color })
412
446
  }, [onUpdate])
413
447
 
414
- const showToolbar = !readOnly && (hovered || selected)
448
+ // In readOnly mode, features are already filtered to prod-only by getFeatures.
449
+ // Show toolbar if there are prod features even when readOnly.
450
+ const hasFeatures = features.length > 0
451
+ const showToolbar = (hovered || selected) && (!readOnly || hasFeatures)
415
452
  const showFeatures = showToolbar && !multiSelected
453
+ const menuFeatures = features.filter((f) => f.menu)
416
454
 
417
455
  return (
418
456
  <div
419
457
  className={styles.chromeContainer}
420
- onMouseEnter={readOnly ? undefined : handleMouseEnter}
421
- onMouseLeave={readOnly ? undefined : handleMouseLeave}
458
+ data-widget-id={widgetId}
459
+ data-tc-elevated={(hovered || selected) || undefined}
460
+ onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
461
+ onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
422
462
  >
423
- <div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
463
+ <div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined}>
424
464
  {children}
425
465
  </div>
426
466
  <div
@@ -464,6 +504,11 @@ export default function WidgetChrome({
464
504
  }
465
505
  }
466
506
 
507
+ // Show-code toggle: swap label based on widget state
508
+ if (feature.action === 'show-code' && widgetRef?.current?.getState?.('showCode')) {
509
+ label = 'Show component'
510
+ }
511
+
467
512
  return (
468
513
  <Tooltip key={feature.id} text={label} direction="n">
469
514
  <button
@@ -495,22 +540,33 @@ export default function WidgetChrome({
495
540
 
496
541
  return null
497
542
  })}
498
- <WidgetOverflowMenu
499
- widgetId={widgetId}
500
- menuFeatures={features.filter((f) => f.menu)}
501
- onAction={onAction}
502
- />
543
+ {menuFeatures.length > 0 && (
544
+ <WidgetOverflowMenu
545
+ widgetId={widgetId}
546
+ menuFeatures={menuFeatures}
547
+ onAction={(actionId) => {
548
+ // Route overflow menu actions through the widget ref first
549
+ if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
550
+ widgetRef.current.handleAction(actionId)
551
+ } else {
552
+ onAction?.(actionId)
553
+ }
554
+ }}
555
+ />
556
+ )}
503
557
  </div>
504
558
  )}
505
559
 
506
- <Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
507
- <button
508
- className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
509
- onClick={handleHandleClick}
510
- aria-label={selected ? "Drag to move widget" : "Select widget"}
511
- aria-pressed={selected}
512
- />
513
- </Tooltip>
560
+ {!readOnly && (
561
+ <Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
562
+ <button
563
+ className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
564
+ onClick={handleHandleClick}
565
+ aria-label={selected ? "Drag to move widget" : "Select widget"}
566
+ aria-pressed={selected}
567
+ />
568
+ </Tooltip>
569
+ )}
514
570
  </div>
515
571
  </div>
516
572
  </div>
@@ -33,7 +33,7 @@
33
33
  top: calc(100% + 10px);
34
34
  }
35
35
 
36
- /* Trigger dot — centered, visible at rest */
36
+ /* Trigger dot — positioned in the toolbar, visible at rest */
37
37
  .triggerDot {
38
38
  width: 6px;
39
39
  height: 6px;
@@ -41,10 +41,6 @@
41
41
  background: var(--borderColor-muted, #d0d7de);
42
42
  opacity: 0.5;
43
43
  transition: opacity 120ms;
44
- position: absolute;
45
- left: 50%;
46
- top: 50%;
47
- transform: translate(-50%, -50%);
48
44
  }
49
45
 
50
46
  :global([data-sb-canvas-theme^='dark']) .triggerDot {
@@ -235,7 +231,7 @@
235
231
  .overflowMenu {
236
232
  position: absolute;
237
233
  top: calc(100% + 10px);
238
- right: 0;
234
+ left: 0;
239
235
  min-width: max-content;
240
236
  padding: 4px;
241
237
  background: var(--bgColor-default, #ffffff);
@@ -11,6 +11,8 @@
11
11
 
12
12
  .content {
13
13
  position: relative;
14
+ width: 100%;
15
+ height: 100%;
14
16
  }
15
17
 
16
18
  @media (prefers-color-scheme: dark) {