@seatlayer/js 0.77.2 → 0.78.0

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
@@ -287,7 +287,9 @@ interface RealtimeSink {
287
287
  * cannot be diffed against what we hold (first snapshot, or the scope's
288
288
  * default itself changed, which redefines every unit we were never told
289
289
  * about), and the sink cannot take a whole projection. */
290
- resync(): void | Promise<void>;
290
+ resync(opts?: {
291
+ reason?: 'connect' | 'frame';
292
+ }): void | Promise<void>;
291
293
  /** Section availability changed (channel-agnostic; identical for every scope). */
292
294
  onSections?(hidden: string[], closed: string[]): void;
293
295
  /** Scope-projected presence counters. */
@@ -395,13 +397,47 @@ interface PickerControllerLike {
395
397
  label: string;
396
398
  }>;
397
399
  deselect(ids: string[]): void;
400
+ /** FORCED re-read. Reserved for "I just changed something, tell me the truth". */
398
401
  refresh(): Promise<void>;
402
+ /**
403
+ * COALESCED re-read, and the one a socket should use.
404
+ *
405
+ * `refresh()` was this sink's only option, and it is the forced call: every
406
+ * connect and every section frame bypassed the controller's snapshot
407
+ * coalescer and issued its own `/objects?compact=1`. On the measured DesiPass
408
+ * open that was two redundant full snapshots landing ~1.5 s after the map was
409
+ * already painted from the bootstrap bundle.
410
+ *
411
+ * Optional so a host pinning an older `@seatlayer/core` still works — that
412
+ * engine simply keeps the forced behaviour it always had.
413
+ */
414
+ resync?(opts?: {
415
+ reason?: 'connect' | 'frame';
416
+ }): Promise<void>;
417
+ /**
418
+ * Paint a whole authoritative projection with no round trip at all.
419
+ *
420
+ * Optional for the same reason: where the engine can take one, the socket's
421
+ * own connect frame IS the snapshot and the HTTP resync never happens.
422
+ */
423
+ applyProjection?(projection: {
424
+ default: string;
425
+ exceptions: Record<string, string>;
426
+ }): void;
399
427
  }
400
428
  interface ControllerSinkOptions {
401
429
  /** Pulse seats other buyers take, as the controller's own socket does. */
402
430
  flashOnLiveChange?: boolean;
403
431
  /** Selected-but-unheld units that stopped being selectable. */
404
432
  onSelectedObjectUnavailable?: (labels: string[], reason: 'ineligible' | 'taken') => void;
433
+ /**
434
+ * Labels the buyer is holding RIGHT NOW — the hold request is in flight and
435
+ * `currentHold()` is still null. The server broadcasts `held` before the
436
+ * hold response lands, so without this the buyer's own echo reads as
437
+ * "someone else took your seat", the selection is evicted, and the winning
438
+ * hold is left with nobody attached to it.
439
+ */
440
+ ownLabels?: () => Iterable<string>;
405
441
  /** Section availability changed and the chart itself needs rebuilding. */
406
442
  onSections?: (hidden: string[], closed: string[]) => void;
407
443
  onStatusChange?: () => void;
@@ -1815,6 +1851,8 @@ declare class SeatingChart {
1815
1851
  private readonly buyerAssetUrls;
1816
1852
  private readonly immersive;
1817
1853
  private realtime;
1854
+ /** Labels whose hold request is in flight — see ControllerSinkOptions.ownLabels. */
1855
+ private holdingLabels;
1818
1856
  constructor(options: SeatingChartOptions);
1819
1857
  /**
1820
1858
  * Camera x/y/scale is renderer-local and deliberately absent from the native
@@ -2535,6 +2573,46 @@ declare class EmbeddedDesigner {
2535
2573
  private handleMessage;
2536
2574
  }
2537
2575
 
2576
+ /**
2577
+ * pickerConfirmCard — the card that stands between a tapped seat and the cart.
2578
+ *
2579
+ * It is one seat's whole story in one anchored popover: who the seat is (the
2580
+ * identity grid), what it costs (the category band), what can be seen from it
2581
+ * (the view thumbnail and the sightline line), and the two buttons that decide
2582
+ * the matter. Raising it, placing it against the seat, wiring its six controls,
2583
+ * committing it and taking it down are all here.
2584
+ *
2585
+ * Extracted from SeatPicker.ts (2026-09-03) so the widget has room to grow
2586
+ * under the app's file-size ratchet, which pins the vendored mirror. Bodies are
2587
+ * verbatim; the only edits are mechanical — the card's own two pieces of state
2588
+ * (`el`, `seat`) live on this class, and every other value the code read off
2589
+ * `this` now comes through {@link ConfirmCardHost}.
2590
+ *
2591
+ * THE HOST IS DELIBERATELY LATE-BOUND. Every member of it is a function the
2592
+ * widget implements by calling its own method, never a captured value: the
2593
+ * picker's tests replace `dismissConfirm`, `reducedMotion`, `scheduleMotion`,
2594
+ * `syncTray`, `canOffer3d`, `enter3d` and `buyerAssetUrls` on the instance, and
2595
+ * a card holding a bound reference from construction time would quietly ignore
2596
+ * all of them. That seam is the same one `SectionCard` keeps for
2597
+ * `sectionNeighbours`.
2598
+ */
2599
+
2600
+ /**
2601
+ * The two questions this one card asks.
2602
+ *
2603
+ * `add` is the original: a seat the buyer has tapped but not yet taken.
2604
+ * `remove` is the same card about a seat already in their cart, raised by a
2605
+ * SECOND tap on it — which used to drop the seat silently, with no card and no
2606
+ * notice (client report, 2026-09-04). Same layout, same identity, same price;
2607
+ * only the primary button changes, and Cancel keeps the seat rather than
2608
+ * dropping it.
2609
+ *
2610
+ * Deliberately not a separate card. A buyer taps a seat and expects the thing
2611
+ * that seat's tap always produces; two popovers for one gesture is a second
2612
+ * thing to learn for no gain.
2613
+ */
2614
+ type ConfirmCardMode = 'add' | 'remove';
2615
+
2538
2616
  /** What the saved-seat comparison chip needs from the widget around it. */
2539
2617
  interface CompareChipHost {
2540
2618
  /** The live 3D overlay the chip mounts into, or null when 3D is down. */
@@ -2740,6 +2818,10 @@ declare class SeatPicker implements GaPromptPicker {
2740
2818
  private ro;
2741
2819
  private holdTimer;
2742
2820
  private toastTimer;
2821
+ /** The availability read every other caller joins. See refreshOfferAvailability. */
2822
+ private offerReadInFlight;
2823
+ /** When the last SUCCESSFUL availability read landed (0 = never). */
2824
+ private offerReadSettledAt;
2743
2825
  private offerRefreshTimer;
2744
2826
  /** Armed only when the offer schedule has a known future transition (or as a
2745
2827
  * bounded retry after a failed read) — never a fixed-cadence poll. */
@@ -2970,6 +3052,32 @@ declare class SeatPicker implements GaPromptPicker {
2970
3052
  private reportFramedHeight;
2971
3053
  /** Full screen via the native API, falling back to a fixed-position overlay (iOS Safari). */
2972
3054
  private toggleFullscreen;
3055
+ /**
3056
+ * Is the picker already as big as the screen it is on?
3057
+ *
3058
+ * Measured, not inferred. A host that opens the widget inside its own
3059
+ * full-height modal — DesiPass does exactly this on a phone — hands us a
3060
+ * root whose rect IS the viewport, and there is nothing left for "Full
3061
+ * screen" to give. The tolerance absorbs a hairline of host chrome, a
3062
+ * fractional device pixel ratio and a rubber-banding iOS toolbar; anything
3063
+ * larger than that is a real embed with page around it.
3064
+ */
3065
+ private fillsViewport;
3066
+ /**
3067
+ * Show the "Full screen" pill only where pressing it can change something.
3068
+ *
3069
+ * On a phone inside a host's full-height modal the pill was a dead control
3070
+ * (client report on DesiPass, 2026-09-04): iOS Safari has no element
3071
+ * fullscreen, so the tap fell through to the fixed-position fallback — which
3072
+ * pins the widget to a viewport it was already filling. Nothing moved, and
3073
+ * an affordance that does nothing is worse than one that is not there.
3074
+ *
3075
+ * Narrow only. A desktop embed that happens to be viewport-sized is still
3076
+ * surrounded by browser chrome that native fullscreen removes, so the pill
3077
+ * has something to do there. And an ACTIVE fullscreen always keeps its pill:
3078
+ * that is the only way back out.
3079
+ */
3080
+ private syncFullscreenAffordance;
2973
3081
  private syncFullscreenButtons;
2974
3082
  /**
2975
3083
  * Native element-fullscreen was unavailable or rejected. When framed, a CSS
@@ -3247,7 +3355,6 @@ declare class SeatPicker implements GaPromptPicker {
3247
3355
  private renderSectionCard;
3248
3356
  private collapseSectionCard;
3249
3357
  private sectionCardOnView;
3250
- private fitSectionStripCount;
3251
3358
  /**
3252
3359
  * Open sections next to the focused one, in authored order.
3253
3360
  *
@@ -3280,7 +3387,7 @@ declare class SeatPicker implements GaPromptPicker {
3280
3387
  * `buyerAssetUrls` on the INSTANCE, and the card has to see those.
3281
3388
  */
3282
3389
  private get confirmCard();
3283
- /** @internal */ showConfirm(seat: ExpandedSeat): void;
3390
+ /** @internal */ showConfirm(seat: ExpandedSeat, mode?: ConfirmCardMode): void;
3284
3391
  private reanchorConfirm;
3285
3392
  private dismissConfirm;
3286
3393
  /** @internal The card's own button calls the card; this is the seam the
@@ -3319,7 +3426,32 @@ declare class SeatPicker implements GaPromptPicker {
3319
3426
  * visibilitychange handler owns catching that tab up.
3320
3427
  */
3321
3428
  private scheduleOfferBoundary;
3322
- /** Debounce the no-store offer read behind a burst of seat-status frames. */
3429
+ /**
3430
+ * Is the answer we hold current enough that asking again would learn nothing?
3431
+ *
3432
+ * True while a read is in flight (its answer is on its way) or while one that
3433
+ * just settled is inside the quiet window.
3434
+ */
3435
+ private offerReadIsCurrent;
3436
+ /**
3437
+ * Debounce the no-store offer read behind a burst of seat-status frames.
3438
+ *
3439
+ * THE SKIP IS DECIDED HERE, WHEN THE REFRESH IS ASKED FOR — not 180 ms later
3440
+ * when the timer fires. Deciding at fire time made the dedupe a race between
3441
+ * this debounce and the quiet window, with 250 − 180 = 70 ms of margin: if
3442
+ * the timer ran even that little late, the window had lapsed and a second,
3443
+ * identical availability read went out. Seventy milliseconds of scheduler
3444
+ * jitter is nothing on a loaded machine, and this optimisation exists for
3445
+ * mid-range phones, where it is nothing on a good day.
3446
+ *
3447
+ * (Caught by the app's test/pickerOpenPathConcurrency.test.ts failing once in
3448
+ * a 462-file parallel run and passing in isolation — the flake was real, and
3449
+ * it was here rather than in the test.)
3450
+ *
3451
+ * The rule is unchanged, only the instant it is evaluated at: a refresh asked
3452
+ * for while the answer is still current is answered by the read that just
3453
+ * happened. A batch arriving past the window still schedules and still reads.
3454
+ */
3323
3455
  private scheduleOfferRefresh;
3324
3456
  /**
3325
3457
  * Pull the server's resolved answer. A failed refresh keeps the last truthful
@@ -3327,6 +3459,8 @@ declare class SeatPicker implements GaPromptPicker {
3327
3459
  * offer is worse than a temporarily stale remaining count.
3328
3460
  */
3329
3461
  private refreshOfferAvailability;
3462
+ /** The read itself. Never rejects — see the catch. */
3463
+ private readOfferAvailability;
3330
3464
  private offerPrice;
3331
3465
  private applyChannelPricing;
3332
3466
  private syncOffer;
package/dist/index.d.ts CHANGED
@@ -287,7 +287,9 @@ interface RealtimeSink {
287
287
  * cannot be diffed against what we hold (first snapshot, or the scope's
288
288
  * default itself changed, which redefines every unit we were never told
289
289
  * about), and the sink cannot take a whole projection. */
290
- resync(): void | Promise<void>;
290
+ resync(opts?: {
291
+ reason?: 'connect' | 'frame';
292
+ }): void | Promise<void>;
291
293
  /** Section availability changed (channel-agnostic; identical for every scope). */
292
294
  onSections?(hidden: string[], closed: string[]): void;
293
295
  /** Scope-projected presence counters. */
@@ -395,13 +397,47 @@ interface PickerControllerLike {
395
397
  label: string;
396
398
  }>;
397
399
  deselect(ids: string[]): void;
400
+ /** FORCED re-read. Reserved for "I just changed something, tell me the truth". */
398
401
  refresh(): Promise<void>;
402
+ /**
403
+ * COALESCED re-read, and the one a socket should use.
404
+ *
405
+ * `refresh()` was this sink's only option, and it is the forced call: every
406
+ * connect and every section frame bypassed the controller's snapshot
407
+ * coalescer and issued its own `/objects?compact=1`. On the measured DesiPass
408
+ * open that was two redundant full snapshots landing ~1.5 s after the map was
409
+ * already painted from the bootstrap bundle.
410
+ *
411
+ * Optional so a host pinning an older `@seatlayer/core` still works — that
412
+ * engine simply keeps the forced behaviour it always had.
413
+ */
414
+ resync?(opts?: {
415
+ reason?: 'connect' | 'frame';
416
+ }): Promise<void>;
417
+ /**
418
+ * Paint a whole authoritative projection with no round trip at all.
419
+ *
420
+ * Optional for the same reason: where the engine can take one, the socket's
421
+ * own connect frame IS the snapshot and the HTTP resync never happens.
422
+ */
423
+ applyProjection?(projection: {
424
+ default: string;
425
+ exceptions: Record<string, string>;
426
+ }): void;
399
427
  }
400
428
  interface ControllerSinkOptions {
401
429
  /** Pulse seats other buyers take, as the controller's own socket does. */
402
430
  flashOnLiveChange?: boolean;
403
431
  /** Selected-but-unheld units that stopped being selectable. */
404
432
  onSelectedObjectUnavailable?: (labels: string[], reason: 'ineligible' | 'taken') => void;
433
+ /**
434
+ * Labels the buyer is holding RIGHT NOW — the hold request is in flight and
435
+ * `currentHold()` is still null. The server broadcasts `held` before the
436
+ * hold response lands, so without this the buyer's own echo reads as
437
+ * "someone else took your seat", the selection is evicted, and the winning
438
+ * hold is left with nobody attached to it.
439
+ */
440
+ ownLabels?: () => Iterable<string>;
405
441
  /** Section availability changed and the chart itself needs rebuilding. */
406
442
  onSections?: (hidden: string[], closed: string[]) => void;
407
443
  onStatusChange?: () => void;
@@ -1815,6 +1851,8 @@ declare class SeatingChart {
1815
1851
  private readonly buyerAssetUrls;
1816
1852
  private readonly immersive;
1817
1853
  private realtime;
1854
+ /** Labels whose hold request is in flight — see ControllerSinkOptions.ownLabels. */
1855
+ private holdingLabels;
1818
1856
  constructor(options: SeatingChartOptions);
1819
1857
  /**
1820
1858
  * Camera x/y/scale is renderer-local and deliberately absent from the native
@@ -2535,6 +2573,46 @@ declare class EmbeddedDesigner {
2535
2573
  private handleMessage;
2536
2574
  }
2537
2575
 
2576
+ /**
2577
+ * pickerConfirmCard — the card that stands between a tapped seat and the cart.
2578
+ *
2579
+ * It is one seat's whole story in one anchored popover: who the seat is (the
2580
+ * identity grid), what it costs (the category band), what can be seen from it
2581
+ * (the view thumbnail and the sightline line), and the two buttons that decide
2582
+ * the matter. Raising it, placing it against the seat, wiring its six controls,
2583
+ * committing it and taking it down are all here.
2584
+ *
2585
+ * Extracted from SeatPicker.ts (2026-09-03) so the widget has room to grow
2586
+ * under the app's file-size ratchet, which pins the vendored mirror. Bodies are
2587
+ * verbatim; the only edits are mechanical — the card's own two pieces of state
2588
+ * (`el`, `seat`) live on this class, and every other value the code read off
2589
+ * `this` now comes through {@link ConfirmCardHost}.
2590
+ *
2591
+ * THE HOST IS DELIBERATELY LATE-BOUND. Every member of it is a function the
2592
+ * widget implements by calling its own method, never a captured value: the
2593
+ * picker's tests replace `dismissConfirm`, `reducedMotion`, `scheduleMotion`,
2594
+ * `syncTray`, `canOffer3d`, `enter3d` and `buyerAssetUrls` on the instance, and
2595
+ * a card holding a bound reference from construction time would quietly ignore
2596
+ * all of them. That seam is the same one `SectionCard` keeps for
2597
+ * `sectionNeighbours`.
2598
+ */
2599
+
2600
+ /**
2601
+ * The two questions this one card asks.
2602
+ *
2603
+ * `add` is the original: a seat the buyer has tapped but not yet taken.
2604
+ * `remove` is the same card about a seat already in their cart, raised by a
2605
+ * SECOND tap on it — which used to drop the seat silently, with no card and no
2606
+ * notice (client report, 2026-09-04). Same layout, same identity, same price;
2607
+ * only the primary button changes, and Cancel keeps the seat rather than
2608
+ * dropping it.
2609
+ *
2610
+ * Deliberately not a separate card. A buyer taps a seat and expects the thing
2611
+ * that seat's tap always produces; two popovers for one gesture is a second
2612
+ * thing to learn for no gain.
2613
+ */
2614
+ type ConfirmCardMode = 'add' | 'remove';
2615
+
2538
2616
  /** What the saved-seat comparison chip needs from the widget around it. */
2539
2617
  interface CompareChipHost {
2540
2618
  /** The live 3D overlay the chip mounts into, or null when 3D is down. */
@@ -2740,6 +2818,10 @@ declare class SeatPicker implements GaPromptPicker {
2740
2818
  private ro;
2741
2819
  private holdTimer;
2742
2820
  private toastTimer;
2821
+ /** The availability read every other caller joins. See refreshOfferAvailability. */
2822
+ private offerReadInFlight;
2823
+ /** When the last SUCCESSFUL availability read landed (0 = never). */
2824
+ private offerReadSettledAt;
2743
2825
  private offerRefreshTimer;
2744
2826
  /** Armed only when the offer schedule has a known future transition (or as a
2745
2827
  * bounded retry after a failed read) — never a fixed-cadence poll. */
@@ -2970,6 +3052,32 @@ declare class SeatPicker implements GaPromptPicker {
2970
3052
  private reportFramedHeight;
2971
3053
  /** Full screen via the native API, falling back to a fixed-position overlay (iOS Safari). */
2972
3054
  private toggleFullscreen;
3055
+ /**
3056
+ * Is the picker already as big as the screen it is on?
3057
+ *
3058
+ * Measured, not inferred. A host that opens the widget inside its own
3059
+ * full-height modal — DesiPass does exactly this on a phone — hands us a
3060
+ * root whose rect IS the viewport, and there is nothing left for "Full
3061
+ * screen" to give. The tolerance absorbs a hairline of host chrome, a
3062
+ * fractional device pixel ratio and a rubber-banding iOS toolbar; anything
3063
+ * larger than that is a real embed with page around it.
3064
+ */
3065
+ private fillsViewport;
3066
+ /**
3067
+ * Show the "Full screen" pill only where pressing it can change something.
3068
+ *
3069
+ * On a phone inside a host's full-height modal the pill was a dead control
3070
+ * (client report on DesiPass, 2026-09-04): iOS Safari has no element
3071
+ * fullscreen, so the tap fell through to the fixed-position fallback — which
3072
+ * pins the widget to a viewport it was already filling. Nothing moved, and
3073
+ * an affordance that does nothing is worse than one that is not there.
3074
+ *
3075
+ * Narrow only. A desktop embed that happens to be viewport-sized is still
3076
+ * surrounded by browser chrome that native fullscreen removes, so the pill
3077
+ * has something to do there. And an ACTIVE fullscreen always keeps its pill:
3078
+ * that is the only way back out.
3079
+ */
3080
+ private syncFullscreenAffordance;
2973
3081
  private syncFullscreenButtons;
2974
3082
  /**
2975
3083
  * Native element-fullscreen was unavailable or rejected. When framed, a CSS
@@ -3247,7 +3355,6 @@ declare class SeatPicker implements GaPromptPicker {
3247
3355
  private renderSectionCard;
3248
3356
  private collapseSectionCard;
3249
3357
  private sectionCardOnView;
3250
- private fitSectionStripCount;
3251
3358
  /**
3252
3359
  * Open sections next to the focused one, in authored order.
3253
3360
  *
@@ -3280,7 +3387,7 @@ declare class SeatPicker implements GaPromptPicker {
3280
3387
  * `buyerAssetUrls` on the INSTANCE, and the card has to see those.
3281
3388
  */
3282
3389
  private get confirmCard();
3283
- /** @internal */ showConfirm(seat: ExpandedSeat): void;
3390
+ /** @internal */ showConfirm(seat: ExpandedSeat, mode?: ConfirmCardMode): void;
3284
3391
  private reanchorConfirm;
3285
3392
  private dismissConfirm;
3286
3393
  /** @internal The card's own button calls the card; this is the seam the
@@ -3319,7 +3426,32 @@ declare class SeatPicker implements GaPromptPicker {
3319
3426
  * visibilitychange handler owns catching that tab up.
3320
3427
  */
3321
3428
  private scheduleOfferBoundary;
3322
- /** Debounce the no-store offer read behind a burst of seat-status frames. */
3429
+ /**
3430
+ * Is the answer we hold current enough that asking again would learn nothing?
3431
+ *
3432
+ * True while a read is in flight (its answer is on its way) or while one that
3433
+ * just settled is inside the quiet window.
3434
+ */
3435
+ private offerReadIsCurrent;
3436
+ /**
3437
+ * Debounce the no-store offer read behind a burst of seat-status frames.
3438
+ *
3439
+ * THE SKIP IS DECIDED HERE, WHEN THE REFRESH IS ASKED FOR — not 180 ms later
3440
+ * when the timer fires. Deciding at fire time made the dedupe a race between
3441
+ * this debounce and the quiet window, with 250 − 180 = 70 ms of margin: if
3442
+ * the timer ran even that little late, the window had lapsed and a second,
3443
+ * identical availability read went out. Seventy milliseconds of scheduler
3444
+ * jitter is nothing on a loaded machine, and this optimisation exists for
3445
+ * mid-range phones, where it is nothing on a good day.
3446
+ *
3447
+ * (Caught by the app's test/pickerOpenPathConcurrency.test.ts failing once in
3448
+ * a 462-file parallel run and passing in isolation — the flake was real, and
3449
+ * it was here rather than in the test.)
3450
+ *
3451
+ * The rule is unchanged, only the instant it is evaluated at: a refresh asked
3452
+ * for while the answer is still current is answered by the read that just
3453
+ * happened. A batch arriving past the window still schedules and still reads.
3454
+ */
3323
3455
  private scheduleOfferRefresh;
3324
3456
  /**
3325
3457
  * Pull the server's resolved answer. A failed refresh keeps the last truthful
@@ -3327,6 +3459,8 @@ declare class SeatPicker implements GaPromptPicker {
3327
3459
  * offer is worse than a temporarily stale remaining count.
3328
3460
  */
3329
3461
  private refreshOfferAvailability;
3462
+ /** The read itself. Never rejects — see the catch. */
3463
+ private readOfferAvailability;
3330
3464
  private offerPrice;
3331
3465
  private applyChannelPricing;
3332
3466
  private syncOffer;