# UI kit

> Framework-free custom elements for the screens every multiplayer project needs: invites, a room browser, a room panel, a lobby, voice settings and voice chat, a network indicator and a join loader, sharing one theme and opening inline or as a dialog.

The UI kit is a set of custom elements for the screens a project
without a backend needs, and that are easy to get subtly wrong:
inviting friends, finding a room, a lobby, voice controls, a loading
state and a sign that the connection is bad. Each works in plain HTML
and in any framework that renders custom elements, renders in a shadow
root, is built for phones as well as desktops, and shares one theme
with the others.

| Element | Shows | Entry |
|---|---|---|
| `<irt-invite>` | The join code, a copy button for the share link, and optionally a QR code | `@irt/client/ui/invite` |
| `<irt-room-browser>` | The project's public rooms of a type, with players out of max, and refresh, join, create and quick join | `@irt/client/ui/room-browser` |
| `<irt-room-panel>` | An invite, the connection status, the players, a name field, and create, join and quick join | `@irt/client/ui/room-panel` |
| `<irt-lobby>` | An invite, the lobby's players, ready flags and leader, ready, start and cancel, the countdown and failures | `@irt/client/ui/lobby` |
| `<irt-voice-settings>` | Microphone on or off, devices, an input level meter, push-to-talk and its key | `@irt/client/ui/voice-settings` |
| `<irt-voice-chat>` | The other players in voice, each with a speaking indicator, a volume slider and a mute button | `@irt/client/ui/voice-chat` |
| `<irt-network>` | Signal bars and the round trip; opened, the transport, round trip, jitter and traffic | `@irt/client/ui/network` |
| `<irt-join-loader>` | A small animation while joining, then how long it took | `@irt/client/ui/join-loader` |

Each element below has a screenshot in the light and dark defaults, and
the [gallery](/docs/ui-kit/gallery) runs them all live in the browser,
with a made-up room, lobby and voice.

`@irt/client/ui` defines them all and exports `open`. Import an
element's own entry to pay only for what you use. `@irt/client/panel`
and `@irt/client/lobby` still define the room panel and the lobby.

Coming from the room panel page: `<irt-lobby>` now holds an
`<irt-invite>`, as `el.invite`, in place of its name field and create
and join buttons, and the `--irt-panel-*` custom properties are now the
`--irt-*` properties every element shares, under [Theming](#theming).

Every element takes its client, or its voice, as a property, its text as
`labels`, and fires events that bubble and cross the shadow root.

## Usage

The room panel finds the room and the project connects to it: on
`room-join`, create the `RoomClient` and give it to the panel, which
then shows its status and players. The network indicator and join
loader take the same client.

```html
<irt-room-panel router="wss://eu.irt.io" project="my-project" qr></irt-room-panel>
<irt-join-loader></irt-join-loader>
<irt-network style="position: fixed; top: 8px; right: 8px"></irt-network>
<script type="module" src="./main.js"></script>
```

```ts
// web/main.ts
import { type ReadyRoomClient, RoomClient } from "@irt/client";
import "@irt/client/ui";
import { schema } from "./schema.js";

const panel = document.querySelector("irt-room-panel")!;
const loader = document.querySelector("irt-join-loader")!;
const network = document.querySelector("irt-network")!;

panel.addEventListener("room-join", (e) => {
  panel.client?.leave();
  const client = new RoomClient({ router: panel.router, project: panel.project, room: e.detail.room, schema });
  panel.client = loader.client = network.client = client;
  client.ready.then(() => start(client as ReadyRoomClient<any, any, any>), () => {});
});

function start(client: ReadyRoomClient<any, any, any>): void {
  client.onTick((tick) => (document.title = `Round ${client.state.round} (tick ${tick})`));
}
```

```ts
// web/schema.ts
import { defineSchema, entity } from "@irt/client";

export const schema = defineSchema({ state: entity("scoreboard", { round: "u16" }) });
```

Give the elements a client made with `new RoomClient` rather than
awaiting `connect`, so they show the connection while it's still
connecting or starting the room, and why it failed if it does.

## Inline or dialog

Placed in the page, an element renders where it is. With the `dialog`
attribute it renders inside a `<dialog>` in its shadow root, closed
until `show()`, with a close button. `show()` opens it with
`showModal()`, so focus stays in it, the page behind is inert and Escape
closes it; focus returns to where it was. `close()` closes it, and the
element fires `close`.

For a one-off dialog, `open` appends the element to `<body>` as a
dialog, sets the properties given, shows it, and removes it on close.
It takes any element but `<irt-network>`:

```ts
// web/browse.ts
import { open } from "@irt/client/ui";

const browser = open("irt-room-browser", { router: "wss://eu.irt.io", project: "my-project" });
browser.addEventListener("room-join", (e) => {
  browser.close();
  console.log("joining", e.detail.room);
});
```

`<irt-network>` is the exception: it's small enough to sit in a corner
of the canvas, and clicking it opens its details in a popover.
`show()` and `close()` open and close that popover, and it takes no
`dialog` attribute.

## The elements

### `<irt-invite>`

![The invite, light](/ui-kit/invite-light.png) ![The invite, dark](/ui-kit/invite-dark.png)

The room's join code and share link, with a copy button, and with the
`qr` attribute a QR code for the link. The link is the current page
with `?code=<join code>`, or `?room=<room ID>` for a room without a
code, such as one from quick join. When the page opens with either, the
invite looks the room up and fires `room-join`, so the link a friend
scans lands in the room. The QR code is drawn as SVG by an encoder in
the package.

| Attribute or property | |
|---|---|
| `router`, `project` attributes | The router address, as for `connect`, and the project's slug, to look up a code |
| `qr` attribute | Show a QR code for the share link |
| `room`, `code` properties | The room ID and join code shown and linked to |
| `link` property | The share link, read-only |

Event: `room-join`, `{ room: string; joinCode: string | null }`.

Labels: `code`, `link`, `copy`, `copied`, `copyFailed` (the browser
refused the clipboard), `qr` (the QR code's accessible name) and
`close`. Parts: `room`, `code`, `share-link`,
`copy`, `qr` and `error`. `--irt-qr-fg` and `--irt-qr-bg`, `#000` and
`#fff` by default, color the QR code; keep them dark on light so every
phone can scan it.

### `<irt-room-browser>`

![The room browser, light](/ui-kit/room-browser-light.png) ![The room browser, dark](/ui-kit/room-browser-dark.png)

The project's active public rooms of its `type` attribute, else of every
type, each with its players out of its maximum and a join button,
"Full" and disabled at the maximum. A room's `meta.name`, when it's a
string, labels it, else its ID. Below the list are the create, join by
code and quick join buttons the room panel has; the browser creates
public rooms of its type and quick joins its type.

`listPublic` is limited to 60 requests a minute per address, so the
browser lists the rooms when it's added to the page, or for a dialog
when it opens, and when the player presses refresh, never on a timer.
Call `refresh()` to list them again yourself.

| Attribute or property | |
|---|---|
| `router`, `project` attributes | As for the invite |
| `type` attribute | The room type to list, create and quick join |
| `rooms` property | The rooms last listed, read-only; undefined before the first list |

Event: `room-join`, with `joinCode` null for a room joined from the list.

Labels: `title`, `refresh`, `loading`, `empty`, `players` (with `{n}`
and `{max}`), `joinRoom`, `full`, `create`, `joinCode`, `join`,
`quickJoin` and `close`. Parts: `header`, `title`, `refresh`, `empty`,
`rooms`, `room`, `room-name`, `room-players`, `join-room`, `actions`,
`create`, `join-form`, `join-code`, `join`, `quick-join` and `error`.

### `<irt-room-panel>`

![The room panel, light](/ui-kit/room-panel-light.png) ![The room panel, dark](/ui-kit/room-panel-dark.png)

The whole room screen as one element: an `<irt-invite>`, the connection
status, the players in the room from [presence](/docs/guides/presence),
a name field for the local player, and the create, join by code and
quick join buttons.

- **Create room** calls `createRoom` with `joinCode: true`, so the room
  can be shared without giving out its ID. **Join** looks the code up
  with `joinByCode`, in any case. **Quick join** calls `quickJoin`. Each
  fires `room-join` with the room ID and join code.
- **Status** is one of connecting, waking, open, reconnecting and
  closed, with the close reason, in a live region screen readers
  announce.
- **Players** each show their name, `(you)` for the local player and
  `(away)` while disconnected, for the 10 seconds before their entry
  leaves.
- **Settings.** The buttons don't know the project's settings. When one
  is off, as `players.quickJoin` is by default, the router's 403 message
  is shown under the buttons. Hide a button you don't use with
  `irt-room-panel::part(quick-join) { display: none; }`.

The buttons are hidden while the panel has a client that isn't closed.

| Attribute or property | |
|---|---|
| `router`, `project` attributes | As for the invite |
| `qr` attribute | Passed to the invite |
| `client` property | The `RoomClient` to show; set it on `room-join` |
| `room`, `code` properties | The invite's; the panel sets them, or set them for a room found another way |
| `invite` property | The `<irt-invite>` inside |
| `playerName` property | The saved name, read-only |
| `link` property | The share link, read-only |

Events: `room-join`, and `name-change` with `{ name: string }`, trimmed,
at most 64 characters.

Labels: the invite's and the buttons', and `idle`, `connecting`,
`waking`, `open`, `reconnecting`, `closed` (followed by `: ` and the
reason), `name`, `saveName`, `players`, `you`, `away` and `unnamed`.
Parts: `status` (with `data-status`), `invite`, the invite's `room`,
`code`, `share-link`, `copy` and `qr`, `name-form`, `name`, `save-name`,
`players-section`, `players-label`, `players`, `player` (also `you` and
`away`), the buttons' parts as in the room browser, and `error`.

**Names.** A presence entry's `name` is set by the room, and its
`status` by the player, so the panel puts the name the player types in
its presence status as `name`, next to any other fields the project
keeps there. It saves the name in `localStorage` under
`irt.name.<project>` and sends it again whenever the client connects or
resyncs. Each player is shown by the room's `name` when it has set one,
else by the `name` in their status, else as "Player". A room that wants
a say checks the claimed name and makes it the presence name, which
then wins:

```ts
// src/room.ts
import { defineRoom } from "@irt/room";

export default defineRoom({
  onTick(room) {
    for (const p of room.presence) {
      const claimed = (p.status as { name?: unknown } | null)?.name;
      if (typeof claimed !== "string") continue;
      const name = claimed.replace(/[^\p{L}\p{N} _-]/gu, "").slice(0, 32);
      if (name && name !== p.name) room.presence.set(p.player, { name });
    }
  },
});
```

A project with player tokens can pass the name in the token's `meta`
instead, which is the presence name from the start; see
[Player tokens](/docs/player-tokens).

### `<irt-lobby>`

![The lobby, light](/ui-kit/lobby-light.png) ![The lobby, dark](/ui-kit/lobby-dark.png)

A [lobby](/docs/guides/lobbies): an `<irt-invite>` for the code, share
link and QR code, the lobby's players with who is ready and who leads,
a ready button, start and cancel buttons for the leader, the countdown,
and why a handoff failed. The leader also gets a "Make leader" button
beside each other connected player, which passes the lead with
`client.lead`. Give it the lobby's client, and take the new room's
client from its `handoff` event; see
[the lobbies guide](/docs/guides/lobbies#the-lobby-element).

| Attribute or property | |
|---|---|
| `router`, `project`, `qr` attributes | Passed to the invite |
| `client` property | The lobby's `RoomClient` |
| `invite` property | The `<irt-invite>` inside, for its `room` and `code` and its `room-join` |

Event: `handoff`, with the client's `Handoff`.

Labels: the invite's, and `waiting`, `countdown` (with `{n}` for the
seconds left), `handingOff`, `ready`, `notReady`, `isReady`, `start`,
`cancel`, `you`, `bot`, `leader`, `makeLeader`, `unnamed`, `moving`,
`players` and `failed`, an object with a text for each handoff failure.
Parts: `invite`, `lobby`, `phase` (with `data-phase`), `players-label`,
`lobby-players`, `lobby-player` (also `you`, `bot`, `is-ready` and
`leader`), `make-leader`, `ready`, `start`, `cancel` and `failed`.

### `<irt-voice-settings>`

![The voice settings, light](/ui-kit/voice-settings-light.png) ![The voice settings, dark](/ui-kit/voice-settings-dark.png)

The local player's voice controls, for a `Voice` from
[`joinVoice`](/docs/guides/proximity-voice): the microphone on or off,
the microphone and speaker devices, an input level meter, and
push-to-talk or open mic with the push-to-talk key. The speaker select
shows only where the browser can pick an output device (`setSinkId`).
Device names appear once the player has allowed the microphone. Set the
voice as its `voice` property, and keep the element in the page with
the `dialog` attribute, opening it with `show()`:

```html
<irt-voice-settings dialog></irt-voice-settings>
```

```ts
// web/voice.ts
import { joinVoice } from "@irt/client/voice";
import "@irt/client/ui/voice-settings";

const settings = document.querySelector("irt-voice-settings")!;
settings.voice = joinVoice(client);
document.querySelector("#voice-settings")!.addEventListener("click", () => settings.show());
```

While the element is in the page and push-to-talk is on, it binds the
push-to-talk key, `key`, a `KeyboardEvent.code` (default `KeyV`), and
ignores it while focus is in a text field. It keeps binding it while its
dialog is closed, which is why it stays in the page rather than coming
from `open()`, which removes it on close. The player picks another key
with the key button; Escape cancels picking one. The key the player
picks is kept in `localStorage` under `irt.pttKey.<project>`, for the
voice's project, and wins over `key`, which sets the key for players
who haven't picked one. Where the browser blocks storage it lasts for
the page.

When the microphone fails to start, whether the player turned it on or
it was on when voice joined, it's turned off again and the element says
why: `micBlocked` when the browser refused it, else `micFailed`. The
voice reports the same failures on its `micError` event.

Labels: `title`, `micOn`, `micOff`, `micBlocked` (the browser refused
the microphone), `micFailed`, `input`, `output`, `defaultDevice`,
`level`, `mode`, `openMic`, `pushToTalk`, `key`, `pressKey` and `close`.
Parts: `title`, `mic` (with `aria-pressed`), `error`, `level`,
`level-bar`, `input-field`, `input`, `output-field`, `output`, `mode`,
`key-field`, `key`, `close` and `link`.

### `<irt-voice-chat>`

![The voice chat, light](/ui-kit/voice-chat-light.png) ![The voice chat, dark](/ui-kit/voice-chat-dark.png)

The other players on the voice server, from the voice's `participants`,
each by their presence name, with a speaking indicator, a volume slider
and a mute button. A player in the room who hasn't joined voice isn't
listed. Volume and mute
are the local player's own, through the voice's `setVolume` and
`setBlocked`: a volume from 0 to 1 multiplies the room's distance
attenuation, and a muted player is unsubscribed, so they cost no
bandwidth. Both are kept per project in `localStorage` by player ID, a
volume once the slider is let go, so a player muted in one room stays
muted in the next. Set its `voice` and
its `client`, whose presence names the players.

When the page listens for `report` on the element, each player also gets
a report button, and the event carries `{ player, name }`. IRT keeps no
reports and does no moderation: what a report does is up to the project.

```ts
// web/voice-chat.ts
import { joinVoice } from "@irt/client/voice";
import "@irt/client/ui/voice-chat";

const chat = document.querySelector("irt-voice-chat")!;
chat.client = client;
chat.voice = joinVoice(client);
chat.addEventListener("report", (e) => void fetch("/api/report", { method: "POST", body: JSON.stringify(e.detail) }));
```

Event: `report`, with `{ player, name }`.

Labels: `title`, `empty`, `unnamed`, `speaking`, `muted` (their
microphone is off), `volume`, `block`, `unblock`, `report` and `close`.
Parts: `title`, `players`, `player` (with `data-player`, and
`data-speaking`, `data-muted` and `data-blocked` while they hold),
`speaking`, `name`, `volume`, `block` (with `aria-pressed`), `report`,
`empty`, `close` and `link`.

### `<irt-network>`

![The network indicator, opened, light](/ui-kit/network-light.png) ![The network indicator, opened, dark](/ui-kit/network-dark.png)

Signal bars and the round trip, for a corner of the canvas: 4 bars under
80 ms, 3 under 150, 2 under 250, 1 above, and none while reconnecting.
Clicked, it opens a popover with the transport, WebTransport or
WebSocket, the round trip, the jitter and the traffic in and out per
second, measured from `client.stats` over one-second windows. Set its
`client` property.

Labels: `signal` (the bars' accessible name, with `{n}`), `offline`,
`details`, `transport`, `rtt`, `jitter`, `in`, `out`, `kinds`, an object
with `webtransport`, `websocket` and `unknown`, and `close`. Parts:
`indicator` (with `data-signal`), `bars`, `bar` (also `lit`),
`rtt-short`, `details`, `title`, `transport`, `rtt`, `jitter`, `in`,
`out`, `close` and `link`.

### `<irt-join-loader>`

![The join loader after a join that started the room, light](/ui-kit/join-loader-light.png) ![The join loader after a join that started the room, dark](/ui-kit/join-loader-dark.png)

A small animation while its client connects, reconnects or waits for
the room to start, then "Joined in 180 ms", or "Room started in 1.4 sec"
when the router started or woke the room on the way, from
`client.joinTime`. The message fades after 3 seconds, and the loader
disappears if the client closes. Set its `client` property.

Labels: `connecting`, `waking`, `reconnecting`, `joined` and `started`
(each with `{time}`), and `close`. Parts: `spinner` and `text`, and
`frame` with `data-state`: `idle`, `joining`, `joined` or `faded`.

## Labels

Each element's English text is its `default…Labels` export, such as
`defaultRoomPanelLabels`. Set `labels` to replace any of them by key;
the rest stay English. Numbers and times are formatted with
`Intl.NumberFormat` in the page's language, from `<html lang>`, else
the browser's.

```ts
// web/labels.ts
import type { RoomPanelLabels } from "@irt/client/ui";

const french: Partial<RoomPanelLabels> = {
  idle: "Pas dans une salle",
  connecting: "Connexion…",
  waking: "Démarrage de la salle…",
  open: "Connecté",
  reconnecting: "Reconnexion…",
  closed: "Déconnecté",
  create: "Créer une salle",
  join: "Rejoindre",
  quickJoin: "Partie rapide",
  name: "Ton nom",
};

if (navigator.language.startsWith("fr")) document.querySelector("irt-room-panel")!.labels = french;
```

Close reasons and the router's errors stay in English.

## Theming

The elements read one set of custom properties, inherited through their
shadow roots, so set them once on `:root` or on any ancestor:

| Custom property | Light default | Dark default | |
|---|---|---|---|
| `--irt-bg`, `--irt-fg` | `#fff`, `#1a1a1a` | `#1f1f22`, `#ececec` | Background and text |
| `--irt-muted` | `#666` | `#a3a3a3` | Labels, away players, the irt.io link |
| `--irt-accent`, `--irt-accent-fg` | `#2456d6`, `#fff` | `#8aadff`, `#0b1a3d` | Buttons and focus rings |
| `--irt-danger` | `#b3261e` | `#ff8a80` | Errors, one signal bar |
| `--irt-good`, `--irt-warn` | `#1e7a3c`, `#9a5b00` | `#6fd08c`, `#f2b84b` | Signal bars and indicators |
| `--irt-radius`, `--irt-gap` | `8px`, `12px` | | |
| `--irt-font` | `16px/1.4 system-ui, sans-serif` | | A `font` shorthand |

The defaults follow `prefers-color-scheme`. Each element sets
`color-scheme: light dark`, so its native controls and dialog backdrop
match the system; a project with its own dark theme on a light system
sets `color-scheme: dark` on the elements as well.

Past what the properties cover, style any part with `::part`:

```css
:root {
  --irt-bg: #14161c;
  --irt-fg: #f2f2f2;
  --irt-accent: #ffb000;
  --irt-accent-fg: #14161c;
}
irt-room-panel::part(player you) { font-weight: 600; }
```

Every element with a dialog also has the parts `frame`, `dialog` and
`close`.

## The irt.io link

Every element has a small "irt.io" link in its bottom-right corner, in
`--irt-muted`, opening `https://irt.io` in a new tab; the network
indicator has it in its popover. It has the part `link`, and can't be
turned off.

## Accessibility

Every field has a label, the order of focus follows the order on screen,
and everything works from the keyboard. Status changes are announced
through live regions and errors as alerts. The QR code is an image
labelled by `labels.qr`, and the share link beside it can be copied for
players who can't scan it. Buttons and fields are at least 44 pixels
tall, apart from the network indicator.

## Testing

The elements need a DOM. In Vitest, run their tests under jsdom with a
`// @vitest-environment jsdom` comment at the top of the file, and give
them a client from `@irt/testing`:

```ts
// web/panel.test.ts
// @vitest-environment jsdom
import "@irt/client/ui/room-panel";
import { defineRoom } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, it } from "vitest";

it("lists the players in the room", async () => {
  const room = testRoom(defineRoom({ onJoin: (room, c) => room.presence.set(c.player, { name: c.player.toUpperCase() }) }));
  const alice = await room.connect({ player: "alice" });
  await room.connect({ player: "bob" });
  const panel = document.createElement("irt-room-panel");
  document.body.append(panel);
  panel.client = alice.client;
  await new Promise((r) => setTimeout(r, 0));
  const players = Array.from(panel.shadowRoot!.querySelectorAll("[part~=player]"), (li) => li.textContent);
  expect(players).toEqual(["ALICE (you)", "BOB"]);
});
```

jsdom has no `showModal`, so a test that opens a dialog stubs it.
