@dfosco/storyboard 0.6.12 → 0.6.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.
@@ -1,231 +1,99 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
+ import { describe, it, expect } from 'vitest'
2
3
  import useUndoRedo from './useUndoRedo.js'
3
4
 
4
- describe('useUndoRedo', () => {
5
+ describe('useUndoRedo (canvas)', () => {
5
6
  it('starts with canUndo and canRedo as false', () => {
6
7
  const { result } = renderHook(() => useUndoRedo())
7
8
  expect(result.current.canUndo).toBe(false)
8
9
  expect(result.current.canRedo).toBe(false)
9
10
  })
10
11
 
11
- it('can undo after a snapshot', () => {
12
+ it('track pushes an event id and enables undo', () => {
12
13
  const { result } = renderHook(() => useUndoRedo())
13
- const widgets = [{ id: '1', type: 'sticky-note', props: { text: 'a' }, position: { x: 0, y: 0 } }]
14
-
15
- act(() => result.current.snapshot(widgets, 'add'))
16
-
14
+ act(() => result.current.track('evt_a'))
17
15
  expect(result.current.canUndo).toBe(true)
18
16
  expect(result.current.canRedo).toBe(false)
19
17
  })
20
18
 
21
- it('undo returns the previous state and enables redo', () => {
19
+ it('popUndo returns the most recent tracked id; pushRedo enables redo', () => {
22
20
  const { result } = renderHook(() => useUndoRedo())
23
- const before = [{ id: '1', props: { text: 'a' } }]
24
- const after = [{ id: '1', props: { text: 'b' } }]
21
+ act(() => result.current.track('evt_a'))
22
+ act(() => result.current.track('evt_b'))
25
23
 
26
- act(() => result.current.snapshot(before, 'edit', '1'))
24
+ let popped
25
+ act(() => { popped = result.current.popUndo() })
27
26
 
28
- let restored
29
- act(() => { restored = result.current.undo(after) })
27
+ expect(popped).toBe('evt_b')
28
+ expect(result.current.canUndo).toBe(true)
29
+ expect(result.current.canRedo).toBe(false)
30
30
 
31
- expect(restored).toEqual(before)
32
- expect(result.current.canUndo).toBe(false)
31
+ act(() => result.current.pushRedo('evt_b_inv'))
33
32
  expect(result.current.canRedo).toBe(true)
34
33
  })
35
34
 
36
- it('redo returns the next state', () => {
35
+ it('popRedo returns the most recent redo id; pushUndo restores undo availability', () => {
37
36
  const { result } = renderHook(() => useUndoRedo())
38
- const before = [{ id: '1', props: { text: 'a' } }]
39
- const after = [{ id: '1', props: { text: 'b' } }]
37
+ act(() => result.current.track('evt_a'))
38
+ act(() => { result.current.popUndo() })
39
+ act(() => result.current.pushRedo('evt_a_inv'))
40
40
 
41
- act(() => result.current.snapshot(before, 'edit', '1'))
42
- act(() => { result.current.undo(after) })
41
+ expect(result.current.canRedo).toBe(true)
43
42
 
44
- let redone
45
- act(() => { redone = result.current.redo(before) })
43
+ let popped
44
+ act(() => { popped = result.current.popRedo() })
45
+ expect(popped).toBe('evt_a_inv')
46
+ expect(result.current.canRedo).toBe(false)
46
47
 
47
- expect(redone).toEqual(after)
48
+ act(() => result.current.pushUndo('evt_a_inv_inv'))
48
49
  expect(result.current.canUndo).toBe(true)
49
- expect(result.current.canRedo).toBe(false)
50
50
  })
51
51
 
52
- it('new mutation after undo clears the redo chain', () => {
52
+ it('new track call after undo clears the redo stack', () => {
53
53
  const { result } = renderHook(() => useUndoRedo())
54
- const s0 = [{ id: '1' }]
55
- const s1 = [{ id: '1' }, { id: '2' }]
56
- const s2 = [{ id: '1' }, { id: '3' }] // eslint-disable-line no-unused-vars
57
-
58
- act(() => result.current.snapshot(s0, 'add'))
59
- act(() => result.current.undo(s1))
54
+ act(() => result.current.track('evt_a'))
55
+ act(() => { result.current.popUndo() })
56
+ act(() => result.current.pushRedo('evt_a_inv'))
60
57
  expect(result.current.canRedo).toBe(true)
61
58
 
62
- // New mutation breaks redo
63
- act(() => result.current.snapshot(s0, 'add'))
59
+ act(() => result.current.track('evt_b'))
64
60
  expect(result.current.canRedo).toBe(false)
65
61
  expect(result.current.canUndo).toBe(true)
66
62
  })
67
63
 
68
- it('supports multi-step undo-redo-undo chains', () => {
64
+ it('trackMany pushes multiple ids and clears redo', () => {
69
65
  const { result } = renderHook(() => useUndoRedo())
70
- const s0 = [{ id: '1' }]
71
- const s1 = [{ id: '1' }, { id: '2' }]
72
- const s2 = [{ id: '1' }, { id: '2' }, { id: '3' }]
73
- const s3 = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]
74
-
75
- // Build history: s0 → s1 → s2 → s3
76
- act(() => result.current.snapshot(s0, 'add'))
77
- act(() => result.current.snapshot(s1, 'add'))
78
- act(() => result.current.snapshot(s2, 'add'))
79
-
80
- // present = s3, past = [s0, s1, s2]
81
- // Undo to s2
82
- let r
83
- act(() => { r = result.current.undo(s3) })
84
- expect(r).toEqual(s2)
85
-
86
- // Undo to s1
87
- act(() => { r = result.current.undo(s2) })
88
- expect(r).toEqual(s1)
89
-
90
- // Redo to s2
91
- act(() => { r = result.current.redo(s1) })
92
- expect(r).toEqual(s2)
93
-
94
- // Redo to s3
95
- act(() => { r = result.current.redo(s2) })
96
- expect(r).toEqual(s3)
97
-
98
- // Undo to s2 again
99
- act(() => { r = result.current.undo(s3) })
100
- expect(r).toEqual(s2)
101
-
102
- // Undo to s1
103
- act(() => { r = result.current.undo(s2) })
104
- expect(r).toEqual(s1)
105
-
106
- // Undo to s0
107
- act(() => { r = result.current.undo(s1) })
108
- expect(r).toEqual(s0)
109
-
110
- // Can't undo further
111
- expect(result.current.canUndo).toBe(false)
112
- act(() => { r = result.current.undo(s0) })
113
- expect(r).toBeNull()
114
- })
115
-
116
- it('coalesces edits to the same widget within timeout', () => {
117
- const { result } = renderHook(() => useUndoRedo())
118
- const s0 = [{ id: '1', props: { text: '' } }]
119
-
120
- // First edit — creates snapshot
121
- act(() => result.current.snapshot(s0, 'edit', '1'))
66
+ act(() => result.current.trackMany(['evt_a', 'evt_b', 'evt_c']))
122
67
  expect(result.current.canUndo).toBe(true)
123
-
124
- // Second edit to same widget within 2s — coalesced, no new snapshot
125
- act(() => result.current.snapshot(s0, 'edit', '1'))
126
- // Still only one entry in past
127
- let r
128
- act(() => { r = result.current.undo([{ id: '1', props: { text: 'abc' } }]) })
129
- expect(r).toEqual(s0)
130
- // No more undo after that
131
- expect(result.current.canUndo).toBe(false)
68
+ let popped
69
+ act(() => { popped = result.current.popUndo() })
70
+ expect(popped).toBe('evt_c')
132
71
  })
133
72
 
134
- it('does NOT coalesce edits to different widgets', () => {
73
+ it('ignores empty / non-string ids', () => {
135
74
  const { result } = renderHook(() => useUndoRedo())
136
- const s0 = [{ id: '1' }, { id: '2' }]
137
- const s1 = [{ id: '1', props: { text: 'a' } }, { id: '2' }]
138
-
139
- act(() => result.current.snapshot(s0, 'edit', '1'))
140
- act(() => result.current.snapshot(s1, 'edit', '2'))
141
-
142
- // Two entries in past
143
- expect(result.current.canUndo).toBe(true)
144
- act(() => { result.current.undo([]) })
145
- expect(result.current.canUndo).toBe(true)
146
- act(() => { result.current.undo([]) })
75
+ act(() => result.current.track(null))
76
+ act(() => result.current.track(undefined))
77
+ act(() => result.current.track(''))
78
+ act(() => result.current.track(42))
147
79
  expect(result.current.canUndo).toBe(false)
148
80
  })
149
81
 
150
- it('does NOT coalesce edit after a different action type', () => {
82
+ it('reset clears both stacks', () => {
151
83
  const { result } = renderHook(() => useUndoRedo())
152
- const s0 = [{ id: '1' }]
153
- const s1 = [{ id: '1' }, { id: '2' }]
154
-
155
- act(() => result.current.snapshot(s0, 'edit', '1'))
156
- act(() => result.current.snapshot(s1, 'add'))
157
- act(() => result.current.snapshot(s1, 'edit', '1'))
158
-
159
- // Three entries in past (edit + add + edit — not coalesced because add broke the sequence)
160
- act(() => { result.current.undo([]) })
161
- act(() => { result.current.undo([]) })
162
- act(() => { result.current.undo([]) })
163
- expect(result.current.canUndo).toBe(false)
164
- })
165
-
166
- it('reset clears all history', () => {
167
- const { result } = renderHook(() => useUndoRedo())
168
- act(() => result.current.snapshot([{ id: '1' }], 'add'))
169
- act(() => result.current.snapshot([{ id: '1' }, { id: '2' }], 'add'))
170
- expect(result.current.canUndo).toBe(true)
84
+ act(() => result.current.track('evt_a'))
85
+ act(() => { result.current.popUndo() })
86
+ act(() => result.current.pushRedo('evt_a_inv'))
171
87
 
172
88
  act(() => result.current.reset())
173
89
  expect(result.current.canUndo).toBe(false)
174
90
  expect(result.current.canRedo).toBe(false)
175
91
  })
176
92
 
177
- it('undo returns null when history is empty', () => {
178
- const { result } = renderHook(() => useUndoRedo())
179
- let r
180
- act(() => { r = result.current.undo([]) })
181
- expect(r).toBeNull()
182
- })
183
-
184
- it('redo returns null when future is empty', () => {
185
- const { result } = renderHook(() => useUndoRedo())
186
- let r
187
- act(() => { r = result.current.redo([]) })
188
- expect(r).toBeNull()
189
- })
190
-
191
- it('snapshots are deep clones (mutations do not leak)', () => {
192
- const { result } = renderHook(() => useUndoRedo())
193
- const widgets = [{ id: '1', props: { text: 'original' } }]
194
-
195
- act(() => result.current.snapshot(widgets, 'add'))
196
-
197
- // Mutate the original array
198
- widgets[0].props.text = 'mutated'
199
-
200
- let restored
201
- act(() => { restored = result.current.undo(widgets) })
202
- expect(restored[0].props.text).toBe('original')
203
- })
204
-
205
- it('caps history at 100 entries', () => {
93
+ it('popUndo on empty stack returns null', () => {
206
94
  const { result } = renderHook(() => useUndoRedo())
207
-
208
- for (let i = 0; i < 110; i++) {
209
- act(() => result.current.snapshot([{ id: String(i) }], 'add'))
210
- }
211
-
212
- // Should be capped at 100 — undo 100 times, then can't undo further
213
- let count = 0
214
- let r = true
215
- while (r !== null) {
216
- act(() => { r = result.current.undo([]) })
217
- if (r !== null) count++
218
- }
219
- expect(count).toBe(100)
220
- })
221
-
222
- it('snapshots null widgets as empty array (first widget on new canvas)', () => {
223
- const { result } = renderHook(() => useUndoRedo())
224
- act(() => result.current.snapshot(null, 'add'))
225
- expect(result.current.canUndo).toBe(true)
226
-
227
- let restored
228
- act(() => { restored = result.current.undo([{ id: '1' }]) })
229
- expect(restored).toEqual([])
95
+ let popped
96
+ act(() => { popped = result.current.popUndo() })
97
+ expect(popped).toBeNull()
230
98
  })
231
99
  })
@@ -1233,6 +1233,78 @@ export default function storyboardDataPlugin() {
1233
1233
  },
1234
1234
 
1235
1235
  configureServer(server) {
1236
+ // ── Prototypes isolation middleware ────────────────────────────
1237
+ // Serves the prototype-iframe HTML shell so consumers don't need to
1238
+ // ship their own `prototypes.html` + `prototypes-entry.jsx`. Loads
1239
+ // the library-side `prototypesEntry.jsx`, which uses absolute globs
1240
+ // (`/src/prototypes/...`) that Vite resolves against the *consumer's*
1241
+ // project root. See .agents/plans/vite-isolation.md.
1242
+ const prototypesEntryPath = new URL('../canvas/prototypesEntry.jsx', import.meta.url).pathname
1243
+ server.middlewares.use(async (req, res, next) => {
1244
+ if (!req.url) return next()
1245
+ let url = req.url
1246
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
1247
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
1248
+ url = url.slice(baseNoTrail.length) || '/'
1249
+ }
1250
+ const cleanUrl = url.split('?')[0].split('#')[0]
1251
+ if (cleanUrl !== '/prototypes.html') return next()
1252
+
1253
+ // Theme bootstrap mirrors index.html so the iframe paints with the
1254
+ // correct color mode on first frame (before mountStoryboardCore
1255
+ // wires up theme sync).
1256
+ const themeBootstrap = [
1257
+ '<script>',
1258
+ '(function() {',
1259
+ ' var stored = localStorage.getItem("sb-color-scheme");',
1260
+ ' var theme = stored || "system";',
1261
+ ' var resolved = theme;',
1262
+ ' if (theme === "system") {',
1263
+ ' resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";',
1264
+ ' }',
1265
+ ' var el = document.documentElement;',
1266
+ ' el.setAttribute("data-sb-theme", resolved);',
1267
+ ' if (theme === "system") {',
1268
+ ' el.setAttribute("data-color-mode", "auto");',
1269
+ ' el.setAttribute("data-light-theme", "light");',
1270
+ ' el.setAttribute("data-dark-theme", "dark");',
1271
+ ' } else if (resolved.indexOf("dark") === 0) {',
1272
+ ' el.setAttribute("data-color-mode", "dark");',
1273
+ ' el.setAttribute("data-dark-theme", resolved);',
1274
+ ' el.setAttribute("data-light-theme", "light");',
1275
+ ' } else {',
1276
+ ' el.setAttribute("data-color-mode", "light");',
1277
+ ' el.setAttribute("data-light-theme", resolved);',
1278
+ ' el.setAttribute("data-dark-theme", "dark");',
1279
+ ' }',
1280
+ '})();',
1281
+ '</script>',
1282
+ ].join('\n')
1283
+
1284
+ const rawHtml = [
1285
+ '<!DOCTYPE html>',
1286
+ '<html lang="en"><head>',
1287
+ '<meta charset="UTF-8" />',
1288
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />',
1289
+ '<title>Storyboard · Prototypes</title>',
1290
+ themeBootstrap,
1291
+ '</head><body>',
1292
+ '<div id="root"></div>',
1293
+ `<script type="module" src="/@fs${prototypesEntryPath}"></script>`,
1294
+ '</body></html>',
1295
+ ].join('\n')
1296
+
1297
+ try {
1298
+ const html = await server.transformIndexHtml(req.url, rawHtml)
1299
+ res.writeHead(200, { 'Content-Type': 'text/html' })
1300
+ res.end(html)
1301
+ } catch (err) {
1302
+ console.error('[storyboard] prototypes.html transform failed:', err)
1303
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
1304
+ res.end('prototypes.html failed')
1305
+ }
1306
+ })
1307
+
1236
1308
  // ── Component isolate middleware ───────────────────────────────
1237
1309
  // Serves a minimal HTML shell for iframe-isolated component widgets.
1238
1310
  // The iframe loads componentIsolate.jsx which reads query params