@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.cjs CHANGED
@@ -6598,6 +6598,11 @@ var ManageApi = class {
6598
6598
  var POLL_MS = 1e4;
6599
6599
  var MAX_FLAGS = 8;
6600
6600
  var SEAT_LIST_PAGE = 300;
6601
+ var PREVIEW_ELIGIBLE_FILL = "#6e7bff";
6602
+ var PREVIEW_ELIGIBLE_STROKE = "#b9c0ff";
6603
+ var PREVIEW_UNAVAILABLE_FILL = "#303846";
6604
+ var PREVIEW_UNAVAILABLE_STROKE = "#4b5669";
6605
+ var ALLOCATION_STROKE = "#101723";
6601
6606
  var CHANNELS_CSS = `
6602
6607
  .slm{--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
6603
6608
  --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);
@@ -6611,6 +6616,8 @@ var CHANNELS_CSS = `
6611
6616
  background:rgba(14,16,23,.88);border:1px solid var(--slm-line);font-size:10px;font-weight:800;letter-spacing:.04em;
6612
6617
  transform:translate(-50%,-50%);white-space:nowrap}
6613
6618
  .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}
6619
+ .slm-ch-section-target{position:absolute;pointer-events:auto;padding:0;border:0;border-radius:8px;background:transparent;cursor:zoom-in}
6620
+ .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)}
6614
6621
 
6615
6622
  /* preview banner \u2014 raised with the organizer chrome dim, as one transition */
6616
6623
  .slm-ch-banner{position:absolute;left:50%;top:14px;z-index:6;display:flex;align-items:center;gap:9px;padding:8px 14px;
@@ -6630,7 +6637,7 @@ var CHANNELS_CSS = `
6630
6637
  transition:transform var(--slm-mo-slow) var(--slm-mo-out),opacity var(--slm-mo-base) var(--slm-mo-out)}
6631
6638
  .slm-ch-staged.on{transform:none;opacity:1}
6632
6639
  .slm-ch-staged.done{background:rgba(31,122,77,.96);border-color:#1f7a4d}
6633
- .slm-ch-staged.shake{animation:slm-ch-shake 320ms var(--slm-mo-in-out) 2}
6640
+ .slm-ch-staged.shake{animation:slm-ch-shake var(--slm-mo-slow) var(--slm-mo-in-out) 2}
6634
6641
  .slm-ch-staged b{font-variant-numeric:tabular-nums}
6635
6642
  .slm-ch-staged .grow{flex:1}
6636
6643
  .slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
@@ -6647,6 +6654,11 @@ var CHANNELS_CSS = `
6647
6654
  .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)}
6648
6655
  .slm-ch-viewseg button.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
6649
6656
  .slm-ch-viewseg button:disabled{opacity:.5;cursor:not-allowed}
6657
+ .slm-ch-mapnav{margin:-2px 0 12px;padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
6658
+ .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)}
6659
+ .slm-ch-mapnav-head button{color:var(--slm-accent);font-size:11px;font-weight:800;letter-spacing:0;text-transform:none;min-height:30px}
6660
+ .slm-ch-mapnav .slm-ch-viewseg{margin:8px 0 5px}
6661
+ .slm-ch-mapnav p{margin:0;font-size:11px;line-height:1.45;color:var(--slm-muted)}
6650
6662
  .slm-ch-list{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
6651
6663
  .slm-ch-row{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
6652
6664
  text-align:left;width:100%;display:block;transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
@@ -6667,7 +6679,7 @@ var CHANNELS_CSS = `
6667
6679
  font-variant-numeric:tabular-nums}
6668
6680
  .slm-ch-counts b{color:var(--slm-text);font-weight:800}
6669
6681
  .slm-ch-counts .free b{color:#5bd39b}
6670
- .slm-ch-counts b.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6682
+ .slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6671
6683
  @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
6672
6684
  .slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
6673
6685
  .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
@@ -6677,12 +6689,13 @@ var CHANNELS_CSS = `
6677
6689
  font-weight:800;color:#0e1017}
6678
6690
  .slm-ch-selsrc-row b{min-width:30px;text-align:right;font-weight:800}
6679
6691
  .slm-ch-selsrc-row span{color:var(--slm-muted)}
6680
- .slm-ch-selnum.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6692
+ .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6681
6693
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
6682
6694
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
6683
6695
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
6684
6696
  line-height:1.5;margin-bottom:12px}
6685
6697
  .slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
6698
+ .slm-ch-alert.info{background:rgba(110,123,255,.12);border:1px solid rgba(110,123,255,.44);color:#c5cbff}
6686
6699
  .slm-ch-alert.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.45);color:#f1a4a6}
6687
6700
  .slm-ch-alert b{color:#fff}
6688
6701
  .slm-ch-alert button{display:block;margin-top:6px;color:#fff;font-weight:800;min-height:36px}
@@ -6829,6 +6842,10 @@ var ChannelsMode = class {
6829
6842
  this.loadError = null;
6830
6843
  this.loading = true;
6831
6844
  this.view = "inspect";
6845
+ /** Pan is intentionally the initial desktop interaction. Assignment's
6846
+ * marquee is powerful, but must never make an organizer lose map navigation. */
6847
+ this.mapIntent = "pan";
6848
+ this.focusedSectionId = null;
6832
6849
  this.showArchived = false;
6833
6850
  this.detailChannelId = null;
6834
6851
  this.targetChannelId = "";
@@ -6845,6 +6862,15 @@ var ChannelsMode = class {
6845
6862
  this.links = [];
6846
6863
  this.linksChannelId = null;
6847
6864
  this.linksState = "idle";
6865
+ /**
6866
+ * Monotonic read generations — one for the channel list + allocation, one for
6867
+ * the open channel's links. Reads are concurrent (a 10s poll versus a
6868
+ * mutation's own reload), and the network does not promise to answer them in
6869
+ * order. Only the NEWEST read of each kind may write to state; an older
6870
+ * answer that arrives late is dropped, never painted.
6871
+ */
6872
+ this.listSeq = 0;
6873
+ this.linksSeq = 0;
6848
6874
  this.previewAudience = [];
6849
6875
  this.previewIncludePublic = false;
6850
6876
  this.previewProjection = null;
@@ -6871,10 +6897,14 @@ var ChannelsMode = class {
6871
6897
  enter() {
6872
6898
  if (this.active) return;
6873
6899
  this.active = true;
6900
+ this.mapIntent = "pan";
6901
+ this.focusedSectionId = null;
6874
6902
  this.ensureLayer();
6875
6903
  this.host.root.classList.add("ch-mode");
6876
6904
  this.applySheetClasses();
6905
+ if (this.host.sections().length > 1) this.host.showSectionOverview();
6877
6906
  this.paintRail();
6907
+ this.onInteractionChange?.();
6878
6908
  void this.refresh();
6879
6909
  this.pollTimer = setInterval(() => {
6880
6910
  void this.refresh({ quiet: true });
@@ -6926,6 +6956,17 @@ var ChannelsMode = class {
6926
6956
  canSelect() {
6927
6957
  return this.caps.manage && this.view === "inspect";
6928
6958
  }
6959
+ /** Bulk seat assignment is explicit. In Pan map, clicks can still inspect a
6960
+ * single seat, while a primary-button drag always moves the camera. */
6961
+ usesMarqueeSelection() {
6962
+ return this.canSelect() && this.mapIntent === "assign";
6963
+ }
6964
+ /** The renderer calls this when the organizer opens a section from overview. */
6965
+ handleSectionFocus(sectionId) {
6966
+ if (!this.active) return;
6967
+ this.focusedSectionId = sectionId;
6968
+ this.paintRail();
6969
+ }
6929
6970
  /**
6930
6971
  * Organizer realtime integration point. M5 ships a per-scope socket for
6931
6972
  * buyers; the organizer channel-count stream is a later milestone. When it
@@ -6953,22 +6994,28 @@ var ChannelsMode = class {
6953
6994
  // ---- data -----------------------------------------------------------------
6954
6995
  async refresh(opts = {}) {
6955
6996
  if (!this.caps.view) return;
6997
+ const seq = ++this.listSeq;
6998
+ const superseded = () => seq !== this.listSeq;
6956
6999
  try {
6957
7000
  const list = await this.host.api.channels(this.host.eventKey, { includeArchived: this.showArchived });
7001
+ if (superseded()) return;
6958
7002
  this.list = list;
6959
7003
  this.assignmentVersion = list.assignmentVersion;
6960
7004
  this.loadError = null;
6961
7005
  if (!this.targetChannelId) {
6962
7006
  this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
6963
7007
  }
6964
- await this.loadAllocation();
7008
+ await this.loadAllocation(seq);
7009
+ if (superseded()) return;
6965
7010
  if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
7011
+ if (superseded()) return;
6966
7012
  this.loading = false;
6967
7013
  if (this.active) {
6968
7014
  this.paintRail();
6969
7015
  this.paintOverlay();
6970
7016
  }
6971
7017
  } catch (err) {
7018
+ if (superseded()) return;
6972
7019
  this.loading = false;
6973
7020
  if (err instanceof ManageApiError && err.status === 403) {
6974
7021
  this.caps = { view: false, manage: false };
@@ -6980,10 +7027,11 @@ var ChannelsMode = class {
6980
7027
  }
6981
7028
  /** Walk every allocation page. Bounded by the event's seat count, and the
6982
7029
  * server caps each page, so an arena is a handful of round trips. */
6983
- async loadAllocation() {
7030
+ async loadAllocation(seq) {
6984
7031
  const next = /* @__PURE__ */ new Map();
6985
7032
  let afterLabel;
6986
7033
  for (let page = 0; page < 200; page += 1) {
7034
+ if (seq !== void 0 && seq !== this.listSeq) return;
6987
7035
  const res = await this.host.api.channelAllocation(this.host.eventKey, {
6988
7036
  afterLabel,
6989
7037
  limit: 1e3
@@ -6995,6 +7043,7 @@ var ChannelsMode = class {
6995
7043
  if (!res.nextAfterLabel) break;
6996
7044
  afterLabel = res.nextAfterLabel;
6997
7045
  }
7046
+ if (seq !== void 0 && seq !== this.listSeq) return;
6998
7047
  this.allocation = next;
6999
7048
  }
7000
7049
  // ---- lookups --------------------------------------------------------------
@@ -7061,9 +7110,10 @@ var ChannelsMode = class {
7061
7110
  * Repaint the allocation (or preview) overlay in ONE canvas pass.
7062
7111
  *
7063
7112
  * Channel identity on the map is a fill in the administrative color PLUS the
7064
- * letter flags below — never color alone. Physical status keeps its own cue:
7065
- * only FREE units take a channel fill, so sold/held/blocked seats still read
7066
- * exactly as they do in every other tool.
7113
+ * letter flags below — never color alone. In buyer preview the map instead
7114
+ * uses two explicit, channel-neutral access states. Physical status keeps its
7115
+ * own cue: only FREE units are repainted, so sold/held/blocked seats still
7116
+ * read exactly as they do in every other tool.
7067
7117
  */
7068
7118
  paintOverlay() {
7069
7119
  const canvas = this.canvas;
@@ -7088,11 +7138,14 @@ var ChannelsMode = class {
7088
7138
  if (!ctx) return;
7089
7139
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
7090
7140
  ctx.clearRect(0, 0, width, height);
7141
+ const seatDetail = this.host.isSeatDetail();
7091
7142
  const size = Math.max(3, this.host.seatPixelSize());
7092
7143
  const half = size / 2;
7093
7144
  const projection = this.view === "preview" ? this.previewProjection : null;
7094
7145
  const eligible = projection ? new Set(projection.available === false ? [] : projection.eligible ?? []) : null;
7095
7146
  const clusters = /* @__PURE__ */ new Map();
7147
+ const sectionTargets = !seatDetail && this.host.sections().length > 1 ? /* @__PURE__ */ new Map() : null;
7148
+ const previewSections = this.view === "preview" && seatDetail && size <= 15 ? /* @__PURE__ */ new Map() : null;
7096
7149
  for (const seat of this.host.seats()) {
7097
7150
  const status = this.host.statusOf(seat.label) ?? "free";
7098
7151
  const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
@@ -7103,24 +7156,150 @@ var ChannelsMode = class {
7103
7156
  cluster.n += 1;
7104
7157
  clusters.set(channelId, cluster);
7105
7158
  }
7159
+ if (!seatDetail) {
7160
+ const section = this.host.sectionOfLabel(seat.label);
7161
+ const point2 = section ? this.host.worldToScreen({ x: seat.x, y: seat.y }) : null;
7162
+ if (sectionTargets && section && point2) {
7163
+ const bounds = sectionTargets.get(section.id) ?? {
7164
+ label: section.label,
7165
+ minX: point2.x,
7166
+ minY: point2.y,
7167
+ maxX: point2.x,
7168
+ maxY: point2.y
7169
+ };
7170
+ bounds.minX = Math.min(bounds.minX, point2.x);
7171
+ bounds.minY = Math.min(bounds.minY, point2.y);
7172
+ bounds.maxX = Math.max(bounds.maxX, point2.x);
7173
+ bounds.maxY = Math.max(bounds.maxY, point2.y);
7174
+ sectionTargets.set(section.id, bounds);
7175
+ }
7176
+ continue;
7177
+ }
7106
7178
  if (status !== "free") continue;
7107
7179
  let fill = null;
7180
+ let stroke = null;
7108
7181
  if (this.view === "preview") {
7109
- fill = eligible ? eligible.has(seat.label) ? null : "#3a4051" : null;
7182
+ if (eligible?.has(seat.label)) {
7183
+ fill = PREVIEW_ELIGIBLE_FILL;
7184
+ stroke = PREVIEW_ELIGIBLE_STROKE;
7185
+ } else {
7186
+ fill = PREVIEW_UNAVAILABLE_FILL;
7187
+ stroke = PREVIEW_UNAVAILABLE_STROKE;
7188
+ }
7110
7189
  } else if (channelId !== PUBLIC_CHANNEL_ID) {
7111
7190
  fill = this.markerFor(channelId).color;
7191
+ stroke = ALLOCATION_STROKE;
7112
7192
  }
7113
7193
  if (!fill) continue;
7114
7194
  const point = this.host.worldToScreen({ x: seat.x, y: seat.y });
7115
7195
  if (!point) continue;
7116
7196
  if (point.x < -size || point.y < -size || point.x > width + size || point.y > height + size) continue;
7197
+ if (previewSections) {
7198
+ const section = this.host.sectionOfLabel(seat.label);
7199
+ if (section) {
7200
+ const bounds = previewSections.get(section.id) ?? {
7201
+ label: section.label,
7202
+ minX: point.x,
7203
+ minY: point.y,
7204
+ maxX: point.x,
7205
+ maxY: point.y
7206
+ };
7207
+ bounds.minX = Math.min(bounds.minX, point.x);
7208
+ bounds.minY = Math.min(bounds.minY, point.y);
7209
+ bounds.maxX = Math.max(bounds.maxX, point.x);
7210
+ bounds.maxY = Math.max(bounds.maxY, point.y);
7211
+ previewSections.set(section.id, bounds);
7212
+ }
7213
+ }
7117
7214
  ctx.fillStyle = fill;
7118
- ctx.globalAlpha = this.view === "preview" ? 0.9 : 0.85;
7119
- ctx.fillRect(point.x - half, point.y - half, size, size);
7215
+ if (this.view === "preview" || channelId !== PUBLIC_CHANNEL_ID) {
7216
+ const radius = Math.max(2, half + Math.min(1.5, half * 0.06));
7217
+ ctx.globalAlpha = 1;
7218
+ ctx.beginPath();
7219
+ ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
7220
+ ctx.fill();
7221
+ ctx.strokeStyle = stroke ?? fill;
7222
+ 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));
7223
+ ctx.stroke();
7224
+ if (this.view === "preview" && eligible?.has(seat.label) && size >= 22) {
7225
+ this.paintPreviewSeatLabel(ctx, seat.label, point.x, point.y, radius);
7226
+ }
7227
+ } else {
7228
+ ctx.globalAlpha = 0.85;
7229
+ ctx.fillRect(point.x - half, point.y - half, size, size);
7230
+ }
7120
7231
  }
7121
7232
  ctx.globalAlpha = 1;
7233
+ if (previewSections) this.paintPreviewSectionLabels(ctx, previewSections);
7234
+ this.paintSectionTargets(sectionTargets);
7122
7235
  this.paintFlags(clusters);
7123
7236
  }
7237
+ /** Draw an eligible seat's actual chart label without inventing a new buyer
7238
+ * identifier. Long labels scale down and are omitted rather than overflowing
7239
+ * into an adjacent seat. */
7240
+ paintPreviewSeatLabel(ctx, label, x, y, radius) {
7241
+ const maxWidth = radius * 1.55;
7242
+ let fontSize = Math.min(13, Math.max(7, radius * 0.55));
7243
+ const minFontSize = 6;
7244
+ while (fontSize >= minFontSize) {
7245
+ ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
7246
+ if (ctx.measureText(label).width <= maxWidth) break;
7247
+ fontSize -= 0.5;
7248
+ }
7249
+ if (fontSize < minFontSize) return;
7250
+ ctx.fillStyle = "#ffffff";
7251
+ ctx.textAlign = "center";
7252
+ ctx.textBaseline = "middle";
7253
+ ctx.fillText(label, x, y);
7254
+ }
7255
+ /** A section overview is a navigation map. These transparent, keyboardable
7256
+ * hit areas sit over the renderer's section shells so both mouse and keyboard
7257
+ * always take the organizer into the real focused-section camera state. */
7258
+ paintSectionTargets(sections) {
7259
+ const layer = this.layer;
7260
+ if (!layer) return;
7261
+ layer.querySelectorAll(".slm-ch-section-target").forEach((el) => el.remove());
7262
+ if (!sections) return;
7263
+ for (const [id, section] of sections) {
7264
+ const width = section.maxX - section.minX;
7265
+ const height = section.maxY - section.minY;
7266
+ if (width < 20 || height < 20) continue;
7267
+ const target = document.createElement("button");
7268
+ target.type = "button";
7269
+ target.className = "slm-ch-section-target";
7270
+ target.style.left = `${section.minX - 8}px`;
7271
+ target.style.top = `${section.minY - 8}px`;
7272
+ target.style.width = `${width + 16}px`;
7273
+ target.style.height = `${height + 16}px`;
7274
+ target.setAttribute("aria-label", `Open ${section.label} seats`);
7275
+ target.addEventListener("click", () => {
7276
+ this.focusedSectionId = id;
7277
+ this.host.focusSection(id);
7278
+ this.paintRail();
7279
+ });
7280
+ layer.appendChild(target);
7281
+ }
7282
+ }
7283
+ /** Keep renderer section names legible over a dense, zoomed-out preview. */
7284
+ paintPreviewSectionLabels(ctx, sections) {
7285
+ for (const section of sections.values()) {
7286
+ const width = section.maxX - section.minX;
7287
+ const height = section.maxY - section.minY;
7288
+ if (width < 52 || height < 26) continue;
7289
+ const centerX = (section.minX + section.maxX) / 2;
7290
+ const centerY = (section.minY + section.maxY) / 2;
7291
+ const fontSize = Math.max(11, Math.min(15, height * 0.16));
7292
+ ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
7293
+ const labelWidth = Math.min(width - 8, ctx.measureText(section.label).width + 18);
7294
+ const labelHeight = fontSize + 10;
7295
+ ctx.fillStyle = "rgba(11, 16, 28, .88)";
7296
+ ctx.fillRect(centerX - labelWidth / 2, centerY - labelHeight / 2, labelWidth, labelHeight);
7297
+ ctx.fillStyle = "#f8fafc";
7298
+ ctx.textAlign = "center";
7299
+ ctx.textBaseline = "middle";
7300
+ ctx.fillText(section.label, centerX, centerY);
7301
+ }
7302
+ }
7124
7303
  /** Letter flags at each channel's centroid — the non-color identity cue. */
7125
7304
  paintFlags(clusters) {
7126
7305
  const layer = this.layer;
@@ -7186,7 +7365,7 @@ var ChannelsMode = class {
7186
7365
  });
7187
7366
  });
7188
7367
  }
7189
- setBanner(on, name = "") {
7368
+ setBanner(on, name = "", eligibleSeats) {
7190
7369
  const banner = this.bannerEl;
7191
7370
  if (!banner) return;
7192
7371
  this.host.root.classList.toggle("ch-preview", on);
@@ -7196,8 +7375,9 @@ var ChannelsMode = class {
7196
7375
  return;
7197
7376
  }
7198
7377
  const marker = this.previewAudience.length === 1 ? this.markerFor(this.previewAudience[0]) : { color: "var(--slm-accent)", letter: "" };
7378
+ const availability = eligibleSeats == null ? "" : ` \xB7 ${eligibleSeats.toLocaleString()} ${eligibleSeats === 1 ? "seat" : "seats"} available now`;
7199
7379
  banner.innerHTML = `<span class="dot" style="background:${esc(marker.color)}"></span>
7200
- Previewing buyer access \xB7 ${esc(name)} \xB7 read-only
7380
+ Previewing buyer access \xB7 ${esc(name)}${availability} \xB7 read-only
7201
7381
  <button type="button" data-ch-act="exit-preview">Exit preview</button>`;
7202
7382
  banner.classList.add("on");
7203
7383
  banner.querySelector('[data-ch-act="exit-preview"]')?.addEventListener("click", () => this.setView("inspect"));
@@ -7232,6 +7412,8 @@ var ChannelsMode = class {
7232
7412
  includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
7233
7413
  });
7234
7414
  this.previewSupported = true;
7415
+ const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
7416
+ this.setBanner(true, names, eligibleSeats);
7235
7417
  } catch (err) {
7236
7418
  const status = err instanceof ManageApiError ? err.status : 0;
7237
7419
  this.previewSupported = !(status === 404 || status === 405 || status === 501);
@@ -7282,6 +7464,22 @@ var ChannelsMode = class {
7282
7464
  aria-pressed="${this.view === "inspect"}">Inspect allocation</button>
7283
7465
  <button type="button" class="${previewOn.trim()}" data-ch-view="preview"
7284
7466
  aria-pressed="${this.view === "preview"}">Preview buyer access</button>
7467
+ </div>${this.mapNavigationHtml()}`;
7468
+ }
7469
+ mapNavigationHtml() {
7470
+ if (this.host.sections().length < 2) return "";
7471
+ const focused = this.focusedSectionId ? this.host.sections().find((section) => section.id === this.focusedSectionId)?.label ?? "section" : null;
7472
+ const panOn = this.mapIntent === "pan" ? " on" : "";
7473
+ const assignOn = this.mapIntent === "assign" ? " on" : "";
7474
+ const intent = this.view === "inspect" && this.caps.manage ? `<div class="slm-ch-viewseg" role="group" aria-label="Map interaction">
7475
+ <button type="button" class="${panOn.trim()}" data-ch-map="pan" aria-pressed="${this.mapIntent === "pan"}">Pan map</button>
7476
+ <button type="button" class="${assignOn.trim()}" data-ch-map="assign" aria-pressed="${this.mapIntent === "assign"}">Assign seats</button>
7477
+ </div>
7478
+ <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>";
7479
+ return `<div class="slm-ch-mapnav">
7480
+ <div class="slm-ch-mapnav-head"><span>${focused ? `Viewing ${esc(focused)}` : "Section overview"}</span>
7481
+ <button type="button" data-ch-act="sections">All sections</button></div>
7482
+ ${intent}
7285
7483
  </div>`;
7286
7484
  }
7287
7485
  countsHtml(counts, key) {
@@ -7420,6 +7618,8 @@ var ChannelsMode = class {
7420
7618
  */
7421
7619
  async loadLinks(channelId) {
7422
7620
  if (!this.caps.view) return;
7621
+ const seq = ++this.linksSeq;
7622
+ const superseded = () => seq !== this.linksSeq || this.linksChannelId !== channelId;
7423
7623
  if (this.linksChannelId !== channelId) {
7424
7624
  this.links = [];
7425
7625
  this.linksChannelId = channelId;
@@ -7427,11 +7627,11 @@ var ChannelsMode = class {
7427
7627
  }
7428
7628
  try {
7429
7629
  const res = await this.host.api.accessLinks(this.host.eventKey, channelId);
7430
- if (this.linksChannelId !== channelId) return;
7630
+ if (superseded()) return;
7431
7631
  this.links = res.links ?? [];
7432
7632
  this.linksState = "ready";
7433
7633
  } catch (err) {
7434
- if (this.linksChannelId !== channelId) return;
7634
+ if (superseded()) return;
7435
7635
  const status = err instanceof ManageApiError ? err.status : 0;
7436
7636
  this.links = [];
7437
7637
  this.linksState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
@@ -7508,9 +7708,9 @@ var ChannelsMode = class {
7508
7708
  const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
7509
7709
  <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")}.
7510
7710
  A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
7511
- const counts = this.previewProjection?.counts;
7512
- 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
7513
- through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
7711
+ const eligibleSeats = this.previewProjection?.counts?.eligible ?? this.previewProjection?.eligible?.length;
7712
+ 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>
7713
+ This is the exact buyer-visible allocation.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this access." : ""}</span></div>` : "";
7514
7714
  const includePublic = isPublicChannelId(current) ? "" : `
7515
7715
  <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
7516
7716
  <input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
@@ -7541,6 +7741,13 @@ var ChannelsMode = class {
7541
7741
  rail.querySelectorAll("[data-ch-view]").forEach((button) => {
7542
7742
  button.addEventListener("click", () => this.setView(button.dataset.chView));
7543
7743
  });
7744
+ rail.querySelectorAll("[data-ch-map]").forEach((button) => {
7745
+ button.addEventListener("click", () => {
7746
+ this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
7747
+ this.paintRail();
7748
+ this.onInteractionChange?.();
7749
+ });
7750
+ });
7544
7751
  rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
7545
7752
  button.addEventListener("click", () => {
7546
7753
  const channelId = button.dataset.chDetail;
@@ -7591,6 +7798,11 @@ var ChannelsMode = class {
7591
7798
  }
7592
7799
  railAction(action) {
7593
7800
  switch (action) {
7801
+ case "sections":
7802
+ this.focusedSectionId = null;
7803
+ this.host.showSectionOverview();
7804
+ this.paintRail();
7805
+ break;
7594
7806
  case "create":
7595
7807
  this.openDialog({ kind: "create" });
7596
7808
  break;
@@ -7765,7 +7977,7 @@ var ChannelsMode = class {
7765
7977
  <label>Marker</label>
7766
7978
  <div style="display:flex;gap:8px;align-items:center">
7767
7979
  <span class="slm-ch-mk" data-ch-marker style="background:${esc(suggestion.color)};width:28px;height:28px;font-size:13px">${esc(suggestion.letter)}</span>
7768
- <span class="slm-note" style="margin:0">Letter + colour suggested from the name. Buyers never see either.</span>
7980
+ <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>
7769
7981
  </div>
7770
7982
  </div>
7771
7983
  <div class="slm-field">
@@ -8116,6 +8328,24 @@ var ChannelsMode = class {
8116
8328
  await this.loadLinks(channelId);
8117
8329
  this.paintRail();
8118
8330
  }
8331
+ /**
8332
+ * The reload EVERY link mutation owes the panel.
8333
+ *
8334
+ * A create/rotate/revoke changes two things the detail panel renders: the
8335
+ * channel's access line (the server sets `access.intent` on create, and clears
8336
+ * it when the last live link goes) and the link status list. Both are re-read
8337
+ * here and the rail repainted, so the panel the organizer is already looking
8338
+ * at is current the moment the mutation lands — no reload, and no dependence
8339
+ * on HOW the one-time reveal was dismissed (the button, Escape, or never).
8340
+ */
8341
+ async reloadAfterLinkChange(channelId) {
8342
+ if (this.detailChannelId === channelId) {
8343
+ await this.refresh({ quiet: true });
8344
+ return;
8345
+ }
8346
+ await this.loadLinks(channelId);
8347
+ if (this.active) this.paintRail();
8348
+ }
8119
8349
  linkById(linkId) {
8120
8350
  return this.links.find((link) => link.id === linkId) ?? null;
8121
8351
  }
@@ -8209,7 +8439,7 @@ var ChannelsMode = class {
8209
8439
  try {
8210
8440
  const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
8211
8441
  this.revealLink(reveal, { channelId });
8212
- await this.refresh({ quiet: true });
8442
+ await this.reloadAfterLinkChange(channelId);
8213
8443
  } catch (err) {
8214
8444
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8215
8445
  if (!(err instanceof ManageApiError)) this.host.onError(err);
@@ -8267,9 +8497,6 @@ var ChannelsMode = class {
8267
8497
  }
8268
8498
  selectSecret(dialog);
8269
8499
  });
8270
- dialog.querySelector("[data-ch-lk-done]")?.addEventListener("click", () => {
8271
- void this.loadLinks(opts.channelId).then(() => this.paintRail());
8272
- });
8273
8500
  });
8274
8501
  this.announce("Your hosted access link is ready and is shown once.");
8275
8502
  }
@@ -8335,6 +8562,7 @@ var ChannelsMode = class {
8335
8562
  endActiveSessions
8336
8563
  );
8337
8564
  this.revealLink(reveal, { channelId, rotated: true });
8565
+ await this.reloadAfterLinkChange(channelId);
8338
8566
  } catch (err) {
8339
8567
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8340
8568
  if (!(err instanceof ManageApiError)) this.host.onError(err);
@@ -8377,9 +8605,7 @@ var ChannelsMode = class {
8377
8605
  endActiveSessions
8378
8606
  );
8379
8607
  this.closeDialog();
8380
- await this.loadLinks(channelId);
8381
- await this.refresh({ quiet: true });
8382
- this.paintRail();
8608
+ await this.reloadAfterLinkChange(channelId);
8383
8609
  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");
8384
8610
  } catch (err) {
8385
8611
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
@@ -8464,9 +8690,17 @@ var LEGEND = [
8464
8690
  { key: "booked", label: "Booked", color: "#22a06b" },
8465
8691
  { key: "blocked", label: "Blocked", color: "#8b94ac" }
8466
8692
  ];
8467
- var CSS2 = `
8693
+ var MANAGER_CSS = `
8468
8694
  .slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:480px;overflow:hidden;
8469
- background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius)}
8695
+ background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius);
8696
+ /* Motion tokens (motion-system \xA72), declared by the cockpit ROOT rather than
8697
+ borrowed from CHANNELS_CSS. The base cockpit animates whether or not
8698
+ Channels mode is in use, so owning its own tokens is what stops a token
8699
+ edit from silently changing only half the surface. Channels mode declares
8700
+ the identical values so an embed of it stays self-contained. */
8701
+ --slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
8702
+ --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);
8703
+ --slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
8470
8704
  .slm *{box-sizing:border-box;margin:0;padding:0}
8471
8705
  .slm button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
8472
8706
  .slm input{font:inherit}
@@ -8479,7 +8713,8 @@ var CSS2 = `
8479
8713
  .slm-mode.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
8480
8714
  .slm-live{display:inline-flex;align-items:center;gap:6px;font-size:11px;letter-spacing:.12em;font-weight:800;color:var(--slm-muted)}
8481
8715
  .slm-live-dot{width:8px;height:8px;border-radius:50%;background:#8b94ac}
8482
- .slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);animation:slm-pulse 2s infinite}
8716
+ .slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);
8717
+ animation:slm-pulse var(--slm-mo-ambient) infinite}
8483
8718
  @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)}}
8484
8719
  .slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
8485
8720
  border-top:1px solid var(--slm-line)}
@@ -8488,7 +8723,11 @@ var CSS2 = `
8488
8723
  font-variant-numeric:tabular-nums;white-space:nowrap}
8489
8724
  .slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
8490
8725
  .slm-kpi .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:baseline}
8491
- .slm-kpi.changed b{animation:slm-kpi-bump .58s cubic-bezier(.2,.8,.2,1)}
8726
+ /* The one playful moment this surface is allowed (\xA72 --mo-spring). It ran at
8727
+ .58s against a 200ms catalog, which read as a different design language from
8728
+ the dashboard tile it mirrors; Channels mode's own count bump is the same
8729
+ pattern and must stay in step with it. */
8730
+ .slm-kpi.changed b{animation:slm-kpi-bump var(--slm-mo-base) var(--slm-mo-spring)}
8492
8731
  .slm-kpidelta{position:absolute;right:4px;top:-12px;padding:2px 5px;border-radius:999px;background:rgba(34,160,107,.17);
8493
8732
  color:#5bd39b!important;font-size:9px!important;letter-spacing:0!important;text-transform:none!important;white-space:nowrap;
8494
8733
  animation:slm-kpi-delta 1.45s ease-out both;pointer-events:none}
@@ -8507,12 +8746,14 @@ var CSS2 = `
8507
8746
  .slm-hud-chip{padding:6px 11px;border-radius:999px;font-size:12px;font-weight:700;background:var(--slm-surface);
8508
8747
  border:1px solid var(--slm-line);color:var(--slm-text)}
8509
8748
  .slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
8510
- background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;transition:opacity .2s}
8749
+ background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;
8750
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
8511
8751
  .slm-zoomhint.on{opacity:1}
8512
8752
  .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));
8513
8753
  padding:8px 12px;border:1px solid var(--slm-line);border-radius:999px;background:color-mix(in srgb,var(--slm-surface) 92%,transparent);
8514
8754
  box-shadow:0 10px 34px rgba(0,0,0,.32);opacity:0;transform:translate(-50%,-8px);pointer-events:none;
8515
- transition:opacity .18s ease,transform .24s ease;backdrop-filter:blur(10px)}
8755
+ transition:opacity var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out);
8756
+ backdrop-filter:blur(10px)}
8516
8757
  .slm-liveevent.on{opacity:1;transform:translate(-50%,0)}
8517
8758
  .slm.block-mode .slm-liveevent{top:52px}
8518
8759
  .slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
@@ -8532,7 +8773,8 @@ var CSS2 = `
8532
8773
  /* activity feed */
8533
8774
  .slm-feed{display:flex;flex-direction:column;gap:0}
8534
8775
  .slm-feedrow{display:flex!important;width:100%;align-items:center;gap:9px;padding:8px 2px!important;border-bottom:1px solid var(--slm-line)!important;
8535
- border-radius:6px;font-size:12.5px;text-align:left!important;animation:slm-in .35s ease}
8776
+ border-radius:6px;font-size:12.5px;text-align:left!important;
8777
+ animation:slm-in var(--slm-mo-quick) var(--slm-mo-out)}
8536
8778
  .slm-feedrow:hover{background:rgba(255,255,255,.035)!important}
8537
8779
  @keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
8538
8780
  .slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
@@ -8599,7 +8841,8 @@ var CSS2 = `
8599
8841
 
8600
8842
  /* toast */
8601
8843
  .slm-toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);padding:10px 16px;border-radius:10px;
8602
- font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;transition:opacity .2s;
8844
+ font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;
8845
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out);
8603
8846
  background:var(--slm-surface);color:var(--slm-text);border:1px solid var(--slm-line);z-index:5}
8604
8847
  .slm-toast.on{opacity:1}
8605
8848
  .slm-toast.err{background:#c0392b;color:#fff;border-color:#c0392b}
@@ -8610,7 +8853,9 @@ var CSS2 = `
8610
8853
  .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
8611
8854
  .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
8612
8855
  .slm-sectionlist + .slm-eyebrow{margin-top:18px}
8613
- .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}
8856
+ .slm-sectionrow{width:100%;padding:10px!important;border:1px solid var(--slm-line)!important;border-radius:10px;
8857
+ background:var(--slm-surface)!important;text-align:left!important;
8858
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-quick) var(--slm-mo-out)}
8614
8859
  .slm-sectionrow:hover{border-color:var(--slm-muted)!important;transform:translateY(-1px)}
8615
8860
  .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
8616
8861
  .slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
@@ -8629,7 +8874,8 @@ var CSS2 = `
8629
8874
  .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
8630
8875
  /* sections: availability windows */
8631
8876
  .slm-availlist{display:flex;flex-direction:column;gap:8px;margin:2px 0 12px}
8632
- .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}
8877
+ .slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
8878
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out),opacity var(--slm-mo-quick) var(--slm-mo-out)}
8633
8879
  .slm-availrow.zone{background:color-mix(in srgb,var(--slm-surface) 82%,#000)}
8634
8880
  .slm-availrow.hidden{opacity:.62}.slm-availrow.closed{opacity:.82}
8635
8881
  .slm-availhead{display:flex;align-items:center;gap:8px}
@@ -8669,16 +8915,29 @@ var CSS2 = `
8669
8915
  .slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
8670
8916
  .slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
8671
8917
  .slm.compact .slm-kpi[data-kpi="sold-pct"],.slm.compact .slm-kpi[data-kpi="gross-sales"]{display:none}
8918
+ /* Reduced motion, as a BLANKET over the cockpit subtree rather than a list of
8919
+ selectors. The list this replaces named four animations and two transitions,
8920
+ and had silently fallen behind the stylesheet: the zoom hint, the toast and
8921
+ the availability rows all still animated for a user who had asked the OS for
8922
+ none. An enumerated list has to be edited every time a rule is added, and
8923
+ nothing fails when it isn't \u2014 so it drifts. This cannot.
8924
+
8925
+ Motion is removed, never the information it carried: Channels mode's own
8926
+ block substitutes static outlines for its shake and success states, and it
8927
+ stays authoritative for those. No JS here waits on animationend or
8928
+ transitionend, so cutting them outright strands no state. */
8672
8929
  @media (prefers-reduced-motion:reduce){
8673
- .slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
8674
- .slm-liveevent,.slm-sectionrow{transition:none!important}
8930
+ .slm,.slm *,.slm *::before,.slm *::after{
8931
+ animation:none!important;
8932
+ transition:none!important;
8933
+ scroll-behavior:auto!important}
8675
8934
  }
8676
8935
  ${CHANNELS_CSS}`;
8677
8936
  function injectStyle() {
8678
8937
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
8679
8938
  const el = document.createElement("style");
8680
8939
  el.id = STYLE_ID2;
8681
- el.textContent = CSS2;
8940
+ el.textContent = MANAGER_CSS;
8682
8941
  document.head.appendChild(el);
8683
8942
  }
8684
8943
  function themeVars(theme) {
@@ -8738,6 +8997,11 @@ var SeatManager = class {
8738
8997
  this.reconnectTimer = null;
8739
8998
  this.attempt = 0;
8740
8999
  this.closed = false;
9000
+ /** Mirrors the `live` root class, so the getter never has to read the DOM. */
9001
+ this.connectionStatus = "reconnecting";
9002
+ /** When the server last told us something. Stamped on accepted traffic only —
9003
+ * a socket that opens and says nothing has not refreshed anything. */
9004
+ this.lastMessageAt = null;
8741
9005
  this.ready = false;
8742
9006
  this.feed = [];
8743
9007
  this.feedTimer = null;
@@ -8947,6 +9211,12 @@ var SeatManager = class {
8947
9211
  },
8948
9212
  worldToScreen: (point) => this.renderer?.worldToScreen(point) ?? null,
8949
9213
  seatPixelSize: () => this.seatPixelSize(),
9214
+ isSeatDetail: () => this.renderer?.getRung?.() === "seats",
9215
+ showSectionOverview: () => {
9216
+ this.renderer?.clearSectionFocus();
9217
+ this.renderer?.setRung?.("sections");
9218
+ },
9219
+ focusSection: (sectionId) => this.renderer?.focusSection(sectionId),
8950
9220
  isCompact: () => !!this.root?.classList.contains("compact"),
8951
9221
  setMapInert: (inert) => {
8952
9222
  this.mapHost.toggleAttribute("inert", inert);
@@ -8956,13 +9226,15 @@ var SeatManager = class {
8956
9226
  onError: (err) => this.opts.onError?.(err)
8957
9227
  };
8958
9228
  }
8959
- /** Approximate on-screen seat size, for the channel overlay's marks. Derived
8960
- * from the live camera so the overlay tracks zoom without a renderer hook. */
9229
+ /** Actual on-screen seat diameter, for the channel overlay's marks. The
9230
+ * renderer's base seat radius is 9 chart units; retaining the camera scale
9231
+ * (rather than capping it) keeps every preview paint aligned with the real
9232
+ * chart geometry at deep zoom. */
8961
9233
  seatPixelSize() {
8962
9234
  const rect = this.renderer?.getVisibleWorldRect?.();
8963
9235
  const width = this.mapHost?.clientWidth ?? 0;
8964
9236
  if (!rect?.width || !width) return 6;
8965
- return Math.max(3, Math.min(24, width / rect.width * 14));
9237
+ return Math.max(3, width / rect.width * 18);
8966
9238
  }
8967
9239
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
8968
9240
  setHeatOverlay(enabled) {
@@ -9138,6 +9410,16 @@ var SeatManager = class {
9138
9410
  getControlRoomSnapshot(windowMinutes = this.trendWindowMinutes) {
9139
9411
  return this.setTrendWindow(windowMinutes);
9140
9412
  }
9413
+ /**
9414
+ * The realtime link's current state and the "as of" behind it.
9415
+ *
9416
+ * Pair with `onConnectionChange` for the edges: a host that mounts after a
9417
+ * drop, or re-reads on tab focus, needs to be able to ASK rather than wait
9418
+ * for the next transition that may never come.
9419
+ */
9420
+ getConnection() {
9421
+ return { status: this.connectionStatus, lastMessageAt: this.lastMessageAt };
9422
+ }
9141
9423
  getLog(opts = {}) {
9142
9424
  return this.api.log(this.key, opts);
9143
9425
  }
@@ -9202,6 +9484,10 @@ var SeatManager = class {
9202
9484
  onSelect: (seat) => this.handleSeatSelect(seat),
9203
9485
  onDeselect: () => this.syncSelection(),
9204
9486
  onMarquee: () => this.syncSelection(),
9487
+ onSectionTap: (sectionId) => {
9488
+ this.renderer?.focusSection(sectionId);
9489
+ this.channels?.handleSectionFocus(sectionId);
9490
+ },
9205
9491
  onViewChange: () => {
9206
9492
  this.updateZoomHint();
9207
9493
  this.channels?.handleViewChange();
@@ -9212,10 +9498,11 @@ var SeatManager = class {
9212
9498
  this.applyHeatOverlay();
9213
9499
  this.updateZoomHint();
9214
9500
  }
9215
- /** Block and Channels are both bulk-selection tools: marquee, ⌘A, category,
9216
- * section. The two differ only in WHICH statuses they may act on. */
9501
+ /** Block always uses a marquee. Channels only enables its marquee after the
9502
+ * organizer deliberately chooses Assign seats; Pan map keeps desktop drag
9503
+ * available for large charts. */
9217
9504
  isBulkSelectMode() {
9218
- return this.mode === "block" || this.mode === "channels" && this.channels?.canSelect() === true;
9505
+ return this.mode === "block" || this.mode === "channels" && this.channels?.usesMarqueeSelection() === true;
9219
9506
  }
9220
9507
  /**
9221
9508
  * Block never touches held or booked inventory, so it cannot select it.
@@ -9225,7 +9512,7 @@ var SeatManager = class {
9225
9512
  */
9226
9513
  selectableStatuses() {
9227
9514
  if (this.mode === "block") return ["free", "not_for_sale"];
9228
- if (this.mode === "inspect" || this.isBulkSelectMode()) {
9515
+ if (this.mode === "inspect" || this.isBulkSelectMode() || this.mode === "channels" && this.channels?.canSelect() === true) {
9229
9516
  return ["free", "held", "booked", "not_for_sale"];
9230
9517
  }
9231
9518
  return [];
@@ -9328,6 +9615,7 @@ var SeatManager = class {
9328
9615
  return;
9329
9616
  }
9330
9617
  if (!msg || typeof msg !== "object") return;
9618
+ this.lastMessageAt = Date.now();
9331
9619
  const m = msg;
9332
9620
  if (Array.isArray(m.hidden) || Array.isArray(m.closed)) {
9333
9621
  this.updateEffectiveAvailability(m.hidden, m.closed);
@@ -9384,6 +9672,7 @@ var SeatManager = class {
9384
9672
  const objs = await this.api.objects(this.key);
9385
9673
  this.applySnapshot(objs.seats);
9386
9674
  this.updateEffectiveAvailability(objs.hidden, objs.closed);
9675
+ this.lastMessageAt = Date.now();
9387
9676
  } catch {
9388
9677
  }
9389
9678
  }
@@ -9858,6 +10147,14 @@ var SeatManager = class {
9858
10147
  this.root?.classList.toggle("live", on);
9859
10148
  if (this.els.livetext) this.els.livetext.textContent = on ? "LIVE" : "RECONNECTING";
9860
10149
  this.paintMonitorInsights();
10150
+ const next = on ? "live" : "reconnecting";
10151
+ if (next === this.connectionStatus) return;
10152
+ this.connectionStatus = next;
10153
+ try {
10154
+ this.opts.onConnectionChange?.(this.getConnection());
10155
+ } catch (err) {
10156
+ this.opts.onError?.(err);
10157
+ }
9861
10158
  }
9862
10159
  updateZoomHint() {
9863
10160
  const hint = this.els.zoomhint;