@seatlayer/core 0.10.0 → 0.10.2

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/dist/index.d.cts CHANGED
@@ -411,6 +411,18 @@ interface ISeatmapRenderer {
411
411
  }): void;
412
412
  /** Bulk status update; re-renders affected seats only. */
413
413
  setStatus(seatIds: string[], status: SeatStatus): void;
414
+ /**
415
+ * SYNCHRONOUS repaint that bypasses requestAnimationFrame. Konva's batchDraw()
416
+ * (used by setStatus and friends) schedules the actual paint on the next rAF
417
+ * tick, which Chrome throttles/pauses on hidden, backgrounded, or occluded
418
+ * tabs — so a seat-status delta updates the scene graph but the pixels never
419
+ * change until the tab is foregrounded again. forceDraw() paints the affected
420
+ * layers immediately (Layer.draw() is synchronous) and flushes any pending
421
+ * cache-debounce, so a caller (visibilitychange catch-up, or an opted-in
422
+ * always-live board) can guarantee the canvas reflects current state
423
+ * regardless of tab visibility. No-op difference in the foreground.
424
+ */
425
+ forceDraw(): void;
414
426
  getStatus(seatId: string): SeatStatus;
415
427
  getSelection(): ExpandedSeat[];
416
428
  clearSelection(): void;
@@ -1035,6 +1047,32 @@ declare class SeatmapRenderer implements ISeatmapRenderer {
1035
1047
  */
1036
1048
  private resolveSelectionColor;
1037
1049
  private setSelected;
1050
+ /**
1051
+ * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
1052
+ * frames this section in the current viewport. Used to decide whether
1053
+ * redirecting a seat tap to section-focus would actually zoom in FURTHER, or
1054
+ * whether the section already fills the viewport (small container) so the tap
1055
+ * must fall through and pick.
1056
+ */
1057
+ private sectionFrameScale;
1058
+ /**
1059
+ * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
1060
+ * then toggle. The gate (§4) redirects small on-screen seats to section-focus
1061
+ * to avoid blind mis-picks at overview scales — but ONLY when focusing would
1062
+ * zoom the seat up to a safe size. If we're already focused on the section, or
1063
+ * the section already fills the viewport (a narrow — OR even a wide — container
1064
+ * frames a big section BELOW LABEL_SCALE), redirecting is a no-op that would
1065
+ * leave the seat permanently unpickable (§5 `picking-gate-unreachable`), so we
1066
+ * fall through and PICK. This is the invariant: seats are never unpickable.
1067
+ */
1068
+ private handleSeatTap;
1069
+ /**
1070
+ * Nearest SELECTABLE seat whose centre is within `slopPx` (screen space) of a
1071
+ * seat circle edge, or null. Enlarges the effective tap target for small seats
1072
+ * so a near-miss on mobile still lands on the intended seat, without ever
1073
+ * hijacking a clean tap on empty space beyond the slop.
1074
+ */
1075
+ private nearestSeatToScreen;
1038
1076
  private wireInteraction;
1039
1077
  /**
1040
1078
  * Which deck (floor) a screen tap landed on in the 3D stacked overview. The
@@ -1083,7 +1121,22 @@ declare class SeatmapRenderer implements ISeatmapRenderer {
1083
1121
  /** rAF-coalesced `onViewChange` — at most one host callback per animation frame. */
1084
1122
  private scheduleViewChange;
1085
1123
  private updateLOD;
1124
+ /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
1125
+ * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
1126
+ private rebuildSeatCache;
1086
1127
  private cacheSeatLayer;
1128
+ /**
1129
+ * SYNCHRONOUS repaint that bypasses requestAnimationFrame — see the
1130
+ * ISeatmapRenderer.forceDraw() contract. Chrome pauses rAF (and therefore
1131
+ * Konva's batchDraw) on hidden/backgrounded/occluded tabs, so seat-status
1132
+ * deltas applied via setStatus() mutate the scene graph but never reach the
1133
+ * canvas until the tab is foregrounded. This flushes the cache-debounce and
1134
+ * paints every layer setStatus() can touch, immediately.
1135
+ *
1136
+ * Additive: the foreground path never calls this, so foreground behaviour is
1137
+ * byte-identical.
1138
+ */
1139
+ forceDraw(): void;
1087
1140
  private updateLabels;
1088
1141
  private handleResize;
1089
1142
  private startFpsLoop;
@@ -1254,6 +1307,18 @@ interface PickerOptions extends PickerCallbacks {
1254
1307
  flashOnLiveChange?: boolean;
1255
1308
  /** Start in colorblind-safe rendering (Okabe-Ito hues + hollow booked seats). */
1256
1309
  colorblindSafe?: boolean;
1310
+ /**
1311
+ * Keep painting seat-status deltas even while the tab is hidden/backgrounded.
1312
+ *
1313
+ * Default FALSE — the buyer widget stays efficient: it lets rAF stay paused
1314
+ * while hidden and does a single synchronous catch-up repaint on regain (the
1315
+ * visibilitychange handler resnapshots + forceDraw()s). Set TRUE only for an
1316
+ * always-live surface — the future organizer control-room board — where a
1317
+ * backgrounded monitor must keep repainting; then each delta calls
1318
+ * renderer.forceDraw() when the tab is hidden. Purely a paint hint; enforcement
1319
+ * and the WS protocol are identical either way.
1320
+ */
1321
+ keepLiveWhileHidden?: boolean;
1257
1322
  }
1258
1323
  declare class PickerController {
1259
1324
  private readonly opts;
@@ -1279,6 +1344,10 @@ declare class PickerController {
1279
1344
  private reconnectTimer;
1280
1345
  private attempt;
1281
1346
  private closed;
1347
+ /** Bound visibilitychange listener (buyer catch-up on tab regain). Kept on the
1348
+ * instance so destroy() can detach it; null when not attached (guards double
1349
+ * mounts / non-DOM environments). */
1350
+ private onVisibilityChange;
1282
1351
  private hold_;
1283
1352
  private liveStatuses;
1284
1353
  private currency;
@@ -1472,6 +1541,19 @@ declare class PickerController {
1472
1541
  private applySeatsMap;
1473
1542
  private clearBookedHoldIfSettled;
1474
1543
  private resnapshot;
1544
+ /**
1545
+ * Buyer catch-up on tab regain. While a tab is hidden Chrome pauses rAF, so
1546
+ * seat-status deltas that arrived over the WS mutated the scene graph but the
1547
+ * canvas colors never repainted. When the tab becomes visible again we (1)
1548
+ * resnapshot to pull authoritative state for anything the socket may have
1549
+ * missed while backgrounded, then (2) forceDraw() a synchronous repaint so the
1550
+ * seat colors are correct immediately rather than on the next incidental draw.
1551
+ *
1552
+ * Attached once per mount; guarded against double-attach and non-DOM
1553
+ * environments; detached in destroy() (no leaks).
1554
+ */
1555
+ private attachVisibilityListener;
1556
+ private detachVisibilityListener;
1475
1557
  private connect;
1476
1558
  private scheduleReconnect;
1477
1559
  }
package/dist/index.d.ts CHANGED
@@ -411,6 +411,18 @@ interface ISeatmapRenderer {
411
411
  }): void;
412
412
  /** Bulk status update; re-renders affected seats only. */
413
413
  setStatus(seatIds: string[], status: SeatStatus): void;
414
+ /**
415
+ * SYNCHRONOUS repaint that bypasses requestAnimationFrame. Konva's batchDraw()
416
+ * (used by setStatus and friends) schedules the actual paint on the next rAF
417
+ * tick, which Chrome throttles/pauses on hidden, backgrounded, or occluded
418
+ * tabs — so a seat-status delta updates the scene graph but the pixels never
419
+ * change until the tab is foregrounded again. forceDraw() paints the affected
420
+ * layers immediately (Layer.draw() is synchronous) and flushes any pending
421
+ * cache-debounce, so a caller (visibilitychange catch-up, or an opted-in
422
+ * always-live board) can guarantee the canvas reflects current state
423
+ * regardless of tab visibility. No-op difference in the foreground.
424
+ */
425
+ forceDraw(): void;
414
426
  getStatus(seatId: string): SeatStatus;
415
427
  getSelection(): ExpandedSeat[];
416
428
  clearSelection(): void;
@@ -1035,6 +1047,32 @@ declare class SeatmapRenderer implements ISeatmapRenderer {
1035
1047
  */
1036
1048
  private resolveSelectionColor;
1037
1049
  private setSelected;
1050
+ /**
1051
+ * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
1052
+ * frames this section in the current viewport. Used to decide whether
1053
+ * redirecting a seat tap to section-focus would actually zoom in FURTHER, or
1054
+ * whether the section already fills the viewport (small container) so the tap
1055
+ * must fall through and pick.
1056
+ */
1057
+ private sectionFrameScale;
1058
+ /**
1059
+ * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
1060
+ * then toggle. The gate (§4) redirects small on-screen seats to section-focus
1061
+ * to avoid blind mis-picks at overview scales — but ONLY when focusing would
1062
+ * zoom the seat up to a safe size. If we're already focused on the section, or
1063
+ * the section already fills the viewport (a narrow — OR even a wide — container
1064
+ * frames a big section BELOW LABEL_SCALE), redirecting is a no-op that would
1065
+ * leave the seat permanently unpickable (§5 `picking-gate-unreachable`), so we
1066
+ * fall through and PICK. This is the invariant: seats are never unpickable.
1067
+ */
1068
+ private handleSeatTap;
1069
+ /**
1070
+ * Nearest SELECTABLE seat whose centre is within `slopPx` (screen space) of a
1071
+ * seat circle edge, or null. Enlarges the effective tap target for small seats
1072
+ * so a near-miss on mobile still lands on the intended seat, without ever
1073
+ * hijacking a clean tap on empty space beyond the slop.
1074
+ */
1075
+ private nearestSeatToScreen;
1038
1076
  private wireInteraction;
1039
1077
  /**
1040
1078
  * Which deck (floor) a screen tap landed on in the 3D stacked overview. The
@@ -1083,7 +1121,22 @@ declare class SeatmapRenderer implements ISeatmapRenderer {
1083
1121
  /** rAF-coalesced `onViewChange` — at most one host callback per animation frame. */
1084
1122
  private scheduleViewChange;
1085
1123
  private updateLOD;
1124
+ /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
1125
+ * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
1126
+ private rebuildSeatCache;
1086
1127
  private cacheSeatLayer;
1128
+ /**
1129
+ * SYNCHRONOUS repaint that bypasses requestAnimationFrame — see the
1130
+ * ISeatmapRenderer.forceDraw() contract. Chrome pauses rAF (and therefore
1131
+ * Konva's batchDraw) on hidden/backgrounded/occluded tabs, so seat-status
1132
+ * deltas applied via setStatus() mutate the scene graph but never reach the
1133
+ * canvas until the tab is foregrounded. This flushes the cache-debounce and
1134
+ * paints every layer setStatus() can touch, immediately.
1135
+ *
1136
+ * Additive: the foreground path never calls this, so foreground behaviour is
1137
+ * byte-identical.
1138
+ */
1139
+ forceDraw(): void;
1087
1140
  private updateLabels;
1088
1141
  private handleResize;
1089
1142
  private startFpsLoop;
@@ -1254,6 +1307,18 @@ interface PickerOptions extends PickerCallbacks {
1254
1307
  flashOnLiveChange?: boolean;
1255
1308
  /** Start in colorblind-safe rendering (Okabe-Ito hues + hollow booked seats). */
1256
1309
  colorblindSafe?: boolean;
1310
+ /**
1311
+ * Keep painting seat-status deltas even while the tab is hidden/backgrounded.
1312
+ *
1313
+ * Default FALSE — the buyer widget stays efficient: it lets rAF stay paused
1314
+ * while hidden and does a single synchronous catch-up repaint on regain (the
1315
+ * visibilitychange handler resnapshots + forceDraw()s). Set TRUE only for an
1316
+ * always-live surface — the future organizer control-room board — where a
1317
+ * backgrounded monitor must keep repainting; then each delta calls
1318
+ * renderer.forceDraw() when the tab is hidden. Purely a paint hint; enforcement
1319
+ * and the WS protocol are identical either way.
1320
+ */
1321
+ keepLiveWhileHidden?: boolean;
1257
1322
  }
1258
1323
  declare class PickerController {
1259
1324
  private readonly opts;
@@ -1279,6 +1344,10 @@ declare class PickerController {
1279
1344
  private reconnectTimer;
1280
1345
  private attempt;
1281
1346
  private closed;
1347
+ /** Bound visibilitychange listener (buyer catch-up on tab regain). Kept on the
1348
+ * instance so destroy() can detach it; null when not attached (guards double
1349
+ * mounts / non-DOM environments). */
1350
+ private onVisibilityChange;
1282
1351
  private hold_;
1283
1352
  private liveStatuses;
1284
1353
  private currency;
@@ -1472,6 +1541,19 @@ declare class PickerController {
1472
1541
  private applySeatsMap;
1473
1542
  private clearBookedHoldIfSettled;
1474
1543
  private resnapshot;
1544
+ /**
1545
+ * Buyer catch-up on tab regain. While a tab is hidden Chrome pauses rAF, so
1546
+ * seat-status deltas that arrived over the WS mutated the scene graph but the
1547
+ * canvas colors never repainted. When the tab becomes visible again we (1)
1548
+ * resnapshot to pull authoritative state for anything the socket may have
1549
+ * missed while backgrounded, then (2) forceDraw() a synchronous repaint so the
1550
+ * seat colors are correct immediately rather than on the next incidental draw.
1551
+ *
1552
+ * Attached once per mount; guarded against double-attach and non-DOM
1553
+ * environments; detached in destroy() (no leaks).
1554
+ */
1555
+ private attachVisibilityListener;
1556
+ private detachVisibilityListener;
1475
1557
  private connect;
1476
1558
  private scheduleReconnect;
1477
1559
  }
package/dist/index.js CHANGED
@@ -632,6 +632,7 @@ var SEAT_RADIUS = 9;
632
632
  var SEAT_LEGIBLE_SCALE = 0.9;
633
633
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
634
634
  var LABEL_SCALE = 1;
635
+ var SEAT_TAP_SLOP_PX = 14;
635
636
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
636
637
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
637
638
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
@@ -2533,27 +2534,84 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2533
2534
  }
2534
2535
  }
2535
2536
  // ---- interaction ----------------------------------------------------------
2536
- wireInteraction() {
2537
- this.seatLayer.on("click tap", (e) => {
2538
- if (this.moved > 8) return;
2539
- const id = seatIdOf(e.target);
2540
- if (!id) return;
2541
- if (this.stacked && this.opts.onDeckTap) {
2542
- const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2543
- if (floorId) {
2544
- this.opts.onDeckTap(floorId);
2545
- return;
2546
- }
2537
+ /**
2538
+ * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
2539
+ * frames this section in the current viewport. Used to decide whether
2540
+ * redirecting a seat tap to section-focus would actually zoom in FURTHER, or
2541
+ * whether the section already fills the viewport (small container) so the tap
2542
+ * must fall through and pick.
2543
+ */
2544
+ sectionFrameScale(id) {
2545
+ const sec = this.sections.find((s) => s.id === id);
2546
+ if (!sec) return this.stage.scaleX();
2547
+ const b = polyBounds(sec.outline);
2548
+ const w = this.stage.width();
2549
+ const h = this.stage.height();
2550
+ const { min, max } = this.zoomBounds();
2551
+ const margin = 1.12;
2552
+ if (b.width <= 0 || b.height <= 0) return this.stage.scaleX();
2553
+ return clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
2554
+ }
2555
+ /**
2556
+ * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
2557
+ * then toggle. The gate (§4) redirects small on-screen seats to section-focus
2558
+ * to avoid blind mis-picks at overview scales — but ONLY when focusing would
2559
+ * zoom the seat up to a safe size. If we're already focused on the section, or
2560
+ * the section already fills the viewport (a narrow — OR even a wide — container
2561
+ * frames a big section BELOW LABEL_SCALE), redirecting is a no-op that would
2562
+ * leave the seat permanently unpickable (§5 `picking-gate-unreachable`), so we
2563
+ * fall through and PICK. This is the invariant: seats are never unpickable.
2564
+ */
2565
+ handleSeatTap(id) {
2566
+ if (this.stacked && this.opts.onDeckTap) {
2567
+ const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2568
+ if (floorId) {
2569
+ this.opts.onDeckTap(floorId);
2570
+ return;
2547
2571
  }
2548
- if (this.effScale() < LABEL_SCALE && this.sections.length) {
2549
- const sec = this.seatSection.get(id);
2550
- if (sec) {
2572
+ }
2573
+ if (this.effScale() < LABEL_SCALE && this.sections.length) {
2574
+ const sec = this.seatSection.get(id);
2575
+ if (sec) {
2576
+ const alreadyFocused = this.focusedSectionId === sec.id;
2577
+ const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
2578
+ if (!alreadyFocused && canZoomInFurther) {
2551
2579
  if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
2552
2580
  else this.focusSection(sec.id);
2553
2581
  return;
2554
2582
  }
2555
2583
  }
2556
- this.toggleSeat(id);
2584
+ }
2585
+ this.toggleSeat(id);
2586
+ }
2587
+ /**
2588
+ * Nearest SELECTABLE seat whose centre is within `slopPx` (screen space) of a
2589
+ * seat circle edge, or null. Enlarges the effective tap target for small seats
2590
+ * so a near-miss on mobile still lands on the intended seat, without ever
2591
+ * hijacking a clean tap on empty space beyond the slop.
2592
+ */
2593
+ nearestSeatToScreen(screen, slopPx) {
2594
+ const s = this.stage.scaleX() || 1;
2595
+ const reachWorld = this.seatR + slopPx / s;
2596
+ const world = this.screenToWorld(screen);
2597
+ let best = null;
2598
+ let bestD = reachWorld;
2599
+ for (const seat of this.seats) {
2600
+ if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
2601
+ const d = Math.hypot(seat.x - world.x, seat.y - world.y);
2602
+ if (d < bestD) {
2603
+ bestD = d;
2604
+ best = seat.id;
2605
+ }
2606
+ }
2607
+ return best;
2608
+ }
2609
+ wireInteraction() {
2610
+ this.seatLayer.on("click tap", (e) => {
2611
+ if (this.moved > 8) return;
2612
+ const id = seatIdOf(e.target);
2613
+ if (!id) return;
2614
+ this.handleSeatTap(id);
2557
2615
  });
2558
2616
  this.seatLayer.on("mouseover", (e) => {
2559
2617
  const id = seatIdOf(e.target);
@@ -2579,10 +2637,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2579
2637
  const factor = Math.exp(-e.evt.deltaY * 2e-3);
2580
2638
  this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
2581
2639
  });
2582
- this.stage.on("click tap", () => {
2583
- if (!this.cached || this.moved > 8) return;
2640
+ this.stage.on("click tap", (e) => {
2641
+ if (this.moved > 8) return;
2584
2642
  const pointer = this.stage.getPointerPosition();
2585
2643
  if (!pointer) return;
2644
+ if (!this.cached) {
2645
+ if (seatIdOf(e.target)) return;
2646
+ const near = this.nearestSeatToScreen(pointer, SEAT_TAP_SLOP_PX);
2647
+ if (near) this.handleSeatTap(near);
2648
+ return;
2649
+ }
2586
2650
  if (this.stacked && this.opts.onDeckTap) {
2587
2651
  const floorId = this.deckFloorAt();
2588
2652
  if (floorId) {
@@ -2785,14 +2849,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2785
2849
  this.seatLayer.batchDraw();
2786
2850
  }
2787
2851
  }
2788
- cacheSeatLayer() {
2852
+ /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
2853
+ * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
2854
+ rebuildSeatCache() {
2789
2855
  const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
2790
2856
  this.seatLayer.clearCache();
2791
2857
  this.seatLayer.cache({ pixelRatio: pr });
2792
2858
  this.seatLayer.listening(false);
2793
2859
  this.cached = true;
2860
+ }
2861
+ cacheSeatLayer() {
2862
+ this.rebuildSeatCache();
2794
2863
  this.seatLayer.batchDraw();
2795
2864
  }
2865
+ /**
2866
+ * SYNCHRONOUS repaint that bypasses requestAnimationFrame — see the
2867
+ * ISeatmapRenderer.forceDraw() contract. Chrome pauses rAF (and therefore
2868
+ * Konva's batchDraw) on hidden/backgrounded/occluded tabs, so seat-status
2869
+ * deltas applied via setStatus() mutate the scene graph but never reach the
2870
+ * canvas until the tab is foregrounded. This flushes the cache-debounce and
2871
+ * paints every layer setStatus() can touch, immediately.
2872
+ *
2873
+ * Additive: the foreground path never calls this, so foreground behaviour is
2874
+ * byte-identical.
2875
+ */
2876
+ forceDraw() {
2877
+ if (this.recacheTimer) {
2878
+ clearTimeout(this.recacheTimer);
2879
+ this.recacheTimer = null;
2880
+ this.rebuildSeatCache();
2881
+ }
2882
+ this.bgLayer.draw();
2883
+ this.seatLayer.draw();
2884
+ this.overlayLayer.draw();
2885
+ }
2796
2886
  updateLabels() {
2797
2887
  const show = this.effScale() > LABEL_SCALE;
2798
2888
  this.labelGroup.destroyChildren();
@@ -2928,6 +3018,10 @@ var PickerController = class {
2928
3018
  this.reconnectTimer = null;
2929
3019
  this.attempt = 0;
2930
3020
  this.closed = false;
3021
+ /** Bound visibilitychange listener (buyer catch-up on tab regain). Kept on the
3022
+ * instance so destroy() can detach it; null when not attached (guards double
3023
+ * mounts / non-DOM environments). */
3024
+ this.onVisibilityChange = null;
2931
3025
  // hold state
2932
3026
  this.hold_ = null;
2933
3027
  this.liveStatuses = /* @__PURE__ */ new Map();
@@ -3053,6 +3147,7 @@ var PickerController = class {
3053
3147
  this.closedSections = new Set(closedIds);
3054
3148
  if (closedIds.length) renderer.setClosedSections?.(closedIds);
3055
3149
  if (seats) this.applySeatsMap(seats);
3150
+ this.attachVisibilityListener();
3056
3151
  this.connect();
3057
3152
  return {
3058
3153
  doc: res.doc,
@@ -3479,6 +3574,7 @@ var PickerController = class {
3479
3574
  }
3480
3575
  destroy() {
3481
3576
  this.closed = true;
3577
+ this.detachVisibilityListener();
3482
3578
  if (this.reconnectTimer) {
3483
3579
  clearTimeout(this.reconnectTimer);
3484
3580
  this.reconnectTimer = null;
@@ -3675,6 +3771,37 @@ var PickerController = class {
3675
3771
  }
3676
3772
  }
3677
3773
  // ---- realtime socket ------------------------------------------------------
3774
+ /**
3775
+ * Buyer catch-up on tab regain. While a tab is hidden Chrome pauses rAF, so
3776
+ * seat-status deltas that arrived over the WS mutated the scene graph but the
3777
+ * canvas colors never repainted. When the tab becomes visible again we (1)
3778
+ * resnapshot to pull authoritative state for anything the socket may have
3779
+ * missed while backgrounded, then (2) forceDraw() a synchronous repaint so the
3780
+ * seat colors are correct immediately rather than on the next incidental draw.
3781
+ *
3782
+ * Attached once per mount; guarded against double-attach and non-DOM
3783
+ * environments; detached in destroy() (no leaks).
3784
+ */
3785
+ attachVisibilityListener() {
3786
+ if (this.onVisibilityChange) return;
3787
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return;
3788
+ const handler = () => {
3789
+ if (this.closed) return;
3790
+ if (document.visibilityState !== "visible") return;
3791
+ void this.resnapshot().then(() => {
3792
+ if (!this.closed) this.renderer?.forceDraw();
3793
+ });
3794
+ };
3795
+ this.onVisibilityChange = handler;
3796
+ document.addEventListener("visibilitychange", handler);
3797
+ }
3798
+ detachVisibilityListener() {
3799
+ if (!this.onVisibilityChange) return;
3800
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
3801
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
3802
+ }
3803
+ this.onVisibilityChange = null;
3804
+ }
3678
3805
  connect() {
3679
3806
  if (this.closed) return;
3680
3807
  let ws;
@@ -3720,6 +3847,9 @@ var PickerController = class {
3720
3847
  }
3721
3848
  r.setStatus([id], next);
3722
3849
  }
3850
+ if (this.opts.keepLiveWhileHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
3851
+ r.forceDraw();
3852
+ }
3723
3853
  this.clearBookedHoldIfSettled();
3724
3854
  this.opts.onStatusChange?.();
3725
3855
  }