@seatlayer/js 0.9.0 → 0.10.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.js CHANGED
@@ -395,6 +395,14 @@ import {
395
395
  var DEFAULT_API_BASE2 = "https://api.seatlayer.io";
396
396
  var DEFAULT_MAX_SELECTION2 = 10;
397
397
  var EXTEND_PROMPT_MS = 6e4;
398
+ function pointInPolygon(x, y, poly) {
399
+ let inside = false;
400
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
401
+ const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
402
+ if (yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
403
+ }
404
+ return inside;
405
+ }
398
406
  function resolveContainer3(container) {
399
407
  if (typeof container === "string") {
400
408
  const el = document.querySelector(container);
@@ -438,12 +446,44 @@ var CSS = `
438
446
  .sl-map-host{position:absolute;inset:0}
439
447
  .sl-side{width:300px;flex:none;border-left:1px solid var(--sl-line);display:flex;flex-direction:column;min-height:0;overflow-y:auto}
440
448
 
441
- /* narrow (container < 640px): side panel becomes a bottom sheet */
449
+ /* narrow (container < 640px): map-first \u2014 the map claims ~80-85% of the
450
+ container and the side panel becomes a PEEKING bottom sheet (AXS/Ticketmaster
451
+ mobile pattern). data-sheet on the root: "peek" (default: grab handle + one
452
+ summary line) / "open" (\u226450%, swipe up or auto-expand on first selection).
453
+ Swipe handling lives on the sheet head ONLY \u2014 never the map host, so the
454
+ map's raw-pointer gesture pipeline is untouched. */
442
455
  .sl-picker[data-layout="narrow"] .sl-body{flex-direction:column}
443
456
  .sl-picker[data-layout="narrow"] .sl-map{min-height:0;flex:1}
444
- .sl-picker[data-layout="narrow"] .sl-side{width:100%;max-height:46%;border-left:0;border-top:1px solid var(--sl-line)}
457
+ .sl-picker[data-layout="narrow"] .sl-side{width:100%;border-left:0;border-top:1px solid var(--sl-line);
458
+ flex:none;height:50%;transition:height .22s ease}
459
+ .sl-picker[data-layout="narrow"][data-sheet="peek"] .sl-side{height:86px;overflow:hidden}
460
+ .sl-picker[data-layout="narrow"][data-sheet="peek"] .sl-side > :not(.sl-sheet-head){display:none}
445
461
  .sl-picker[data-layout="narrow"] .sl-tray{flex:none}
446
462
  .sl-picker[data-layout="narrow"] .sl-foot{position:sticky;bottom:0;background:var(--sl-bg)}
463
+ /* touch chrome: pinch-zoom exists \u2014 hide +/\u2212 on the sheet layout (keep fit) */
464
+ .sl-picker[data-layout="narrow"] .sl-zoom [data-ref="zin"],
465
+ .sl-picker[data-layout="narrow"] .sl-zoom [data-ref="zout"]{display:none}
466
+
467
+ /* bottom-sheet head: grab handle + one-line summary (narrow only) */
468
+ .sl-sheet-head{display:none;flex-direction:column;padding:6px 16px 8px;cursor:grab;touch-action:none;
469
+ user-select:none;-webkit-user-select:none;flex:none}
470
+ .sl-picker[data-layout="narrow"] .sl-sheet-head{display:flex}
471
+ .sl-sheet-grab{width:36px;height:4px;border-radius:999px;background:var(--sl-muted);opacity:.55;margin:2px auto 7px}
472
+ .sl-sheet-peek{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:700;color:var(--sl-text);
473
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:20px}
474
+ .sl-sheet-peek .sub{color:var(--sl-muted);font-weight:600}
475
+ .sl-sheet-peek .go{margin-left:auto;color:var(--sl-accent);font-weight:800;flex:none}
476
+
477
+ /* consolidated Filters row inside the sheet (a11y chips + colorblind toggle
478
+ dock here on narrow; they live on the map / zoom column on wide) */
479
+ .sl-filtersec{display:none}
480
+ .sl-filters{display:none;gap:6px;flex-wrap:wrap;align-items:center;padding:2px 16px 10px}
481
+ .sl-picker[data-layout="narrow"] .sl-filtersec.has{display:block}
482
+ .sl-picker[data-layout="narrow"] .sl-filters.has{display:flex}
483
+ .sl-cbbtn{width:32px;height:32px;border-radius:999px;background:var(--sl-surface);border:1px solid var(--sl-line);
484
+ color:var(--sl-text);display:flex;align-items:center;justify-content:center;transition:border-color .15s}
485
+ .sl-cbbtn:hover{border-color:var(--sl-muted)}
486
+ .sl-cbbtn svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
447
487
 
448
488
  /* price panel */
449
489
  .sl-sec{padding:14px 16px 4px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--sl-muted);font-weight:700}
@@ -486,19 +526,44 @@ var CSS = `
486
526
  .sl-cta:hover{filter:brightness(1.08)}
487
527
  .sl-cta:disabled{opacity:.45;cursor:not-allowed}
488
528
 
489
- /* zoom column */
490
- .sl-zoom{position:absolute;right:12px;bottom:12px;display:flex;flex-direction:column;gap:6px;z-index:5}
529
+ /* Chrome anchor regions (Feature 6) \u2014 every persistent map overlay is APPENDED
530
+ INTO one of these positioned flex containers and flows/stacks within it, so no
531
+ two pieces of chrome free-float on top of each other. Regions never overlap:
532
+ the top strip splits into left/center/right; rails + corners own their edge. */
533
+ .sl-anchor{position:absolute;z-index:5;display:flex;gap:8px;pointer-events:none}
534
+ .sl-anchor > *{pointer-events:auto}
535
+ .sl-anchor[data-region="top-left"]{top:12px;left:12px;flex-wrap:wrap;max-width:38%}
536
+ .sl-anchor[data-region="top-center"]{top:12px;left:50%;transform:translateX(-50%);flex-direction:column;
537
+ align-items:center;max-width:44%}
538
+ .sl-anchor[data-region="top-right"]{top:12px;right:12px;justify-content:flex-end;flex-wrap:wrap;max-width:38%}
539
+ .sl-anchor[data-region="left-rail"]{top:50%;left:12px;transform:translateY(-50%);flex-direction:column;max-width:42%;gap:6px}
540
+ .sl-anchor[data-region="bottom-left"]{left:12px;bottom:12px;flex-direction:column;align-items:flex-start}
541
+ .sl-anchor[data-region="bottom-center"]{left:50%;bottom:14px;transform:translateX(-50%);z-index:9;
542
+ flex-direction:column;align-items:center;gap:8px;max-width:92%}
543
+ .sl-anchor[data-region="bottom-right"]{right:12px;bottom:12px;flex-direction:column;align-items:flex-end;gap:6px}
544
+ /* narrow: tighten the top strip so left/center can't crowd each other */
545
+ .sl-picker[data-layout="narrow"] .sl-anchor[data-region="top-left"]{max-width:30%}
546
+ .sl-picker[data-layout="narrow"] .sl-anchor[data-region="top-center"]{max-width:44%}
547
+
548
+ /* TEST MODE badge \u2014 a small pill in the top-right region (shrinks on narrow) */
549
+ .sl-testbadge{padding:5px 11px;border-radius:999px;font-size:10px;font-weight:800;letter-spacing:.1em;
550
+ text-transform:uppercase;white-space:nowrap;background:var(--sl-accent);color:var(--sl-accent-ink);
551
+ box-shadow:0 2px 8px rgba(0,0,0,.25)}
552
+ .sl-picker[data-layout="narrow"] .sl-testbadge{padding:3px 8px;font-size:8.5px;letter-spacing:.06em}
553
+
554
+ /* zoom column (flows within the bottom-right region) */
555
+ .sl-zoom{display:flex;flex-direction:column;gap:6px}
491
556
  .sl-zoom button{width:36px;height:36px;border-radius:999px;background:var(--sl-surface);border:1px solid var(--sl-line);
492
557
  color:var(--sl-text);font-size:17px;font-weight:700;display:flex;align-items:center;justify-content:center;transition:border-color .15s}
493
558
  .sl-zoom button:hover{border-color:var(--sl-muted)}
494
559
  .sl-zoom svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
495
560
 
496
- /* toast + boot states */
497
- .sl-toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%) translateY(6px);z-index:8;max-width:88%;
561
+ /* toast + boot states (toast flows in the bottom-center region) */
562
+ .sl-toast{transform:translateY(6px);max-width:100%;
498
563
  background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-text);border-radius:999px;padding:9px 16px;
499
564
  font-size:12.5px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;white-space:nowrap;
500
565
  overflow:hidden;text-overflow:ellipsis}
501
- .sl-toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
566
+ .sl-toast.on{opacity:1;transform:translateY(0)}
502
567
  .sl-boot{position:absolute;inset:0;z-index:6;display:flex;flex-direction:column;align-items:center;justify-content:center;
503
568
  gap:10px;background:var(--sl-bg);font-size:13px;font-weight:600;color:var(--sl-muted)}
504
569
  .sl-boot-spin{width:24px;height:24px;border-radius:50%;border:3px solid var(--sl-line);border-top-color:var(--sl-accent);
@@ -508,12 +573,12 @@ var CSS = `
508
573
  .sl-boot-retry{margin-top:4px;padding:9px 20px;border-radius:var(--sl-r-sm);background:var(--sl-accent);
509
574
  color:var(--sl-accent-ink);font-weight:700;font-size:13px}
510
575
 
511
- /* "Need more time?" extend prompt (bottom-center over the map, above the toast) */
512
- .sl-extend{position:absolute;left:50%;bottom:16px;transform:translateX(-50%) translateY(6px);z-index:9;
513
- display:none;align-items:center;gap:12px;max-width:92%;background:var(--sl-surface);border:1px solid var(--sl-line);
576
+ /* "Need more time?" extend prompt (flows in the bottom-center region, above the toast) */
577
+ .sl-extend{transform:translateY(6px);
578
+ display:none;align-items:center;gap:12px;max-width:100%;background:var(--sl-surface);border:1px solid var(--sl-line);
514
579
  color:var(--sl-text);border-radius:14px;padding:10px 12px 10px 16px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);
515
580
  opacity:0;transition:opacity .2s,transform .2s}
516
- .sl-extend.on{display:flex;opacity:1;transform:translateX(-50%) translateY(0)}
581
+ .sl-extend.on{display:flex;opacity:1;transform:translateY(0)}
517
582
  .sl-extend-txt{font-size:12.5px;font-weight:600;line-height:1.35}
518
583
  .sl-extend-txt b{font-variant-numeric:tabular-nums}
519
584
  .sl-extend-btn{flex:none;padding:8px 14px;border-radius:999px;font-weight:800;font-size:12.5px;
@@ -532,8 +597,8 @@ var CSS = `
532
597
  .sl-booked-sub{font-size:13px;color:var(--sl-muted);line-height:1.5;max-width:320px}
533
598
  .sl-booked-seats{font-weight:700;color:var(--sl-text)}
534
599
 
535
- /* a11y filter chips (over the map, top-left) */
536
- .sl-chips{position:absolute;top:12px;left:12px;z-index:5;display:flex;gap:6px;flex-wrap:wrap;max-width:70%}
600
+ /* a11y filter chips (flow within the top-left region) */
601
+ .sl-chips{display:flex;gap:6px;flex-wrap:wrap}
537
602
  .sl-chip-f{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;font-size:12px;font-weight:700;
538
603
  background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted);transition:color .15s,border-color .15s}
539
604
  .sl-chip-f:hover{color:var(--sl-text)}
@@ -573,18 +638,18 @@ var CSS = `
573
638
  .sl-chip .view:hover{color:var(--sl-text)}
574
639
  .sl-chip .view svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
575
640
 
576
- /* arena: LOD rung pills (top-center over the map) */
577
- .sl-rungs{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:5;display:none;
578
- background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:999px;padding:3px}
641
+ /* arena: LOD rung pills (flow within the top-center region) */
642
+ .sl-rungs{display:none;background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:999px;padding:3px}
579
643
  .sl-rungs.on{display:inline-flex;gap:2px}
580
644
  .sl-rungs button{padding:6px 13px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.07em;
581
645
  color:var(--sl-muted);white-space:nowrap;transition:color .15s}
582
646
  .sl-rungs button:hover{color:var(--sl-text)}
583
647
  .sl-rungs button.on{background:var(--sl-accent);color:var(--sl-accent-ink)}
648
+ /* narrow: shrink the rung pills so the centered row can't reach the corner regions */
649
+ .sl-picker[data-layout="narrow"] .sl-rungs button{padding:5px 9px;font-size:9px;letter-spacing:.03em}
584
650
 
585
- /* multi-floor switcher (center-left rail over the map) */
586
- .sl-floors{position:absolute;top:50%;left:12px;transform:translateY(-50%);z-index:5;display:none;
587
- flex-direction:column;gap:6px;max-width:42%}
651
+ /* multi-floor switcher (flows within the left-rail region) */
652
+ .sl-floors{display:none;flex-direction:column;gap:6px;max-width:100%}
588
653
  .sl-floors.on{display:flex}
589
654
  .sl-floors button{padding:7px 13px;border-radius:999px;font-size:12px;font-weight:700;background:var(--sl-surface);
590
655
  border:1px solid var(--sl-line);color:var(--sl-muted);white-space:nowrap;max-width:100%;overflow:hidden;
@@ -592,11 +657,25 @@ var CSS = `
592
657
  .sl-floors button:hover{color:var(--sl-text)}
593
658
  .sl-floors button.on{background:var(--sl-accent);color:var(--sl-accent-ink);border-color:transparent}
594
659
 
595
- /* tapped-section summary card (top-center, under the rung pills) */
596
- .sl-seccard{position:absolute;top:54px;left:50%;transform:translateX(-50%);z-index:6;width:250px;
597
- max-width:calc(100% - 24px);background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:12px;
660
+ /* tapped-section summary card \u2014 docks INSIDE the top-center anchor region on
661
+ wide (flows below the rung pills, never over them, never floating over the
662
+ seats at the tap point). Auto-collapses to a slim pill once seat-picking
663
+ begins (first seat select, or a pan/zoom after the focus glide); tapping the
664
+ pill re-expands; \u2715 closes in both states. On narrow it renders as a compact
665
+ strip inside the bottom sheet's peek head \u2014 never over the canvas. */
666
+ .sl-seccard{width:250px;max-width:100%;background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:12px;
598
667
  padding:12px 14px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);display:none}
599
668
  .sl-seccard.on{display:block}
669
+ /* collapsed pill (wide) */
670
+ .sl-seccard.mini{width:auto;padding:5px 7px 5px 12px;border-radius:999px;cursor:pointer}
671
+ .sl-seccard.mini.on{display:inline-flex;align-items:center;gap:7px}
672
+ .sl-seccard.mini .sl-seccard-name{font-size:12px;flex:none;max-width:120px}
673
+ .sl-seccard.mini .sl-seccard-left{font-size:11px}
674
+ /* narrow: compact strip inside the sheet head (peek area) */
675
+ .sl-seccard.strip{width:100%;padding:7px 0 0;border:0;border-radius:0;box-shadow:none;background:none;cursor:default}
676
+ .sl-seccard.strip.on{display:flex;align-items:center;gap:7px;font-size:12.5px}
677
+ .sl-seccard.strip .sl-seccard-name{font-size:12.5px}
678
+ .sl-seccard.strip .sl-seccard-price{margin-left:auto}
600
679
  .sl-seccard-head{display:flex;align-items:center;gap:8px}
601
680
  .sl-seccard-dot{width:10px;height:10px;border-radius:50%;flex:none}
602
681
  .sl-seccard-name{font-weight:800;font-size:14px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -638,6 +717,20 @@ var CSS = `
638
717
  font-size:11.5px;font-weight:600;background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted);
639
718
  white-space:nowrap;pointer-events:none;max-width:90%;overflow:hidden;text-overflow:ellipsis}
640
719
 
720
+ /* F3 minimap \u2014 venue overview + live viewport rect (flows in the bottom-left region) */
721
+ .sl-minimap{border:1px solid var(--sl-line);border-radius:9px;
722
+ overflow:hidden;background:var(--sl-surface);box-shadow:0 12px 34px -14px rgba(0,0,0,.55);line-height:0;cursor:pointer}
723
+ .sl-minimap canvas{display:block}
724
+ .sl-picker[data-layout="narrow"] .sl-minimap{display:none}
725
+
726
+ /* F4 price-band filter \u2014 chip row in the side panel, above the price legend it
727
+ * dims (keeps clear of the map's top-center rungs / top-left a11y chips). */
728
+ .sl-pricef{display:flex;gap:6px;flex-wrap:wrap;padding:2px 16px 10px}
729
+ .sl-pricef .sl-chip-f{padding:6px 11px;font-size:11.5px}
730
+ /* F4 legend reflection: rows + counts for out-of-band categories read muted */
731
+ .sl-price-row.sl-dim{opacity:.4}
732
+ .sl-seccard-mix-item.sl-dim{opacity:.4}
733
+
641
734
  /* modal host */
642
735
  .sl-modal-scrim{position:fixed;inset:0;z-index:2147483000;background:rgba(5,7,12,.66);display:flex;align-items:center;justify-content:center;padding:18px}
643
736
  .sl-modal-frame{width:min(1200px,100%);height:min(820px,100%);border-radius:16px;overflow:hidden;box-shadow:0 40px 120px -30px rgba(0,0,0,.8)}
@@ -673,6 +766,8 @@ var SeatPicker = class _SeatPicker {
673
766
  this.destroyed = false;
674
767
  // chrome refs
675
768
  this.els = {};
769
+ /** Feature 6 anchor regions — positioned flex containers over the map. */
770
+ this.regions = {};
676
771
  this.ro = null;
677
772
  this.holdTimer = null;
678
773
  this.toastTimer = null;
@@ -703,6 +798,25 @@ var SeatPicker = class _SeatPicker {
703
798
  this.viewEl = null;
704
799
  this.viewCleanup = null;
705
800
  this.allSeatsCache = null;
801
+ // F3 minimap
802
+ this.miniEl = null;
803
+ this.miniCanvas = null;
804
+ this.miniBase = null;
805
+ this.miniTf = null;
806
+ // F4 price-band filter — active band's category keys (null = all prices)
807
+ this.priceBandKeys = null;
808
+ this.priceFilterEl = null;
809
+ /** Last surfaced section summary (re-rendered when the price band changes). */
810
+ this.lastSection = null;
811
+ /** Section card collapsed to its slim pill (seat-picking has begun). */
812
+ this.secCardCollapsed = false;
813
+ /** When the card was (re)shown — the focus glide's own view change must not collapse it. */
814
+ this.secCardShownAt = 0;
815
+ /** Previous tray ticket count — first 0→n transition auto-expands the mobile sheet. */
816
+ this.lastTrayCount = 0;
817
+ // narrow-layout chrome that docks into the sheet's Filters row on mobile
818
+ this.a11yChipsEl = null;
819
+ this.cbEl = null;
706
820
  // modal plumbing (set by open())
707
821
  this.modalScrim = null;
708
822
  this.prevFocus = null;
@@ -721,11 +835,15 @@ var SeatPicker = class _SeatPicker {
721
835
  currency: options.currency,
722
836
  flashOnLiveChange: true,
723
837
  colorblindSafe: options.colorblindSafe,
724
- onSelectionChange: () => this.syncTray(),
838
+ onSelectionChange: () => {
839
+ this.syncTray();
840
+ if (this.controller.getSelection().length) this.collapseSectionCard();
841
+ },
725
842
  onStatusChange: () => {
726
843
  this.syncPrices();
727
844
  this.evictTakenSelections();
728
845
  this.detectBooked();
846
+ this.refreshMinimap();
729
847
  },
730
848
  onHoldExpired: () => {
731
849
  this.hold = null;
@@ -744,6 +862,8 @@ var SeatPicker = class _SeatPicker {
744
862
  onViewChange: () => {
745
863
  this.reanchorConfirm();
746
864
  this.syncRung();
865
+ this.drawMinimapRect();
866
+ this.sectionCardOnView();
747
867
  },
748
868
  // Tapped-section glide-in → surface (or clear) the section-summary card.
749
869
  onSectionFocus: (summary) => this.showSectionCard(summary),
@@ -823,7 +943,7 @@ var SeatPicker = class _SeatPicker {
823
943
  <div class="sl-body">
824
944
  <div class="sl-map">
825
945
  <div class="sl-map-host" data-ref="map"></div>
826
- <div class="sl-zoom">
946
+ <div class="sl-zoom" data-ref="zoom">
827
947
  <button type="button" aria-label="Zoom in" data-ref="zin">+</button>
828
948
  <button type="button" aria-label="Zoom out" data-ref="zout">\u2212</button>
829
949
  <button type="button" aria-label="Fit to screen" data-ref="zfit">
@@ -833,7 +953,13 @@ var SeatPicker = class _SeatPicker {
833
953
  <div class="sl-boot" data-ref="boot"><span class="sl-boot-spin"></span>Loading seat map\u2026</div>
834
954
  <div class="sl-toast" data-ref="toast" role="status" aria-live="polite"></div>
835
955
  </div>
836
- <div class="sl-side">
956
+ <div class="sl-side" data-ref="side">
957
+ <div class="sl-sheet-head" data-ref="sheetHead">
958
+ <div class="sl-sheet-grab"></div>
959
+ <div class="sl-sheet-peek" data-ref="peek"></div>
960
+ </div>
961
+ <div class="sl-sec sl-filtersec" data-ref="filtersSec">Filters</div>
962
+ <div class="sl-filters" data-ref="filters"></div>
837
963
  <div class="sl-sec" data-ref="pricesSec">Prices</div>
838
964
  <div class="sl-prices" data-ref="prices"></div>
839
965
  <div class="sl-sec">Your seats</div>
@@ -848,14 +974,55 @@ var SeatPicker = class _SeatPicker {
848
974
  this.els[el.dataset.ref] = el;
849
975
  });
850
976
  this.mapHost = this.els.map;
851
- this.ro = new ResizeObserver(() => {
977
+ const applyLayout = () => {
852
978
  const w = root.clientWidth;
853
- root.dataset.layout = w < 640 ? "narrow" : "wide";
854
- });
979
+ if (w <= 0) return;
980
+ const next = w < 640 ? "narrow" : "wide";
981
+ if (root.dataset.layout === next) return;
982
+ root.dataset.layout = next;
983
+ if (next === "narrow" && !root.dataset.sheet) root.dataset.sheet = "peek";
984
+ this.dockLayoutChrome();
985
+ };
986
+ this.ro = new ResizeObserver(applyLayout);
855
987
  this.ro.observe(root);
988
+ applyLayout();
989
+ requestAnimationFrame(applyLayout);
856
990
  this.els.zin.addEventListener("click", () => this.controller.zoomIn());
857
991
  this.els.zout.addEventListener("click", () => this.controller.zoomOut());
858
992
  this.els.zfit.addEventListener("click", () => this.controller.zoomToFit());
993
+ const head = this.els.sheetHead;
994
+ if (head) {
995
+ const setSheet = (open) => {
996
+ root.dataset.sheet = open ? "open" : "peek";
997
+ };
998
+ let startY = 0;
999
+ let swiped = false;
1000
+ let tracking = false;
1001
+ head.addEventListener("pointerdown", (e) => {
1002
+ tracking = true;
1003
+ swiped = false;
1004
+ startY = e.clientY;
1005
+ head.setPointerCapture?.(e.pointerId);
1006
+ });
1007
+ head.addEventListener("pointermove", (e) => {
1008
+ if (!tracking || swiped) return;
1009
+ const dy = e.clientY - startY;
1010
+ if (dy < -18) {
1011
+ setSheet(true);
1012
+ swiped = true;
1013
+ } else if (dy > 18) {
1014
+ setSheet(false);
1015
+ swiped = true;
1016
+ }
1017
+ });
1018
+ head.addEventListener("pointerup", (e) => {
1019
+ if (tracking && !swiped && Math.abs(e.clientY - startY) < 6) {
1020
+ if (!e.target.closest(".sl-seccard")) setSheet(root.dataset.sheet !== "open");
1021
+ }
1022
+ tracking = false;
1023
+ head.releasePointerCapture?.(e.pointerId);
1024
+ });
1025
+ }
859
1026
  this.tipEl = document.createElement("div");
860
1027
  this.tipEl.setAttribute("role", "tooltip");
861
1028
  this.tipEl.style.cssText = "position:absolute;z-index:7;pointer-events:none;display:none;max-width:240px;background:var(--sl-surface);color:var(--sl-text);border:1px solid var(--sl-line);border-radius:10px;padding:9px 12px;font-size:12px;line-height:1.45;";
@@ -882,13 +1049,15 @@ var SeatPicker = class _SeatPicker {
882
1049
  return this;
883
1050
  }
884
1051
  this.els.boot.remove();
1052
+ this.buildRegions();
1053
+ this.regions["bottom-right"].appendChild(this.els.zoom);
1054
+ this.regions["bottom-center"].appendChild(this.els.toast);
885
1055
  if (info.mode === "test") {
886
- const ribbon = document.createElement("div");
887
- ribbon.textContent = t2("picker.testMode");
888
- ribbon.setAttribute("aria-label", t2("picker.testMode"));
889
- ribbon.style.cssText = "position:absolute;top:18px;right:-34px;z-index:6;transform:rotate(45deg);width:140px;text-align:center;padding:4px 0;background:#f4b740;color:#1a1200;font:800 10.5px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;letter-spacing:.12em;box-shadow:0 2px 8px rgba(0,0,0,.25);pointer-events:none;";
890
- this.els.map.style.overflow = "hidden";
891
- this.els.map.appendChild(ribbon);
1056
+ const badge = document.createElement("div");
1057
+ badge.className = "sl-testbadge";
1058
+ badge.textContent = t2("picker.testMode");
1059
+ badge.setAttribute("aria-label", t2("picker.testMode"));
1060
+ this.regions["top-right"].appendChild(badge);
892
1061
  }
893
1062
  const chartTheme = this.controller.doc?.theme;
894
1063
  Object.entries(resolveTokens(chartTheme, this.opts.theme)).forEach(([k, v]) => root.style.setProperty(k, v));
@@ -912,7 +1081,8 @@ var SeatPicker = class _SeatPicker {
912
1081
  const GLYPH = { wheelchair: "\u267F", companion: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" };
913
1082
  const mk = (key, label) => `<button type="button" class="sl-chip-f${key === "all" ? " on" : ""}" data-f="${key}">${label}</button>`;
914
1083
  chips.innerHTML = mk("all", "All seats") + [...present].map((type) => mk(type, `${GLYPH[type] ? GLYPH[type] + " " : ""}${type[0].toUpperCase()}${type.slice(1).replace(/-/g, " ")}`)).join("");
915
- this.els.map.appendChild(chips);
1084
+ this.regions["top-left"].appendChild(chips);
1085
+ this.a11yChipsEl = chips;
916
1086
  chips.querySelectorAll("button").forEach((btn) => {
917
1087
  btn.addEventListener("click", () => {
918
1088
  const f = btn.dataset.f;
@@ -924,6 +1094,8 @@ var SeatPicker = class _SeatPicker {
924
1094
  }
925
1095
  const cb = document.createElement("button");
926
1096
  cb.type = "button";
1097
+ cb.className = "sl-cbbtn";
1098
+ this.cbEl = cb;
927
1099
  cb.setAttribute("aria-label", "Toggle colorblind-friendly colors");
928
1100
  cb.setAttribute("aria-pressed", String(!!this.opts.colorblindSafe));
929
1101
  cb.innerHTML = '<svg viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
@@ -939,19 +1111,45 @@ var SeatPicker = class _SeatPicker {
939
1111
  this.srEl.setAttribute("aria-live", "polite");
940
1112
  root.appendChild(this.srEl);
941
1113
  this.buildArenaChrome();
1114
+ this.buildMinimap();
1115
+ this.buildPriceFilter();
942
1116
  this.buildExtendPrompt();
943
1117
  this.buildBookedOverlay();
1118
+ this.dockLayoutChrome();
944
1119
  this.syncPrices();
945
1120
  this.syncTray();
946
1121
  return this;
947
1122
  }
1123
+ /**
1124
+ * Move layout-dependent chrome between its wide dock (map regions / zoom
1125
+ * column) and its narrow dock (the sheet's consolidated Filters row), and
1126
+ * re-render the section card in the form the layout wants (docked card/pill
1127
+ * on wide, sheet strip on narrow). Runs on every layout flip + once post-render.
1128
+ */
1129
+ dockLayoutChrome() {
1130
+ const narrow = this.root?.dataset.layout === "narrow";
1131
+ const filters = this.els.filters;
1132
+ if (filters) {
1133
+ if (narrow) {
1134
+ if (this.a11yChipsEl) filters.appendChild(this.a11yChipsEl);
1135
+ if (this.cbEl) filters.appendChild(this.cbEl);
1136
+ } else {
1137
+ if (this.a11yChipsEl) this.regions["top-left"]?.appendChild(this.a11yChipsEl);
1138
+ if (this.cbEl) this.els.zoom?.appendChild(this.cbEl);
1139
+ }
1140
+ const has = narrow && filters.children.length > 0;
1141
+ filters.classList.toggle("has", has);
1142
+ this.els.filtersSec?.classList.toggle("has", has);
1143
+ }
1144
+ if (this.lastSection) this.renderSectionCard(this.lastSection);
1145
+ }
948
1146
  /** The "Need more time?" prompt shown in the hold's final EXTEND_PROMPT_MS. */
949
1147
  buildExtendPrompt() {
950
1148
  const el = document.createElement("div");
951
1149
  el.className = "sl-extend";
952
1150
  el.setAttribute("role", "status");
953
1151
  el.innerHTML = `<span class="sl-extend-txt" data-ref="extendTxt"></span><button type="button" class="sl-extend-btn" data-ref="extendBtn"></button>`;
954
- this.els.map.appendChild(el);
1152
+ (this.regions["bottom-center"] ?? this.els.map).appendChild(el);
955
1153
  this.extendEl = el;
956
1154
  this.els.extendTxt = el.querySelector('[data-ref="extendTxt"]');
957
1155
  this.els.extendBtn = el.querySelector('[data-ref="extendBtn"]');
@@ -969,6 +1167,258 @@ var SeatPicker = class _SeatPicker {
969
1167
  this.bookedEl = el;
970
1168
  this.els.bookedSub = el.querySelector('[data-ref="bookedSub"]');
971
1169
  }
1170
+ // ---- Feature 6: chrome anchor regions -------------------------------------
1171
+ /**
1172
+ * Create the positioned flex containers that own every persistent map overlay.
1173
+ * Appended once after controller.render(); each chrome piece is then appended
1174
+ * INTO its region and flows within it, so nothing free-floats over anything
1175
+ * else. Regions carve the map into non-overlapping zones (top strip split into
1176
+ * left/center/right, left rail, and the three used corners).
1177
+ */
1178
+ buildRegions() {
1179
+ if (!this.els.map) return;
1180
+ const REGIONS = ["top-left", "top-center", "top-right", "left-rail", "bottom-left", "bottom-center", "bottom-right"];
1181
+ for (const region of REGIONS) {
1182
+ const el = document.createElement("div");
1183
+ el.className = "sl-anchor";
1184
+ el.dataset.region = region;
1185
+ this.els.map.appendChild(el);
1186
+ this.regions[region] = el;
1187
+ }
1188
+ }
1189
+ // ---- F3 minimap -----------------------------------------------------------
1190
+ /** Read a resolved --sl-* token value (canvas needs a real color, not var()). */
1191
+ cssVar(name) {
1192
+ return this.root ? getComputedStyle(this.root).getPropertyValue(name).trim() : "";
1193
+ }
1194
+ /** Section-bearing objects on the active floor (single-floor → doc.objects). */
1195
+ activeFloorObjects() {
1196
+ const doc = this.controller.doc;
1197
+ if (!doc) return [];
1198
+ const floors = doc.floors;
1199
+ if (floors?.length) {
1200
+ const id = this.controller.getActiveFloorId();
1201
+ return (floors.find((f) => f.id === id) ?? floors[0]).objects ?? [];
1202
+ }
1203
+ return doc.objects ?? [];
1204
+ }
1205
+ /**
1206
+ * Build the overview minimap: a static venue thumbnail (section outlines, or
1207
+ * seat dots when the chart has no sections) with the live viewport rectangle
1208
+ * drawn on top. The rect tracks pan/zoom via the constructor's onViewChange.
1209
+ */
1210
+ buildMinimap() {
1211
+ const vp = this.controller.getViewport();
1212
+ if (!vp || !this.els.map) return;
1213
+ const b = vp.bounds;
1214
+ if (!(b.width > 0 && b.height > 0)) return;
1215
+ const MAXW = 158;
1216
+ const MAXH = 118;
1217
+ const PAD = 6;
1218
+ const aspect = b.width / Math.max(1, b.height);
1219
+ let w = MAXW;
1220
+ let h = Math.round(MAXW / aspect);
1221
+ if (h > MAXH) {
1222
+ h = MAXH;
1223
+ w = Math.round(MAXH * aspect);
1224
+ }
1225
+ w = Math.max(64, w);
1226
+ h = Math.max(48, h);
1227
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
1228
+ const wrap = document.createElement("div");
1229
+ wrap.className = "sl-minimap";
1230
+ wrap.setAttribute("aria-hidden", "true");
1231
+ const canvas = document.createElement("canvas");
1232
+ canvas.width = Math.round(w * dpr);
1233
+ canvas.height = Math.round(h * dpr);
1234
+ canvas.style.width = `${w}px`;
1235
+ canvas.style.height = `${h}px`;
1236
+ wrap.appendChild(canvas);
1237
+ (this.regions["bottom-left"] ?? this.els.map).appendChild(wrap);
1238
+ this.miniEl = wrap;
1239
+ this.miniCanvas = canvas;
1240
+ const scale = Math.min((w - PAD * 2) / Math.max(1, b.width), (h - PAD * 2) / Math.max(1, b.height)) * dpr;
1241
+ const offX = (w * dpr - b.width * scale) / 2 - b.x * scale;
1242
+ const offY = (h * dpr - b.height * scale) / 2 - b.y * scale;
1243
+ this.miniTf = { scale, offX, offY, dpr };
1244
+ const base = document.createElement("canvas");
1245
+ base.width = canvas.width;
1246
+ base.height = canvas.height;
1247
+ this.miniBase = base;
1248
+ wrap.addEventListener("click", (e) => this.minimapJump(e));
1249
+ this.drawMinimapStatic();
1250
+ this.drawMinimapRect();
1251
+ }
1252
+ /** Repaint the static overview + rect (floor switch, live open/close). */
1253
+ refreshMinimap() {
1254
+ if (!this.miniBase) return;
1255
+ this.drawMinimapStatic();
1256
+ this.drawMinimapRect();
1257
+ }
1258
+ /** Paint the venue overview into the offscreen base canvas. */
1259
+ drawMinimapStatic() {
1260
+ const base = this.miniBase;
1261
+ const tf = this.miniTf;
1262
+ const doc = this.controller.doc;
1263
+ if (!base || !tf || !doc) return;
1264
+ const ctx = base.getContext("2d");
1265
+ if (!ctx) return;
1266
+ ctx.clearRect(0, 0, base.width, base.height);
1267
+ const fx = (x) => x * tf.scale + tf.offX;
1268
+ const fy = (y) => y * tf.scale + tf.offY;
1269
+ const line = this.cssVar("--sl-line") || "rgba(139,147,167,.5)";
1270
+ const muted = this.cssVar("--sl-muted") || "#8b93a7";
1271
+ const accent = this.cssVar("--sl-accent") || "#6e7bff";
1272
+ const zoneColor = new Map((doc.zones ?? []).map((z) => [z.id, z.color]));
1273
+ let drewSection = false;
1274
+ for (const o of this.activeFloorObjects()) {
1275
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1276
+ drewSection = true;
1277
+ const closed = this.controller.isSectionClosed(o.id);
1278
+ const fill = closed ? muted : o.color ?? (o.zone && zoneColor.get(o.zone)) ?? accent;
1279
+ ctx.beginPath();
1280
+ o.outline.forEach((p, i) => i === 0 ? ctx.moveTo(fx(p.x), fy(p.y)) : ctx.lineTo(fx(p.x), fy(p.y)));
1281
+ ctx.closePath();
1282
+ ctx.globalAlpha = closed ? 0.26 : 0.42;
1283
+ ctx.fillStyle = fill;
1284
+ ctx.fill();
1285
+ ctx.globalAlpha = 0.85;
1286
+ ctx.lineWidth = Math.max(1, tf.dpr);
1287
+ ctx.strokeStyle = line;
1288
+ ctx.stroke();
1289
+ }
1290
+ ctx.globalAlpha = 1;
1291
+ if (!drewSection) {
1292
+ const r = Math.max(1, tf.dpr);
1293
+ for (const seat of expandChart(doc)) {
1294
+ const cat = doc.categories.find((c) => c.key === seat.categoryKey);
1295
+ ctx.fillStyle = cat?.color ?? accent;
1296
+ ctx.beginPath();
1297
+ ctx.arc(fx(seat.x), fy(seat.y), r, 0, Math.PI * 2);
1298
+ ctx.fill();
1299
+ }
1300
+ }
1301
+ }
1302
+ /** Blit the base overview, then stroke the current viewport rectangle on top. */
1303
+ drawMinimapRect() {
1304
+ const canvas = this.miniCanvas;
1305
+ const base = this.miniBase;
1306
+ const tf = this.miniTf;
1307
+ if (!canvas || !base || !tf) return;
1308
+ const ctx = canvas.getContext("2d");
1309
+ if (!ctx) return;
1310
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1311
+ ctx.drawImage(base, 0, 0);
1312
+ const vp = this.controller.getViewport();
1313
+ if (!vp) return;
1314
+ const v = vp.visible;
1315
+ const x = v.x * tf.scale + tf.offX;
1316
+ const y = v.y * tf.scale + tf.offY;
1317
+ const w = v.width * tf.scale;
1318
+ const h = v.height * tf.scale;
1319
+ const accent = this.cssVar("--sl-accent") || "#f4b740";
1320
+ ctx.save();
1321
+ ctx.globalAlpha = 0.14;
1322
+ ctx.fillStyle = accent;
1323
+ ctx.fillRect(x, y, w, h);
1324
+ ctx.globalAlpha = 1;
1325
+ ctx.lineWidth = Math.max(1.5, tf.dpr * 1.5);
1326
+ ctx.strokeStyle = accent;
1327
+ ctx.strokeRect(x, y, w, h);
1328
+ ctx.restore();
1329
+ }
1330
+ /** Minimap click → focus the section under the point (or overview on a miss). */
1331
+ minimapJump(e) {
1332
+ const canvas = this.miniCanvas;
1333
+ const tf = this.miniTf;
1334
+ if (!canvas || !tf) return;
1335
+ const r = canvas.getBoundingClientRect();
1336
+ const px = (e.clientX - r.left) * (canvas.width / r.width);
1337
+ const py = (e.clientY - r.top) * (canvas.height / r.height);
1338
+ const wx = (px - tf.offX) / tf.scale;
1339
+ const wy = (py - tf.offY) / tf.scale;
1340
+ for (const o of this.activeFloorObjects()) {
1341
+ if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
1342
+ if (this.controller.isSectionClosed(o.id)) continue;
1343
+ if (pointInPolygon(wx, wy, o.outline)) {
1344
+ this.controller.focusSection(o.id);
1345
+ return;
1346
+ }
1347
+ }
1348
+ this.controller.overview();
1349
+ }
1350
+ // ---- F4 price-band filter -------------------------------------------------
1351
+ /** Effective price of a category (first tier when tiered, else base price). */
1352
+ catPrice(c) {
1353
+ return c.tiers?.length ? c.tiers[0].price : c.price;
1354
+ }
1355
+ /** Derive price bands: one chip per distinct price (≤5), else quantile ranges. */
1356
+ priceBands() {
1357
+ const doc = this.controller.doc;
1358
+ if (!doc) return [];
1359
+ const priced = doc.categories.map((c) => ({ key: c.key, price: this.catPrice(c) })).filter((x) => x.price != null);
1360
+ if (!priced.length) return [];
1361
+ const distinct = [...new Set(priced.map((p) => p.price))].sort((a, b) => a - b);
1362
+ if (distinct.length <= 5) {
1363
+ return distinct.map((price) => ({
1364
+ id: `p${price}`,
1365
+ label: this.money(price),
1366
+ keys: priced.filter((p) => p.price === price).map((p) => p.key),
1367
+ min: price,
1368
+ max: price
1369
+ }));
1370
+ }
1371
+ const chunk = Math.ceil(distinct.length / 4);
1372
+ const bands = [];
1373
+ for (let i = 0; i < distinct.length; i += chunk) {
1374
+ const slice = distinct.slice(i, i + chunk);
1375
+ const lo = slice[0];
1376
+ const hi = slice[slice.length - 1];
1377
+ bands.push({
1378
+ id: `b${i}`,
1379
+ label: lo === hi ? this.money(lo) : `${this.money(lo)}\u2013${this.money(hi)}`,
1380
+ keys: priced.filter((p) => p.price >= lo && p.price <= hi).map((p) => p.key),
1381
+ min: lo,
1382
+ max: hi
1383
+ });
1384
+ }
1385
+ return bands;
1386
+ }
1387
+ /**
1388
+ * Build the price-band chip row in the side panel, directly under the "Prices"
1389
+ * header and above the legend it dims. Living in the panel (not a map overlay)
1390
+ * keeps it clear of the top-center rung pills and top-left a11y chips, and it
1391
+ * rides the bottom sheet on narrow layouts. Skipped when there's <2 bands.
1392
+ */
1393
+ buildPriceFilter() {
1394
+ if (!this.els.prices || !this.els.pricesSec) return;
1395
+ const bands = this.priceBands();
1396
+ if (bands.length < 2) return;
1397
+ const wrap = document.createElement("div");
1398
+ wrap.className = "sl-pricef";
1399
+ wrap.setAttribute("role", "group");
1400
+ wrap.setAttribute("aria-label", "Filter seats by price");
1401
+ const mk = (band, label, on) => `<button type="button" class="sl-chip-f${on ? " on" : ""}" data-band="${band}">${label}</button>`;
1402
+ wrap.innerHTML = mk("all", "All prices", true) + bands.map((bd) => mk(bd.id, bd.label, false)).join("");
1403
+ this.els.pricesSec.after(wrap);
1404
+ this.priceFilterEl = wrap;
1405
+ wrap.querySelectorAll("button").forEach((btn) => {
1406
+ btn.addEventListener("click", () => {
1407
+ wrap.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b === btn));
1408
+ const id = btn.dataset.band;
1409
+ if (id === "all") {
1410
+ this.priceBandKeys = null;
1411
+ this.controller.setCategoryFilter(null);
1412
+ } else {
1413
+ const bd = bands.find((x) => x.id === id);
1414
+ this.priceBandKeys = bd ? new Set(bd.keys) : null;
1415
+ this.controller.setCategoryFilter(bd ? bd.keys : null);
1416
+ }
1417
+ this.syncPrices();
1418
+ if (this.lastSection) this.showSectionCard(this.lastSection);
1419
+ });
1420
+ });
1421
+ }
972
1422
  // ---- arena / multi-floor chrome -------------------------------------------
973
1423
  /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
974
1424
  buildArenaChrome() {
@@ -997,7 +1447,7 @@ var SeatPicker = class _SeatPicker {
997
1447
  pills.querySelectorAll("button").forEach((btn) => {
998
1448
  btn.addEventListener("click", () => this.controller.setRung(btn.dataset.rung));
999
1449
  });
1000
- this.els.map.appendChild(pills);
1450
+ this.regions["top-center"].appendChild(pills);
1001
1451
  this.rungsEl = pills;
1002
1452
  this.syncRung();
1003
1453
  }
@@ -1014,9 +1464,10 @@ var SeatPicker = class _SeatPicker {
1014
1464
  this.showSectionCard(null);
1015
1465
  this.syncFloors();
1016
1466
  this.syncRung();
1467
+ this.refreshMinimap();
1017
1468
  });
1018
1469
  });
1019
- this.els.map.appendChild(rail);
1470
+ this.regions["left-rail"].appendChild(rail);
1020
1471
  this.floorsEl = rail;
1021
1472
  this.syncFloors();
1022
1473
  }
@@ -1041,27 +1492,105 @@ var SeatPicker = class _SeatPicker {
1041
1492
  }
1042
1493
  /** Show (or clear, on null) the tapped-section summary card. */
1043
1494
  showSectionCard(summary) {
1044
- if (!summary) {
1045
- this.secCardEl?.remove();
1046
- this.secCardEl = null;
1047
- return;
1048
- }
1495
+ this.lastSection = summary;
1496
+ this.secCardEl?.remove();
1497
+ this.secCardEl = null;
1498
+ if (!summary) return;
1499
+ this.secCardCollapsed = false;
1500
+ this.secCardShownAt = Date.now();
1501
+ this.renderSectionCard(summary);
1502
+ }
1503
+ /**
1504
+ * Render the section card in the form the layout + state want: expanded card
1505
+ * or slim pill in the top-center anchor region (wide), or a compact strip in
1506
+ * the sheet head (narrow). Never floats over the seats at the tap point.
1507
+ */
1508
+ renderSectionCard(summary) {
1049
1509
  if (!this.els.map) return;
1050
1510
  this.secCardEl?.remove();
1051
1511
  const priceLabel = summary.priceMin === summary.priceMax ? this.money(summary.priceMin) : `${this.money(summary.priceMin)}\u2013${this.money(summary.priceMax)}`;
1512
+ const leftLabel = tCount("picker.seatsLeftInSection", summary.seatsLeft);
1513
+ const xBtn = `<button type="button" class="sl-seccard-x" aria-label="${t2("picker.closeSectionSummary")}">\u2715</button>`;
1052
1514
  const card = document.createElement("div");
1053
- card.className = "sl-seccard on";
1054
- card.setAttribute("role", "dialog");
1055
- card.setAttribute("aria-label", t2("picker.sectionSummaryAria", { label: summary.label }));
1056
- const mix = summary.categories.map(
1057
- (c) => `<span class="sl-seccard-mix-item"><span class="sl-seccard-mix-dot" style="background:${c.color}"></span>${c.label} <span class="sl-seccard-mix-price">${this.money(c.price)}</span></span>`
1058
- ).join("");
1059
- card.innerHTML = `<div class="sl-seccard-head"><span class="sl-seccard-dot" style="background:${summary.color}"></span><span class="sl-seccard-name">${summary.label}</span>` + (summary.categories.length ? `<span class="sl-seccard-price">${priceLabel}</span>` : "") + `<button type="button" class="sl-seccard-x" aria-label="${t2("picker.closeSectionSummary")}">\u2715</button></div><div class="sl-seccard-zone">${summary.zoneLabel ? `${summary.zoneLabel} \xB7 ` : ""}<span class="sl-seccard-left">${tCount("picker.seatsLeftInSection", summary.seatsLeft)}</span></div>` + (mix ? `<div class="sl-seccard-mix">${mix}</div>` : "") + `<div class="sl-seccard-foot"><button type="button" class="sl-seccard-overview">\u2190 ${t2("picker.overview")}</button><span class="sl-seccard-hint">${t2("picker.tapSeatHint")}</span></div>`;
1060
- card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
1061
- card.querySelector(".sl-seccard-overview").addEventListener("click", () => this.controller.overview());
1062
- this.els.map.appendChild(card);
1515
+ const narrow = this.root?.dataset.layout === "narrow";
1516
+ if (narrow) {
1517
+ card.className = "sl-seccard strip on";
1518
+ card.setAttribute("role", "status");
1519
+ card.innerHTML = `<span class="sl-seccard-dot" style="background:${summary.color}"></span><span class="sl-seccard-name">${summary.label}</span><span class="sl-seccard-left">${leftLabel}</span>` + (summary.categories.length ? `<span class="sl-seccard-price">${priceLabel}</span>` : "") + xBtn;
1520
+ card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
1521
+ (this.els.sheetHead ?? this.els.side ?? this.els.map).appendChild(card);
1522
+ } else if (this.secCardCollapsed) {
1523
+ card.className = "sl-seccard mini on";
1524
+ card.setAttribute("role", "button");
1525
+ card.setAttribute("aria-label", t2("picker.sectionSummaryAria", { label: summary.label }));
1526
+ card.innerHTML = `<span class="sl-seccard-dot" style="background:${summary.color}"></span><span class="sl-seccard-name">${summary.label}</span><span class="sl-seccard-left">${leftLabel}</span>` + xBtn;
1527
+ card.addEventListener("click", (e) => {
1528
+ if (e.target.closest(".sl-seccard-x")) return;
1529
+ this.secCardCollapsed = false;
1530
+ this.secCardShownAt = Date.now();
1531
+ this.renderSectionCard(summary);
1532
+ });
1533
+ card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
1534
+ (this.regions["top-center"] ?? this.els.map).appendChild(card);
1535
+ } else {
1536
+ card.className = "sl-seccard on";
1537
+ card.setAttribute("role", "dialog");
1538
+ card.setAttribute("aria-label", t2("picker.sectionSummaryAria", { label: summary.label }));
1539
+ const mix = summary.categories.map((c) => {
1540
+ const dim = this.priceBandKeys != null && !this.priceBandKeys.has(c.key);
1541
+ return `<span class="sl-seccard-mix-item${dim ? " sl-dim" : ""}"><span class="sl-seccard-mix-dot" style="background:${c.color}"></span>${c.label} <span class="sl-seccard-mix-price">${this.money(c.price)}</span></span>`;
1542
+ }).join("");
1543
+ card.innerHTML = `<div class="sl-seccard-head"><span class="sl-seccard-dot" style="background:${summary.color}"></span><span class="sl-seccard-name">${summary.label}</span>` + (summary.categories.length ? `<span class="sl-seccard-price">${priceLabel}</span>` : "") + xBtn + `</div><div class="sl-seccard-zone">${summary.zoneLabel ? `${summary.zoneLabel} \xB7 ` : ""}<span class="sl-seccard-left">${leftLabel}</span></div>` + (mix ? `<div class="sl-seccard-mix">${mix}</div>` : "") + `<div class="sl-seccard-foot"><button type="button" class="sl-seccard-overview">\u2190 ${t2("picker.overview")}</button><span class="sl-seccard-hint">${t2("picker.tapSeatHint")}</span></div>`;
1544
+ card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
1545
+ card.querySelector(".sl-seccard-overview").addEventListener("click", () => this.controller.overview());
1546
+ (this.regions["top-center"] ?? this.els.map).appendChild(card);
1547
+ }
1063
1548
  this.secCardEl = card;
1064
1549
  }
1550
+ /** Collapse the expanded card to its slim pill (seat-picking started). */
1551
+ collapseSectionCard() {
1552
+ if (!this.secCardEl || this.secCardCollapsed || !this.lastSection) return;
1553
+ if (this.root?.dataset.layout === "narrow") return;
1554
+ this.secCardCollapsed = true;
1555
+ this.renderSectionCard(this.lastSection);
1556
+ }
1557
+ /**
1558
+ * onViewChange hook for the card. The focus glide's own settle (within the
1559
+ * grace window) enforces the ~25% coverage rule with the FINAL viewport; any
1560
+ * later pan/zoom means seat-picking has begun → collapse to the pill.
1561
+ */
1562
+ sectionCardOnView() {
1563
+ if (!this.secCardEl || this.secCardCollapsed || !this.lastSection) return;
1564
+ if (this.root?.dataset.layout === "narrow") return;
1565
+ if (Date.now() - this.secCardShownAt < 1400) {
1566
+ if (this.sectionCardCoverage() > 0.25) this.collapseSectionCard();
1567
+ return;
1568
+ }
1569
+ this.collapseSectionCard();
1570
+ }
1571
+ /** Fraction of the focused section's on-screen bbox covered by the card. */
1572
+ sectionCardCoverage() {
1573
+ const card = this.secCardEl;
1574
+ const sec = this.lastSection;
1575
+ if (!card || !sec || !this.els.map) return 0;
1576
+ const outline = this.activeFloorObjects().find((o) => o.type === "section" && o.id === sec.id)?.outline;
1577
+ if (!outline || outline.length < 3) return 0;
1578
+ const pts = outline.map((p) => this.controller.worldToScreen(p));
1579
+ const xs = pts.map((p) => p.x);
1580
+ const ys = pts.map((p) => p.y);
1581
+ const bx = Math.min(...xs);
1582
+ const by = Math.min(...ys);
1583
+ const bw = Math.max(...xs) - bx;
1584
+ const bh = Math.max(...ys) - by;
1585
+ if (bw <= 0 || bh <= 0) return 0;
1586
+ const mapR = this.els.map.getBoundingClientRect();
1587
+ const cr = card.getBoundingClientRect();
1588
+ const cx = cr.left - mapR.left;
1589
+ const cy = cr.top - mapR.top;
1590
+ const ox = Math.max(0, Math.min(cx + cr.width, bx + bw) - Math.max(cx, bx));
1591
+ const oy = Math.max(0, Math.min(cy + cr.height, by + bh) - Math.max(cy, by));
1592
+ return ox * oy / (bw * bh);
1593
+ }
1065
1594
  /** aria-live readout when keyboard focus lands on a seat. */
1066
1595
  announceSeat(seat) {
1067
1596
  if (!this.srEl) return;
@@ -1236,7 +1765,8 @@ var SeatPicker = class _SeatPicker {
1236
1765
  const left = this.controller.categoryAvailability();
1237
1766
  this.els.prices.innerHTML = doc.categories.map((c) => {
1238
1767
  const price = c.tiers?.length ? c.tiers[0].price : c.price;
1239
- return `<div class="sl-price-row" data-cat="${c.key}"><span class="sl-dot" style="background:${c.color}"></span><span class="sl-price-label">${c.label}</span><span class="sl-price-left">${left[c.key] ?? 0} left</span>` + (price != null ? `<span class="sl-price-amt">${this.money(price)}</span>` : "") + `</div>`;
1768
+ const dim = this.priceBandKeys != null && !this.priceBandKeys.has(c.key);
1769
+ return `<div class="sl-price-row${dim ? " sl-dim" : ""}" data-cat="${c.key}"><span class="sl-dot" style="background:${c.color}"></span><span class="sl-price-label">${c.label}</span><span class="sl-price-left">${left[c.key] ?? 0} left</span>` + (price != null ? `<span class="sl-price-amt">${this.money(price)}</span>` : "") + `</div>`;
1240
1770
  }).join("");
1241
1771
  this.els.prices.querySelectorAll(".sl-price-row").forEach((row) => {
1242
1772
  row.addEventListener("mouseenter", () => this.controller.getRenderer()?.setCategoryHighlight?.(row.dataset.cat ?? null));
@@ -1342,6 +1872,18 @@ var SeatPicker = class _SeatPicker {
1342
1872
  const cta = this.els.cta;
1343
1873
  cta.disabled = count === 0;
1344
1874
  cta.textContent = this.hold ? "Continue to checkout" : count ? "Hold seats & checkout" : "Select seats";
1875
+ if (this.els.peek) {
1876
+ if (count) {
1877
+ this.els.peek.innerHTML = `<span>${count} ${count === 1 ? "ticket" : "tickets"} \xB7 ${this.money(total)}</span><span class="go">${this.hold ? "Continue \u2192" : "Review \u2192"}</span>`;
1878
+ } else {
1879
+ const prices = (this.controller.doc?.categories ?? []).map((c) => this.catPrice(c)).filter((p) => p != null);
1880
+ this.els.peek.innerHTML = (prices.length ? `<span>From ${this.money(Math.min(...prices))}</span>` : "<span>Pick your seats</span>") + `<span class="sub">\xB7 Best available</span><span class="go">\u2191</span>`;
1881
+ }
1882
+ }
1883
+ if (this.root?.dataset.layout === "narrow" && count > 0 && this.lastTrayCount === 0) {
1884
+ this.root.dataset.sheet = "open";
1885
+ }
1886
+ this.lastTrayCount = count;
1345
1887
  this.opts.onSelectionChange?.(seats);
1346
1888
  }
1347
1889
  async handleCta() {