cursedbelt 4.7.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +36 -0
  2. package/dist/react/chat/index.d.ts +1 -0
  3. package/dist/react/chat/index.d.ts.map +1 -1
  4. package/dist/react/chat/index.js +1 -0
  5. package/dist/react/chat/index.js.map +1 -1
  6. package/dist/react/chat/usePresence.d.ts +75 -0
  7. package/dist/react/chat/usePresence.d.ts.map +1 -0
  8. package/dist/react/chat/usePresence.js +109 -0
  9. package/dist/react/chat/usePresence.js.map +1 -0
  10. package/dist/react/components/SecretInput.d.ts +7 -2
  11. package/dist/react/components/SecretInput.d.ts.map +1 -1
  12. package/dist/react/components/SecretInput.js +5 -2
  13. package/dist/react/components/SecretInput.js.map +1 -1
  14. package/dist/react/components/TooltipImpl.d.ts.map +1 -1
  15. package/dist/react/components/TooltipImpl.js +52 -3
  16. package/dist/react/components/TooltipImpl.js.map +1 -1
  17. package/dist/react/emoji/EmojiPicker.d.ts.map +1 -1
  18. package/dist/react/emoji/EmojiPicker.js +49 -8
  19. package/dist/react/emoji/EmojiPicker.js.map +1 -1
  20. package/dist/react/index.d.ts +1 -0
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +1 -0
  23. package/dist/react/index.js.map +1 -1
  24. package/dist/react/lib/form.d.ts +2 -2
  25. package/dist/react/lib/form.d.ts.map +1 -1
  26. package/dist/react/lib/form.js +4 -41
  27. package/dist/react/lib/form.js.map +1 -1
  28. package/dist/react/lib/zodResolver.d.ts +9 -0
  29. package/dist/react/lib/zodResolver.d.ts.map +1 -0
  30. package/dist/react/lib/zodResolver.js +105 -0
  31. package/dist/react/lib/zodResolver.js.map +1 -0
  32. package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
  33. package/dist/react/master-lock/MasterLockDialog.js +2 -1
  34. package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
  35. package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
  36. package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
  37. package/dist/react/master-lock/MasterLockProvider.js +14 -20
  38. package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
  39. package/dist/react/media/MediaUploadControl.d.ts +3 -7
  40. package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
  41. package/dist/react/media/MediaUploadControl.js +4 -13
  42. package/dist/react/media/MediaUploadControl.js.map +1 -1
  43. package/dist/react/media/fileUploadClient.d.ts +0 -8
  44. package/dist/react/media/fileUploadClient.d.ts.map +1 -1
  45. package/dist/react/media/fileUploadClient.js +0 -8
  46. package/dist/react/media/fileUploadClient.js.map +1 -1
  47. package/dist/react/media/hlsSource.d.ts.map +1 -1
  48. package/dist/react/media/hlsSource.js +4 -2
  49. package/dist/react/media/hlsSource.js.map +1 -1
  50. package/dist/react/media/index.d.ts +0 -4
  51. package/dist/react/media/index.d.ts.map +1 -1
  52. package/dist/react/media/index.js +0 -4
  53. package/dist/react/media/index.js.map +1 -1
  54. package/dist/react/sessionAuth.d.ts +1 -2
  55. package/dist/react/sessionAuth.d.ts.map +1 -1
  56. package/dist/react/sessionAuth.js +2 -3
  57. package/dist/react/sessionAuth.js.map +1 -1
  58. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  59. package/dist/styles-areas/activity-bar.css +1 -1
  60. package/dist/styles-areas/analytics.css +1 -1
  61. package/dist/styles-areas/auth.css +1 -1
  62. package/dist/styles-areas/charts.css +1 -1
  63. package/dist/styles-areas/chat.css +1 -1
  64. package/dist/styles-areas/core.css +1 -1
  65. package/dist/styles-areas/dashboard-grid.css +1 -1
  66. package/dist/styles-areas/data-table.css +1 -1
  67. package/dist/styles-areas/deep-link.css +1 -1
  68. package/dist/styles-areas/disk-usage.css +1 -1
  69. package/dist/styles-areas/emoji.css +1 -1
  70. package/dist/styles-areas/fields.css +1 -1
  71. package/dist/styles-areas/file-tree.css +1 -1
  72. package/dist/styles-areas/keep-awake.css +1 -1
  73. package/dist/styles-areas/layout-engine.css +1 -1
  74. package/dist/styles-areas/markdown.css +1 -1
  75. package/dist/styles-areas/master-detail.css +1 -1
  76. package/dist/styles-areas/master-lock.css +108 -2
  77. package/dist/styles-areas/media-gallery.css +1 -1
  78. package/dist/styles-areas/media.css +1 -17
  79. package/dist/styles-areas/nav.css +1 -1
  80. package/dist/styles-areas/notifications.css +1 -1
  81. package/dist/styles-areas/overlays.css +1 -1
  82. package/dist/styles-areas/palette.css +1 -1
  83. package/dist/styles-areas/spreadsheet.css +1 -1
  84. package/dist/styles-areas/virtual.css +1 -1
  85. package/dist/styles-areas/wizard.css +1 -1
  86. package/dist/styles-areas/workbench.css +1 -1
  87. package/dist/styles-areas/workbook-viewer.css +1 -1
  88. package/dist/styles-utilities.css +0 -16
  89. package/dist/testing/_noBun.d.ts +2 -0
  90. package/dist/testing/_noBun.d.ts.map +1 -0
  91. package/dist/testing/_noBun.js +10 -0
  92. package/dist/testing/_noBun.js.map +1 -0
  93. package/package.json +54 -20
  94. package/scripts/checkAreaStyles.spec.ts +71 -0
  95. package/scripts/checkAreaStyles.ts +150 -0
  96. package/scripts/guardrailsEnforce.spec.ts +43 -0
  97. package/scripts/guardrailsEnforce.ts +46 -1
  98. package/scripts/styleAreas.ts +1 -2
  99. package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
  100. package/src/bundlerOnlyEntries.spec.ts +101 -0
  101. package/src/declaredImports.spec.ts +4 -0
  102. package/src/optionalPeersTable.spec.ts +127 -0
  103. package/src/react/chat/index.ts +8 -0
  104. package/src/react/chat/usePresence.spec.tsx +138 -0
  105. package/src/react/chat/usePresence.ts +185 -0
  106. package/src/react/components/SecretInput.tsx +12 -1
  107. package/src/react/components/TooltipImpl.tsx +54 -5
  108. package/src/react/emoji/EmojiPicker.tsx +70 -19
  109. package/src/react/index.ts +8 -0
  110. package/src/react/lib/form.ts +5 -48
  111. package/src/react/lib/zodResolver.spec.ts +82 -0
  112. package/src/react/lib/zodResolver.ts +136 -0
  113. package/src/react/master-lock/MasterLockDialog.tsx +13 -9
  114. package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
  115. package/src/react/master-lock/MasterLockProvider.tsx +16 -19
  116. package/src/react/media/MediaUploadControl.tsx +8 -29
  117. package/src/react/media/fileUploadClient.ts +7 -17
  118. package/src/react/media/hlsSource.ts +4 -1
  119. package/src/react/media/index.ts +0 -16
  120. package/src/react/sessionAuth.ts +2 -3
  121. package/src/scriptsResolve.spec.ts +66 -0
  122. package/src/styles-areas/activity-bar.css +1 -1
  123. package/src/styles-areas/analytics.css +1 -1
  124. package/src/styles-areas/auth.css +1 -1
  125. package/src/styles-areas/charts.css +1 -1
  126. package/src/styles-areas/chat.css +1 -1
  127. package/src/styles-areas/core.css +1 -1
  128. package/src/styles-areas/dashboard-grid.css +1 -1
  129. package/src/styles-areas/data-table.css +1 -1
  130. package/src/styles-areas/deep-link.css +1 -1
  131. package/src/styles-areas/disk-usage.css +1 -1
  132. package/src/styles-areas/emoji.css +1 -1
  133. package/src/styles-areas/fields.css +1 -1
  134. package/src/styles-areas/file-tree.css +1 -1
  135. package/src/styles-areas/keep-awake.css +1 -1
  136. package/src/styles-areas/layout-engine.css +1 -1
  137. package/src/styles-areas/markdown.css +1 -1
  138. package/src/styles-areas/master-detail.css +1 -1
  139. package/src/styles-areas/master-lock.css +108 -2
  140. package/src/styles-areas/media-gallery.css +1 -1
  141. package/src/styles-areas/media.css +1 -17
  142. package/src/styles-areas/nav.css +1 -1
  143. package/src/styles-areas/notifications.css +1 -1
  144. package/src/styles-areas/overlays.css +1 -1
  145. package/src/styles-areas/palette.css +1 -1
  146. package/src/styles-areas/spreadsheet.css +1 -1
  147. package/src/styles-areas/virtual.css +1 -1
  148. package/src/styles-areas/wizard.css +1 -1
  149. package/src/styles-areas/workbench.css +1 -1
  150. package/src/styles-areas/workbook-viewer.css +1 -1
  151. package/src/styles-utilities.css +0 -16
  152. package/src/testing/_noBun.ts +12 -0
  153. package/src/unpeeredStaysOffTheBarrel.spec.ts +99 -0
  154. package/src/verifyGraph.spec.ts +11 -0
  155. package/dist/react/media/PrivateIndexView.d.ts +0 -6
  156. package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
  157. package/dist/react/media/PrivateIndexView.js +0 -44
  158. package/dist/react/media/PrivateIndexView.js.map +0 -1
  159. package/dist/react/media/PrivateModeBadge.d.ts +0 -17
  160. package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
  161. package/dist/react/media/PrivateModeBadge.js +0 -24
  162. package/dist/react/media/PrivateModeBadge.js.map +0 -1
  163. package/dist/react/media/privateClient.d.ts +0 -34
  164. package/dist/react/media/privateClient.d.ts.map +0 -1
  165. package/dist/react/media/privateClient.js +0 -36
  166. package/dist/react/media/privateClient.js.map +0 -1
  167. package/dist/react/media/usePrivateUnlock.d.ts +0 -25
  168. package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
  169. package/dist/react/media/usePrivateUnlock.js +0 -68
  170. package/dist/react/media/usePrivateUnlock.js.map +0 -1
  171. package/src/react/media/PrivateIndexView.tsx +0 -113
  172. package/src/react/media/PrivateModeBadge.tsx +0 -50
  173. package/src/react/media/privateClient.spec.ts +0 -113
  174. package/src/react/media/privateClient.ts +0 -82
  175. package/src/react/media/usePrivateUnlock.ts +0 -132
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { act, render } from '@testing-library/react';
3
+ import { PresenceRoster } from './PresenceRoster.js';
4
+ import { presenceUsers, usePresence } from './usePresence.js';
5
+
6
+ /** A socket the test drives by hand: `open()`, `snapshot(rows)`, `drop()`. */
7
+ class FakeSocket extends EventTarget {
8
+ static made: FakeSocket[] = [];
9
+ closed = false;
10
+ constructor(readonly url: string) {
11
+ super();
12
+ FakeSocket.made.push(this);
13
+ }
14
+ open() {
15
+ this.dispatchEvent(new Event('open'));
16
+ }
17
+ snapshot(rows: unknown, event = 'presence') {
18
+ const message = new MessageEvent('message', { data: JSON.stringify({ event, data: rows }) });
19
+ this.dispatchEvent(message);
20
+ }
21
+ drop() {
22
+ this.dispatchEvent(new Event('close'));
23
+ }
24
+ close() {
25
+ if (this.closed) return;
26
+ this.closed = true;
27
+ this.dispatchEvent(new Event('close'));
28
+ }
29
+ }
30
+
31
+ const NAMES: Record<string, string> = { u1: 'Ada', u2: 'Brian', me: 'Me' };
32
+
33
+ function Harness({ enabled = true }: { enabled?: boolean }) {
34
+ const { users, connected } = usePresence({
35
+ channel: 'rooms',
36
+ enabled,
37
+ selfId: 'me',
38
+ resolveUser: (id) => ({ name: NAMES[id] }),
39
+ retryMs: [5],
40
+ location: { protocol: 'https:', host: 'roms.example' },
41
+ createSocket: (url) => new FakeSocket(url) as unknown as WebSocket,
42
+ });
43
+ return (
44
+ <div>
45
+ <span data-testid='state'>{connected ? 'on' : 'off'}</span>
46
+ <span data-testid='names'>{users.map((u) => u.name).join(',')}</span>
47
+ <PresenceRoster users={users} />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ const last = () => FakeSocket.made[FakeSocket.made.length - 1] as FakeSocket;
53
+
54
+ describe('presenceUsers', () => {
55
+ test('one row per user (the earliest tab), arrival order, without the viewer', () => {
56
+ const users = presenceUsers(
57
+ [
58
+ { userId: 'u2', connectedAt: 30 },
59
+ { userId: 'me', connectedAt: 1 },
60
+ { userId: 'u1', connectedAt: 20 },
61
+ { userId: 'u2', connectedAt: 10 },
62
+ ],
63
+ 'me',
64
+ (id) => ({ name: NAMES[id] }),
65
+ );
66
+ expect(users).toEqual([
67
+ { id: 'u2', isOnline: true, name: 'Brian' },
68
+ { id: 'u1', isOnline: true, name: 'Ada' },
69
+ ]);
70
+ });
71
+
72
+ test("a row's own name wins over the resolver, and a malformed row is skipped", () => {
73
+ const users = presenceUsers([{ userId: 'u1', name: 'Ada L.' }, { userId: '' } as never, null as never], null, () => ({ name: 'x' }));
74
+ expect(users).toEqual([{ id: 'u1', isOnline: true, name: 'Ada L.' }]);
75
+ });
76
+ });
77
+
78
+ describe('usePresence', () => {
79
+ test('connects to /ws/<channel> over wss, and a join and a leave redraw the roster', () => {
80
+ FakeSocket.made = [];
81
+ const { getByTestId } = render(<Harness />);
82
+ expect(last().url).toBe('wss://roms.example/ws/rooms');
83
+ expect(getByTestId('state').textContent).toBe('off');
84
+
85
+ act(() => last().open());
86
+ expect(getByTestId('state').textContent).toBe('on');
87
+
88
+ act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u1', connectedAt: 2 }]));
89
+ expect(getByTestId('names').textContent).toBe('Ada');
90
+
91
+ act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u1', connectedAt: 2 }, { userId: 'u2', connectedAt: 3 }]));
92
+ expect(getByTestId('names').textContent).toBe('Ada,Brian');
93
+
94
+ act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u2', connectedAt: 3 }]));
95
+ expect(getByTestId('names').textContent).toBe('Brian');
96
+
97
+ // Another channel's frame is not a presence snapshot.
98
+ act(() => last().snapshot([{ userId: 'u9' }], 'chat:message'));
99
+ expect(getByTestId('names').textContent).toBe('Brian');
100
+ });
101
+
102
+ test('🔴 a dropped socket empties the roster, then reconnects and refills it', async () => {
103
+ FakeSocket.made = [];
104
+ const { getByTestId } = render(<Harness />);
105
+ act(() => last().open());
106
+ act(() => last().snapshot([{ userId: 'u1', connectedAt: 2 }]));
107
+ expect(getByTestId('names').textContent).toBe('Ada');
108
+
109
+ const first = last();
110
+ act(() => first.drop());
111
+ expect(getByTestId('state').textContent).toBe('off');
112
+ expect(getByTestId('names').textContent).toBe('');
113
+
114
+ await act(async () => {
115
+ await new Promise((r) => setTimeout(r, 30));
116
+ });
117
+ expect(FakeSocket.made.length).toBe(2);
118
+ expect(last()).not.toBe(first);
119
+ act(() => last().open());
120
+ act(() => last().snapshot([{ userId: 'u2', connectedAt: 5 }]));
121
+ expect(getByTestId('state').textContent).toBe('on');
122
+ expect(getByTestId('names').textContent).toBe('Brian');
123
+ });
124
+
125
+ test('disabled opens no socket; unmount closes it and does not reconnect', async () => {
126
+ FakeSocket.made = [];
127
+ const off = render(<Harness enabled={false} />);
128
+ expect(FakeSocket.made.length).toBe(0);
129
+ off.unmount();
130
+
131
+ const on = render(<Harness />);
132
+ act(() => last().open());
133
+ on.unmount();
134
+ expect(last().closed).toBe(true);
135
+ await new Promise((r) => setTimeout(r, 30));
136
+ expect(FakeSocket.made.length).toBe(1);
137
+ });
138
+ });
@@ -0,0 +1,185 @@
1
+ import { buildChannelSocketUrl, parseWsMessage, type WsLocation } from 'cursedbelt-core/ws-wire';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import type { PresenceUser } from './types.js';
4
+
5
+ /**
6
+ * Who else is here — the CLIENT half of presence, so `PresenceRoster` has something to render.
7
+ *
8
+ * Task 078 (owner, 2026-09-19: *"show which other users are on apps"*). The library shipped the
9
+ * view (`PresenceRoster`) and the server (`cursedbelt-server`'s `createWsHub` with
10
+ * `presence: true`, whose `getPresence(channel)` is one `{ userId, connectedAt }` row per live
11
+ * client) and nothing in between — so the roster sat exported with zero consumers, and the one
12
+ * app that shows presence (`apps/family`, `src/live.ts`) hand-rolled its own socket. This is that
13
+ * socket, generalised: open `/ws/<channel>`, take every `presence` snapshot the channel
14
+ * broadcasts, and reconnect with a capped backoff when the socket drops.
15
+ *
16
+ * The server side a channel needs is one line in its lifecycle hooks:
17
+ *
18
+ * onConnect: (_c, hub) => hub.broadcast('rooms', 'presence', hub.getPresence('rooms')),
19
+ * onDisconnect: (_c, hub) => hub.broadcast('rooms', 'presence', hub.getPresence('rooms')),
20
+ *
21
+ * ── What it does with a snapshot ─────────────────────────────────────────────────────────
22
+ * · ONE row per user, not per tab — the same person in three tabs is one avatar — keeping the
23
+ * earliest `connectedAt`, so the roster's order is "who arrived first" and stable.
24
+ * · `selfId` drops the viewer: the roster is who ELSE is here.
25
+ * · `resolveUser` turns an id into a name/avatar; the hub deliberately ships only the id (its
26
+ * `PresenceEntry` is the "business-safe view" of a client). A row may also carry `name` /
27
+ * `avatarUrl` itself, for a channel that broadcasts richer rows.
28
+ *
29
+ * ── A dropped socket is an EMPTY roster, not a stale one ─────────────────────────────────
30
+ * On close the users go to `[]` and `connected` to false until the next snapshot. Showing the
31
+ * last list while disconnected would tell the viewer someone is here who may have left an hour
32
+ * ago; an empty strip is merely less informative. Same choice as family's `useLiveTree`.
33
+ */
34
+
35
+ /** One presence row as a hub channel broadcasts it (`cursedbelt-server`'s `PresenceEntry`). */
36
+ export interface PresenceEntryLike {
37
+ userId: string;
38
+ connectedAt?: number;
39
+ name?: string;
40
+ avatarUrl?: string;
41
+ }
42
+
43
+ export interface UsePresenceOptions {
44
+ /** The hub channel (`/ws/<channel>`) whose snapshots to follow. */
45
+ channel: string;
46
+ /** Room within the channel (`?room=`), when the channel is roomed. */
47
+ roomId?: string;
48
+ /** Off ⇒ no socket at all (e.g. signed out). Default true. */
49
+ enabled?: boolean;
50
+ /** The viewer's own id — left out of the roster. */
51
+ selfId?: string | null;
52
+ /** Name and avatar for an id the snapshot does not carry them for. */
53
+ resolveUser?: (userId: string) => { name?: string; avatarUrl?: string } | undefined;
54
+ /** The frame event carrying the snapshot. Default `'presence'`. */
55
+ event?: string;
56
+ /** Reconnect backoff, capped at its last entry. */
57
+ retryMs?: readonly number[];
58
+ /** Test/SSR seams. Default `window.location` and the global `WebSocket`. */
59
+ location?: WsLocation;
60
+ createSocket?: (url: string) => WebSocket;
61
+ }
62
+
63
+ export interface PresenceSubscription {
64
+ /** Everybody else on the channel now — pass straight to `<PresenceRoster users={…}>`. */
65
+ users: PresenceUser[];
66
+ /** Is the socket open? A closed one also means `users` is `[]`. */
67
+ connected: boolean;
68
+ }
69
+
70
+ /** Backoff, capped. A tab left open overnight must not hammer the box. */
71
+ export const PRESENCE_RETRY_MS = [500, 1_000, 2_000, 5_000, 10_000, 30_000] as const;
72
+
73
+ /**
74
+ * Collapse a snapshot to one {@link PresenceUser} per user, earliest arrival first, without
75
+ * `selfId`. Pure — the hook's whole judgement, exported so it is tested without a socket.
76
+ */
77
+ export function presenceUsers(
78
+ rows: readonly PresenceEntryLike[],
79
+ selfId?: string | null,
80
+ resolveUser?: UsePresenceOptions['resolveUser'],
81
+ ): PresenceUser[] {
82
+ const byUser = new Map<string, PresenceEntryLike>();
83
+ for (const row of rows) {
84
+ if (!row || typeof row.userId !== 'string' || row.userId === '') continue;
85
+ if (selfId != null && row.userId === selfId) continue;
86
+ const seen = byUser.get(row.userId);
87
+ if (!seen || (row.connectedAt ?? Infinity) < (seen.connectedAt ?? Infinity)) byUser.set(row.userId, row);
88
+ }
89
+ return [...byUser.values()]
90
+ .sort((a, b) => (a.connectedAt ?? Infinity) - (b.connectedAt ?? Infinity))
91
+ .map((row) => {
92
+ const resolved = resolveUser?.(row.userId);
93
+ const name = row.name ?? resolved?.name;
94
+ const avatarUrl = row.avatarUrl ?? resolved?.avatarUrl;
95
+ return {
96
+ id: row.userId,
97
+ isOnline: true,
98
+ ...(name !== undefined ? { name } : {}),
99
+ ...(avatarUrl !== undefined ? { avatarUrl } : {}),
100
+ };
101
+ });
102
+ }
103
+
104
+ /** The rows in a snapshot frame: the hub's bare array, or `{ users }` / `{ peers }` / `{ presence }`. */
105
+ function rowsOf(data: unknown): PresenceEntryLike[] | null {
106
+ if (Array.isArray(data)) return data as PresenceEntryLike[];
107
+ if (data && typeof data === 'object') {
108
+ for (const key of ['users', 'peers', 'presence'] as const) {
109
+ const value = (data as Record<string, unknown>)[key];
110
+ if (Array.isArray(value)) return value as PresenceEntryLike[];
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ export function usePresence(options: UsePresenceOptions): PresenceSubscription {
117
+ const {
118
+ channel,
119
+ roomId,
120
+ enabled = true,
121
+ selfId = null,
122
+ resolveUser,
123
+ event = 'presence',
124
+ retryMs = PRESENCE_RETRY_MS,
125
+ location,
126
+ createSocket,
127
+ } = options;
128
+ const [rows, setRows] = useState<PresenceEntryLike[]>([]);
129
+ const [connected, setConnected] = useState(false);
130
+ // The seams and backoff are read through a ref so an inline lambda or array literal in the
131
+ // caller does not tear the socket down on every render.
132
+ const seams = useRef({ location, createSocket, retryMs });
133
+ seams.current = { location, createSocket, retryMs };
134
+
135
+ useEffect(() => {
136
+ const loc = seams.current.location ?? (typeof window !== 'undefined' ? window.location : undefined);
137
+ if (!enabled || !loc) return;
138
+ let closed = false;
139
+ let attempt = 0;
140
+ let retry: ReturnType<typeof setTimeout> | null = null;
141
+ let socket: WebSocket | null = null;
142
+
143
+ const open = () => {
144
+ if (closed) return;
145
+ const url = buildChannelSocketUrl(channel, roomId, loc);
146
+ const make = seams.current.createSocket ?? ((u: string) => new WebSocket(u));
147
+ const ws = make(url);
148
+ socket = ws;
149
+ ws.addEventListener('open', () => {
150
+ attempt = 0;
151
+ setConnected(true);
152
+ });
153
+ ws.addEventListener('message', (message) => {
154
+ const frame = parseWsMessage(String((message as MessageEvent).data));
155
+ if (!frame || frame.event !== event) return;
156
+ const next = rowsOf(frame.data);
157
+ if (next) setRows(next);
158
+ });
159
+ ws.addEventListener('close', () => {
160
+ setConnected(false);
161
+ setRows([]);
162
+ if (closed) return;
163
+ const steps = seams.current.retryMs;
164
+ const wait = steps[Math.min(attempt, steps.length - 1)] ?? 30_000;
165
+ attempt += 1;
166
+ retry = setTimeout(open, wait);
167
+ });
168
+ // An `error` is always followed by a `close`; reconnecting from both would schedule two.
169
+ ws.addEventListener('error', () => ws.close());
170
+ };
171
+
172
+ open();
173
+ return () => {
174
+ closed = true;
175
+ if (retry) clearTimeout(retry);
176
+ socket?.close();
177
+ socket = null;
178
+ setConnected(false);
179
+ setRows([]);
180
+ };
181
+ }, [enabled, channel, roomId, event]);
182
+
183
+ const users = useMemo(() => presenceUsers(rows, selfId, resolveUser), [rows, selfId, resolveUser]);
184
+ return { users, connected };
185
+ }
@@ -1,4 +1,4 @@
1
- import { type CSSProperties, type ReactNode, useState } from 'react';
1
+ import { type CSSProperties, type KeyboardEvent, type ReactNode, useState } from 'react';
2
2
  import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
3
3
  import { CONTROL_HEIGHT_CLASS, type ControlSize } from '../lib/field.js';
4
4
  import { resolveClass, resolveStyle, type StyleableProps } from '../styling.js';
@@ -118,6 +118,11 @@ export interface SecretInputProps extends StyleableProps<SecretInputSlot> {
118
118
  'aria-invalid'?: boolean | 'true' | 'false';
119
119
  'aria-describedby'?: string;
120
120
  autoFocus?: boolean;
121
+ /** Disable typing while a form submits (the eye stays usable — seeing what you typed is
122
+ * harmless while it is in flight). */
123
+ disabled?: boolean;
124
+ /** Fires on Enter in THIS field — the login/rotation forms submit from their last field. */
125
+ onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
121
126
  className?: string;
122
127
  style?: CSSProperties;
123
128
  /** Container height on the shared control-height scale (`--control-h-*`, task
@@ -149,6 +154,8 @@ export const SecretInput = ({
149
154
  'aria-invalid': ariaInvalid,
150
155
  'aria-describedby': ariaDescribedBy,
151
156
  autoFocus = false,
157
+ disabled,
158
+ onEnter,
152
159
  className,
153
160
  style,
154
161
  classNames,
@@ -199,6 +206,10 @@ export const SecretInput = ({
199
206
  value={value}
200
207
  readOnly={readOnly}
201
208
  onChange={onChange ? (e) => onChange(e.target.value) : undefined}
209
+ onKeyDown={onEnter ? (e) => {
210
+ if (e.key === 'Enter') onEnter(e);
211
+ } : undefined}
212
+ disabled={disabled}
202
213
  placeholder={placeholder}
203
214
  aria-label={label}
204
215
  aria-invalid={ariaInvalid}
@@ -44,6 +44,10 @@ export default function TooltipImpl({
44
44
  const resolvedSide = placement ?? side ?? 'top';
45
45
  const triggerRef = useRef<HTMLElement | null>(null);
46
46
  const wasFocusedRef = useRef(false);
47
+ /** The node a TOUCH disclosure's taps land on — the ⓘ, or the `tap` branch's span. */
48
+ const touchTriggerRef = useRef<HTMLButtonElement | null>(null);
49
+ /** True while one of that trigger's own events is being dispatched — see `onTouchOpenChange`. */
50
+ const inTriggerEventRef = useRef(false);
47
51
 
48
52
  // Replay the arming gesture. Radix opens on `pointermove` over the trigger and on
49
53
  // focus; both already happened before this module existed, so re-dispatch one on
@@ -215,7 +219,16 @@ export default function TooltipImpl({
215
219
  sideOffset={6}
216
220
  collisionPadding={FLOATING_COLLISION_PADDING}
217
221
  style={maxWidth != null ? { maxWidth } : undefined}
218
- onPointerDownOutside={() => setOpen(false)}
222
+ onPointerDownOutside={(e) => {
223
+ // A touch disclosure's OWN trigger is outside the bubble, but a tap on it is the
224
+ // toggle's business (it closes an open bubble on its click). Letting the layer
225
+ // dismiss here as well would close on this pointerdown and re-open on the click.
226
+ if (!hasHover && touchTriggerRef.current?.contains(e.target as Node)) {
227
+ e.preventDefault();
228
+ return;
229
+ }
230
+ setOpen(false);
231
+ }}
219
232
  className={cn(
220
233
  // NOTE: the bubble is a transient hint and must NEVER be a click target —
221
234
  // a focus-opened tooltip (e.g. a prompt auto-focusing its safe default
@@ -276,6 +289,42 @@ export default function TooltipImpl({
276
289
 
277
290
  const toggle = () => setOpen((o) => !o);
278
291
 
292
+ /*
293
+ * 🔴 On touch, the TAP owns `open`, and Radix's trigger handlers are told nothing they say.
294
+ *
295
+ * Tasks 2082 + 2093, measured in a Pixel 7 context on station's Console: the first tap on
296
+ * every ⓘ did nothing and the second opened it. A real tap fires `pointerdown → pointerup →
297
+ * focus → click`. Radix's Trigger clears its pointer-down flag on `pointerup`, so the
298
+ * `focus` that follows reads as KEYBOARD focus and opens; then this branch's click `toggle`
299
+ * flips that fresh `true` back to `false`. (Radix also composes `onClose` onto every click
300
+ * and every `pointerdown` on an open trigger, which is the half the tasks first suspected.)
301
+ *
302
+ * So while one of the trigger's own events is being dispatched, `onOpenChange` from Radix
303
+ * is ignored — the flag is set by the handlers below, which `composeEventHandlers` runs
304
+ * BEFORE Radix's, and cleared in a microtask, which runs once the whole dispatch returns.
305
+ * Nothing is `preventDefault`ed, so a `mobile="tap"` trigger that is a link still
306
+ * navigates. Every OTHER close Radix makes still lands: Escape, scroll, and another
307
+ * tooltip opening. `e2e/tooltip-tap.spec.ts` is the proof, in the `touch` project — it
308
+ * cannot be a happy-dom case, because happy-dom fires whatever order a spec writes.
309
+ */
310
+ const markTriggerEvent = () => {
311
+ inTriggerEventRef.current = true;
312
+ queueMicrotask(() => {
313
+ inTriggerEventRef.current = false;
314
+ });
315
+ };
316
+ const onTouchOpenChange = (next: boolean) => {
317
+ if (inTriggerEventRef.current) return;
318
+ setOpen(next);
319
+ };
320
+ const touchTriggerProps = {
321
+ ref: touchTriggerRef,
322
+ onPointerDown: markTriggerEvent,
323
+ onFocus: markTriggerEvent,
324
+ onBlur: markTriggerEvent,
325
+ onClick: markTriggerEvent,
326
+ };
327
+
279
328
  // `icon`: render the trigger as-is plus a tappable ⓘ disclosure that owns the
280
329
  // tooltip (so a tap on the trigger still does its own action).
281
330
  if (mobile === 'icon') {
@@ -287,8 +336,8 @@ export default function TooltipImpl({
287
336
  accidental hover to protect against on a touch device — see
288
337
  `lib/hoverIntent.ts`, "delay the accident, never the request". */}
289
338
  <RTooltip.Provider delayDuration={HOVER_HINT_INSTANT_MS}>
290
- <RTooltip.Root open={open} onOpenChange={setOpen}>
291
- <RTooltip.Trigger asChild>
339
+ <RTooltip.Root open={open} onOpenChange={onTouchOpenChange}>
340
+ <RTooltip.Trigger asChild {...touchTriggerProps}>
292
341
  <button
293
342
  // guardrails-ignore no-raw-action-button: this IS the Tooltip primitive's
294
343
  // own mobile disclosure trigger — Button/IconButton wrap every button in a
@@ -321,8 +370,8 @@ export default function TooltipImpl({
321
370
  // as the `icon` branch above — the tap IS the request.
322
371
  return (
323
372
  <RTooltip.Provider delayDuration={HOVER_HINT_INSTANT_MS}>
324
- <RTooltip.Root open={open} onOpenChange={setOpen}>
325
- <RTooltip.Trigger asChild>
373
+ <RTooltip.Root open={open} onOpenChange={onTouchOpenChange}>
374
+ <RTooltip.Trigger asChild {...touchTriggerProps}>
326
375
  {/* On touch, tapping the trigger toggles the tooltip. Radix's Trigger
327
376
  (asChild) merges the real keyboard + ARIA interaction onto this element;
328
377
  the bare onClick here is only the tap affordance. */}
@@ -1,15 +1,68 @@
1
- import { lazy, type ReactNode, Suspense, useMemo } from 'react';
1
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { IconButton } from '../components/IconButton.js';
3
3
  import { Popover } from '../components/Popover.js';
4
4
  import { Skeleton } from '../components/Skeleton.js';
5
5
  import { resolveEmojiTheme } from './emojiSearch.js';
6
6
 
7
- // The emoji-mart React picker + its data are BOTH lazy: the component code-splits
8
- // (only loaded when the panel first mounts) and the ~300 KB data is fetched via the
9
- // async `data` loader (never statically imported — that would tax every page).
10
- const Picker = lazy(() => import('@emoji-mart/react'));
7
+ // The emoji-mart engine and its ~300 KB data are BOTH lazy: nothing loads until the panel
8
+ // first mounts, and the data is fetched through emoji-mart's async `data` loader.
11
9
  const loadEmojiData = () => import('@emoji-mart/data').then((m) => m.default);
12
10
 
11
+ /**
12
+ * 🔴 emoji-mart's `Picker` custom element, mounted directly — NOT through `@emoji-mart/react`
13
+ * (cursedbelt 5.0.1). That wrapper is twenty lines (`new Picker({ ...props, ref })` into a div),
14
+ * and depending on it cost every consumer something either way: as an optional peer it made
15
+ * `npm install cursedbelt` fail ERESOLVE (it peers `react <=18`; task 2087), and as `unpeered`
16
+ * (5.0.0) a literal `import('@emoji-mart/react')` broke every clean-install `vite build` that
17
+ * imports `cursedbelt/react`, because Vite stubs a missing import only for a DECLARED optional
18
+ * peer. `emoji-mart` itself IS a declared optional peer, so a consumer that never renders this
19
+ * builds green with it absent, and one that does gets a named error rather than a blank panel.
20
+ */
21
+ function EmojiMartPanel(props: Record<string, unknown>) {
22
+ const host = useRef<HTMLDivElement>(null);
23
+ const picker = useRef<{ update?: (props: object) => void } | null>(null);
24
+ const latest = useRef(props);
25
+ latest.current = props;
26
+ const [ready, setReady] = useState(false);
27
+ const [failed, setFailed] = useState<Error | null>(null);
28
+
29
+ useEffect(() => {
30
+ let live = true;
31
+ import('emoji-mart').then(
32
+ ({ Picker }) => {
33
+ if (!live || !host.current) return;
34
+ picker.current = new Picker({ ...latest.current, ref: host }) as { update?: (props: object) => void };
35
+ setReady(true);
36
+ },
37
+ (cause: unknown) => {
38
+ if (live) {
39
+ setFailed(
40
+ new Error("cursedbelt: EmojiPicker needs the optional peer 'emoji-mart' (and '@emoji-mart/data') — run `bun add emoji-mart @emoji-mart/data` in the app that renders it.", { cause }),
41
+ );
42
+ }
43
+ },
44
+ );
45
+ return () => {
46
+ live = false;
47
+ picker.current = null;
48
+ host.current?.replaceChildren();
49
+ };
50
+ }, []);
51
+
52
+ // The wrapper's contract: every render pushes the current props into the live element.
53
+ useEffect(() => {
54
+ picker.current?.update?.(props);
55
+ });
56
+
57
+ if (failed) throw failed;
58
+ return (
59
+ <>
60
+ {!ready && <Skeleton className='h-[420px] w-[352px]' />}
61
+ <div ref={host} />
62
+ </>
63
+ );
64
+ }
65
+
13
66
  interface EmojiMartSelectEvent {
14
67
  native: string;
15
68
  id: string;
@@ -37,7 +90,7 @@ export interface EmojiPickerProps {
37
90
  onOpenChange?: (open: boolean) => void;
38
91
  }
39
92
 
40
- /** The emoji-mart grid itself, themed + lazy, behind a Suspense skeleton. */
93
+ /** The emoji-mart grid itself, themed + lazy, behind a skeleton until the engine has loaded. */
41
94
  function EmojiPanel({
42
95
  onSelect,
43
96
  theme = 'auto',
@@ -47,19 +100,17 @@ function EmojiPanel({
47
100
  }: Pick<EmojiPickerProps, 'onSelect' | 'theme' | 'locale' | 'previewPosition' | 'set'>) {
48
101
  const resolvedTheme = useMemo(() => resolveEmojiTheme(theme), [theme]);
49
102
  return (
50
- <Suspense fallback={<Skeleton className='h-[420px] w-[352px]' />}>
51
- <Picker
52
- data={loadEmojiData}
53
- onEmojiSelect={(e: EmojiMartSelectEvent) => onSelect(e.native)}
54
- theme={resolvedTheme}
55
- locale={locale}
56
- previewPosition={previewPosition}
57
- set={set}
58
- navPosition='top'
59
- skinTonePosition='search'
60
- dynamicWidth={false}
61
- />
62
- </Suspense>
103
+ <EmojiMartPanel
104
+ data={loadEmojiData}
105
+ onEmojiSelect={(e: EmojiMartSelectEvent) => onSelect(e.native)}
106
+ theme={resolvedTheme}
107
+ locale={locale}
108
+ previewPosition={previewPosition}
109
+ set={set}
110
+ navPosition='top'
111
+ skinTonePosition='search'
112
+ dynamicWidth={false}
113
+ />
63
114
  );
64
115
  }
65
116
 
@@ -47,6 +47,14 @@ export { MessageScroller, type MessageScrollerProps } from './chat/MessageScroll
47
47
  export { MessageThread, type MessageThreadProps } from './chat/MessageThread.js';
48
48
  export { PresenceRoster, type PresenceRosterProps } from './chat/PresenceRoster.js';
49
49
  export { StatusMarker, type StatusMarkerProps } from './chat/StatusMarker.js';
50
+ export {
51
+ PRESENCE_RETRY_MS,
52
+ type PresenceEntryLike,
53
+ type PresenceSubscription,
54
+ presenceUsers,
55
+ type UsePresenceOptions,
56
+ usePresence,
57
+ } from './chat/usePresence.js';
50
58
  export { Markdown, type MarkdownProps } from './markdown/Markdown.js';
51
59
  export type {
52
60
  ChatAttachment,
@@ -1,36 +1,6 @@
1
- import type { DefaultValues, FieldValues, Resolver, UseFormProps } from 'react-hook-form';
1
+ import type { DefaultValues, FieldValues, UseFormProps } from 'react-hook-form';
2
2
  import type { z } from 'zod';
3
-
4
- /**
5
- * 🔴 `@hookform/resolvers` is an OPTIONAL peer, and this dynamic import is what makes it
6
- * one (task 149). `cursedbelt/react` is the barrel all eight apps import; a static
7
- * `import { zodResolver } from '@hookform/resolvers/zod'` at the top of this file put the
8
- * package — and its own dependency tree — into every one of those installs, to serve the
9
- * one function below. `src/barrelsReachNoOptionalPeer.spec.ts` is the check that keeps it
10
- * out, and a `await import(...)` inside the function that needs it is the shape that spec
11
- * blesses.
12
- *
13
- * Resolved once per process and memoized: RHF calls the resolver on every validation
14
- * pass, and `zodResolver(schema)` is cheap but the module load is not.
15
- */
16
- let zodResolverModule: Promise<typeof import('@hookform/resolvers/zod')> | undefined;
17
-
18
- const loadZodResolver = async (): Promise<typeof import('@hookform/resolvers/zod')> => {
19
- zodResolverModule ??= import('@hookform/resolvers/zod');
20
- try {
21
- return await zodResolverModule;
22
- } catch (cause) {
23
- // 🔴 Loud and actionable, not an unhandled rejection three frames into RHF. A missing
24
- // optional peer surfaces here as "the form never validates", which is the worst
25
- // possible thing to debug without the package name in the message.
26
- zodResolverModule = undefined;
27
- throw new Error(
28
- "cursedbelt: makeFormConfig() needs the optional peer '@hookform/resolvers' — " +
29
- 'run `bun add @hookform/resolvers` in the app that renders this form.',
30
- { cause },
31
- );
32
- }
33
- };
3
+ import { zodResolver } from './zodResolver.js';
34
4
 
35
5
  /**
36
6
  * The canonical react-hook-form config for every cursedbelt form (Wizard,
@@ -53,27 +23,14 @@ const loadZodResolver = async (): Promise<typeof import('@hookform/resolvers/zod
53
23
  * const form = useForm<Values>(makeFormConfig(schema, { name: '', tags: [] }));
54
24
  * ```
55
25
  *
56
- * 🔴 Needs the optional peer `@hookform/resolvers` installed in the app — RHF resolvers
57
- * are already async, so the load happens on the first validation rather than at import.
26
+ * The resolver is this package's own (`./zodResolver.ts`, 5.0.1) — there is nothing for the
27
+ * app to install, and nothing for a bundler to fail to resolve.
58
28
  */
59
29
  export function makeFormConfig<TFieldValues extends FieldValues>(
60
30
  schema: z.ZodType,
61
31
  defaults: TFieldValues,
62
32
  ): UseFormProps<TFieldValues> {
63
- // Built once per form, then reused across every validation pass.
64
- let resolved: Resolver<TFieldValues> | undefined;
65
- const resolver: Resolver<TFieldValues> = async (values, context, options) => {
66
- if (!resolved) {
67
- const { zodResolver } = await loadZodResolver();
68
- // zodResolver's generics don't line up with the caller's value type (the schema
69
- // input/output may differ from TFieldValues); the runtime resolver is correct, so
70
- // cast through the overload's param/return types rather than over-genericize.
71
- resolved = zodResolver(
72
- schema as Parameters<typeof zodResolver>[0],
73
- ) as unknown as Resolver<TFieldValues>;
74
- }
75
- return resolved(values, context, options);
76
- };
33
+ const resolver = zodResolver<TFieldValues>(schema);
77
34
 
78
35
  return {
79
36
  resolver,