@dfosco/storyboard-core 4.2.1 → 4.2.3

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 (57) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +7973 -8418
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/package.json +2 -12
  5. package/scaffold/AGENTS.md +432 -0
  6. package/scaffold/manifest.json +13 -8
  7. package/src/ActionMenuButton.jsx +1 -1
  8. package/src/AutosyncMenuButton.jsx +1 -1
  9. package/src/CanvasAgentsMenu.jsx +1 -1
  10. package/src/CanvasCreateMenu.jsx +1 -1
  11. package/src/CanvasSnap.jsx +1 -1
  12. package/src/CanvasZoomToFit.jsx +1 -1
  13. package/src/CommandMenu.jsx +2 -2
  14. package/src/CommandPalette.jsx +1 -1
  15. package/src/CommandPaletteTrigger.jsx +1 -1
  16. package/src/CommentsMenuButton.jsx +1 -1
  17. package/src/CoreUIBar.jsx +18 -2
  18. package/src/CreateMenuButton.jsx +1 -1
  19. package/src/HideChromeTrigger.jsx +1 -1
  20. package/src/{svelte-plugin-ui/components/Icon.jsx → Icon.jsx} +8 -10
  21. package/src/ThemeMenuButton.jsx +1 -1
  22. package/src/comments/ui/authModal.js +1 -1
  23. package/src/configSchema.js +2 -0
  24. package/src/configStore.js +1 -1
  25. package/src/devtools-consumer.js +2 -2
  26. package/src/index.js +3 -3
  27. package/src/loader.js +9 -2
  28. package/src/mountStoryboardCore.js +3 -3
  29. package/src/sidepanel.css +1 -1
  30. package/src/toolbarConfigStore.js +1 -1
  31. package/src/ui/design-modes.ts +4 -51
  32. package/src/ui/viewfinder.ts +4 -55
  33. package/src/ui-entry.js +5 -5
  34. package/src/vite/server-plugin.js +9 -0
  35. package/src/workshop/features/createFlow/index.js +1 -1
  36. package/src/workshop/features/createPrototype/index.js +1 -1
  37. package/src/workshop/features/registry-server.js +1 -1
  38. package/src/workshop/ui/mount.ts +3 -65
  39. package/scaffold/svelte.config.js +0 -1
  40. package/src/svelte-plugin-ui/__tests__/ModeSwitch.test.ts +0 -75
  41. package/src/svelte-plugin-ui/__tests__/ToolbarShell.test.ts +0 -126
  42. package/src/svelte-plugin-ui/__tests__/designModes.test.ts +0 -58
  43. package/src/svelte-plugin-ui/__tests__/modeStore.test.ts +0 -53
  44. package/src/svelte-plugin-ui/__tests__/mount.test.ts +0 -29
  45. package/src/svelte-plugin-ui/components/Icon.css +0 -11
  46. package/src/svelte-plugin-ui/components/ModeSwitch.css +0 -90
  47. package/src/svelte-plugin-ui/components/ModeSwitch.jsx +0 -47
  48. package/src/svelte-plugin-ui/components/ToolbarShell.css +0 -80
  49. package/src/svelte-plugin-ui/components/ToolbarShell.jsx +0 -84
  50. package/src/svelte-plugin-ui/components/Viewfinder.css +0 -412
  51. package/src/svelte-plugin-ui/components/Viewfinder.jsx +0 -513
  52. package/src/svelte-plugin-ui/index.ts +0 -20
  53. package/src/svelte-plugin-ui/mount.ts +0 -120
  54. package/src/svelte-plugin-ui/stores/modeStore.ts +0 -91
  55. package/src/svelte-plugin-ui/stores/toolStore.ts +0 -71
  56. package/src/svelte-plugin-ui/stores/types.ts +0 -55
  57. package/src/svelte-plugin-ui/styles/base.css +0 -69
@@ -1,513 +0,0 @@
1
- /**
2
- * Viewfinder — prototype index and flow dashboard.
3
- *
4
- * Full-page component that lists prototypes as expandable groups,
5
- * each showing its flows. Global flows (not belonging to any prototype)
6
- * appear as an "Other flows" group.
7
- *
8
- * Mounted via mountViewfinder() from the viewfinder plugin entry point.
9
- */
10
-
11
- import { useState, useMemo, useEffect, useCallback } from 'react'
12
- import './Viewfinder.css'
13
- import { buildPrototypeIndex } from '../../viewfinder.js'
14
- import { getLocal, setLocal } from '../../localStorage.js'
15
- import { Icon } from './Icon.jsx'
16
-
17
- const VIEW_MODE_KEY = 'viewfinder.viewMode'
18
- const EXPANDED_KEY = 'viewfinder.expanded'
19
-
20
- function loadExpanded() {
21
- const raw = getLocal(EXPANDED_KEY)
22
- if (!raw) return {}
23
- try { return JSON.parse(raw) } catch { return {} }
24
- }
25
-
26
- function formatName(name) {
27
- return name
28
- .split('-')
29
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
30
- .join(' ')
31
- }
32
-
33
- function placeholderSvg(name) {
34
- const h = (function hashStr(s) {
35
- let v = 0
36
- for (let i = 0; i < s.length; i++) v = ((v << 5) - v + s.charCodeAt(i)) | 0
37
- return Math.abs(v)
38
- })(name)
39
-
40
- let rects = ''
41
- for (let i = 0; i < 12; i++) {
42
- const s = h * (i + 1)
43
- const x = (s * 7 + i * 31) % 320
44
- const y = (s * 13 + i * 17) % 200
45
- const w = 20 + (s * (i + 3)) % 80
46
- const ht = 8 + (s * (i + 7)) % 40
47
- const opacity = 0.06 + ((s * (i + 2)) % 20) / 100
48
- const fill = i % 3 === 0 ? 'var(--sb--placeholder-accent)' : i % 3 === 1 ? 'var(--sb--placeholder-fg)' : 'var(--sb--placeholder-muted)'
49
- rects += `<rect x="${x}" y="${y}" width="${w}" height="${ht}" rx="2" fill="${fill}" opacity="${opacity}" />`
50
- }
51
-
52
- let lines = ''
53
- for (let i = 0; i < 6; i++) {
54
- const s = h * (i + 5)
55
- const y = 10 + (s % 180)
56
- lines += `<line x1="0" y1="${y}" x2="320" y2="${y}" stroke="var(--sb--placeholder-grid)" stroke-width="0.5" opacity="0.4" />`
57
- }
58
- for (let i = 0; i < 8; i++) {
59
- const s = h * (i + 9)
60
- const x = 10 + (s % 300)
61
- lines += `<line x1="${x}" y1="0" x2="${x}" y2="200" stroke="var(--sb--placeholder-grid)" stroke-width="0.5" opacity="0.3" />`
62
- }
63
-
64
- return `<svg viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="320" height="200" fill="var(--sb--placeholder-bg)" />${lines}${rects}</svg>`
65
- }
66
-
67
- /* ── Author block ── */
68
- function AuthorBlock({ author, gitAuthor }) {
69
- if (author) {
70
- const authors = Array.isArray(author) ? author : [author]
71
- return (
72
- <div className="author">
73
- <span className="authorAvatars">
74
- {authors.map((a) => (
75
- <img key={a} src={`https://github.com/${a}.png?size=48`} alt={a} className="authorAvatar" />
76
- ))}
77
- </span>
78
- <span className="authorName">{authors.join(', ')}</span>
79
- </div>
80
- )
81
- }
82
- if (gitAuthor) {
83
- return <p className="authorPlain">{gitAuthor}</p>
84
- }
85
- return null
86
- }
87
-
88
- /* ── Proto entry ── */
89
- function ProtoEntry({ proto, isExpanded, toggle, withBase, showThumbnails }) {
90
- if (proto.isExternal) {
91
- return (
92
- <section className="protoGroup">
93
- <a className="listItem" href={proto.externalUrl} target="_blank" rel="noopener noreferrer">
94
- <div className="cardBody">
95
- <p className="protoName">
96
- {proto.icon && <span className="protoIcon">{proto.icon}</span>}
97
- {proto.name}
98
- <span className="externalBadge">
99
- <Icon size={12} color="var(--fgColor-muted)" name="primer/link-external" offsetY={-2} />
100
- external
101
- </span>
102
- </p>
103
- {proto.description && <p className="protoDesc">{proto.description}</p>}
104
- <AuthorBlock author={proto.author} gitAuthor={proto.gitAuthor} />
105
- </div>
106
- </a>
107
- </section>
108
- )
109
- }
110
-
111
- if (proto.hideFlows && proto.flows.length === 1) {
112
- return (
113
- <section className="protoGroup">
114
- <a className="listItem" href={withBase(proto.flows[0].route)}>
115
- <div className="cardBody">
116
- <p className={`protoName${proto.dirName === '__global__' ? ' otherflows' : ''}`}>
117
- {proto.icon && <span className="protoIcon">{proto.icon}</span>}
118
- {proto.name}
119
- </p>
120
- {proto.description && <p className="protoDesc">{proto.description}</p>}
121
- <AuthorBlock author={proto.author} gitAuthor={proto.gitAuthor} />
122
- </div>
123
- </a>
124
- </section>
125
- )
126
- }
127
-
128
- if (proto.flows.length > 0) {
129
- const expanded = isExpanded(proto.dirName)
130
- return (
131
- <section className="protoGroup">
132
- <button
133
- className="listItem protoHeader"
134
- onClick={() => toggle(proto.dirName)}
135
- aria-expanded={expanded}
136
- >
137
- <div className="cardBody">
138
- <p className={`protoName${proto.dirName === '__global__' ? ' otherflows' : ''}`}>
139
- {proto.icon && <span className="protoIcon">{proto.icon}</span>}
140
- {proto.name}
141
- <span className="protoChevron">
142
- {expanded
143
- ? <Icon size={12} color="var(--fgColor-disabled)" name="primer/chevron-down" offsetY={-3} offsetX={2} />
144
- : <Icon size={12} color="var(--fgColor-disabled)" name="primer/chevron-right" offsetY={-3} offsetX={2} />
145
- }
146
- </span>
147
- </p>
148
- {proto.description && <p className="protoDesc">{proto.description}</p>}
149
- <AuthorBlock author={proto.author} gitAuthor={proto.gitAuthor} />
150
- </div>
151
- </button>
152
-
153
- {!(proto.hideFlows && proto.flows.length === 1) && expanded && proto.flows.length > 0 && (
154
- <div className="flowList">
155
- {proto.flows.map((flow) => (
156
- <a key={flow.key} href={withBase(flow.route)} className="listItem flowItem">
157
- {showThumbnails && (
158
- <div className="thumbnail" dangerouslySetInnerHTML={{ __html: placeholderSvg(flow.key) }} />
159
- )}
160
- <div className="cardBody">
161
- <p className="protoName">{flow.meta?.title || formatName(flow.name)}</p>
162
- {flow.meta?.description && <p className="flowDesc">{flow.meta.description}</p>}
163
- </div>
164
- </a>
165
- ))}
166
- </div>
167
- )}
168
- </section>
169
- )
170
- }
171
-
172
- // Prototype with no flows — navigates directly
173
- return (
174
- <section className="protoGroup">
175
- <a className="listItem" href={withBase(`/${proto.dirName}`)}>
176
- <div className="cardBody">
177
- <p className={`protoName${proto.dirName === '__global__' ? ' otherflows' : ''}`}>
178
- {proto.icon && <span className="protoIcon">{proto.icon}</span>}
179
- {proto.name}
180
- </p>
181
- {proto.description && <p className="protoDesc">{proto.description}</p>}
182
- <AuthorBlock author={proto.author} gitAuthor={proto.gitAuthor} />
183
- </div>
184
- </a>
185
- </section>
186
- )
187
- }
188
-
189
- /* ── Canvas entry ── */
190
- function CanvasEntry({ canvas, withBase }) {
191
- const authors = canvas.author
192
- ? (Array.isArray(canvas.author) ? canvas.author : [canvas.author])
193
- : null
194
-
195
- return (
196
- <section className="protoGroup">
197
- <a className="listItem" href={withBase(canvas.route)}>
198
- <div className="cardBody">
199
- <p className="protoName">
200
- <span className="protoIcon">{canvas.icon || ''}</span>
201
- {canvas.name}
202
- </p>
203
- {canvas.description && <p className="protoDesc">{canvas.description}</p>}
204
- {authors ? (
205
- <div className="author">
206
- <span className="authorAvatars">
207
- {authors.map((a) => (
208
- <img key={a} src={`https://github.com/${a}.png?size=48`} alt={a} className="authorAvatar" />
209
- ))}
210
- </span>
211
- <span className="authorName">{authors.join(', ')}</span>
212
- </div>
213
- ) : canvas.gitAuthor ? (
214
- <p className="authorPlain">{canvas.gitAuthor}</p>
215
- ) : null}
216
- </div>
217
- </a>
218
- </section>
219
- )
220
- }
221
-
222
- /* ── Folder section ── */
223
- function FolderSection({ folder, isExpanded, toggle, renderItems, itemKey = 'prototypes' }) {
224
- const key = `folder:${folder.dirName}`
225
- const expanded = isExpanded(key)
226
- const items = folder[itemKey] || []
227
-
228
- return (
229
- <section className={`folderGroup${expanded ? ' folderGroupOpen' : ''}`}>
230
- <button
231
- className="folderHeader"
232
- onClick={() => toggle(key)}
233
- aria-expanded={expanded}
234
- >
235
- <p className="folderName">
236
- <span>
237
- {expanded
238
- ? <Icon size={20} offsetY={-1.5} name="folder-open" color="#54aeff" />
239
- : <Icon size={20} offsetY={-1.5} name="folder" color="#54aeff" />
240
- }
241
- </span>
242
- {folder.name}
243
- </p>
244
- {folder.description && <p className="folderDesc">{folder.description}</p>}
245
- </button>
246
- {expanded && items.length > 0 && (
247
- <div className="folderContent">
248
- {renderItems(items)}
249
- </div>
250
- )}
251
- </section>
252
- )
253
- }
254
-
255
- /* ── Main Viewfinder component ── */
256
- export function Viewfinder({
257
- title = 'Storyboard',
258
- subtitle = '',
259
- basePath = '/',
260
- knownRoutes = [],
261
- showThumbnails = false,
262
- hideDefaultFlow = false,
263
- }) {
264
- const prototypeIndex = useMemo(() => buildPrototypeIndex(knownRoutes), [knownRoutes])
265
-
266
- const globalFlows = useMemo(() => {
267
- return hideDefaultFlow
268
- ? prototypeIndex.globalFlows.filter((f) => f.key !== 'default')
269
- : prototypeIndex.globalFlows
270
- }, [prototypeIndex, hideDefaultFlow])
271
-
272
- const ungroupedProtos = prototypeIndex.prototypes
273
- const folders = prototypeIndex.folders || []
274
-
275
- const otherFlows = useMemo(() => {
276
- if (globalFlows.length === 0) return null
277
- return {
278
- name: 'Other flows',
279
- dirName: '__global__',
280
- description: null,
281
- author: null,
282
- gitAuthor: null,
283
- lastModified: null,
284
- icon: null,
285
- team: null,
286
- tags: null,
287
- flows: globalFlows,
288
- }
289
- }, [globalFlows])
290
-
291
- const totalProtos = ungroupedProtos.length + folders.reduce((sum, f) => sum + f.prototypes.length, 0)
292
-
293
- // Sorting
294
- const [sortBy, setSortBy] = useState('updated')
295
- const sortedProtos = prototypeIndex.sorted?.[sortBy]?.prototypes ?? ungroupedProtos
296
- const _sortedFolders = prototypeIndex.sorted?.[sortBy]?.folders ?? folders
297
- void _sortedFolders
298
-
299
- // Canvases
300
- const ungroupedCanvases = prototypeIndex.canvases || []
301
- const sortedCanvases = prototypeIndex.sorted?.[sortBy]?.canvases ?? ungroupedCanvases
302
- const totalCanvases = ungroupedCanvases.length + folders.reduce((sum, f) => sum + (f.canvases?.length || 0), 0)
303
-
304
- // View mode
305
- const [viewMode, setViewMode] = useState(() =>
306
- getLocal(VIEW_MODE_KEY) === 'canvases' ? 'canvases' : 'prototypes'
307
- )
308
-
309
- useEffect(() => {
310
- setLocal(VIEW_MODE_KEY, viewMode)
311
- }, [viewMode])
312
-
313
- // Canvas folders
314
- const canvasFolders = useMemo(() => {
315
- const src = prototypeIndex.sorted?.[sortBy]?.folders ?? folders
316
- return src
317
- .filter((f) => f.canvases && f.canvases.length > 0)
318
- .map((f) => ({ ...f, prototypes: [], canvases: f.canvases }))
319
- }, [prototypeIndex, sortBy, folders])
320
-
321
- // Proto-only folders
322
- const protoOnlyFolders = useMemo(() => {
323
- const src = prototypeIndex.sorted?.[sortBy]?.folders ?? folders
324
- return src
325
- .filter((f) => f.prototypes.length > 0)
326
- .map((f) => ({ ...f, canvases: [] }))
327
- }, [prototypeIndex, sortBy, folders])
328
-
329
- // Expanded state
330
- const [expanded, setExpanded] = useState(loadExpanded)
331
-
332
- const isExpandedFn = useCallback((dirName) => expanded[dirName] ?? false, [expanded])
333
-
334
- const toggleFn = useCallback((dirName) => {
335
- setExpanded((prev) => {
336
- const next = { ...prev, [dirName]: !(prev[dirName] ?? false) }
337
- setLocal(EXPANDED_KEY, JSON.stringify(next))
338
- return next
339
- })
340
- }, [])
341
-
342
- // URL helpers
343
- const withBase = useCallback((route) => {
344
- const normalizedRoute = route.startsWith('/') ? route : `/${route}`
345
- const normalizedBase = (basePath || '/').replace(/\/+$/, '')
346
- if (!normalizedBase || normalizedBase === '/') return normalizedRoute
347
- return `${normalizedBase}${normalizedRoute}`.replace(/\/+/g, '/')
348
- }, [basePath])
349
-
350
- // Branch switching
351
- const branches = (typeof window !== 'undefined' && Array.isArray(window.__SB_BRANCHES__))
352
- ? window.__SB_BRANCHES__
353
- : null
354
-
355
- const branchBasePath = (basePath || '/storyboard-source/').replace(/\/branch--[^/]*\/$/, '/')
356
- const currentBranch = useMemo(() => {
357
- const m = (basePath || '').match(/\/branch--([^/]+)\/?$/)
358
- return m ? m[1] : 'main'
359
- }, [basePath])
360
-
361
- function handleBranchChange(e) {
362
- const folder = e.target.value
363
- if (folder) {
364
- window.location.href = `${branchBasePath}${folder}/`
365
- }
366
- }
367
-
368
- return (
369
- <div className="container">
370
- <header className="header">
371
- <div className="headerTop">
372
- <div>
373
- <h1 className="title">{title}</h1>
374
- {subtitle && <p className="subtitle">{subtitle}</p>}
375
- </div>
376
- </div>
377
- <div className="controlsRow">
378
- {/* View mode toggle */}
379
- <div className="sortToggle">
380
- <button
381
- className={`sortButton${viewMode === 'prototypes' ? ' sortButtonActive' : ''}`}
382
- onClick={() => setViewMode('prototypes')}
383
- >
384
- Prototypes
385
- </button>
386
- <button
387
- className={`sortButton${viewMode === 'canvases' ? ' sortButtonActive' : ''}`}
388
- onClick={() => setViewMode('canvases')}
389
- >
390
- Canvas
391
- </button>
392
- </div>
393
- {/* Sort toggle — hidden for now */}
394
- <div className="sortToggle" style={{ display: 'none' }}>
395
- <button
396
- className={`sortButton${sortBy === 'updated' ? ' sortButtonActive' : ''}`}
397
- onClick={() => setSortBy('updated')}
398
- >
399
- <Icon name="primer/clock" size={14} color="var(--fgColor-muted)" />
400
- Last updated
401
- </button>
402
- <button
403
- className={`sortButton${sortBy === 'title' ? ' sortButtonActive' : ''}`}
404
- onClick={() => setSortBy('title')}
405
- >
406
- <Icon name="primer/sort-asc" size={14} color="var(--fgColor-muted)" />
407
- Title A–Z
408
- </button>
409
- </div>
410
- {branches && branches.length > 0 && (
411
- <div className="branchDropdown">
412
- <span className="branchIcon">
413
- <Icon size={16} color="var(--fgColor-muted)" offsetY={-1} offsetX={2} name="primer/git-branch" />
414
- </span>
415
- <select
416
- className="branchSelect"
417
- onChange={handleBranchChange}
418
- aria-label="Switch branch"
419
- defaultValue=""
420
- >
421
- <option value="" disabled>{currentBranch}</option>
422
- {branches.map((b) => (
423
- <option key={b.folder} value={b.folder}>{b.branch}</option>
424
- ))}
425
- </select>
426
- </div>
427
- )}
428
- </div>
429
- </header>
430
-
431
- {viewMode === 'prototypes' && totalProtos === 0 && folders.length === 0 ? (
432
- <p className="empty">No flows found. Add a <code>*.flow.json</code> file to get started.</p>
433
- ) : viewMode === 'canvases' && totalCanvases === 0 ? (
434
- <p className="empty">No canvases found. Add a <code>*.canvas.jsonl</code> file to get started.</p>
435
- ) : (
436
- <div className="list">
437
- {viewMode === 'prototypes' ? (
438
- <>
439
- {protoOnlyFolders.map((folder) => (
440
- <FolderSection
441
- key={folder.dirName}
442
- folder={folder}
443
- isExpanded={isExpandedFn}
444
- toggle={toggleFn}
445
- renderItems={(protos) =>
446
- protos.map((proto) => (
447
- <ProtoEntry
448
- key={proto.dirName}
449
- proto={proto}
450
- isExpanded={isExpandedFn}
451
- toggle={toggleFn}
452
- withBase={withBase}
453
- showThumbnails={showThumbnails}
454
- />
455
- ))
456
- }
457
- />
458
- ))}
459
-
460
- {sortedProtos.map((proto) => (
461
- <ProtoEntry
462
- key={proto.dirName}
463
- proto={proto}
464
- isExpanded={isExpandedFn}
465
- toggle={toggleFn}
466
- withBase={withBase}
467
- showThumbnails={showThumbnails}
468
- />
469
- ))}
470
-
471
- {otherFlows && (
472
- <ProtoEntry
473
- proto={otherFlows}
474
- isExpanded={isExpandedFn}
475
- toggle={toggleFn}
476
- withBase={withBase}
477
- showThumbnails={showThumbnails}
478
- />
479
- )}
480
- </>
481
- ) : (
482
- <>
483
- <div className="canvasWarning">
484
- <Icon size={14} name="primer/alert" color="#9a6700" offsetY={-1} />
485
- <span>Canvas is an experimental feature. Use with caution.</span>
486
- </div>
487
- {canvasFolders.map((folder) => (
488
- <FolderSection
489
- key={folder.dirName}
490
- folder={folder}
491
- isExpanded={isExpandedFn}
492
- toggle={toggleFn}
493
- itemKey="canvases"
494
- renderItems={(canvases) =>
495
- canvases.map((canvas) => (
496
- <CanvasEntry key={canvas.dirName} canvas={canvas} withBase={withBase} />
497
- ))
498
- }
499
- />
500
- ))}
501
-
502
- {sortedCanvases.map((canvas) => (
503
- <CanvasEntry key={canvas.dirName} canvas={canvas} withBase={withBase} />
504
- ))}
505
- </>
506
- )}
507
- </div>
508
- )}
509
- </div>
510
- )
511
- }
512
-
513
- export default Viewfinder
@@ -1,20 +0,0 @@
1
- /**
2
- * Svelte plugin UI — Svelte component framework for core plugin UIs.
3
- *
4
- * Part of @dfosco/storyboard-core. Provides mount utilities, shared styles,
5
- * and reusable components that any storyboard core plugin can use,
6
- * independent of the prototype app's frontend framework.
7
- */
8
-
9
- // Mount utility
10
- export { mountSveltePlugin, injectStyles, type PluginHandle } from './mount.js'
11
-
12
- // Stores
13
- export { modeState, switchMode } from './stores/modeStore.js'
14
-
15
- // Type re-exports
16
- export type { ModeState } from './stores/modeStore.js'
17
- export type { ModeConfig, ModeToolConfig } from './stores/types.js'
18
-
19
- // Viewfinder
20
- export { mountViewfinder, unmountViewfinder, type ViewfinderProps } from './plugins/viewfinder.js'
@@ -1,120 +0,0 @@
1
- /**
2
- * Generic mount utility for React plugin components.
3
- *
4
- * Provides a framework-agnostic way to mount React components into any
5
- * DOM target. Handles shared style injection (idempotent) and returns
6
- * a destroy handle for cleanup.
7
- *
8
- * Usage:
9
- * import { mountSveltePlugin } from '@dfosco/storyboard-core/svelte-plugin-ui'
10
- * import MyComponent from './MyComponent.jsx'
11
- *
12
- * const handle = mountSveltePlugin(document.body, MyComponent, { someProp: 'value' })
13
- * // later: handle.destroy()
14
- */
15
-
16
- import { createElement, type ComponentType } from 'react'
17
- import { createRoot, type Root } from 'react-dom/client'
18
-
19
- const STYLE_ID = 'sb-svelte-ui-styles'
20
-
21
- let stylesInjected = false
22
-
23
- /**
24
- * Check whether the shared base styles are already present in the document
25
- * (e.g. bundled by Vite's CSS code-splitting for the ui-entry chunk).
26
- */
27
- function stylesAlreadyLoaded(): boolean {
28
- if (typeof document === 'undefined') return false
29
- // Check for an existing sb- custom property which is defined in the
30
- // base stylesheet. If any <style> or <link> already defines it, the
31
- // styles are present and we can skip dynamic injection.
32
- try {
33
- const val = getComputedStyle(document.documentElement).getPropertyValue('--sb--bg')
34
- if (val && val.trim()) return true
35
- } catch { /* SSR or non-standard env — fall through */ }
36
- return false
37
- }
38
-
39
- /**
40
- * Inject shared base styles (Tachyons + sb-* tokens) into <head>.
41
- * Idempotent — only injects once per page. Returns a promise that
42
- * resolves when the stylesheet is ready.
43
- */
44
- export function injectStyles(): Promise<void> {
45
- if (stylesInjected) return Promise.resolve()
46
- if (typeof document === 'undefined') return Promise.resolve()
47
- if (document.getElementById(STYLE_ID)) {
48
- stylesInjected = true
49
- return Promise.resolve()
50
- }
51
-
52
- // If Vite already bundled the styles into the page, skip the <link>.
53
- if (stylesAlreadyLoaded()) {
54
- stylesInjected = true
55
- return Promise.resolve()
56
- }
57
-
58
- return new Promise<void>((resolve) => {
59
- const link = document.createElement('link')
60
- link.id = STYLE_ID
61
- link.rel = 'stylesheet'
62
- link.href = new URL('../styles/tailwind.css', import.meta.url).href
63
- link.onload = () => resolve()
64
- link.onerror = () => resolve() // resolve anyway so the UI still appears
65
- document.head.appendChild(link)
66
- stylesInjected = true
67
- })
68
- }
69
-
70
- export interface PluginHandle {
71
- /** Remove the component and its wrapper from the DOM */
72
- destroy: () => void
73
- /** The wrapper element containing the React component */
74
- element: HTMLElement
75
- /** Resolves when all styles are loaded and the component is ready to show */
76
- ready: Promise<void>
77
- }
78
-
79
- /**
80
- * Mount a React component into a DOM target.
81
- *
82
- * @param target - DOM element to append the component wrapper to
83
- * @param ComponentClass - React component
84
- * @param props - Props to pass to the component
85
- * @returns Handle with destroy() method and a `ready` promise
86
- */
87
- export function mountSveltePlugin<T extends Record<string, unknown>>(
88
- target: HTMLElement,
89
- ComponentClass: ComponentType<T>,
90
- props?: T,
91
- ): PluginHandle {
92
- const stylesReady = injectStyles()
93
-
94
- const wrapper = document.createElement('div')
95
- wrapper.classList.add('sb-plugin-root')
96
- wrapper.style.display = 'contents'
97
- target.appendChild(wrapper)
98
-
99
- const root: Root = createRoot(wrapper)
100
- root.render(createElement(ComponentClass, props ?? ({} as T)))
101
-
102
- return {
103
- element: wrapper,
104
- ready: stylesReady,
105
- destroy() {
106
- root.unmount()
107
- wrapper.remove()
108
- },
109
- }
110
- }
111
-
112
- /**
113
- * Reset style injection state. Only for use in tests.
114
- */
115
- export function _resetStyles(): void {
116
- stylesInjected = false
117
- if (typeof document !== 'undefined') {
118
- document.getElementById(STYLE_ID)?.remove()
119
- }
120
- }