contour-deck 0.0.0-stage → 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 (94) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +261 -2
  3. package/dist/astro/Deck.astro +68 -0
  4. package/dist/astro/host.d.ts +20 -0
  5. package/dist/astro/host.js +170 -0
  6. package/dist/astro/legacy.d.ts +22 -0
  7. package/dist/astro/legacy.js +42 -0
  8. package/dist/core/deck.d.ts +2 -0
  9. package/dist/core/deck.js +598 -0
  10. package/dist/core/dom.d.ts +8 -0
  11. package/dist/core/dom.js +22 -0
  12. package/dist/core/gestures.d.ts +6 -0
  13. package/dist/core/gestures.js +24 -0
  14. package/dist/core/index.d.ts +3 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/labels.d.ts +2 -0
  17. package/dist/core/labels.js +20 -0
  18. package/dist/core/model.d.ts +37 -0
  19. package/dist/core/model.js +51 -0
  20. package/dist/core/navigator.d.ts +6 -0
  21. package/dist/core/navigator.js +12 -0
  22. package/dist/core/notes.d.ts +11 -0
  23. package/dist/core/notes.js +34 -0
  24. package/dist/core/overview.d.ts +11 -0
  25. package/dist/core/overview.js +104 -0
  26. package/dist/core/rail.d.ts +8 -0
  27. package/dist/core/rail.js +26 -0
  28. package/dist/core/stops.d.ts +17 -0
  29. package/dist/core/stops.js +40 -0
  30. package/dist/core/transitions.d.ts +9 -0
  31. package/dist/core/transitions.js +37 -0
  32. package/dist/core/types.d.ts +82 -0
  33. package/dist/core/types.js +1 -0
  34. package/dist/rehype/hast.d.ts +26 -0
  35. package/dist/rehype/hast.js +48 -0
  36. package/dist/rehype/index.d.ts +12 -0
  37. package/dist/rehype/index.js +221 -0
  38. package/dist/rehype/page.d.ts +2 -0
  39. package/dist/rehype/page.js +84 -0
  40. package/dist/rehype/reading.d.ts +11 -0
  41. package/dist/rehype/reading.js +9 -0
  42. package/dist/rehype/titles.d.ts +19 -0
  43. package/dist/rehype/titles.js +63 -0
  44. package/dist/remark/index.d.ts +20 -0
  45. package/dist/remark/index.js +117 -0
  46. package/dist/remote/clock.d.ts +35 -0
  47. package/dist/remote/clock.js +48 -0
  48. package/dist/remote/guest.d.ts +41 -0
  49. package/dist/remote/guest.js +223 -0
  50. package/dist/remote/index.d.ts +18 -0
  51. package/dist/remote/index.js +11 -0
  52. package/dist/remote/ink.d.ts +11 -0
  53. package/dist/remote/ink.js +74 -0
  54. package/dist/remote/labels.d.ts +72 -0
  55. package/dist/remote/labels.js +68 -0
  56. package/dist/remote/links.d.ts +2 -0
  57. package/dist/remote/links.js +12 -0
  58. package/dist/remote/overlay.d.ts +11 -0
  59. package/dist/remote/overlay.js +63 -0
  60. package/dist/remote/pairing.d.ts +40 -0
  61. package/dist/remote/pairing.js +97 -0
  62. package/dist/remote/panel.d.ts +38 -0
  63. package/dist/remote/panel.js +131 -0
  64. package/dist/remote/phone.d.ts +10 -0
  65. package/dist/remote/phone.js +407 -0
  66. package/dist/remote/presenter.d.ts +2 -0
  67. package/dist/remote/presenter.js +126 -0
  68. package/dist/remote/protocol.d.ts +106 -0
  69. package/dist/remote/protocol.js +123 -0
  70. package/dist/remote/qr.d.ts +8 -0
  71. package/dist/remote/qr.js +266 -0
  72. package/dist/remote/sensor.d.ts +9 -0
  73. package/dist/remote/sensor.js +50 -0
  74. package/dist/remote/session.d.ts +39 -0
  75. package/dist/remote/session.js +287 -0
  76. package/dist/remote/store.d.ts +9 -0
  77. package/dist/remote/store.js +27 -0
  78. package/dist/remote/style.d.ts +2 -0
  79. package/dist/remote/style.js +131 -0
  80. package/dist/remote/tilt.d.ts +78 -0
  81. package/dist/remote/tilt.js +148 -0
  82. package/dist/remote/transport.d.ts +35 -0
  83. package/dist/remote/transport.js +162 -0
  84. package/dist/remote/view.d.ts +48 -0
  85. package/dist/remote/view.js +119 -0
  86. package/dist/shared/markers.d.ts +15 -0
  87. package/dist/shared/markers.js +15 -0
  88. package/dist/style.css +629 -0
  89. package/docs/CONTRACT.md +178 -0
  90. package/docs/PROTOCOL.md +99 -0
  91. package/package.json +89 -4
  92. package/styles/base.css +3 -0
  93. package/styles/deck.css +151 -0
  94. package/styles/index.css +3 -0
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The hand-off between `contour-deck/remark` and `contour-deck/rehype`.
3
+ *
4
+ * The remark plugin writes these marker elements; the rehype plugin consumes them and
5
+ * removes every one, so none appears in the final HTML except `data-ct-notes`, which is
6
+ * already the contract's own attribute.
7
+ */
8
+ /** hast property that names a marker (`data-ct-mark` in HTML). */
9
+ export declare const MARK = "dataCtMark";
10
+ /** hast property of a notes element (`data-ct-notes` in HTML). */
11
+ export declare const NOTES = "dataCtNotes";
12
+ export declare const MARK_BLOCK = "block";
13
+ export declare const MARK_SLIDE = "slide";
14
+ export declare const MARK_VERTICAL = "vertical";
15
+ export declare const MARK_BUILD = "build";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The hand-off between `contour-deck/remark` and `contour-deck/rehype`.
3
+ *
4
+ * The remark plugin writes these marker elements; the rehype plugin consumes them and
5
+ * removes every one, so none appears in the final HTML except `data-ct-notes`, which is
6
+ * already the contract's own attribute.
7
+ */
8
+ /** hast property that names a marker (`data-ct-mark` in HTML). */
9
+ export const MARK = "dataCtMark";
10
+ /** hast property of a notes element (`data-ct-notes` in HTML). */
11
+ export const NOTES = "dataCtNotes";
12
+ export const MARK_BLOCK = "block";
13
+ export const MARK_SLIDE = "slide";
14
+ export const MARK_VERTICAL = "vertical";
15
+ export const MARK_BUILD = "build";