modal_stack 0.2.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +136 -52
  4. data/app/assets/javascripts/modal_stack.js +612 -63
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
  6. data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
  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 +54 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
  12. data/app/javascript/modal_stack/install.js +7 -1
  13. data/app/javascript/modal_stack/orchestrator.js +132 -3
  14. data/app/javascript/modal_stack/orchestrator.test.js +264 -2
  15. data/app/javascript/modal_stack/runtime.js +222 -13
  16. data/app/javascript/modal_stack/runtime.test.js +151 -0
  17. data/app/javascript/modal_stack/state.js +338 -39
  18. data/app/javascript/modal_stack/state.test.js +400 -13
  19. data/app/views/modal_stack/_dialog.html.erb +1 -0
  20. data/app/views/modal_stack/_panel.html.erb +4 -0
  21. data/lib/generators/modal_stack/install/install_generator.rb +18 -4
  22. data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
  23. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  24. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  25. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  26. data/lib/modal_stack/capybara.rb +21 -0
  27. data/lib/modal_stack/configuration.rb +43 -17
  28. data/lib/modal_stack/controller_extensions.rb +8 -1
  29. data/lib/modal_stack/engine.rb +2 -0
  30. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  33. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  34. data/lib/modal_stack/version.rb +1 -1
  35. data/lib/modal_stack.rb +5 -1
  36. metadata +11 -3
@@ -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,24 +234,160 @@ 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
- const commands = [
199
- { type: "unmountTopLayer" },
200
- { type: "historyBack", n: 1 },
201
- ];
372
+ const commands = [];
202
373
  if (newTop) {
374
+ commands.push({ type: "unmountTopLayer" });
375
+ commands.push({ type: "clearFrameCache", layerId: popped.id });
376
+ commands.push({ type: "historyBack", n: framesToWalkBack });
203
377
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
204
378
  commands.push({ type: "persistSnapshot" });
205
379
  } else {
380
+ // closeDialog first so the dialog's exit transition (opacity +
381
+ // backdrop background + display/overlay allow-discrete) starts
382
+ // immediately and runs in parallel with the layer's [data-leaving]
383
+ // transition. Without this order, the orchestrator awaits 220ms
384
+ // on unmountTopLayer before closing the dialog, then the backdrop
385
+ // fade kicks in for *another* 220ms — visually the backdrop fades
386
+ // after the modal is gone.
206
387
  commands.push({ type: "closeDialog" });
388
+ commands.push({ type: "unmountTopLayer" });
389
+ commands.push({ type: "clearFrameCache", layerId: popped.id });
390
+ commands.push({ type: "historyBack", n: framesToWalkBack });
207
391
  commands.push({ type: "unlockScroll" });
208
392
  commands.push({ type: "clearSnapshot" });
209
393
  }
@@ -226,6 +410,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
226
410
  }
227
411
 
228
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;
229
418
  const next = freezeLayer({
230
419
  id: patch.id ?? top.id,
231
420
  url: patch.url ?? top.url,
@@ -235,6 +424,8 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
235
424
  side: patch.side ?? top.side,
236
425
  width: patch.width ?? top.width,
237
426
  height: patch.height ?? top.height,
427
+ // single-frame layer — drop any path that was on the previous layer
428
+ frames: undefined,
238
429
  });
239
430
  const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
240
431
  const depth = newLayers.length;
@@ -242,28 +433,35 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
242
433
  const historyCmd = {
243
434
  type: historyMode === "push" ? "pushHistory" : "replaceHistory",
244
435
  url: next.url,
245
- historyState: { stackId: state.stackId, layerId: next.id, depth },
436
+ historyState: {
437
+ stackId: state.stackId,
438
+ layerId: next.id,
439
+ depth,
440
+ frameIndex: 0,
441
+ },
246
442
  };
247
443
 
248
- return {
249
- state: { ...state, layers: newLayers },
250
- commands: [
251
- {
252
- type: "morphTopLayer",
253
- layerId: next.id,
254
- url: next.url,
255
- depth,
256
- variant: next.variant,
257
- dismissible: next.dismissible,
258
- ...(next.size ? { size: next.size } : {}),
259
- ...(next.side ? { side: next.side } : {}),
260
- ...(next.width ? { width: next.width } : {}),
261
- ...(next.height ? { height: next.height } : {}),
262
- },
263
- historyCmd,
264
- { type: "persistSnapshot" },
265
- ],
266
- };
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 };
267
465
  }
268
466
 
269
467
  /**
@@ -273,12 +471,19 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
273
471
  */
274
472
  export function closeAll(state) {
275
473
  if (state.layers.length === 0) return { state, commands: [] };
276
- 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
+ }));
277
479
  return {
278
480
  state: { ...state, layers: Object.freeze([]) },
481
+ // closeDialog first so the dialog's exit transition runs in
482
+ // parallel with the layers' [data-leaving] transitions.
279
483
  commands: [
280
- { type: "unmountAllLayers" },
281
484
  { type: "closeDialog" },
485
+ { type: "unmountAllLayers" },
486
+ ...cacheClears,
282
487
  { type: "unlockScroll" },
283
488
  { type: "historyBack", n },
284
489
  { type: "clearSnapshot" },
@@ -287,8 +492,9 @@ export function closeAll(state) {
287
492
  }
288
493
 
289
494
  /**
290
- * Reduce a browser `popstate` into a transition: pop layers, morph the top,
291
- * 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.
292
498
  * @param {Stack} state
293
499
  * @param {{ historyState: any, locationHref: string }} options
294
500
  * @returns {Transition}
@@ -299,11 +505,17 @@ export function handlePopstate(state, { historyState, locationHref }) {
299
505
 
300
506
  if (!isOurs) {
301
507
  if (state.layers.length === 0) return { state, commands: [] };
508
+ const cacheClears = state.layers.map((l) => ({
509
+ type: "clearFrameCache",
510
+ layerId: l.id,
511
+ }));
302
512
  return {
303
513
  state: { ...state, layers: Object.freeze([]) },
514
+ // closeDialog first — see closeAll() for rationale.
304
515
  commands: [
305
- { type: "unmountAllLayers" },
306
516
  { type: "closeDialog" },
517
+ { type: "unmountAllLayers" },
518
+ ...cacheClears,
307
519
  { type: "unlockScroll" },
308
520
  { type: "clearSnapshot" },
309
521
  ],
@@ -313,19 +525,27 @@ export function handlePopstate(state, { historyState, locationHref }) {
313
525
  const targetDepth = historyState.depth ?? 0;
314
526
  const currentDepth = state.layers.length;
315
527
  const targetLayerId = historyState.layerId ?? null;
528
+ const targetFrameIndex = historyState.frameIndex ?? 0;
316
529
 
317
530
  if (targetDepth < currentDepth) {
531
+ const droppedLayers = state.layers.slice(targetDepth);
318
532
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
319
533
  const newTop = newLayers[newLayers.length - 1] ?? null;
320
534
  const commands = [];
321
- for (let i = 0; i < currentDepth - targetDepth; i++) {
535
+ // When popping back to the root via popstate, fire closeDialog
536
+ // first so the dialog's exit transition runs alongside the
537
+ // sequential unmountTopLayer cascade.
538
+ if (!newTop) commands.push({ type: "closeDialog" });
539
+ for (let i = 0; i < droppedLayers.length; i++) {
322
540
  commands.push({ type: "unmountTopLayer" });
323
541
  }
542
+ for (const dropped of droppedLayers) {
543
+ commands.push({ type: "clearFrameCache", layerId: dropped.id });
544
+ }
324
545
  if (newTop) {
325
546
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
326
547
  commands.push({ type: "persistSnapshot" });
327
548
  } else {
328
- commands.push({ type: "closeDialog" });
329
549
  commands.push({ type: "unlockScroll" });
330
550
  commands.push({ type: "clearSnapshot" });
331
551
  }
@@ -342,6 +562,55 @@ export function handlePopstate(state, { historyState, locationHref }) {
342
562
  }
343
563
 
344
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
+
345
614
  if (top && targetLayerId && top.id !== targetLayerId) {
346
615
  const updatedTop = freezeLayer({
347
616
  id: targetLayerId,
@@ -360,6 +629,7 @@ export function handlePopstate(state, { historyState, locationHref }) {
360
629
  return {
361
630
  state: { ...state, layers: newLayers },
362
631
  commands: [
632
+ { type: "clearFrameCache", layerId: top.id },
363
633
  {
364
634
  type: "morphTopLayer",
365
635
  layerId: targetLayerId,
@@ -382,6 +652,11 @@ export function handlePopstate(state, { historyState, locationHref }) {
382
652
 
383
653
  /**
384
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
+ *
385
660
  * @param {Stack} state
386
661
  * @param {{ now?: () => number }} [options]
387
662
  * @returns {string}
@@ -391,14 +666,32 @@ export function snapshot(state, { now = Date.now } = {}) {
391
666
  v: SNAPSHOT_VERSION,
392
667
  stackId: state.stackId,
393
668
  baseUrl: state.baseUrl,
394
- layers: state.layers,
669
+ layers: state.layers.map(serializeLayer),
395
670
  savedAt: now(),
396
671
  });
397
672
  }
398
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
+
399
688
  /**
400
689
  * Restore a stack from a serialized snapshot. Returns null on any validation
401
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
+ *
402
695
  * @param {string} serialized
403
696
  * @param {{ stackId?: string, maxAgeMs?: number, now?: () => number }} [options]
404
697
  * @returns {Stack|null}
@@ -414,7 +707,7 @@ export function restore(
414
707
  } catch {
415
708
  return null;
416
709
  }
417
- if (parsed?.v !== SNAPSHOT_VERSION) return null;
710
+ if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION) return null;
418
711
  if (typeof parsed.stackId !== "string") return null;
419
712
  if (typeof parsed.baseUrl !== "string") return null;
420
713
  if (!Array.isArray(parsed.layers)) return null;
@@ -425,6 +718,12 @@ export function restore(
425
718
  for (const l of parsed.layers) {
426
719
  if (!l || typeof l.id !== "string" || typeof l.url !== "string") return null;
427
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
+ }
428
727
  }
429
728
 
430
729
  return Object.freeze({