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

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.12",
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.12",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.12",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -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 {
@@ -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') {
@@ -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
+ })