@statewalker/httpeers-join 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/qr.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * How the widget reads a QR code: a seam, with html5-qrcode behind it.
3
+ *
4
+ * A SEAM BECAUSE A TEST HAS NO CAMERA. The widget's own behaviour -- which
5
+ * button shows, what a scanned code does, what a picture with no code says --
6
+ * is tested through a fake `QrScanner`; the real one is two calls into
7
+ * `@statewalker/httpeers-qr/browser`, which has its own tests.
8
+ *
9
+ * THE CAMERA LIBRARY IS LOADED ON THE FIRST SCAN, NOT AT IMPORT. `html5-qrcode`
10
+ * is the largest thing in this package's closure by far, and most visits to a
11
+ * join page never scan anything: they resume, or arrive with `?join=`. A
12
+ * dynamic `import()` gives a bundler its own chunk to fetch only when someone
13
+ * presses a scan button, and keeps importing this module free of anything
14
+ * that needs a browser.
15
+ *
16
+ * WHAT COUNTS AS AN INVITATION is `invitationFromQrText` from
17
+ * `@statewalker/httpeers-member`: a join link (`?join=` or `?invite=`) or a
18
+ * join blob. A bare invitation id does NOT count from a QR -- nothing tells it
19
+ * apart from any other short string -- which is why a live scan carries on
20
+ * past a wifi sticker instead of trying to join with it.
21
+ */
22
+
23
+ import { invitationFromQrText } from "@statewalker/httpeers-member";
24
+
25
+ /** A running camera scan. */
26
+ export interface QrCameraScan {
27
+ stop(): Promise<void>;
28
+ }
29
+
30
+ /** What a picture held. */
31
+ export type QrFileScan =
32
+ | { ok: true; invitation: string }
33
+ /** `no-qr`: nothing readable in it. `not-accepted`: a QR, but not an invitation. */
34
+ | { ok: false; reason: "no-qr" | "not-accepted" };
35
+
36
+ export interface QrScanner {
37
+ /** Whether a camera can even be asked for. `false` hides the camera button; the picture stays. */
38
+ cameraAvailable(): boolean;
39
+ /**
40
+ * Scan from the camera into `host` (which has an `id`, and is visible) and
41
+ * call `onInvitation` once with the first invitation seen, the camera
42
+ * already stopped. REJECTS when the camera cannot start -- refused, absent,
43
+ * or an insecure origin -- which the widget turns into "choose a picture".
44
+ */
45
+ scanCamera(host: HTMLElement, onInvitation: (invitation: string) => void): Promise<QrCameraScan>;
46
+ /** Find an invitation in a picture. Never rejects for an ordinary miss; that is `ok: false`. */
47
+ scanFile(file: Blob): Promise<QrFileScan>;
48
+ }
49
+
50
+ /**
51
+ * The real scanner: the rear camera or a picture, through
52
+ * `@statewalker/httpeers-qr/browser`.
53
+ *
54
+ * `cameraAvailable` asks only whether `getUserMedia` exists. That is `false`
55
+ * on an insecure origin (browsers remove `mediaDevices` there) and in a
56
+ * browser with no camera API, which are the two cases where a button would be
57
+ * a promise the page cannot keep. A camera that exists but is refused is
58
+ * found out by pressing the button, and said then.
59
+ */
60
+ export function defaultQrScanner(): QrScanner {
61
+ return {
62
+ cameraAvailable: () =>
63
+ typeof navigator !== "undefined" &&
64
+ typeof navigator.mediaDevices?.getUserMedia === "function",
65
+ async scanCamera(host, onInvitation) {
66
+ const { scanFromCamera } = await import("@statewalker/httpeers-qr/browser");
67
+ return scanFromCamera(host, { accept: invitationFromQrText, onCode: onInvitation });
68
+ },
69
+ async scanFile(file) {
70
+ const { scanFile } = await import("@statewalker/httpeers-qr/browser");
71
+ const found = await scanFile(file, { accept: invitationFromQrText });
72
+ if (found.ok) return { ok: true, invitation: found.value };
73
+ // A QR was read and it was not an invitation: a second decoder would
74
+ // only read the same wrong code.
75
+ if (found.reason === "not-accepted") return { ok: false, reason: "not-accepted" };
76
+ const text = await decodePicture(file);
77
+ if (text == null) return { ok: false, reason: "no-qr" };
78
+ const invitation = invitationFromQrText(text);
79
+ return invitation != null ? { ok: true, invitation } : { ok: false, reason: "not-accepted" };
80
+ },
81
+ };
82
+ }
83
+
84
+ /**
85
+ * The longest sides a picture is decoded at, largest first, when
86
+ * html5-qrcode found nothing.
87
+ */
88
+ const FALLBACK_SIDES = [1600, 800, 400];
89
+
90
+ /**
91
+ * A second decoder for a picture: `decodeQr` (jsQR) from
92
+ * `@statewalker/httpeers-qr`, over the picture drawn at a few sizes.
93
+ *
94
+ * WHY THIS EXISTS. Measured on the demos hub page's own invitation QR (a
95
+ * 310-character join blob), html5-qrcode's `scanFile` found nothing in a
96
+ * clean 684x684 screenshot, nor at 600 or 480 pixels, and read it at 400
97
+ * and 300. A phone screenshot or photo is far larger than that. jsQR read the
98
+ * same 684-pixel picture at once. The two decoders fail on different
99
+ * pictures (the package's rung 03 measured 9/12 and 8/12 over twelve
100
+ * degradations, differing on heavy blur), so trying both costs a second only
101
+ * when the first has already failed.
102
+ *
103
+ * The sizes go DOWN from the picture's own size, capped at 1600: jsQR is
104
+ * slow on a 12-megapixel photo, and a code that fills a phone photo is still
105
+ * many pixels per module at 1600.
106
+ */
107
+ async function decodePicture(file: Blob): Promise<string | null> {
108
+ let bitmap: ImageBitmap;
109
+ try {
110
+ bitmap = await createImageBitmap(file);
111
+ } catch {
112
+ return null; // not a picture the browser can open
113
+ }
114
+ try {
115
+ const { decodeQr } = await import("@statewalker/httpeers-qr");
116
+ const longest = Math.max(bitmap.width, bitmap.height);
117
+ const sides = [Math.min(longest, FALLBACK_SIDES[0] as number)];
118
+ for (const side of FALLBACK_SIDES) if (side < (sides[0] as number)) sides.push(side);
119
+ for (const side of sides) {
120
+ const scale = side / longest;
121
+ const width = Math.max(1, Math.round(bitmap.width * scale));
122
+ const height = Math.max(1, Math.round(bitmap.height * scale));
123
+ const canvas = document.createElement("canvas");
124
+ canvas.width = width;
125
+ canvas.height = height;
126
+ const context = canvas.getContext("2d", { willReadFrequently: true });
127
+ if (context == null) return null;
128
+ context.drawImage(bitmap, 0, 0, width, height);
129
+ const text = decodeQr(context.getImageData(0, 0, width, height));
130
+ if (text != null) return text;
131
+ }
132
+ return null;
133
+ } finally {
134
+ bitmap.close();
135
+ }
136
+ }
package/src/styles.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The widget's own stylesheet, injected once per document.
3
+ *
4
+ * OWN CSS, NOT THE PAGE'S. The two pages that use it style in opposite ways:
5
+ * the demos with a few plain element rules, llm-chat with Tailwind, whose
6
+ * preflight strips every button of its border and background. Every rule here
7
+ * is on an `hp-join-` or `hp-invite-` class, so it neither leaks into the page nor depends on
8
+ * it, and it is NOT in a cascade layer -- unlayered rules beat Tailwind's
9
+ * `@layer base`, which is what keeps a button looking like a button under it.
10
+ *
11
+ * `font: inherit` everywhere: the widget takes the page's typeface and size.
12
+ * Colours are custom properties on `.hp-join`, so a page re-themes it by
13
+ * setting `--hp-join-accent` and friends instead of overriding selectors.
14
+ */
15
+
16
+ export const JOIN_WIDGET_STYLE_ID = "hp-join-style";
17
+
18
+ export const JOIN_WIDGET_CSS = `
19
+ .hp-join {
20
+ --hp-join-fg: #18181b;
21
+ --hp-join-muted: #52525b;
22
+ --hp-join-border: #d4d4d8;
23
+ --hp-join-bg: #ffffff;
24
+ --hp-join-accent: #1d4ed8;
25
+ --hp-join-accent-fg: #ffffff;
26
+ --hp-join-danger: #b91c1c;
27
+ --hp-join-note-bg: #eff6ff;
28
+ --hp-join-alert-bg: #fee2e2;
29
+ --hp-join-live: #15803d;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: .6rem;
33
+ font: inherit;
34
+ color: var(--hp-join-fg);
35
+ }
36
+ .hp-join p { margin: 0; }
37
+ .hp-join [hidden] { display: none !important; }
38
+ .hp-join-status { display: flex; flex-wrap: wrap; align-items: baseline; gap: .2rem .6rem; }
39
+ .hp-join-phase { font-weight: 600; }
40
+ .hp-join-phase::before {
41
+ content: ""; display: inline-block; width: .55em; height: .55em; border-radius: 50%;
42
+ margin-right: .45em; vertical-align: .08em; background: var(--hp-join-muted);
43
+ }
44
+ .hp-join[data-phase="live"] .hp-join-phase::before { background: var(--hp-join-live); }
45
+ .hp-join[data-phase="blocked"] .hp-join-phase::before,
46
+ .hp-join[data-phase="failed"] .hp-join-phase::before { background: var(--hp-join-danger); }
47
+ .hp-join-peer {
48
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em;
49
+ color: var(--hp-join-muted); word-break: break-all;
50
+ }
51
+ .hp-join-message, .hp-join-error {
52
+ padding: .6rem .8rem; border-radius: 6px; white-space: pre-wrap; font-size: .95em;
53
+ }
54
+ .hp-join-message { background: var(--hp-join-note-bg); }
55
+ .hp-join-message[role="alert"], .hp-join-error { background: var(--hp-join-alert-bg); }
56
+ .hp-join-form { display: flex; flex-direction: column; gap: .4rem; margin: 0; }
57
+ .hp-join-label { font-weight: 500; }
58
+ .hp-join-input {
59
+ font: inherit; font-size: .9em; width: 100%; box-sizing: border-box; padding: .45rem .55rem;
60
+ border: 1px solid var(--hp-join-border); border-radius: 6px; resize: vertical;
61
+ background: var(--hp-join-bg); color: var(--hp-join-fg);
62
+ }
63
+ .hp-join-input:focus-visible, .hp-join-button:focus-visible, .hp-join-file:focus-within,
64
+ .hp-join-menu-toggle:focus-visible {
65
+ outline: 2px solid var(--hp-join-accent); outline-offset: 2px;
66
+ }
67
+ .hp-join-actions, .hp-join-controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
68
+ .hp-join-button {
69
+ font: inherit; font-size: .95em; line-height: 1.3; padding: .4rem .8rem; cursor: pointer;
70
+ border: 1px solid var(--hp-join-border); border-radius: 6px;
71
+ background: var(--hp-join-bg); color: var(--hp-join-fg);
72
+ }
73
+ .hp-join-button:disabled { opacity: .5; cursor: default; }
74
+ .hp-join-primary {
75
+ background: var(--hp-join-accent); border-color: var(--hp-join-accent); color: var(--hp-join-accent-fg);
76
+ }
77
+ .hp-join-danger { color: var(--hp-join-danger); }
78
+ .hp-join-file { display: inline-block; position: relative; }
79
+ .hp-join-visually-hidden {
80
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
81
+ clip: rect(0 0 0 0); white-space: nowrap; border: 0;
82
+ }
83
+ .hp-join-viewfinder {
84
+ width: 100%; max-width: 22rem; min-height: 10rem; border-radius: 6px; overflow: hidden;
85
+ background: #000;
86
+ }
87
+ .hp-join-viewfinder video { width: 100% !important; display: block; }
88
+ .hp-join-camera-stop { align-self: flex-start; }
89
+ .hp-join-compact { flex-direction: row; align-items: center; gap: .5rem; font-size: .8rem; }
90
+ .hp-join-compact .hp-join-phase::before { width: .5em; height: .5em; }
91
+ .hp-join-compact .hp-join-phase { font-weight: 500; color: var(--hp-join-muted); }
92
+ .hp-join-menu { position: relative; }
93
+ .hp-join-menu-toggle {
94
+ cursor: pointer; list-style: none; padding: .1rem .45rem; border-radius: 6px;
95
+ border: 1px solid var(--hp-join-border); color: var(--hp-join-muted);
96
+ }
97
+ .hp-join-menu-toggle::-webkit-details-marker { display: none; }
98
+ .hp-join-menu-panel {
99
+ position: absolute; right: 0; top: calc(100% + .3rem); z-index: 20; width: max-content;
100
+ max-width: min(22rem, calc(100vw - 1.5rem)); max-height: calc(100vh - 5rem); overflow-y: auto;
101
+ display: flex; flex-direction: column; gap: .5rem; padding: .6rem;
102
+ background: var(--hp-join-bg); border: 1px solid var(--hp-join-border); border-radius: 8px;
103
+ box-shadow: 0 6px 20px rgb(0 0 0 / .12);
104
+ }
105
+ .hp-invite {
106
+ border-top: 1px solid var(--hp-join-border); padding-top: .5rem;
107
+ --hp-invite-warn-bg: #fef3c7; --hp-invite-warn-fg: #78350f;
108
+ }
109
+ .hp-join-compact .hp-invite { font-size: .9rem; }
110
+ .hp-invite-toggle { cursor: pointer; font-weight: 600; padding: .35rem 0; }
111
+ .hp-invite-body { display: flex; flex-direction: column; gap: .55rem; padding-top: .4rem; }
112
+ .hp-invite-roles { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: .3rem 1rem; }
113
+ .hp-invite-legend { font-weight: 500; padding: 0; margin-bottom: .2rem; }
114
+ .hp-invite-role { display: inline-flex; align-items: center; gap: .35rem; min-height: 2rem; cursor: pointer; }
115
+ .hp-invite-role-input { width: 1.1rem; height: 1.1rem; margin: 0; accent-color: var(--hp-join-accent); }
116
+ .hp-invite-warning {
117
+ padding: .55rem .75rem; border-radius: 6px; font-size: .9em;
118
+ background: var(--hp-invite-warn-bg); color: var(--hp-invite-warn-fg);
119
+ border-left: 3px solid var(--hp-join-danger);
120
+ }
121
+ .hp-invite-expiry {
122
+ font: inherit; font-size: .95em; padding: .4rem .5rem; border-radius: 6px;
123
+ border: 1px solid var(--hp-join-border); background: var(--hp-join-bg); color: var(--hp-join-fg);
124
+ }
125
+ .hp-invite-create-admin { background: var(--hp-join-danger); border-color: var(--hp-join-danger); }
126
+ .hp-invite-result { display: flex; flex-direction: column; gap: .45rem; }
127
+ .hp-invite-note { font-size: .9em; color: var(--hp-join-muted); }
128
+ .hp-invite-qr { width: 100%; max-width: 15rem; align-self: center; }
129
+ .hp-invite-qr svg { display: block; width: 100%; height: auto; }
130
+ .hp-invite-link { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em; }
131
+ .hp-invite-copied { font-size: .85em; color: var(--hp-join-live); }
132
+ .hp-invite-pending { display: flex; flex-direction: column; gap: .3rem; font-size: .9em; }
133
+ .hp-invite-pending-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
134
+ .hp-invite-pending-title { font-weight: 500; }
135
+ .hp-invite-refresh { font-size: .85em; padding: .2rem .55rem; }
136
+ .hp-invite-pending-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .25rem; }
137
+ .hp-invite-pending-empty { color: var(--hp-join-muted); }
138
+ .hp-invite-tag {
139
+ display: inline-block; padding: 0 .4rem; border-radius: 999px; font-size: .85em;
140
+ border: 1px solid var(--hp-join-border);
141
+ }
142
+ .hp-invite-tag-admin { color: var(--hp-join-danger); border-color: var(--hp-join-danger); }
143
+ .hp-invite-when { color: var(--hp-join-muted); }
144
+ @media (max-width: 30rem) {
145
+ /* On a phone the header's menu may sit mid-row; anchored to its button it
146
+ would run off the screen's left edge. It becomes a sheet along the bottom
147
+ instead, short enough to leave the header -- and the toggle that closes
148
+ it -- in view. */
149
+ .hp-join-menu-panel {
150
+ position: fixed; left: .5rem; right: .5rem; top: auto; bottom: .5rem; width: auto;
151
+ max-width: none; max-height: 70vh; box-shadow: 0 -4px 24px rgb(0 0 0 / .18);
152
+ }
153
+ .hp-join-button, .hp-invite-expiry { min-height: 2.5rem; }
154
+ .hp-invite-share, .hp-invite-copy, .hp-invite-create { flex: 1 1 auto; }
155
+ }
156
+ `;
157
+
158
+ /** Add the stylesheet to `doc` unless it is already there. */
159
+ export function injectJoinWidgetStyles(doc: Document): void {
160
+ if (doc.getElementById(JOIN_WIDGET_STYLE_ID) != null) return;
161
+ const style = doc.createElement("style");
162
+ style.id = JOIN_WIDGET_STYLE_ID;
163
+ style.textContent = JOIN_WIDGET_CSS;
164
+ doc.head.append(style);
165
+ }