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,2 @@
1
+ import type { RemoteViewOptions, RemoteView } from "./phone.js";
2
+ export declare function presenterView(root: HTMLElement, options?: RemoteViewOptions): RemoteView;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The presenter window, `?view=presenter&room=…`: a second window of the same browser that follows the deck
3
+ * over BroadcastChannel and shows the current page, what comes next, the notes, and the clocks.
4
+ */
5
+ import { h } from "../core/dom.js";
6
+ import { defaultLabels } from "../core/labels.js";
7
+ import { notesHtml } from "../core/notes.js";
8
+ import { createGuest } from "./guest.js";
9
+ import { defaultRemoteLabels } from "./labels.js";
10
+ import { injectStyle } from "./style.js";
11
+ import { broadcast } from "./transport.js";
12
+ import { createPreview, createTimers, keepAwake, pageAt, positionLabel, readOutline, statePos, upNext } from "./view.js";
13
+ /** The keys of the deck, as commands. */
14
+ const KEYS = {
15
+ " ": "next",
16
+ Enter: "next",
17
+ PageDown: "next",
18
+ PageUp: "prev",
19
+ Backspace: "prev",
20
+ ArrowRight: "slideNext",
21
+ ArrowLeft: "slidePrev",
22
+ ArrowDown: "pageNext",
23
+ ArrowUp: "pagePrev",
24
+ Escape: "overview",
25
+ o: "overview",
26
+ };
27
+ export function presenterView(root, options = {}) {
28
+ injectStyle(options.nonce);
29
+ const labels = { ...defaultRemoteLabels, ...options.labels };
30
+ const outline = readOutline(root, options.skip);
31
+ const room = options.room ?? new URLSearchParams(location.search).get("room") ?? "";
32
+ const abort = new AbortController();
33
+ const { signal } = abort;
34
+ const app = h("div", "ct-app ct-pr", "", { "data-ct-mode": "stage", "data-ct-wait": "" });
35
+ const top = app.appendChild(h("header", "ct-pr-top"));
36
+ const position = h("span", "ct-rmt-pos", labels.noDeck);
37
+ const timers = createTimers(labels, outline);
38
+ const button = (command, text) => h("button", "ct-btn", text, { "data-ct-do": command });
39
+ top.append(button("prev", defaultLabels.prev), button("next", defaultLabels.next), button("overview", defaultLabels.overview), position, timers.block, timers.time);
40
+ function cell(title, next = false) {
41
+ const el = app.appendChild(h("section", "ct-pr-cell"));
42
+ if (next)
43
+ el.setAttribute("data-next", "");
44
+ const head = el.appendChild(h("h2", "", title));
45
+ const caption = head.appendChild(h("span"));
46
+ const preview = createPreview(root);
47
+ el.appendChild(h("div", "ct-pr-box")).append(preview.el);
48
+ return { preview, caption };
49
+ }
50
+ const now = cell(labels.now);
51
+ const coming = cell(labels.upNext, true);
52
+ const notes = app.appendChild(h("div", "ct-pr-notes"));
53
+ root.after(app);
54
+ document.documentElement.setAttribute("data-ct-open", "");
55
+ keepAwake(signal);
56
+ const guest = createGuest({ room, open: () => broadcast(room), trusted: true });
57
+ let drawnPage = "";
58
+ function draw() {
59
+ const { state } = guest;
60
+ app.toggleAttribute("data-ct-wait", !state);
61
+ if (!state)
62
+ return;
63
+ timers.draw(state, guest.stateAt);
64
+ app.style.setProperty("--ct-ratio", String(state.ratio ?? 16 / 9));
65
+ const pos = statePos(outline, state);
66
+ if (!pos)
67
+ return;
68
+ const overview = state.mode === "overview";
69
+ position.textContent = (overview ? `${labels.inOverview} · ` : "") + positionLabel(outline, pos, state);
70
+ const page = pageAt(outline, pos);
71
+ now.preview.show(page, state.step);
72
+ now.caption.textContent = outline.blocks[pos.b].slides[pos.s].title;
73
+ const up = upNext(outline, pos, state.step);
74
+ const kinds = { step: labels.nextStep, page: labels.nextPage, slide: labels.nextSlide, end: labels.theEnd };
75
+ coming.preview.show(up.kind === "end" ? null : pageAt(outline, up.pos), up.step);
76
+ coming.caption.textContent = up.kind === "end" ? kinds.end : `${kinds[up.kind]}|${up.text}`;
77
+ if (state.at === drawnPage)
78
+ return;
79
+ drawnPage = state.at;
80
+ notes.scrollTop = 0;
81
+ notes.innerHTML = notesHtml(page);
82
+ if (!notes.childNodes.length)
83
+ notes.append(h("p", "", defaultLabels.noNotes));
84
+ }
85
+ app.addEventListener("click", event => {
86
+ const command = event.target.closest("[data-ct-do]")?.dataset.ctDo;
87
+ if (command)
88
+ guest.command(command);
89
+ }, { signal });
90
+ document.addEventListener("keydown", event => {
91
+ if (event.metaKey || event.ctrlKey || event.altKey)
92
+ return;
93
+ const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
94
+ const onButton = !!event.target.closest?.("button,a");
95
+ if (onButton && (key === " " || key === "Enter"))
96
+ return;
97
+ const command = key === " " && event.shiftKey ? "prev" : KEYS[key];
98
+ if (!command)
99
+ return;
100
+ event.preventDefault();
101
+ guest.command(command);
102
+ }, { signal });
103
+ // The mouse over the current page is the laser pointer on the deck.
104
+ const sheet = now.preview.el;
105
+ const aim = (event, on) => {
106
+ const box = sheet.getBoundingClientRect();
107
+ guest.point((event.clientX - box.left) / box.width, (event.clientY - box.top) / box.height, on);
108
+ };
109
+ sheet.addEventListener("pointermove", event => aim(event, true), { signal });
110
+ sheet.addEventListener("pointerleave", event => aim(event, false), { signal });
111
+ const stop = guest.subscribe(draw);
112
+ const ticking = window.setInterval(() => timers.draw(guest.state, guest.stateAt), 500);
113
+ draw();
114
+ return {
115
+ destroy() {
116
+ abort.abort();
117
+ stop();
118
+ clearInterval(ticking);
119
+ guest.close();
120
+ now.preview.destroy();
121
+ coming.preview.destroy();
122
+ app.remove();
123
+ document.documentElement.removeAttribute("data-ct-open");
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,106 @@
1
+ /** The wire messages of docs/PROTOCOL.md, and the checks every incoming frame passes before it is used. */
2
+ /** Frames above this size are dropped by the relay (it closes the socket with 1009). */
3
+ export declare const MAX_FRAME = 2048;
4
+ /** An `ink` message carries at most this many points. */
5
+ export declare const MAX_POINTS = 64;
6
+ export type Command = "next" | "prev" | "slideNext" | "slidePrev" | "pageNext" | "pagePrev" | "overview" | "open";
7
+ /** Where the deck is and how the time stands; sent to the granted guest and to the presenter window. */
8
+ export interface StateMessage {
9
+ t: "state";
10
+ mode: "overview" | "stage";
11
+ /** `b.s.p`, 1-based. */
12
+ at: string;
13
+ step: number;
14
+ steps: number;
15
+ /** Seconds since the talk started; 0 while the clock has not started. */
16
+ elapsed: number;
17
+ /** Planned seconds of the whole talk, or `null` when no block has minutes. */
18
+ total: number | null;
19
+ blockElapsed: number;
20
+ blockMinutes: number | null;
21
+ /** Width over height of the stage, so a phone draws on a pad of the same shape. Not in protocol v1; optional. */
22
+ ratio?: number;
23
+ }
24
+ /** What the relay itself sends. */
25
+ export type RelayMessage = {
26
+ t: "hello";
27
+ id: string;
28
+ role: "host" | "guest";
29
+ } | {
30
+ t: "join";
31
+ id: string;
32
+ } | {
33
+ t: "leave";
34
+ id: string;
35
+ };
36
+ /** Host to guest. `to` addresses one guest; the relay strips nothing, the guest ignores it. */
37
+ export type HostMessage = {
38
+ t: "pair";
39
+ to: string;
40
+ code: string;
41
+ } | {
42
+ t: "grant";
43
+ to: string;
44
+ token: string;
45
+ } | {
46
+ t: "deny";
47
+ to: string;
48
+ } | {
49
+ t: "revoke";
50
+ to: string;
51
+ } | {
52
+ t: "pong";
53
+ to?: string;
54
+ } | (StateMessage & {
55
+ to?: string;
56
+ });
57
+ /** Guest to host. The relay sets `from`; over BroadcastChannel there is none. */
58
+ export type GuestMessage = {
59
+ t: "resume";
60
+ token: string;
61
+ } | {
62
+ t: "cmd";
63
+ c: Command;
64
+ at?: string;
65
+ } | {
66
+ t: "ptr";
67
+ x: number;
68
+ y: number;
69
+ on: boolean;
70
+ } | {
71
+ t: "ink";
72
+ s: number;
73
+ pts: number[];
74
+ end?: boolean;
75
+ } | {
76
+ t: "ink-clear";
77
+ } | {
78
+ t: "ping";
79
+ };
80
+ export type Message = RelayMessage | HostMessage | GuestMessage;
81
+ type Fields = Record<string, unknown>;
82
+ /** A frame as an object with a string `t`, or `null`. */
83
+ export declare function decode(frame: unknown): (Fields & {
84
+ t: string;
85
+ }) | null;
86
+ /**
87
+ * A message a guest may send, with every field checked and every coordinate clamped to [0, 1];
88
+ * `null` for anything else. This is the only door for input from outside the host's own page.
89
+ */
90
+ export declare function guestMessage(value: Fields): GuestMessage | null;
91
+ /** A `state` message as the guest reads it; a field of the wrong type takes its neutral value. */
92
+ export declare function stateMessage(value: Fields): StateMessage | null;
93
+ /**
94
+ * A host key: 26 characters of Crockford base32 from `crypto.getRandomValues` (the first carries 3 bits, 128 in all).
95
+ * It stays in the host's tab; the relay gives the host seat of a room only to the key whose hash is the room.
96
+ */
97
+ export declare const newKey: () => string;
98
+ /** A room name, and the shape of a host key too. */
99
+ export declare const isRoom: (value: unknown) => value is string;
100
+ /** The room of a host key: the first 26 Crockford base32 characters of SHA-256 of the key (docs/PROTOCOL.md). */
101
+ export declare function roomOf(key: string): Promise<string>;
102
+ /** A resume token: 22 characters of base64url, 132 random bits. */
103
+ export declare const newToken: () => string;
104
+ /** A fresh 4-digit pairing code, without modulo bias. */
105
+ export declare function newCode(): string;
106
+ export {};
@@ -0,0 +1,123 @@
1
+ /** The wire messages of docs/PROTOCOL.md, and the checks every incoming frame passes before it is used. */
2
+ /** Frames above this size are dropped by the relay (it closes the socket with 1009). */
3
+ export const MAX_FRAME = 2048;
4
+ /** An `ink` message carries at most this many points. */
5
+ export const MAX_POINTS = 64;
6
+ const COMMANDS = ["next", "prev", "slideNext", "slidePrev", "pageNext", "pagePrev", "overview", "open"];
7
+ const isFields = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8
+ const text = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
9
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
10
+ const unit = (value) => Math.min(1, Math.max(0, value));
11
+ /** A frame as an object with a string `t`, or `null`. */
12
+ export function decode(frame) {
13
+ if (typeof frame !== "string" || frame.length > MAX_FRAME)
14
+ return null;
15
+ try {
16
+ const value = JSON.parse(frame);
17
+ return isFields(value) && typeof value.t === "string" ? value : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /**
24
+ * A message a guest may send, with every field checked and every coordinate clamped to [0, 1];
25
+ * `null` for anything else. This is the only door for input from outside the host's own page.
26
+ */
27
+ export function guestMessage(value) {
28
+ switch (value.t) {
29
+ case "resume":
30
+ return text(value.token, 64) ? { t: "resume", token: value.token } : null;
31
+ case "cmd": {
32
+ if (typeof value.c !== "string" || !COMMANDS.includes(value.c))
33
+ return null;
34
+ const c = value.c;
35
+ if (c !== "open")
36
+ return { t: "cmd", c };
37
+ return text(value.at, 64) ? { t: "cmd", c, at: value.at } : null;
38
+ }
39
+ case "ptr":
40
+ return finite(value.x) && finite(value.y) && typeof value.on === "boolean"
41
+ ? { t: "ptr", x: unit(value.x), y: unit(value.y), on: value.on }
42
+ : null;
43
+ case "ink": {
44
+ const { s, pts } = value;
45
+ if (!finite(s) || !Array.isArray(pts) || pts.length < 2 || pts.length > MAX_POINTS * 2 || pts.length % 2)
46
+ return null;
47
+ if (!pts.every(finite))
48
+ return null;
49
+ return { t: "ink", s, pts: pts.map(unit), ...(value.end === true ? { end: true } : {}) };
50
+ }
51
+ case "ink-clear":
52
+ return { t: "ink-clear" };
53
+ case "ping":
54
+ return { t: "ping" };
55
+ default:
56
+ return null;
57
+ }
58
+ }
59
+ const number = (value, fallback = 0) => (finite(value) ? value : fallback);
60
+ /** A `state` message as the guest reads it; a field of the wrong type takes its neutral value. */
61
+ export function stateMessage(value) {
62
+ if (value.t !== "state" || typeof value.at !== "string")
63
+ return null;
64
+ return {
65
+ t: "state",
66
+ mode: value.mode === "overview" ? "overview" : "stage",
67
+ at: value.at.slice(0, 32),
68
+ step: number(value.step),
69
+ steps: number(value.steps),
70
+ elapsed: number(value.elapsed),
71
+ total: finite(value.total) ? value.total : null,
72
+ blockElapsed: number(value.blockElapsed),
73
+ blockMinutes: finite(value.blockMinutes) ? value.blockMinutes : null,
74
+ ...(finite(value.ratio) && value.ratio > 0 ? { ratio: value.ratio } : {}),
75
+ };
76
+ }
77
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
78
+ const BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
79
+ function randomText(alphabet, bits, length) {
80
+ const bytes = crypto.getRandomValues(new Uint8Array(Math.ceil((length * bits) / 8)));
81
+ let out = "";
82
+ let buffer = 0;
83
+ let held = 0;
84
+ for (const byte of bytes) {
85
+ buffer = (buffer << 8) | byte;
86
+ held += 8;
87
+ while (held >= bits && out.length < length) {
88
+ held -= bits;
89
+ out += alphabet[(buffer >> held) & ((1 << bits) - 1)];
90
+ }
91
+ buffer &= (1 << held) - 1;
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * A host key: 26 characters of Crockford base32 from `crypto.getRandomValues` (the first carries 3 bits, 128 in all).
97
+ * It stays in the host's tab; the relay gives the host seat of a room only to the key whose hash is the room.
98
+ */
99
+ export const newKey = () => CROCKFORD[crypto.getRandomValues(new Uint8Array(1))[0] & 7] + randomText(CROCKFORD, 5, 25);
100
+ /** A room name, and the shape of a host key too. */
101
+ export const isRoom = (value) => typeof value === "string" && /^[0-9A-HJKMNP-TV-Z]{26}$/.test(value);
102
+ /** The room of a host key: the first 26 Crockford base32 characters of SHA-256 of the key (docs/PROTOCOL.md). */
103
+ export async function roomOf(key) {
104
+ if (!globalThis.crypto?.subtle)
105
+ throw new Error("contour-deck: the remote relay needs a secure context (https or localhost)");
106
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key)));
107
+ let room = "";
108
+ for (let bit = 0; room.length < 26; bit += 5) {
109
+ const at = bit >> 3;
110
+ room += CROCKFORD[(((hash[at] << 8) | hash[at + 1]) >> (11 - (bit & 7))) & 31];
111
+ }
112
+ return room;
113
+ }
114
+ /** A resume token: 22 characters of base64url, 132 random bits. */
115
+ export const newToken = () => randomText(BASE64URL, 6, 22);
116
+ /** A fresh 4-digit pairing code, without modulo bias. */
117
+ export function newCode() {
118
+ const one = new Uint16Array(1);
119
+ do
120
+ crypto.getRandomValues(one);
121
+ while (one[0] >= 60000);
122
+ return String(one[0] % 10000).padStart(4, "0");
123
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A small QR encoder for the pairing links: byte mode, error correction M, versions 1 to 12
3
+ * (up to 287 bytes of UTF-8). A host that already ships a QR library passes its own through the `qr` option.
4
+ */
5
+ /** The modules of the QR code of `text` (`true` is dark), `[row][column]`, or `null` when it does not fit. */
6
+ export declare function qrMatrix(text: string): boolean[][] | null;
7
+ /** The QR code of `text` as an SVG string, dark on white whatever the theme; `null` when the text is too long. */
8
+ export declare function qrSvg(text: string): string | null;
@@ -0,0 +1,266 @@
1
+ /**
2
+ * A small QR encoder for the pairing links: byte mode, error correction M, versions 1 to 12
3
+ * (up to 287 bytes of UTF-8). A host that already ships a QR library passes its own through the `qr` option.
4
+ */
5
+ /** Error-correction codewords per block and the number of blocks at level M, for versions 1 to 12. */
6
+ const BLOCKS = [[10, 1], [16, 1], [26, 1], [18, 2], [24, 2], [16, 4], [18, 4], [22, 4], [22, 5], [26, 5], [30, 5], [22, 8]];
7
+ function gfMul(x, y) {
8
+ let z = 0;
9
+ for (let i = 7; i >= 0; i--) {
10
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
11
+ z ^= ((y >>> i) & 1) * x;
12
+ }
13
+ return z;
14
+ }
15
+ /** Reed-Solomon remainder of `data` for `degree` error-correction codewords. */
16
+ function errorCorrection(data, degree) {
17
+ const divisor = new Array(degree).fill(0);
18
+ divisor[degree - 1] = 1;
19
+ let root = 1;
20
+ for (let i = 0; i < degree; i++) {
21
+ for (let j = 0; j < degree; j++) {
22
+ divisor[j] = gfMul(divisor[j], root);
23
+ if (j + 1 < degree)
24
+ divisor[j] ^= divisor[j + 1];
25
+ }
26
+ root = gfMul(root, 2);
27
+ }
28
+ const result = new Array(degree).fill(0);
29
+ for (const byte of data) {
30
+ const factor = byte ^ result.shift();
31
+ result.push(0);
32
+ divisor.forEach((coefficient, i) => (result[i] ^= gfMul(coefficient, factor)));
33
+ }
34
+ return result;
35
+ }
36
+ /** Codewords a version holds in all, data and error correction. */
37
+ function rawCodewords(version) {
38
+ let modules = (16 * version + 128) * version + 64;
39
+ if (version >= 2) {
40
+ const align = Math.floor(version / 7) + 2;
41
+ modules -= (25 * align - 10) * align - 55;
42
+ if (version >= 7)
43
+ modules -= 36;
44
+ }
45
+ return modules >> 3;
46
+ }
47
+ function alignmentCentres(version, size) {
48
+ if (version === 1)
49
+ return [];
50
+ const count = Math.floor(version / 7) + 2;
51
+ const step = Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2;
52
+ const centres = [6];
53
+ for (let at = size - 7; centres.length < count; at -= step)
54
+ centres.splice(1, 0, at);
55
+ return centres;
56
+ }
57
+ const MASKS = [
58
+ (x, y) => (x + y) % 2 === 0,
59
+ (_, y) => y % 2 === 0,
60
+ x => x % 3 === 0,
61
+ (x, y) => (x + y) % 3 === 0,
62
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
63
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
64
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
65
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
66
+ ];
67
+ /** The penalty score of the standard: long runs, 2×2 blocks, finder look-alikes, and dark balance. */
68
+ function penalty(modules) {
69
+ const size = modules.length;
70
+ let score = 0;
71
+ let dark = 0;
72
+ const lines = [];
73
+ for (let i = 0; i < size; i++) {
74
+ let row = "";
75
+ let column = "";
76
+ for (let j = 0; j < size; j++) {
77
+ row += modules[i][j] ? 1 : 0;
78
+ column += modules[j][i] ? 1 : 0;
79
+ if (modules[i][j])
80
+ dark++;
81
+ if (i && j && modules[i][j] === modules[i - 1][j] && modules[i][j] === modules[i][j - 1] && modules[i][j] === modules[i - 1][j - 1])
82
+ score += 3;
83
+ }
84
+ lines.push(row, column);
85
+ }
86
+ for (const line of lines) {
87
+ for (const run of line.match(/0{5,}|1{5,}/g) ?? [])
88
+ score += run.length - 2;
89
+ for (let at = 0; at <= size - 11; at++) {
90
+ const window = line.substr(at, 11);
91
+ if (window === "10111010000" || window === "00001011101")
92
+ score += 40;
93
+ }
94
+ }
95
+ return score + Math.floor(Math.abs((dark * 20) / (size * size) - 10)) * 10;
96
+ }
97
+ /** The modules of the QR code of `text` (`true` is dark), `[row][column]`, or `null` when it does not fit. */
98
+ export function qrMatrix(text) {
99
+ const bytes = Array.from(new TextEncoder().encode(text));
100
+ let version = 0;
101
+ let dataLength = 0;
102
+ for (let v = 1; v <= BLOCKS.length && !version; v++) {
103
+ const [ecLength, blocks] = BLOCKS[v - 1];
104
+ dataLength = rawCodewords(v) - ecLength * blocks;
105
+ if (4 + (v < 10 ? 8 : 16) + bytes.length * 8 <= dataLength * 8)
106
+ version = v;
107
+ }
108
+ if (!version)
109
+ return null;
110
+ const [ecLength, blockCount] = BLOCKS[version - 1];
111
+ const size = version * 4 + 17;
112
+ /* ---- data codewords: mode, length, bytes, terminator, padding ---- */
113
+ const bits = [];
114
+ const push = (value, length) => {
115
+ for (let i = length - 1; i >= 0; i--)
116
+ bits.push((value >>> i) & 1);
117
+ };
118
+ push(4, 4);
119
+ push(bytes.length, version < 10 ? 8 : 16);
120
+ for (const byte of bytes)
121
+ push(byte, 8);
122
+ push(0, Math.min(4, dataLength * 8 - bits.length));
123
+ push(0, (8 - (bits.length % 8)) % 8);
124
+ for (let pad = 0xec; bits.length < dataLength * 8; pad ^= 0xec ^ 0x11)
125
+ push(pad, 8);
126
+ const data = [];
127
+ for (let i = 0; i < bits.length; i += 8)
128
+ data.push(bits.slice(i, i + 8).reduce((byte, bit) => (byte << 1) | bit, 0));
129
+ /* ---- blocks with error correction, interleaved ---- */
130
+ const shortBlocks = blockCount - (rawCodewords(version) % blockCount);
131
+ const shortLength = Math.floor(rawCodewords(version) / blockCount) - ecLength;
132
+ const blocks = [];
133
+ for (let i = 0, at = 0; i < blockCount; i++) {
134
+ const part = data.slice(at, (at += shortLength + (i < shortBlocks ? 0 : 1)));
135
+ blocks.push({ data: part, ec: errorCorrection(part, ecLength) });
136
+ }
137
+ const codewords = [];
138
+ for (let i = 0; i <= shortLength; i++)
139
+ for (const block of blocks)
140
+ if (i < block.data.length)
141
+ codewords.push(block.data[i]);
142
+ for (let i = 0; i < ecLength; i++)
143
+ for (const block of blocks)
144
+ codewords.push(block.ec[i]);
145
+ /* ---- function patterns ---- */
146
+ const modules = Array.from({ length: size }, () => new Array(size).fill(false));
147
+ const reserved = Array.from({ length: size }, () => new Array(size).fill(false));
148
+ const set = (x, y, dark) => {
149
+ if (x < 0 || y < 0 || x >= size || y >= size)
150
+ return;
151
+ modules[y][x] = dark;
152
+ reserved[y][x] = true;
153
+ };
154
+ for (let i = 0; i < size; i++) {
155
+ set(6, i, i % 2 === 0);
156
+ set(i, 6, i % 2 === 0);
157
+ }
158
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
159
+ for (let dy = -4; dy <= 4; dy++) {
160
+ for (let dx = -4; dx <= 4; dx++) {
161
+ const ring = Math.max(Math.abs(dx), Math.abs(dy));
162
+ set(cx + dx, cy + dy, ring !== 2 && ring !== 4);
163
+ }
164
+ }
165
+ }
166
+ const centres = alignmentCentres(version, size);
167
+ centres.forEach((cy, i) => centres.forEach((cx, j) => {
168
+ // The three corners with a finder pattern have no alignment pattern.
169
+ if ((i === 0 && j === 0) || (i === 0 && j === centres.length - 1) || (i === centres.length - 1 && j === 0))
170
+ return;
171
+ for (let dy = -2; dy <= 2; dy++)
172
+ for (let dx = -2; dx <= 2; dx++)
173
+ set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
174
+ }));
175
+ const format = (mask) => {
176
+ // Level M is 00, so the five data bits are the mask alone.
177
+ let remainder = mask;
178
+ for (let i = 0; i < 10; i++)
179
+ remainder = (remainder << 1) ^ ((remainder >>> 9) * 0x537);
180
+ const word = ((mask << 10) | remainder) ^ 0x5412;
181
+ const bit = (i) => ((word >>> i) & 1) === 1;
182
+ for (let i = 0; i <= 5; i++)
183
+ set(8, i, bit(i));
184
+ set(8, 7, bit(6));
185
+ set(8, 8, bit(7));
186
+ set(7, 8, bit(8));
187
+ for (let i = 9; i < 15; i++)
188
+ set(14 - i, 8, bit(i));
189
+ for (let i = 0; i < 8; i++)
190
+ set(size - 1 - i, 8, bit(i));
191
+ for (let i = 8; i < 15; i++)
192
+ set(8, size - 15 + i, bit(i));
193
+ set(8, size - 8, true);
194
+ };
195
+ format(0);
196
+ if (version >= 7) {
197
+ let remainder = version;
198
+ for (let i = 0; i < 12; i++)
199
+ remainder = (remainder << 1) ^ ((remainder >>> 11) * 0x1f25);
200
+ const word = (version << 12) | remainder;
201
+ for (let i = 0; i < 18; i++) {
202
+ const dark = ((word >>> i) & 1) === 1;
203
+ const a = size - 11 + (i % 3);
204
+ const b = Math.floor(i / 3);
205
+ set(a, b, dark);
206
+ set(b, a, dark);
207
+ }
208
+ }
209
+ /* ---- data in the zigzag, then the mask with the lowest penalty ---- */
210
+ let index = 0;
211
+ for (let right = size - 1; right >= 1; right -= 2) {
212
+ if (right === 6)
213
+ right = 5;
214
+ for (let step = 0; step < size; step++) {
215
+ for (let j = 0; j < 2; j++) {
216
+ const x = right - j;
217
+ const y = ((right + 1) & 2) === 0 ? size - 1 - step : step;
218
+ if (reserved[y][x] || index >= codewords.length * 8)
219
+ continue;
220
+ modules[y][x] = ((codewords[index >>> 3] >>> (7 - (index & 7))) & 1) === 1;
221
+ index++;
222
+ }
223
+ }
224
+ }
225
+ const flip = (mask) => {
226
+ for (let y = 0; y < size; y++)
227
+ for (let x = 0; x < size; x++)
228
+ if (!reserved[y][x] && MASKS[mask](x, y))
229
+ modules[y][x] = !modules[y][x];
230
+ };
231
+ let best = 0;
232
+ let lowest = Infinity;
233
+ for (let mask = 0; mask < 8; mask++) {
234
+ flip(mask);
235
+ format(mask);
236
+ const score = penalty(modules);
237
+ if (score < lowest)
238
+ [best, lowest] = [mask, score];
239
+ flip(mask);
240
+ }
241
+ flip(best);
242
+ format(best);
243
+ return modules;
244
+ }
245
+ /** Quiet zone around the code, in modules. */
246
+ const QUIET = 4;
247
+ /** The QR code of `text` as an SVG string, dark on white whatever the theme; `null` when the text is too long. */
248
+ export function qrSvg(text) {
249
+ const modules = qrMatrix(text);
250
+ if (!modules)
251
+ return null;
252
+ const side = modules.length + QUIET * 2;
253
+ let path = "";
254
+ modules.forEach((row, y) => {
255
+ for (let x = 0; x < row.length; x++) {
256
+ if (!row[x])
257
+ continue;
258
+ let run = 1;
259
+ while (row[x + run])
260
+ run++;
261
+ path += `M${x + QUIET} ${y + QUIET}h${run}v1h-${run}z`;
262
+ x += run;
263
+ }
264
+ });
265
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${side} ${side}" shape-rendering="crispEdges"><path fill="#fff" d="M0 0h${side}v${side}H0z"/><path d="${path}"/></svg>`;
266
+ }
@@ -0,0 +1,9 @@
1
+ import type { Aim } from "./tilt.js";
2
+ export type SensorResult = "ok" | "denied" | "unsupported";
3
+ export interface Sensor {
4
+ /** Asks for the sensor. Call it from a tap: iOS shows its permission prompt only then. */
5
+ start(): Promise<SensorResult>;
6
+ stop(): void;
7
+ }
8
+ /** Calls `onAim` with the direction the phone points at, and the time in seconds, for every reading. */
9
+ export declare function createSensor(onAim: (aim: Aim, t: number) => void): Sensor;