cursedbelt 4.7.0 β†’ 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) 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/index.d.ts +1 -0
  18. package/dist/react/index.d.ts.map +1 -1
  19. package/dist/react/index.js +1 -0
  20. package/dist/react/index.js.map +1 -1
  21. package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
  22. package/dist/react/master-lock/MasterLockDialog.js +2 -1
  23. package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
  24. package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
  25. package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
  26. package/dist/react/master-lock/MasterLockProvider.js +14 -20
  27. package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
  28. package/dist/react/media/MediaUploadControl.d.ts +3 -7
  29. package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
  30. package/dist/react/media/MediaUploadControl.js +4 -13
  31. package/dist/react/media/MediaUploadControl.js.map +1 -1
  32. package/dist/react/media/fileUploadClient.d.ts +0 -8
  33. package/dist/react/media/fileUploadClient.d.ts.map +1 -1
  34. package/dist/react/media/fileUploadClient.js +0 -8
  35. package/dist/react/media/fileUploadClient.js.map +1 -1
  36. package/dist/react/media/hlsSource.d.ts.map +1 -1
  37. package/dist/react/media/hlsSource.js +4 -2
  38. package/dist/react/media/hlsSource.js.map +1 -1
  39. package/dist/react/media/index.d.ts +0 -4
  40. package/dist/react/media/index.d.ts.map +1 -1
  41. package/dist/react/media/index.js +0 -4
  42. package/dist/react/media/index.js.map +1 -1
  43. package/dist/react/sessionAuth.d.ts +1 -2
  44. package/dist/react/sessionAuth.d.ts.map +1 -1
  45. package/dist/react/sessionAuth.js +2 -3
  46. package/dist/react/sessionAuth.js.map +1 -1
  47. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  48. package/dist/styles-areas/activity-bar.css +1 -1
  49. package/dist/styles-areas/auth.css +1 -1
  50. package/dist/styles-areas/charts.css +1 -1
  51. package/dist/styles-areas/chat.css +1 -1
  52. package/dist/styles-areas/core.css +1 -1
  53. package/dist/styles-areas/data-table.css +1 -1
  54. package/dist/styles-areas/disk-usage.css +1 -1
  55. package/dist/styles-areas/fields.css +1 -1
  56. package/dist/styles-areas/master-detail.css +1 -1
  57. package/dist/styles-areas/master-lock.css +108 -2
  58. package/dist/styles-areas/media-gallery.css +1 -1
  59. package/dist/styles-areas/media.css +1 -17
  60. package/dist/styles-areas/nav.css +1 -1
  61. package/dist/styles-areas/notifications.css +1 -1
  62. package/dist/styles-areas/spreadsheet.css +1 -1
  63. package/dist/styles-areas/stats.css +1 -1
  64. package/dist/styles-areas/wizard.css +1 -1
  65. package/dist/styles-areas/workbook-viewer.css +1 -1
  66. package/dist/styles-utilities.css +0 -16
  67. package/dist/testing/_noBun.d.ts +2 -0
  68. package/dist/testing/_noBun.d.ts.map +1 -0
  69. package/dist/testing/_noBun.js +10 -0
  70. package/dist/testing/_noBun.js.map +1 -0
  71. package/package.json +42 -19
  72. package/scripts/checkAreaStyles.spec.ts +71 -0
  73. package/scripts/checkAreaStyles.ts +150 -0
  74. package/scripts/guardrailsEnforce.spec.ts +43 -0
  75. package/scripts/guardrailsEnforce.ts +46 -1
  76. package/scripts/styleAreas.ts +1 -2
  77. package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
  78. package/src/bundlerOnlyEntries.spec.ts +101 -0
  79. package/src/declaredImports.spec.ts +4 -0
  80. package/src/optionalPeersTable.spec.ts +127 -0
  81. package/src/react/chat/index.ts +8 -0
  82. package/src/react/chat/usePresence.spec.tsx +138 -0
  83. package/src/react/chat/usePresence.ts +185 -0
  84. package/src/react/components/SecretInput.tsx +12 -1
  85. package/src/react/components/TooltipImpl.tsx +54 -5
  86. package/src/react/index.ts +8 -0
  87. package/src/react/master-lock/MasterLockDialog.tsx +13 -9
  88. package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
  89. package/src/react/master-lock/MasterLockProvider.tsx +16 -19
  90. package/src/react/media/MediaUploadControl.tsx +8 -29
  91. package/src/react/media/fileUploadClient.ts +7 -17
  92. package/src/react/media/hlsSource.ts +4 -1
  93. package/src/react/media/index.ts +0 -16
  94. package/src/react/sessionAuth.ts +2 -3
  95. package/src/scriptsResolve.spec.ts +66 -0
  96. package/src/styles-areas/activity-bar.css +1 -1
  97. package/src/styles-areas/auth.css +1 -1
  98. package/src/styles-areas/charts.css +1 -1
  99. package/src/styles-areas/chat.css +1 -1
  100. package/src/styles-areas/core.css +1 -1
  101. package/src/styles-areas/data-table.css +1 -1
  102. package/src/styles-areas/disk-usage.css +1 -1
  103. package/src/styles-areas/fields.css +1 -1
  104. package/src/styles-areas/master-detail.css +1 -1
  105. package/src/styles-areas/master-lock.css +108 -2
  106. package/src/styles-areas/media-gallery.css +1 -1
  107. package/src/styles-areas/media.css +1 -17
  108. package/src/styles-areas/nav.css +1 -1
  109. package/src/styles-areas/notifications.css +1 -1
  110. package/src/styles-areas/spreadsheet.css +1 -1
  111. package/src/styles-areas/stats.css +1 -1
  112. package/src/styles-areas/wizard.css +1 -1
  113. package/src/styles-areas/workbook-viewer.css +1 -1
  114. package/src/styles-utilities.css +0 -16
  115. package/src/testing/_noBun.ts +12 -0
  116. package/src/verifyGraph.spec.ts +11 -0
  117. package/dist/react/media/PrivateIndexView.d.ts +0 -6
  118. package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
  119. package/dist/react/media/PrivateIndexView.js +0 -44
  120. package/dist/react/media/PrivateIndexView.js.map +0 -1
  121. package/dist/react/media/PrivateModeBadge.d.ts +0 -17
  122. package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
  123. package/dist/react/media/PrivateModeBadge.js +0 -24
  124. package/dist/react/media/PrivateModeBadge.js.map +0 -1
  125. package/dist/react/media/privateClient.d.ts +0 -34
  126. package/dist/react/media/privateClient.d.ts.map +0 -1
  127. package/dist/react/media/privateClient.js +0 -36
  128. package/dist/react/media/privateClient.js.map +0 -1
  129. package/dist/react/media/usePrivateUnlock.d.ts +0 -25
  130. package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
  131. package/dist/react/media/usePrivateUnlock.js +0 -68
  132. package/dist/react/media/usePrivateUnlock.js.map +0 -1
  133. package/src/react/media/PrivateIndexView.tsx +0 -113
  134. package/src/react/media/PrivateModeBadge.tsx +0 -50
  135. package/src/react/media/privateClient.spec.ts +0 -113
  136. package/src/react/media/privateClient.ts +0 -82
  137. 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. */}
@@ -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,
@@ -28,6 +28,7 @@ import { Button } from "../components/Button.js";
28
28
  import { FieldLabel } from "../components/FieldLabel.js";
29
29
  import { Input } from "../components/Input.js";
30
30
  import { Modal } from "../components/Modal.js";
31
+ import { SecretInput } from "../components/SecretInput.js";
31
32
 
32
33
  export interface MasterLockDialogProps {
33
34
  open: boolean;
@@ -201,39 +202,42 @@ export function MasterLockDialog({
201
202
  <label htmlFor="ml-current">
202
203
  <FieldLabel>Current master password</FieldLabel>
203
204
  </label>
204
- <Input
205
+ <SecretInput
205
206
  id="ml-current"
206
- type="password"
207
+ label="current master password"
207
208
  autoComplete="current-password"
209
+ showCopy={false}
208
210
  value={current}
209
211
  disabled={busy}
210
- onChange={(event) => setCurrent(event.target.value)}
212
+ onChange={setCurrent}
211
213
  />
212
214
  </div>
213
215
  <div className="flex flex-col gap-1.5">
214
216
  <label htmlFor="ml-next">
215
217
  <FieldLabel>New master password</FieldLabel>
216
218
  </label>
217
- <Input
219
+ <SecretInput
218
220
  id="ml-next"
219
- type="password"
221
+ label="new master password"
220
222
  autoComplete="new-password"
223
+ showCopy={false}
221
224
  value={next}
222
225
  disabled={busy}
223
- onChange={(event) => setNext(event.target.value)}
226
+ onChange={setNext}
224
227
  />
225
228
  </div>
226
229
  <div className="flex flex-col gap-1.5">
227
230
  <label htmlFor="ml-confirm">
228
231
  <FieldLabel>Repeat the new one</FieldLabel>
229
232
  </label>
230
- <Input
233
+ <SecretInput
231
234
  id="ml-confirm"
232
- type="password"
235
+ label="repeated new master password"
233
236
  autoComplete="new-password"
237
+ showCopy={false}
234
238
  value={confirm}
235
239
  disabled={busy}
236
- onChange={(event) => setConfirm(event.target.value)}
240
+ onChange={setConfirm}
237
241
  onEnter={() => void changePassword()}
238
242
  />
239
243
  </div>
@@ -126,3 +126,20 @@ describe("πŸ”΄ the rotation never mints a KDF", () => {
126
126
  expect(code).toContain("kdf: MasterLockKdfParams;");
127
127
  });
128
128
  });
129
+
130
+ describe("the account-menu entries (task 106)", () => {
131
+ test("πŸ”΄ 'Master passwords…' is a LINK to the accounts page, from the wire, not a string here", async () => {
132
+ const { MasterLockMenuItems } = await import("./MasterLockProvider.js");
133
+ const { findByText, getByText } = render(
134
+ <MasterLockProvider onLocked={() => {}}>
135
+ <MasterLockMenuItems itemClassName="sam-item" />
136
+ </MasterLockProvider>,
137
+ );
138
+ const link = await findByText("Master passwords…");
139
+ expect(link.tagName).toBe("A");
140
+ expect(link.getAttribute("href")).toBe(MASTER_LOCK_PATHS.accounts);
141
+ expect(link.className).toBe("sam-item");
142
+ // The idle timeout lives only in the dialog, so its entry stays.
143
+ expect(getByText("Lock settings…").tagName).toBe("BUTTON");
144
+ });
145
+ });
@@ -237,31 +237,25 @@ export interface MasterLockMenuItemsProps {
237
237
  }
238
238
 
239
239
  /**
240
- * The two entries for the account menu β€” *"a new option in the user dropdown … which is
240
+ * The entries for the account menu β€” *"a new option in the user dropdown … which is
241
241
  * located near the sign out and switch user options"*. The owner chose that placement
242
242
  * deliberately: *"this placement will help make sure it isn't confused with the private
243
243
  * collections lock or other locks like that."*
244
244
  *
245
245
  * Renders nothing when no master password is configured for the app.
246
246
  *
247
- * ── πŸ”΄ The second entry is a LINK now, not a dialog (2026-09-17) ─────────────────────────
248
- * It used to call {@link MasterLockControl.openSettings}, and that dialog minted a FRESH `kdf`
249
- * salt on every rotation β€” the shape the wire had before accounts existed. Every account on an
250
- * app now derives under ONE shared descriptor, so honouring that salt would store a verifier
251
- * hash nothing could ever match: the owner sets a new password, the rotation logs him out, and
252
- * neither the old nor the new one opens the app. `cursedbelt-server`'s guard therefore answers
253
- * that shape `409 {"error":"stale-client"}` β€” deliberately distinguishable from a wrong
254
- * password, because it describes a broken CLIENT. So the entry did not merely look wrong; it
255
- * refused, and the owner could lock a site and enroll a first password but could not CHANGE one.
247
+ * ── The second entry is a LINK to the accounts page (task 106, 2026-09-23) ─────────────────
248
+ * `cursedbelt-server` serves `GET /__lock/accounts` from the same guard that serves the lock
249
+ * page: server-rendered, listing the accounts and adding, renaming and rotating them. So the
250
+ * entry is an `<a href>`, not a button β€” the target is a same-origin page, so there is nothing
251
+ * for this bundle to open and nothing for it to keep in step β€” and the path is
252
+ * `MASTER_LOCK_PATHS.accounts` from `cursedbelt-core`'s wire, never a string here, because a
253
+ * second copy of the wire in the design system is what `wire.ts`'s header exists to prevent.
256
254
  *
257
- * `cursedbelt-server` serves a richer replacement at `GET /__lock/accounts`, from the same guard
258
- * that serves the lock page: server-rendered, content-negotiated, listing the accounts and
259
- * adding/renaming them as well as rotating. This package does NOT link to it yet β€” the path
260
- * constant lives in `cursedbelt-core@2`'s wire and this package is still on `^1.1.0`, so the
261
- * link is a task, not a one-liner (`tasks/Not_Started`, filed 2026-09-17). What IS fixed here
262
- * is the dialog itself: `MasterLockDialog` no longer mints a salt, so this entry works again
263
- * rather than 409ing. `apps/collections` links to the page directly from its own rail in the
264
- * meantime, and says why in a comment there.
255
+ * The THIRD entry keeps the dialog, because the dialog also owns the idle timeout, which the
256
+ * accounts page does not. (History: until 2026-09-17 the dialog minted a fresh `kdf` salt on
257
+ * every rotation and the server refused it `409 stale-client`; it derives under the app's
258
+ * existing descriptor now, so both surfaces rotate correctly.)
265
259
  */
266
260
  export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItemsProps) {
267
261
  const lock = useMasterLock();
@@ -283,6 +277,9 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
283
277
  >
284
278
  πŸ”’ Lock this site
285
279
  </button>
280
+ <a href={MASTER_LOCK_PATHS.accounts} className={cls} onClick={() => close?.()}>
281
+ Master passwords…
282
+ </a>
286
283
  <button
287
284
  // guardrails-ignore no-raw-action-button: see the sibling above β€” it must match
288
285
  // the host menu's own rows, not this design system's buttons.
@@ -293,7 +290,7 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
293
290
  lock.openSettings();
294
291
  }}
295
292
  >
296
- Master password…
293
+ Lock settings…
297
294
  </button>
298
295
  </>
299
296
  );