@dfosco/storyboard-react 4.0.0-beta.43 → 4.0.0-beta.44

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * CreateDialog — centered modal for creating storyboard artifacts.
3
+ * Triggered from the command palette's Create actions.
4
+ */
5
+ import { useState, useEffect } from 'react'
6
+
7
+ const TYPE_LABELS = {
8
+ Canvas: 'Canvas',
9
+ Prototype: 'Prototype',
10
+ Component: 'Component',
11
+ Flow: 'Prototype Flow',
12
+ Page: 'Prototype Page',
13
+ }
14
+
15
+ export default function CreateDialog({ type, basePath, onClose }) {
16
+ const [name, setName] = useState('')
17
+ const [title, setTitle] = useState('')
18
+ const [description, setDescription] = useState('')
19
+ const [url, setUrl] = useState('')
20
+ const [isExternal, setIsExternal] = useState(false)
21
+ const [prototype, setPrototype] = useState('')
22
+ const [prototypes, setPrototypes] = useState([])
23
+ const [error, setError] = useState('')
24
+ const [submitting, setSubmitting] = useState(false)
25
+
26
+ const needsPrototype = type === 'Flow' || type === 'Page'
27
+
28
+ // Reset form when type changes
29
+ useEffect(() => {
30
+ setName('')
31
+ setTitle('')
32
+ setDescription('')
33
+ setUrl('')
34
+ setIsExternal(false)
35
+ setPrototype('')
36
+ setError('')
37
+ setSubmitting(false)
38
+ }, [type])
39
+
40
+ // Fetch prototypes list when needed
41
+ useEffect(() => {
42
+ if (!needsPrototype || !type) return
43
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
44
+ fetch(`${apiBase}/_storyboard/workshop/flows`)
45
+ .then(r => r.ok ? r.json() : null)
46
+ .then(data => { if (data?.prototypes) setPrototypes(data.prototypes) })
47
+ .catch(() => {})
48
+ }, [needsPrototype, type, basePath])
49
+
50
+ if (!type) return null
51
+
52
+ function withBase(base, route) {
53
+ const b = (base || '/').replace(/\/+$/, '')
54
+ return b === '/' ? route : `${b}${route}`
55
+ }
56
+
57
+ async function handleSubmit(e) {
58
+ e.preventDefault()
59
+ if (!name.trim()) { setError('Name is required'); return }
60
+ if (needsPrototype && !prototype) { setError('Select a prototype'); return }
61
+ setError('')
62
+ setSubmitting(true)
63
+
64
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
65
+ let endpoint, body
66
+ if (type === 'Canvas') {
67
+ endpoint = `${apiBase}/_storyboard/canvas/create`
68
+ body = { name: name.trim(), title: title.trim(), description: description.trim(), grid: true, gridSize: 24 }
69
+ } else if (type === 'Prototype') {
70
+ endpoint = `${apiBase}/_storyboard/workshop/prototypes`
71
+ body = { name: name.trim(), title: title.trim(), description: description.trim() }
72
+ if (isExternal) { body.external = true; body.url = url.trim() }
73
+ } else if (type === 'Flow') {
74
+ endpoint = `${apiBase}/_storyboard/workshop/flows`
75
+ body = { name: name.trim(), title: title.trim(), prototype, description: description.trim() }
76
+ } else if (type === 'Page') {
77
+ endpoint = `${apiBase}/_storyboard/workshop/pages`
78
+ body = { name: name.trim(), prototype }
79
+ } else {
80
+ endpoint = `${apiBase}/_storyboard/canvas/create-story`
81
+ body = { name: name.trim(), location: 'src/components' }
82
+ }
83
+
84
+ try {
85
+ const res = await fetch(endpoint, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify(body),
89
+ })
90
+ if (!res.ok) {
91
+ const text = await res.text()
92
+ throw new Error(text || `Request failed (${res.status})`)
93
+ }
94
+ const data = await res.json().catch(() => ({}))
95
+ const route = data.route || data.path || `/${name.trim()}`
96
+ window.location.href = withBase(basePath, route)
97
+ } catch (err) {
98
+ setError(err.message)
99
+ setSubmitting(false)
100
+ }
101
+ }
102
+
103
+ return (
104
+ <div style={overlayStyle} onClick={onClose}>
105
+ <div style={dialogStyle} onClick={e => e.stopPropagation()}>
106
+ <div style={headerStyle}>
107
+ <span style={titleStyle}>New {TYPE_LABELS[type] || type}</span>
108
+ <button style={closeBtnStyle} onClick={onClose}>✕</button>
109
+ </div>
110
+
111
+ <form onSubmit={handleSubmit} style={formStyle}>
112
+ {needsPrototype && (
113
+ <label style={fieldStyle}>
114
+ <span style={labelStyle}>Prototype *</span>
115
+ <select style={inputStyle} value={prototype} onChange={e => setPrototype(e.target.value)}>
116
+ <option value="">Select a prototype…</option>
117
+ {prototypes.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
118
+ </select>
119
+ </label>
120
+ )}
121
+
122
+ <label style={fieldStyle}>
123
+ <span style={labelStyle}>Name *</span>
124
+ <input style={inputStyle} value={name} onChange={e => setName(e.target.value)}
125
+ placeholder={type === 'Page' ? 'my-page' : `my-${type.toLowerCase()}`} autoFocus />
126
+ </label>
127
+
128
+ {type !== 'Page' && (
129
+ <label style={fieldStyle}>
130
+ <span style={labelStyle}>Title</span>
131
+ <input style={inputStyle} value={title} onChange={e => setTitle(e.target.value)}
132
+ placeholder="Display title (optional)" />
133
+ </label>
134
+ )}
135
+
136
+ {(type === 'Canvas' || type === 'Prototype') && (
137
+ <label style={fieldStyle}>
138
+ <span style={labelStyle}>Description</span>
139
+ <input style={inputStyle} value={description} onChange={e => setDescription(e.target.value)}
140
+ placeholder="Short description (optional)" />
141
+ </label>
142
+ )}
143
+
144
+ {type === 'Prototype' && (
145
+ <label style={checkboxFieldStyle}>
146
+ <input type="checkbox" checked={isExternal} onChange={e => setIsExternal(e.target.checked)} />
147
+ <span>External prototype</span>
148
+ </label>
149
+ )}
150
+
151
+ {isExternal && (
152
+ <label style={fieldStyle}>
153
+ <span style={labelStyle}>URL *</span>
154
+ <input style={inputStyle} value={url} onChange={e => setUrl(e.target.value)}
155
+ placeholder="https://example.com" />
156
+ </label>
157
+ )}
158
+
159
+ {error && <div style={errorStyle}>{error}</div>}
160
+
161
+ <button type="submit" disabled={submitting} style={submitStyle}>
162
+ {submitting ? 'Creating…' : `Create ${TYPE_LABELS[type] || type}`}
163
+ </button>
164
+ </form>
165
+ </div>
166
+ </div>
167
+ )
168
+ }
169
+
170
+ const overlayStyle = {
171
+ position: 'fixed', inset: 0, zIndex: 10001,
172
+ background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(2px)',
173
+ display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
174
+ paddingTop: '15vh',
175
+ }
176
+
177
+ const dialogStyle = {
178
+ background: '#fff', borderRadius: 12, width: '100%', maxWidth: 420,
179
+ boxShadow: '0 16px 48px rgba(0,0,0,0.15)', overflow: 'hidden',
180
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
181
+ }
182
+
183
+ const headerStyle = {
184
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
185
+ padding: '16px 20px 12px', borderBottom: '1px solid #e5e5e5',
186
+ }
187
+
188
+ const titleStyle = { fontSize: 16, fontWeight: 600, color: '#1a1a1a' }
189
+
190
+ const closeBtnStyle = {
191
+ background: 'none', border: 'none', cursor: 'pointer',
192
+ fontSize: 16, color: '#999', padding: 4,
193
+ }
194
+
195
+ const formStyle = { padding: '16px 20px 20px', display: 'flex', flexDirection: 'column', gap: 12 }
196
+
197
+ const fieldStyle = { display: 'flex', flexDirection: 'column', gap: 4 }
198
+
199
+ const labelStyle = { fontSize: 13, fontWeight: 500, color: '#555' }
200
+
201
+ const inputStyle = {
202
+ padding: '8px 10px', border: '1px solid #ddd', borderRadius: 6,
203
+ fontSize: 14, outline: 'none', fontFamily: 'inherit',
204
+ }
205
+
206
+ const checkboxFieldStyle = {
207
+ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: '#555',
208
+ }
209
+
210
+ const errorStyle = {
211
+ fontSize: 13, color: '#ef4444', background: '#fef2f2',
212
+ padding: '6px 10px', borderRadius: 6,
213
+ }
214
+
215
+ const submitStyle = {
216
+ padding: '10px 16px', background: '#1a1a1a', color: '#fff',
217
+ border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 500,
218
+ cursor: 'pointer', fontFamily: 'inherit', marginTop: 4,
219
+ }
@@ -0,0 +1,66 @@
1
+ /*
2
+ * Theme overrides for react-cmdk to match storyboard core-ui styling.
3
+ * The z-index is set above the CoreUIBar (9999) so the palette overlays everything.
4
+ */
5
+ .command-palette {
6
+ z-index: 10001 !important;
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
8
+ }
9
+
10
+ /*
11
+ * Dark mode overrides for react-cmdk.
12
+ * react-cmdk uses @media (prefers-color-scheme: dark) internally,
13
+ * but we drive dark mode via data-color-mode on body. Override all
14
+ * colors with a single high-specificity block.
15
+ */
16
+ body[data-color-mode="dark"] .command-palette {
17
+ color-scheme: dark;
18
+ }
19
+
20
+ body[data-color-mode="dark"] .command-palette .command-palette-content {
21
+ color: #e6edf3 !important;
22
+ }
23
+
24
+ /* Panel background */
25
+ body[data-color-mode="dark"] .command-palette .bg-white {
26
+ background-color: #2d333b !important;
27
+ }
28
+
29
+ /* Hover highlight */
30
+ body[data-color-mode="dark"] .command-palette .hover\:bg-gray-100:hover {
31
+ background-color: #373e47 !important;
32
+ }
33
+
34
+ /* Selected/active item highlight */
35
+ body[data-color-mode="dark"] .command-palette .bg-gray-200\/50 {
36
+ background-color: rgba(99, 110, 123, 0.4) !important;
37
+ }
38
+
39
+ /* Muted text (headings, badges, "Action" labels) */
40
+ body[data-color-mode="dark"] .command-palette .text-gray-400,
41
+ body[data-color-mode="dark"] .command-palette .text-gray-500 {
42
+ color: #768390 !important;
43
+ }
44
+
45
+ /* Input text */
46
+ body[data-color-mode="dark"] .command-palette input {
47
+ color: #e6edf3 !important;
48
+ }
49
+ body[data-color-mode="dark"] .command-palette .placeholder-gray-500::placeholder {
50
+ color: #636e7b !important;
51
+ }
52
+ body[data-color-mode="dark"] .command-palette .placeholder-gray-500::-moz-placeholder {
53
+ color: #636e7b !important;
54
+ }
55
+
56
+ /* Borders / dividers */
57
+ body[data-color-mode="dark"] .command-palette .divide-y > :not([hidden]) ~ :not([hidden]),
58
+ body[data-color-mode="dark"] .command-palette .border-t,
59
+ body[data-color-mode="dark"] .command-palette .border-b {
60
+ border-color: #444c56 !important;
61
+ }
62
+
63
+ /* Hide "Action" / "Link" type labels on all list items */
64
+ .command-palette .command-palette-list-item .text-gray-500.text-sm {
65
+ display: none !important;
66
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { useState, useEffect, useMemo, useCallback, useSyncExternalStore } from 'react'
8
8
  import { buildPrototypeIndex, listStories, getStoryData, getLocal, setLocal } from '@dfosco/storyboard-core'
9
- import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon } from '@primer/octicons-react'
9
+ import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon } from '@primer/octicons-react'
10
10
  import { Menu } from '@base-ui/react/menu'
11
11
  import Icon from './Icon.jsx'
12
12
  import css from './Viewfinder.module.css'
@@ -195,6 +195,7 @@ function ArtifactCard({ item, basePath, starred, onToggleStar }) {
195
195
  <span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
196
196
  <div className={css.cardActions}>
197
197
  {item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
198
+ {item.pages?.length > 1 && <PagesDropdown pages={item.pages} basePath={basePath} />}
198
199
  <StarBtn active={starred} onClick={() => onToggleStar(item.id)} />
199
200
  </div>
200
201
  </div>
@@ -276,6 +277,43 @@ function FlowsDropdown({ flows, basePath }) {
276
277
  )
277
278
  }
278
279
 
280
+ /* ─── Pages Dropdown ─── */
281
+
282
+ function PagesDropdown({ pages, basePath }) {
283
+ if (!pages || pages.length < 2) return null
284
+ return (
285
+ <Menu.Root>
286
+ <Menu.Trigger
287
+ className={css.iconBtn}
288
+ onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
289
+ aria-label="See pages"
290
+ title="See pages"
291
+ >
292
+ <StackIcon size={16} />
293
+ </Menu.Trigger>
294
+ <Menu.Portal>
295
+ <Menu.Positioner className={css.flowsPositioner} side="bottom" align="end" sideOffset={4}>
296
+ <Menu.Popup className={css.flowsPopup}>
297
+ <div className={css.flowsTitle}>Pages</div>
298
+ {pages.map(page => (
299
+ <Menu.Item
300
+ key={page.route}
301
+ className={css.flowsItem}
302
+ onClick={(e) => {
303
+ e.preventDefault()
304
+ window.location.href = withBase(basePath, page.route)
305
+ }}
306
+ >
307
+ {page.name}
308
+ </Menu.Item>
309
+ ))}
310
+ </Menu.Popup>
311
+ </Menu.Positioner>
312
+ </Menu.Portal>
313
+ </Menu.Root>
314
+ )
315
+ }
316
+
279
317
  /* ─── Folder Section ─── */
280
318
 
281
319
  function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar }) {
@@ -330,7 +368,7 @@ function CreateFooter() {
330
368
 
331
369
  /* ─── Create Form ─── */
332
370
 
333
- function CreateForm({ type, onBack, onClose, basePath }) {
371
+ function CreateForm({ type, onClose, basePath }) {
334
372
  const [name, setName] = useState('')
335
373
  const [title, setTitle] = useState('')
336
374
  const [description, setDescription] = useState('')
@@ -404,10 +442,12 @@ function CreateForm({ type, onBack, onClose, basePath }) {
404
442
 
405
443
  return (
406
444
  <form onSubmit={handleSubmit}>
407
- <button type="button" className={css.createFormBack} onClick={onBack}>
408
- Back
409
- </button>
410
- <div className={css.createMenuTitle}>New {typeLabels[type] || type}</div>
445
+ <div className={css.createFormHeader}>
446
+ <div className={css.createMenuTitle}>New {typeLabels[type] || type}</div>
447
+ <button type="button" className={css.createFormClose} onClick={onClose} aria-label="Close">
448
+ <XIcon size={16} />
449
+ </button>
450
+ </div>
411
451
 
412
452
  {needsPrototype && (
413
453
  <div className={css.createFormField}>
@@ -488,13 +528,10 @@ function CreateForm({ type, onBack, onClose, basePath }) {
488
528
  {error && <div className={css.createFormError}>{error}</div>}
489
529
 
490
530
  <div className={css.createFormActions}>
491
- <button type="button" className={css.btnSecondary} onClick={onClose}>Cancel</button>
492
- <button type="submit" className={css.btnPrimary} disabled={submitting}>
493
- {submitting ? 'Creating…' : 'Create'}
531
+ <button type="submit" className={css.createFormSubmit} disabled={submitting}>
532
+ {submitting ? 'Creating…' : `Create ${typeLabels[type] || type}`}
494
533
  </button>
495
534
  </div>
496
-
497
- <CreateFooter />
498
535
  </form>
499
536
  )
500
537
  }
@@ -785,6 +822,7 @@ export default function Viewfinder({
785
822
  externalUrl: null,
786
823
  folder: canvas.folder,
787
824
  description: canvas.description,
825
+ pages: canvas.pages || null,
788
826
  })
789
827
  }
790
828
 
@@ -395,6 +395,7 @@
395
395
  display: flex;
396
396
  align-items: center;
397
397
  gap: 0;
398
+ min-height: 52px;
398
399
  padding: 0 32px;
399
400
  background: var(--bgColor-default, #fff);
400
401
  border-bottom: 1px solid var(--borderColor-default, #e5e5e5);
@@ -488,7 +489,7 @@
488
489
  font-weight: 600;
489
490
  text-transform: uppercase;
490
491
  letter-spacing: 0.3px;
491
- background: rgba(0,0,0,0.06);
492
+ background: var(--bgColor-neutral-muted, #f0f0f0);
492
493
  color: var(--fgColor-muted, #555);
493
494
  }
494
495
 
@@ -619,7 +620,6 @@
619
620
  .createMenuTitle {
620
621
  font-size: 18px;
621
622
  font-weight: 600;
622
- margin-bottom: 16px;
623
623
  color: var(--fgColor-default, #1a1a1a);
624
624
  }
625
625
 
@@ -673,6 +673,31 @@
673
673
 
674
674
  /* Create form styles */
675
675
 
676
+ .createFormHeader {
677
+ display: flex;
678
+ align-items: center;
679
+ justify-content: space-between;
680
+ margin-bottom: 16px;
681
+ }
682
+
683
+ .createFormClose {
684
+ display: flex;
685
+ align-items: center;
686
+ justify-content: center;
687
+ width: 28px;
688
+ height: 28px;
689
+ background: none;
690
+ border: none;
691
+ border-radius: 6px;
692
+ color: var(--fgColor-muted, #888);
693
+ cursor: pointer;
694
+ }
695
+
696
+ .createFormClose:hover {
697
+ color: var(--fgColor-default, #1a1a1a);
698
+ background: var(--bgColor-neutral-muted, #f0f0f0);
699
+ }
700
+
676
701
  .createFormField {
677
702
  margin-bottom: 14px;
678
703
  }
@@ -706,27 +731,29 @@
706
731
  }
707
732
 
708
733
  .createFormActions {
709
- display: flex;
710
- justify-content: flex-end;
711
- gap: 8px;
712
- margin-top: 16px;
734
+ margin-top: 20px;
713
735
  }
714
736
 
715
- .createFormBack {
716
- display: flex;
717
- align-items: center;
718
- gap: 4px;
719
- background: none;
737
+ .createFormSubmit {
738
+ width: 100%;
739
+ padding: 12px 16px;
740
+ background: var(--fgColor-default, #1a1a1a);
741
+ color: var(--bgColor-default, #fff);
720
742
  border: none;
721
- color: var(--fgColor-muted, #888);
743
+ border-radius: 10px;
722
744
  font-size: 16px;
745
+ font-weight: 600;
723
746
  cursor: pointer;
724
- padding: 0;
725
- margin-bottom: 12px;
747
+ transition: opacity 0.15s;
726
748
  }
727
749
 
728
- .createFormBack:hover {
729
- color: var(--fgColor-default, #1a1a1a);
750
+ .createFormSubmit:hover {
751
+ opacity: 0.85;
752
+ }
753
+
754
+ .createFormSubmit:disabled {
755
+ opacity: 0.4;
756
+ cursor: not-allowed;
730
757
  }
731
758
 
732
759
  .createFooter {
@@ -9,10 +9,13 @@ 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 { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
12
13
  import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
13
14
  import WidgetChrome from './widgets/WidgetChrome.jsx'
14
15
  import ComponentWidget from './widgets/ComponentWidget.jsx'
15
16
  import useUndoRedo from './useUndoRedo.js'
17
+ import useMarqueeSelect from './useMarqueeSelect.js'
18
+ import MarqueeOverlay from './MarqueeOverlay.jsx'
16
19
  import {
17
20
  addWidget as addWidgetApi,
18
21
  checkGitHubCliAvailable,
@@ -23,6 +26,7 @@ import {
23
26
  uploadImage,
24
27
  } from './canvasApi.js'
25
28
  import PageSelector from './PageSelector.jsx'
29
+ import Icon from '../Icon.jsx'
26
30
  import { stories as storyIndex } from 'virtual:storyboard-data-index'
27
31
  import styles from './CanvasPage.module.css'
28
32
 
@@ -35,6 +39,8 @@ const VIEWPORT_TTL_MS = 15 * 60 * 1000
35
39
  const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
36
40
  const GH_INSTALL_URL = 'https://github.com/cli/cli'
37
41
 
42
+ registerSmoothCorners()
43
+
38
44
  /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
39
45
  const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
40
46
 
@@ -1096,6 +1102,69 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1096
1102
  }
1097
1103
  }, [canvasId])
1098
1104
 
1105
+ // --- Selected widgets bridge ---
1106
+ // Writes .selectedwidgets.json so Copilot knows which canvas/widgets are active.
1107
+ // Uses a stable tabId to survive WebSocket reconnects.
1108
+ const selectionTabIdRef = useRef(Math.random().toString(36).slice(2, 10))
1109
+
1110
+ // Gather selected widget data from refs (safe for callbacks/timeouts)
1111
+ const getSelectedWidgetData = useCallback(() => {
1112
+ const ids = [...selectedIdsRef.current]
1113
+ const widgets = (stateRef.current.widgets || [])
1114
+ .filter(w => ids.includes(w.id))
1115
+ .map(w => ({ id: w.id, type: w.type, props: w.props }))
1116
+
1117
+ // Include jsx-* component selections
1118
+ for (const id of ids) {
1119
+ if (id.startsWith('jsx-') && !widgets.some(w => w.id === id)) {
1120
+ widgets.push({ id, type: 'component', props: { exportName: id.slice(4) } })
1121
+ }
1122
+ }
1123
+
1124
+ return { widgetIds: ids, widgets }
1125
+ }, [])
1126
+
1127
+ // Send focus event on mount, tab focus, and visibility change
1128
+ useEffect(() => {
1129
+ if (!import.meta.hot) return
1130
+
1131
+ const tabId = selectionTabIdRef.current
1132
+
1133
+ function sendFocus() {
1134
+ const { widgetIds, widgets } = getSelectedWidgetData()
1135
+ import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets })
1136
+ }
1137
+
1138
+ sendFocus()
1139
+
1140
+ function handleVisibility() {
1141
+ if (!document.hidden) sendFocus()
1142
+ }
1143
+ function handleFocus() { sendFocus() }
1144
+
1145
+ document.addEventListener('visibilitychange', handleVisibility)
1146
+ window.addEventListener('focus', handleFocus)
1147
+
1148
+ return () => {
1149
+ document.removeEventListener('visibilitychange', handleVisibility)
1150
+ window.removeEventListener('focus', handleFocus)
1151
+ import.meta.hot.send('storyboard:canvas-unfocused', { tabId })
1152
+ }
1153
+ }, [canvasId, getSelectedWidgetData])
1154
+
1155
+ // Debounced selection change (500ms) — reads from refs at fire time
1156
+ useEffect(() => {
1157
+ if (!import.meta.hot) return
1158
+
1159
+ const tabId = selectionTabIdRef.current
1160
+ const timer = setTimeout(() => {
1161
+ const { widgetIds, widgets } = getSelectedWidgetData()
1162
+ import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets })
1163
+ }, 500)
1164
+
1165
+ return () => clearTimeout(timer)
1166
+ }, [selectedWidgetIds, canvasId, getSelectedWidgetData])
1167
+
1099
1168
  // Add a widget by type — used by CanvasControls and CoreUIBar event
1100
1169
  const addWidget = useCallback(async (type) => {
1101
1170
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
@@ -1828,6 +1897,18 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1828
1897
  // Stable callback for deselecting all widgets
1829
1898
  const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
1830
1899
 
1900
+ // Marquee (lasso) multi-select on canvas background drag
1901
+ const { marqueeScreenRect, handleMarqueeMouseDown } = useMarqueeSelect({
1902
+ scrollRef,
1903
+ zoomRef: zoomRef,
1904
+ setSelectedWidgetIds,
1905
+ widgets: localWidgets,
1906
+ componentEntries,
1907
+ fallbackSizes: WIDGET_FALLBACK_SIZES,
1908
+ spaceHeld,
1909
+ isLocalDev,
1910
+ })
1911
+
1831
1912
  // Stable callback for widget removal + deselect
1832
1913
  const handleWidgetRemoveAndDeselect = useCallback((id) => {
1833
1914
  handleWidgetRemove(id)
@@ -1865,7 +1946,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1865
1946
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
1866
1947
  const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
1867
1948
 
1868
- // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1949
+ // Merge JSX-sourced widgets and JSON widgets
1869
1950
  const allChildren = []
1870
1951
 
1871
1952
  // 1. Component widgets (from jsxExports or sources fallback)
@@ -1954,7 +2035,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1954
2035
  return (
1955
2036
  <>
1956
2037
  <div className={styles.canvasTitle}>
1957
- <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
2038
+ <a href={(import.meta.env?.BASE_URL || '/')} className={`${styles.canvasLogo} smooth-corners`} aria-label="Go to homepage">
2039
+ <Icon name="iconoir/key-command" size={16} color="#fff" />
2040
+ </a>
2041
+ {siblingPages.length > 1 && (
2042
+ <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
2043
+ )}
1958
2044
  <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
1959
2045
  {isLocalDev && (
1960
2046
  <span className={styles.localEditingLabel}>Local editing</span>
@@ -1970,9 +2056,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1970
2056
  ...canvasThemeVars,
1971
2057
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
1972
2058
  }}
1973
- onClick={handleDeselectAll}
1974
- onMouseDown={handlePanStart}
2059
+ onMouseDown={(e) => { handlePanStart(e); handleMarqueeMouseDown(e); }}
1975
2060
  >
2061
+ <MarqueeOverlay rect={marqueeScreenRect} />
1976
2062
  <div
1977
2063
  ref={zoomElRef}
1978
2064
  data-storyboard-canvas-zoom
@@ -15,6 +15,7 @@
15
15
  }
16
16
 
17
17
  .canvasScroll {
18
+ position: relative;
18
19
  width: 100vw;
19
20
  height: calc(100vh - var(--sb-branch-bar-height, 0px));
20
21
  overflow: auto;
@@ -41,6 +42,20 @@
41
42
 
42
43
  /* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
43
44
 
45
+ .canvasLogo {
46
+ width: 32px;
47
+ height: 32px;
48
+ background: var(--bgColor-emphasis, #313131);
49
+ border-radius: 6px;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ color: var(--fgColor-onEmphasis, #fff);
54
+ flex-shrink: 0;
55
+ text-decoration: none;
56
+ transform: rotate(-1deg);
57
+ }
58
+
44
59
  .canvasTitle {
45
60
  position: fixed;
46
61
  top: calc(12px + var(--sb-branch-bar-height, 0px));
@@ -140,3 +155,13 @@
140
155
  .ghInstallBannerDismiss:hover {
141
156
  background: var(--bgColor-muted, #f6f8fa);
142
157
  }
158
+
159
+ /* Marquee selection rectangle */
160
+ .marqueeRect {
161
+ position: absolute;
162
+ background: rgba(56, 132, 255, 0.12);
163
+ border: 1.5px solid rgba(56, 132, 255, 0.6);
164
+ border-radius: 2px;
165
+ pointer-events: none;
166
+ z-index: 9999;
167
+ }