@statewalker/httpeers-join 0.1.3 → 0.1.4

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 (2) hide show
  1. package/README.md +94 -59
  2. package/package.json +8 -4
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @statewalker/httpeers-join
2
2
 
3
+ ## What it is
4
+
3
5
  The **join-the-mesh widget**: one small piece of plain DOM that every page joining
4
6
  an httpeers hub shows the same way.
5
7
 
@@ -15,27 +17,30 @@ an httpeers hub shows the same way.
15
17
  (`session.resetIdentity()`, which forgets the identity), behind a confirmation.
16
18
  - **Invite** (mesh admins only): make an invitation as a member or an admin, valid for 1 hour,
17
19
  1 day or 7 days, and hand it over as a link, a QR code, the share sheet or the clipboard. See
18
- [Invite](#invite).
20
+ [Invite](#invite-is-shown-only-to-an-admin-the-hub-confirms).
19
21
 
20
22
  Every button is shown only when `SessionState.controls` allows it. There is one addition: "Leave"
21
23
  is hidden while there is no identity (a first run has nothing to forget). The widget decides
22
24
  nothing else about the session: `PeerSession` already decides it, and the widget shows the
23
25
  result.
24
26
 
25
- ```ts
26
- import { mountJoinWidget } from "@statewalker/httpeers-join";
27
- import { createSession } from "@statewalker/httpeers-member/browser";
27
+ ## Why it exists
28
28
 
29
- let widget;
30
- const session = createSession({ /* … */, onChange: (state) => widget?.update(state) });
31
- widget = mountJoinWidget(document.querySelector("#mesh-join")!, {
32
- session,
33
- state: session.state(),
34
- });
35
- await session.start();
29
+ Several pages join a hub: llm-chat's `mesh.html` (React and Tailwind) and the demos' `app`,
30
+ `images` and `proxy` pages (plain DOM). Each needs the same join form, QR scanning, Disconnect,
31
+ Reconnect, Leave and, for an admin, Invite, with the same wording. Apps do not import from other
32
+ apps, so a package is the one place they can all share it from, and plain DOM is the one shape both
33
+ a React page and a framework-free page can use.
34
+
35
+ ## How to use
36
+
37
+ ```sh
38
+ pnpm add @statewalker/httpeers-join
36
39
  ```
37
40
 
38
- ## API
41
+ One entry point (`.`). It needs a DOM at mount time, not at import. Dependencies:
42
+ `@statewalker/httpeers-member` (types and `invitationFromQrText`) and `@statewalker/httpeers-qr`
43
+ (the scanner and `qrSvg`, both loaded lazily); `html5-qrcode` comes with the QR scanner.
39
44
 
40
45
  | Export | What it is |
41
46
  |---|---|
@@ -69,7 +74,52 @@ await session.start();
69
74
  The state passed to `update()` may carry `handle` (a `SessionState` does). The widget reads only
70
75
  its `hubPeerId`, `baseUrl` and `meshView()`, and only for the Invite panel.
71
76
 
72
- **QR.** `defaultQrScanner()` loads `@statewalker/httpeers-qr/browser` (and with it
77
+ ## Examples
78
+
79
+ A plain page:
80
+
81
+ ```ts
82
+ import { type JoinWidget, mountJoinWidget } from "@statewalker/httpeers-join";
83
+ import { createSession } from "@statewalker/httpeers-member/browser";
84
+
85
+ let widget: JoinWidget | undefined;
86
+ const session = createSession({ key: "peers", mounts, rules, onChange: (state) => widget?.update(state) });
87
+ widget = mountJoinWidget(document.querySelector("#mesh-join")!, {
88
+ session,
89
+ state: session.state(),
90
+ });
91
+ await session.start();
92
+ ```
93
+
94
+ In React. The widget is not a React component, and React pages wrap it in a few lines. The session and the
95
+ state stay in React. The widget is mounted once and fed each state:
96
+
97
+ ```tsx
98
+ import { useEffect, useRef } from "react";
99
+ import { type JoinWidget, mountJoinWidget } from "@statewalker/httpeers-join";
100
+
101
+ function JoinWidgetView({ session, state, compact }: Props) {
102
+ const host = useRef<HTMLDivElement>(null);
103
+ const widget = useRef<JoinWidget | null>(null);
104
+ useEffect(() => {
105
+ if (host.current == null) return;
106
+ const w = mountJoinWidget(host.current, { session, compact });
107
+ widget.current = w;
108
+ return () => { w.destroy(); widget.current = null; };
109
+ }, [session, compact]);
110
+ useEffect(() => widget.current?.update(state), [state]);
111
+ return <div ref={host} />;
112
+ }
113
+ ```
114
+
115
+ `apps/llm-chat/src/mesh/join-widget.tsx` is this wrapper. It lives in `mesh/` because the
116
+ boundary test keeps httpeers out of everything the standalone page reaches.
117
+
118
+ ## Internals
119
+
120
+ ### The QR scanner loads on the first scan
121
+
122
+ `defaultQrScanner()` loads `@statewalker/httpeers-qr/browser` (and with it
73
123
  `html5-qrcode`) with a dynamic `import()` on the **first scan**, so a page that only resumes
74
124
  never downloads the camera library, and a bundler gives it a chunk of its own. The camera button
75
125
  is hidden when `navigator.mediaDevices.getUserMedia` is missing, which covers an insecure origin
@@ -78,7 +128,9 @@ is reported when the button is pressed, with a pointer to the picture button. A
78
128
  QR code that is not an invitation keeps scanning (camera) or says so (picture). A bare invitation
79
129
  id is not accepted from a QR code: nothing tells it apart from any other short string.
80
130
 
81
- **A picture gets two decoders.** html5-qrcode's `scanFile` could not read the demos hub's own
131
+ ### A picture gets two decoders, because one misses mid-size screenshots
132
+
133
+ html5-qrcode's `scanFile` could not read the demos hub's own
82
134
  invitation QR (a 310-character join blob) from a clean 684 px screenshot, nor at 600 or 480 px.
83
135
  It read the same picture at 400 px and below. Phone screenshots and photos are larger than that.
84
136
  When html5-qrcode finds nothing, `defaultQrScanner` therefore draws the picture to a canvas at
@@ -88,19 +140,23 @@ built demos app, every size from 300 to 3000 px decodes, as does a blurred, tilt
88
140
  3000 px copy. The same run, scanning the hub page's real QR code, joined its mesh live. The
89
141
  camera path is html5-qrcode alone. It gets many frames and the person aims it.
90
142
 
91
- **Accessibility.** The status line is `role="status"`. The phase message is `role="alert"` when it
143
+ ### Status and errors are announced
144
+
145
+ The status line is `role="status"`. The phase message is `role="alert"` when it
92
146
  reports trouble (a hub that does not know this peer, a refused invitation, `blocked`, `failed`) and
93
147
  `role="status"` when it gives instructions (a first run, `disconnected`, a live note). A rejected
94
148
  call and a failed scan go to a separate `role="alert"` line. The field has a `<label>`, every
95
149
  control is a `<button>` (or a labelled file input), and ids are unique per instance.
96
150
 
97
- **Styling.** The widget injects one `<style id="hp-join-style">` per document on its first mount.
151
+ ### Styles beat Tailwind's preflight without leaking
152
+
153
+ The widget injects one `<style id="hp-join-style">` per document on its first mount.
98
154
  Every rule is on an `hp-join-` or `hp-invite-` class, and none is in a cascade layer, so the rules beat
99
155
  Tailwind's preflight (`@layer base`) without leaking into the page. Colours are custom properties
100
156
  on `.hp-join` (`--hp-join-accent`, `--hp-join-danger`, `--hp-join-bg`, …). The widget inherits
101
157
  the page's font.
102
158
 
103
- ### Invite
159
+ ### Invite is shown only to an admin the hub confirms
104
160
 
105
161
  A mesh admin gets an **Invite someone** section: below the controls in the full widget, and in
106
162
  the "Mesh" menu in the compact one. It holds:
@@ -151,40 +207,10 @@ scanner. It encodes the hub's `link`, not the bare blob, so a phone's own camera
151
207
  join page directly. The widget's scanner accepts the same link.
152
208
 
153
209
  **On a phone**, below 30rem, the compact widget's menu becomes a sheet along the bottom of the
154
- screen (at most 70% of its height, scrolling), because anchored to its button it ran off the
155
- left edge. The header, and the toggle that closes the menu, stay in view.
156
-
157
- ### In React
158
-
159
- The widget is not a React component, and React pages wrap it in a few lines. The session and the
160
- state stay in React. The widget is mounted once and fed each state:
161
-
162
- ```tsx
163
- function JoinWidgetView({ session, state, compact }: Props) {
164
- const host = useRef<HTMLDivElement>(null);
165
- const widget = useRef<JoinWidget | null>(null);
166
- useEffect(() => {
167
- if (host.current == null) return;
168
- const w = mountJoinWidget(host.current, { session, compact });
169
- widget.current = w;
170
- return () => { w.destroy(); widget.current = null; };
171
- }, [session, compact]);
172
- useEffect(() => widget.current?.update(state), [state]);
173
- return <div ref={host} />;
174
- }
175
- ```
210
+ screen (at most 70% of its height, scrolling), because a menu anchored to its button would run off
211
+ the left edge. The header, and the toggle that closes the menu, stay in view.
176
212
 
177
- `apps/llm-chat/src/mesh/join-widget.tsx` is this wrapper. It lives in `mesh/` because the
178
- boundary test keeps httpeers out of everything the standalone page reaches.
179
-
180
- ## Design
181
-
182
- **What had to be shared.** Four pages join a hub: llm-chat's `mesh.html` (React and Tailwind)
183
- and the demos' `app`, `images` and `proxy` pages (plain DOM). Each had its own copy of the join
184
- form and the Disconnect/Reconnect buttons. None of them offered QR scanning or a way to leave, and
185
- the copies had already drifted in wording.
186
-
187
- **Three shapes were considered.**
213
+ ### A mount function, not a component or a custom element
188
214
 
189
215
  1. **A React component, with a DOM copy for the demos.** Rejected. That keeps two copies, which is
190
216
  the problem this package solves, and the demos would take on a framework to show one form.
@@ -193,18 +219,21 @@ the copies had already drifted in wording.
193
219
  ship a copy. Its state goes in through a property that React 19 sets but earlier versions do
194
220
  not. Its lifecycle (`connectedCallback`, upgrade order) is harder to test than a function call.
195
221
  None of this buys anything a function does not.
196
- 3. **A function that mounts into a container and returns `{ update, destroy }`.** Chosen. It is
222
+ 3. **A function that mounts into a container and returns `{ update, destroy }`.** This one. It is
197
223
  plain TypeScript and DOM, has no globals and no registration, and is straightforward to test
198
224
  under happy-dom with a fake session. React wraps it in about ten lines, and a plain page calls
199
225
  it directly.
200
226
 
201
- **The widget holds no session state.** The session reports changes through one `onChange` that
227
+ ### The widget holds no session state
228
+
229
+ The session reports changes through one `onChange` that
202
230
  the page wires up when it creates it. The page therefore owns the session and passes each
203
231
  state to `update()`. The widget keeps only what exists only on screen: the typed text, a running
204
232
  camera, a button whose call is in flight, and the last rejection.
205
233
 
206
- **Where it lives.** It is a new package rather than an entry in `@statewalker/httpeers-member`
207
- (`/join-widget`), for two reasons:
234
+ ### It is a package of its own, not an entry of `httpeers-member`
235
+
236
+ Two reasons:
208
237
 
209
238
  - `httpeers-member` compiles without the DOM lib on purpose (see its `tsconfig.json`), and its
210
239
  boundary test keeps DOM out of everything the root reaches. A widget is DOM from top to bottom.
@@ -217,16 +246,18 @@ camera, a button whose call is in flight, and the last rejection.
217
246
  `httpeers-qr`, and nothing in `packages/` depends on it except the private conformance leaf. Apps
218
247
  never import from other apps, so a package is the only place both apps can share it from.
219
248
 
220
- **Nothing happens at import.** The widget touches no `document`, `window` or stylesheet until
249
+ ### Nothing happens at import
250
+
251
+ The widget touches no `document`, `window` or stylesheet until
221
252
  `mountJoinWidget` runs, and the camera library is loaded by the first scan.
222
253
  `tests/import.test.ts` imports the built `dist/` under plain Node to check both.
223
254
  `httpeers-conformance`'s `runtime-import.test.ts` imports the entry again, together with every
224
255
  other published entry.
225
256
 
226
- ## The `dist/` trap
257
+ ### Pages bundle `dist/`, so rebuild after changing `src/`
227
258
 
228
- The apps' Vite configs set no `resolve.conditions`, and this package's `exports` map offers no
229
- `source` condition. **Pages bundle `dist/`**, which is gitignored and holds whatever was last
259
+ The `exports` map has a `source` condition, but the apps' Vite configs do not enable it (no
260
+ `resolve.conditions`), so **pages bundle `dist/`**, which is gitignored and holds whatever was last
230
261
  built. After changing `src/`:
231
262
 
232
263
  ```sh
@@ -237,7 +268,7 @@ pnpm --filter @statewalker/httpeers-llm-chat build # and/or the demos
237
268
  Then check that the page's bundle hash changed. If it did not, the change did not reach the
238
269
  bundle. `turbo build` builds this package before the apps because they depend on it.
239
270
 
240
- ## Tests
271
+ ### What the tests cover
241
272
 
242
273
  ```sh
243
274
  pnpm --filter @statewalker/httpeers-join test # build, typecheck, vitest
@@ -263,3 +294,7 @@ pnpm --filter @statewalker/httpeers-join test # build, typecheck, vitest
263
294
  Nothing here drives a real camera or a real hub. That is covered by the apps' Playwright smokes
264
295
  (`apps/demos/scripts/join-smoke.mjs`, `apps/llm-chat/scripts/mesh-smoke.mjs`), which join through
265
296
  this widget.
297
+
298
+ ## License
299
+
300
+ MIT
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@statewalker/httpeers-join",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "The join-the-mesh widget: paste or scan an invitation, see the link to the hub, disconnect, reconnect or leave. Plain DOM, no framework.",
6
+ "bugs": {
7
+ "url": "https://github.com/statewalker/httpeers/issues"
8
+ },
9
+ "homepage": "https://github.com/statewalker/httpeers/tree/main/packages/httpeers-join#readme",
6
10
  "license": "MIT",
7
11
  "repository": {
8
12
  "type": "git",
9
- "url": "git+ssh://git@github.com/statewalker/httpeers.git",
13
+ "url": "git+https://github.com/statewalker/httpeers.git",
10
14
  "directory": "packages/httpeers-join"
11
15
  },
12
16
  "main": "./dist/index.js",
@@ -25,8 +29,8 @@
25
29
  "sideEffects": false,
26
30
  "dependencies": {
27
31
  "html5-qrcode": "^2.3.8",
28
- "@statewalker/httpeers-member": "^0.1.4",
29
- "@statewalker/httpeers-qr": "^0.1.2"
32
+ "@statewalker/httpeers-member": "^0.1.6",
33
+ "@statewalker/httpeers-qr": "^0.1.3"
30
34
  },
31
35
  "devDependencies": {
32
36
  "@biomejs/biome": "^2.5.15",