contour-deck 0.1.0 → 0.2.1

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 +229 -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 +76 -0
  17. package/dist/remote/labels.js +70 -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 +40 -0
  25. package/dist/remote/panel.js +137 -0
  26. package/dist/remote/phone.d.ts +10 -0
  27. package/dist/remote/phone.js +408 -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 +292 -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 +132 -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 +36 -0
  45. package/dist/remote/transport.js +170 -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 +100 -0
  50. package/package.json +9 -2
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Who may control the deck: the host's side of the pairing in docs/PROTOCOL.md, as a state machine without DOM.
3
+ * A guest gets a 4-digit code and waits; the presenter allows or denies it; one guest at a time holds control,
4
+ * and its token lets it come back after a reconnect without asking again.
5
+ */
6
+ import { newCode, newToken } from "./protocol.js";
7
+ /** A request the presenter has not answered after this long is denied. */
8
+ export const REQUEST_TTL = 120_000;
9
+ export function createPairing({ send, changed, token: restored = null, now = Date.now }) {
10
+ let requests = [];
11
+ let controller = null;
12
+ let token = restored;
13
+ const drop = (id) => {
14
+ const before = requests.length;
15
+ requests = requests.filter(request => request.id !== id);
16
+ return requests.length !== before;
17
+ };
18
+ /** Hands control to `id`; whoever held it before is told it lost it. */
19
+ function seat(id, granted) {
20
+ if (controller && controller !== id)
21
+ send({ t: "revoke", to: controller });
22
+ drop(id);
23
+ controller = id;
24
+ token = granted;
25
+ send({ t: "grant", to: id, token: granted });
26
+ changed();
27
+ }
28
+ return {
29
+ get requests() {
30
+ return requests;
31
+ },
32
+ get controller() {
33
+ return controller;
34
+ },
35
+ get token() {
36
+ return token;
37
+ },
38
+ join(id) {
39
+ drop(id);
40
+ const request = { id, code: newCode(), at: now() };
41
+ requests.push(request);
42
+ send({ t: "pair", to: id, code: request.code });
43
+ changed();
44
+ },
45
+ leave(id) {
46
+ const waiting = drop(id);
47
+ if (controller === id)
48
+ controller = null;
49
+ else if (!waiting)
50
+ return;
51
+ changed();
52
+ },
53
+ resume(id, presented) {
54
+ // Only a guest the relay announced, and only with the one token in force.
55
+ if (!token || presented !== token || (controller !== id && !requests.some(request => request.id === id)))
56
+ return false;
57
+ seat(id, token);
58
+ return true;
59
+ },
60
+ allow(id) {
61
+ // A fresh token on every decision: the one of the phone that held control before stops working.
62
+ if (requests.some(request => request.id === id))
63
+ seat(id, newToken());
64
+ },
65
+ deny(id) {
66
+ if (!drop(id))
67
+ return;
68
+ send({ t: "deny", to: id });
69
+ changed();
70
+ },
71
+ revoke() {
72
+ if (!controller && !token)
73
+ return;
74
+ if (controller)
75
+ send({ t: "revoke", to: controller });
76
+ controller = null;
77
+ token = null;
78
+ changed();
79
+ },
80
+ expire() {
81
+ const limit = now() - REQUEST_TTL;
82
+ for (const request of requests.filter(item => item.at <= limit)) {
83
+ drop(request.id);
84
+ send({ t: "deny", to: request.id });
85
+ changed();
86
+ }
87
+ },
88
+ controls: id => controller !== null && id === controller,
89
+ offline() {
90
+ if (!controller && !requests.length)
91
+ return;
92
+ controller = null;
93
+ requests = [];
94
+ changed();
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,40 @@
1
+ import type { RemoteLabels } from "./labels.js";
2
+ import type { PairRequest } from "./pairing.js";
3
+ import type { TransportStatus } from "./transport.js";
4
+ /** Draws a QR code as an SVG string; `null` (or a rejection) shows the link as text only. */
5
+ export type QrEncoder = (text: string) => string | null | Promise<string | null>;
6
+ /** What the panel shows; the host session hands in a new one on every change. */
7
+ export interface PanelView {
8
+ /** `null` without a relay. */
9
+ relay: TransportStatus | null;
10
+ /** Close code of the last relay connection. */
11
+ code: number;
12
+ requests: readonly PairRequest[];
13
+ paired: "no" | "online" | "offline";
14
+ elapsed: number;
15
+ }
16
+ export interface PanelOptions {
17
+ /** The viewport of the deck's shell (`.ct-vp`). */
18
+ parent: HTMLElement;
19
+ labels: RemoteLabels;
20
+ qr: QrEncoder;
21
+ /** The phone-remote link, or `null` without a relay. */
22
+ remoteLink: string | null;
23
+ notesLink: string;
24
+ allow(id: string): void;
25
+ deny(id: string): void;
26
+ revoke(): void;
27
+ /** After 4009: takes the host seat back for this tab. */
28
+ retake(): void;
29
+ presenter(): void;
30
+ resetTimer(): void;
31
+ end(): void;
32
+ close(): void;
33
+ }
34
+ export interface Panel {
35
+ readonly open: boolean;
36
+ show(open: boolean): void;
37
+ update(view: PanelView): void;
38
+ destroy(): void;
39
+ }
40
+ export declare function createPanel(options: PanelOptions): Panel;
@@ -0,0 +1,137 @@
1
+ /** The remote panel on the deck: the QR codes, the pairing requests, the paired phone, and the presenter window. */
2
+ import { h } from "../core/dom.js";
3
+ import { formatClock } from "./clock.js";
4
+ export function createPanel(options) {
5
+ const { labels } = options;
6
+ const el = h("aside", "ct-rp", "", { role: "dialog", "aria-label": labels.title, tabindex: -1 });
7
+ el.hidden = true;
8
+ const button = (action, text, primary = false) => {
9
+ const control = h("button", "ct-btn", text, { "data-ct-rp": action });
10
+ if (primary)
11
+ control.setAttribute("data-ct-primary", "");
12
+ return control;
13
+ };
14
+ const head = el.appendChild(h("div", "ct-rp-head"));
15
+ const status = h("span", "ct-badge");
16
+ const closeButton = button("close", "✕");
17
+ closeButton.setAttribute("aria-label", labels.close);
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;
23
+ /** Requests and the paired phone: the part that changes. */
24
+ const live = el.appendChild(h("div", "ct-rp-live", "", { "aria-live": "polite" }));
25
+ live.style.display = "contents";
26
+ const codes = el.appendChild(h("div", "ct-rp-qrs"));
27
+ function tile(title, hint, link) {
28
+ const figure = codes.appendChild(h("figure", "ct-rp-qr"));
29
+ const image = h("div", "ct-rp-img");
30
+ image.hidden = true;
31
+ figure.append(image, h("h3", "", title), h("p", "", hint), h("a", "ct-rp-url", link, { href: link, target: "_blank", rel: "noopener" }));
32
+ // The encoder belongs to the host page (or is the built-in one): its SVG goes in as markup.
33
+ void Promise.resolve()
34
+ .then(() => options.qr(link))
35
+ .then(svg => {
36
+ if (!svg)
37
+ return;
38
+ image.innerHTML = svg;
39
+ image.hidden = false;
40
+ })
41
+ .catch(() => { });
42
+ }
43
+ if (options.remoteLink)
44
+ tile(labels.remoteQr, labels.remoteHint, options.remoteLink);
45
+ else
46
+ codes.before(h("p", "", labels.noRelay));
47
+ tile(labels.notesQr, labels.notesHint, options.notesLink);
48
+ const tools = el.appendChild(h("div", "ct-rp-row"));
49
+ const time = h("b", "ct-rp-time", "0:00");
50
+ const timer = h("span", "ct-rp-row");
51
+ timer.append(h("span", "ct-meta", labels.timer), time, button("reset", labels.resetTimer));
52
+ tools.append(button("presenter", labels.presenter), timer);
53
+ el.appendChild(h("div", "ct-rp-row")).append(h("span"), button("end", labels.end));
54
+ const actions = {
55
+ close: options.close,
56
+ allow: options.allow,
57
+ deny: options.deny,
58
+ revoke: options.revoke,
59
+ retake: options.retake,
60
+ presenter: options.presenter,
61
+ reset: options.resetTimer,
62
+ end: options.end,
63
+ };
64
+ el.addEventListener("click", event => {
65
+ const control = event.target.closest("[data-ct-rp]");
66
+ if (control)
67
+ actions[control.dataset.ctRp ?? ""]?.(control.dataset.ctId ?? "");
68
+ });
69
+ // Escape closes the panel, and only the panel: the deck's own Escape (outline, exit) does not run.
70
+ const onKey = (event) => {
71
+ if (event.key !== "Escape" || el.hidden)
72
+ return;
73
+ event.preventDefault();
74
+ event.stopPropagation();
75
+ options.close();
76
+ };
77
+ document.addEventListener("keydown", onKey, true);
78
+ options.parent.append(el);
79
+ let returnTo = null;
80
+ let drawn = "";
81
+ return {
82
+ get open() {
83
+ return !el.hidden;
84
+ },
85
+ show(open) {
86
+ if (open === !el.hidden)
87
+ return;
88
+ el.hidden = !open;
89
+ if (open) {
90
+ returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null;
91
+ el.focus({ preventScroll: true });
92
+ }
93
+ else {
94
+ returnTo?.focus({ preventScroll: true });
95
+ returnTo = null;
96
+ }
97
+ },
98
+ update(view) {
99
+ time.textContent = formatClock(view.elapsed);
100
+ const relayText = view.relay === null
101
+ ? ""
102
+ : view.relay === "open"
103
+ ? labels.relayOpen
104
+ : view.code === 4009
105
+ ? labels.roomTaken
106
+ : view.relay === "closed"
107
+ ? labels.relayClosed
108
+ : labels.relayConnecting;
109
+ retake.hidden = !(view.relay === "closed" && view.code === 4009);
110
+ status.textContent = relayText;
111
+ status.hidden = !relayText;
112
+ status.dataset.st = view.relay ?? "";
113
+ // The requests are rebuilt only when they change, so a button under the finger stays the same element.
114
+ const key = JSON.stringify([view.requests, view.paired]);
115
+ if (key === drawn)
116
+ return;
117
+ drawn = key;
118
+ live.replaceChildren(...view.requests.map(request => {
119
+ const ask = h("section", "ct-rp-ask", "", { "data-ct-id": request.id });
120
+ const allow = button("allow", labels.allow, true);
121
+ const deny = button("deny", labels.deny);
122
+ allow.dataset.ctId = deny.dataset.ctId = request.id;
123
+ const code = h("b", "ct-rp-code", request.code, { "aria-label": `${labels.code} ${request.code.split("").join(" ")}` });
124
+ ask.append(h("h3", "", labels.request), h("span", "ct-meta", labels.code), code, allow, deny);
125
+ return ask;
126
+ }));
127
+ if (view.paired !== "no") {
128
+ const paired = live.appendChild(h("div", "ct-rp-row"));
129
+ paired.append(h("span", "ct-badge", view.paired === "online" ? labels.paired : labels.pairedOffline), button("revoke", labels.revoke));
130
+ }
131
+ },
132
+ destroy() {
133
+ document.removeEventListener("keydown", onKey, true);
134
+ el.remove();
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,10 @@
1
+ import type { RemoteOptions } from "./session.js";
2
+ export interface RemoteViewOptions extends RemoteOptions {
3
+ /** The room to join. Default: the `room` query parameter. */
4
+ room?: string;
5
+ }
6
+ export interface RemoteView {
7
+ /** Closes the connection and removes the view; the page is a document again. */
8
+ destroy(): void;
9
+ }
10
+ export declare function remoteView(root: HTMLElement, options?: RemoteViewOptions): RemoteView;