@irtio/lobby 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,21 +1,45 @@
1
1
  /**
2
2
  * `<irt-lobby>` — the whole share story in one tag: room code, share link, QR,
3
- * connection status, an optional name box, and the badge.
3
+ * connection status, player count, an optional name box, and the badge.
4
4
  *
5
5
  * Two deliberate constraints shape this file. It is a **plain `HTMLElement`** with shadow DOM and
6
6
  * inline styles, so a consumer needs no build step, no CSS import and no framework. And it takes
7
7
  * its room **structurally** (`AttachableRoom`) rather than importing `@irtio/client`, so the
8
8
  * package keeps zero dependencies and a React/Svelte user can drive it with attributes instead.
9
+ *
10
+ * ### Modal mode
11
+ *
12
+ * Set the boolean `modal` attribute to render the panel inside a fixed, full-viewport backdrop
13
+ * instead of inline. The element supplies no trigger of its own — a dev wires their own button to
14
+ * call `.open()`, and the element handles the rest: it renders hidden until the `open` boolean
15
+ * attribute is present, closes on a backdrop click, Escape, or its own × button, and toggling
16
+ * either the attribute or the `open()`/`close()`/`toggle()` methods keeps the other in sync.
17
+ * Leaving `modal` off keeps the element's non-modal rendering byte-for-byte unchanged.
9
18
  */
10
19
  /**
11
20
  * The part of `@irtio/client`'s `Room` the lobby actually uses. Typed structurally on purpose:
12
21
  * `lobby.attach(room)` accepts a real room without this package depending on the client.
22
+ *
23
+ * `clients`, `maxClients` and `rtt` are all optional, so a room without them still satisfies this
24
+ * type and still attaches; the element just leaves the player count unset. A stand-in whose `on`
25
+ * takes a plain `string` event (rather than the exact three-way overload below) is still
26
+ * structurally compatible — TypeScript checks methods bivariantly — so an older or hand-rolled
27
+ * room needs no changes to attach.
13
28
  */
14
29
  interface AttachableRoom {
15
30
  readonly id: string;
16
31
  readonly link: string;
17
32
  readonly status: string;
33
+ readonly clients?: readonly {
34
+ connected: boolean;
35
+ }[];
36
+ readonly maxClients?: number;
37
+ readonly rtt?: number;
18
38
  on(event: 'status', cb: (status: string) => void): () => void;
39
+ on(event: 'clients', cb: (clients: readonly {
40
+ connected: boolean;
41
+ }[]) => void): () => void;
42
+ on(event: 'rtt', cb: (rtt: number) => void): () => void;
19
43
  }
20
44
  /**
21
45
  * The custom element class. Registered as `irt-lobby` by `defineLobby()`, which this package
@@ -37,14 +61,36 @@ declare class IrtLobbyElement extends HTMLElement {
37
61
  /** Whether the name box is shown. Mirrors the `name-entry` boolean attribute. */
38
62
  get nameEntry(): boolean;
39
63
  set nameEntry(value: boolean);
64
+ /**
65
+ * The connected player count. `undefined` (the attribute absent) hides the whole row; any other
66
+ * value, including `0`, shows it. Mirrors the `players` attribute.
67
+ */
68
+ get players(): number | undefined;
69
+ set players(value: number | undefined);
70
+ /** The room's capacity, if known. `0` or the attribute absent both mean "unknown". */
71
+ get maxPlayers(): number;
72
+ set maxPlayers(value: number);
73
+ /** Whether the panel renders inside a fixed full-viewport backdrop. Mirrors `modal`. */
74
+ get modal(): boolean;
75
+ set modal(value: boolean);
76
+ /** Whether a modal panel is currently shown. Meaningless outside `modal` mode. */
77
+ get isOpen(): boolean;
78
+ /** Shows the modal panel and starts listening for Escape. No-op outside `modal` mode. */
79
+ open(): void;
80
+ /** Hides the modal panel and stops listening for Escape. */
81
+ close(): void;
82
+ /** Convenience for a single trigger button that both opens and closes the modal. */
83
+ toggle(): void;
40
84
  connectedCallback(): void;
41
- attributeChangedCallback(): void;
85
+ attributeChangedCallback(name: string): void;
42
86
  /** Unsubscribing here is why `attach` needs no matching `detach` call in app code. */
43
87
  disconnectedCallback(): void;
44
88
  /**
45
89
  * Drives the element from a live room: copies `id`/`link`/`status` in and follows `status`
46
- * afterwards. Attaching twice replaces the previous subscription. Returns the unsubscribe so
47
- * a framework can tie it to its own teardown; `disconnectedCallback` calls it regardless.
90
+ * afterwards. When the room reports `clients` or `maxClients`, seeds and follows the player
91
+ * count too; a room without them just leaves `players` unset. Attaching twice replaces the
92
+ * previous subscription. Returns the unsubscribe so a framework can tie it to its own teardown;
93
+ * `disconnectedCallback` calls it regardless.
48
94
  */
49
95
  attach(room: AttachableRoom): () => void;
50
96
  /** The room passed to the last `attach`, if it is still attached. */
@@ -60,6 +106,112 @@ declare const LOBBY_TAG = "irt-lobby";
60
106
  */
61
107
  declare function defineLobby(tag?: string): void;
62
108
 
109
+ /**
110
+ * `<irt-status>` — a compact inline status dot for when you already have your own share UI and
111
+ * just want the connection indicator: a coloured dot, and optionally the latest ping next to it.
112
+ *
113
+ * Same constraints as `<irt-lobby>`: plain `HTMLElement`, shadow DOM, inline styles, zero
114
+ * dependencies, and a structural `AttachableRoom` rather than an import of `@irtio/client`.
115
+ */
116
+
117
+ /**
118
+ * The custom element class. Registered as `irt-status` by `defineStatus()`, which this package
119
+ * calls for you on import when a `customElements` registry exists.
120
+ */
121
+ declare class IrtStatusElement extends HTMLElement {
122
+ #private;
123
+ static get observedAttributes(): readonly string[];
124
+ constructor();
125
+ /** One of `@irtio/client`'s statuses. Anything else renders as a grey dot with that text. */
126
+ get status(): string;
127
+ set status(value: string);
128
+ /** The latest round-trip time in milliseconds, if known. */
129
+ get ping(): number | undefined;
130
+ set ping(value: number | undefined);
131
+ /** Whether the ping is rendered next to the dot. Mirrors the `show-ping` boolean attribute. */
132
+ get showPing(): boolean;
133
+ set showPing(value: boolean);
134
+ connectedCallback(): void;
135
+ attributeChangedCallback(): void;
136
+ disconnectedCallback(): void;
137
+ /**
138
+ * Drives the element from a live room: seeds `status` and `ping` and follows `status`
139
+ * afterwards. For the ping, it follows the room's `'rtt'` event when the room emits one;
140
+ * otherwise it polls `room.rtt` every two seconds, since not every room reports RTT as an
141
+ * event. Attaching twice replaces the previous subscription and polling loop.
142
+ */
143
+ attach(room: AttachableRoom): () => void;
144
+ render(): void;
145
+ }
146
+ /** The tag name the element registers under. */
147
+ declare const STATUS_TAG = "irt-status";
148
+ /**
149
+ * Registers `<irt-status>`. Idempotent and safe to call anywhere — importing this package already
150
+ * calls it once, and a second registration (HMR, two bundled copies) would otherwise throw.
151
+ */
152
+ declare function defineStatus(tag?: string): void;
153
+
154
+ /**
155
+ * `<irt-profile>` — a dev-mode bandwidth overlay: where this client's bytes are going, by
156
+ * collection and field, once a second (D65).
157
+ *
158
+ * Same constraints as the other two elements: plain `HTMLElement`, shadow DOM, inline styles,
159
+ * zero dependencies, and a structural room type rather than an import of `@irtio/client`. It is a
160
+ * development surface — mount it behind a flag rather than shipping it on.
161
+ */
162
+ /**
163
+ * The part of `@irtio/client`'s `Room` this element uses, typed structurally so the package keeps
164
+ * its zero dependencies. `profile` is what `joinRoom(schema, { profile: true })` adds; a room
165
+ * without it attaches fine and the overlay says the profiler is off.
166
+ */
167
+ interface ProfilableRoom {
168
+ readonly profile?: {
169
+ perSecond(): ProfileReading;
170
+ total(): ProfileReading;
171
+ };
172
+ }
173
+ /** The shape `@irtio/protocol`'s `ProfileSnapshot` has, structurally. */
174
+ interface ProfileReading {
175
+ readonly rows: readonly {
176
+ readonly kind: string;
177
+ readonly key: string;
178
+ readonly out: number;
179
+ readonly in: number;
180
+ }[];
181
+ readonly bytesIn: number;
182
+ readonly bytesOut: number;
183
+ }
184
+ declare class IrtProfileElement extends HTMLElement {
185
+ #private;
186
+ static get observedAttributes(): readonly string[];
187
+ constructor();
188
+ /** How many rows to show. Mirrors the `rows` attribute. */
189
+ get rows(): number;
190
+ set rows(value: number);
191
+ /** The heading above the table. Mirrors the `title-text` attribute. */
192
+ get titleText(): string;
193
+ set titleText(value: string);
194
+ /**
195
+ * The room to read. Assigning it starts the 1 Hz refresh; assigning `undefined` stops it. A
196
+ * room joined without `profile: true` has no ledger, and the overlay says so rather than
197
+ * rendering zeros.
198
+ */
199
+ get room(): ProfilableRoom | undefined;
200
+ set room(value: ProfilableRoom | undefined);
201
+ /** Symmetry with `<irt-lobby>` and `<irt-status>`: `attach(room)` returns a detach function. */
202
+ attach(room: ProfilableRoom): () => void;
203
+ connectedCallback(): void;
204
+ attributeChangedCallback(): void;
205
+ disconnectedCallback(): void;
206
+ /** Reads the ledger once. Exposed so a test drives it without waiting for the interval. */
207
+ refresh(): void;
208
+ render(): void;
209
+ }
210
+ /** The tag name the element registers under. */
211
+ declare const PROFILE_TAG = "irt-profile";
212
+ /** Registers `<irt-profile>`. Idempotent, like `defineLobby()` and `defineStatus()`. */
213
+ declare function defineProfile(tag?: string): void;
214
+
63
215
  /**
64
216
  * A QR encoder small enough to inline: byte mode, error-correction level L, versions 1–40,
65
217
  * automatic version selection and automatic mask selection.
@@ -87,4 +239,4 @@ interface QrMatrix {
87
239
  */
88
240
  declare function encodeQr(text: string): QrMatrix;
89
241
 
90
- export { type AttachableRoom, IrtLobbyElement, LOBBY_TAG, type QrMatrix, defineLobby, encodeQr };
242
+ export { type AttachableRoom, IrtLobbyElement, IrtProfileElement, IrtStatusElement, LOBBY_TAG, PROFILE_TAG, type ProfilableRoom, type ProfileReading, type QrMatrix, STATUS_TAG, defineLobby, defineProfile, defineStatus, encodeQr };
package/dist/index.js CHANGED
@@ -420,7 +420,7 @@ function encodeQr(text) {
420
420
  return { size: grid.size, version, mask: bestMask, modules: grid.modules };
421
421
  }
422
422
 
423
- // src/element.ts
423
+ // src/status-colors.ts
424
424
  var STATUS_LABELS = {
425
425
  connecting: "connecting",
426
426
  starting: "waking the server",
@@ -435,7 +435,22 @@ var STATUS_COLORS = {
435
435
  reconnecting: "#e0a33a",
436
436
  closed: "#d1495b"
437
437
  };
438
- var OBSERVED = ["room-code", "link", "status", "name-entry"];
438
+ var UNKNOWN_STATUS_COLOR = "#9aa1ad";
439
+
440
+ // src/element.ts
441
+ function countConnected(clients) {
442
+ return clients.filter((c) => c.connected !== false).length;
443
+ }
444
+ var OBSERVED = [
445
+ "room-code",
446
+ "link",
447
+ "status",
448
+ "name-entry",
449
+ "players",
450
+ "max-players",
451
+ "modal",
452
+ "open"
453
+ ];
439
454
  var STYLE = `
440
455
  :host {
441
456
  display: block;
@@ -449,8 +464,10 @@ var STYLE = `
449
464
  max-width: 320px;
450
465
  }
451
466
  :host([hidden]) { display: none; }
467
+ :host([modal]) { display: contents; }
452
468
  * { box-sizing: border-box; }
453
469
  .status { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #9aa1ad; }
470
+ .status .players { margin-left: auto; }
454
471
  .dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa1ad; flex: none; }
455
472
  .code {
456
473
  display: block; width: 100%; margin: 10px 0 4px;
@@ -480,6 +497,29 @@ var STYLE = `
480
497
  .badge { margin-top: 12px; font-size: 11px; color: #6c7280; text-align: center; }
481
498
  .badge a { color: inherit; }
482
499
  .toast { min-height: 14px; font-size: 11px; color: #3ac177; text-align: center; }
500
+ .backdrop {
501
+ position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
502
+ background: rgba(8, 9, 12, 0.6); z-index: 999;
503
+ }
504
+ .backdrop[hidden] { display: none; }
505
+ .panel {
506
+ position: relative;
507
+ box-sizing: border-box;
508
+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
509
+ color: #f2f3f5;
510
+ background: #16181d;
511
+ border: 1px solid #2a2e37;
512
+ border-radius: 12px;
513
+ padding: 16px;
514
+ max-width: 320px;
515
+ width: calc(100vw - 32px);
516
+ }
517
+ .close {
518
+ position: absolute; top: -10px; right: -10px; width: 26px; height: 26px; border-radius: 50%;
519
+ border: 1px solid #2a2e37; background: #16181d; color: #f2f3f5; font-size: 14px; line-height: 1;
520
+ cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0;
521
+ }
522
+ .close:hover { background: #1e2129; }
483
523
  `;
484
524
  var IrtLobbyElement = class extends HTMLElement {
485
525
  static get observedAttributes() {
@@ -490,6 +530,7 @@ var IrtLobbyElement = class extends HTMLElement {
490
530
  /** Set by `attach`; re-read on every render so a reconnect that changes the code is picked up. */
491
531
  #room;
492
532
  #toastTimer;
533
+ #escapeListening = false;
493
534
  constructor() {
494
535
  super();
495
536
  this.#root = this.attachShadow({ mode: "open" });
@@ -524,11 +565,61 @@ var IrtLobbyElement = class extends HTMLElement {
524
565
  if (value) this.setAttribute("name-entry", "");
525
566
  else this.removeAttribute("name-entry");
526
567
  }
568
+ /**
569
+ * The connected player count. `undefined` (the attribute absent) hides the whole row; any other
570
+ * value, including `0`, shows it. Mirrors the `players` attribute.
571
+ */
572
+ get players() {
573
+ if (!this.hasAttribute("players")) return void 0;
574
+ const value = Number(this.getAttribute("players"));
575
+ return Number.isFinite(value) ? value : void 0;
576
+ }
577
+ set players(value) {
578
+ if (value === void 0) this.removeAttribute("players");
579
+ else this.setAttribute("players", String(value));
580
+ }
581
+ /** The room's capacity, if known. `0` or the attribute absent both mean "unknown". */
582
+ get maxPlayers() {
583
+ const value = Number(this.getAttribute("max-players"));
584
+ return Number.isFinite(value) && value > 0 ? value : 0;
585
+ }
586
+ set maxPlayers(value) {
587
+ this.setAttribute("max-players", String(value));
588
+ }
589
+ /** Whether the panel renders inside a fixed full-viewport backdrop. Mirrors `modal`. */
590
+ get modal() {
591
+ return this.hasAttribute("modal");
592
+ }
593
+ set modal(value) {
594
+ if (value) this.setAttribute("modal", "");
595
+ else this.removeAttribute("modal");
596
+ }
597
+ /** Whether a modal panel is currently shown. Meaningless outside `modal` mode. */
598
+ get isOpen() {
599
+ return this.hasAttribute("open");
600
+ }
601
+ /** Shows the modal panel and starts listening for Escape. No-op outside `modal` mode. */
602
+ open() {
603
+ if (!this.modal || this.hasAttribute("open")) return;
604
+ this.setAttribute("open", "");
605
+ }
606
+ /** Hides the modal panel and stops listening for Escape. */
607
+ close() {
608
+ if (!this.hasAttribute("open")) return;
609
+ this.removeAttribute("open");
610
+ }
611
+ /** Convenience for a single trigger button that both opens and closes the modal. */
612
+ toggle() {
613
+ if (this.hasAttribute("open")) this.close();
614
+ else this.open();
615
+ }
527
616
  // -- lifecycle -------------------------------------------------------------
528
617
  connectedCallback() {
529
618
  this.render();
619
+ this.#syncEscapeListener();
530
620
  }
531
- attributeChangedCallback() {
621
+ attributeChangedCallback(name) {
622
+ if (name === "open" || name === "modal") this.#syncEscapeListener();
532
623
  if (this.#root.childElementCount > 0) this.render();
533
624
  }
534
625
  /** Unsubscribing here is why `attach` needs no matching `detach` call in app code. */
@@ -536,12 +627,28 @@ var IrtLobbyElement = class extends HTMLElement {
536
627
  this.#detach?.();
537
628
  this.#detach = void 0;
538
629
  if (this.#toastTimer !== void 0) clearTimeout(this.#toastTimer);
630
+ this.#setEscapeListening(false);
631
+ }
632
+ /** Adds or removes the document-level Escape listener to match whether the modal is open. */
633
+ #syncEscapeListener() {
634
+ this.#setEscapeListening(this.modal && this.hasAttribute("open") && this.isConnected);
539
635
  }
636
+ #setEscapeListening(on) {
637
+ if (on === this.#escapeListening) return;
638
+ this.#escapeListening = on;
639
+ if (on) this.ownerDocument.addEventListener("keydown", this.#onKeydown);
640
+ else this.ownerDocument.removeEventListener("keydown", this.#onKeydown);
641
+ }
642
+ #onKeydown = (event) => {
643
+ if (event.key === "Escape") this.close();
644
+ };
540
645
  // -- the room path ---------------------------------------------------------
541
646
  /**
542
647
  * Drives the element from a live room: copies `id`/`link`/`status` in and follows `status`
543
- * afterwards. Attaching twice replaces the previous subscription. Returns the unsubscribe so
544
- * a framework can tie it to its own teardown; `disconnectedCallback` calls it regardless.
648
+ * afterwards. When the room reports `clients` or `maxClients`, seeds and follows the player
649
+ * count too; a room without them just leaves `players` unset. Attaching twice replaces the
650
+ * previous subscription. Returns the unsubscribe so a framework can tie it to its own teardown;
651
+ * `disconnectedCallback` calls it regardless.
545
652
  */
546
653
  attach(room) {
547
654
  this.#detach?.();
@@ -549,13 +656,23 @@ var IrtLobbyElement = class extends HTMLElement {
549
656
  this.roomCode = room.id;
550
657
  this.link = room.link;
551
658
  this.status = room.status;
552
- const off = room.on("status", (status) => {
553
- this.roomCode = room.id;
554
- this.link = room.link;
555
- this.status = status;
556
- });
659
+ if (room.maxClients !== void 0) this.maxPlayers = room.maxClients;
660
+ if (room.clients !== void 0) this.players = countConnected(room.clients);
661
+ const offs = [];
662
+ offs.push(
663
+ room.on("status", (status) => {
664
+ this.roomCode = room.id;
665
+ this.link = room.link;
666
+ this.status = status;
667
+ })
668
+ );
669
+ offs.push(
670
+ room.on("clients", (clients) => {
671
+ this.players = countConnected(clients);
672
+ })
673
+ );
557
674
  const detach = () => {
558
- off();
675
+ for (const off of offs) off();
559
676
  if (this.#detach === detach) {
560
677
  this.#detach = void 0;
561
678
  this.#room = void 0;
@@ -577,17 +694,54 @@ var IrtLobbyElement = class extends HTMLElement {
577
694
  const style = doc.createElement("style");
578
695
  style.textContent = STYLE;
579
696
  this.#root.append(style);
697
+ const modal = this.modal;
698
+ let host;
699
+ if (modal) {
700
+ const backdrop = doc.createElement("div");
701
+ backdrop.className = "backdrop";
702
+ backdrop.setAttribute("part", "backdrop");
703
+ backdrop.hidden = !this.isOpen;
704
+ backdrop.addEventListener("click", (event) => {
705
+ if (event.target === backdrop) this.close();
706
+ });
707
+ const panel = doc.createElement("div");
708
+ panel.className = "panel";
709
+ panel.setAttribute("part", "panel");
710
+ const closeButton = doc.createElement("button");
711
+ closeButton.type = "button";
712
+ closeButton.className = "close";
713
+ closeButton.setAttribute("part", "close");
714
+ closeButton.setAttribute("aria-label", "close");
715
+ closeButton.textContent = "\xD7";
716
+ closeButton.addEventListener("click", () => this.close());
717
+ panel.append(closeButton);
718
+ backdrop.append(panel);
719
+ this.#root.append(backdrop);
720
+ host = panel;
721
+ } else {
722
+ host = this.#root;
723
+ }
580
724
  const status = this.status;
581
725
  const statusRow = doc.createElement("div");
582
726
  statusRow.className = "status";
583
727
  statusRow.setAttribute("part", "status");
584
728
  const dot = doc.createElement("span");
585
729
  dot.className = "dot";
586
- dot.style.background = STATUS_COLORS[status] ?? "#9aa1ad";
730
+ dot.style.background = STATUS_COLORS[status] ?? UNKNOWN_STATUS_COLOR;
587
731
  const statusText = doc.createElement("span");
588
732
  statusText.textContent = STATUS_LABELS[status] ?? status;
589
733
  statusRow.append(dot, statusText);
590
- this.#root.append(statusRow);
734
+ const players = this.players;
735
+ if (players !== void 0) {
736
+ const playersSpan = doc.createElement("span");
737
+ playersSpan.className = "players";
738
+ playersSpan.setAttribute("part", "players");
739
+ const max = this.maxPlayers;
740
+ const noun = max > 0 ? "players" : players === 1 ? "player" : "players";
741
+ playersSpan.textContent = max > 0 ? `${players}/${max} ${noun}` : `${players} ${noun}`;
742
+ statusRow.append(playersSpan);
743
+ }
744
+ host.append(statusRow);
591
745
  const code = doc.createElement("button");
592
746
  code.type = "button";
593
747
  code.className = "code";
@@ -597,7 +751,7 @@ var IrtLobbyElement = class extends HTMLElement {
597
751
  code.addEventListener("click", () => {
598
752
  void this.#copy(this.roomCode, "room code copied");
599
753
  });
600
- this.#root.append(code);
754
+ host.append(code);
601
755
  const link = doc.createElement("button");
602
756
  link.type = "button";
603
757
  link.className = "link";
@@ -607,16 +761,16 @@ var IrtLobbyElement = class extends HTMLElement {
607
761
  link.addEventListener("click", () => {
608
762
  void this.#copy(this.link, "link copied");
609
763
  });
610
- this.#root.append(link);
764
+ host.append(link);
611
765
  const toast = doc.createElement("div");
612
766
  toast.className = "toast";
613
- this.#root.append(toast);
767
+ host.append(toast);
614
768
  const canvas = doc.createElement("canvas");
615
769
  canvas.setAttribute("part", "qr");
616
- this.#root.append(canvas);
770
+ host.append(canvas);
617
771
  if (this.link) this.#drawQr(canvas, this.link);
618
772
  else canvas.hidden = true;
619
- if (this.nameEntry) this.#root.append(this.#nameForm(doc));
773
+ if (this.nameEntry) host.append(this.#nameForm(doc));
620
774
  const badge = doc.createElement("div");
621
775
  badge.className = "badge";
622
776
  badge.setAttribute("part", "badge");
@@ -626,7 +780,7 @@ var IrtLobbyElement = class extends HTMLElement {
626
780
  anchor.rel = "noreferrer";
627
781
  anchor.textContent = "multiplayer by irt.io";
628
782
  badge.append(anchor);
629
- this.#root.append(badge);
783
+ host.append(badge);
630
784
  }
631
785
  /** The optional name box. Submitting emits `name` **before** the app joins, which is the point. */
632
786
  #nameForm(doc) {
@@ -712,11 +866,338 @@ function defineLobby(tag = LOBBY_TAG) {
712
866
  registry.define(tag, IrtLobbyElement);
713
867
  }
714
868
 
869
+ // src/profile.ts
870
+ var OBSERVED2 = ["rows", "title-text"];
871
+ var REFRESH_MS = 1e3;
872
+ var DEFAULT_ROWS = 8;
873
+ var STYLE2 = `
874
+ :host {
875
+ display: block;
876
+ box-sizing: border-box;
877
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
878
+ font-size: 11px;
879
+ line-height: 1.5;
880
+ color: #c9d1e1;
881
+ background: rgba(11, 14, 20, 0.86);
882
+ border: 1px solid #232838;
883
+ border-radius: 6px;
884
+ padding: 8px 10px;
885
+ min-width: 260px;
886
+ }
887
+ * { box-sizing: border-box; }
888
+ h4 { margin: 0 0 4px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
889
+ font-weight: 600; color: #6b7590; }
890
+ table { border-collapse: collapse; width: 100%; }
891
+ td { padding: 1px 0; white-space: nowrap; }
892
+ td.kind { color: #6b7590; padding-right: 8px; }
893
+ td.key { padding-right: 10px; }
894
+ td.num { text-align: right; font-variant-numeric: tabular-nums; padding-left: 8px; }
895
+ tr.total td { border-top: 1px solid #232838; padding-top: 3px; color: #7dd3fc; }
896
+ p.off { margin: 0; color: #6b7590; }
897
+ `;
898
+ function rate(bytesPerSecond) {
899
+ if (bytesPerSecond >= 1e6) return `${(bytesPerSecond / 1e6).toFixed(1)} MB/s`;
900
+ if (bytesPerSecond >= 1e3) return `${(bytesPerSecond / 1e3).toFixed(1)} kB/s`;
901
+ return `${Math.round(bytesPerSecond)} B/s`;
902
+ }
903
+ function labelFor(kind, key) {
904
+ return kind === "churn" ? `${key} enter/leave (incl. spawns)` : key;
905
+ }
906
+ var IrtProfileElement = class extends HTMLElement {
907
+ static get observedAttributes() {
908
+ return OBSERVED2;
909
+ }
910
+ #shadow;
911
+ #room;
912
+ #timer;
913
+ #reading;
914
+ constructor() {
915
+ super();
916
+ this.#shadow = this.attachShadow({ mode: "open" });
917
+ }
918
+ /** How many rows to show. Mirrors the `rows` attribute. */
919
+ get rows() {
920
+ const value = Number(this.getAttribute("rows"));
921
+ return Number.isInteger(value) && value > 0 ? value : DEFAULT_ROWS;
922
+ }
923
+ set rows(value) {
924
+ this.setAttribute("rows", String(value));
925
+ }
926
+ /** The heading above the table. Mirrors the `title-text` attribute. */
927
+ get titleText() {
928
+ return this.getAttribute("title-text") ?? "bandwidth";
929
+ }
930
+ set titleText(value) {
931
+ this.setAttribute("title-text", value);
932
+ }
933
+ /**
934
+ * The room to read. Assigning it starts the 1 Hz refresh; assigning `undefined` stops it. A
935
+ * room joined without `profile: true` has no ledger, and the overlay says so rather than
936
+ * rendering zeros.
937
+ */
938
+ get room() {
939
+ return this.#room;
940
+ }
941
+ set room(value) {
942
+ this.#room = value;
943
+ this.#reading = void 0;
944
+ this.#stop();
945
+ if (value?.profile) {
946
+ this.#tick();
947
+ this.#timer = setInterval(() => this.#tick(), REFRESH_MS);
948
+ }
949
+ this.render();
950
+ }
951
+ /** Symmetry with `<irt-lobby>` and `<irt-status>`: `attach(room)` returns a detach function. */
952
+ attach(room) {
953
+ this.room = room;
954
+ return () => {
955
+ if (this.#room === room) this.room = void 0;
956
+ };
957
+ }
958
+ connectedCallback() {
959
+ this.render();
960
+ }
961
+ attributeChangedCallback() {
962
+ if (this.#shadow.childElementCount > 0) this.render();
963
+ }
964
+ disconnectedCallback() {
965
+ this.#stop();
966
+ }
967
+ /** Reads the ledger once. Exposed so a test drives it without waiting for the interval. */
968
+ refresh() {
969
+ this.#tick();
970
+ }
971
+ #tick() {
972
+ const profile = this.#room?.profile;
973
+ if (!profile) return;
974
+ this.#reading = profile.perSecond();
975
+ this.render();
976
+ }
977
+ #stop() {
978
+ if (this.#timer !== void 0) {
979
+ clearInterval(this.#timer);
980
+ this.#timer = void 0;
981
+ }
982
+ }
983
+ render() {
984
+ const doc = this.ownerDocument;
985
+ this.#shadow.textContent = "";
986
+ const style = doc.createElement("style");
987
+ style.textContent = STYLE2;
988
+ this.#shadow.append(style);
989
+ const heading = doc.createElement("h4");
990
+ heading.textContent = this.titleText;
991
+ this.#shadow.append(heading);
992
+ if (!this.#room?.profile) {
993
+ const off = doc.createElement("p");
994
+ off.className = "off";
995
+ off.setAttribute("part", "off");
996
+ off.textContent = "join with { profile: true } to see this";
997
+ this.#shadow.append(off);
998
+ return;
999
+ }
1000
+ const reading = this.#reading;
1001
+ const table = doc.createElement("table");
1002
+ table.setAttribute("part", "table");
1003
+ const shown = [...reading?.rows ?? []].filter((r) => r.out !== 0 || r.in !== 0).sort((a, b) => b.out + b.in - (a.out + a.in)).slice(0, this.rows);
1004
+ for (const row of shown) {
1005
+ const tr = doc.createElement("tr");
1006
+ const kind = doc.createElement("td");
1007
+ kind.className = "kind";
1008
+ kind.textContent = row.kind;
1009
+ const key = doc.createElement("td");
1010
+ key.className = "key";
1011
+ key.textContent = labelFor(row.kind, row.key);
1012
+ const inCell = doc.createElement("td");
1013
+ inCell.className = "num";
1014
+ inCell.textContent = rate(row.in);
1015
+ const outCell = doc.createElement("td");
1016
+ outCell.className = "num";
1017
+ outCell.textContent = rate(row.out);
1018
+ tr.append(kind, key, inCell, outCell);
1019
+ table.append(tr);
1020
+ }
1021
+ const total = doc.createElement("tr");
1022
+ total.className = "total";
1023
+ total.setAttribute("part", "total");
1024
+ const label = doc.createElement("td");
1025
+ label.className = "kind";
1026
+ label.textContent = "total";
1027
+ const spacer = doc.createElement("td");
1028
+ const totalIn = doc.createElement("td");
1029
+ totalIn.className = "num";
1030
+ totalIn.textContent = `in ${rate(reading?.bytesIn ?? 0)}`;
1031
+ const totalOut = doc.createElement("td");
1032
+ totalOut.className = "num";
1033
+ totalOut.textContent = `out ${rate(reading?.bytesOut ?? 0)}`;
1034
+ total.append(label, spacer, totalIn, totalOut);
1035
+ table.append(total);
1036
+ this.#shadow.append(table);
1037
+ }
1038
+ };
1039
+ var PROFILE_TAG = "irt-profile";
1040
+ function defineProfile(tag = PROFILE_TAG) {
1041
+ const registry = globalThis.customElements;
1042
+ if (!registry || registry.get(tag)) return;
1043
+ registry.define(tag, IrtProfileElement);
1044
+ }
1045
+
1046
+ // src/status.ts
1047
+ var OBSERVED3 = ["status", "ping", "show-ping"];
1048
+ var PING_POLL_MS = 2e3;
1049
+ var STYLE3 = `
1050
+ :host {
1051
+ display: inline-flex;
1052
+ align-items: center;
1053
+ gap: 6px;
1054
+ box-sizing: border-box;
1055
+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
1056
+ font-size: 12px;
1057
+ color: #9aa1ad;
1058
+ }
1059
+ * { box-sizing: border-box; }
1060
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa1ad; flex: none; }
1061
+ .ping { font-variant-numeric: tabular-nums; }
1062
+ `;
1063
+ var IrtStatusElement = class extends HTMLElement {
1064
+ static get observedAttributes() {
1065
+ return OBSERVED3;
1066
+ }
1067
+ #root;
1068
+ #detach;
1069
+ #pingTimer;
1070
+ constructor() {
1071
+ super();
1072
+ this.#root = this.attachShadow({ mode: "open" });
1073
+ }
1074
+ /** One of `@irtio/client`'s statuses. Anything else renders as a grey dot with that text. */
1075
+ get status() {
1076
+ return this.getAttribute("status") ?? "connecting";
1077
+ }
1078
+ set status(value) {
1079
+ this.setAttribute("status", value);
1080
+ }
1081
+ /** The latest round-trip time in milliseconds, if known. */
1082
+ get ping() {
1083
+ if (!this.hasAttribute("ping")) return void 0;
1084
+ const value = Number(this.getAttribute("ping"));
1085
+ return Number.isFinite(value) ? value : void 0;
1086
+ }
1087
+ set ping(value) {
1088
+ if (value === void 0) this.removeAttribute("ping");
1089
+ else this.setAttribute("ping", String(value));
1090
+ }
1091
+ /** Whether the ping is rendered next to the dot. Mirrors the `show-ping` boolean attribute. */
1092
+ get showPing() {
1093
+ return this.hasAttribute("show-ping");
1094
+ }
1095
+ set showPing(value) {
1096
+ if (value) this.setAttribute("show-ping", "");
1097
+ else this.removeAttribute("show-ping");
1098
+ }
1099
+ connectedCallback() {
1100
+ this.render();
1101
+ }
1102
+ attributeChangedCallback() {
1103
+ if (this.#root.childElementCount > 0) this.render();
1104
+ }
1105
+ disconnectedCallback() {
1106
+ this.#detach?.();
1107
+ this.#detach = void 0;
1108
+ this.#stopPolling();
1109
+ }
1110
+ /**
1111
+ * Drives the element from a live room: seeds `status` and `ping` and follows `status`
1112
+ * afterwards. For the ping, it follows the room's `'rtt'` event when the room emits one;
1113
+ * otherwise it polls `room.rtt` every two seconds, since not every room reports RTT as an
1114
+ * event. Attaching twice replaces the previous subscription and polling loop.
1115
+ */
1116
+ attach(room) {
1117
+ this.#detach?.();
1118
+ this.#stopPolling();
1119
+ this.status = room.status;
1120
+ if (room.rtt !== void 0) this.ping = room.rtt;
1121
+ const offs = [];
1122
+ offs.push(
1123
+ room.on("status", (status) => {
1124
+ this.status = status;
1125
+ })
1126
+ );
1127
+ let usesRttEvent = false;
1128
+ try {
1129
+ offs.push(
1130
+ room.on("rtt", (rtt) => {
1131
+ usesRttEvent = true;
1132
+ this.ping = rtt;
1133
+ })
1134
+ );
1135
+ } catch {
1136
+ }
1137
+ this.#pingTimer = setInterval(() => {
1138
+ if (usesRttEvent) return;
1139
+ if (room.rtt !== void 0) this.ping = room.rtt;
1140
+ }, PING_POLL_MS);
1141
+ const detach = () => {
1142
+ for (const off of offs) off();
1143
+ this.#stopPolling();
1144
+ if (this.#detach === detach) this.#detach = void 0;
1145
+ };
1146
+ this.#detach = detach;
1147
+ this.render();
1148
+ return detach;
1149
+ }
1150
+ #stopPolling() {
1151
+ if (this.#pingTimer !== void 0) {
1152
+ clearInterval(this.#pingTimer);
1153
+ this.#pingTimer = void 0;
1154
+ }
1155
+ }
1156
+ render() {
1157
+ const doc = this.ownerDocument;
1158
+ this.#root.textContent = "";
1159
+ const style = doc.createElement("style");
1160
+ style.textContent = STYLE3;
1161
+ this.#root.append(style);
1162
+ const status = this.status;
1163
+ const label = STATUS_LABELS[status] ?? status;
1164
+ this.title = label;
1165
+ this.setAttribute("aria-label", label);
1166
+ const dot = doc.createElement("span");
1167
+ dot.className = "dot";
1168
+ dot.setAttribute("part", "dot");
1169
+ dot.style.background = STATUS_COLORS[status] ?? UNKNOWN_STATUS_COLOR;
1170
+ this.#root.append(dot);
1171
+ if (this.showPing) {
1172
+ const ping = this.ping;
1173
+ const pingSpan = doc.createElement("span");
1174
+ pingSpan.className = "ping";
1175
+ pingSpan.setAttribute("part", "ping");
1176
+ pingSpan.textContent = ping === void 0 || ping <= 0 ? "" : `${Math.round(ping)}ms`;
1177
+ this.#root.append(pingSpan);
1178
+ }
1179
+ }
1180
+ };
1181
+ var STATUS_TAG = "irt-status";
1182
+ function defineStatus(tag = STATUS_TAG) {
1183
+ const registry = globalThis.customElements;
1184
+ if (!registry || registry.get(tag)) return;
1185
+ registry.define(tag, IrtStatusElement);
1186
+ }
1187
+
715
1188
  // src/index.ts
716
1189
  defineLobby();
1190
+ defineStatus();
1191
+ defineProfile();
717
1192
  export {
718
1193
  IrtLobbyElement,
1194
+ IrtProfileElement,
1195
+ IrtStatusElement,
719
1196
  LOBBY_TAG,
1197
+ PROFILE_TAG,
1198
+ STATUS_TAG,
720
1199
  defineLobby,
1200
+ defineProfile,
1201
+ defineStatus,
721
1202
  encodeQr
722
1203
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/lobby",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "<irt-lobby>: room code, share link, QR, connection status, name entry",
5
5
  "license": "MIT",
6
6
  "publishConfig": {