@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/LICENSE +21 -0
- package/README.md +265 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/invite-panel.d.ts +50 -0
- package/dist/invite-panel.d.ts.map +1 -0
- package/dist/invite-panel.js +357 -0
- package/dist/invite.d.ts +126 -0
- package/dist/invite.d.ts.map +1 -0
- package/dist/invite.js +192 -0
- package/dist/qr.d.ts +60 -0
- package/dist/qr.d.ts.map +1 -0
- package/dist/qr.js +115 -0
- package/dist/styles.d.ts +19 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +163 -0
- package/dist/widget.d.ts +83 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +375 -0
- package/package.json +48 -0
- package/src/index.ts +56 -0
- package/src/invite-panel.ts +429 -0
- package/src/invite.ts +250 -0
- package/src/qr.ts +136 -0
- package/src/styles.ts +165 -0
- package/src/widget.ts +450 -0
package/dist/qr.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
/** A running camera scan. */
|
|
23
|
+
export interface QrCameraScan {
|
|
24
|
+
stop(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** What a picture held. */
|
|
27
|
+
export type QrFileScan = {
|
|
28
|
+
ok: true;
|
|
29
|
+
invitation: string;
|
|
30
|
+
}
|
|
31
|
+
/** `no-qr`: nothing readable in it. `not-accepted`: a QR, but not an invitation. */
|
|
32
|
+
| {
|
|
33
|
+
ok: false;
|
|
34
|
+
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
|
+
* The real scanner: the rear camera or a picture, through
|
|
51
|
+
* `@statewalker/httpeers-qr/browser`.
|
|
52
|
+
*
|
|
53
|
+
* `cameraAvailable` asks only whether `getUserMedia` exists. That is `false`
|
|
54
|
+
* on an insecure origin (browsers remove `mediaDevices` there) and in a
|
|
55
|
+
* browser with no camera API, which are the two cases where a button would be
|
|
56
|
+
* a promise the page cannot keep. A camera that exists but is refused is
|
|
57
|
+
* found out by pressing the button, and said then.
|
|
58
|
+
*/
|
|
59
|
+
export declare function defaultQrScanner(): QrScanner;
|
|
60
|
+
//# sourceMappingURL=qr.d.ts.map
|
package/dist/qr.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qr.d.ts","sourceRoot":"","sources":["../src/qr.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,6BAA6B;AAC7B,MAAM,WAAW,YAAY;IAC3B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,2BAA2B;AAC3B,MAAM,MAAM,UAAU,GAClB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE;AAClC,oFAAoF;GAClF;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,OAAO,GAAG,cAAc,CAAA;CAAE,CAAC;AAEpD,MAAM,WAAW,SAAS;IACxB,kGAAkG;IAClG,eAAe,IAAI,OAAO,CAAC;IAC3B;;;;;OAKG;IACH,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,YAAY,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACjG,gGAAgG;IAChG,QAAQ,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;CAC3C;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,IAAI,SAAS,CAsB5C"}
|
package/dist/qr.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
import { invitationFromQrText } from "@statewalker/httpeers-member";
|
|
23
|
+
/**
|
|
24
|
+
* The real scanner: the rear camera or a picture, through
|
|
25
|
+
* `@statewalker/httpeers-qr/browser`.
|
|
26
|
+
*
|
|
27
|
+
* `cameraAvailable` asks only whether `getUserMedia` exists. That is `false`
|
|
28
|
+
* on an insecure origin (browsers remove `mediaDevices` there) and in a
|
|
29
|
+
* browser with no camera API, which are the two cases where a button would be
|
|
30
|
+
* a promise the page cannot keep. A camera that exists but is refused is
|
|
31
|
+
* found out by pressing the button, and said then.
|
|
32
|
+
*/
|
|
33
|
+
export function defaultQrScanner() {
|
|
34
|
+
return {
|
|
35
|
+
cameraAvailable: () => typeof navigator !== "undefined" &&
|
|
36
|
+
typeof navigator.mediaDevices?.getUserMedia === "function",
|
|
37
|
+
async scanCamera(host, onInvitation) {
|
|
38
|
+
const { scanFromCamera } = await import("@statewalker/httpeers-qr/browser");
|
|
39
|
+
return scanFromCamera(host, { accept: invitationFromQrText, onCode: onInvitation });
|
|
40
|
+
},
|
|
41
|
+
async scanFile(file) {
|
|
42
|
+
const { scanFile } = await import("@statewalker/httpeers-qr/browser");
|
|
43
|
+
const found = await scanFile(file, { accept: invitationFromQrText });
|
|
44
|
+
if (found.ok)
|
|
45
|
+
return { ok: true, invitation: found.value };
|
|
46
|
+
// A QR was read and it was not an invitation: a second decoder would
|
|
47
|
+
// only read the same wrong code.
|
|
48
|
+
if (found.reason === "not-accepted")
|
|
49
|
+
return { ok: false, reason: "not-accepted" };
|
|
50
|
+
const text = await decodePicture(file);
|
|
51
|
+
if (text == null)
|
|
52
|
+
return { ok: false, reason: "no-qr" };
|
|
53
|
+
const invitation = invitationFromQrText(text);
|
|
54
|
+
return invitation != null ? { ok: true, invitation } : { ok: false, reason: "not-accepted" };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The longest sides a picture is decoded at, largest first, when
|
|
60
|
+
* html5-qrcode found nothing.
|
|
61
|
+
*/
|
|
62
|
+
const FALLBACK_SIDES = [1600, 800, 400];
|
|
63
|
+
/**
|
|
64
|
+
* A second decoder for a picture: `decodeQr` (jsQR) from
|
|
65
|
+
* `@statewalker/httpeers-qr`, over the picture drawn at a few sizes.
|
|
66
|
+
*
|
|
67
|
+
* WHY THIS EXISTS. Measured on the demos hub page's own invitation QR (a
|
|
68
|
+
* 310-character join blob), html5-qrcode's `scanFile` found nothing in a
|
|
69
|
+
* clean 684x684 screenshot, nor at 600 or 480 pixels, and read it at 400
|
|
70
|
+
* and 300. A phone screenshot or photo is far larger than that. jsQR read the
|
|
71
|
+
* same 684-pixel picture at once. The two decoders fail on different
|
|
72
|
+
* pictures (the package's rung 03 measured 9/12 and 8/12 over twelve
|
|
73
|
+
* degradations, differing on heavy blur), so trying both costs a second only
|
|
74
|
+
* when the first has already failed.
|
|
75
|
+
*
|
|
76
|
+
* The sizes go DOWN from the picture's own size, capped at 1600: jsQR is
|
|
77
|
+
* slow on a 12-megapixel photo, and a code that fills a phone photo is still
|
|
78
|
+
* many pixels per module at 1600.
|
|
79
|
+
*/
|
|
80
|
+
async function decodePicture(file) {
|
|
81
|
+
let bitmap;
|
|
82
|
+
try {
|
|
83
|
+
bitmap = await createImageBitmap(file);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null; // not a picture the browser can open
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const { decodeQr } = await import("@statewalker/httpeers-qr");
|
|
90
|
+
const longest = Math.max(bitmap.width, bitmap.height);
|
|
91
|
+
const sides = [Math.min(longest, FALLBACK_SIDES[0])];
|
|
92
|
+
for (const side of FALLBACK_SIDES)
|
|
93
|
+
if (side < sides[0])
|
|
94
|
+
sides.push(side);
|
|
95
|
+
for (const side of sides) {
|
|
96
|
+
const scale = side / longest;
|
|
97
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
98
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
99
|
+
const canvas = document.createElement("canvas");
|
|
100
|
+
canvas.width = width;
|
|
101
|
+
canvas.height = height;
|
|
102
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
103
|
+
if (context == null)
|
|
104
|
+
return null;
|
|
105
|
+
context.drawImage(bitmap, 0, 0, width, height);
|
|
106
|
+
const text = decodeQr(context.getImageData(0, 0, width, height));
|
|
107
|
+
if (text != null)
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
bitmap.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/styles.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
export declare const JOIN_WIDGET_STYLE_ID = "hp-join-style";
|
|
16
|
+
export declare const JOIN_WIDGET_CSS = "\n.hp-join {\n --hp-join-fg: #18181b;\n --hp-join-muted: #52525b;\n --hp-join-border: #d4d4d8;\n --hp-join-bg: #ffffff;\n --hp-join-accent: #1d4ed8;\n --hp-join-accent-fg: #ffffff;\n --hp-join-danger: #b91c1c;\n --hp-join-note-bg: #eff6ff;\n --hp-join-alert-bg: #fee2e2;\n --hp-join-live: #15803d;\n display: flex;\n flex-direction: column;\n gap: .6rem;\n font: inherit;\n color: var(--hp-join-fg);\n}\n.hp-join p { margin: 0; }\n.hp-join [hidden] { display: none !important; }\n.hp-join-status { display: flex; flex-wrap: wrap; align-items: baseline; gap: .2rem .6rem; }\n.hp-join-phase { font-weight: 600; }\n.hp-join-phase::before {\n content: \"\"; display: inline-block; width: .55em; height: .55em; border-radius: 50%;\n margin-right: .45em; vertical-align: .08em; background: var(--hp-join-muted);\n}\n.hp-join[data-phase=\"live\"] .hp-join-phase::before { background: var(--hp-join-live); }\n.hp-join[data-phase=\"blocked\"] .hp-join-phase::before,\n.hp-join[data-phase=\"failed\"] .hp-join-phase::before { background: var(--hp-join-danger); }\n.hp-join-peer {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em;\n color: var(--hp-join-muted); word-break: break-all;\n}\n.hp-join-message, .hp-join-error {\n padding: .6rem .8rem; border-radius: 6px; white-space: pre-wrap; font-size: .95em;\n}\n.hp-join-message { background: var(--hp-join-note-bg); }\n.hp-join-message[role=\"alert\"], .hp-join-error { background: var(--hp-join-alert-bg); }\n.hp-join-form { display: flex; flex-direction: column; gap: .4rem; margin: 0; }\n.hp-join-label { font-weight: 500; }\n.hp-join-input {\n font: inherit; font-size: .9em; width: 100%; box-sizing: border-box; padding: .45rem .55rem;\n border: 1px solid var(--hp-join-border); border-radius: 6px; resize: vertical;\n background: var(--hp-join-bg); color: var(--hp-join-fg);\n}\n.hp-join-input:focus-visible, .hp-join-button:focus-visible, .hp-join-file:focus-within,\n.hp-join-menu-toggle:focus-visible {\n outline: 2px solid var(--hp-join-accent); outline-offset: 2px;\n}\n.hp-join-actions, .hp-join-controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }\n.hp-join-button {\n font: inherit; font-size: .95em; line-height: 1.3; padding: .4rem .8rem; cursor: pointer;\n border: 1px solid var(--hp-join-border); border-radius: 6px;\n background: var(--hp-join-bg); color: var(--hp-join-fg);\n}\n.hp-join-button:disabled { opacity: .5; cursor: default; }\n.hp-join-primary {\n background: var(--hp-join-accent); border-color: var(--hp-join-accent); color: var(--hp-join-accent-fg);\n}\n.hp-join-danger { color: var(--hp-join-danger); }\n.hp-join-file { display: inline-block; position: relative; }\n.hp-join-visually-hidden {\n position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;\n clip: rect(0 0 0 0); white-space: nowrap; border: 0;\n}\n.hp-join-viewfinder {\n width: 100%; max-width: 22rem; min-height: 10rem; border-radius: 6px; overflow: hidden;\n background: #000;\n}\n.hp-join-viewfinder video { width: 100% !important; display: block; }\n.hp-join-camera-stop { align-self: flex-start; }\n.hp-join-compact { flex-direction: row; align-items: center; gap: .5rem; font-size: .8rem; }\n.hp-join-compact .hp-join-phase::before { width: .5em; height: .5em; }\n.hp-join-compact .hp-join-phase { font-weight: 500; color: var(--hp-join-muted); }\n.hp-join-menu { position: relative; }\n.hp-join-menu-toggle {\n cursor: pointer; list-style: none; padding: .1rem .45rem; border-radius: 6px;\n border: 1px solid var(--hp-join-border); color: var(--hp-join-muted);\n}\n.hp-join-menu-toggle::-webkit-details-marker { display: none; }\n.hp-join-menu-panel {\n position: absolute; right: 0; top: calc(100% + .3rem); z-index: 20; width: max-content;\n max-width: min(22rem, calc(100vw - 1.5rem)); max-height: calc(100vh - 5rem); overflow-y: auto;\n display: flex; flex-direction: column; gap: .5rem; padding: .6rem;\n background: var(--hp-join-bg); border: 1px solid var(--hp-join-border); border-radius: 8px;\n box-shadow: 0 6px 20px rgb(0 0 0 / .12);\n}\n.hp-invite {\n border-top: 1px solid var(--hp-join-border); padding-top: .5rem;\n --hp-invite-warn-bg: #fef3c7; --hp-invite-warn-fg: #78350f;\n}\n.hp-join-compact .hp-invite { font-size: .9rem; }\n.hp-invite-toggle { cursor: pointer; font-weight: 600; padding: .35rem 0; }\n.hp-invite-body { display: flex; flex-direction: column; gap: .55rem; padding-top: .4rem; }\n.hp-invite-roles { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: .3rem 1rem; }\n.hp-invite-legend { font-weight: 500; padding: 0; margin-bottom: .2rem; }\n.hp-invite-role { display: inline-flex; align-items: center; gap: .35rem; min-height: 2rem; cursor: pointer; }\n.hp-invite-role-input { width: 1.1rem; height: 1.1rem; margin: 0; accent-color: var(--hp-join-accent); }\n.hp-invite-warning {\n padding: .55rem .75rem; border-radius: 6px; font-size: .9em;\n background: var(--hp-invite-warn-bg); color: var(--hp-invite-warn-fg);\n border-left: 3px solid var(--hp-join-danger);\n}\n.hp-invite-expiry {\n font: inherit; font-size: .95em; padding: .4rem .5rem; border-radius: 6px;\n border: 1px solid var(--hp-join-border); background: var(--hp-join-bg); color: var(--hp-join-fg);\n}\n.hp-invite-create-admin { background: var(--hp-join-danger); border-color: var(--hp-join-danger); }\n.hp-invite-result { display: flex; flex-direction: column; gap: .45rem; }\n.hp-invite-note { font-size: .9em; color: var(--hp-join-muted); }\n.hp-invite-qr { width: 100%; max-width: 15rem; align-self: center; }\n.hp-invite-qr svg { display: block; width: 100%; height: auto; }\n.hp-invite-link { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em; }\n.hp-invite-copied { font-size: .85em; color: var(--hp-join-live); }\n.hp-invite-pending { display: flex; flex-direction: column; gap: .3rem; font-size: .9em; }\n.hp-invite-pending-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }\n.hp-invite-pending-title { font-weight: 500; }\n.hp-invite-refresh { font-size: .85em; padding: .2rem .55rem; }\n.hp-invite-pending-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .25rem; }\n.hp-invite-pending-empty { color: var(--hp-join-muted); }\n.hp-invite-tag {\n display: inline-block; padding: 0 .4rem; border-radius: 999px; font-size: .85em;\n border: 1px solid var(--hp-join-border);\n}\n.hp-invite-tag-admin { color: var(--hp-join-danger); border-color: var(--hp-join-danger); }\n.hp-invite-when { color: var(--hp-join-muted); }\n@media (max-width: 30rem) {\n /* On a phone the header's menu may sit mid-row; anchored to its button it\n would run off the screen's left edge. It becomes a sheet along the bottom\n instead, short enough to leave the header -- and the toggle that closes\n it -- in view. */\n .hp-join-menu-panel {\n position: fixed; left: .5rem; right: .5rem; top: auto; bottom: .5rem; width: auto;\n max-width: none; max-height: 70vh; box-shadow: 0 -4px 24px rgb(0 0 0 / .18);\n }\n .hp-join-button, .hp-invite-expiry { min-height: 2.5rem; }\n .hp-invite-share, .hp-invite-copy, .hp-invite-create { flex: 1 1 auto; }\n}\n";
|
|
17
|
+
/** Add the stylesheet to `doc` unless it is already there. */
|
|
18
|
+
export declare function injectJoinWidgetStyles(doc: Document): void;
|
|
19
|
+
//# sourceMappingURL=styles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"styles.d.ts","sourceRoot":"","sources":["../src/styles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,eAAO,MAAM,oBAAoB,kBAAkB,CAAC;AAEpD,eAAO,MAAM,eAAe,0mOA0I3B,CAAC;AAEF,8DAA8D;AAC9D,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,CAM1D"}
|
package/dist/styles.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
export const JOIN_WIDGET_STYLE_ID = "hp-join-style";
|
|
16
|
+
export const JOIN_WIDGET_CSS = `
|
|
17
|
+
.hp-join {
|
|
18
|
+
--hp-join-fg: #18181b;
|
|
19
|
+
--hp-join-muted: #52525b;
|
|
20
|
+
--hp-join-border: #d4d4d8;
|
|
21
|
+
--hp-join-bg: #ffffff;
|
|
22
|
+
--hp-join-accent: #1d4ed8;
|
|
23
|
+
--hp-join-accent-fg: #ffffff;
|
|
24
|
+
--hp-join-danger: #b91c1c;
|
|
25
|
+
--hp-join-note-bg: #eff6ff;
|
|
26
|
+
--hp-join-alert-bg: #fee2e2;
|
|
27
|
+
--hp-join-live: #15803d;
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
gap: .6rem;
|
|
31
|
+
font: inherit;
|
|
32
|
+
color: var(--hp-join-fg);
|
|
33
|
+
}
|
|
34
|
+
.hp-join p { margin: 0; }
|
|
35
|
+
.hp-join [hidden] { display: none !important; }
|
|
36
|
+
.hp-join-status { display: flex; flex-wrap: wrap; align-items: baseline; gap: .2rem .6rem; }
|
|
37
|
+
.hp-join-phase { font-weight: 600; }
|
|
38
|
+
.hp-join-phase::before {
|
|
39
|
+
content: ""; display: inline-block; width: .55em; height: .55em; border-radius: 50%;
|
|
40
|
+
margin-right: .45em; vertical-align: .08em; background: var(--hp-join-muted);
|
|
41
|
+
}
|
|
42
|
+
.hp-join[data-phase="live"] .hp-join-phase::before { background: var(--hp-join-live); }
|
|
43
|
+
.hp-join[data-phase="blocked"] .hp-join-phase::before,
|
|
44
|
+
.hp-join[data-phase="failed"] .hp-join-phase::before { background: var(--hp-join-danger); }
|
|
45
|
+
.hp-join-peer {
|
|
46
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em;
|
|
47
|
+
color: var(--hp-join-muted); word-break: break-all;
|
|
48
|
+
}
|
|
49
|
+
.hp-join-message, .hp-join-error {
|
|
50
|
+
padding: .6rem .8rem; border-radius: 6px; white-space: pre-wrap; font-size: .95em;
|
|
51
|
+
}
|
|
52
|
+
.hp-join-message { background: var(--hp-join-note-bg); }
|
|
53
|
+
.hp-join-message[role="alert"], .hp-join-error { background: var(--hp-join-alert-bg); }
|
|
54
|
+
.hp-join-form { display: flex; flex-direction: column; gap: .4rem; margin: 0; }
|
|
55
|
+
.hp-join-label { font-weight: 500; }
|
|
56
|
+
.hp-join-input {
|
|
57
|
+
font: inherit; font-size: .9em; width: 100%; box-sizing: border-box; padding: .45rem .55rem;
|
|
58
|
+
border: 1px solid var(--hp-join-border); border-radius: 6px; resize: vertical;
|
|
59
|
+
background: var(--hp-join-bg); color: var(--hp-join-fg);
|
|
60
|
+
}
|
|
61
|
+
.hp-join-input:focus-visible, .hp-join-button:focus-visible, .hp-join-file:focus-within,
|
|
62
|
+
.hp-join-menu-toggle:focus-visible {
|
|
63
|
+
outline: 2px solid var(--hp-join-accent); outline-offset: 2px;
|
|
64
|
+
}
|
|
65
|
+
.hp-join-actions, .hp-join-controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
|
|
66
|
+
.hp-join-button {
|
|
67
|
+
font: inherit; font-size: .95em; line-height: 1.3; padding: .4rem .8rem; cursor: pointer;
|
|
68
|
+
border: 1px solid var(--hp-join-border); border-radius: 6px;
|
|
69
|
+
background: var(--hp-join-bg); color: var(--hp-join-fg);
|
|
70
|
+
}
|
|
71
|
+
.hp-join-button:disabled { opacity: .5; cursor: default; }
|
|
72
|
+
.hp-join-primary {
|
|
73
|
+
background: var(--hp-join-accent); border-color: var(--hp-join-accent); color: var(--hp-join-accent-fg);
|
|
74
|
+
}
|
|
75
|
+
.hp-join-danger { color: var(--hp-join-danger); }
|
|
76
|
+
.hp-join-file { display: inline-block; position: relative; }
|
|
77
|
+
.hp-join-visually-hidden {
|
|
78
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
|
|
79
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
80
|
+
}
|
|
81
|
+
.hp-join-viewfinder {
|
|
82
|
+
width: 100%; max-width: 22rem; min-height: 10rem; border-radius: 6px; overflow: hidden;
|
|
83
|
+
background: #000;
|
|
84
|
+
}
|
|
85
|
+
.hp-join-viewfinder video { width: 100% !important; display: block; }
|
|
86
|
+
.hp-join-camera-stop { align-self: flex-start; }
|
|
87
|
+
.hp-join-compact { flex-direction: row; align-items: center; gap: .5rem; font-size: .8rem; }
|
|
88
|
+
.hp-join-compact .hp-join-phase::before { width: .5em; height: .5em; }
|
|
89
|
+
.hp-join-compact .hp-join-phase { font-weight: 500; color: var(--hp-join-muted); }
|
|
90
|
+
.hp-join-menu { position: relative; }
|
|
91
|
+
.hp-join-menu-toggle {
|
|
92
|
+
cursor: pointer; list-style: none; padding: .1rem .45rem; border-radius: 6px;
|
|
93
|
+
border: 1px solid var(--hp-join-border); color: var(--hp-join-muted);
|
|
94
|
+
}
|
|
95
|
+
.hp-join-menu-toggle::-webkit-details-marker { display: none; }
|
|
96
|
+
.hp-join-menu-panel {
|
|
97
|
+
position: absolute; right: 0; top: calc(100% + .3rem); z-index: 20; width: max-content;
|
|
98
|
+
max-width: min(22rem, calc(100vw - 1.5rem)); max-height: calc(100vh - 5rem); overflow-y: auto;
|
|
99
|
+
display: flex; flex-direction: column; gap: .5rem; padding: .6rem;
|
|
100
|
+
background: var(--hp-join-bg); border: 1px solid var(--hp-join-border); border-radius: 8px;
|
|
101
|
+
box-shadow: 0 6px 20px rgb(0 0 0 / .12);
|
|
102
|
+
}
|
|
103
|
+
.hp-invite {
|
|
104
|
+
border-top: 1px solid var(--hp-join-border); padding-top: .5rem;
|
|
105
|
+
--hp-invite-warn-bg: #fef3c7; --hp-invite-warn-fg: #78350f;
|
|
106
|
+
}
|
|
107
|
+
.hp-join-compact .hp-invite { font-size: .9rem; }
|
|
108
|
+
.hp-invite-toggle { cursor: pointer; font-weight: 600; padding: .35rem 0; }
|
|
109
|
+
.hp-invite-body { display: flex; flex-direction: column; gap: .55rem; padding-top: .4rem; }
|
|
110
|
+
.hp-invite-roles { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: .3rem 1rem; }
|
|
111
|
+
.hp-invite-legend { font-weight: 500; padding: 0; margin-bottom: .2rem; }
|
|
112
|
+
.hp-invite-role { display: inline-flex; align-items: center; gap: .35rem; min-height: 2rem; cursor: pointer; }
|
|
113
|
+
.hp-invite-role-input { width: 1.1rem; height: 1.1rem; margin: 0; accent-color: var(--hp-join-accent); }
|
|
114
|
+
.hp-invite-warning {
|
|
115
|
+
padding: .55rem .75rem; border-radius: 6px; font-size: .9em;
|
|
116
|
+
background: var(--hp-invite-warn-bg); color: var(--hp-invite-warn-fg);
|
|
117
|
+
border-left: 3px solid var(--hp-join-danger);
|
|
118
|
+
}
|
|
119
|
+
.hp-invite-expiry {
|
|
120
|
+
font: inherit; font-size: .95em; padding: .4rem .5rem; border-radius: 6px;
|
|
121
|
+
border: 1px solid var(--hp-join-border); background: var(--hp-join-bg); color: var(--hp-join-fg);
|
|
122
|
+
}
|
|
123
|
+
.hp-invite-create-admin { background: var(--hp-join-danger); border-color: var(--hp-join-danger); }
|
|
124
|
+
.hp-invite-result { display: flex; flex-direction: column; gap: .45rem; }
|
|
125
|
+
.hp-invite-note { font-size: .9em; color: var(--hp-join-muted); }
|
|
126
|
+
.hp-invite-qr { width: 100%; max-width: 15rem; align-self: center; }
|
|
127
|
+
.hp-invite-qr svg { display: block; width: 100%; height: auto; }
|
|
128
|
+
.hp-invite-link { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em; }
|
|
129
|
+
.hp-invite-copied { font-size: .85em; color: var(--hp-join-live); }
|
|
130
|
+
.hp-invite-pending { display: flex; flex-direction: column; gap: .3rem; font-size: .9em; }
|
|
131
|
+
.hp-invite-pending-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
|
|
132
|
+
.hp-invite-pending-title { font-weight: 500; }
|
|
133
|
+
.hp-invite-refresh { font-size: .85em; padding: .2rem .55rem; }
|
|
134
|
+
.hp-invite-pending-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .25rem; }
|
|
135
|
+
.hp-invite-pending-empty { color: var(--hp-join-muted); }
|
|
136
|
+
.hp-invite-tag {
|
|
137
|
+
display: inline-block; padding: 0 .4rem; border-radius: 999px; font-size: .85em;
|
|
138
|
+
border: 1px solid var(--hp-join-border);
|
|
139
|
+
}
|
|
140
|
+
.hp-invite-tag-admin { color: var(--hp-join-danger); border-color: var(--hp-join-danger); }
|
|
141
|
+
.hp-invite-when { color: var(--hp-join-muted); }
|
|
142
|
+
@media (max-width: 30rem) {
|
|
143
|
+
/* On a phone the header's menu may sit mid-row; anchored to its button it
|
|
144
|
+
would run off the screen's left edge. It becomes a sheet along the bottom
|
|
145
|
+
instead, short enough to leave the header -- and the toggle that closes
|
|
146
|
+
it -- in view. */
|
|
147
|
+
.hp-join-menu-panel {
|
|
148
|
+
position: fixed; left: .5rem; right: .5rem; top: auto; bottom: .5rem; width: auto;
|
|
149
|
+
max-width: none; max-height: 70vh; box-shadow: 0 -4px 24px rgb(0 0 0 / .18);
|
|
150
|
+
}
|
|
151
|
+
.hp-join-button, .hp-invite-expiry { min-height: 2.5rem; }
|
|
152
|
+
.hp-invite-share, .hp-invite-copy, .hp-invite-create { flex: 1 1 auto; }
|
|
153
|
+
}
|
|
154
|
+
`;
|
|
155
|
+
/** Add the stylesheet to `doc` unless it is already there. */
|
|
156
|
+
export function injectJoinWidgetStyles(doc) {
|
|
157
|
+
if (doc.getElementById(JOIN_WIDGET_STYLE_ID) != null)
|
|
158
|
+
return;
|
|
159
|
+
const style = doc.createElement("style");
|
|
160
|
+
style.id = JOIN_WIDGET_STYLE_ID;
|
|
161
|
+
style.textContent = JOIN_WIDGET_CSS;
|
|
162
|
+
doc.head.append(style);
|
|
163
|
+
}
|
package/dist/widget.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The join widget: one small piece of DOM that renders a `SessionState` and
|
|
3
|
+
* calls the session's four controls.
|
|
4
|
+
*
|
|
5
|
+
* IT HOLDS NO SESSION STATE OF ITS OWN. `PeerSession` already decides every
|
|
6
|
+
* thing worth deciding -- which phase, which message, which controls make
|
|
7
|
+
* sense (`SessionState.controls`) -- and the widget only shows it. The page
|
|
8
|
+
* owns the session and hands each new state to `update()`, because the session
|
|
9
|
+
* reports changes through one `onChange` callback that the page wires up when
|
|
10
|
+
* it creates it; the widget cannot subscribe on its own.
|
|
11
|
+
*
|
|
12
|
+
* What the widget DOES own is what exists only on screen: the text being
|
|
13
|
+
* typed, a camera that is running, a button that is waiting on its call, and
|
|
14
|
+
* the complaint left by a call that rejected.
|
|
15
|
+
*
|
|
16
|
+
* NOTHING HAPPENS AT IMPORT. No `document`, no `window`, no stylesheet until
|
|
17
|
+
* `mountJoinWidget` runs -- `tests/import.test.ts` imports the built entry
|
|
18
|
+
* under plain Node to hold the package to that, because a page's bundle may
|
|
19
|
+
* import this module long before it has anything to mount.
|
|
20
|
+
*/
|
|
21
|
+
import type { PeerSession, SessionState } from "@statewalker/httpeers-member";
|
|
22
|
+
import type { HubTarget } from "./invite.js";
|
|
23
|
+
import { type InvitePanelOptions } from "./invite-panel.js";
|
|
24
|
+
import { type QrScanner } from "./qr.js";
|
|
25
|
+
/** The four calls the widget makes. `PeerSession` satisfies it; a test passes four spies. */
|
|
26
|
+
export type JoinWidgetSession = Pick<PeerSession, "join" | "disconnect" | "reconnect" | "resetIdentity">;
|
|
27
|
+
/**
|
|
28
|
+
* The part of `SessionState` the widget reads. A full `SessionState` is one;
|
|
29
|
+
* so is a hand-built test state with no handle. The handle is read only for
|
|
30
|
+
* the Invite panel: its hub peer id and edge base URL.
|
|
31
|
+
*/
|
|
32
|
+
export type JoinWidgetState = Pick<SessionState, "phase" | "identity" | "hubLink" | "controls"> & {
|
|
33
|
+
handle?: HubTarget | null;
|
|
34
|
+
};
|
|
35
|
+
export interface JoinWidgetOptions {
|
|
36
|
+
session: JoinWidgetSession;
|
|
37
|
+
/** The state to show at once. Usually `session.state()`; omitted, the widget says it is starting until `update()`. */
|
|
38
|
+
state?: JoinWidgetState | null;
|
|
39
|
+
/**
|
|
40
|
+
* A status line and a small menu with Disconnect and Leave, and no join
|
|
41
|
+
* form -- for a header that stays on screen while the page does its real
|
|
42
|
+
* work. The full widget is the default.
|
|
43
|
+
*/
|
|
44
|
+
compact?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* How QR codes are read. Defaults to `defaultQrScanner()` (html5-qrcode,
|
|
47
|
+
* loaded on the first scan). `null` hides both QR buttons.
|
|
48
|
+
*/
|
|
49
|
+
scanner?: QrScanner | null;
|
|
50
|
+
/** Asked before "Leave this mesh". Defaults to `window.confirm`. May be async, for a page with its own dialog. */
|
|
51
|
+
confirm?: (message: string) => boolean | Promise<boolean>;
|
|
52
|
+
/** `false` leaves styling to the page; the class names are the API then. Default `true`. */
|
|
53
|
+
injectStyles?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* The Invite panel, shown to a mesh admin while live (see `./invite-panel.ts`).
|
|
56
|
+
* On by default: it asks the hub once per link and stays hidden for anyone
|
|
57
|
+
* the hub refuses. `false` leaves it out altogether; an object configures it.
|
|
58
|
+
*/
|
|
59
|
+
invite?: InvitePanelOptions | false;
|
|
60
|
+
}
|
|
61
|
+
export interface JoinWidget {
|
|
62
|
+
/** The widget's root element, already inside the container. */
|
|
63
|
+
readonly element: HTMLElement;
|
|
64
|
+
/** Show a new state. Call it from the session's `onChange`, and on any timer the page already re-reads the state on. */
|
|
65
|
+
update(state: JoinWidgetState | null): void;
|
|
66
|
+
/** Stop the camera if it runs and remove the widget. The stylesheet stays: another instance may be using it. */
|
|
67
|
+
destroy(): void;
|
|
68
|
+
}
|
|
69
|
+
/** What "Leave this mesh" asks before it forgets the identity. */
|
|
70
|
+
export declare const LEAVE_CONFIRMATION: string;
|
|
71
|
+
/** `12D3KooW…a1b2c3`: enough to tell two peers apart on screen. The whole id goes in a tooltip. */
|
|
72
|
+
export declare function shortPeerId(peerId: string): string;
|
|
73
|
+
/** The status line's text for a state, for a page that wants it without the widget. */
|
|
74
|
+
export declare function describePhase(state: JoinWidgetState | null): string;
|
|
75
|
+
/**
|
|
76
|
+
* Build the widget inside `container` and return its handle.
|
|
77
|
+
*
|
|
78
|
+
* Every id is suffixed per instance, so two widgets on one page -- a full one
|
|
79
|
+
* and a compact one in a header -- never share a label target or a camera
|
|
80
|
+
* host.
|
|
81
|
+
*/
|
|
82
|
+
export declare function mountJoinWidget(container: HTMLElement, options: JoinWidgetOptions): JoinWidget;
|
|
83
|
+
//# sourceMappingURL=widget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widget.d.ts","sourceRoot":"","sources":["../src/widget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAuC,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACjG,OAAO,EAAoB,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AAG3D,6FAA6F;AAC7F,MAAM,MAAM,iBAAiB,GAAG,IAAI,CAClC,WAAW,EACX,MAAM,GAAG,YAAY,GAAG,WAAW,GAAG,eAAe,CACtD,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,YAAY,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,CAAC,GAAG;IAChG,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;CAC3B,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,sHAAsH;IACtH,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IAC/B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC3B,kHAAkH;IAClH,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,4FAA4F;IAC5F,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,MAAM,CAAC,EAAE,kBAAkB,GAAG,KAAK,CAAC;CACrC;AAED,MAAM,WAAW,UAAU;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,wHAAwH;IACxH,MAAM,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,GAAG,IAAI,CAAC;IAC5C,gHAAgH;IAChH,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,kEAAkE;AAClE,eAAO,MAAM,kBAAkB,QAGuC,CAAC;AAEvE,mGAAmG;AACnG,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,uFAAuF;AACvF,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,GAAG,MAAM,CAmBnE;AAgCD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,WAAW,EAAE,OAAO,EAAE,iBAAiB,GAAG,UAAU,CA6S9F"}
|