@simon-says-build/ssb-overlay 0.0.0-stage → 0.4.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.
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # @simon-says-build/ssb-overlay
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The SSB button inside a native app SimonSays controls (docs/IN-APP-SSB.md). A
4
+ member of the app's organization, an SSB operator, or an allowed cast login
5
+ (the super-admin by default) gets a small **SSB** button; it opens Tell SSB on
6
+ the phone: the screen's picture, a mark on it, the conversation, SSB's drafts
7
+ and **Start working**. Everyone else sees nothing.
8
+
9
+ ```tsx
10
+ import { SsbHost } from "@simon-says-build/ssb-overlay";
11
+ import { captureScreen } from "react-native-view-shot";
12
+
13
+ <SsbHost
14
+ appId={Constants.expoConfig?.extra?.ssb?.appId}
15
+ backend={process.env.EXPO_PUBLIC_SUPABASE_URL}
16
+ getToken={async () => (await supabase.auth.getSession()).data.session?.access_token}
17
+ sessionKey={session?.user.id}
18
+ route={usePathname()}
19
+ capture={() => captureScreen({ format: "jpg", quality: 0.6, result: "data-uri" })}
20
+ storage={AsyncStorage}
21
+ >
22
+ {/* the app */}
23
+ </SsbHost>
24
+ ```
25
+
26
+ Nothing secret goes in the bundle: the app's own session token is checked by
27
+ SSB against the app's backend (functions/in-app.mjs).
28
+
29
+ `npm test` runs the pure half.
Binary file
package/index.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * Wrap the app's root. Renders `children` unchanged unless SSB lets the
5
+ * signed-in login in (a member of the app's organization, an SSB operator, or
6
+ * an allowed cast login — the super-admin by default); then an SSB button
7
+ * floats bottom-right. docs/IN-APP-SSB.md.
8
+ */
9
+ /** The link an SSB notification opens, or null: for an app that handles its own notification taps. */
10
+ export function ssbLinkOf(response: unknown): string | null;
11
+
12
+ export function SsbHost(props: {
13
+ /** The app's SSB id (app.json `extra.ssb.appId`). Missing = no overlay. */
14
+ appId?: string;
15
+ /** Where the app signs in: its Supabase URL, or `firebase:<projectId>`. Missing = no overlay. */
16
+ backend?: string;
17
+ /** SSB's origin. Default https://simonsays.build. */
18
+ api?: string;
19
+ /** The app's CURRENT session token (Supabase access token / Firebase ID token), or null when signed out. */
20
+ getToken: () => Promise<string | null | undefined>;
21
+ /** Anything that changes when the login does (the user id): SSB is asked again. */
22
+ sessionKey?: string | null;
23
+ /** The current route (expo-router's usePathname()). */
24
+ route?: string;
25
+ /** A picture of the screen as a JPEG/PNG data URL, e.g. react-native-view-shot's captureScreen({ result: 'data-uri' }). Without it, sends carry words and marks only. */
26
+ capture?: () => Promise<string>;
27
+ /** Remembers the conversation across launches (AsyncStorage fits). */
28
+ storage?: { getItem(k: string): Promise<string | null> | string | null; setItem(k: string, v: string): unknown; removeItem?(k: string): unknown };
29
+ /** This device's Expo push token (Notifications.getExpoPushTokenAsync), so SSB can say "it's done, tap to see it" in this app. */
30
+ pushToken?: () => Promise<string | null | undefined>;
31
+ /** The app's expo-notifications module: a tapped SSB notification then opens the demo's preview in the dev client. */
32
+ notifications?: {
33
+ addNotificationResponseReceivedListener(fn: (r: unknown) => void): { remove(): void };
34
+ getLastNotificationResponseAsync?(): Promise<unknown>;
35
+ };
36
+ /**
37
+ * 🎙 Talk: SSB by voice, which can draft and start jobs. The app's own LiveKit client:
38
+ * `{ Room }` from livekit-client, and @livekit/react-native's AudioSession start/stop.
39
+ * Without it there is no Talk button.
40
+ */
41
+ voice?: { Room: new (opts?: object) => any; startAudio?: () => Promise<void> | void; stopAudio?: () => Promise<void> | void };
42
+ /** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
43
+ insets?: { top?: number; bottom?: number };
44
+ children?: ReactNode;
45
+ }): ReactNode;
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { SsbHost } from './src/SsbHost.js';
2
+ export { overlayConfig, inAppApi, drawingMark, sendBody, ssbLinkOf } from './src/core.js';
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "The SSB button inside a native app SimonSays controls: screenshot the screen, mark it, and ask SSB for a change. Renders nothing for anyone SSB does not let in.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "files": ["index.js", "index.d.ts", "src/", "assets/", "README.md"],
9
+ "peerDependencies": {
10
+ "react": ">=18",
11
+ "react-native": ">=0.76"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test test/"
15
+ },
16
+ "license": "UNLICENSED"
17
+ }
package/src/SsbHost.js ADDED
@@ -0,0 +1,615 @@
1
+ // <SsbHost>: the SSB button inside a native app (docs/IN-APP-SSB.md).
2
+ //
3
+ // Renders its children and nothing else until SSB says the signed-in login
4
+ // may use it (GET /api/in-app/me: a member of the app's organization, an SSB
5
+ // operator, or a cast login on the app's allowed list, by default the
6
+ // super-admin). Then SSB's logo floats on the screen (drag it anywhere).
7
+ // Tapping it takes a picture of the screen as it is and opens SSB FULL SCREEN
8
+ // (2026-10-20, the product owner: "abandon the idea of a partial overlaying
9
+ // sheet … like a full-screen overlay, unless you're in draw / box mode, or
10
+ // voice mode"): the WORK at the top — what is in flight on this app, this
11
+ // chat's drafts to start — the way the extension's card has it, and the
12
+ // conversation with SSB below (Tell SSB's, functions/annotate.mjs). Every send carries
13
+ // the screen (2026-10-19, the product owner: "always send the screen, don't
14
+ // make it an option"). **Draw** puts a pencil and a box on the live screen:
15
+ // scribble on or box what should change (as much as it takes, Undo), say what,
16
+ // send — the drawing is captured ON the screen, with the field and our chrome
17
+ // hidden. (It was **Mark**, one pin or one box, until 0.2.0.) SSB replies on the thread and proposes jobs; the drafts bar
18
+ // starts them. The same conversation is on the console.
19
+ //
20
+ // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
21
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
+ import {
23
+ ActivityIndicator, Image, Keyboard, KeyboardAvoidingView, Linking, PanResponder, Platform, Pressable,
24
+ ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
25
+ } from 'react-native';
26
+ import {
27
+ FAB, addPoint, clampFab, demoBanner, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
28
+ STATE_WORD, boxItem, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
29
+ } from './core.js';
30
+
31
+ const h = React.createElement;
32
+ // THE LOOK (2026-10-20, the product owner: "a futuristic look … dark,
33
+ // matching the 'real-object' look of our logo"): the toy's black chassis, and
34
+ // its four pads for meaning — blue to act, green for ready and talking,
35
+ // yellow for a draft waiting on you, red to draw and to end. What is live glows.
36
+ const BG = '#0b0d12', SURFACE = '#141821', RAISED = '#1b2030', LINE = '#232a3a';
37
+ const TEXT = '#eef2fa', MUTED = '#8a93a8';
38
+ const BLUE = '#3d7bff', GREEN = '#2fd27a', YELLOW = '#f6c544', RED = '#ff4d5a';
39
+ const INK = BG; // the drawing hint and the talk pill: the chassis
40
+ const DEFAULT_INSETS = { top: 60, bottom: 40 };
41
+ const INK_W = 4; // the pencil's line, in points
42
+ // The button IS SSB's logo, the round Simon toy (web/public/logo-192.png, 156px: 3x of 52pt).
43
+ const LOGO = require('../assets/ssb-logo.png');
44
+
45
+ // React keeps the fiber on the host instance (Fabric: __internalInstanceHandle).
46
+ const fiberOf = (instance) => instance?.__internalInstanceHandle ?? instance?._internalInstanceHandle ?? null;
47
+ let publicInstanceOf = (fiber) => fiber?.stateNode?.canonical?.publicInstance ?? null;
48
+ try {
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
50
+ const impl = require('react-native/Libraries/ReactNative/RendererImplementation');
51
+ if (typeof impl.getPublicInstanceFromInternalInstanceHandle === 'function') {
52
+ const fallback = publicInstanceOf;
53
+ publicInstanceOf = (fiber) => {
54
+ try { return impl.getPublicInstanceFromInternalInstanceHandle(fiber) ?? fallback(fiber); } catch { return fallback(fiber); }
55
+ };
56
+ }
57
+ } catch { /* an older renderer: the fallback above */ }
58
+ const measure = (fiber) => new Promise((resolve) => {
59
+ const inst = publicInstanceOf(fiber);
60
+ if (!inst?.measureInWindow) return resolve(null);
61
+ inst.measureInWindow((x, y, width, height) => resolve(width || height ? { left: x, top: y, width, height } : null));
62
+ });
63
+ const frames = (n = 2) => new Promise((resolve) => {
64
+ const step = (k) => (k <= 0 ? resolve() : requestAnimationFrame(() => step(k - 1)));
65
+ step(n);
66
+ });
67
+
68
+ export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, children }) {
69
+ const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
70
+ useSsbLinks(notifications);
71
+ if (!config || typeof getToken !== 'function') return children ?? null;
72
+ return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
73
+ }
74
+
75
+ // A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
76
+ // the demo's preview in the app's dev client. Heard whoever is signed in,
77
+ // and on a cold start too: the tap may be what launched the app.
78
+ function useSsbLinks(notifications) {
79
+ useEffect(() => {
80
+ if (!notifications?.addNotificationResponseReceivedListener) return undefined;
81
+ const open = (r) => { const url = ssbLinkOf(r); if (url) Linking.openURL(url).catch(() => {}); };
82
+ Promise.resolve(notifications.getLastNotificationResponseAsync?.()).then(open).catch(() => {});
83
+ const sub = notifications.addNotificationResponseReceivedListener(open);
84
+ return () => sub?.remove?.();
85
+ }, [notifications]);
86
+ }
87
+
88
+ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, children }) {
89
+ const screen = useWindowDimensions();
90
+ const tokenRef = useRef(getToken);
91
+ tokenRef.current = getToken;
92
+ const ssb = useMemo(() => inAppApi(config, () => tokenRef.current()), [config]);
93
+ const rootRef = useRef(null);
94
+ const screenRef = useRef(screen);
95
+ screenRef.current = screen;
96
+ const insetsRef = useRef(insets);
97
+ insetsRef.current = insets;
98
+ const chromeRef = useRef(null);
99
+ const storeKey = `ssb-overlay:chat:${config.appId}`;
100
+
101
+ const [gate, setGate] = useState(null); // {show, person, app, canJob} once SSB has answered
102
+ const [mode, setMode] = useState('idle'); // idle | sheet | drawing | capturing
103
+ const [shot, setShot] = useState(null); // the screen as it was when the button was tapped
104
+ const [items, setItems] = useState([]); // what is drawn: strokes and boxes, in screen points (core.js)
105
+ const [live, setLive] = useState(null); // the item under the finger
106
+ const liveRef = useRef(null);
107
+ const [tool, setTool] = useState('pencil'); // pencil | box
108
+ // 🎙 Talk: a LiveKit room with SSB's voice agent (the host's livekit-client Room).
109
+ const [talk, setTalk] = useState(null); // null | { status: connecting | waiting | live, muted }
110
+ const roomRef = useRef(null);
111
+ const toolRef = useRef(tool);
112
+ toolRef.current = tool;
113
+ // The button goes wherever it is dragged (2026-10-19), remembered per app on this phone.
114
+ const fabKey = `ssb-overlay:fab:${config.appId}`;
115
+ const [fab, setFab] = useState(null);
116
+ const fabRef = useRef(null);
117
+ fabRef.current = fab;
118
+ const [note, setNote] = useState('');
119
+ const [draft, setDraft] = useState('');
120
+ const [chatId, setChatId] = useState(null);
121
+ const [chat, setChat] = useState(null);
122
+ const [busy, setBusy] = useState(false);
123
+ const [error, setError] = useState(null);
124
+ const [work, setWork] = useState([]); // the app's work in flight (GET /api/in-app/work)
125
+ const [seen, setSeen] = useState(null);
126
+ const [kbTop, setKbTop] = useState(screen.height);
127
+
128
+ // ---- the gate: ask once per login --------------------------------------
129
+ useEffect(() => {
130
+ let live = true;
131
+ setGate(null);
132
+ setMode('idle');
133
+ ssb.me().then((g) => {
134
+ if (!live) return;
135
+ setGate(g?.show ? g : null);
136
+ // This device, for "it's done, tap to see it" — only for someone SSB let in.
137
+ if (g?.show && pushToken) Promise.resolve(pushToken()).then((t) => (t ? ssb.pushToken(t, Platform.OS) : null)).catch(() => {});
138
+ }).catch(() => { if (live) setGate(null); });
139
+ return () => { live = false; };
140
+ }, [ssb, sessionKey]);
141
+
142
+ useEffect(() => {
143
+ if (!storage?.getItem) return;
144
+ Promise.resolve(storage.getItem(fabKey)).then((v) => { try { const p = JSON.parse(v); if (Number.isFinite(p?.x) && Number.isFinite(p?.y)) setFab(p); } catch { /* none kept */ } }).catch(() => {});
145
+ }, [fabKey]);
146
+ useEffect(() => {
147
+ if (!storage?.getItem) return;
148
+ Promise.resolve(storage.getItem(storeKey)).then((id) => { if (id) setChatId(id); }).catch(() => {});
149
+ }, [storeKey]);
150
+ const rememberChat = useCallback((id) => {
151
+ setChatId(id);
152
+ if (storage?.setItem) Promise.resolve(id ? storage.setItem(storeKey, id) : storage.removeItem?.(storeKey)).catch(() => {});
153
+ }, [storage, storeKey]);
154
+
155
+ useEffect(() => {
156
+ const show = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', (e) => setKbTop(e.endCoordinates.screenY));
157
+ const hide = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => setKbTop(screen.height));
158
+ return () => { show.remove(); hide.remove(); };
159
+ }, [screen.height]);
160
+
161
+ // A new screen makes the drawing and the picture stale (the extension's
162
+ // rule): the scribbles are dropped and their words move to the composer.
163
+ const lastRoute = useRef(route);
164
+ useEffect(() => {
165
+ if (lastRoute.current === route) return;
166
+ lastRoute.current = route;
167
+ setShot(null);
168
+ if (mode === 'drawing') {
169
+ if (note.trim()) setDraft((d) => (d ? `${d}\n${note}` : note));
170
+ setItems([]); setNote(''); setMode('sheet');
171
+ }
172
+ }, [route]);
173
+
174
+ // ---- the thread ----------------------------------------------------------
175
+ const load = useCallback(async () => {
176
+ if (!chatId) return;
177
+ try { setChat(await ssb.chat(chatId)); }
178
+ catch (e) { if (e.status === 404) { rememberChat(null); setChat(null); } }
179
+ }, [ssb, chatId, rememberChat]);
180
+
181
+ const open = mode !== 'idle';
182
+ useEffect(() => { if (open) load(); }, [open, chatId]);
183
+ // The top of the panel: the app's work, asked when it opens and while it stays open.
184
+ const loadWork = useCallback(async () => { try { setWork((await ssb.work())?.inFlight || []); } catch { /* the last list stands */ } }, [ssb]);
185
+ useEffect(() => {
186
+ if (mode !== 'sheet' || !gate) return undefined;
187
+ loadWork();
188
+ const t = setInterval(loadWork, 10000);
189
+ return () => clearInterval(t);
190
+ }, [mode, gate, loadWork]);
191
+ useEffect(() => {
192
+ if (!gate || !chatId) return undefined;
193
+ const every = talk ? 2000 : pollEvery({ open, thinking: !!chat?.thinking || chat?.demo?.status === 'preparing' });
194
+ if (!every) return undefined;
195
+ const t = setInterval(load, every);
196
+ return () => clearInterval(t);
197
+ }, [gate, chatId, open, chat?.thinking, load, !!talk]);
198
+
199
+ const lastReplyId = (c) => [...(c?.messages || [])].reverse().find((m) => m.role !== 'user' && !m.streaming)?.id ?? null;
200
+ const [seenDemo, setSeenDemo] = useState(null);
201
+ const unread = !open && chat && (newReply(chat, seen) || (chat.demo?.status === 'ready' && chat.demo.url !== seenDemo));
202
+
203
+ // ---- open, draw, send ----------------------------------------------------
204
+ const grab = useCallback(async () => {
205
+ if (!capture) return null;
206
+ await frames(2);
207
+ try { return await capture(); } catch { return null; }
208
+ }, [capture]);
209
+
210
+ const openSheet = useCallback(async () => {
211
+ setError(null);
212
+ setMode('capturing');
213
+ const pic = await grab();
214
+ setShot(pic);
215
+ setMode('sheet');
216
+ }, [grab]);
217
+
218
+ // A demo opens IN the app (or the dev client): SSB steps aside first, or the
219
+ // full-screen panel would sit on top of what it opened.
220
+ const openLink = useCallback((url) => { if (!url) return; Keyboard.dismiss(); setMode('idle'); Linking.openURL(url).catch(() => {}); }, []);
221
+ const openRef = useRef(openSheet);
222
+ openRef.current = openSheet;
223
+ const close = useCallback(() => {
224
+ Keyboard.dismiss();
225
+ setSeen(lastReplyId(chat));
226
+ setSeenDemo(chat?.demo?.url ?? null);
227
+ setItems([]); setNote('');
228
+ setMode('idle');
229
+ }, [chat]);
230
+
231
+ const deliver = useCallback(async (body) => {
232
+ setBusy(true); setError(null);
233
+ try {
234
+ const r = await ssb.send(body);
235
+ rememberChat(r.chatId);
236
+ const c = await ssb.chat(r.chatId).catch(() => null);
237
+ if (c) setChat(c);
238
+ return true;
239
+ } catch (e) {
240
+ setError(e.message);
241
+ return false;
242
+ } finally { setBusy(false); }
243
+ }, [ssb, rememberChat]);
244
+
245
+ const sendDraft = useCallback(async () => {
246
+ const text = draft.trim();
247
+ if (!text || busy) return;
248
+ // Always the screen: the picture taken when the button was tapped.
249
+ const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, screenshot: shot }));
250
+ if (ok) setDraft('');
251
+ }, [draft, busy, deliver, chatId, route, screen, shot]);
252
+
253
+ // What the app has under the scribbles, named the way the app names it.
254
+ const elementUnder = useCallback(async (pt) => {
255
+ if (!pt) return null;
256
+ try {
257
+ const hosts = namedHosts(fiberOf(rootRef.current), fiberOf(chromeRef.current));
258
+ const cands = await Promise.all(hosts.map(async (fiber) => ({ fiber, rect: await measure(fiber) })));
259
+ const hit = smallestAt(cands, pt);
260
+ return hit ? describeHost(hit.fiber) : null;
261
+ } catch { return null; }
262
+ }, []);
263
+
264
+ const sendDrawing = useCallback(async () => {
265
+ const text = note.trim();
266
+ if (!text || busy || !items.length) return;
267
+ Keyboard.dismiss();
268
+ const element = await elementUnder(drawingPoint(items));
269
+ setMode('capturing'); // only the drawing stays on screen
270
+ const pic = await grab();
271
+ setMode('sheet');
272
+ const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, mark: drawingMark(items, screen), element, screenshot: pic || shot }));
273
+ if (ok) { setItems([]); setNote(''); }
274
+ else setDraft((d) => d || text);
275
+ }, [note, busy, items, elementUnder, grab, deliver, chatId, route, screen, shot]);
276
+
277
+ // ---- 🎙 Talk ------------------------------------------------------------------
278
+ const endTalk = useCallback(async () => {
279
+ const room = roomRef.current;
280
+ roomRef.current = null;
281
+ setTalk(null);
282
+ try { await room?.disconnect(); } catch { /* gone */ }
283
+ try { await voice?.stopAudio?.(); } catch { /* not ours */ }
284
+ load();
285
+ }, [voice, load]);
286
+
287
+ const startTalk = useCallback(async () => {
288
+ if (!voice?.Room || roomRef.current || talk) return;
289
+ setError(null);
290
+ setTalk({ status: 'connecting', muted: false });
291
+ try {
292
+ const r = await ssb.voice({ chatId, route, platform: Platform.OS, page: { title: `In the app (${Platform.OS}): ${route || '/'}` }, ...(shot ? { screenshot: shot } : {}) });
293
+ if (r.chatId) rememberChat(r.chatId);
294
+ if (!r.configured) { setTalk(null); setError(r.say || 'Voice is not set up yet.'); return; }
295
+ await voice.startAudio?.();
296
+ const room = new voice.Room({ adaptiveStream: true, dynacast: true });
297
+ roomRef.current = room;
298
+ let shown = false;
299
+ const show = () => {
300
+ if (shown || !roomRef.current) return;
301
+ shown = true;
302
+ room.localParticipant.sendText(snapshotOf({ route, screenshot: shot, first: true }), { topic: SNAPSHOT_TOPIC }).catch(() => {});
303
+ setTalk((t) => (t ? { ...t, status: 'live' } : t));
304
+ };
305
+ room.on('participantConnected', show);
306
+ room.on('disconnected', () => { if (roomRef.current === room) endTalk(); });
307
+ await room.connect(r.url, r.token);
308
+ await room.localParticipant.setMicrophoneEnabled(true);
309
+ if (room.remoteParticipants?.size) show(); else setTalk((t) => (t ? { ...t, status: 'waiting' } : t));
310
+ // VOICE MODE shows the app: SSB steps aside to a pill, so they can move
311
+ // around while they talk (and SSB is told each screen they reach).
312
+ Keyboard.dismiss();
313
+ setMode('idle');
314
+ } catch (e) {
315
+ setError(`Could not start talking: ${String(e.message || e).split('\n')[0]}`);
316
+ endTalk();
317
+ }
318
+ }, [voice, talk, ssb, chatId, route, shot, rememberChat, endTalk]);
319
+
320
+ const toggleMute = useCallback(async () => {
321
+ const room = roomRef.current;
322
+ if (!room || !talk) return;
323
+ const muted = !talk.muted;
324
+ try { await room.localParticipant.setMicrophoneEnabled(!muted); setTalk({ ...talk, muted }); } catch { /* left as it was */ }
325
+ }, [talk]);
326
+
327
+ // A new screen while talking: SSB is told where they went.
328
+ useEffect(() => {
329
+ const room = roomRef.current;
330
+ if (room && talk?.status === 'live') room.localParticipant.sendText(snapshotOf({ route }), { topic: SNAPSHOT_TOPIC }).catch(() => {});
331
+ }, [route]);
332
+ // The person leaves (signs out, the gate closes, the host unmounts): the talk ends.
333
+ useEffect(() => () => { const room = roomRef.current; roomRef.current = null; room?.disconnect?.(); voice?.stopAudio?.(); }, []);
334
+ useEffect(() => { if (!gate && roomRef.current) endTalk(); }, [gate]);
335
+
336
+ const decide = useCallback(async (p, action) => {
337
+ setBusy(true); setError(null);
338
+ try {
339
+ await ssb.decide(chatId, p.id, action, action === 'raise' ? { kind: p.kind, title: p.title, detail: p.detail, severity: p.severity } : {});
340
+ await load();
341
+ loadWork();
342
+ return true;
343
+ } catch (e) { setError(`${p.title}: ${e.message}`); return false; }
344
+ finally { setBusy(false); }
345
+ }, [ssb, chatId, load, loadWork]);
346
+
347
+ const startAll = useCallback(async () => {
348
+ for (const p of draftsOf(chat).waiting) if (!(await decide(p, 'raise'))) break;
349
+ }, [chat, decide]);
350
+
351
+ // The pencil and the box. The item lives in a ref (a move must not wait on a render); state only draws it.
352
+ const finish = () => {
353
+ const it = liveRef.current; liveRef.current = null; setLive(null);
354
+ const done = it?.kind === 'box' ? boxItem(it) : it?.points?.length ? it : null;
355
+ if (done) setItems((all) => [...all, done]);
356
+ };
357
+ const pan = useMemo(() => PanResponder.create({
358
+ onStartShouldSetPanResponder: () => true,
359
+ onMoveShouldSetPanResponder: () => true,
360
+ onPanResponderGrant: (e) => {
361
+ const { pageX: x, pageY: y } = e.nativeEvent;
362
+ liveRef.current = toolRef.current === 'box' ? { kind: 'box', x0: x, y0: y, x1: x, y1: y } : { kind: 'stroke', points: [{ x, y }] };
363
+ setLive(liveRef.current);
364
+ },
365
+ onPanResponderMove: (e) => {
366
+ const it = liveRef.current;
367
+ if (!it) return;
368
+ const p = { x: e.nativeEvent.pageX, y: e.nativeEvent.pageY };
369
+ if (it.kind === 'box') { liveRef.current = { ...it, x1: p.x, y1: p.y }; setLive(liveRef.current); return; }
370
+ const points = addPoint(it.points, p);
371
+ if (points !== it.points) { liveRef.current = { ...it, points }; setLive(liveRef.current); }
372
+ },
373
+ onPanResponderRelease: finish,
374
+ onPanResponderTerminate: finish,
375
+ }), []);
376
+
377
+ // The button: a drag moves it, a tap opens SSB. Its start is kept for the gesture.
378
+ const fabStart = useRef(null);
379
+ const fabPan = useMemo(() => PanResponder.create({
380
+ onStartShouldSetPanResponder: () => true,
381
+ onPanResponderGrant: () => { fabStart.current = fabRef.current ?? fabHome(screenRef.current, insetsRef.current); },
382
+ onPanResponderMove: (_e, g) => {
383
+ if (!fabStart.current || !isDrag(g.dx, g.dy)) return;
384
+ setFab(clampFab({ x: fabStart.current.x + g.dx, y: fabStart.current.y + g.dy }, screenRef.current, insetsRef.current));
385
+ },
386
+ onPanResponderRelease: (_e, g) => {
387
+ fabStart.current = null;
388
+ if (!isDrag(g.dx, g.dy)) { openRef.current(); return; }
389
+ const at = fabRef.current;
390
+ if (at && storage?.setItem) Promise.resolve(storage.setItem(fabKey, JSON.stringify(at))).catch(() => {});
391
+ },
392
+ onPanResponderTerminate: () => { fabStart.current = null; },
393
+ }), [fabKey, storage]);
394
+
395
+ // ---- drawing ------------------------------------------------------------
396
+ const drawingViews = (list) => list.flatMap((it, i) => (it.kind === 'box'
397
+ ? [h(View, { key: `b${i}`, pointerEvents: 'none', style: [s.box, itemRect(it)] })]
398
+ : segmentsOf(it.points, INK_W).map((g, j) => h(View, {
399
+ key: `${i}-${j}`, pointerEvents: 'none',
400
+ style: [s.ink, { left: g.left, top: g.top, width: g.width, transform: [{ rotate: `${g.angle}rad` }] }],
401
+ }))));
402
+
403
+ const chrome = (() => {
404
+ if (!gate) return null;
405
+ if (mode === 'capturing') return items.length ? h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'none' }, ...drawingViews(items)) : null;
406
+ if (mode === 'idle' && talk) {
407
+ return h(View, { style: [s.pill, { top: insets.top + 8 }], testID: 'ssb-talk-pill' },
408
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
409
+ h(Text, { style: s.pillText }, talkLine(talk)),
410
+ h(Pressable, { onPress: toggleMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.pillBtn }, h(Text, { style: s.pillBtnText }, talk.muted ? 'Unmute' : 'Mute')),
411
+ h(Pressable, { testID: 'ssb-talk-open', onPress: () => setMode('sheet'), accessibilityRole: 'button', accessibilityLabel: 'Open SSB', style: s.pillBtn }, h(Text, { style: s.pillBtnText }, 'Open')),
412
+ h(Pressable, { testID: 'ssb-talk-end', onPress: endTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.pillBtn, s.talkEnd] }, h(Text, { style: [s.pillBtnText, { color: '#fff' }] }, 'End')));
413
+ }
414
+ if (mode === 'idle') {
415
+ const at = clampFab(fab ?? fabHome(screen, insets), screen, insets); // a rotation can strand a kept spot
416
+ return h(View, {
417
+ testID: 'ssb-button', accessible: true, accessibilityRole: 'button', accessibilityLabel: 'Ask SSB about this screen',
418
+ accessibilityHint: 'Drag to move it', onAccessibilityTap: openSheet,
419
+ style: [s.fab, { left: at.x, top: at.y }, talk && s.fabTalking], ...fabPan.panHandlers,
420
+ }, h(Image, { source: LOGO, style: s.fabLogo }), unread ? h(View, { style: s.dot }) : null);
421
+ }
422
+ if (mode === 'drawing') {
423
+ const box = drawingBox(items);
424
+ const top = box ? fieldTop({ rect: box, screen, keyboardTop: kbTop, insets }) : null;
425
+ return h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none' },
426
+ // The surface first, so the hint and the field above it still take their own taps.
427
+ h(View, { style: StyleSheet.absoluteFill, ...pan.panHandlers, testID: 'ssb-draw-surface' },
428
+ ...drawingViews(live ? [...items, live] : items)),
429
+ h(View, { style: [s.hint, { top: insets.top + 8 }] },
430
+ ...['pencil', 'box'].map((t) => h(Pressable, {
431
+ key: t, onPress: () => setTool(t), accessibilityRole: 'button', accessibilityState: { selected: tool === t },
432
+ accessibilityLabel: t === 'box' ? 'Box: drag a box round it' : 'Pencil: scribble on it', style: [s.toolBtn, tool === t && s.toolOn],
433
+ }, h(Text, { style: [s.toolText, tool === t && s.toolTextOn] }, t === 'box' ? '▭ Box' : '✎ Pencil'))),
434
+ h(View, { style: s.hintSep }),
435
+ items.length ? h(Pressable, { onPress: () => setItems((all) => all.slice(0, -1)), accessibilityRole: 'button', accessibilityLabel: 'Undo the last one', style: s.hintBtn }, h(Text, { style: s.hintLink }, 'Undo')) : null,
436
+ h(Pressable, { onPress: () => { Keyboard.dismiss(); setItems([]); setMode('sheet'); }, accessibilityRole: 'button', accessibilityLabel: 'Stop drawing', style: s.hintBtn }, h(Text, { style: s.hintLink }, 'Cancel'))),
437
+ top != null ? h(View, { style: [s.field, { top }] },
438
+ h(TextInput, {
439
+ testID: 'ssb-draw-note', value: note, onChangeText: setNote, placeholder: 'What should change here?',
440
+ placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.fieldInput, returnKeyType: 'send', onSubmitEditing: sendDrawing, blurOnSubmit: false,
441
+ }),
442
+ h(Pressable, { testID: 'ssb-draw-send', onPress: sendDrawing, disabled: !note.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.sendRound, (!note.trim() || busy) && s.disabled] }, h(Text, { style: s.sendRoundText }, '→'))) : null);
443
+ }
444
+ if (mode === 'sheet') return h(Panel, {
445
+ gate, chat, work, screen, insets, draft, setDraft, busy, error,
446
+ onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
447
+ canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
448
+ onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
449
+ });
450
+ return null;
451
+ })();
452
+
453
+ return h(View, { ref: rootRef, style: s.root, collapsable: false },
454
+ children,
455
+ h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
456
+ }
457
+
458
+ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
459
+ const scroll = useRef(null);
460
+ const [open, setOpen] = useState(null); // the draft whose detail is shown
461
+ const { flying, waiting, sent } = workView({ inFlight: work, chat });
462
+ const offer = offerOf(chat);
463
+ const banner = demoBanner(chat);
464
+ const msgs = chat?.messages || [];
465
+ const hasWork = flying.length || waiting.length || sent.length || banner;
466
+ const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
467
+ return h(View, { style: [s.panel, { paddingTop: insets.top }], testID: 'ssb-panel' },
468
+ h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1 } },
469
+ h(View, { style: s.header },
470
+ h(View, { style: s.headLogoRing }, h(Image, { source: LOGO, style: s.headLogo, accessibilityLabel: 'SSB' })),
471
+ h(View, { style: { flex: 1 } },
472
+ h(Text, { style: s.title }, 'Tell SSB'),
473
+ h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
474
+ chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
475
+ h(Pressable, { onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
476
+ talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
477
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
478
+ h(Text, { style: s.talkText }, talkLine(talk)),
479
+ h(Pressable, { onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.talkBtn }, h(Text, { style: s.talkBtnText }, talk.muted ? 'Unmute' : 'Mute')),
480
+ h(Pressable, { onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.talkBtn, s.talkEnd] }, h(Text, { style: [s.talkBtnText, { color: '#fff' }] }, 'End'))) : null,
481
+ // THE WORK, at the top: in flight, then drafts to start, then what this chat started.
482
+ hasWork ? h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
483
+ banner ? h(Pressable, {
484
+ testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
485
+ accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
486
+ }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
487
+ flying.length ? h(Text, { style: s.section, testID: 'ssb-section-flight' }, 'In flight') : null,
488
+ ...flying.map((x) => h(View, { key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}` },
489
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, x.title),
490
+ showMe(x)
491
+ ? h(Pressable, { onPress: () => onOpenLink(showMe(x)), accessibilityRole: 'button', accessibilityLabel: `Show me: ${x.title}`, style: [s.primary, s.small] }, h(Text, { style: s.primaryText }, '▶ Show me'))
492
+ : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
493
+ waiting.length ? h(View, { style: s.sectionRow },
494
+ h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
495
+ waiting.length > 1 ? h(Pressable, { testID: 'ssb-start-all', onPress: onStartAll, disabled: busy, accessibilityRole: 'button', accessibilityLabel: 'Start all', style: [s.primary, s.small, busy && s.disabled] }, h(Text, { style: s.primaryText }, `Start all (${waiting.length})`)) : null) : null,
496
+ ...waiting.map((p) => h(View, { key: `d${p.id}`, style: s.draftRow, testID: `ssb-draft-${p.id}` },
497
+ h(View, { style: s.workLine },
498
+ h(Pressable, { onPress: () => setOpen(open === p.id ? null : p.id), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: p.title },
499
+ h(Text, { style: s.workTitle, numberOfLines: open === p.id ? undefined : 2 }, p.title)),
500
+ h(Pressable, { testID: `ssb-draft-start-${p.id}`, onPress: () => onDecide(p, 'raise'), disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Start: ${p.title}`, style: [s.primary, s.small, busy && s.disabled] }, h(Text, { style: s.primaryText }, 'Start'))),
501
+ open === p.id ? h(View, null,
502
+ p.detail ? h(Text, { style: s.draftBody }, p.detail) : null,
503
+ h(Pressable, { onPress: () => onDecide(p, 'dismiss'), disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Dismiss: ${p.title}`, style: [s.quiet, { alignSelf: 'flex-start' }] }, h(Text, { style: s.quietText }, 'Dismiss'))) : null)),
504
+ ...sent.map((p) => h(View, { key: `s${p.id}`, style: s.workRow, testID: `ssb-sent-${p.id}` },
505
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, `✓ ${p.title}`),
506
+ p.jobUrl ? h(Pressable, { onPress: () => Linking.openURL(p.jobUrl).catch(() => {}), accessibilityRole: 'link' }, h(Text, { style: s.link }, 'Started →')) : h(Text, { style: s.state }, 'Started')))) : null,
507
+ // THE CONVERSATION, below.
508
+ h(ScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingVertical: 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
509
+ msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Draw to scribble on it.'),
510
+ ...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
511
+ m.image ? h(Image, { source: { uri: m.image }, style: s.thumb, resizeMode: 'contain' }) : null,
512
+ h(Text, { style: m.role === 'user' ? s.mineText : s.theirsText }, m.text),
513
+ m.id === offer ? h(Pressable, { onPress: onStartAll, disabled: busy, style: [s.primary, { marginTop: 8, alignSelf: 'flex-start' }] }, h(Text, { style: s.primaryText }, 'Confirm and start work')) : null)),
514
+ chat?.thinking ? h(View, { style: [s.bubble, s.theirs, s.row] }, h(ActivityIndicator, { size: 'small', color: MUTED }), h(Text, { style: [s.theirsText, { marginLeft: 8 }] }, 'SSB is thinking…')) : null),
515
+ error ? h(Text, { style: s.error }, error) : null,
516
+ h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }] },
517
+ h(TextInput, {
518
+ testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
519
+ placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
520
+ }),
521
+ h(View, { style: s.row },
522
+ h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
523
+ canTalk && !talk ? h(Pressable, { testID: 'ssb-talk', onPress: onTalk, accessibilityRole: 'button', accessibilityLabel: 'Talk to SSB', style: s.chip }, h(Text, { style: s.chipText }, '🎙 Talk')) : null,
524
+ h(View, { style: { flex: 1 } }),
525
+ h(Pressable, { testID: 'ssb-send', onPress: onSend, disabled: !draft.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.primary, (!draft.trim() || busy) && s.disabled] },
526
+ busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
527
+ }
528
+
529
+ const shadow = { shadowColor: '#000', shadowOpacity: 0.45, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 12 };
530
+ const glow = (c, r = 12) => ({ shadowColor: c, shadowOpacity: 0.55, shadowRadius: r, shadowOffset: { width: 0, height: 0 } });
531
+ const s = StyleSheet.create({
532
+ root: { flex: 1 },
533
+ pressed: { opacity: 0.7 },
534
+ disabled: { opacity: 0.45 },
535
+ fab: { position: 'absolute', width: FAB, height: FAB, borderRadius: FAB / 2, alignItems: 'center', justifyContent: 'center', ...shadow },
536
+ fabLogo: { width: FAB, height: FAB, borderRadius: FAB / 2 },
537
+ fabTalking: { borderWidth: 3, borderColor: GREEN, ...glow(GREEN, 14) },
538
+ dot: { position: 'absolute', top: 2, right: 2, width: 12, height: 12, borderRadius: 6, backgroundColor: RED, borderWidth: 2, borderColor: BG, ...glow(RED, 6) },
539
+ // Drawing on the live screen.
540
+ ink: { position: 'absolute', height: INK_W, borderRadius: INK_W / 2, backgroundColor: RED },
541
+ box: { position: 'absolute', borderWidth: 3, borderColor: RED, borderRadius: 6, backgroundColor: 'rgba(255,77,90,0.08)' },
542
+ hint: { position: 'absolute', alignSelf: 'center', flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: 12, borderRadius: 20, backgroundColor: INK, borderWidth: 1, borderColor: LINE, ...shadow },
543
+ toolBtn: { paddingVertical: 5, paddingHorizontal: 10, borderRadius: 12, marginRight: 4 },
544
+ toolOn: { backgroundColor: RED, ...glow(RED, 8) },
545
+ toolText: { color: TEXT, fontSize: 14, fontWeight: '600' },
546
+ toolTextOn: { color: '#fff' },
547
+ hintSep: { width: 1, height: 18, backgroundColor: LINE, marginHorizontal: 6 },
548
+ hintLink: { color: '#8fb3ff', fontSize: 14, fontWeight: '600' },
549
+ hintBtn: { paddingHorizontal: 6, paddingVertical: 2 },
550
+ field: { position: 'absolute', left: 12, right: 12, height: 56, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 6, borderRadius: 16, backgroundColor: SURFACE, borderWidth: 1, borderColor: LINE, ...shadow },
551
+ fieldInput: { flex: 1, fontSize: 16, color: TEXT },
552
+ sendRound: { width: 40, height: 40, borderRadius: 20, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', marginLeft: 4, ...glow(BLUE, 10) },
553
+ sendRoundText: { color: '#fff', fontSize: 20, fontWeight: '700' },
554
+ // The panel. Spelled out: StyleSheet.absoluteFillObject is gone in React
555
+ // Native 0.86, and spreading `undefined` left the panel with no size at all
556
+ // (the harness caught it).
557
+ panel: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: BG },
558
+ header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingTop: 12, paddingHorizontal: 16, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: LINE },
559
+ headLogoRing: { width: 40, height: 40, borderRadius: 20, ...glow(BLUE, 10) },
560
+ headLogo: { width: 40, height: 40, borderRadius: 20 },
561
+ title: { fontSize: 17, fontWeight: '800', color: TEXT, letterSpacing: 0.3 },
562
+ sub: { fontSize: 12, color: MUTED, marginTop: 2 },
563
+ talkBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, backgroundColor: 'rgba(47,210,122,0.08)', borderBottomWidth: 1, borderBottomColor: LINE },
564
+ talkDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: GREEN, marginRight: 8, ...glow(GREEN, 6) },
565
+ talkText: { flex: 1, fontSize: 14, fontWeight: '600', color: TEXT },
566
+ talkBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: LINE, backgroundColor: RAISED, marginLeft: 6 },
567
+ talkEnd: { backgroundColor: RED, borderColor: RED },
568
+ talkBtnText: { fontSize: 13, fontWeight: '700', color: TEXT },
569
+ // The work, at the top.
570
+ work: { flexGrow: 0, borderBottomWidth: 1, borderBottomColor: LINE, backgroundColor: SURFACE },
571
+ section: { fontSize: 11, fontWeight: '800', letterSpacing: 1.4, textTransform: 'uppercase', color: MUTED, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 6 },
572
+ sectionRow: { flexDirection: 'row', alignItems: 'center', paddingRight: 16 },
573
+ workRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 11, gap: 10, borderTopWidth: 1, borderTopColor: LINE },
574
+ draftRow: { paddingHorizontal: 16, paddingVertical: 11, borderTopWidth: 1, borderTopColor: LINE, borderLeftWidth: 3, borderLeftColor: YELLOW, backgroundColor: 'rgba(246,197,68,0.06)' },
575
+ workLine: { flexDirection: 'row', alignItems: 'center', gap: 10 },
576
+ workTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: TEXT },
577
+ state: { fontSize: 12, fontWeight: '800', letterSpacing: 0.3, color: MUTED, paddingVertical: 4, paddingHorizontal: 9, borderRadius: 9, backgroundColor: RAISED, borderWidth: 1, borderColor: LINE, overflow: 'hidden' },
578
+ state_ready: { color: BG, backgroundColor: GREEN, borderColor: GREEN },
579
+ state_building: { color: '#9cc0ff', backgroundColor: 'rgba(61,123,255,0.14)', borderColor: 'rgba(61,123,255,0.5)' },
580
+ state_checking: { color: '#9cc0ff', backgroundColor: 'rgba(61,123,255,0.14)', borderColor: 'rgba(61,123,255,0.5)' },
581
+ state_blocked: { color: '#fff', backgroundColor: RED, borderColor: RED },
582
+ demo: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 11, backgroundColor: 'rgba(47,210,122,0.08)' },
583
+ demoReady: { backgroundColor: 'rgba(47,210,122,0.16)' },
584
+ demoText: { flex: 1, fontSize: 13, fontWeight: '600', color: TEXT },
585
+ demoGo: { color: GREEN, fontSize: 14, fontWeight: '800', marginLeft: 8 },
586
+ draftBody: { fontSize: 13, color: MUTED, marginTop: 6, lineHeight: 19 },
587
+ link: { color: '#8fb3ff', fontSize: 13, fontWeight: '600' },
588
+ // The conversation takes what the work and the composer leave.
589
+ thread: { flex: 1, paddingHorizontal: 12 },
590
+ empty: { color: MUTED, fontSize: 14, textAlign: 'center', paddingVertical: 24, paddingHorizontal: 20, lineHeight: 20 },
591
+ bubble: { maxWidth: '86%', borderRadius: 16, paddingVertical: 9, paddingHorizontal: 13, marginVertical: 4 },
592
+ mine: { alignSelf: 'flex-end', backgroundColor: BLUE, borderBottomRightRadius: 4 },
593
+ theirs: { alignSelf: 'flex-start', backgroundColor: RAISED, borderWidth: 1, borderColor: LINE, borderBottomLeftRadius: 4 },
594
+ mineText: { color: '#fff', fontSize: 15, lineHeight: 21 },
595
+ theirsText: { color: TEXT, fontSize: 15, lineHeight: 21 },
596
+ thumb: { width: 140, height: 220, borderRadius: 8, marginBottom: 6, backgroundColor: '#0006' },
597
+ error: { color: RED, fontSize: 13, paddingHorizontal: 16, paddingTop: 6 },
598
+ composer: { paddingHorizontal: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: LINE, backgroundColor: SURFACE },
599
+ input: { minHeight: 44, maxHeight: 120, fontSize: 16, color: TEXT, backgroundColor: BG, borderWidth: 1, borderColor: LINE, borderRadius: 14, paddingHorizontal: 13, paddingTop: 11, paddingBottom: 11 },
600
+ row: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 8 },
601
+ chip: { flexDirection: 'row', alignItems: 'center', paddingVertical: 7, paddingHorizontal: 11, borderRadius: 12, borderWidth: 1, borderColor: LINE, backgroundColor: RAISED },
602
+ chipText: { fontSize: 13, color: TEXT, fontWeight: '600' },
603
+ primary: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 12, backgroundColor: BLUE, minWidth: 64, alignItems: 'center', ...glow(BLUE, 10) },
604
+ primaryText: { color: '#fff', fontSize: 14, fontWeight: '800', letterSpacing: 0.2 },
605
+ small: { paddingVertical: 6, paddingHorizontal: 12, minWidth: 0 },
606
+ quiet: { paddingVertical: 6, paddingHorizontal: 10 },
607
+ quietText: { color: MUTED, fontSize: 14, fontWeight: '600' },
608
+ iconBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: 18, backgroundColor: RAISED },
609
+ iconText: { color: TEXT, fontSize: 16 },
610
+ // Voice mode: a pill over the app.
611
+ pill: { position: 'absolute', left: 12, right: 12, flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingLeft: 14, paddingRight: 6, borderRadius: 24, backgroundColor: INK, borderWidth: 1, borderColor: 'rgba(47,210,122,0.45)', ...shadow },
612
+ pillText: { flex: 1, color: TEXT, fontSize: 14, fontWeight: '600' },
613
+ pillBtn: { paddingVertical: 6, paddingHorizontal: 10, borderRadius: 14, backgroundColor: RAISED, marginLeft: 6 },
614
+ pillBtnText: { color: TEXT, fontSize: 13, fontWeight: '700' },
615
+ });
package/src/core.js ADDED
@@ -0,0 +1,310 @@
1
+ // The overlay's pure half: no React Native here, so node --test runs it.
2
+ // The server side is functions/in-app.mjs; the conversation is Tell SSB's
3
+ // (functions/annotate.mjs), so the shapes below are the extension's.
4
+
5
+ export const DEFAULT_API = 'https://simonsays.build';
6
+ export const HOST = 5;
7
+ export const TEXT = 6;
8
+ const PIN_SLOP = 12; // a drag shorter than this is a tap: a pin, not a box
9
+
10
+ /** The app's SSB settings, or null when it is not set up (the overlay renders nothing). */
11
+ export function overlayConfig({ appId, backend, api } = {}) {
12
+ if (!appId || !backend) return null;
13
+ return { appId: String(appId), backend: String(backend).replace(/\/+$/, ''), api: String(api || DEFAULT_API).replace(/\/+$/, '') };
14
+ }
15
+
16
+ /**
17
+ * The in-app API (functions/index.mjs /api/in-app/*). Every call carries the
18
+ * app's CURRENT session token, asked for each time: the app refreshes it.
19
+ */
20
+ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
21
+ const call = async (path, init = {}) => {
22
+ const token = await getToken();
23
+ if (!token) { const e = new Error('signed out'); e.status = 401; throw e; }
24
+ const res = await fetchImpl(`${config.api}/api/in-app${path}`, {
25
+ ...init,
26
+ headers: {
27
+ 'content-type': 'application/json',
28
+ authorization: `Bearer ${token}`,
29
+ 'x-ssb-app': config.appId,
30
+ 'x-ssb-backend': config.backend,
31
+ ...(init.headers || {}),
32
+ },
33
+ });
34
+ const json = await res.json().catch(() => ({}));
35
+ if (!res.ok) { const e = new Error(json.error || `SSB answered ${res.status}`); e.status = res.status; throw e; }
36
+ return json;
37
+ };
38
+ const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) });
39
+ return {
40
+ me: () => call('/me'),
41
+ send: (body) => post('/annotations', body),
42
+ chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
43
+ decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
44
+ pushToken: (token, platform) => post('/push-token', { token, platform }),
45
+ voice: (body) => post('/voice', body),
46
+ work: () => call('/work'),
47
+ };
48
+ }
49
+
50
+ // ---- the pencil: scribbles on the live screen --------------------------------
51
+ const MIN_STEP = 3; // a point closer than this to the last one adds nothing
52
+
53
+ /** A stroke with `p` added, unless it is too close to the last point to matter. */
54
+ export function addPoint(stroke, p) {
55
+ const last = stroke[stroke.length - 1];
56
+ if (last && Math.hypot(p.x - last.x, p.y - last.y) < MIN_STEP) return stroke;
57
+ return [...stroke, { x: p.x, y: p.y }];
58
+ }
59
+
60
+ /**
61
+ * A stroke as straight segments a View can draw: each is a bar `width` long,
62
+ * centred on its midpoint and turned by `angle` (radians). A single point is a dot.
63
+ */
64
+ export function segmentsOf(stroke, thickness = 4) {
65
+ if (stroke.length === 1) return [{ left: stroke[0].x - thickness / 2, top: stroke[0].y - thickness / 2, width: thickness, angle: 0 }];
66
+ const out = [];
67
+ for (let i = 1; i < stroke.length; i++) {
68
+ const a = stroke[i - 1], b = stroke[i];
69
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
70
+ const width = len + thickness; // overlap the joints so a curve has no gaps
71
+ out.push({ left: (a.x + b.x) / 2 - width / 2, top: (a.y + b.y) / 2 - thickness / 2, width, angle: Math.atan2(b.y - a.y, b.x - a.x) });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ // A drawing is a list of items, each a pencil stroke or a box:
77
+ // { kind: 'stroke', points: [{x, y}] } | { kind: 'box', x0, y0, x1, y1 } (screen points)
78
+ const MIN_BOX = 8; // a box smaller than this was a slip, not a box
79
+
80
+ /** A box item from its drag, or null when it is too small to mean anything. */
81
+ export function boxItem(d) {
82
+ if (Math.abs(d.x1 - d.x0) < MIN_BOX || Math.abs(d.y1 - d.y0) < MIN_BOX) return null;
83
+ return { kind: 'box', x0: d.x0, y0: d.y0, x1: d.x1, y1: d.y1 };
84
+ }
85
+
86
+ /** One item's box, in points. */
87
+ export function itemRect(it) {
88
+ if (it.kind === 'box') {
89
+ const left = Math.min(it.x0, it.x1), top = Math.min(it.y0, it.y1);
90
+ return { left, top, width: Math.abs(it.x1 - it.x0), height: Math.abs(it.y1 - it.y0) };
91
+ }
92
+ const xs = it.points.map((p) => p.x), ys = it.points.map((p) => p.y);
93
+ const left = Math.min(...xs), top = Math.min(...ys);
94
+ return { left, top, width: Math.max(...xs) - left, height: Math.max(...ys) - top };
95
+ }
96
+
97
+ /** The box round everything drawn, or null with nothing drawn. */
98
+ export function drawingBox(items) {
99
+ const rs = items.filter((it) => it.kind === 'box' || it.points?.length).map(itemRect);
100
+ if (!rs.length) return null;
101
+ const left = Math.min(...rs.map((r) => r.left)), top = Math.min(...rs.map((r) => r.top));
102
+ return { left, top, width: Math.max(...rs.map((r) => r.left + r.width)) - left, height: Math.max(...rs.map((r) => r.top + r.height)) - top };
103
+ }
104
+
105
+ /**
106
+ * What was drawn, as the ONE mark a send carries (annotate.mjs cleanMarks):
107
+ * a `box` when only boxes were drawn, else a `pen`, over the drawing's box,
108
+ * in fractions of the screen.
109
+ */
110
+ export function drawingMark(items, screen) {
111
+ const r = drawingBox(items);
112
+ if (!r) return null;
113
+ const f = (v, d) => Math.min(1, Math.max(0, v / d));
114
+ return { kind: items.every((it) => it.kind === 'box') ? 'box' : 'pen',
115
+ x: f(r.left, screen.width), y: f(r.top, screen.height), w: f(r.width, screen.width), h: f(r.height, screen.height) };
116
+ }
117
+
118
+ /** The point the drawing is about (what the app has under it is named): the middle of its box. */
119
+ export const drawingPoint = (items) => {
120
+ const r = drawingBox(items);
121
+ return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null;
122
+ };
123
+
124
+ /**
125
+ * Where the note field goes: just above the keyboard when that is clear of
126
+ * the drawing, else under the status bar. `keyboardTop` is the
127
+ * keyboard's top edge in screen points (the screen height when it is down).
128
+ */
129
+ export function fieldTop({ rect, screen, keyboardTop, insets, fieldH = 56 }) {
130
+ const above = Math.min(screen.height - insets.bottom, keyboardTop) - fieldH - 12;
131
+ if (above >= rect.top + rect.height + 8) return above;
132
+ return insets.top + 8;
133
+ }
134
+
135
+ // ---- the element under a mark: React's own tree ---------------------------
136
+ const lower = (s) => String(s ?? '').trim();
137
+
138
+ function* walk(root) {
139
+ const stack = root ? [root] : [];
140
+ while (stack.length) {
141
+ const f = stack.pop();
142
+ yield f;
143
+ const kids = [];
144
+ for (let c = f.child; c; c = c.sibling) kids.push(c);
145
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
146
+ }
147
+ }
148
+
149
+ const textUnder = (f, max = 120) => {
150
+ let out = '';
151
+ for (const n of walk(f.child)) {
152
+ if (n.tag === TEXT) out += (typeof n.memoizedProps === 'string' ? n.memoizedProps : String(n.memoizedProps?.text ?? '')) + ' ';
153
+ if (out.length > max) break;
154
+ }
155
+ return out.replace(/\s+/g, ' ').trim().slice(0, max);
156
+ };
157
+
158
+ /** The host fibers worth naming: ones with a testID, a label, a role, or a press. Our own `skip` subtree is left out. */
159
+ export function namedHosts(root, skip = null, max = 400) {
160
+ const out = [];
161
+ const stack = root ? [root] : [];
162
+ while (stack.length && out.length < max) {
163
+ const f = stack.pop();
164
+ if (skip && f === skip) continue;
165
+ const p = f.memoizedProps || {};
166
+ if (f.tag === HOST && (p.testID || p.accessibilityLabel || p['aria-label'] || p.accessibilityRole || p.role || typeof p.onPress === 'function' || typeof p.onChangeText === 'function')) out.push(f);
167
+ for (let c = f.child; c; c = c.sibling) stack.push(c);
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /** What the page says about an element, the shape the extension sends (annotate.mjs cleanMarks `element`). */
173
+ export function describeHost(f) {
174
+ const p = f?.memoizedProps || {};
175
+ const role = p.accessibilityRole || p.role || (typeof p.onChangeText === 'function' ? 'textbox' : typeof p.onPress === 'function' ? 'button' : '');
176
+ const el = {
177
+ tag: typeof f?.type === 'string' ? f.type : '',
178
+ role: lower(role),
179
+ name: lower(p.accessibilityLabel || p['aria-label']),
180
+ text: textUnder(f),
181
+ css: p.testID ? `testID=${p.testID}` : '',
182
+ };
183
+ return Object.fromEntries(Object.entries(el).filter(([, v]) => v));
184
+ }
185
+
186
+ /** Of measured candidates [{rect, fiber}], the smallest that holds the point. */
187
+ export function smallestAt(cands, pt) {
188
+ let best = null;
189
+ for (const c of cands) {
190
+ const r = c.rect;
191
+ if (!r || pt.x < r.left || pt.x > r.left + r.width || pt.y < r.top || pt.y > r.top + r.height) continue;
192
+ if (!best || r.width * r.height < best.rect.width * best.rect.height) best = c;
193
+ }
194
+ return best;
195
+ }
196
+
197
+ // ---- the conversation -------------------------------------------------------
198
+ /** The drafts SSB has proposed: waiting, and the ones already started. */
199
+ export function draftsOf(chat) {
200
+ const ps = chat?.proposals || [];
201
+ return { waiting: ps.filter((p) => p.status === 'proposed'), sent: ps.filter((p) => p.status === 'raised') };
202
+ }
203
+
204
+ /** The drafts bar's words: "2 drafts ready · 1 started", or '' when there is nothing. */
205
+ export function draftsLine({ waiting, sent }) {
206
+ const parts = [];
207
+ if (waiting.length) parts.push(`${waiting.length} draft${waiting.length === 1 ? '' : 's'} ready`);
208
+ if (sent.length) parts.push(`${sent.length} started`);
209
+ return parts.join(' · ');
210
+ }
211
+
212
+ /** The latest SSB reply carrying "Confirm and start work", while a draft waits. */
213
+ export function offerOf(chat) {
214
+ if (!draftsOf(chat).waiting.length) return null;
215
+ const last = [...(chat?.messages || [])].reverse().find((m) => m.role !== 'user');
216
+ return last?.offerStart ? last.id : null;
217
+ }
218
+
219
+ /** How often to ask for the thread: quick while it is open or SSB is writing, never otherwise. */
220
+ export function pollEvery({ open, thinking }) {
221
+ if (open) return thinking ? 1500 : 4000;
222
+ return thinking ? 5000 : 0;
223
+ }
224
+
225
+ /** Did SSB say something new since `seenId`? (the button's dot while the sheet is closed). */
226
+ export function newReply(chat, seenId) {
227
+ const last = [...(chat?.messages || [])].reverse().find((m) => m.role !== 'user' && !m.streaming);
228
+ return !!last && last.id !== seenId;
229
+ }
230
+
231
+ /** The body of a send (POST /api/in-app/annotations). */
232
+ export function sendBody({ chatId, text, route, platform, screen, mark, element, screenshot }) {
233
+ return {
234
+ ...(chatId ? { chatId } : {}),
235
+ text: String(text || '').trim(),
236
+ url: String(route || '/'),
237
+ page: { title: `In the app (${platform || 'phone'}): ${route || '/'}`, viewport: { w: Math.round(screen.width), h: Math.round(screen.height), scrollY: 0 } },
238
+ marks: mark ? [{ ...mark, note: String(text || '').trim(), ...(element && Object.keys(element).length ? { element } : {}) }] : [],
239
+ ...(screenshot ? { screenshot } : {}),
240
+ };
241
+ }
242
+
243
+ // ---- "it's done, tap to see it" (functions/in-app-notify.mjs) ----------------
244
+ /** The link an SSB notification opens (the dev-client preview with the demo), or null for any other notification. */
245
+ export function ssbLinkOf(response) {
246
+ const data = response?.notification?.request?.content?.data;
247
+ return data?.kind === 'ssb-demo' && typeof data.url === 'string' && data.url ? data.url : null;
248
+ }
249
+
250
+ /** What the sheet says about the conversation's phone demo, or null. */
251
+ export function demoBanner(chat) {
252
+ const d = chat?.demo;
253
+ if (!d) return null;
254
+ const what = d.title || 'your change';
255
+ if (d.status === 'ready' && d.url) return { text: `Ready to see: ${what}`, url: d.url };
256
+ if (d.status === 'preparing') return { text: `Done: ${what}. Preparing a demo you can open in the app…` };
257
+ if (d.status === 'failed') return { text: `Done: ${what}. The demo couldn't be prepared${d.error ? `: ${d.error}` : '.'}` };
258
+ return null;
259
+ }
260
+
261
+ // ---- the button: wherever it is put --------------------------------------------
262
+ export const FAB = 52;
263
+ const TAP_SLOP = 8; // a press that moved less than this is a tap, not a drag
264
+
265
+ /** Where the button sits by default: bottom-right, clear of the insets. */
266
+ export const fabHome = (screen, insets) => ({ x: screen.width - FAB - 16, y: screen.height - FAB - 16 - insets.bottom });
267
+
268
+ /** A position kept on screen: the whole button visible, off the status bar. */
269
+ export function clampFab(pos, screen, insets) {
270
+ return {
271
+ x: Math.min(screen.width - FAB - 4, Math.max(4, pos.x)),
272
+ y: Math.min(screen.height - FAB - 4, Math.max(insets.top, pos.y)),
273
+ };
274
+ }
275
+
276
+ /** Did the finger travel far enough to be a drag? */
277
+ export const isDrag = (dx, dy) => Math.hypot(dx, dy) >= TAP_SLOP;
278
+
279
+ // ---- 🎙 Talk (functions/voice-chat.mjs, voice-agent/src/chat.mjs) --------------
280
+ export const SNAPSHOT_TOPIC = 'ssb.guide.snapshot'; // the agent's (voice-agent/src/guide.mjs TOPIC.snapshot)
281
+
282
+ /** What the agent is sent about the screen: the route, the picture when there is one, and whether this is the opening look. */
283
+ export const snapshotOf = ({ route, screenshot, first = false }) => JSON.stringify({ route: route || '/', ...(screenshot ? { screenshot } : {}), ...(first ? { first: true } : {}) });
284
+
285
+ /** The talk bar's words. */
286
+ export function talkLine(talk) {
287
+ if (!talk) return '';
288
+ if (talk.status === 'connecting') return 'Connecting to SSB…';
289
+ if (talk.status === 'waiting') return 'Waiting for SSB to join…';
290
+ return talk.muted ? 'Talking with SSB · muted' : 'Talking with SSB';
291
+ }
292
+
293
+ // ---- the panel's top: the work (2026-10-20, the product owner: "the web
294
+ // extension has jobs and in-progress things at the top, with chat down the
295
+ // bottom … maybe we can move to that pattern") ------------------------------
296
+ /** The extension's words for a job in flight (extension/overlay.js inFlightHtml). */
297
+ export const STATE_WORD = Object.freeze({ ready: 'Ready', queued: 'Queued', building: 'Building', checking: 'Checking', blocked: 'Blocked' });
298
+
299
+ /**
300
+ * What the top of the panel shows: the app's work in flight (GET
301
+ * /api/in-app/work, functions/sessions.mjs inFlightOf — whatever chat it came
302
+ * from), this chat's drafts waiting to start, and what this chat started that
303
+ * is not in flight any more (the row says so, with its job).
304
+ */
305
+ export function workView({ inFlight = [], chat = null } = {}) {
306
+ const flying = Array.isArray(inFlight) ? inFlight : [];
307
+ const flyingIds = new Set(flying.map((x) => x.id));
308
+ const { waiting, sent } = draftsOf(chat);
309
+ return { flying, waiting, sent: sent.filter((p) => !p.jobId || !flyingIds.has(p.jobId)) };
310
+ }