@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.5

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.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.3",
3
+ "version": "4.0.0-beta.5",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.3",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.3",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.5",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.5",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -0,0 +1,50 @@
1
+ import { Component } from 'react'
2
+
3
+ /**
4
+ * Error boundary for canvas component widgets.
5
+ * Catches render-time errors so a single broken component
6
+ * doesn't crash the entire canvas page.
7
+ *
8
+ * Used as a production fallback when iframe isolation is not available.
9
+ */
10
+ export default class ComponentErrorBoundary extends Component {
11
+ constructor(props) {
12
+ super(props)
13
+ this.state = { error: null }
14
+ }
15
+
16
+ static getDerivedStateFromError(error) {
17
+ return { error }
18
+ }
19
+
20
+ componentDidCatch(error, info) {
21
+ console.error(
22
+ `[storyboard] Component widget "${this.props.name || 'unknown'}" crashed:`,
23
+ error,
24
+ info?.componentStack,
25
+ )
26
+ }
27
+
28
+ render() {
29
+ if (this.state.error) {
30
+ return (
31
+ <div style={{
32
+ padding: '16px',
33
+ color: '#cf222e',
34
+ fontFamily: 'system-ui, -apple-system, sans-serif',
35
+ fontSize: '13px',
36
+ lineHeight: 1.5,
37
+ whiteSpace: 'pre-wrap',
38
+ wordBreak: 'break-word',
39
+ minWidth: 200,
40
+ minHeight: 60,
41
+ }}>
42
+ <strong>{this.props.name || 'Component'}</strong>
43
+ <br />
44
+ {String(this.state.error.message || this.state.error)}
45
+ </div>
46
+ )
47
+ }
48
+ return this.props.children
49
+ }
50
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { enableCanvasGuard, disableCanvasGuard, isCanvasGuardActive } from './canvasReloadGuard.js'
3
+
4
+ describe('canvasReloadGuard', () => {
5
+ beforeEach(() => {
6
+ disableCanvasGuard()
7
+ })
8
+
9
+ it('starts inactive', () => {
10
+ expect(isCanvasGuardActive()).toBe(false)
11
+ })
12
+
13
+ it('can be enabled and disabled', () => {
14
+ enableCanvasGuard()
15
+ expect(isCanvasGuardActive()).toBe(true)
16
+ disableCanvasGuard()
17
+ expect(isCanvasGuardActive()).toBe(false)
18
+ })
19
+
20
+ it('enable is idempotent', () => {
21
+ enableCanvasGuard()
22
+ enableCanvasGuard()
23
+ expect(isCanvasGuardActive()).toBe(true)
24
+ disableCanvasGuard()
25
+ expect(isCanvasGuardActive()).toBe(false)
26
+ })
27
+ })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Canvas Component Isolate — iframe entry point.
3
+ *
4
+ * Renders a single named export from a .canvas.jsx module inside an
5
+ * isolated document. The parent CanvasPage embeds this via an iframe
6
+ * so a broken component cannot crash the entire canvas.
7
+ *
8
+ * Query params:
9
+ * module — absolute or base-relative path to the .canvas.jsx file
10
+ * export — the named export to render
11
+ * theme — canvas theme (light / dark / dark_dimmed)
12
+ */
13
+ import { createElement, Component as ReactComponent } from 'react'
14
+ import { createRoot } from 'react-dom/client'
15
+
16
+ // ── Error Boundary ──────────────────────────────────────────────────
17
+ class IsolateErrorBoundary extends ReactComponent {
18
+ constructor(props) {
19
+ super(props)
20
+ this.state = { error: null }
21
+ }
22
+ static getDerivedStateFromError(error) {
23
+ return { error }
24
+ }
25
+ render() {
26
+ if (this.state.error) {
27
+ return createElement('div', { style: errorStyle },
28
+ createElement('strong', null, this.props.name || 'Component'),
29
+ createElement('br'),
30
+ String(this.state.error.message || this.state.error),
31
+ )
32
+ }
33
+ return this.props.children
34
+ }
35
+ }
36
+
37
+ // ── Styles ──────────────────────────────────────────────────────────
38
+ const errorStyle = {
39
+ padding: '16px',
40
+ color: '#cf222e',
41
+ fontFamily: 'system-ui, -apple-system, sans-serif',
42
+ fontSize: '13px',
43
+ lineHeight: 1.5,
44
+ whiteSpace: 'pre-wrap',
45
+ wordBreak: 'break-word',
46
+ }
47
+
48
+ // ── Resolve module path (mirrors useCanvas.resolveCanvasModuleImport) ─
49
+ function resolveModulePath(raw) {
50
+ if (!raw) return raw
51
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return raw
52
+ if (!raw.startsWith('/')) return raw
53
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
54
+ if (!base) return raw
55
+ if (raw.startsWith(base)) return raw
56
+ return `${base}${raw}`
57
+ }
58
+
59
+ // ── Main ────────────────────────────────────────────────────────────
60
+ const params = new URLSearchParams(window.location.search)
61
+ const modulePath = params.get('module')
62
+ const exportName = params.get('export')
63
+ const theme = params.get('theme') || 'light'
64
+
65
+ // Apply theme to document for Primer / CSS-var inheritance
66
+ document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
67
+ document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
68
+ document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
69
+
70
+ const root = createRoot(document.getElementById('root'))
71
+
72
+ async function mount() {
73
+ if (!modulePath || !exportName) {
74
+ root.render(createElement('div', { style: errorStyle }, 'Missing module or export param'))
75
+ return
76
+ }
77
+
78
+ // Validate: only allow .canvas.jsx modules
79
+ if (!modulePath.endsWith('.canvas.jsx')) {
80
+ root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .canvas.jsx files are allowed'))
81
+ return
82
+ }
83
+
84
+ try {
85
+ const resolved = resolveModulePath(modulePath)
86
+ const mod = await import(/* @vite-ignore */ resolved)
87
+ const Component = mod[exportName]
88
+
89
+ if (!Component || typeof Component !== 'function') {
90
+ throw new Error(`Export "${exportName}" not found or is not a component`)
91
+ }
92
+
93
+ root.render(
94
+ createElement(IsolateErrorBoundary, { name: exportName },
95
+ createElement(Component),
96
+ ),
97
+ )
98
+ } catch (err) {
99
+ root.render(
100
+ createElement('div', { style: errorStyle },
101
+ createElement('strong', null, exportName),
102
+ createElement('br'),
103
+ String(err.message || err),
104
+ ),
105
+ )
106
+ }
107
+ }
108
+
109
+ mount()
@@ -236,7 +236,7 @@
236
236
  position: absolute;
237
237
  top: calc(100% + 10px);
238
238
  right: 0;
239
- min-width: 180px;
239
+ min-width: max-content;
240
240
  padding: 4px;
241
241
  background: var(--bgColor-default, #ffffff);
242
242
  border-radius: 10px;
@@ -265,6 +265,7 @@
265
265
  color: var(--fgColor-default, #1f2328);
266
266
  border-radius: 6px;
267
267
  box-sizing: border-box;
268
+ white-space: nowrap;
268
269
  }
269
270
 
270
271
  :global([data-sb-canvas-theme^='dark']) .overflowItem {
@@ -155,14 +155,101 @@ function getLastModified(root, dirPath) {
155
155
  }
156
156
  }
157
157
 
158
+ /**
159
+ * Batch-fetch git metadata (author + lastModified) for multiple files in a
160
+ * single subprocess, avoiding per-file git overhead during startup.
161
+ *
162
+ * Returns a Map<absPath, { gitAuthor: string|null, lastModified: string|null }>
163
+ */
164
+ function batchGitMetadata(root, filePaths) {
165
+ const result = new Map()
166
+ if (filePaths.length === 0) return result
167
+
168
+ // Initialize all entries
169
+ for (const fp of filePaths) {
170
+ result.set(fp, { gitAuthor: null, lastModified: null })
171
+ }
172
+
173
+ try {
174
+ // Batch lastModified: one git log call with all paths
175
+ // git log -1 gives the most recent commit touching any of these paths,
176
+ // but we need per-path data. Use --name-only to correlate.
177
+ // For efficiency, use a single git log with --format and --name-only
178
+ // that outputs one record per commit touching these files.
179
+ const allDirs = [...new Set(filePaths.map(fp => path.dirname(fp)))]
180
+ const dirsArg = allDirs.map(d => `"${d}"`).join(' ')
181
+
182
+ // Get lastModified per directory in one call using git log --format
183
+ // We output "MARKER<sep>dir<sep>date" per commit, then take the latest per dir.
184
+ const logResult = execSync(
185
+ `git log --format="%aI" --name-only -- ${dirsArg}`,
186
+ { cwd: root, encoding: 'utf-8', timeout: 10000, maxBuffer: 1024 * 1024 },
187
+ ).trim()
188
+
189
+ if (logResult) {
190
+ // Parse: alternating date lines and filename lines separated by blank lines
191
+ const blocks = logResult.split('\n\n')
192
+ const dirDates = new Map() // dir → most recent date
193
+ for (const block of blocks) {
194
+ const lines = block.split('\n').filter(Boolean)
195
+ if (lines.length < 2) continue
196
+ const date = lines[0]
197
+ for (let li = 1; li < lines.length; li++) {
198
+ const fileLine = lines[li].trim()
199
+ if (!fileLine) continue
200
+ const dir = path.dirname(path.resolve(root, fileLine))
201
+ if (!dirDates.has(dir)) {
202
+ dirDates.set(dir, date)
203
+ }
204
+ }
205
+ }
206
+ for (const fp of filePaths) {
207
+ const dir = path.dirname(fp)
208
+ const entry = result.get(fp)
209
+ if (dirDates.has(dir) && entry) {
210
+ entry.lastModified = dirDates.get(dir)
211
+ }
212
+ }
213
+ }
214
+ } catch { /* git not available or failed — leave nulls */ }
215
+
216
+ // Batch gitAuthor: use git log for each file's creation author.
217
+ // Unfortunately --follow --diff-filter=A doesn't combine well with multiple
218
+ // paths, so batch them in a single shell invocation using a for loop.
219
+ try {
220
+ const relPaths = filePaths.map(fp => path.relative(root, fp))
221
+ // Build a shell script that outputs "PATH<tab>AUTHOR" per file
222
+ const cmds = relPaths.map(rp =>
223
+ `echo -n "${rp}\\t"; git log --follow --diff-filter=A --format="%aN" -- "${rp}" | tail -1`
224
+ ).join('; ')
225
+ const authorResult = execSync(cmds, {
226
+ cwd: root, encoding: 'utf-8', timeout: 10000, shell: true, maxBuffer: 1024 * 1024,
227
+ }).trim()
228
+
229
+ if (authorResult) {
230
+ for (const line of authorResult.split('\n')) {
231
+ const tabIdx = line.indexOf('\t')
232
+ if (tabIdx < 0) continue
233
+ const relPath = line.slice(0, tabIdx)
234
+ const author = line.slice(tabIdx + 1).trim()
235
+ if (!author) continue
236
+ const absPath2 = path.resolve(root, relPath)
237
+ const entry = result.get(absPath2)
238
+ if (entry) entry.gitAuthor = author
239
+ }
240
+ }
241
+ } catch { /* git not available */ }
242
+
243
+ return result
244
+ }
245
+
158
246
  /**
159
247
  * Scan the repo for all data files, validate uniqueness, return the index.
160
248
  */
161
249
  function buildIndex(root) {
162
- const ignore = ['node_modules/**', 'dist/**', '.git/**']
163
- // Scope to src/ all data files live there (avoids walking .worktrees/, public/, etc.)
164
- const files = globSync(`src/${GLOB_PATTERN}`, { cwd: root, ignore, absolute: false })
165
- const canvasFiles = globSync(`src/${CANVAS_GLOB_PATTERN}`, { cwd: root, ignore, absolute: false })
250
+ const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
251
+ const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
252
+ const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
166
253
 
167
254
  // Detect nested .folder/ directories (not supported)
168
255
  // Scan directories directly since empty nested folders have no data files
@@ -340,6 +427,13 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
340
427
  const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
341
428
  let i = 0
342
429
 
430
+ // Batch-fetch git metadata for all prototype + canvas files in 1-2 subprocesses
431
+ const gitPaths = [
432
+ ...Object.values(index.prototype || {}),
433
+ ...Object.values(index.canvas || {}),
434
+ ]
435
+ const gitMeta = batchGitMetadata(root, gitPaths)
436
+
343
437
  for (const suffix of INDEX_KEYS) {
344
438
  for (const [name, absPath] of Object.entries(index[suffix])) {
345
439
  const varName = `_d${i++}`
@@ -350,18 +444,17 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
350
444
 
351
445
  // Auto-fill gitAuthor for prototype metadata from git history
352
446
  if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
353
- const gitAuthor = getGitAuthor(root, absPath)
354
- if (gitAuthor) {
355
- parsed = { ...parsed, gitAuthor }
447
+ const meta = gitMeta.get(absPath)
448
+ if (meta?.gitAuthor) {
449
+ parsed = { ...parsed, gitAuthor: meta.gitAuthor }
356
450
  }
357
451
  }
358
452
 
359
453
  // Auto-fill lastModified from git history for prototypes
360
454
  if (suffix === 'prototype' && parsed) {
361
- const protoDir = path.dirname(absPath)
362
- const lastModified = getLastModified(root, protoDir)
363
- if (lastModified) {
364
- parsed = { ...parsed, lastModified }
455
+ const meta = gitMeta.get(absPath)
456
+ if (meta?.lastModified) {
457
+ parsed = { ...parsed, lastModified: meta.lastModified }
365
458
  }
366
459
  }
367
460
 
@@ -400,9 +493,9 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
400
493
 
401
494
  // Auto-fill gitAuthor for canvas metadata from git history
402
495
  if (suffix === 'canvas' && parsed && !parsed.gitAuthor) {
403
- const gitAuthor = getGitAuthor(root, absPath)
404
- if (gitAuthor) {
405
- parsed = { ...parsed, gitAuthor }
496
+ const meta = gitMeta.get(absPath)
497
+ if (meta?.gitAuthor) {
498
+ parsed = { ...parsed, gitAuthor: meta.gitAuthor }
406
499
  }
407
500
  }
408
501