contour-deck 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -186,7 +186,7 @@ The laser dot eases towards its target and fades when released; with `prefers-re
186
186
  #### The relay
187
187
 
188
188
  The relay is a WebSocket forwarder, one room per talk, specified in [docs/PROTOCOL.md](docs/PROTOCOL.md): rooms, the pairing messages, close codes, and limits (2 KB frames, 60 messages a second, 4 guests).
189
- `relay(url, { room, role, key })` and `broadcast(room)` are the two `Transport`s (`send`, `subscribe`, `watch`, `reconnect`, `close`, `status`); the relay one reconnects with backoff, except after `4003` (removed by the host) and `4009` (another tab of the session took the host seat).
189
+ `relay(url, { room, role, key })` and `broadcast(room)` are the two `Transport`s (`send`, `subscribe`, `watch`, `reconnect`, `close`, `status`); the relay one reconnects with backoff (up to 15 s between tries, 60 s after `4004`, no host), except after `4003` (removed by the host), `4009` (another tab of the session took the host seat), and, for a guest, `4029` (room full).
190
190
  The demo takes a relay from the query string, the deployed one or one on this machine: `https://wayne930242.github.io/contour-deck/?relay=wss://contour-relay.wayne930242.workers.dev&view=deck`, or `?relay=ws://localhost:8787`; it ignores any other.
191
191
  The phone must be able to open the page's URL, so a deck on `localhost` needs a tunnel or the LAN address for the phone part.
192
192
 
@@ -3,7 +3,7 @@ import type { Transport } from "./transport.js";
3
3
  /**
4
4
  * `connecting`: no answer from the room yet. `pairing`: showing a code, waiting for the presenter.
5
5
  * `granted`: in control. `denied`, `revoked`: the presenter said no; the connection is closed until `retry()`.
6
- * `offline`: the connection ended and is being retried.
6
+ * `offline`: the connection ended and is being retried, except after 4029 (room full), where it waits for `retry()`.
7
7
  */
8
8
  export type GuestPhase = "connecting" | "pairing" | "granted" | "denied" | "revoked" | "offline";
9
9
  export interface Stroke {
@@ -25,7 +25,7 @@ export interface Guest {
25
25
  /** Starts an ink stroke; its points go out in batches. */
26
26
  stroke(): Stroke;
27
27
  clearInk(): void;
28
- /** After `denied` or `revoked`: connects again and asks anew. */
28
+ /** After `denied` or `revoked`, or offline after 4029 (room full): connects again and asks anew. */
29
29
  retry(): void;
30
30
  /** Calls `listener` after every change of phase, code, or state. */
31
31
  subscribe(listener: () => void): () => void;
@@ -202,10 +202,16 @@ export function createGuest({ room, open, trusted = false }) {
202
202
  },
203
203
  clearInk: () => send({ t: "ink-clear" }),
204
204
  retry() {
205
- if (closed || (phase !== "denied" && phase !== "revoked"))
205
+ if (closed)
206
+ return;
207
+ const full = phase === "offline" && transport.code === 4029;
208
+ if (!full && phase !== "denied" && phase !== "revoked")
206
209
  return;
207
210
  phase = "connecting";
208
- connect();
211
+ if (full)
212
+ transport.reconnect();
213
+ else
214
+ connect();
209
215
  emit();
210
216
  },
211
217
  subscribe(listener) {
@@ -6,12 +6,18 @@ export interface RemoteLabels {
6
6
  remoteQr: string;
7
7
  remoteHint: string;
8
8
  notesQr: string;
9
+ /** Names the switch between the two QR codes. */
10
+ qrMode: string;
9
11
  notesHint: string;
10
12
  noRelay: string;
11
13
  relayConnecting: string;
12
14
  relayOpen: string;
13
15
  relayClosed: string;
14
16
  roomTaken: string;
17
+ /** Name of the bar button while another tab holds the room. */
18
+ roomTakenBar: string;
19
+ /** Takes the room back for this tab. */
20
+ useThisWindow: string;
15
21
  request: string;
16
22
  /** Name of the bar button while phones wait for an answer. */
17
23
  waiting(count: number): string;
@@ -4,12 +4,15 @@ export const defaultRemoteLabels = {
4
4
  remoteQr: "手機遙控",
5
5
  remoteHint: "掃描後手機會顯示確認碼,在這裡按允許才能控制。",
6
6
  notesQr: "講稿",
7
+ qrMode: "QR 碼用途",
7
8
  notesHint: "手機自己翻頁的講稿,不需要連線。",
8
9
  noRelay: "這個頁面沒有設定中繼服務,手機無法遙控;講稿與講者視窗仍可使用。",
9
10
  relayConnecting: "中繼連線中",
10
11
  relayOpen: "中繼已連線",
11
12
  relayClosed: "中繼離線,重試中",
12
13
  roomTaken: "房間已被另一個視窗使用",
14
+ roomTakenBar: "遙控:房間已被另一個視窗使用",
15
+ useThisWindow: "改用這個視窗",
13
16
  request: "有手機要求控制",
14
17
  waiting: count => `遙控:${count} 支手機等待允許`,
15
18
  code: "確認碼",
@@ -30,7 +33,7 @@ export const defaultRemoteLabels = {
30
33
  revoked: "電腦收回了控制權。",
31
34
  retry: "重新要求控制",
32
35
  noHost: "找不到簡報:電腦還沒開啟遙控,或已經關閉。重試中…",
33
- full: "已有太多裝置連上這場簡報。重試中…",
36
+ full: "已有太多裝置連上這場簡報。等有人離開後再試一次。",
34
37
  badLink: "這個連結無法遙控,請重新掃描電腦上的 QR。",
35
38
  notesOnly: "只看講稿",
36
39
  leave: "離開",
@@ -24,6 +24,8 @@ export interface PanelOptions {
24
24
  allow(id: string): void;
25
25
  deny(id: string): void;
26
26
  revoke(): void;
27
+ /** After 4009: takes the host seat back for this tab. */
28
+ retake(): void;
27
29
  presenter(): void;
28
30
  resetTimer(): void;
29
31
  end(): void;
@@ -16,12 +16,20 @@ export function createPanel(options) {
16
16
  const closeButton = button("close", "✕");
17
17
  closeButton.setAttribute("aria-label", labels.close);
18
18
  head.append(h("h2", "", labels.title), status, closeButton);
19
+ // Another tab with the same key holds the seat (4009); this tab takes it back only when asked.
20
+ const retake = el.appendChild(h("div", "ct-rp-row"));
21
+ retake.append(h("p", "", labels.roomTaken), button("retake", labels.useThisWindow, true));
22
+ retake.hidden = true;
19
23
  /** Requests and the paired phone: the part that changes. */
20
24
  const live = el.appendChild(h("div", "ct-rp-live", "", { "aria-live": "polite" }));
21
25
  live.style.display = "contents";
26
+ // One QR at a time: the phone remote or the notes, chosen with a switch above it.
27
+ const modes = el.appendChild(h("div", "ct-rp-modes", "", { role: "group", "aria-label": labels.qrMode }));
22
28
  const codes = el.appendChild(h("div", "ct-rp-qrs"));
23
- function tile(title, hint, link) {
24
- const figure = codes.appendChild(h("figure", "ct-rp-qr"));
29
+ const tiles = {};
30
+ function tile(mode, title, hint, link) {
31
+ const figure = codes.appendChild(h("figure", "ct-rp-qr", "", { "data-ct-qr": mode }));
32
+ tiles[mode] = { figure, switch: modes.appendChild(button(`qr:${mode}`, title)) };
25
33
  const image = h("div", "ct-rp-img");
26
34
  image.hidden = true;
27
35
  figure.append(image, h("h3", "", title), h("p", "", hint), h("a", "ct-rp-url", link, { href: link, target: "_blank", rel: "noopener" }));
@@ -37,10 +45,19 @@ export function createPanel(options) {
37
45
  .catch(() => { });
38
46
  }
39
47
  if (options.remoteLink)
40
- tile(labels.remoteQr, labels.remoteHint, options.remoteLink);
48
+ tile("remote", labels.remoteQr, labels.remoteHint, options.remoteLink);
41
49
  else
42
50
  codes.before(h("p", "", labels.noRelay));
43
- tile(labels.notesQr, labels.notesHint, options.notesLink);
51
+ tile("notes", labels.notesQr, labels.notesHint, options.notesLink);
52
+ // Without a relay there is only the notes QR, and nothing to switch.
53
+ modes.hidden = Object.keys(tiles).length < 2;
54
+ function showQr(mode) {
55
+ for (const [name, { figure, switch: control }] of Object.entries(tiles)) {
56
+ figure.hidden = name !== mode;
57
+ control.setAttribute("aria-pressed", String(name === mode));
58
+ }
59
+ }
60
+ showQr(options.remoteLink ? "remote" : "notes");
44
61
  const tools = el.appendChild(h("div", "ct-rp-row"));
45
62
  const time = h("b", "ct-rp-time", "0:00");
46
63
  const timer = h("span", "ct-rp-row");
@@ -52,12 +69,16 @@ export function createPanel(options) {
52
69
  allow: options.allow,
53
70
  deny: options.deny,
54
71
  revoke: options.revoke,
72
+ retake: options.retake,
55
73
  presenter: options.presenter,
56
74
  reset: options.resetTimer,
57
75
  end: options.end,
58
76
  };
59
77
  el.addEventListener("click", event => {
60
78
  const control = event.target.closest("[data-ct-rp]");
79
+ const action = control?.dataset.ctRp ?? "";
80
+ if (action.startsWith("qr:"))
81
+ return showQr(action.slice(3));
61
82
  if (control)
62
83
  actions[control.dataset.ctRp ?? ""]?.(control.dataset.ctId ?? "");
63
84
  });
@@ -101,6 +122,7 @@ export function createPanel(options) {
101
122
  : view.relay === "closed"
102
123
  ? labels.relayClosed
103
124
  : labels.relayConnecting;
125
+ retake.hidden = !(view.relay === "closed" && view.code === 4009);
104
126
  status.textContent = relayText;
105
127
  status.hidden = !relayText;
106
128
  status.dataset.st = view.relay ?? "";
@@ -139,7 +139,8 @@ export function remoteView(root, options = {}) {
139
139
  const { phase, closeCode } = guest;
140
140
  cardCode.hidden = phase !== "pairing";
141
141
  cardCode.textContent = guest.code;
142
- retry.hidden = phase !== "denied" && phase !== "revoked";
142
+ // A full room is not retried by itself: each try counts against the relay's limit for this network.
143
+ retry.hidden = phase !== "denied" && phase !== "revoked" && !(phase === "offline" && closeCode === 4029);
143
144
  cardBadge.textContent = phase === "pairing" ? labels.code : phase === "offline" ? labels.offline : labels.title;
144
145
  cardText.textContent =
145
146
  phase === "pairing"
@@ -182,6 +182,7 @@ function start(deck, root, options, { app, stage, viewport }, { key, room, befor
182
182
  allow: id => pairing.allow(id),
183
183
  deny: id => pairing.deny(id),
184
184
  revoke: () => pairing.revoke(),
185
+ retake: () => wan?.reconnect(),
185
186
  presenter: () => void window.open(pageLink("presenter", room), "contour-deck-presenter", "popup,width=1180,height=760"),
186
187
  resetTimer() {
187
188
  clock.reset();
@@ -204,15 +205,19 @@ function start(deck, root, options, { app, stage, viewport }, { key, room, befor
204
205
  paired: pairing.controller ? "online" : pairing.token ? "offline" : "no",
205
206
  elapsed: clock.read(Date.now()).elapsed,
206
207
  });
207
- // With the panel closed, a waiting phone shows as a count on the bar button; nothing opens by itself.
208
- if (requests.length) {
208
+ // With the panel closed, a waiting phone shows as a count on the bar button, and a seat lost to another tab
209
+ // as a mark on it; nothing opens by itself.
210
+ const taken = wan?.status === "closed" && wan.code === 4009;
211
+ barButton?.toggleAttribute("data-ct-taken", taken);
212
+ if (requests.length)
209
213
  barButton?.setAttribute("data-ct-count", String(requests.length));
210
- barButton?.setAttribute("aria-label", labels.waiting(requests.length));
211
- }
212
- else {
214
+ else
213
215
  barButton?.removeAttribute("data-ct-count");
216
+ const name = taken ? labels.roomTakenBar : requests.length ? labels.waiting(requests.length) : null;
217
+ if (name)
218
+ barButton?.setAttribute("aria-label", name);
219
+ else
214
220
  barButton?.removeAttribute("aria-label");
215
- }
216
221
  barButton?.setAttribute("aria-pressed", String(panel.open));
217
222
  }
218
223
  const stops = [
@@ -275,7 +280,7 @@ function start(deck, root, options, { app, stage, viewport }, { key, room, befor
275
280
  wan?.close();
276
281
  overlay.destroy();
277
282
  panel.destroy();
278
- for (const name of ["data-ct-count", "aria-label", "aria-pressed"])
283
+ for (const name of ["data-ct-count", "data-ct-taken", "aria-label", "aria-pressed"])
279
284
  barButton?.removeAttribute(name);
280
285
  hosts.delete(deck);
281
286
  },
@@ -4,7 +4,8 @@
4
4
  * and go first in <head>, so a host's own rules win a tie.
5
5
  */
6
6
  const css = `
7
- .ct-btn[data-ct-act="remote"][data-ct-count]::after { content: attr(data-ct-count); content: attr(data-ct-count) / ""; margin-left: .4em; padding: .1em .4em; background: var(--ct-accent); color: var(--ct-accent-fg); }
7
+ .ct-btn[data-ct-act="remote"]:is([data-ct-count], [data-ct-taken])::after { content: attr(data-ct-count); content: attr(data-ct-count) / ""; margin-left: .4em; padding: .1em .4em; background: var(--ct-accent); color: var(--ct-accent-fg); }
8
+ .ct-btn[data-ct-act="remote"][data-ct-taken]::after { content: "!"; content: "!" / ""; }
8
9
 
9
10
  /* ---- over the stage: laser dot and ink ---- */
10
11
  .ct-ink { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 5; pointer-events: none; color: var(--ct-accent); }
@@ -25,8 +26,10 @@ const css = `
25
26
  .ct-rp-code { font: 800 1.9rem/1 var(--ct-mono); letter-spacing: .14em; margin-right: auto; }
26
27
  .ct-rp-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
27
28
  .ct-rp-row > :last-child { margin-left: auto; }
28
- .ct-rp-qrs { display: grid; grid-template-columns: 1fr 1fr; gap: .7rem; }
29
- .ct-rp-qr { margin: 0; display: flex; flex-direction: column; gap: .35rem; min-width: 0; }
29
+ .ct-rp-modes { display: grid; grid-template-columns: 1fr 1fr; gap: .4rem; }
30
+ .ct-rp-modes[hidden], .ct-rp-qr[hidden] { display: none; }
31
+ .ct-rp-qrs { display: grid; justify-items: center; }
32
+ .ct-rp-qr { margin: 0; display: flex; flex-direction: column; gap: .35rem; min-width: 0; width: min(100%, 15rem); }
30
33
  .ct-rp-img { aspect-ratio: 1; background: #fff; border: 1px solid var(--ct-border); }
31
34
  .ct-rp-img svg { display: block; width: 100%; height: 100%; }
32
35
  .ct-rp-url { font: .62rem/1.35 var(--ct-mono); color: var(--ct-muted); overflow-wrap: anywhere; }
@@ -39,6 +42,8 @@ const css = `
39
42
  .ct-pv { position: relative; overflow: hidden; aspect-ratio: var(--ct-ratio, 16 / 9); background: var(--ct-bg); border: 1px solid var(--ct-border); }
40
43
  .ct-app .ct-pv > .ct-stage { inset: auto; left: 0; top: 0; width: 1280px; height: calc(1280px / var(--ct-ratio, 16 / 9)); transform: scale(var(--ct-k, .25)); transform-origin: 0 0; border: 0; z-index: 0; visibility: visible; pointer-events: none; }
41
44
  .ct-pvw { height: 100%; overflow: hidden; }
45
+ /* The copy shows the page as it stands, revealed: a host's entrance motion would hold it at its first frame (often transparent), since the copy is never the current page. */
46
+ .ct-pv.ct-pv > .ct-stage *, .ct-pv.ct-pv > .ct-stage *::before, .ct-pv.ct-pv > .ct-stage *::after { animation: none !important; transition: none !important; }
42
47
 
43
48
  /* ---- the phone ---- */
44
49
  .ct-app.ct-rmt { display: flex; flex-direction: column; grid-template-rows: none; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); font-size: 1rem; -webkit-user-select: none; user-select: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
@@ -30,6 +30,7 @@ export interface RelayOptions {
30
30
  export declare function relayUrl(base: string, { room, role, key }: RelayOptions): string;
31
31
  /**
32
32
  * A WebSocket to the relay. A connection that ends is opened again with exponential backoff
33
- * (0.5 s doubling to 15 s, with jitter), and at once when the page comes back or the network returns.
33
+ * (0.5 s doubling to 15 s, 60 s after 4004, with jitter), and at once when the page comes back or the network
34
+ * returns. After a final close it waits for `reconnect()`.
34
35
  */
35
36
  export declare function relay(base: string, options: RelayOptions): Transport;
@@ -46,18 +46,25 @@ export function broadcast(room) {
46
46
  }
47
47
  const FIRST_RETRY = 500;
48
48
  const LAST_RETRY = 15_000;
49
+ /**
50
+ * The longest wait after `4004` (no host): a phone left open after the talk tries about once a minute, so the
51
+ * phones of one venue network do not spend the relay's per-address budget, which the presenter's phone shares.
52
+ */
53
+ const LAST_RETRY_NO_HOST = 60_000;
49
54
  /** `https://relay.example` or `wss://relay.example` to the room endpoint. */
50
55
  export function relayUrl(base, { room, role, key }) {
51
56
  return `${base.replace(/^http/, "ws").replace(/\/+$/, "")}/v1/room/${room}?role=${role}${key ? `&key=${key}` : ""}`;
52
57
  }
53
58
  /**
54
59
  * Closes after which the connection is not opened again by itself: 4009, a newer connection with the same key
55
- * took the host seat (the same session in another tab); 4003, the host removed this guest.
60
+ * took the host seat (the same session in another tab); 4003, the host removed this guest; 4029, a guest found
61
+ * the room full (the audience behind one address would otherwise keep spending its request budget).
56
62
  */
57
- const FINAL = [4003, 4009];
63
+ const FINAL = { host: [4003, 4009], guest: [4003, 4009, 4029] };
58
64
  /**
59
65
  * A WebSocket to the relay. A connection that ends is opened again with exponential backoff
60
- * (0.5 s doubling to 15 s, with jitter), and at once when the page comes back or the network returns.
66
+ * (0.5 s doubling to 15 s, 60 s after 4004, with jitter), and at once when the page comes back or the network
67
+ * returns. After a final close it waits for `reconnect()`.
61
68
  */
62
69
  export function relay(base, options) {
63
70
  const messages = emitter();
@@ -108,10 +115,11 @@ export function relay(base, options) {
108
115
  if (ended)
109
116
  return;
110
117
  set("closed");
111
- parked = FINAL.includes(code);
118
+ parked = FINAL[options.role].includes(code);
112
119
  if (parked)
113
120
  return;
114
- const delay = Math.min(LAST_RETRY, FIRST_RETRY * 2 ** attempt++) * (0.5 + Math.random() / 2);
121
+ const last = code === 4004 ? LAST_RETRY_NO_HOST : LAST_RETRY;
122
+ const delay = Math.min(last, FIRST_RETRY * 2 ** attempt++) * (0.5 + Math.random() / 2);
115
123
  timer = window.setTimeout(open, delay);
116
124
  };
117
125
  }
package/docs/PROTOCOL.md CHANGED
@@ -37,7 +37,8 @@ One Durable Object per room (`idFromName(room)`).
37
37
  | no message in the room for 2 hours | closes everyone with `4000` |
38
38
  | a socket exceeds 60 messages/s (burst 120) | extra messages are dropped; above 10 s of sustained excess, closes with `1008` |
39
39
  | request `Origin` not in `ALLOWED_ORIGINS` | HTTP 403 before the upgrade |
40
- | more than 60 requests to room paths a minute from one client address | HTTP 429 before anything else is checked |
40
+ | more than 60 requests to room paths a minute from one client address, not counting host requests with the room's key | HTTP 429 before anything else is checked |
41
+ | more than 30 host requests with the room's key a minute from one client address | HTTP 429 before anything else is checked |
41
42
 
42
43
  `ALLOWED_ORIGINS` is a comma-separated Worker variable (e.g. `https://www.wayneh.tw,https://wayne930242.github.io,http://localhost:4321`). Guests never reach other guests.
43
44
 
@@ -92,8 +93,8 @@ The presenter window on the same computer uses `BroadcastChannel("contour-deck:<
92
93
  | --- | --- |
93
94
  | `4000` | room closed (host left or idle, or a new host took the seat) |
94
95
  | `4003` | removed by the host (after `deny` or `revoke`); the client does not reconnect by itself |
95
- | `4004` | no host in this room |
96
+ | `4004` | no host in this room; the client retries with a longer backoff (up to 60 s between tries) |
96
97
  | `4009` | host replaced: a newer connection with the same key holds the host seat; the client does not reconnect by itself |
97
- | `4029` | room full |
98
+ | `4029` | room full; the client does not reconnect by itself (each retry would count against the address's limit) |
98
99
  | `1008` | rate limit |
99
100
  | `1009` | message too large |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contour-deck",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Outline-first slide decks on real DOM: blocks, slides, pages.",
5
5
  "keywords": [
6
6
  "slides",