contour-deck 0.1.0 → 0.2.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.
Files changed (50) hide show
  1. package/README.md +107 -5
  2. package/dist/astro/Deck.astro +8 -1
  3. package/dist/astro/host.d.ts +7 -0
  4. package/dist/astro/host.js +58 -4
  5. package/dist/core/deck.js +16 -5
  6. package/dist/core/labels.js +1 -0
  7. package/dist/core/types.d.ts +12 -0
  8. package/dist/remote/clock.d.ts +35 -0
  9. package/dist/remote/clock.js +48 -0
  10. package/dist/remote/guest.d.ts +41 -0
  11. package/dist/remote/guest.js +223 -0
  12. package/dist/remote/index.d.ts +18 -0
  13. package/dist/remote/index.js +11 -0
  14. package/dist/remote/ink.d.ts +11 -0
  15. package/dist/remote/ink.js +74 -0
  16. package/dist/remote/labels.d.ts +72 -0
  17. package/dist/remote/labels.js +68 -0
  18. package/dist/remote/links.d.ts +2 -0
  19. package/dist/remote/links.js +12 -0
  20. package/dist/remote/overlay.d.ts +11 -0
  21. package/dist/remote/overlay.js +63 -0
  22. package/dist/remote/pairing.d.ts +40 -0
  23. package/dist/remote/pairing.js +97 -0
  24. package/dist/remote/panel.d.ts +38 -0
  25. package/dist/remote/panel.js +131 -0
  26. package/dist/remote/phone.d.ts +10 -0
  27. package/dist/remote/phone.js +407 -0
  28. package/dist/remote/presenter.d.ts +2 -0
  29. package/dist/remote/presenter.js +126 -0
  30. package/dist/remote/protocol.d.ts +106 -0
  31. package/dist/remote/protocol.js +123 -0
  32. package/dist/remote/qr.d.ts +8 -0
  33. package/dist/remote/qr.js +266 -0
  34. package/dist/remote/sensor.d.ts +9 -0
  35. package/dist/remote/sensor.js +50 -0
  36. package/dist/remote/session.d.ts +39 -0
  37. package/dist/remote/session.js +287 -0
  38. package/dist/remote/store.d.ts +9 -0
  39. package/dist/remote/store.js +27 -0
  40. package/dist/remote/style.d.ts +2 -0
  41. package/dist/remote/style.js +131 -0
  42. package/dist/remote/tilt.d.ts +78 -0
  43. package/dist/remote/tilt.js +148 -0
  44. package/dist/remote/transport.d.ts +35 -0
  45. package/dist/remote/transport.js +162 -0
  46. package/dist/remote/view.d.ts +48 -0
  47. package/dist/remote/view.js +119 -0
  48. package/docs/CONTRACT.md +14 -0
  49. package/docs/PROTOCOL.md +99 -0
  50. package/package.json +9 -2
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Pointing with the phone: from device orientation to a point on the screen. Pure math, no sensors:
3
+ * the phone is held like a laser pointer, its top edge towards the screen, and five aimed samples
4
+ * (centre and four corners) calibrate where the screen is in the room.
5
+ */
6
+ /** The order the guided calibration asks for. */
7
+ export const CALIBRATION_TARGETS = ["centre", "topLeft", "topRight", "bottomRight", "bottomLeft"];
8
+ const RAD = Math.PI / 180;
9
+ /** A screen edge closer than this to the centre means the samples were not aimed at corners. */
10
+ const MIN_RANGE = 2 * RAD;
11
+ const MAX_RANGE = 80 * RAD;
12
+ /** An angle difference brought into (-π, π], so 358° to 2° is +4° and not -356°. */
13
+ export function wrap(angle) {
14
+ const turn = 2 * Math.PI;
15
+ const reduced = ((angle % turn) + turn) % turn;
16
+ return reduced > Math.PI ? reduced - turn : reduced;
17
+ }
18
+ /**
19
+ * The direction the top of the screen points at, from a sensor reading.
20
+ * The reading becomes the rotation matrix R = Rz(alpha)·Rx(beta)·Ry(gamma) of the W3C specification, and
21
+ * the pointing axis is the edge at the top of the screen as it is turned now (`screenAngle`, the value of
22
+ * `screen.orientation.angle`): the device's +Y in portrait, +X after a quarter turn. Reading the axis in
23
+ * the room, and not the three angles one by one, keeps the aim independent of how the wrist is rolled.
24
+ */
25
+ export function aimOf({ alpha, beta, gamma }, screenAngle = 0) {
26
+ const [a, b, g, s] = [alpha * RAD, beta * RAD, gamma * RAD, screenAngle * RAD];
27
+ const [cA, sA, cB, sB, cG, sG] = [Math.cos(a), Math.sin(a), Math.cos(b), Math.sin(b), Math.cos(g), Math.sin(g)];
28
+ // Columns 1 and 2 of R: the device's X and Y axes in room coordinates (east, north, up).
29
+ const xAxis = [cA * cG - sA * sB * sG, cG * sA + cA * sB * sG, -cB * sG];
30
+ const yAxis = [-cB * sA, cA * cB, sB];
31
+ const [east, north, up] = [0, 1, 2].map(i => Math.sin(s) * xAxis[i] + Math.cos(s) * yAxis[i]);
32
+ return { yaw: Math.atan2(-east, north), pitch: Math.asin(Math.min(1, Math.max(-1, up))) };
33
+ }
34
+ const mean = (a, b) => (a + b) / 2;
35
+ /** The average of a few readings taken close together (the hand shakes when the thumb taps), across the yaw seam. */
36
+ export function meanAim(aims) {
37
+ const last = aims[aims.length - 1];
38
+ let yaw = 0;
39
+ let pitch = 0;
40
+ for (const aim of aims) {
41
+ yaw += wrap(aim.yaw - last.yaw);
42
+ pitch += aim.pitch;
43
+ }
44
+ return { yaw: wrap(last.yaw + yaw / aims.length), pitch: pitch / aims.length };
45
+ }
46
+ /**
47
+ * The calibration from the five aimed samples, or `null` when they cannot be a screen:
48
+ * an edge too close to the centre, or both sides of the centre on the same side.
49
+ */
50
+ export function calibrate(aims) {
51
+ const { centre } = aims;
52
+ const yaw = (target) => wrap(aims[target].yaw - centre.yaw);
53
+ const pitch = (target) => aims[target].pitch - centre.pitch;
54
+ const calibration = {
55
+ yaw: centre.yaw,
56
+ pitch: centre.pitch,
57
+ left: mean(yaw("topLeft"), yaw("bottomLeft")),
58
+ right: mean(yaw("topRight"), yaw("bottomRight")),
59
+ top: mean(pitch("topLeft"), pitch("topRight")),
60
+ bottom: mean(pitch("bottomLeft"), pitch("bottomRight")),
61
+ };
62
+ return isCalibration(calibration) ? calibration : null;
63
+ }
64
+ /** True for a calibration that maps to a screen; also the check for one read back from storage. */
65
+ export function isCalibration(value) {
66
+ if (typeof value !== "object" || value === null)
67
+ return false;
68
+ const c = value;
69
+ const numbers = [c.yaw, c.pitch, c.left, c.right, c.top, c.bottom];
70
+ if (!numbers.every((n) => typeof n === "number" && Number.isFinite(n)))
71
+ return false;
72
+ const [, , left, right, top, bottom] = numbers;
73
+ const inRange = (edge) => Math.abs(edge) >= MIN_RANGE && Math.abs(edge) <= MAX_RANGE;
74
+ return [left, right, top, bottom].every(inRange) && left * right < 0 && top * bottom < 0;
75
+ }
76
+ /** A calibration for a screen of ordinary size seen from the middle of a room: ±20° wide, ±12° high around `centre`. */
77
+ export function defaultCalibration(centre) {
78
+ return { yaw: centre.yaw, pitch: centre.pitch, left: 20 * RAD, right: -20 * RAD, top: 12 * RAD, bottom: -12 * RAD };
79
+ }
80
+ /** Half the screen on each side of the centre, each side with its own range: 0 at the `low` edge, 1 at the `high` edge. */
81
+ const along = (delta, low, high) => 0.5 + (delta * low > 0 ? -delta / low : delta / high) / 2;
82
+ /** The aimed point in screen coordinates, origin at the top left; outside [0, 1] when the aim is past an edge. */
83
+ export function project(calibration, aim) {
84
+ return {
85
+ x: along(wrap(aim.yaw - calibration.yaw), calibration.left, calibration.right),
86
+ y: along(aim.pitch - calibration.pitch, calibration.top, calibration.bottom),
87
+ };
88
+ }
89
+ /** The same screen size, with the centre where the phone points now: the answer to drift, or to a step sideways. */
90
+ export function recentre(calibration, aim) {
91
+ return { ...calibration, yaw: aim.yaw, pitch: aim.pitch };
92
+ }
93
+ /**
94
+ * Drift correction while pointing: an aim past an edge drags the calibration along, so the edge stays
95
+ * under the aim and the pointer answers at once when the hand comes back, as a mouse does at the screen edge.
96
+ */
97
+ export function follow(calibration, aim) {
98
+ const { x, y } = project(calibration, aim);
99
+ let { yaw, pitch } = calibration;
100
+ if (x < 0)
101
+ yaw = aim.yaw - calibration.left;
102
+ else if (x > 1)
103
+ yaw = aim.yaw - calibration.right;
104
+ if (y < 0)
105
+ pitch = aim.pitch - calibration.top;
106
+ else if (y > 1)
107
+ pitch = aim.pitch - calibration.bottom;
108
+ return yaw === calibration.yaw && pitch === calibration.pitch ? calibration : { ...calibration, yaw, pitch };
109
+ }
110
+ /**
111
+ * The 1€ filter (Casiez, Roussel, Vogel 2012): a low-pass whose cutoff rises with speed, so a still hand
112
+ * gives a still pointer and a fast sweep is not held back. `t` is in seconds.
113
+ */
114
+ export function oneEuro(minCutoff = 1.2, beta = 8, derivativeCutoff = 1) {
115
+ const share = (cutoff, dt) => 1 / (1 + 1 / (2 * Math.PI * cutoff * dt));
116
+ let held = null;
117
+ let speed = 0;
118
+ let last = 0;
119
+ return (value, t) => {
120
+ if (held === null) {
121
+ held = value;
122
+ last = t;
123
+ return value;
124
+ }
125
+ const dt = Math.max(1e-3, t - last);
126
+ last = t;
127
+ speed += share(derivativeCutoff, dt) * ((value - held) / dt - speed);
128
+ held += share(minCutoff + beta * Math.abs(speed), dt) * (value - held);
129
+ return held;
130
+ };
131
+ }
132
+ const unit = (value) => Math.min(1, Math.max(0, value));
133
+ /** Calibration, drift correction, and smoothing in one: feed it aims, read points. */
134
+ export function createPointer(calibration) {
135
+ let filters = [oneEuro(), oneEuro()];
136
+ return {
137
+ calibration,
138
+ update(aim, t) {
139
+ this.calibration = follow(this.calibration, aim);
140
+ const { x, y } = project(this.calibration, aim);
141
+ return { x: unit(filters[0](unit(x), t)), y: unit(filters[1](unit(y), t)) };
142
+ },
143
+ recentre(aim) {
144
+ this.calibration = recentre(this.calibration, aim);
145
+ filters = [oneEuro(), oneEuro()];
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,35 @@
1
+ import type { Message } from "./protocol.js";
2
+ export type TransportStatus = "connecting" | "open" | "closed";
3
+ /** A decoded frame: an object with a string `t`. The receiver checks the rest. */
4
+ export type Incoming = Record<string, unknown> & {
5
+ t: string;
6
+ };
7
+ export interface Transport {
8
+ readonly status: TransportStatus;
9
+ /** Close code of the last connection that ended (4004, 4009, …); 0 when none did. */
10
+ readonly code: number;
11
+ /** Sends a message; it is dropped unless the transport is open. */
12
+ send(message: Message): void;
13
+ subscribe(listener: (message: Incoming) => void): () => void;
14
+ /** Calls `listener` on every change of `status`. */
15
+ watch(listener: (status: TransportStatus) => void): () => void;
16
+ /** Ends the current connection and opens a new one at once. */
17
+ reconnect(): void;
18
+ close(): void;
19
+ }
20
+ export declare const channelName: (room: string) => string;
21
+ /** Windows of the same browser and origin, through `BroadcastChannel`. No pairing: same browser, trusted. */
22
+ export declare function broadcast(room: string): Transport;
23
+ export interface RelayOptions {
24
+ room: string;
25
+ role: "host" | "guest";
26
+ /** The host key whose hash is `room`; the relay seats a host only with it. Guests have none. */
27
+ key?: string;
28
+ }
29
+ /** `https://relay.example` or `wss://relay.example` to the room endpoint. */
30
+ export declare function relayUrl(base: string, { room, role, key }: RelayOptions): string;
31
+ /**
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.
34
+ */
35
+ export declare function relay(base: string, options: RelayOptions): Transport;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * How messages travel: `broadcast` between windows of one browser, `relay` between devices
3
+ * through the WebSocket relay of docs/PROTOCOL.md. Both carry the same messages.
4
+ */
5
+ import { decode, MAX_FRAME } from "./protocol.js";
6
+ function emitter() {
7
+ const listeners = new Set();
8
+ return {
9
+ add(listener) {
10
+ listeners.add(listener);
11
+ return () => void listeners.delete(listener);
12
+ },
13
+ emit: (value) => listeners.forEach(listener => listener(value)),
14
+ };
15
+ }
16
+ export const channelName = (room) => `contour-deck:${room}`;
17
+ /** Windows of the same browser and origin, through `BroadcastChannel`. No pairing: same browser, trusted. */
18
+ export function broadcast(room) {
19
+ const messages = emitter();
20
+ const channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(channelName(room));
21
+ let status = channel ? "open" : "closed";
22
+ if (channel) {
23
+ channel.onmessage = event => {
24
+ const message = decode(event.data);
25
+ if (message)
26
+ messages.emit(message);
27
+ };
28
+ }
29
+ return {
30
+ get status() {
31
+ return status;
32
+ },
33
+ code: 0,
34
+ send(message) {
35
+ if (status === "open")
36
+ channel?.postMessage(JSON.stringify(message));
37
+ },
38
+ subscribe: messages.add,
39
+ watch: () => () => { },
40
+ reconnect() { },
41
+ close() {
42
+ status = "closed";
43
+ channel?.close();
44
+ },
45
+ };
46
+ }
47
+ const FIRST_RETRY = 500;
48
+ const LAST_RETRY = 15_000;
49
+ /** `https://relay.example` or `wss://relay.example` to the room endpoint. */
50
+ export function relayUrl(base, { room, role, key }) {
51
+ return `${base.replace(/^http/, "ws").replace(/\/+$/, "")}/v1/room/${room}?role=${role}${key ? `&key=${key}` : ""}`;
52
+ }
53
+ /**
54
+ * 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.
56
+ */
57
+ const FINAL = [4003, 4009];
58
+ /**
59
+ * 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.
61
+ */
62
+ export function relay(base, options) {
63
+ const messages = emitter();
64
+ const statuses = emitter();
65
+ const encoder = new TextEncoder();
66
+ const abort = new AbortController();
67
+ let socket = null;
68
+ let status = "connecting";
69
+ let code = 0;
70
+ let attempt = 0;
71
+ let timer = 0;
72
+ let ended = false;
73
+ /** After a final close: waiting for `reconnect()`, not for a timer or the page coming back. */
74
+ let parked = false;
75
+ function set(next) {
76
+ if (next === status)
77
+ return;
78
+ status = next;
79
+ statuses.emit(next);
80
+ }
81
+ function open() {
82
+ clearTimeout(timer);
83
+ if (ended)
84
+ return;
85
+ const current = new WebSocket(relayUrl(base, options));
86
+ socket = current;
87
+ set("connecting");
88
+ current.onopen = () => {
89
+ if (socket === current)
90
+ set("open");
91
+ };
92
+ current.onmessage = event => {
93
+ if (socket !== current)
94
+ return;
95
+ const message = decode(event.data);
96
+ if (!message)
97
+ return;
98
+ // The relay greets every socket; a greeting means the room took us, so the backoff starts over.
99
+ if (message.t === "hello")
100
+ attempt = 0;
101
+ messages.emit(message);
102
+ };
103
+ current.onclose = event => {
104
+ if (socket !== current)
105
+ return;
106
+ socket = null;
107
+ code = event.code;
108
+ if (ended)
109
+ return;
110
+ set("closed");
111
+ parked = FINAL.includes(code);
112
+ if (parked)
113
+ return;
114
+ const delay = Math.min(LAST_RETRY, FIRST_RETRY * 2 ** attempt++) * (0.5 + Math.random() / 2);
115
+ timer = window.setTimeout(open, delay);
116
+ };
117
+ }
118
+ function reconnect() {
119
+ if (ended)
120
+ return;
121
+ const current = socket;
122
+ socket = null;
123
+ current?.close();
124
+ attempt = 0;
125
+ parked = false;
126
+ open();
127
+ }
128
+ /** Waiting for a retry while the page was hidden or offline: try now. */
129
+ const wake = () => {
130
+ if (!socket && !parked && !document.hidden)
131
+ reconnect();
132
+ };
133
+ addEventListener("online", wake, { signal: abort.signal });
134
+ document.addEventListener("visibilitychange", wake, { signal: abort.signal });
135
+ open();
136
+ return {
137
+ get status() {
138
+ return status;
139
+ },
140
+ get code() {
141
+ return code;
142
+ },
143
+ send(message) {
144
+ if (status !== "open" || !socket)
145
+ return;
146
+ const frame = JSON.stringify(message);
147
+ if (encoder.encode(frame).length <= MAX_FRAME)
148
+ socket.send(frame);
149
+ },
150
+ subscribe: messages.add,
151
+ watch: statuses.add,
152
+ reconnect,
153
+ close() {
154
+ ended = true;
155
+ abort.abort();
156
+ clearTimeout(timer);
157
+ socket?.close();
158
+ socket = null;
159
+ set("closed");
160
+ },
161
+ };
162
+ }
@@ -0,0 +1,48 @@
1
+ import type { BlockNode, PageNode, Pos } from "../core/model.js";
2
+ import type { RemoteLabels } from "./labels.js";
3
+ import type { StateMessage } from "./protocol.js";
4
+ export interface Outline {
5
+ blocks: BlockNode[];
6
+ skip: string[];
7
+ }
8
+ export declare const readOutline: (root: HTMLElement, skip?: string[]) => Outline;
9
+ /** The position a `state` names, if this page has it: both sides load the same page, but one may be stale. */
10
+ export declare function statePos({ blocks }: Outline, state: StateMessage): Pos | null;
11
+ export declare const pageAt: ({ blocks }: Outline, pos: Pos) => PageNode;
12
+ /** `三 · 4/6 · 頁 2/2 · 要點 1/4`, as DESIGN.md writes the position on the phone. */
13
+ export declare function positionLabel({ blocks }: Outline, pos: Pos, state: StateMessage): string;
14
+ export interface UpNext {
15
+ kind: "step" | "page" | "slide" | "end";
16
+ text: string;
17
+ /** The page the next advance shows, and how many of its steps are revealed then. */
18
+ pos: Pos;
19
+ step: number;
20
+ }
21
+ /** What the next advance does: one more step, the page below, the next slide, or nothing. */
22
+ export declare function upNext(outline: Outline, pos: Pos, step: number): UpNext;
23
+ export interface Preview {
24
+ el: HTMLElement;
25
+ /** Draws a page with its first `steps` steps revealed; `null` empties the sheet. */
26
+ show(page: PageNode | null, steps: number): void;
27
+ destroy(): void;
28
+ }
29
+ /**
30
+ * A page of this document drawn small: a copy laid out by the stage's own rules on a 1280 px wide sheet,
31
+ * then scaled to the box. A guide, not a mirror: it does not know how far the real page is scrolled.
32
+ */
33
+ export declare function createPreview(root: HTMLElement): Preview;
34
+ export interface Timers {
35
+ /** Whole-talk time, `3:12 / 12:00`. */
36
+ time: HTMLElement;
37
+ /** Block title and block time. */
38
+ block: HTMLElement;
39
+ /** Redraws from the last state; call it on every state and every half second. */
40
+ draw(state: StateMessage | null, receivedAt: number): void;
41
+ }
42
+ /**
43
+ * The two clocks. Between states they run on the local clock: a state every 15 s is enough to stay in step.
44
+ * The block title comes from this page's own outline, at the position the state names; the relay carries no text.
45
+ */
46
+ export declare function createTimers(labels: RemoteLabels, outline: Outline): Timers;
47
+ /** Keeps the screen awake while a view is open; the browser drops the lock when the page hides, so it is asked again. */
48
+ export declare function keepAwake(signal: AbortSignal): void;
@@ -0,0 +1,119 @@
1
+ /** What the phone and the presenter window share: reading the deck of their own page, and drawing pages small. */
2
+ import { h } from "../core/dom.js";
3
+ import { defaultLabels } from "../core/labels.js";
4
+ import { fitPos, parseAt, readModel } from "../core/model.js";
5
+ import { neighbourBlock } from "../core/navigator.js";
6
+ import { pageTitle } from "../core/notes.js";
7
+ import { formatClock } from "./clock.js";
8
+ export const readOutline = (root, skip = ["appendix"]) => ({
9
+ blocks: readModel(root, root.dataset.title || document.title),
10
+ skip,
11
+ });
12
+ /** The position a `state` names, if this page has it: both sides load the same page, but one may be stale. */
13
+ export function statePos({ blocks }, state) {
14
+ const parsed = parseAt(state.at);
15
+ return parsed && fitPos(blocks, parsed);
16
+ }
17
+ export const pageAt = ({ blocks }, pos) => blocks[pos.b].slides[pos.s].pages[pos.p];
18
+ /** `三 · 4/6 · 頁 2/2 · 要點 1/4`, as DESIGN.md writes the position on the phone. */
19
+ export function positionLabel({ blocks }, pos, state) {
20
+ const block = blocks[pos.b];
21
+ const pages = block.slides[pos.s].pages.length;
22
+ return [
23
+ block.short,
24
+ `${pos.s + 1}/${block.slides.length}`,
25
+ pages > 1 ? defaultLabels.page(pos.p + 1, pages) : "",
26
+ state.steps ? defaultLabels.steps(state.step, state.steps) : "",
27
+ ]
28
+ .filter(Boolean)
29
+ .join(" · ");
30
+ }
31
+ const clip = (text) => text.replace(/\s+/g, " ").trim().slice(0, 80);
32
+ /** What the next advance does: one more step, the page below, the next slide, or nothing. */
33
+ export function upNext(outline, pos, step) {
34
+ const { blocks, skip } = outline;
35
+ const slide = blocks[pos.b].slides[pos.s];
36
+ const page = slide.pages[pos.p];
37
+ if (step < page.steps.length)
38
+ return { kind: "step", text: clip(page.steps[step].textContent ?? ""), pos, step: step + 1 };
39
+ if (pos.p + 1 < slide.pages.length) {
40
+ const below = { ...pos, p: pos.p + 1 };
41
+ return { kind: "page", text: pageTitle(slide.pages[below.p]) || slide.title, pos: below, step: 0 };
42
+ }
43
+ const nextBlock = neighbourBlock(blocks.map(block => block.kind), pos.b, 1, skip);
44
+ const next = pos.s + 1 < blocks[pos.b].slides.length ? { b: pos.b, s: pos.s + 1, p: 0 } : nextBlock >= 0 ? { b: nextBlock, s: 0, p: 0 } : null;
45
+ if (!next)
46
+ return { kind: "end", text: "", pos, step };
47
+ return { kind: "slide", text: blocks[next.b].slides[next.s].title, pos: next, step: 0 };
48
+ }
49
+ /** Width of the sheet a page is laid out on before it is scaled to the box. */
50
+ const SHEET = 1280;
51
+ /**
52
+ * A page of this document drawn small: a copy laid out by the stage's own rules on a 1280 px wide sheet,
53
+ * then scaled to the box. A guide, not a mirror: it does not know how far the real page is scrolled.
54
+ */
55
+ export function createPreview(root) {
56
+ const el = h("div", "ct-pv");
57
+ const sheet = el.appendChild(h("div", "ct-stage", "", { "aria-hidden": "true" }));
58
+ const wrap = sheet.appendChild(h("div", `ct-pvw ${root.className}`.trim()));
59
+ sheet.inert = true;
60
+ const observer = new ResizeObserver(() => el.style.setProperty("--ct-k", String(el.clientWidth / SHEET)));
61
+ observer.observe(el);
62
+ let drawn = null;
63
+ return {
64
+ el,
65
+ show(page, steps) {
66
+ if (page !== drawn) {
67
+ drawn = page;
68
+ const copy = page?.el.cloneNode(true);
69
+ // A copy must not repeat ids, play media, or run frames a second time.
70
+ copy?.querySelectorAll("[id]").forEach(node => node.removeAttribute("id"));
71
+ copy?.querySelectorAll("iframe,video,audio,script,[data-ct-notes]").forEach(node => node.remove());
72
+ copy?.removeAttribute("id");
73
+ wrap.replaceChildren(...(copy ? [copy] : []));
74
+ }
75
+ wrap.querySelectorAll("[data-ct-step]").forEach((node, i) => node.toggleAttribute("data-ct-on", i < steps));
76
+ },
77
+ destroy: () => observer.disconnect(),
78
+ };
79
+ }
80
+ /**
81
+ * The two clocks. Between states they run on the local clock: a state every 15 s is enough to stay in step.
82
+ * The block title comes from this page's own outline, at the position the state names; the relay carries no text.
83
+ */
84
+ export function createTimers(labels, outline) {
85
+ const time = h("b", "ct-rmt-time", "0:00");
86
+ const block = h("div", "ct-rmt-blk");
87
+ const title = block.appendChild(h("span"));
88
+ const spent = block.appendChild(h("b"));
89
+ return {
90
+ time,
91
+ block,
92
+ draw(state, receivedAt) {
93
+ if (!state)
94
+ return;
95
+ // `elapsed` is 0 until the talk starts and never 0 after, so it also says whether the clock runs.
96
+ const since = state.elapsed > 0 ? (performance.now() - receivedAt) / 1000 : 0;
97
+ const elapsed = state.elapsed + since;
98
+ time.textContent = formatClock(elapsed) + (state.total ? ` / ${formatClock(state.total)}` : "");
99
+ time.toggleAttribute("data-over", !!state.total && elapsed > state.total);
100
+ const budget = state.blockMinutes ? state.blockMinutes * 60 : 0;
101
+ const inBlock = state.blockElapsed + since;
102
+ const pos = statePos(outline, state);
103
+ title.textContent = `${labels.block} ${pos ? outline.blocks[pos.b].title : ""}`;
104
+ spent.textContent = formatClock(inBlock) + (budget ? ` / ${formatClock(budget)}` : "");
105
+ spent.toggleAttribute("data-over", !!budget && inBlock > budget);
106
+ },
107
+ };
108
+ }
109
+ /** Keeps the screen awake while a view is open; the browser drops the lock when the page hides, so it is asked again. */
110
+ export function keepAwake(signal) {
111
+ let lock = null;
112
+ const ask = () => {
113
+ if (!document.hidden && !signal.aborted)
114
+ lock = navigator.wakeLock?.request("screen").catch(() => { }) ?? null;
115
+ };
116
+ document.addEventListener("visibilitychange", ask, { signal });
117
+ signal.addEventListener("abort", () => void lock?.then(held => held?.release()));
118
+ ask();
119
+ }
package/docs/CONTRACT.md CHANGED
@@ -122,6 +122,8 @@ The core removes all of them in `destroy()`.
122
122
 
123
123
  Every other `data-ct-*` name is reserved for later versions, except the [host attributes](#host-attributes).
124
124
 
125
+ `contour-deck/remote`, when it loads, adds one `<style data-ct-remote>` at the start of `<head>` and leaves it there; its elements (`.ct-rp`, `.ct-laser`, `.ct-ink`, and the `.ct-app` of the phone and presenter views) go with the session or the view that made them.
126
+
125
127
  The core moves the deck element into its shell (`.ct-app .ct-stage`) while the deck is open and puts it back in `destroy()`; it does not clone it.
126
128
  The shell is inserted where the deck element was, so the deck keeps its ancestors and the styles it inherits from them.
127
129
 
@@ -147,6 +149,8 @@ The core does not read them, and a host that calls `createDeck` itself needs non
147
149
  | `data-ct-overview` | the wrapper | `flat` or `3d`: the `overview` option of the core. |
148
150
  | `data-ct-legacy` | the wrapper | Accept old reveal.js links: `?view=slides&slide=h.v` is rewritten to `?view=deck&at=…` through `data-legacy` and `data-legacy-v`. |
149
151
  | `data-ct-start` | any control in the page | A click opens the deck; with the value `notes`, its notes view. Inside a wrapper it opens that deck, elsewhere the first deck of the page. |
152
+ | `data-ct-remote` | the wrapper | The deck has the remote button and the `R` key (`contour-deck/remote`, loaded on first use). |
153
+ | `data-ct-relay` | the wrapper | Base URL of the relay (`wss://…`) for the phone remote; implies `data-ct-remote`. See [PROTOCOL.md](PROTOCOL.md). |
150
154
 
151
155
  ## Deep links
152
156
 
@@ -159,6 +163,16 @@ The core owns two query parameters while a deck is open, and removes both in `de
159
163
  | `at` | `b.s` or `b.s.p` | 1-based ordinals: block in the deck, slide in the block, page in the slide. `4.4.2` is the second page of the fourth slide of the fourth block. |
160
164
  | `at` | an element id | The slide, page, or block with that id, or the one that contains the element with that id. |
161
165
 
166
+ `contour-deck/remote` owns two more values of `view`, on pages that are views of a deck open elsewhere; the host loads the module when it sees them.
167
+
168
+ | Parameter | Value | Meaning |
169
+ | --- | --- | --- |
170
+ | `view` | `remote` | The phone remote of the first deck of the page. |
171
+ | `view` | `presenter` | The presenter window, in the same browser as the deck. |
172
+ | `room` | 26 characters of Crockford base32 | The room both sides meet in: the relay room of the phone, the `BroadcastChannel` of the presenter window. |
173
+
174
+ The links to these views keep every other query parameter of the deck's page and drop `at` and the fragment.
175
+
162
176
  Without `at` the deck opens on the outline.
163
177
  The position is in the query string, not the fragment, because a restricted page on the blog drops the fragment on redirect.
164
178
  The slide `id` also works as a plain document anchor (`#s-4-6`) when the deck is closed.
@@ -0,0 +1,99 @@
1
+ # Remote protocol
2
+
3
+ How a deck (the **host**) and phones (**guests**) talk through the relay. The relay is a per-room forwarder; it decides who may hold the host seat, and the host decides who may control the deck. Version 1.
4
+
5
+ Version 1 changed before its first release (contour-deck 0.1.0 has no remote): the host seat needs a key, the relay removes a guest the host denied or revoked, it limits requests per client address, and `state` lost `block`. Nothing that spoke the earlier draft was published, so the version stays 1.
6
+
7
+ ## Transport
8
+
9
+ ```
10
+ wss://<relay>/v1/room/<room>?role=host&key=<key>
11
+ wss://<relay>/v1/room/<room>?role=guest
12
+ ```
13
+
14
+ - `key`: the host key, 26 characters of Crockford base32 (128 random bits from `crypto.getRandomValues`). The host keeps it in `sessionStorage` and sends it to the relay only.
15
+ - `room`: the first 26 characters of the Crockford base32 encoding of SHA-256 of the key (its UTF-8 bytes, upper case), bits taken most significant first. The QR and every link carry the room, never the key. Anything that is not 26 Crockford characters gets HTTP 400.
16
+ - Test vector: key `0123456789ABCDEFGHJKMNPQRS` opens room `YQV9WCK1VFXD4486E3JKK6CYND`.
17
+ - Frames are UTF-8 JSON text, at most **2048 bytes**. A larger or binary frame closes the socket with `1009`.
18
+ - Every message has a string field `t` (its type). The relay reads only `t`, `to`, and the size; it never stores or logs message bodies.
19
+
20
+ ## Relay rules
21
+
22
+ One Durable Object per room (`idFromName(room)`).
23
+
24
+ | Event | Relay does |
25
+ | --- | --- |
26
+ | host opens without a key, or with a key whose room is not this room | HTTP 403 before the upgrade |
27
+ | socket opens | sends `{ "t": "hello", "id": "<8 chars>", "role": "host" \| "guest" }` to it. `id` is assigned by the relay. |
28
+ | host opens while a host is connected | the new host takes the seat: the old host is closed with `4009`, its guests with `4000`; the room stays |
29
+ | guest opens with no host connected | closes it with `4004` |
30
+ | guest opens when 4 guests are connected | closes it with `4029` |
31
+ | guest opens / closes | sends `{ "t": "join", "id" }` / `{ "t": "leave", "id" }` to the host |
32
+ | host sends a message with `to` | forwards it to that guest only (unknown `to` is dropped) |
33
+ | host sends `deny` or `revoke` with `to` | forwards it, then closes that guest with `4003` (the guest leaves; the host gets `leave`) |
34
+ | host sends a message without `to` | forwards it to every guest |
35
+ | guest sends a message | forwards it to the host only, with `from` set to the guest's id (overwriting any `from` the guest sent) |
36
+ | host closes | closes every guest with `4000`; the room is gone |
37
+ | no message in the room for 2 hours | closes everyone with `4000` |
38
+ | a socket exceeds 60 messages/s (burst 120) | extra messages are dropped; above 10 s of sustained excess, closes with `1008` |
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 |
41
+
42
+ `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
+ Only the key holder can be host, so only the deck's own tab receives what guests send, the resume token included. A host that reconnects while the relay still counts its old connection as open takes the seat back at once. Of two tabs with the same key (a duplicated tab copies `sessionStorage`), the newer one holds the seat; the older one is closed with `4009` and does not reconnect by itself.
45
+
46
+ `GET /v1/health` answers `200 ok` (no upgrade) for monitoring.
47
+
48
+ ## Application messages
49
+
50
+ The relay does not interpret these, except that a `deny` or `revoke` with `to` removes that guest. Coordinates are normalised to the stage viewport: `x`, `y` in `[0, 1]`, origin at the top left.
51
+
52
+ ### Pairing
53
+
54
+ 1. Host opens the room with its key and shows a QR for `<page URL>?view=remote&room=<room>`.
55
+ 2. Guest connects; host gets `join` and sends `{ "t": "pair", "to": id, "code": "1234" }` — a fresh 4-digit code per guest.
56
+ 3. Guest shows the code large. Host shows "a phone asks for control: code 1234 — allow / deny".
57
+ 4. Presenter compares the two screens and answers:
58
+ - allow → `{ "t": "grant", "to": id, "token": "<22 chars>" }` (random, kept by the guest in `sessionStorage` for this room)
59
+ - deny → `{ "t": "deny", "to": id }`; the relay then closes that guest with `4003`, so a denied guest cannot keep a place
60
+ 5. A reconnecting guest sends `{ "t": "resume", "token" }`; if it matches a token the host granted in this room, the host grants again without asking.
61
+ 6. The host can take control back: `{ "t": "revoke", "to": id }`, which also removes the guest from the room (`4003`). At most one guest holds control; granting a second revokes the first.
62
+
63
+ A guest that is not granted gets `state` messages only if the host chooses (default: none), and its commands are ignored. A projected QR scanned by the audience therefore only produces a request the presenter denies.
64
+
65
+ ### Host → granted guest
66
+
67
+ | Message | Fields |
68
+ | --- | --- |
69
+ | `state` | `mode` (`overview` \| `stage`), `at` (`b.s.p`, 1-based), `step`, `steps`, `elapsed` (s), `total` (s or `null`), `blockElapsed` (s), `blockMinutes` (or `null`), `ratio` (optional: width over height of the stage) |
70
+
71
+ Sent on every position change and every 15 s. No text of the page crosses the relay: the phone has the same page and reads the notes and the block title from its own DOM, at the position `at` names.
72
+
73
+ ### Granted guest → host
74
+
75
+ | Message | Fields | Host does |
76
+ | --- | --- | --- |
77
+ | `cmd` | `c`: `next` \| `prev` \| `slideNext` \| `slidePrev` \| `pageNext` \| `pagePrev` \| `overview` \| `open`; `at` with `open` | same as the keyboard |
78
+ | `ptr` | `x`, `y`, `on` (bool) | shows or hides the laser pointer |
79
+ | `ink` | `s` (stroke id, number), `pts` ([x, y, x, y, …], at most 64 points), `end` (bool, optional) | draws the stroke on the overlay |
80
+ | `ink-clear` | — | clears the overlay |
81
+ | `ping` | — | answers `{ "t": "pong", "to": from }` |
82
+
83
+ Ink is cleared when the page changes. The host rate-limits `ptr` rendering to one per animation frame.
84
+
85
+ ## Same machine
86
+
87
+ The presenter window on the same computer uses `BroadcastChannel("contour-deck:<room>")` with the same messages, without `pair` (same browser, trusted).
88
+
89
+ ## Close codes
90
+
91
+ | Code | Meaning |
92
+ | --- | --- |
93
+ | `4000` | room closed (host left or idle, or a new host took the seat) |
94
+ | `4003` | removed by the host (after `deny` or `revoke`); the client does not reconnect by itself |
95
+ | `4004` | no host in this room |
96
+ | `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
+ | `1008` | rate limit |
99
+ | `1009` | message too large |