@seatlayer/js 0.36.3 → 0.37.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
@@ -6547,6 +6547,11 @@ var ManageApi = class {
6547
6547
  var POLL_MS = 1e4;
6548
6548
  var MAX_FLAGS = 8;
6549
6549
  var SEAT_LIST_PAGE = 300;
6550
+ var PREVIEW_ELIGIBLE_FILL = "#6e7bff";
6551
+ var PREVIEW_ELIGIBLE_STROKE = "#b9c0ff";
6552
+ var PREVIEW_UNAVAILABLE_FILL = "#303846";
6553
+ var PREVIEW_UNAVAILABLE_STROKE = "#4b5669";
6554
+ var ALLOCATION_STROKE = "#101723";
6550
6555
  var CHANNELS_CSS = `
6551
6556
  .slm{--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
6552
6557
  --slm-mo-out:cubic-bezier(.2,.8,.2,1);--slm-mo-in-out:cubic-bezier(.4,0,.2,1);--slm-mo-exit:cubic-bezier(.4,0,1,1);
@@ -6560,6 +6565,8 @@ var CHANNELS_CSS = `
6560
6565
  background:rgba(14,16,23,.88);border:1px solid var(--slm-line);font-size:10px;font-weight:800;letter-spacing:.04em;
6561
6566
  transform:translate(-50%,-50%);white-space:nowrap}
6562
6567
  .slm-ch-flag .mk{width:14px;height:14px;border-radius:4px;display:grid;place-items:center;font-size:8.5px;font-weight:800;color:#0e1017}
6568
+ .slm-ch-section-target{position:absolute;pointer-events:auto;padding:0;border:0;border-radius:8px;background:transparent;cursor:zoom-in}
6569
+ .slm-ch-section-target:focus-visible{outline:2px solid var(--slm-accent);outline-offset:-3px;background:color-mix(in srgb,var(--slm-accent) 12%,transparent)}
6563
6570
 
6564
6571
  /* preview banner \u2014 raised with the organizer chrome dim, as one transition */
6565
6572
  .slm-ch-banner{position:absolute;left:50%;top:14px;z-index:6;display:flex;align-items:center;gap:9px;padding:8px 14px;
@@ -6579,7 +6586,7 @@ var CHANNELS_CSS = `
6579
6586
  transition:transform var(--slm-mo-slow) var(--slm-mo-out),opacity var(--slm-mo-base) var(--slm-mo-out)}
6580
6587
  .slm-ch-staged.on{transform:none;opacity:1}
6581
6588
  .slm-ch-staged.done{background:rgba(31,122,77,.96);border-color:#1f7a4d}
6582
- .slm-ch-staged.shake{animation:slm-ch-shake 320ms var(--slm-mo-in-out) 2}
6589
+ .slm-ch-staged.shake{animation:slm-ch-shake var(--slm-mo-slow) var(--slm-mo-in-out) 2}
6583
6590
  .slm-ch-staged b{font-variant-numeric:tabular-nums}
6584
6591
  .slm-ch-staged .grow{flex:1}
6585
6592
  .slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
@@ -6596,6 +6603,11 @@ var CHANNELS_CSS = `
6596
6603
  .slm-ch-viewseg button{flex:1;padding:6px 8px;min-height:34px;border-radius:7px;font-size:11px;font-weight:800;color:var(--slm-muted)}
6597
6604
  .slm-ch-viewseg button.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
6598
6605
  .slm-ch-viewseg button:disabled{opacity:.5;cursor:not-allowed}
6606
+ .slm-ch-mapnav{margin:-2px 0 12px;padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
6607
+ .slm-ch-mapnav-head{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--slm-muted)}
6608
+ .slm-ch-mapnav-head button{color:var(--slm-accent);font-size:11px;font-weight:800;letter-spacing:0;text-transform:none;min-height:30px}
6609
+ .slm-ch-mapnav .slm-ch-viewseg{margin:8px 0 5px}
6610
+ .slm-ch-mapnav p{margin:0;font-size:11px;line-height:1.45;color:var(--slm-muted)}
6599
6611
  .slm-ch-list{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
6600
6612
  .slm-ch-row{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
6601
6613
  text-align:left;width:100%;display:block;transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
@@ -6616,7 +6628,7 @@ var CHANNELS_CSS = `
6616
6628
  font-variant-numeric:tabular-nums}
6617
6629
  .slm-ch-counts b{color:var(--slm-text);font-weight:800}
6618
6630
  .slm-ch-counts .free b{color:#5bd39b}
6619
- .slm-ch-counts b.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6631
+ .slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6620
6632
  @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
6621
6633
  .slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
6622
6634
  .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
@@ -6626,12 +6638,13 @@ var CHANNELS_CSS = `
6626
6638
  font-weight:800;color:#0e1017}
6627
6639
  .slm-ch-selsrc-row b{min-width:30px;text-align:right;font-weight:800}
6628
6640
  .slm-ch-selsrc-row span{color:var(--slm-muted)}
6629
- .slm-ch-selnum.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6641
+ .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6630
6642
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
6631
6643
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
6632
6644
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
6633
6645
  line-height:1.5;margin-bottom:12px}
6634
6646
  .slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
6647
+ .slm-ch-alert.info{background:rgba(110,123,255,.12);border:1px solid rgba(110,123,255,.44);color:#c5cbff}
6635
6648
  .slm-ch-alert.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.45);color:#f1a4a6}
6636
6649
  .slm-ch-alert b{color:#fff}
6637
6650
  .slm-ch-alert button{display:block;margin-top:6px;color:#fff;font-weight:800;min-height:36px}
@@ -6778,6 +6791,10 @@ var ChannelsMode = class {
6778
6791
  this.loadError = null;
6779
6792
  this.loading = true;
6780
6793
  this.view = "inspect";
6794
+ /** Pan is intentionally the initial desktop interaction. Assignment's
6795
+ * marquee is powerful, but must never make an organizer lose map navigation. */
6796
+ this.mapIntent = "pan";
6797
+ this.focusedSectionId = null;
6781
6798
  this.showArchived = false;
6782
6799
  this.detailChannelId = null;
6783
6800
  this.targetChannelId = "";
@@ -6794,6 +6811,15 @@ var ChannelsMode = class {
6794
6811
  this.links = [];
6795
6812
  this.linksChannelId = null;
6796
6813
  this.linksState = "idle";
6814
+ /**
6815
+ * Monotonic read generations — one for the channel list + allocation, one for
6816
+ * the open channel's links. Reads are concurrent (a 10s poll versus a
6817
+ * mutation's own reload), and the network does not promise to answer them in
6818
+ * order. Only the NEWEST read of each kind may write to state; an older
6819
+ * answer that arrives late is dropped, never painted.
6820
+ */
6821
+ this.listSeq = 0;
6822
+ this.linksSeq = 0;
6797
6823
  this.previewAudience = [];
6798
6824
  this.previewIncludePublic = false;
6799
6825
  this.previewProjection = null;
@@ -6820,10 +6846,14 @@ var ChannelsMode = class {
6820
6846
  enter() {
6821
6847
  if (this.active) return;
6822
6848
  this.active = true;
6849
+ this.mapIntent = "pan";
6850
+ this.focusedSectionId = null;
6823
6851
  this.ensureLayer();
6824
6852
  this.host.root.classList.add("ch-mode");
6825
6853
  this.applySheetClasses();
6854
+ if (this.host.sections().length > 1) this.host.showSectionOverview();
6826
6855
  this.paintRail();
6856
+ this.onInteractionChange?.();
6827
6857
  void this.refresh();
6828
6858
  this.pollTimer = setInterval(() => {
6829
6859
  void this.refresh({ quiet: true });
@@ -6875,6 +6905,17 @@ var ChannelsMode = class {
6875
6905
  canSelect() {
6876
6906
  return this.caps.manage && this.view === "inspect";
6877
6907
  }
6908
+ /** Bulk seat assignment is explicit. In Pan map, clicks can still inspect a
6909
+ * single seat, while a primary-button drag always moves the camera. */
6910
+ usesMarqueeSelection() {
6911
+ return this.canSelect() && this.mapIntent === "assign";
6912
+ }
6913
+ /** The renderer calls this when the organizer opens a section from overview. */
6914
+ handleSectionFocus(sectionId) {
6915
+ if (!this.active) return;
6916
+ this.focusedSectionId = sectionId;
6917
+ this.paintRail();
6918
+ }
6878
6919
  /**
6879
6920
  * Organizer realtime integration point. M5 ships a per-scope socket for
6880
6921
  * buyers; the organizer channel-count stream is a later milestone. When it
@@ -6902,22 +6943,28 @@ var ChannelsMode = class {
6902
6943
  // ---- data -----------------------------------------------------------------
6903
6944
  async refresh(opts = {}) {
6904
6945
  if (!this.caps.view) return;
6946
+ const seq = ++this.listSeq;
6947
+ const superseded = () => seq !== this.listSeq;
6905
6948
  try {
6906
6949
  const list = await this.host.api.channels(this.host.eventKey, { includeArchived: this.showArchived });
6950
+ if (superseded()) return;
6907
6951
  this.list = list;
6908
6952
  this.assignmentVersion = list.assignmentVersion;
6909
6953
  this.loadError = null;
6910
6954
  if (!this.targetChannelId) {
6911
6955
  this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
6912
6956
  }
6913
- await this.loadAllocation();
6957
+ await this.loadAllocation(seq);
6958
+ if (superseded()) return;
6914
6959
  if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
6960
+ if (superseded()) return;
6915
6961
  this.loading = false;
6916
6962
  if (this.active) {
6917
6963
  this.paintRail();
6918
6964
  this.paintOverlay();
6919
6965
  }
6920
6966
  } catch (err) {
6967
+ if (superseded()) return;
6921
6968
  this.loading = false;
6922
6969
  if (err instanceof ManageApiError && err.status === 403) {
6923
6970
  this.caps = { view: false, manage: false };
@@ -6929,10 +6976,11 @@ var ChannelsMode = class {
6929
6976
  }
6930
6977
  /** Walk every allocation page. Bounded by the event's seat count, and the
6931
6978
  * server caps each page, so an arena is a handful of round trips. */
6932
- async loadAllocation() {
6979
+ async loadAllocation(seq) {
6933
6980
  const next = /* @__PURE__ */ new Map();
6934
6981
  let afterLabel;
6935
6982
  for (let page = 0; page < 200; page += 1) {
6983
+ if (seq !== void 0 && seq !== this.listSeq) return;
6936
6984
  const res = await this.host.api.channelAllocation(this.host.eventKey, {
6937
6985
  afterLabel,
6938
6986
  limit: 1e3
@@ -6944,6 +6992,7 @@ var ChannelsMode = class {
6944
6992
  if (!res.nextAfterLabel) break;
6945
6993
  afterLabel = res.nextAfterLabel;
6946
6994
  }
6995
+ if (seq !== void 0 && seq !== this.listSeq) return;
6947
6996
  this.allocation = next;
6948
6997
  }
6949
6998
  // ---- lookups --------------------------------------------------------------
@@ -7010,9 +7059,10 @@ var ChannelsMode = class {
7010
7059
  * Repaint the allocation (or preview) overlay in ONE canvas pass.
7011
7060
  *
7012
7061
  * Channel identity on the map is a fill in the administrative color PLUS the
7013
- * letter flags below — never color alone. Physical status keeps its own cue:
7014
- * only FREE units take a channel fill, so sold/held/blocked seats still read
7015
- * exactly as they do in every other tool.
7062
+ * letter flags below — never color alone. In buyer preview the map instead
7063
+ * uses two explicit, channel-neutral access states. Physical status keeps its
7064
+ * own cue: only FREE units are repainted, so sold/held/blocked seats still
7065
+ * read exactly as they do in every other tool.
7016
7066
  */
7017
7067
  paintOverlay() {
7018
7068
  const canvas = this.canvas;
@@ -7037,11 +7087,14 @@ var ChannelsMode = class {
7037
7087
  if (!ctx) return;
7038
7088
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
7039
7089
  ctx.clearRect(0, 0, width, height);
7090
+ const seatDetail = this.host.isSeatDetail();
7040
7091
  const size = Math.max(3, this.host.seatPixelSize());
7041
7092
  const half = size / 2;
7042
7093
  const projection = this.view === "preview" ? this.previewProjection : null;
7043
7094
  const eligible = projection ? new Set(projection.available === false ? [] : projection.eligible ?? []) : null;
7044
7095
  const clusters = /* @__PURE__ */ new Map();
7096
+ const sectionTargets = !seatDetail && this.host.sections().length > 1 ? /* @__PURE__ */ new Map() : null;
7097
+ const previewSections = this.view === "preview" && seatDetail && size <= 15 ? /* @__PURE__ */ new Map() : null;
7045
7098
  for (const seat of this.host.seats()) {
7046
7099
  const status = this.host.statusOf(seat.label) ?? "free";
7047
7100
  const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
@@ -7052,24 +7105,150 @@ var ChannelsMode = class {
7052
7105
  cluster.n += 1;
7053
7106
  clusters.set(channelId, cluster);
7054
7107
  }
7108
+ if (!seatDetail) {
7109
+ const section = this.host.sectionOfLabel(seat.label);
7110
+ const point2 = section ? this.host.worldToScreen({ x: seat.x, y: seat.y }) : null;
7111
+ if (sectionTargets && section && point2) {
7112
+ const bounds = sectionTargets.get(section.id) ?? {
7113
+ label: section.label,
7114
+ minX: point2.x,
7115
+ minY: point2.y,
7116
+ maxX: point2.x,
7117
+ maxY: point2.y
7118
+ };
7119
+ bounds.minX = Math.min(bounds.minX, point2.x);
7120
+ bounds.minY = Math.min(bounds.minY, point2.y);
7121
+ bounds.maxX = Math.max(bounds.maxX, point2.x);
7122
+ bounds.maxY = Math.max(bounds.maxY, point2.y);
7123
+ sectionTargets.set(section.id, bounds);
7124
+ }
7125
+ continue;
7126
+ }
7055
7127
  if (status !== "free") continue;
7056
7128
  let fill = null;
7129
+ let stroke = null;
7057
7130
  if (this.view === "preview") {
7058
- fill = eligible ? eligible.has(seat.label) ? null : "#3a4051" : null;
7131
+ if (eligible?.has(seat.label)) {
7132
+ fill = PREVIEW_ELIGIBLE_FILL;
7133
+ stroke = PREVIEW_ELIGIBLE_STROKE;
7134
+ } else {
7135
+ fill = PREVIEW_UNAVAILABLE_FILL;
7136
+ stroke = PREVIEW_UNAVAILABLE_STROKE;
7137
+ }
7059
7138
  } else if (channelId !== PUBLIC_CHANNEL_ID) {
7060
7139
  fill = this.markerFor(channelId).color;
7140
+ stroke = ALLOCATION_STROKE;
7061
7141
  }
7062
7142
  if (!fill) continue;
7063
7143
  const point = this.host.worldToScreen({ x: seat.x, y: seat.y });
7064
7144
  if (!point) continue;
7065
7145
  if (point.x < -size || point.y < -size || point.x > width + size || point.y > height + size) continue;
7146
+ if (previewSections) {
7147
+ const section = this.host.sectionOfLabel(seat.label);
7148
+ if (section) {
7149
+ const bounds = previewSections.get(section.id) ?? {
7150
+ label: section.label,
7151
+ minX: point.x,
7152
+ minY: point.y,
7153
+ maxX: point.x,
7154
+ maxY: point.y
7155
+ };
7156
+ bounds.minX = Math.min(bounds.minX, point.x);
7157
+ bounds.minY = Math.min(bounds.minY, point.y);
7158
+ bounds.maxX = Math.max(bounds.maxX, point.x);
7159
+ bounds.maxY = Math.max(bounds.maxY, point.y);
7160
+ previewSections.set(section.id, bounds);
7161
+ }
7162
+ }
7066
7163
  ctx.fillStyle = fill;
7067
- ctx.globalAlpha = this.view === "preview" ? 0.9 : 0.85;
7068
- ctx.fillRect(point.x - half, point.y - half, size, size);
7164
+ if (this.view === "preview" || channelId !== PUBLIC_CHANNEL_ID) {
7165
+ const radius = Math.max(2, half + Math.min(1.5, half * 0.06));
7166
+ ctx.globalAlpha = 1;
7167
+ ctx.beginPath();
7168
+ ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
7169
+ ctx.fill();
7170
+ ctx.strokeStyle = stroke ?? fill;
7171
+ ctx.lineWidth = this.view === "preview" ? Math.max(1, Math.min(1.75, size * 0.13)) : Math.max(1, Math.min(1.5, size * 0.1));
7172
+ ctx.stroke();
7173
+ if (this.view === "preview" && eligible?.has(seat.label) && size >= 22) {
7174
+ this.paintPreviewSeatLabel(ctx, seat.label, point.x, point.y, radius);
7175
+ }
7176
+ } else {
7177
+ ctx.globalAlpha = 0.85;
7178
+ ctx.fillRect(point.x - half, point.y - half, size, size);
7179
+ }
7069
7180
  }
7070
7181
  ctx.globalAlpha = 1;
7182
+ if (previewSections) this.paintPreviewSectionLabels(ctx, previewSections);
7183
+ this.paintSectionTargets(sectionTargets);
7071
7184
  this.paintFlags(clusters);
7072
7185
  }
7186
+ /** Draw an eligible seat's actual chart label without inventing a new buyer
7187
+ * identifier. Long labels scale down and are omitted rather than overflowing
7188
+ * into an adjacent seat. */
7189
+ paintPreviewSeatLabel(ctx, label, x, y, radius) {
7190
+ const maxWidth = radius * 1.55;
7191
+ let fontSize = Math.min(13, Math.max(7, radius * 0.55));
7192
+ const minFontSize = 6;
7193
+ while (fontSize >= minFontSize) {
7194
+ ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
7195
+ if (ctx.measureText(label).width <= maxWidth) break;
7196
+ fontSize -= 0.5;
7197
+ }
7198
+ if (fontSize < minFontSize) return;
7199
+ ctx.fillStyle = "#ffffff";
7200
+ ctx.textAlign = "center";
7201
+ ctx.textBaseline = "middle";
7202
+ ctx.fillText(label, x, y);
7203
+ }
7204
+ /** A section overview is a navigation map. These transparent, keyboardable
7205
+ * hit areas sit over the renderer's section shells so both mouse and keyboard
7206
+ * always take the organizer into the real focused-section camera state. */
7207
+ paintSectionTargets(sections) {
7208
+ const layer = this.layer;
7209
+ if (!layer) return;
7210
+ layer.querySelectorAll(".slm-ch-section-target").forEach((el) => el.remove());
7211
+ if (!sections) return;
7212
+ for (const [id, section] of sections) {
7213
+ const width = section.maxX - section.minX;
7214
+ const height = section.maxY - section.minY;
7215
+ if (width < 20 || height < 20) continue;
7216
+ const target = document.createElement("button");
7217
+ target.type = "button";
7218
+ target.className = "slm-ch-section-target";
7219
+ target.style.left = `${section.minX - 8}px`;
7220
+ target.style.top = `${section.minY - 8}px`;
7221
+ target.style.width = `${width + 16}px`;
7222
+ target.style.height = `${height + 16}px`;
7223
+ target.setAttribute("aria-label", `Open ${section.label} seats`);
7224
+ target.addEventListener("click", () => {
7225
+ this.focusedSectionId = id;
7226
+ this.host.focusSection(id);
7227
+ this.paintRail();
7228
+ });
7229
+ layer.appendChild(target);
7230
+ }
7231
+ }
7232
+ /** Keep renderer section names legible over a dense, zoomed-out preview. */
7233
+ paintPreviewSectionLabels(ctx, sections) {
7234
+ for (const section of sections.values()) {
7235
+ const width = section.maxX - section.minX;
7236
+ const height = section.maxY - section.minY;
7237
+ if (width < 52 || height < 26) continue;
7238
+ const centerX = (section.minX + section.maxX) / 2;
7239
+ const centerY = (section.minY + section.maxY) / 2;
7240
+ const fontSize = Math.max(11, Math.min(15, height * 0.16));
7241
+ ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
7242
+ const labelWidth = Math.min(width - 8, ctx.measureText(section.label).width + 18);
7243
+ const labelHeight = fontSize + 10;
7244
+ ctx.fillStyle = "rgba(11, 16, 28, .88)";
7245
+ ctx.fillRect(centerX - labelWidth / 2, centerY - labelHeight / 2, labelWidth, labelHeight);
7246
+ ctx.fillStyle = "#f8fafc";
7247
+ ctx.textAlign = "center";
7248
+ ctx.textBaseline = "middle";
7249
+ ctx.fillText(section.label, centerX, centerY);
7250
+ }
7251
+ }
7073
7252
  /** Letter flags at each channel's centroid — the non-color identity cue. */
7074
7253
  paintFlags(clusters) {
7075
7254
  const layer = this.layer;
@@ -7135,7 +7314,7 @@ var ChannelsMode = class {
7135
7314
  });
7136
7315
  });
7137
7316
  }
7138
- setBanner(on, name = "") {
7317
+ setBanner(on, name = "", eligibleSeats) {
7139
7318
  const banner = this.bannerEl;
7140
7319
  if (!banner) return;
7141
7320
  this.host.root.classList.toggle("ch-preview", on);
@@ -7145,8 +7324,9 @@ var ChannelsMode = class {
7145
7324
  return;
7146
7325
  }
7147
7326
  const marker = this.previewAudience.length === 1 ? this.markerFor(this.previewAudience[0]) : { color: "var(--slm-accent)", letter: "" };
7327
+ const availability = eligibleSeats == null ? "" : ` \xB7 ${eligibleSeats.toLocaleString()} ${eligibleSeats === 1 ? "seat" : "seats"} available now`;
7148
7328
  banner.innerHTML = `<span class="dot" style="background:${esc(marker.color)}"></span>
7149
- Previewing buyer access \xB7 ${esc(name)} \xB7 read-only
7329
+ Previewing buyer access \xB7 ${esc(name)}${availability} \xB7 read-only
7150
7330
  <button type="button" data-ch-act="exit-preview">Exit preview</button>`;
7151
7331
  banner.classList.add("on");
7152
7332
  banner.querySelector('[data-ch-act="exit-preview"]')?.addEventListener("click", () => this.setView("inspect"));
@@ -7181,6 +7361,8 @@ var ChannelsMode = class {
7181
7361
  includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
7182
7362
  });
7183
7363
  this.previewSupported = true;
7364
+ const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
7365
+ this.setBanner(true, names, eligibleSeats);
7184
7366
  } catch (err) {
7185
7367
  const status = err instanceof ManageApiError ? err.status : 0;
7186
7368
  this.previewSupported = !(status === 404 || status === 405 || status === 501);
@@ -7231,6 +7413,22 @@ var ChannelsMode = class {
7231
7413
  aria-pressed="${this.view === "inspect"}">Inspect allocation</button>
7232
7414
  <button type="button" class="${previewOn.trim()}" data-ch-view="preview"
7233
7415
  aria-pressed="${this.view === "preview"}">Preview buyer access</button>
7416
+ </div>${this.mapNavigationHtml()}`;
7417
+ }
7418
+ mapNavigationHtml() {
7419
+ if (this.host.sections().length < 2) return "";
7420
+ const focused = this.focusedSectionId ? this.host.sections().find((section) => section.id === this.focusedSectionId)?.label ?? "section" : null;
7421
+ const panOn = this.mapIntent === "pan" ? " on" : "";
7422
+ const assignOn = this.mapIntent === "assign" ? " on" : "";
7423
+ const intent = this.view === "inspect" && this.caps.manage ? `<div class="slm-ch-viewseg" role="group" aria-label="Map interaction">
7424
+ <button type="button" class="${panOn.trim()}" data-ch-map="pan" aria-pressed="${this.mapIntent === "pan"}">Pan map</button>
7425
+ <button type="button" class="${assignOn.trim()}" data-ch-map="assign" aria-pressed="${this.mapIntent === "assign"}">Assign seats</button>
7426
+ </div>
7427
+ <p>${this.mapIntent === "pan" ? "Drag to explore. Click a section to open its seats." : "Drag across seats to select them for allocation."}</p>` : "<p>Drag to explore. Click a section to open its seats.</p>";
7428
+ return `<div class="slm-ch-mapnav">
7429
+ <div class="slm-ch-mapnav-head"><span>${focused ? `Viewing ${esc(focused)}` : "Section overview"}</span>
7430
+ <button type="button" data-ch-act="sections">All sections</button></div>
7431
+ ${intent}
7234
7432
  </div>`;
7235
7433
  }
7236
7434
  countsHtml(counts, key) {
@@ -7369,6 +7567,8 @@ var ChannelsMode = class {
7369
7567
  */
7370
7568
  async loadLinks(channelId) {
7371
7569
  if (!this.caps.view) return;
7570
+ const seq = ++this.linksSeq;
7571
+ const superseded = () => seq !== this.linksSeq || this.linksChannelId !== channelId;
7372
7572
  if (this.linksChannelId !== channelId) {
7373
7573
  this.links = [];
7374
7574
  this.linksChannelId = channelId;
@@ -7376,11 +7576,11 @@ var ChannelsMode = class {
7376
7576
  }
7377
7577
  try {
7378
7578
  const res = await this.host.api.accessLinks(this.host.eventKey, channelId);
7379
- if (this.linksChannelId !== channelId) return;
7579
+ if (superseded()) return;
7380
7580
  this.links = res.links ?? [];
7381
7581
  this.linksState = "ready";
7382
7582
  } catch (err) {
7383
- if (this.linksChannelId !== channelId) return;
7583
+ if (superseded()) return;
7384
7584
  const status = err instanceof ManageApiError ? err.status : 0;
7385
7585
  this.links = [];
7386
7586
  this.linksState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
@@ -7457,9 +7657,9 @@ var ChannelsMode = class {
7457
7657
  const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
7458
7658
  <span><b>This private sale is not available.</b> ${esc((this.previewProjection.unavailable ?? []).map((entry) => `${this.nameOf(entry.channelId) ?? "This channel"} is ${entry.state}`).join("; ") || "The audience cannot buy right now")}.
7459
7659
  A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
7460
- const counts = this.previewProjection?.counts;
7461
- const summary = counts?.eligible != null && this.previewProjection?.available !== false ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>${counts.eligible.toLocaleString()} seats are buyable
7462
- through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
7660
+ const eligibleSeats = this.previewProjection?.counts?.eligible ?? this.previewProjection?.eligible?.length;
7661
+ const summary = eligibleSeats != null && this.previewProjection?.available !== false ? `<div class="slm-ch-alert info"><span>\u2713</span><span><b>${eligibleSeats.toLocaleString()} ${eligibleSeats === 1 ? "seat is" : "seats are"} available now.</b>
7662
+ This is the exact buyer-visible allocation.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this access." : ""}</span></div>` : "";
7463
7663
  const includePublic = isPublicChannelId(current) ? "" : `
7464
7664
  <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
7465
7665
  <input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
@@ -7490,6 +7690,13 @@ var ChannelsMode = class {
7490
7690
  rail.querySelectorAll("[data-ch-view]").forEach((button) => {
7491
7691
  button.addEventListener("click", () => this.setView(button.dataset.chView));
7492
7692
  });
7693
+ rail.querySelectorAll("[data-ch-map]").forEach((button) => {
7694
+ button.addEventListener("click", () => {
7695
+ this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
7696
+ this.paintRail();
7697
+ this.onInteractionChange?.();
7698
+ });
7699
+ });
7493
7700
  rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
7494
7701
  button.addEventListener("click", () => {
7495
7702
  const channelId = button.dataset.chDetail;
@@ -7540,6 +7747,11 @@ var ChannelsMode = class {
7540
7747
  }
7541
7748
  railAction(action) {
7542
7749
  switch (action) {
7750
+ case "sections":
7751
+ this.focusedSectionId = null;
7752
+ this.host.showSectionOverview();
7753
+ this.paintRail();
7754
+ break;
7543
7755
  case "create":
7544
7756
  this.openDialog({ kind: "create" });
7545
7757
  break;
@@ -7714,7 +7926,7 @@ var ChannelsMode = class {
7714
7926
  <label>Marker</label>
7715
7927
  <div style="display:flex;gap:8px;align-items:center">
7716
7928
  <span class="slm-ch-mk" data-ch-marker style="background:${esc(suggestion.color)};width:28px;height:28px;font-size:13px">${esc(suggestion.letter)}</span>
7717
- <span class="slm-note" style="margin:0">Letter + colour suggested from the name. Buyers never see either.</span>
7929
+ <span class="slm-note" style="margin:0">Letter comes from the name; colour is chosen automatically from the next available palette. Buyers never see either.</span>
7718
7930
  </div>
7719
7931
  </div>
7720
7932
  <div class="slm-field">
@@ -8065,6 +8277,24 @@ var ChannelsMode = class {
8065
8277
  await this.loadLinks(channelId);
8066
8278
  this.paintRail();
8067
8279
  }
8280
+ /**
8281
+ * The reload EVERY link mutation owes the panel.
8282
+ *
8283
+ * A create/rotate/revoke changes two things the detail panel renders: the
8284
+ * channel's access line (the server sets `access.intent` on create, and clears
8285
+ * it when the last live link goes) and the link status list. Both are re-read
8286
+ * here and the rail repainted, so the panel the organizer is already looking
8287
+ * at is current the moment the mutation lands — no reload, and no dependence
8288
+ * on HOW the one-time reveal was dismissed (the button, Escape, or never).
8289
+ */
8290
+ async reloadAfterLinkChange(channelId) {
8291
+ if (this.detailChannelId === channelId) {
8292
+ await this.refresh({ quiet: true });
8293
+ return;
8294
+ }
8295
+ await this.loadLinks(channelId);
8296
+ if (this.active) this.paintRail();
8297
+ }
8068
8298
  linkById(linkId) {
8069
8299
  return this.links.find((link) => link.id === linkId) ?? null;
8070
8300
  }
@@ -8158,7 +8388,7 @@ var ChannelsMode = class {
8158
8388
  try {
8159
8389
  const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
8160
8390
  this.revealLink(reveal, { channelId });
8161
- await this.refresh({ quiet: true });
8391
+ await this.reloadAfterLinkChange(channelId);
8162
8392
  } catch (err) {
8163
8393
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8164
8394
  if (!(err instanceof ManageApiError)) this.host.onError(err);
@@ -8216,9 +8446,6 @@ var ChannelsMode = class {
8216
8446
  }
8217
8447
  selectSecret(dialog);
8218
8448
  });
8219
- dialog.querySelector("[data-ch-lk-done]")?.addEventListener("click", () => {
8220
- void this.loadLinks(opts.channelId).then(() => this.paintRail());
8221
- });
8222
8449
  });
8223
8450
  this.announce("Your hosted access link is ready and is shown once.");
8224
8451
  }
@@ -8284,6 +8511,7 @@ var ChannelsMode = class {
8284
8511
  endActiveSessions
8285
8512
  );
8286
8513
  this.revealLink(reveal, { channelId, rotated: true });
8514
+ await this.reloadAfterLinkChange(channelId);
8287
8515
  } catch (err) {
8288
8516
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8289
8517
  if (!(err instanceof ManageApiError)) this.host.onError(err);
@@ -8326,9 +8554,7 @@ var ChannelsMode = class {
8326
8554
  endActiveSessions
8327
8555
  );
8328
8556
  this.closeDialog();
8329
- await this.loadLinks(channelId);
8330
- await this.refresh({ quiet: true });
8331
- this.paintRail();
8557
+ await this.reloadAfterLinkChange(channelId);
8332
8558
  this.host.toast(res.endedSessions ? `Link revoked. ${res.endedSessions.toLocaleString()} buyer${res.endedSessions === 1 ? "" : "s"} lost access.` : "Link revoked. It no longer opens for anyone.", "ok");
8333
8559
  } catch (err) {
8334
8560
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
@@ -8413,9 +8639,17 @@ var LEGEND = [
8413
8639
  { key: "booked", label: "Booked", color: "#22a06b" },
8414
8640
  { key: "blocked", label: "Blocked", color: "#8b94ac" }
8415
8641
  ];
8416
- var CSS2 = `
8642
+ var MANAGER_CSS = `
8417
8643
  .slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:480px;overflow:hidden;
8418
- background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius)}
8644
+ background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius);
8645
+ /* Motion tokens (motion-system \xA72), declared by the cockpit ROOT rather than
8646
+ borrowed from CHANNELS_CSS. The base cockpit animates whether or not
8647
+ Channels mode is in use, so owning its own tokens is what stops a token
8648
+ edit from silently changing only half the surface. Channels mode declares
8649
+ the identical values so an embed of it stays self-contained. */
8650
+ --slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
8651
+ --slm-mo-out:cubic-bezier(.2,.8,.2,1);--slm-mo-in-out:cubic-bezier(.4,0,.2,1);--slm-mo-exit:cubic-bezier(.4,0,1,1);
8652
+ --slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
8419
8653
  .slm *{box-sizing:border-box;margin:0;padding:0}
8420
8654
  .slm button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
8421
8655
  .slm input{font:inherit}
@@ -8428,7 +8662,8 @@ var CSS2 = `
8428
8662
  .slm-mode.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
8429
8663
  .slm-live{display:inline-flex;align-items:center;gap:6px;font-size:11px;letter-spacing:.12em;font-weight:800;color:var(--slm-muted)}
8430
8664
  .slm-live-dot{width:8px;height:8px;border-radius:50%;background:#8b94ac}
8431
- .slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);animation:slm-pulse 2s infinite}
8665
+ .slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);
8666
+ animation:slm-pulse var(--slm-mo-ambient) infinite}
8432
8667
  @keyframes slm-pulse{0%{box-shadow:0 0 0 0 rgba(34,160,107,.5)}70%{box-shadow:0 0 0 7px rgba(34,160,107,0)}100%{box-shadow:0 0 0 0 rgba(34,160,107,0)}}
8433
8668
  .slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
8434
8669
  border-top:1px solid var(--slm-line)}
@@ -8437,7 +8672,11 @@ var CSS2 = `
8437
8672
  font-variant-numeric:tabular-nums;white-space:nowrap}
8438
8673
  .slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
8439
8674
  .slm-kpi .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:baseline}
8440
- .slm-kpi.changed b{animation:slm-kpi-bump .58s cubic-bezier(.2,.8,.2,1)}
8675
+ /* The one playful moment this surface is allowed (\xA72 --mo-spring). It ran at
8676
+ .58s against a 200ms catalog, which read as a different design language from
8677
+ the dashboard tile it mirrors; Channels mode's own count bump is the same
8678
+ pattern and must stay in step with it. */
8679
+ .slm-kpi.changed b{animation:slm-kpi-bump var(--slm-mo-base) var(--slm-mo-spring)}
8441
8680
  .slm-kpidelta{position:absolute;right:4px;top:-12px;padding:2px 5px;border-radius:999px;background:rgba(34,160,107,.17);
8442
8681
  color:#5bd39b!important;font-size:9px!important;letter-spacing:0!important;text-transform:none!important;white-space:nowrap;
8443
8682
  animation:slm-kpi-delta 1.45s ease-out both;pointer-events:none}
@@ -8456,12 +8695,14 @@ var CSS2 = `
8456
8695
  .slm-hud-chip{padding:6px 11px;border-radius:999px;font-size:12px;font-weight:700;background:var(--slm-surface);
8457
8696
  border:1px solid var(--slm-line);color:var(--slm-text)}
8458
8697
  .slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
8459
- background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;transition:opacity .2s}
8698
+ background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;
8699
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
8460
8700
  .slm-zoomhint.on{opacity:1}
8461
8701
  .slm-liveevent{position:absolute;left:50%;top:14px;z-index:4;display:flex;align-items:center;gap:8px;max-width:min(560px,calc(100% - 32px));
8462
8702
  padding:8px 12px;border:1px solid var(--slm-line);border-radius:999px;background:color-mix(in srgb,var(--slm-surface) 92%,transparent);
8463
8703
  box-shadow:0 10px 34px rgba(0,0,0,.32);opacity:0;transform:translate(-50%,-8px);pointer-events:none;
8464
- transition:opacity .18s ease,transform .24s ease;backdrop-filter:blur(10px)}
8704
+ transition:opacity var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out);
8705
+ backdrop-filter:blur(10px)}
8465
8706
  .slm-liveevent.on{opacity:1;transform:translate(-50%,0)}
8466
8707
  .slm.block-mode .slm-liveevent{top:52px}
8467
8708
  .slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
@@ -8481,7 +8722,8 @@ var CSS2 = `
8481
8722
  /* activity feed */
8482
8723
  .slm-feed{display:flex;flex-direction:column;gap:0}
8483
8724
  .slm-feedrow{display:flex!important;width:100%;align-items:center;gap:9px;padding:8px 2px!important;border-bottom:1px solid var(--slm-line)!important;
8484
- border-radius:6px;font-size:12.5px;text-align:left!important;animation:slm-in .35s ease}
8725
+ border-radius:6px;font-size:12.5px;text-align:left!important;
8726
+ animation:slm-in var(--slm-mo-quick) var(--slm-mo-out)}
8485
8727
  .slm-feedrow:hover{background:rgba(255,255,255,.035)!important}
8486
8728
  @keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
8487
8729
  .slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
@@ -8548,7 +8790,8 @@ var CSS2 = `
8548
8790
 
8549
8791
  /* toast */
8550
8792
  .slm-toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);padding:10px 16px;border-radius:10px;
8551
- font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;transition:opacity .2s;
8793
+ font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;
8794
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out);
8552
8795
  background:var(--slm-surface);color:var(--slm-text);border:1px solid var(--slm-line);z-index:5}
8553
8796
  .slm-toast.on{opacity:1}
8554
8797
  .slm-toast.err{background:#c0392b;color:#fff;border-color:#c0392b}
@@ -8559,7 +8802,9 @@ var CSS2 = `
8559
8802
  .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
8560
8803
  .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
8561
8804
  .slm-sectionlist + .slm-eyebrow{margin-top:18px}
8562
- .slm-sectionrow{width:100%;padding:10px!important;border:1px solid var(--slm-line)!important;border-radius:10px;background:var(--slm-surface)!important;text-align:left!important;transition:border-color .15s ease,transform .15s ease}
8805
+ .slm-sectionrow{width:100%;padding:10px!important;border:1px solid var(--slm-line)!important;border-radius:10px;
8806
+ background:var(--slm-surface)!important;text-align:left!important;
8807
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-quick) var(--slm-mo-out)}
8563
8808
  .slm-sectionrow:hover{border-color:var(--slm-muted)!important;transform:translateY(-1px)}
8564
8809
  .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
8565
8810
  .slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
@@ -8578,7 +8823,8 @@ var CSS2 = `
8578
8823
  .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
8579
8824
  /* sections: availability windows */
8580
8825
  .slm-availlist{display:flex;flex-direction:column;gap:8px;margin:2px 0 12px}
8581
- .slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);transition:border-color .15s ease,opacity .15s ease}
8826
+ .slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
8827
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out),opacity var(--slm-mo-quick) var(--slm-mo-out)}
8582
8828
  .slm-availrow.zone{background:color-mix(in srgb,var(--slm-surface) 82%,#000)}
8583
8829
  .slm-availrow.hidden{opacity:.62}.slm-availrow.closed{opacity:.82}
8584
8830
  .slm-availhead{display:flex;align-items:center;gap:8px}
@@ -8618,16 +8864,29 @@ var CSS2 = `
8618
8864
  .slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
8619
8865
  .slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
8620
8866
  .slm.compact .slm-kpi[data-kpi="sold-pct"],.slm.compact .slm-kpi[data-kpi="gross-sales"]{display:none}
8867
+ /* Reduced motion, as a BLANKET over the cockpit subtree rather than a list of
8868
+ selectors. The list this replaces named four animations and two transitions,
8869
+ and had silently fallen behind the stylesheet: the zoom hint, the toast and
8870
+ the availability rows all still animated for a user who had asked the OS for
8871
+ none. An enumerated list has to be edited every time a rule is added, and
8872
+ nothing fails when it isn't \u2014 so it drifts. This cannot.
8873
+
8874
+ Motion is removed, never the information it carried: Channels mode's own
8875
+ block substitutes static outlines for its shake and success states, and it
8876
+ stays authoritative for those. No JS here waits on animationend or
8877
+ transitionend, so cutting them outright strands no state. */
8621
8878
  @media (prefers-reduced-motion:reduce){
8622
- .slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
8623
- .slm-liveevent,.slm-sectionrow{transition:none!important}
8879
+ .slm,.slm *,.slm *::before,.slm *::after{
8880
+ animation:none!important;
8881
+ transition:none!important;
8882
+ scroll-behavior:auto!important}
8624
8883
  }
8625
8884
  ${CHANNELS_CSS}`;
8626
8885
  function injectStyle() {
8627
8886
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
8628
8887
  const el = document.createElement("style");
8629
8888
  el.id = STYLE_ID2;
8630
- el.textContent = CSS2;
8889
+ el.textContent = MANAGER_CSS;
8631
8890
  document.head.appendChild(el);
8632
8891
  }
8633
8892
  function themeVars(theme) {
@@ -8687,6 +8946,11 @@ var SeatManager = class {
8687
8946
  this.reconnectTimer = null;
8688
8947
  this.attempt = 0;
8689
8948
  this.closed = false;
8949
+ /** Mirrors the `live` root class, so the getter never has to read the DOM. */
8950
+ this.connectionStatus = "reconnecting";
8951
+ /** When the server last told us something. Stamped on accepted traffic only —
8952
+ * a socket that opens and says nothing has not refreshed anything. */
8953
+ this.lastMessageAt = null;
8690
8954
  this.ready = false;
8691
8955
  this.feed = [];
8692
8956
  this.feedTimer = null;
@@ -8896,6 +9160,12 @@ var SeatManager = class {
8896
9160
  },
8897
9161
  worldToScreen: (point) => this.renderer?.worldToScreen(point) ?? null,
8898
9162
  seatPixelSize: () => this.seatPixelSize(),
9163
+ isSeatDetail: () => this.renderer?.getRung?.() === "seats",
9164
+ showSectionOverview: () => {
9165
+ this.renderer?.clearSectionFocus();
9166
+ this.renderer?.setRung?.("sections");
9167
+ },
9168
+ focusSection: (sectionId) => this.renderer?.focusSection(sectionId),
8899
9169
  isCompact: () => !!this.root?.classList.contains("compact"),
8900
9170
  setMapInert: (inert) => {
8901
9171
  this.mapHost.toggleAttribute("inert", inert);
@@ -8905,13 +9175,15 @@ var SeatManager = class {
8905
9175
  onError: (err) => this.opts.onError?.(err)
8906
9176
  };
8907
9177
  }
8908
- /** Approximate on-screen seat size, for the channel overlay's marks. Derived
8909
- * from the live camera so the overlay tracks zoom without a renderer hook. */
9178
+ /** Actual on-screen seat diameter, for the channel overlay's marks. The
9179
+ * renderer's base seat radius is 9 chart units; retaining the camera scale
9180
+ * (rather than capping it) keeps every preview paint aligned with the real
9181
+ * chart geometry at deep zoom. */
8910
9182
  seatPixelSize() {
8911
9183
  const rect = this.renderer?.getVisibleWorldRect?.();
8912
9184
  const width = this.mapHost?.clientWidth ?? 0;
8913
9185
  if (!rect?.width || !width) return 6;
8914
- return Math.max(3, Math.min(24, width / rect.width * 14));
9186
+ return Math.max(3, width / rect.width * 18);
8915
9187
  }
8916
9188
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
8917
9189
  setHeatOverlay(enabled) {
@@ -9087,6 +9359,16 @@ var SeatManager = class {
9087
9359
  getControlRoomSnapshot(windowMinutes = this.trendWindowMinutes) {
9088
9360
  return this.setTrendWindow(windowMinutes);
9089
9361
  }
9362
+ /**
9363
+ * The realtime link's current state and the "as of" behind it.
9364
+ *
9365
+ * Pair with `onConnectionChange` for the edges: a host that mounts after a
9366
+ * drop, or re-reads on tab focus, needs to be able to ASK rather than wait
9367
+ * for the next transition that may never come.
9368
+ */
9369
+ getConnection() {
9370
+ return { status: this.connectionStatus, lastMessageAt: this.lastMessageAt };
9371
+ }
9090
9372
  getLog(opts = {}) {
9091
9373
  return this.api.log(this.key, opts);
9092
9374
  }
@@ -9151,6 +9433,10 @@ var SeatManager = class {
9151
9433
  onSelect: (seat) => this.handleSeatSelect(seat),
9152
9434
  onDeselect: () => this.syncSelection(),
9153
9435
  onMarquee: () => this.syncSelection(),
9436
+ onSectionTap: (sectionId) => {
9437
+ this.renderer?.focusSection(sectionId);
9438
+ this.channels?.handleSectionFocus(sectionId);
9439
+ },
9154
9440
  onViewChange: () => {
9155
9441
  this.updateZoomHint();
9156
9442
  this.channels?.handleViewChange();
@@ -9161,10 +9447,11 @@ var SeatManager = class {
9161
9447
  this.applyHeatOverlay();
9162
9448
  this.updateZoomHint();
9163
9449
  }
9164
- /** Block and Channels are both bulk-selection tools: marquee, ⌘A, category,
9165
- * section. The two differ only in WHICH statuses they may act on. */
9450
+ /** Block always uses a marquee. Channels only enables its marquee after the
9451
+ * organizer deliberately chooses Assign seats; Pan map keeps desktop drag
9452
+ * available for large charts. */
9166
9453
  isBulkSelectMode() {
9167
- return this.mode === "block" || this.mode === "channels" && this.channels?.canSelect() === true;
9454
+ return this.mode === "block" || this.mode === "channels" && this.channels?.usesMarqueeSelection() === true;
9168
9455
  }
9169
9456
  /**
9170
9457
  * Block never touches held or booked inventory, so it cannot select it.
@@ -9174,7 +9461,7 @@ var SeatManager = class {
9174
9461
  */
9175
9462
  selectableStatuses() {
9176
9463
  if (this.mode === "block") return ["free", "not_for_sale"];
9177
- if (this.mode === "inspect" || this.isBulkSelectMode()) {
9464
+ if (this.mode === "inspect" || this.isBulkSelectMode() || this.mode === "channels" && this.channels?.canSelect() === true) {
9178
9465
  return ["free", "held", "booked", "not_for_sale"];
9179
9466
  }
9180
9467
  return [];
@@ -9277,6 +9564,7 @@ var SeatManager = class {
9277
9564
  return;
9278
9565
  }
9279
9566
  if (!msg || typeof msg !== "object") return;
9567
+ this.lastMessageAt = Date.now();
9280
9568
  const m = msg;
9281
9569
  if (Array.isArray(m.hidden) || Array.isArray(m.closed)) {
9282
9570
  this.updateEffectiveAvailability(m.hidden, m.closed);
@@ -9333,6 +9621,7 @@ var SeatManager = class {
9333
9621
  const objs = await this.api.objects(this.key);
9334
9622
  this.applySnapshot(objs.seats);
9335
9623
  this.updateEffectiveAvailability(objs.hidden, objs.closed);
9624
+ this.lastMessageAt = Date.now();
9336
9625
  } catch {
9337
9626
  }
9338
9627
  }
@@ -9807,6 +10096,14 @@ var SeatManager = class {
9807
10096
  this.root?.classList.toggle("live", on);
9808
10097
  if (this.els.livetext) this.els.livetext.textContent = on ? "LIVE" : "RECONNECTING";
9809
10098
  this.paintMonitorInsights();
10099
+ const next = on ? "live" : "reconnecting";
10100
+ if (next === this.connectionStatus) return;
10101
+ this.connectionStatus = next;
10102
+ try {
10103
+ this.opts.onConnectionChange?.(this.getConnection());
10104
+ } catch (err) {
10105
+ this.opts.onError?.(err);
10106
+ }
9810
10107
  }
9811
10108
  updateZoomHint() {
9812
10109
  const hint = this.els.zoomhint;