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

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,13 +1,16 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.5",
3
+ "version": "4.0.0-beta.6",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.5",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.5",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.6",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.6",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
- "jsonc-parser": "^3.3.1"
10
+ "jsonc-parser": "^3.3.1",
11
+ "remark": "^15.0.1",
12
+ "remark-gfm": "^4.0.1",
13
+ "remark-html": "^16.0.1"
11
14
  },
12
15
  "license": "MIT",
13
16
  "repository": {
@@ -1,4 +1,4 @@
1
- import { createElement, useCallback, useEffect, useRef, useState } from 'react'
1
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { Canvas } from '@dfosco/tiny-canvas'
4
4
  import '@dfosco/tiny-canvas/style.css'
@@ -158,7 +158,7 @@ const FIT_PADDING = 48
158
158
  * Compute the axis-aligned bounding box that contains every widget and source.
159
159
  * Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
160
160
  */
161
- function computeCanvasBounds(widgets, sources, jsxExports) {
161
+ function computeCanvasBounds(widgets, componentEntries) {
162
162
  let minX = Infinity
163
163
  let minY = Infinity
164
164
  let maxX = -Infinity
@@ -179,24 +179,18 @@ function computeCanvasBounds(widgets, sources, jsxExports) {
179
179
  hasItems = true
180
180
  }
181
181
 
182
- // JSX sources
183
- const sourceMap = Object.fromEntries(
184
- (sources || []).filter((s) => s?.export).map((s) => [s.export, s])
185
- )
186
- if (jsxExports) {
187
- for (const exportName of Object.keys(jsxExports)) {
188
- const sourceData = sourceMap[exportName] || {}
189
- const x = sourceData.position?.x ?? 0
190
- const y = sourceData.position?.y ?? 0
191
- const fallback = WIDGET_FALLBACK_SIZES['component']
192
- const width = sourceData.width ?? fallback.width
193
- const height = sourceData.height ?? fallback.height
194
- minX = Math.min(minX, x)
195
- minY = Math.min(minY, y)
196
- maxX = Math.max(maxX, x + width)
197
- maxY = Math.max(maxY, y + height)
198
- hasItems = true
199
- }
182
+ // Component widgets (from jsxExports or sources fallback)
183
+ for (const entry of componentEntries) {
184
+ const x = entry.sourceData?.position?.x ?? 0
185
+ const y = entry.sourceData?.position?.y ?? 0
186
+ const fallback = WIDGET_FALLBACK_SIZES['component']
187
+ const width = entry.sourceData?.width ?? fallback.width
188
+ const height = entry.sourceData?.height ?? fallback.height
189
+ minX = Math.min(minX, x)
190
+ minY = Math.min(minY, y)
191
+ maxX = Math.max(maxX, x + width)
192
+ maxY = Math.max(maxY, y + height)
193
+ hasItems = true
200
194
  }
201
195
 
202
196
  return hasItems ? { minX, minY, maxX, maxY } : null
@@ -275,7 +269,7 @@ function ChromeWrappedWidget({
275
269
  * @param {{ name: string }} props - Canvas name as indexed by the data plugin
276
270
  */
277
271
  export default function CanvasPage({ name }) {
278
- const { canvas, jsxExports, loading } = useCanvas(name)
272
+ const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
279
273
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
280
274
 
281
275
  // Local mutable copy of widgets for instant UI updates
@@ -294,6 +288,34 @@ export default function CanvasPage({ name }) {
294
288
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
295
289
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
296
290
 
291
+ // Centralized list of component export names.
292
+ // When jsxExports is available, use it (discovers new exports not yet in sources).
293
+ // When jsxExports is null (module import failed), fall back to sources so iframes
294
+ // still render — the error is contained inside each iframe.
295
+ const componentEntries = useMemo(() => {
296
+ const sourceMap = Object.fromEntries(
297
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
298
+ )
299
+ if (jsxExports) {
300
+ return Object.keys(jsxExports).map((exportName) => ({
301
+ exportName,
302
+ Component: jsxExports[exportName],
303
+ sourceData: sourceMap[exportName] || {},
304
+ }))
305
+ }
306
+ // Fallback: use sources when module import failed (iframe isolation still works)
307
+ if (jsxError && canvas?._jsxModule) {
308
+ return (localSources || [])
309
+ .filter((s) => s?.export)
310
+ .map((s) => ({
311
+ exportName: s.export,
312
+ Component: null,
313
+ sourceData: s,
314
+ }))
315
+ }
316
+ return []
317
+ }, [jsxExports, jsxError, localSources, canvas?._jsxModule])
318
+
297
319
  // Undo/redo history — tracks both widgets and sources as a combined snapshot
298
320
  const undoRedo = useUndoRedo()
299
321
  const stateRef = useRef({ widgets: localWidgets, sources: localSources })
@@ -673,16 +695,13 @@ export default function CanvasPage({ name }) {
673
695
  // Check JSX sources (jsx-ExportName)
674
696
  if (!widget && targetId.startsWith('jsx-')) {
675
697
  const exportName = targetId.slice(4)
676
- const sourceMap = Object.fromEntries(
677
- (localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
678
- )
679
- const sourceData = sourceMap[exportName]
680
- if (sourceData || (jsxExports && exportName in jsxExports)) {
698
+ const entry = componentEntries.find((e) => e.exportName === exportName)
699
+ if (entry) {
681
700
  const fallback = WIDGET_FALLBACK_SIZES['component']
682
- x = sourceData?.position?.x ?? 0
683
- y = sourceData?.position?.y ?? 0
684
- w = sourceData?.width ?? fallback.width
685
- h = sourceData?.height ?? fallback.height
701
+ x = entry.sourceData?.position?.x ?? 0
702
+ y = entry.sourceData?.position?.y ?? 0
703
+ w = entry.sourceData?.width ?? fallback.width
704
+ h = entry.sourceData?.height ?? fallback.height
686
705
  }
687
706
  }
688
707
 
@@ -696,7 +715,7 @@ export default function CanvasPage({ name }) {
696
715
  const url = new URL(window.location.href)
697
716
  url.searchParams.delete('widget')
698
717
  window.history.replaceState({}, '', url.toString())
699
- }, [loading, localWidgets, localSources, jsxExports])
718
+ }, [loading, localWidgets, componentEntries])
700
719
 
701
720
  // Persist viewport state (zoom + scroll) to localStorage on changes
702
721
  useEffect(() => {
@@ -893,7 +912,7 @@ export default function CanvasPage({ name }) {
893
912
  const el = scrollRef.current
894
913
  if (!el) return
895
914
 
896
- const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
915
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
897
916
  if (!bounds) return
898
917
 
899
918
  const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
@@ -917,7 +936,7 @@ export default function CanvasPage({ name }) {
917
936
  }
918
937
  document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
919
938
  return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
920
- }, [localWidgets, localSources, jsxExports])
939
+ }, [localWidgets, componentEntries])
921
940
 
922
941
  // Canvas background should follow toolbar theme target.
923
942
  useEffect(() => {
@@ -1328,54 +1347,50 @@ export default function CanvasPage({ name }) {
1328
1347
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1329
1348
  const allChildren = []
1330
1349
 
1331
- const sourceDataByExport = Object.fromEntries(
1332
- (localSources || [])
1333
- .filter((source) => source?.export)
1334
- .map((source) => [source.export, source])
1335
- )
1336
-
1337
- // 1. JSX-sourced component widgets (wrapped in WidgetChrome, not deletable)
1350
+ // 1. Component widgets (from jsxExports or sources fallback)
1338
1351
  const componentFeatures = getFeatures('component')
1339
- if (jsxExports) {
1340
- for (const [exportName, Component] of Object.entries(jsxExports)) {
1341
- const sourceData = sourceDataByExport[exportName] || {}
1342
- const sourcePosition = sourceData.position || { x: 0, y: 0 }
1343
- allChildren.push(
1344
- <div
1345
- key={`jsx-${exportName}`}
1346
- id={`jsx-${exportName}`}
1347
- data-tc-x={sourcePosition.x}
1348
- data-tc-y={sourcePosition.y}
1349
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1350
- {...canvasPrimerAttrs}
1351
- style={canvasThemeVars}
1352
- onClick={isLocalDev ? (e) => {
1353
- e.stopPropagation()
1354
- if (!e.target.closest('.tc-drag-handle')) {
1355
- handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1356
- }
1357
- } : undefined}
1352
+ for (const entry of componentEntries) {
1353
+ const { exportName, Component, sourceData } = entry
1354
+ const sourcePosition = sourceData.position || { x: 0, y: 0 }
1355
+ allChildren.push(
1356
+ <div
1357
+ key={`jsx-${exportName}`}
1358
+ id={`jsx-${exportName}`}
1359
+ data-tc-x={sourcePosition.x}
1360
+ data-tc-y={sourcePosition.y}
1361
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1362
+ {...canvasPrimerAttrs}
1363
+ style={canvasThemeVars}
1364
+ onClick={isLocalDev ? (e) => {
1365
+ e.stopPropagation()
1366
+ if (!e.target.closest('.tc-drag-handle')) {
1367
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1368
+ }
1369
+ } : undefined}
1370
+ >
1371
+ <WidgetChrome
1372
+ widgetId={`jsx-${exportName}`}
1373
+ features={componentFeatures}
1374
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1375
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1376
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1377
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1378
+ readOnly={!isLocalDev}
1358
1379
  >
1359
- <WidgetChrome
1360
- widgetId={`jsx-${exportName}`}
1361
- features={componentFeatures}
1362
- selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1363
- multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1364
- onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1365
- onDeselect={() => setSelectedWidgetIds(new Set())}
1366
- readOnly={!isLocalDev}
1367
- >
1368
- <ComponentWidget
1369
- component={Component}
1370
- width={sourceData.width}
1371
- height={sourceData.height}
1372
- onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1373
- resizable={isResizable('component') && isLocalDev}
1374
- />
1375
- </WidgetChrome>
1376
- </div>
1377
- )
1378
- }
1380
+ <ComponentWidget
1381
+ component={Component}
1382
+ jsxModule={canvas?._jsxModule}
1383
+ exportName={exportName}
1384
+ canvasTheme={canvasTheme}
1385
+ isLocalDev={isLocalDev}
1386
+ width={sourceData.width}
1387
+ height={sourceData.height}
1388
+ onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1389
+ resizable={isResizable('component') && isLocalDev}
1390
+ />
1391
+ </WidgetChrome>
1392
+ </div>
1393
+ )
1379
1394
  }
1380
1395
 
1381
1396
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Canvas reload guard — client-side state for preventing HMR full reloads.
3
+ *
4
+ * This module tracks whether a canvas is currently active. When active,
5
+ * the Vite plugin suppresses full-page reloads to preserve canvas state.
6
+ *
7
+ * The actual guard logic is implemented in:
8
+ * - Server: vite.config.js (ws.send monkey-patch + heartbeat)
9
+ * - Client: CanvasPage.jsx (vite:beforeFullReload + vite:ws:disconnect)
10
+ *
11
+ * This module provides the state that those systems check.
12
+ */
13
+
14
+ let active = false
15
+
16
+ /**
17
+ * Enable the canvas reload guard.
18
+ * Call when a canvas page mounts.
19
+ */
20
+ export function enableCanvasGuard() {
21
+ active = true
22
+ }
23
+
24
+ /**
25
+ * Disable the canvas reload guard.
26
+ * Call when a canvas page unmounts.
27
+ */
28
+ export function disableCanvasGuard() {
29
+ active = false
30
+ }
31
+
32
+ /**
33
+ * Check if the canvas reload guard is currently active.
34
+ */
35
+ export function isCanvasGuardActive() {
36
+ return active
37
+ }
@@ -32,12 +32,13 @@ export function resolveCanvasModuleImport(modulePath, baseUrl = import.meta.env?
32
32
  * fresh widget data from the server to pick up persisted edits.
33
33
  *
34
34
  * @param {string} name - Canvas name as indexed by the data plugin
35
- * @returns {{ canvas: object|null, jsxExports: object|null, loading: boolean }}
35
+ * @returns {{ canvas: object|null, jsxExports: object|null, jsxError: boolean, loading: boolean }}
36
36
  */
37
37
  export function useCanvas(name) {
38
38
  const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
39
39
  const [canvas, setCanvas] = useState(buildTimeCanvas)
40
40
  const [jsxExports, setJsxExports] = useState(null)
41
+ const [jsxError, setJsxError] = useState(false)
41
42
  const [loading, setLoading] = useState(true)
42
43
 
43
44
  // Fetch fresh data from server on mount
@@ -66,6 +67,7 @@ export function useCanvas(name) {
66
67
  useEffect(() => {
67
68
  if (!jsxModule) {
68
69
  setJsxExports(null)
70
+ setJsxError(false)
69
71
  return
70
72
  }
71
73
 
@@ -82,10 +84,12 @@ export function useCanvas(name) {
82
84
  }
83
85
  }
84
86
  setJsxExports(exports)
87
+ setJsxError(false)
85
88
  })
86
89
  .catch((err) => {
87
90
  console.error(`[storyboard] Failed to load canvas JSX module: ${jsxModule}`, err)
88
91
  setJsxExports(null)
92
+ setJsxError(true)
89
93
  })
90
94
  }, [jsxModule, jsxImport])
91
95
 
@@ -109,5 +113,5 @@ export function useCanvas(name) {
109
113
  }
110
114
  }, [name, buildTimeCanvas])
111
115
 
112
- return { canvas, jsxExports, loading }
116
+ return { canvas, jsxExports, jsxError, loading }
113
117
  }
@@ -1,17 +1,33 @@
1
- import { useRef, useCallback, useState, useEffect } from 'react'
1
+ import { useRef, useCallback, useState, useEffect, useMemo } from 'react'
2
2
  import WidgetWrapper from './WidgetWrapper.jsx'
3
3
  import ResizeHandle from './ResizeHandle.jsx'
4
+ import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
4
5
  import styles from './ComponentWidget.module.css'
5
6
 
6
7
  /**
7
8
  * Renders a live JSX export from a .canvas.jsx companion file.
8
- * Content is read-only (re-renders on HMR), only position and size are mutable.
9
- * Cannot be deleted from canvas only removed from source code.
9
+ *
10
+ * In dev mode (isLocalDev), each component is rendered inside an iframe
11
+ * via the /_storyboard/canvas/isolate middleware. This isolates broken
12
+ * components so they cannot crash the entire canvas page.
13
+ *
14
+ * In production, the component is rendered directly with an ErrorBoundary
15
+ * as a fallback safety net.
10
16
  *
11
17
  * Double-click the overlay to enter interactive mode (dropdowns, buttons work).
12
18
  * Click outside to exit interactive mode.
13
19
  */
14
- export default function ComponentWidget({ component: Component, width, height, onUpdate, resizable }) {
20
+ export default function ComponentWidget({
21
+ component: Component,
22
+ jsxModule,
23
+ exportName,
24
+ canvasTheme,
25
+ isLocalDev,
26
+ width,
27
+ height,
28
+ onUpdate,
29
+ resizable,
30
+ }) {
15
31
  const containerRef = useRef(null)
16
32
  const [interactive, setInteractive] = useState(false)
17
33
 
@@ -33,7 +49,21 @@ export default function ComponentWidget({ component: Component, width, height, o
33
49
  return () => document.removeEventListener('pointerdown', handlePointerDown)
34
50
  }, [interactive])
35
51
 
36
- if (!Component) return null
52
+ // Build iframe src for dev isolation
53
+ const iframeSrc = useMemo(() => {
54
+ if (!isLocalDev || !jsxModule || !exportName) return null
55
+ const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
56
+ const params = new URLSearchParams({
57
+ module: jsxModule,
58
+ export: exportName,
59
+ theme: canvasTheme || 'light',
60
+ })
61
+ return `${basePath}/_storyboard/canvas/isolate?${params}`
62
+ }, [isLocalDev, jsxModule, exportName, canvasTheme])
63
+
64
+ const useIframe = isLocalDev && iframeSrc
65
+
66
+ if (!useIframe && !Component) return null
37
67
 
38
68
  const sizeStyle = {}
39
69
  if (typeof width === 'number') sizeStyle.width = `${width}px`
@@ -43,7 +73,18 @@ export default function ComponentWidget({ component: Component, width, height, o
43
73
  <WidgetWrapper>
44
74
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
45
75
  <div className={styles.content}>
46
- <Component />
76
+ {useIframe ? (
77
+ <iframe
78
+ src={iframeSrc}
79
+ className={styles.iframe}
80
+ title={exportName || 'Component widget'}
81
+ sandbox="allow-same-origin allow-scripts"
82
+ />
83
+ ) : Component ? (
84
+ <ComponentErrorBoundary name={exportName}>
85
+ <Component />
86
+ </ComponentErrorBoundary>
87
+ ) : null}
47
88
  </div>
48
89
  {!interactive && (
49
90
  <div
@@ -1,6 +1,6 @@
1
1
  .container {
2
2
  position: relative;
3
- overflow: auto;
3
+ overflow: hidden;
4
4
  min-width: 100px;
5
5
  min-height: 60px;
6
6
  }
@@ -10,6 +10,13 @@
10
10
  height: 100%;
11
11
  }
12
12
 
13
+ .iframe {
14
+ display: block;
15
+ width: 100%;
16
+ height: 100%;
17
+ border: none;
18
+ }
19
+
13
20
  .interactOverlay {
14
21
  position: absolute;
15
22
  inset: 0;
@@ -1,28 +1,21 @@
1
- import { useState, useRef, useEffect, useCallback } from 'react'
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
2
+ import { remark } from 'remark'
3
+ import remarkGfm from 'remark-gfm'
4
+ import remarkHtml from 'remark-html'
2
5
  import WidgetWrapper from './WidgetWrapper.jsx'
3
6
  import { readProp, markdownSchema } from './widgetProps.js'
4
7
  import styles from './MarkdownBlock.module.css'
5
8
 
6
9
  /**
7
- * Renders markdown as plain HTML using a minimal built-in converter.
10
+ * Renders markdown to HTML using remark with GitHub Flavored Markdown support.
8
11
  */
9
12
  function renderMarkdown(text) {
10
13
  if (!text) return ''
11
- return text
12
- .replace(/^### (.+)$/gm, '<h3>$1</h3>')
13
- .replace(/^## (.+)$/gm, '<h2>$1</h2>')
14
- .replace(/^# (.+)$/gm, '<h1>$1</h1>')
15
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
16
- .replace(/\*(.+?)\*/g, '<em>$1</em>')
17
- .replace(/`(.+?)`/g, '<code>$1</code>')
18
- .replace(/^- (.+)$/gm, '<li>$1</li>')
19
- .replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
20
- .replace(/\n\n/g, '</p><p>')
21
- .replace(/\n/g, '<br>')
22
- .replace(/^(.+)$/gm, (line) => {
23
- if (line.startsWith('<')) return line
24
- return `<p>${line}</p>`
25
- })
14
+ const result = remark()
15
+ .use(remarkGfm)
16
+ .use(remarkHtml, { sanitize: false })
17
+ .processSync(text)
18
+ return String(result)
26
19
  }
27
20
 
28
21
  export default function MarkdownBlock({ props, onUpdate }) {
@@ -65,6 +65,88 @@
65
65
  margin: 0 0 2px;
66
66
  }
67
67
 
68
+ .preview ol {
69
+ margin: 0 0 8px;
70
+ padding-left: 20px;
71
+ }
72
+
73
+ /* GFM: Task lists */
74
+ .preview input[type="checkbox"] {
75
+ margin-right: 6px;
76
+ pointer-events: none;
77
+ }
78
+
79
+ .preview li:has(input[type="checkbox"]) {
80
+ list-style: none;
81
+ margin-left: -20px;
82
+ }
83
+
84
+ /* GFM: Strikethrough */
85
+ .preview del {
86
+ text-decoration: line-through;
87
+ color: var(--sb--markdown-muted);
88
+ }
89
+
90
+ /* GFM: Tables */
91
+ .preview table {
92
+ border-collapse: collapse;
93
+ margin: 8px 0;
94
+ width: 100%;
95
+ font-size: 13px;
96
+ }
97
+
98
+ .preview th,
99
+ .preview td {
100
+ border: 1px solid var(--borderColor-default, #d0d7de);
101
+ padding: 6px 12px;
102
+ text-align: left;
103
+ }
104
+
105
+ .preview th {
106
+ background: var(--bgColor-muted, #f6f8fa);
107
+ font-weight: 600;
108
+ }
109
+
110
+ /* GFM: Autolinks */
111
+ .preview a {
112
+ color: var(--sb--markdown-accent);
113
+ text-decoration: none;
114
+ }
115
+
116
+ .preview a:hover {
117
+ text-decoration: underline;
118
+ }
119
+
120
+ /* Code blocks */
121
+ .preview pre {
122
+ background: var(--bgColor-neutral-muted, #afb8c133);
123
+ padding: 12px 16px;
124
+ border-radius: 6px;
125
+ overflow-x: auto;
126
+ margin: 8px 0;
127
+ }
128
+
129
+ .preview pre code {
130
+ background: none;
131
+ padding: 0;
132
+ font-size: 13px;
133
+ }
134
+
135
+ /* Blockquotes */
136
+ .preview blockquote {
137
+ border-left: 4px solid var(--borderColor-default, #d0d7de);
138
+ margin: 8px 0;
139
+ padding: 4px 16px;
140
+ color: var(--sb--markdown-muted);
141
+ }
142
+
143
+ /* Horizontal rules */
144
+ .preview hr {
145
+ border: none;
146
+ border-top: 1px solid var(--borderColor-default, #d0d7de);
147
+ margin: 16px 0;
148
+ }
149
+
68
150
  .preview :global(.placeholder) {
69
151
  color: var(--sb--markdown-muted);
70
152
  font-style: italic;
@@ -50,4 +50,43 @@ describe('MarkdownBlock', () => {
50
50
 
51
51
  expect(setData).toHaveBeenCalledWith('text/plain', '**Hello**\n- item')
52
52
  })
53
+
54
+ describe('GitHub Flavored Markdown', () => {
55
+ it('renders tables', () => {
56
+ const markdown = `| Name | Age |
57
+ | --- | --- |
58
+ | Alice | 30 |`
59
+ const { container } = render(<MarkdownBlock props={{ content: markdown, width: 420 }} />)
60
+
61
+ expect(container.querySelector('table')).not.toBeNull()
62
+ expect(container.querySelector('th')).not.toBeNull()
63
+ expect(screen.getByText('Alice')).toBeTruthy()
64
+ })
65
+
66
+ it('renders task lists', () => {
67
+ const markdown = `- [x] Done
68
+ - [ ] Todo`
69
+ const { container } = render(<MarkdownBlock props={{ content: markdown, width: 420 }} />)
70
+
71
+ const checkboxes = container.querySelectorAll('input[type="checkbox"]')
72
+ expect(checkboxes).toHaveLength(2)
73
+ expect(checkboxes[0].checked).toBe(true)
74
+ expect(checkboxes[1].checked).toBe(false)
75
+ })
76
+
77
+ it('renders strikethrough', () => {
78
+ const { container } = render(<MarkdownBlock props={{ content: '~~deleted~~', width: 420 }} />)
79
+
80
+ expect(container.querySelector('del')).not.toBeNull()
81
+ expect(screen.getByText('deleted')).toBeTruthy()
82
+ })
83
+
84
+ it('renders autolinks', () => {
85
+ const { container } = render(<MarkdownBlock props={{ content: 'https://github.com', width: 420 }} />)
86
+
87
+ const link = container.querySelector('a')
88
+ expect(link).not.toBeNull()
89
+ expect(link.href).toBe('https://github.com/')
90
+ })
91
+ })
53
92
  })
@@ -675,6 +675,41 @@ export default function storyboardDataPlugin() {
675
675
  },
676
676
 
677
677
  configureServer(server) {
678
+ // ── Component isolate middleware ───────────────────────────────
679
+ // Serves a minimal HTML shell for iframe-isolated component widgets.
680
+ // The iframe loads componentIsolate.jsx which reads query params
681
+ // (module, export, theme) and renders a single canvas.jsx export.
682
+ const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
683
+ server.middlewares.use(async (req, res, next) => {
684
+ if (!req.url) return next()
685
+ let url = req.url
686
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
687
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
688
+ url = url.slice(baseNoTrail.length) || '/'
689
+ }
690
+ if (!url.startsWith('/_storyboard/canvas/isolate')) return next()
691
+
692
+ const rawHtml = [
693
+ '<!DOCTYPE html>',
694
+ '<html><head>',
695
+ '<style>html,body{margin:0;padding:0;width:100%;height:100%}#root{width:100%;height:100%}</style>',
696
+ '</head><body>',
697
+ '<div id="root"></div>',
698
+ `<script type="module" src="/@fs${isolateEntryPath}"></script>`,
699
+ '</body></html>',
700
+ ].join('\n')
701
+
702
+ try {
703
+ const html = await server.transformIndexHtml(req.url, rawHtml)
704
+ res.writeHead(200, { 'Content-Type': 'text/html' })
705
+ res.end(html)
706
+ } catch (err) {
707
+ console.error('[storyboard] Component isolate HTML transform failed:', err)
708
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
709
+ res.end('Component isolate failed')
710
+ }
711
+ })
712
+
678
713
  // Watch for data file changes in dev mode
679
714
  const watcher = server.watcher
680
715
  if (!buildResult) buildResult = buildIndex(root)