modal_stack 0.3.0 → 0.4.1

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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +113 -32
  4. data/app/assets/javascripts/modal_stack.js +488 -50
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
  6. data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
  9. data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
  10. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +50 -0
  11. data/app/javascript/modal_stack/install.js +7 -1
  12. data/app/javascript/modal_stack/orchestrator.js +53 -7
  13. data/app/javascript/modal_stack/orchestrator.test.js +96 -0
  14. data/app/javascript/modal_stack/runtime.js +167 -5
  15. data/app/javascript/modal_stack/runtime.test.js +83 -0
  16. data/app/javascript/modal_stack/state.js +319 -34
  17. data/app/javascript/modal_stack/state.test.js +394 -9
  18. data/app/views/modal_stack/_dialog.html.erb +1 -0
  19. data/app/views/modal_stack/_panel.html.erb +4 -0
  20. data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
  21. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  22. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  23. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  24. data/lib/modal_stack/capybara.rb +21 -0
  25. data/lib/modal_stack/configuration.rb +37 -16
  26. data/lib/modal_stack/engine.rb +2 -0
  27. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  28. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +1 -1
  29. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  30. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  31. data/lib/modal_stack/version.rb +1 -1
  32. data/lib/modal_stack.rb +5 -1
  33. metadata +9 -2
@@ -2,16 +2,22 @@
2
2
  * @typedef {"modal" | "drawer" | "bottom_sheet" | "confirmation"} Variant
3
3
  * @typedef {"left" | "right" | "top" | "bottom"} DrawerSide
4
4
  * @typedef {"sm" | "md" | "lg" | "xl"} Size
5
+ * @typedef {"slide" | "fade" | "none"} Transition
6
+ *
7
+ * @typedef {Object} Frame
8
+ * @property {string} url Frame URL — written to history when this frame is on top
9
+ * @property {boolean} stale When true, runtime refetches before restoring on back
5
10
  *
6
11
  * @typedef {Object} Layer
7
12
  * @property {string} id Stable layer identifier (used for inertness + DOM lookup)
8
- * @property {string} url Layer URL — also written to history
13
+ * @property {string} url Top frame URL — kept for back-compat with the existing API
9
14
  * @property {Variant} variant
10
15
  * @property {boolean} dismissible
11
16
  * @property {Size|null} size
12
17
  * @property {DrawerSide|null} side Required for drawers; null otherwise
13
18
  * @property {string|null} width Free-form CSS width (e.g. "42rem")
14
19
  * @property {string|null} height
20
+ * @property {readonly Frame[]} frames Path frames; layer.url === frames[last].url
15
21
  *
16
22
  * @typedef {Object} Stack
17
23
  * @property {string} stackId
@@ -29,7 +35,11 @@ export const VARIANTS = Object.freeze([
29
35
  "confirmation",
30
36
  ]);
31
37
 
32
- const SNAPSHOT_VERSION = 1;
38
+ export const TRANSITIONS = Object.freeze(["slide", "fade", "none"]);
39
+
40
+ // v2 introduced `frames` per layer (modal path feature). v1 snapshots are
41
+ // rehydrated by synthesising a single-frame array — see restore().
42
+ const SNAPSHOT_VERSION = 2;
33
43
  const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
34
44
  const DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
35
45
  const MAX_DEPTH_STRATEGIES = Object.freeze(["raise", "warn", "silent"]);
@@ -66,17 +76,36 @@ function normalizeLayerOptions({ variant, size, side, width, height }) {
66
76
  };
67
77
  }
68
78
 
69
- function freezeLayer({ id, url, variant, dismissible, size, side, width, height }) {
79
+ function freezeFrame({ url, stale = false }) {
80
+ if (typeof url !== "string" || url.length === 0) {
81
+ throw new Error("frame.url required");
82
+ }
83
+ return Object.freeze({ url, stale: !!stale });
84
+ }
85
+
86
+ function freezeFrames(frames) {
87
+ return Object.freeze(frames.map(freezeFrame));
88
+ }
89
+
90
+ function freezeLayer({ id, url, variant, dismissible, size, side, width, height, frames }) {
70
91
  const normalized = normalizeLayerOptions({ variant, size, side, width, height });
92
+ // The layer's url is always the top frame's url; if frames isn't supplied
93
+ // (e.g. fresh push or restoring a v1 snapshot) we synthesize a single frame.
94
+ const framesArray = Array.isArray(frames) && frames.length > 0
95
+ ? frames
96
+ : [{ url, stale: false }];
97
+ const frozenFrames = freezeFrames(framesArray);
98
+ const topUrl = frozenFrames[frozenFrames.length - 1].url;
71
99
  return Object.freeze({
72
100
  id,
73
- url,
101
+ url: topUrl,
74
102
  variant,
75
103
  dismissible: !!dismissible,
76
104
  size: normalized.size,
77
105
  side: normalized.side,
78
106
  width: normalized.width,
79
107
  height: normalized.height,
108
+ frames: frozenFrames,
80
109
  });
81
110
  }
82
111
 
@@ -99,6 +128,20 @@ export function topLayer(state) {
99
128
  return state.layers[state.layers.length - 1] ?? null;
100
129
  }
101
130
 
131
+ function totalFrameCount(layers) {
132
+ let n = 0;
133
+ for (const l of layers) n += l.frames.length;
134
+ return n;
135
+ }
136
+
137
+ function validateTransition(value) {
138
+ if (value == null) return null;
139
+ if (!TRANSITIONS.includes(value)) {
140
+ throw new Error(`unknown transition: ${value}`);
141
+ }
142
+ return value;
143
+ }
144
+
102
145
  /**
103
146
  * Push a new layer on top of the stack.
104
147
  *
@@ -178,7 +221,12 @@ export function push(state, layer, options = {}) {
178
221
  commands.push({
179
222
  type: "pushHistory",
180
223
  url: newLayer.url,
181
- historyState: { stackId: state.stackId, layerId: newLayer.id, depth },
224
+ historyState: {
225
+ stackId: state.stackId,
226
+ layerId: newLayer.id,
227
+ depth,
228
+ frameIndex: 0,
229
+ },
182
230
  });
183
231
  commands.push({ type: "persistSnapshot" });
184
232
 
@@ -186,19 +234,146 @@ export function push(state, layer, options = {}) {
186
234
  }
187
235
 
188
236
  /**
189
- * Pop the top layer. No-op when the stack is empty.
237
+ * Append a frame to the top layer's path. Forward navigation in a wizard
238
+ * that retains a back-history.
239
+ *
240
+ * @param {Stack} state
241
+ * @param {{ url: string, stale?: boolean }} frame
242
+ * @param {{ transition?: Transition|null }} [options]
243
+ * @returns {Transition}
244
+ */
245
+ export function pathTo(state, frame, options = {}) {
246
+ if (state.layers.length === 0) {
247
+ throw new Error("pathTo requires at least one layer");
248
+ }
249
+ if (typeof frame?.url !== "string" || frame.url.length === 0) {
250
+ throw new Error("pathTo requires a frame.url");
251
+ }
252
+ const transition = validateTransition(options.transition ?? null);
253
+
254
+ const top = topLayer(state);
255
+ const previousFrameIndex = top.frames.length - 1;
256
+ const newFrame = freezeFrame({ url: frame.url, stale: !!frame.stale });
257
+ const newFrames = [...top.frames, newFrame];
258
+ const newTop = freezeLayer({
259
+ id: top.id,
260
+ url: newFrame.url,
261
+ variant: top.variant,
262
+ dismissible: top.dismissible,
263
+ size: top.size,
264
+ side: top.side,
265
+ width: top.width,
266
+ height: top.height,
267
+ frames: newFrames,
268
+ });
269
+ const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
270
+ const depth = newLayers.length;
271
+ const newFrameIndex = newFrames.length - 1;
272
+
273
+ return {
274
+ state: { ...state, layers: newLayers },
275
+ commands: [
276
+ {
277
+ type: "mountFrame",
278
+ layerId: newTop.id,
279
+ fromFrameIndex: previousFrameIndex,
280
+ toFrameIndex: newFrameIndex,
281
+ url: newFrame.url,
282
+ stale: newFrame.stale,
283
+ ...(transition ? { transition } : {}),
284
+ },
285
+ {
286
+ type: "pushHistory",
287
+ url: newFrame.url,
288
+ historyState: {
289
+ stackId: state.stackId,
290
+ layerId: newTop.id,
291
+ depth,
292
+ frameIndex: newFrameIndex,
293
+ },
294
+ },
295
+ { type: "persistSnapshot" },
296
+ ],
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Step back through frames in the top layer's path. Clamps at the first
302
+ * frame: the layer is never closed by pathBack — use pop() / closeAll()
303
+ * for that.
304
+ *
305
+ * @param {Stack} state
306
+ * @param {{ steps?: number, transition?: Transition|null }} [options]
307
+ * @returns {Transition}
308
+ */
309
+ export function pathBack(state, options = {}) {
310
+ if (state.layers.length === 0) {
311
+ throw new Error("pathBack requires at least one layer");
312
+ }
313
+ const requestedSteps = options.steps == null ? 1 : Math.floor(options.steps);
314
+ if (!Number.isFinite(requestedSteps) || requestedSteps < 1) {
315
+ throw new Error("pathBack: steps must be a positive integer");
316
+ }
317
+ const transition = validateTransition(options.transition ?? null);
318
+
319
+ const top = topLayer(state);
320
+ const fromFrameIndex = top.frames.length - 1;
321
+ const maxSteps = top.frames.length - 1;
322
+ const effectiveSteps = Math.min(requestedSteps, maxSteps);
323
+ if (effectiveSteps === 0) return { state, commands: [] };
324
+
325
+ const toFrameIndex = fromFrameIndex - effectiveSteps;
326
+ const newFrames = top.frames.slice(0, toFrameIndex + 1);
327
+ const targetFrame = newFrames[newFrames.length - 1];
328
+ const newTop = freezeLayer({
329
+ id: top.id,
330
+ url: targetFrame.url,
331
+ variant: top.variant,
332
+ dismissible: top.dismissible,
333
+ size: top.size,
334
+ side: top.side,
335
+ width: top.width,
336
+ height: top.height,
337
+ frames: newFrames,
338
+ });
339
+ const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
340
+
341
+ return {
342
+ state: { ...state, layers: newLayers },
343
+ commands: [
344
+ {
345
+ type: "unmountFrame",
346
+ layerId: newTop.id,
347
+ fromFrameIndex,
348
+ toFrameIndex,
349
+ url: targetFrame.url,
350
+ stale: targetFrame.stale,
351
+ ...(transition ? { transition } : {}),
352
+ },
353
+ { type: "historyBack", n: effectiveSteps },
354
+ { type: "persistSnapshot" },
355
+ ],
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Pop the top layer (and all of its path frames). No-op when the stack
361
+ * is empty.
190
362
  * @param {Stack} state
191
363
  * @returns {Transition}
192
364
  */
193
365
  export function pop(state) {
194
366
  if (state.layers.length === 0) return { state, commands: [] };
195
367
 
368
+ const popped = topLayer(state);
369
+ const framesToWalkBack = popped.frames.length;
196
370
  const newLayers = Object.freeze(state.layers.slice(0, -1));
197
371
  const newTop = newLayers[newLayers.length - 1] ?? null;
198
372
  const commands = [];
199
373
  if (newTop) {
200
374
  commands.push({ type: "unmountTopLayer" });
201
- commands.push({ type: "historyBack", n: 1 });
375
+ commands.push({ type: "clearFrameCache", layerId: popped.id });
376
+ commands.push({ type: "historyBack", n: framesToWalkBack });
202
377
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
203
378
  commands.push({ type: "persistSnapshot" });
204
379
  } else {
@@ -211,7 +386,8 @@ export function pop(state) {
211
386
  // after the modal is gone.
212
387
  commands.push({ type: "closeDialog" });
213
388
  commands.push({ type: "unmountTopLayer" });
214
- commands.push({ type: "historyBack", n: 1 });
389
+ commands.push({ type: "clearFrameCache", layerId: popped.id });
390
+ commands.push({ type: "historyBack", n: framesToWalkBack });
215
391
  commands.push({ type: "unlockScroll" });
216
392
  commands.push({ type: "clearSnapshot" });
217
393
  }
@@ -234,6 +410,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
234
410
  }
235
411
 
236
412
  const top = topLayer(state);
413
+ // replaceTop collapses the top layer's path back to a single frame
414
+ // — the existing path is forgotten. Walk history back one step per
415
+ // dropped frame so the browser's back button doesn't land on stale
416
+ // frame entries.
417
+ const framesToCollapse = top.frames.length - 1;
237
418
  const next = freezeLayer({
238
419
  id: patch.id ?? top.id,
239
420
  url: patch.url ?? top.url,
@@ -243,6 +424,8 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
243
424
  side: patch.side ?? top.side,
244
425
  width: patch.width ?? top.width,
245
426
  height: patch.height ?? top.height,
427
+ // single-frame layer — drop any path that was on the previous layer
428
+ frames: undefined,
246
429
  });
247
430
  const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
248
431
  const depth = newLayers.length;
@@ -250,28 +433,35 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
250
433
  const historyCmd = {
251
434
  type: historyMode === "push" ? "pushHistory" : "replaceHistory",
252
435
  url: next.url,
253
- historyState: { stackId: state.stackId, layerId: next.id, depth },
436
+ historyState: {
437
+ stackId: state.stackId,
438
+ layerId: next.id,
439
+ depth,
440
+ frameIndex: 0,
441
+ },
254
442
  };
255
443
 
256
- return {
257
- state: { ...state, layers: newLayers },
258
- commands: [
259
- {
260
- type: "morphTopLayer",
261
- layerId: next.id,
262
- url: next.url,
263
- depth,
264
- variant: next.variant,
265
- dismissible: next.dismissible,
266
- ...(next.size ? { size: next.size } : {}),
267
- ...(next.side ? { side: next.side } : {}),
268
- ...(next.width ? { width: next.width } : {}),
269
- ...(next.height ? { height: next.height } : {}),
270
- },
271
- historyCmd,
272
- { type: "persistSnapshot" },
273
- ],
274
- };
444
+ const commands = [];
445
+ if (framesToCollapse > 0) {
446
+ commands.push({ type: "clearFrameCache", layerId: top.id });
447
+ commands.push({ type: "historyBack", n: framesToCollapse });
448
+ }
449
+ commands.push({
450
+ type: "morphTopLayer",
451
+ layerId: next.id,
452
+ url: next.url,
453
+ depth,
454
+ variant: next.variant,
455
+ dismissible: next.dismissible,
456
+ ...(next.size ? { size: next.size } : {}),
457
+ ...(next.side ? { side: next.side } : {}),
458
+ ...(next.width ? { width: next.width } : {}),
459
+ ...(next.height ? { height: next.height } : {}),
460
+ });
461
+ commands.push(historyCmd);
462
+ commands.push({ type: "persistSnapshot" });
463
+
464
+ return { state: { ...state, layers: newLayers }, commands };
275
465
  }
276
466
 
277
467
  /**
@@ -281,7 +471,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
281
471
  */
282
472
  export function closeAll(state) {
283
473
  if (state.layers.length === 0) return { state, commands: [] };
284
- const n = state.layers.length;
474
+ const n = totalFrameCount(state.layers);
475
+ const cacheClears = state.layers.map((l) => ({
476
+ type: "clearFrameCache",
477
+ layerId: l.id,
478
+ }));
285
479
  return {
286
480
  state: { ...state, layers: Object.freeze([]) },
287
481
  // closeDialog first so the dialog's exit transition runs in
@@ -289,6 +483,7 @@ export function closeAll(state) {
289
483
  commands: [
290
484
  { type: "closeDialog" },
291
485
  { type: "unmountAllLayers" },
486
+ ...cacheClears,
292
487
  { type: "unlockScroll" },
293
488
  { type: "historyBack", n },
294
489
  { type: "clearSnapshot" },
@@ -297,8 +492,9 @@ export function closeAll(state) {
297
492
  }
298
493
 
299
494
  /**
300
- * Reduce a browser `popstate` into a transition: pop layers, morph the top,
301
- * or request a rebuild from snapshot for forward navigation.
495
+ * Reduce a browser `popstate` into a transition: pop layers, step back
496
+ * through frames, morph the top, or request a rebuild from snapshot for
497
+ * forward navigation.
302
498
  * @param {Stack} state
303
499
  * @param {{ historyState: any, locationHref: string }} options
304
500
  * @returns {Transition}
@@ -309,12 +505,17 @@ export function handlePopstate(state, { historyState, locationHref }) {
309
505
 
310
506
  if (!isOurs) {
311
507
  if (state.layers.length === 0) return { state, commands: [] };
508
+ const cacheClears = state.layers.map((l) => ({
509
+ type: "clearFrameCache",
510
+ layerId: l.id,
511
+ }));
312
512
  return {
313
513
  state: { ...state, layers: Object.freeze([]) },
314
514
  // closeDialog first — see closeAll() for rationale.
315
515
  commands: [
316
516
  { type: "closeDialog" },
317
517
  { type: "unmountAllLayers" },
518
+ ...cacheClears,
318
519
  { type: "unlockScroll" },
319
520
  { type: "clearSnapshot" },
320
521
  ],
@@ -324,8 +525,10 @@ export function handlePopstate(state, { historyState, locationHref }) {
324
525
  const targetDepth = historyState.depth ?? 0;
325
526
  const currentDepth = state.layers.length;
326
527
  const targetLayerId = historyState.layerId ?? null;
528
+ const targetFrameIndex = historyState.frameIndex ?? 0;
327
529
 
328
530
  if (targetDepth < currentDepth) {
531
+ const droppedLayers = state.layers.slice(targetDepth);
329
532
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
330
533
  const newTop = newLayers[newLayers.length - 1] ?? null;
331
534
  const commands = [];
@@ -333,9 +536,12 @@ export function handlePopstate(state, { historyState, locationHref }) {
333
536
  // first so the dialog's exit transition runs alongside the
334
537
  // sequential unmountTopLayer cascade.
335
538
  if (!newTop) commands.push({ type: "closeDialog" });
336
- for (let i = 0; i < currentDepth - targetDepth; i++) {
539
+ for (let i = 0; i < droppedLayers.length; i++) {
337
540
  commands.push({ type: "unmountTopLayer" });
338
541
  }
542
+ for (const dropped of droppedLayers) {
543
+ commands.push({ type: "clearFrameCache", layerId: dropped.id });
544
+ }
339
545
  if (newTop) {
340
546
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
341
547
  commands.push({ type: "persistSnapshot" });
@@ -356,6 +562,55 @@ export function handlePopstate(state, { historyState, locationHref }) {
356
562
  }
357
563
 
358
564
  const top = topLayer(state);
565
+ if (top && targetLayerId && top.id === targetLayerId) {
566
+ const currentFrameIndex = top.frames.length - 1;
567
+ if (targetFrameIndex === currentFrameIndex) {
568
+ return { state, commands: [] };
569
+ }
570
+ if (targetFrameIndex < currentFrameIndex) {
571
+ const newFrames = top.frames.slice(0, targetFrameIndex + 1);
572
+ const targetFrame = newFrames[newFrames.length - 1];
573
+ const updatedTop = freezeLayer({
574
+ id: top.id,
575
+ url: targetFrame.url,
576
+ variant: top.variant,
577
+ dismissible: top.dismissible,
578
+ size: top.size,
579
+ side: top.side,
580
+ width: top.width,
581
+ height: top.height,
582
+ frames: newFrames,
583
+ });
584
+ const newLayers = Object.freeze([
585
+ ...state.layers.slice(0, -1),
586
+ updatedTop,
587
+ ]);
588
+ return {
589
+ state: { ...state, layers: newLayers },
590
+ commands: [
591
+ {
592
+ type: "unmountFrame",
593
+ layerId: top.id,
594
+ fromFrameIndex: currentFrameIndex,
595
+ toFrameIndex: targetFrameIndex,
596
+ url: targetFrame.url,
597
+ stale: targetFrame.stale,
598
+ },
599
+ { type: "persistSnapshot" },
600
+ ],
601
+ };
602
+ }
603
+ // Forward popstate to a frame we no longer track — happens when the
604
+ // user pressed back, then forward, after the path frames were dropped
605
+ // from state. Defer to the controller (snapshot rebuild / fetch).
606
+ return {
607
+ state,
608
+ commands: [
609
+ { type: "rebuildFromSnapshot", targetDepth, targetLayerId },
610
+ ],
611
+ };
612
+ }
613
+
359
614
  if (top && targetLayerId && top.id !== targetLayerId) {
360
615
  const updatedTop = freezeLayer({
361
616
  id: targetLayerId,
@@ -374,6 +629,7 @@ export function handlePopstate(state, { historyState, locationHref }) {
374
629
  return {
375
630
  state: { ...state, layers: newLayers },
376
631
  commands: [
632
+ { type: "clearFrameCache", layerId: top.id },
377
633
  {
378
634
  type: "morphTopLayer",
379
635
  layerId: targetLayerId,
@@ -396,6 +652,11 @@ export function handlePopstate(state, { historyState, locationHref }) {
396
652
 
397
653
  /**
398
654
  * Serialize the stack for sessionStorage. Versioned + timestamped.
655
+ *
656
+ * Frames are serialized as `{ url, stale }` only — the cached HTML lives
657
+ * in the runtime, not the snapshot, so a refresh refetches the top frame
658
+ * and lazily refetches earlier frames if/when the user steps back.
659
+ *
399
660
  * @param {Stack} state
400
661
  * @param {{ now?: () => number }} [options]
401
662
  * @returns {string}
@@ -405,14 +666,32 @@ export function snapshot(state, { now = Date.now } = {}) {
405
666
  v: SNAPSHOT_VERSION,
406
667
  stackId: state.stackId,
407
668
  baseUrl: state.baseUrl,
408
- layers: state.layers,
669
+ layers: state.layers.map(serializeLayer),
409
670
  savedAt: now(),
410
671
  });
411
672
  }
412
673
 
674
+ function serializeLayer(layer) {
675
+ return {
676
+ id: layer.id,
677
+ url: layer.url,
678
+ variant: layer.variant,
679
+ dismissible: layer.dismissible,
680
+ size: layer.size,
681
+ side: layer.side,
682
+ width: layer.width,
683
+ height: layer.height,
684
+ frames: layer.frames.map((f) => ({ url: f.url, stale: f.stale })),
685
+ };
686
+ }
687
+
413
688
  /**
414
689
  * Restore a stack from a serialized snapshot. Returns null on any validation
415
690
  * failure (wrong stackId, expired, malformed JSON, etc.).
691
+ *
692
+ * Accepts both v1 (pre-frames) and v2 snapshots: v1 layers are rehydrated
693
+ * with a synthetic single-frame array so existing tabs survive an upgrade.
694
+ *
416
695
  * @param {string} serialized
417
696
  * @param {{ stackId?: string, maxAgeMs?: number, now?: () => number }} [options]
418
697
  * @returns {Stack|null}
@@ -428,7 +707,7 @@ export function restore(
428
707
  } catch {
429
708
  return null;
430
709
  }
431
- if (parsed?.v !== SNAPSHOT_VERSION) return null;
710
+ if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION) return null;
432
711
  if (typeof parsed.stackId !== "string") return null;
433
712
  if (typeof parsed.baseUrl !== "string") return null;
434
713
  if (!Array.isArray(parsed.layers)) return null;
@@ -439,6 +718,12 @@ export function restore(
439
718
  for (const l of parsed.layers) {
440
719
  if (!l || typeof l.id !== "string" || typeof l.url !== "string") return null;
441
720
  if (!VARIANTS.includes(l.variant)) return null;
721
+ if (l.frames !== undefined) {
722
+ if (!Array.isArray(l.frames) || l.frames.length === 0) return null;
723
+ for (const f of l.frames) {
724
+ if (!f || typeof f.url !== "string") return null;
725
+ }
726
+ }
442
727
  }
443
728
 
444
729
  return Object.freeze({