@dfosco/storyboard-react 4.0.0-beta.11 → 4.0.0-beta.13

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.11",
3
+ "version": "4.0.0-beta.13",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.11",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.11",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.13",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.13",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -12,7 +12,7 @@ import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
12
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
14
14
  import useUndoRedo from './useUndoRedo.js'
15
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
15
+ import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage, getCanvas as getCanvasApi } from './canvasApi.js'
16
16
  import styles from './CanvasPage.module.css'
17
17
 
18
18
  const ZOOM_MIN = 25
@@ -51,6 +51,7 @@ function resolveCanvasThemeFromStorage() {
51
51
  * Get the copyable URL for a widget based on its type.
52
52
  * Returns the most relevant URL/path for the widget content.
53
53
  */
54
+ // eslint-disable-next-line no-unused-vars
54
55
  function getWidgetCopyableUrl(widget) {
55
56
  const { type, props = {} } = widget
56
57
  const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
@@ -403,13 +404,13 @@ export default function CanvasPage({ name }) {
403
404
  // Flag to suppress the click-based selection reset that fires after a drag
404
405
  const justDraggedRef = useRef(false)
405
406
 
406
- const handleItemDragStart = useCallback((dragId, position) => {
407
+ const handleItemDragStart = useCallback((dragId) => {
407
408
  const ids = selectedIdsRef.current
408
409
  peerArticlesRef.current.clear()
409
410
  if (ids.size <= 1 || !ids.has(dragId)) return
410
411
 
411
412
  // Suppress selection changes for the duration of the drag
412
- justDraggedRef.current = true
413
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
413
414
 
414
415
  // Collect peer article elements for transition on drag end
415
416
  for (const id of ids) {
@@ -449,6 +450,8 @@ export default function CanvasPage({ name }) {
449
450
  setLocalWidgets(canvas?.widgets ?? null)
450
451
  setLocalSources(canvas?.sources ?? [])
451
452
  setCanvasTitle(canvas?.title || name)
453
+ setSnapEnabled(canvas?.snapToGrid ?? false)
454
+ setSnapGridSize(canvas?.gridSize || 40)
452
455
  undoRedo.reset()
453
456
  }
454
457
 
@@ -575,7 +578,7 @@ export default function CanvasPage({ name }) {
575
578
  if (ids.size > 1 && ids.has(dragId)) {
576
579
  transitionPeers()
577
580
  // Suppress the click-based selection reset that fires after pointerup
578
- justDraggedRef.current = true
581
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
579
582
  requestAnimationFrame(() => { justDraggedRef.current = false })
580
583
  undoRedo.snapshot(stateRef.current, 'multi-move')
581
584
 
@@ -911,7 +914,7 @@ export default function CanvasPage({ name }) {
911
914
  function handleSnapToggle() {
912
915
  setSnapEnabled((prev) => {
913
916
  const next = !prev
914
- updateCanvas(name, { snapToGrid: next }).catch((err) =>
917
+ updateCanvas(name, { settings: { snapToGrid: next } }).catch((err) =>
915
918
  console.error('[canvas] Failed to persist snap setting:', err)
916
919
  )
917
920
  return next
@@ -929,6 +932,17 @@ export default function CanvasPage({ name }) {
929
932
  snapEnabledRef.current = snapEnabled
930
933
  }, [snapEnabled])
931
934
 
935
+ // Respond to snap-state requests from Svelte toolbar (handles mount-order race)
936
+ useEffect(() => {
937
+ function handleRequest() {
938
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
939
+ detail: { snapEnabled: snapEnabledRef.current }
940
+ }))
941
+ }
942
+ document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
943
+ return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
944
+ }, [])
945
+
932
946
  // Listen for gridSize from Svelte toolbar config
933
947
  useEffect(() => {
934
948
  function handleGridSize(e) {
@@ -1015,33 +1029,13 @@ export default function CanvasPage({ name }) {
1015
1029
  e.preventDefault()
1016
1030
  setSelectedWidgetIds(new Set())
1017
1031
  }
1018
- // Copy shortcuts (single widget selected):
1019
- // - cmd+c → copy URL/content
1020
- // - Shift+C (no cmd) → copy widget ID (or file path for images)
1032
+ // Copy shortcut (single widget selected):
1033
+ // cmd+c → copy canvasName/widgetId (for cross-canvas paste-duplicate)
1021
1034
  const mod = e.metaKey || e.ctrlKey
1022
1035
  if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
1023
1036
  const widgetId = [...selectedWidgetIds][0]
1024
- const widget = localWidgets?.find(w => w.id === widgetId)
1025
- if (widget) {
1026
- e.preventDefault()
1027
- const url = getWidgetCopyableUrl(widget)
1028
- if (url) {
1029
- navigator.clipboard.writeText(url).catch(() => {})
1030
- }
1031
- }
1032
- }
1033
- // Shift+C (uppercase C, no cmd) → copy ID or file path
1034
- if (e.key === 'C' && e.shiftKey && !mod && selectedWidgetIds.size === 1) {
1035
- const widgetId = [...selectedWidgetIds][0]
1036
- const widget = localWidgets?.find(w => w.id === widgetId)
1037
- if (widget) {
1038
- e.preventDefault()
1039
- if (widget.type === 'image' && widget.props?.src) {
1040
- navigator.clipboard.writeText(`src/canvas/images/${widget.props.src}`).catch(() => {})
1041
- } else {
1042
- navigator.clipboard.writeText(widgetId).catch(() => {})
1043
- }
1044
- }
1037
+ e.preventDefault()
1038
+ navigator.clipboard.writeText(`${name}/${widgetId}`).catch(() => {})
1045
1039
  }
1046
1040
  if (e.key === 'Delete' || e.key === 'Backspace') {
1047
1041
  e.preventDefault()
@@ -1217,6 +1211,40 @@ export default function CanvasPage({ name }) {
1217
1211
 
1218
1212
  e.preventDefault()
1219
1213
 
1214
+ // Detect canvasName/widgetId format for widget duplication (cross-canvas copy-paste)
1215
+ const widgetRefMatch = text.match(/^([^/]+)\/([^/]+)$/)
1216
+ if (widgetRefMatch) {
1217
+ const [, sourceCanvas, sourceWidgetId] = widgetRefMatch
1218
+ // Component widgets are code, not duplicable data — silently consume the ref
1219
+ if (sourceWidgetId.startsWith('jsx-')) return
1220
+ try {
1221
+ let sourceWidget = null
1222
+ if (sourceCanvas === name) {
1223
+ sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
1224
+ } else {
1225
+ const canvasData = await getCanvasApi(sourceCanvas)
1226
+ sourceWidget = (canvasData?.widgets ?? []).find(w => w.id === sourceWidgetId)
1227
+ }
1228
+ if (sourceWidget) {
1229
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1230
+ const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
1231
+ undoRedo.snapshot(stateRef.current, 'add')
1232
+ const result = await addWidgetApi(name, {
1233
+ type: sourceWidget.type,
1234
+ props: { ...sourceWidget.props },
1235
+ position: pos,
1236
+ })
1237
+ if (result.success && result.widget) {
1238
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1239
+ }
1240
+ }
1241
+ } catch (err) {
1242
+ console.error('[canvas] Failed to paste widget reference:', err)
1243
+ }
1244
+ // Always consume the ref — never fall through to markdown creation
1245
+ return
1246
+ }
1247
+
1220
1248
  let type, props
1221
1249
  const url = looksLikeWebUrl(text)
1222
1250
  if (url) {
@@ -1256,7 +1284,7 @@ export default function CanvasPage({ name }) {
1256
1284
 
1257
1285
  document.addEventListener('paste', handlePaste)
1258
1286
  return () => document.removeEventListener('paste', handlePaste)
1259
- }, [name, undoRedo])
1287
+ }, [name, undoRedo, localWidgets])
1260
1288
 
1261
1289
  // --- Drag and drop handlers for images from Finder/file manager ---
1262
1290
  // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
@@ -103,6 +103,12 @@
103
103
  overflow: visible;
104
104
  }
105
105
 
106
+ /* Elevate stacking context for hovered/selected widgets so their chrome
107
+ (toolbar, menus, selection outline) renders above sibling widgets. */
108
+ :global(.tc-drag:has([data-tc-elevated])) {
109
+ z-index: 1;
110
+ }
111
+
106
112
  .localEditingLabel {
107
113
  display: inline-flex;
108
114
  align-items: center;
@@ -47,3 +47,7 @@ export function uploadImage(dataUrl, canvasName) {
47
47
  export function toggleImagePrivacy(filename) {
48
48
  return request('/image/toggle-private', 'POST', { filename })
49
49
  }
50
+
51
+ export function getCanvas(name) {
52
+ return request(`/read?name=${encodeURIComponent(name)}`, 'GET')
53
+ }
@@ -3,6 +3,9 @@
3
3
  overflow: hidden;
4
4
  min-width: 100px;
5
5
  min-height: 60px;
6
+ background: var(--bgColor-default, #ffffff);
7
+ width: 100%;
8
+ height: 100%;
6
9
  }
7
10
 
8
11
  .content {
@@ -421,6 +421,7 @@ export default function WidgetChrome({
421
421
  return (
422
422
  <div
423
423
  className={styles.chromeContainer}
424
+ data-tc-elevated={(hovered || selected) || undefined}
424
425
  onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
425
426
  onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
426
427
  >
@@ -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) {
@@ -34,7 +34,16 @@ function resolveFeature(feature) {
34
34
  if (key === 'items' && Array.isArray(val)) {
35
35
  resolved[key] = val.map((item) => {
36
36
  const r = {}
37
- for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
37
+ for (const [k, v] of Object.entries(item)) {
38
+ // Resolve nested alt object inside items
39
+ if (k === 'alt' && v && typeof v === 'object') {
40
+ const altResolved = {}
41
+ for (const [ak, av] of Object.entries(v)) altResolved[ak] = resolveVar(av)
42
+ r[k] = altResolved
43
+ } else {
44
+ r[k] = resolveVar(v)
45
+ }
46
+ }
38
47
  return r
39
48
  })
40
49
  } else if (key === 'alt' && val && typeof val === 'object') {
@@ -17,10 +17,12 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
17
17
  *
18
18
  * @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
19
19
  * Omit to get the entire flow object.
20
+ * @param {{ optional?: boolean }} [opts] - Pass { optional: true } to suppress
21
+ * the "path not found" warning for optional data.
20
22
  * @returns {*} The resolved value. Returns {} if path is missing after loading.
21
23
  * @throws If used outside a StoryboardProvider.
22
24
  */
23
- export function useFlowData(path) {
25
+ export function useFlowData(path, opts) {
24
26
  const context = useContext(StoryboardContext)
25
27
 
26
28
  if (context === null) {
@@ -73,7 +75,7 @@ export function useFlowData(path) {
73
75
  }
74
76
 
75
77
  if (sceneValue === undefined) {
76
- if (data != null && Object.keys(data).length > 0) {
78
+ if (!opts?.optional && data != null && Object.keys(data).length > 0) {
77
79
  console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
78
80
  }
79
81
  return {}
@@ -633,6 +633,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
633
633
  `export { flows, scenes, objects, records, prototypes, folders, canvases }`,
634
634
  `export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
635
635
  `export default index`,
636
+ '',
637
+ '// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
638
+ 'if (import.meta.hot) {',
639
+ ' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
640
+ ' if (!data) return',
641
+ ' if (data.removed) {',
642
+ ' delete canvases[data.name]',
643
+ ' } else if (data.metadata) {',
644
+ ' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
645
+ ' canvases[data.name] = canvases[data.name]',
646
+ ' ? Object.assign({}, canvases[data.name], data.metadata)',
647
+ ' : data.metadata',
648
+ ' }',
649
+ ' init({ flows, objects, records, prototypes, folders, canvases })',
650
+ ' })',
651
+ '}',
636
652
  ].join('\n')
637
653
  }
638
654
 
@@ -697,7 +713,7 @@ export default function storyboardDataPlugin() {
697
713
  const rawHtml = [
698
714
  '<!DOCTYPE html>',
699
715
  '<html><head>',
700
- '<style>html,body{margin:0;padding:0;width:100%;height:100%}#root{width:100%;height:100%}</style>',
716
+ '<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
701
717
  '</head><body>',
702
718
  '<div id="root"></div>',
703
719
  `<script type="module" src="/@fs${isolateEntryPath}"></script>`,
@@ -730,22 +746,50 @@ export default function storyboardDataPlugin() {
730
746
  }
731
747
  }
732
748
 
749
+ // Mark the virtual module as stale so the next page load rebuilds it,
750
+ // but do NOT trigger a full-reload (avoids losing canvas editing state).
751
+ const softInvalidate = () => {
752
+ buildResult = null
753
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
754
+ if (mod) server.moduleGraph.invalidateModule(mod)
755
+ }
756
+
757
+ // Read a canvas file and build HMR metadata for the client-side listener.
758
+ const readCanvasMetadata = (filePath, parsed) => {
759
+ try {
760
+ const absPath = path.resolve(root, filePath)
761
+ const raw = fs.readFileSync(absPath, 'utf-8')
762
+ const materialized = materializeFromText(raw)
763
+ const result = { ...materialized }
764
+ // Inject _route and _folder the same way generateModule does
765
+ if (parsed.inferredRoute) result._route = parsed.inferredRoute
766
+ const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
767
+ if (folderDirMatch) result._folder = folderDirMatch[1]
768
+ return result
769
+ } catch {
770
+ return null
771
+ }
772
+ }
773
+
733
774
  const invalidate = (filePath) => {
734
775
  const normalized = filePath.replace(/\\/g, '/')
735
- // Skip .canvas.jsonl content changes entirely these are mutated
736
- // at runtime by the canvas server API. A full-reload would create
737
- // a feedback loop (save → file change → reload → lose editing state).
738
- // Instead, send a custom HMR event so the active canvas page can refetch
739
- // file-backed data in place with no navigation or document reload.
776
+ // Canvas .jsonl content changes are mutated at runtime by the canvas
777
+ // server API. A full-reload would create a feedback loop (save →
778
+ // file change → reload → lose editing state). Instead, soft-invalidate
779
+ // the virtual module (so page refresh picks up changes) and send a
780
+ // custom HMR event with updated metadata so the canvas page and
781
+ // viewfinder can react in place.
740
782
  if (/\.canvas\.jsonl$/.test(normalized)) {
741
783
  const parsed = parseDataFile(filePath)
742
784
  if (parsed?.suffix === 'canvas' && parsed?.name) {
785
+ const metadata = readCanvasMetadata(filePath, parsed)
743
786
  server.ws.send({
744
787
  type: 'custom',
745
788
  event: 'storyboard:canvas-file-changed',
746
- data: { name: parsed.name },
789
+ data: { name: parsed.name, ...(metadata ? { metadata } : {}) },
747
790
  })
748
791
  }
792
+ softInvalidate()
749
793
  return
750
794
  }
751
795
 
@@ -790,23 +834,27 @@ export default function storyboardDataPlugin() {
790
834
  server.ws.send({
791
835
  type: 'custom',
792
836
  event: 'storyboard:canvas-file-changed',
793
- data: { name },
837
+ data: { name, removed: true },
794
838
  })
839
+ softInvalidate()
795
840
  }, 1500)
796
841
  pendingCanvasUnlinks.set(name, timer)
797
842
  return
798
843
  }
799
844
 
800
845
  if (eventType === 'add') {
846
+ const metadata = readCanvasMetadata(filePath, parsed)
801
847
  const pending = pendingCanvasUnlinks.get(name)
802
848
  if (pending) {
849
+ // unlink+add pair = in-place save (atomic write), not a real remove
803
850
  clearTimeout(pending)
804
851
  pendingCanvasUnlinks.delete(name)
805
852
  server.ws.send({
806
853
  type: 'custom',
807
854
  event: 'storyboard:canvas-file-changed',
808
- data: { name },
855
+ data: { name, ...(metadata ? { metadata } : {}) },
809
856
  })
857
+ softInvalidate()
810
858
  return
811
859
  }
812
860
 
@@ -814,8 +862,9 @@ export default function storyboardDataPlugin() {
814
862
  server.ws.send({
815
863
  type: 'custom',
816
864
  event: 'storyboard:canvas-file-changed',
817
- data: { name },
865
+ data: { name, ...(metadata ? { metadata } : {}) },
818
866
  })
867
+ softInvalidate()
819
868
  return
820
869
  }
821
870
 
@@ -823,8 +872,9 @@ export default function storyboardDataPlugin() {
823
872
  server.ws.send({
824
873
  type: 'custom',
825
874
  event: 'storyboard:canvas-file-changed',
826
- data: { name },
875
+ data: { name, ...(metadata ? { metadata } : {}) },
827
876
  })
877
+ softInvalidate()
828
878
  return
829
879
  }
830
880
  }
@@ -859,18 +909,10 @@ export default function storyboardDataPlugin() {
859
909
  const normalized = ctx.file.replace(/\\/g, '/')
860
910
  if (!/\.canvas\.jsonl$/.test(normalized)) return
861
911
 
862
- const parsed = parseDataFile(ctx.file)
863
- if (parsed?.suffix === 'canvas' && parsed?.name) {
864
- ctx.server.ws.send({
865
- type: 'custom',
866
- event: 'storyboard:canvas-file-changed',
867
- data: { name: parsed.name },
868
- })
869
- }
870
-
871
912
  // Prevent Vite's default fallback behavior (full page reload) for
872
- // non-module .canvas.jsonl edits. Canvas pages consume these updates
873
- // through the custom WS event and in-page refetch.
913
+ // non-module .canvas.jsonl edits. The watcher 'change' handler
914
+ // (invalidate) already sends the custom HMR event and soft-invalidates
915
+ // the virtual module — no duplicate event needed here.
874
916
  return []
875
917
  },
876
918
 
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
1
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import path from 'node:path'
4
4
  import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars } from './data-plugin.js'
@@ -829,3 +829,220 @@ describe('template variable integration', () => {
829
829
  warnSpy.mockRestore()
830
830
  })
831
831
  })
832
+
833
+ // ── Canvas watcher / HMR tests ──────────────────────────────────────
834
+
835
+ describe('canvas watcher behavior', () => {
836
+ /** Helper: create a mock Vite dev server for configureServer */
837
+ function createMockServer(root) {
838
+ const listeners = {}
839
+ const wsSent = []
840
+ const invalidatedModules = []
841
+
842
+ return {
843
+ wsSent,
844
+ invalidatedModules,
845
+ listeners,
846
+ config: { root, base: '/' },
847
+ watcher: {
848
+ add: vi.fn(),
849
+ on(event, fn) {
850
+ if (!listeners[event]) listeners[event] = []
851
+ listeners[event].push(fn)
852
+ },
853
+ },
854
+ moduleGraph: {
855
+ getModuleById(id) {
856
+ if (id === RESOLVED_ID) return { id: RESOLVED_ID }
857
+ return null
858
+ },
859
+ invalidateModule(mod) {
860
+ invalidatedModules.push(mod.id)
861
+ },
862
+ },
863
+ ws: {
864
+ send(msg) { wsSent.push(msg) },
865
+ },
866
+ middlewares: {
867
+ use: vi.fn(),
868
+ },
869
+ }
870
+ }
871
+
872
+ /** Emit a watcher event on the mock server */
873
+ function emit(server, event, filePath) {
874
+ for (const fn of (server.listeners[event] || [])) {
875
+ fn(filePath)
876
+ }
877
+ }
878
+
879
+ function writeCanvasFile(dir, name, title) {
880
+ const canvasDir = path.join(dir, 'src', 'canvas')
881
+ mkdirSync(canvasDir, { recursive: true })
882
+ const evt = { event: 'canvas_created', title: title || name, timestamp: Date.now() }
883
+ writeFileSync(path.join(canvasDir, `${name}.canvas.jsonl`), JSON.stringify(evt) + '\n')
884
+ }
885
+
886
+ it('soft-invalidates virtual module on canvas content change (no full-reload)', () => {
887
+ writeCanvasFile(tmpDir, 'test-canvas', 'Original Title')
888
+ const plugin = createPlugin()
889
+ // Force initial buildResult
890
+ plugin.load(RESOLVED_ID)
891
+
892
+ const server = createMockServer(tmpDir)
893
+ plugin.configureServer(server)
894
+
895
+ // Simulate a canvas file content change
896
+ const canvasPath = path.join(tmpDir, 'src', 'canvas', 'test-canvas.canvas.jsonl')
897
+ emit(server, 'change', canvasPath)
898
+
899
+ // Should have sent custom HMR event (not full-reload)
900
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
901
+ const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
902
+
903
+ expect(customEvents.length).toBe(1)
904
+ expect(customEvents[0].event).toBe('storyboard:canvas-file-changed')
905
+ expect(customEvents[0].data.name).toBe('test-canvas')
906
+ expect(fullReloads.length).toBe(0)
907
+
908
+ // Should have invalidated the virtual module
909
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
910
+ })
911
+
912
+ it('includes metadata in HMR event for canvas content changes', () => {
913
+ writeCanvasFile(tmpDir, 'meta-canvas', 'My Canvas Title')
914
+ const plugin = createPlugin()
915
+ plugin.load(RESOLVED_ID)
916
+
917
+ const server = createMockServer(tmpDir)
918
+ plugin.configureServer(server)
919
+
920
+ emit(server, 'change', path.join(tmpDir, 'src', 'canvas', 'meta-canvas.canvas.jsonl'))
921
+
922
+ const event = server.wsSent.find(m => m.type === 'custom')
923
+ expect(event.data.metadata).toBeDefined()
924
+ expect(event.data.metadata.title).toBe('My Canvas Title')
925
+ })
926
+
927
+ it('soft-invalidates on canvas file add (new canvas)', () => {
928
+ const plugin = createPlugin()
929
+ plugin.load(RESOLVED_ID)
930
+
931
+ const server = createMockServer(tmpDir)
932
+ plugin.configureServer(server)
933
+
934
+ // Create the file after the server is configured
935
+ writeCanvasFile(tmpDir, 'new-canvas', 'Brand New')
936
+ emit(server, 'add', path.join(tmpDir, 'src', 'canvas', 'new-canvas.canvas.jsonl'))
937
+
938
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
939
+ const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
940
+
941
+ expect(customEvents.length).toBe(1)
942
+ expect(customEvents[0].data.name).toBe('new-canvas')
943
+ expect(customEvents[0].data.metadata).toBeDefined()
944
+ expect(fullReloads.length).toBe(0)
945
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
946
+ })
947
+
948
+ it('soft-invalidates on canvas file unlink after timeout (true delete)', async () => {
949
+ writeCanvasFile(tmpDir, 'doomed-canvas', 'Gone Soon')
950
+ const plugin = createPlugin()
951
+ plugin.load(RESOLVED_ID)
952
+
953
+ const server = createMockServer(tmpDir)
954
+ plugin.configureServer(server)
955
+
956
+ emit(server, 'unlink', path.join(tmpDir, 'src', 'canvas', 'doomed-canvas.canvas.jsonl'))
957
+
958
+ // Immediately after unlink — no event yet (deferred by 1500ms)
959
+ expect(server.wsSent.length).toBe(0)
960
+
961
+ // Wait for deferred timer
962
+ await new Promise(resolve => setTimeout(resolve, 1600))
963
+
964
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
965
+ expect(customEvents.length).toBe(1)
966
+ expect(customEvents[0].data.name).toBe('doomed-canvas')
967
+ expect(customEvents[0].data.removed).toBe(true)
968
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
969
+ })
970
+
971
+ it('cancels deferred unlink on add (atomic write / in-place save)', async () => {
972
+ writeCanvasFile(tmpDir, 'saved-canvas', 'Saved')
973
+ const plugin = createPlugin()
974
+ plugin.load(RESOLVED_ID)
975
+
976
+ const server = createMockServer(tmpDir)
977
+ plugin.configureServer(server)
978
+
979
+ const canvasPath = path.join(tmpDir, 'src', 'canvas', 'saved-canvas.canvas.jsonl')
980
+
981
+ // Simulate atomic write: unlink then add within 1500ms
982
+ emit(server, 'unlink', canvasPath)
983
+ emit(server, 'add', canvasPath)
984
+
985
+ // Should have sent one event immediately (the add cancelling the unlink)
986
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
987
+ expect(customEvents.length).toBe(1)
988
+ expect(customEvents[0].data.name).toBe('saved-canvas')
989
+ expect(customEvents[0].data.removed).toBeUndefined()
990
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
991
+
992
+ // Wait past the unlink timer — should NOT get a second event
993
+ await new Promise(resolve => setTimeout(resolve, 1600))
994
+ const allCustom = server.wsSent.filter(m => m.type === 'custom')
995
+ expect(allCustom.length).toBe(1)
996
+ })
997
+
998
+ it('handleHotUpdate returns empty array for canvas files (suppresses full-reload)', () => {
999
+ const plugin = createPlugin()
1000
+ const result = plugin.handleHotUpdate({
1001
+ file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
1002
+ server: createMockServer(tmpDir),
1003
+ modules: [],
1004
+ })
1005
+ expect(result).toEqual([])
1006
+ })
1007
+
1008
+ it('handleHotUpdate does not send duplicate HMR events', () => {
1009
+ const plugin = createPlugin()
1010
+ const server = createMockServer(tmpDir)
1011
+ plugin.handleHotUpdate({
1012
+ file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
1013
+ server,
1014
+ modules: [],
1015
+ })
1016
+ // handleHotUpdate should NOT send events (invalidate() handles it)
1017
+ expect(server.wsSent.length).toBe(0)
1018
+ })
1019
+
1020
+ it('generated virtual module includes HMR listener for canvas updates', () => {
1021
+ writeCanvasFile(tmpDir, 'hmr-canvas', 'HMR Test')
1022
+ const plugin = createPlugin()
1023
+ const code = plugin.load(RESOLVED_ID)
1024
+
1025
+ expect(code).toContain('import.meta.hot')
1026
+ expect(code).toContain('storyboard:canvas-file-changed')
1027
+ expect(code).toContain('data.removed')
1028
+ expect(code).toContain('data.metadata')
1029
+ // Should merge into existing entries to preserve build-time fields
1030
+ expect(code).toContain('Object.assign')
1031
+ })
1032
+
1033
+ it('page refresh after canvas add yields updated module with new canvas', () => {
1034
+ const plugin = createPlugin()
1035
+ // First load — no canvases
1036
+ const code1 = plugin.load(RESOLVED_ID)
1037
+ expect(code1).not.toContain('"refresh-canvas"')
1038
+
1039
+ // Simulate adding a canvas and clearing buildResult (what softInvalidate does)
1040
+ writeCanvasFile(tmpDir, 'refresh-canvas', 'After Refresh')
1041
+
1042
+ // Manually clear buildResult by loading a fresh plugin instance with the same root
1043
+ const plugin2 = createPlugin()
1044
+ const code2 = plugin2.load(RESOLVED_ID)
1045
+ expect(code2).toContain('"refresh-canvas"')
1046
+ expect(code2).toContain('After Refresh')
1047
+ })
1048
+ })