@simon-says-build/ssb-overlay 0.1.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/SsbHost.js CHANGED
@@ -3,12 +3,18 @@
3
3
  // Renders its children and nothing else until SSB says the signed-in login
4
4
  // may use it (GET /api/in-app/me: a member of the app's organization, an SSB
5
5
  // operator, or a cast login on the app's allowed list, by default the
6
- // super-admin). Then a small SSB button floats bottom-right. Tapping it takes
7
- // a picture of the screen as it is and opens a sheet: the conversation with
8
- // SSB about this app (Tell SSB's, functions/annotate.mjs), the picture to
9
- // attach, and **Mark** — tap or drag on the live screen, type beside the
10
- // mark, send: the mark is captured ON the screen, with the field and our
11
- // chrome hidden. SSB replies on the thread and proposes jobs; the drafts bar
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
12
18
  // starts them. The same conversation is on the console.
13
19
  //
14
20
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
@@ -18,13 +24,23 @@ import {
18
24
  ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
19
25
  } from 'react-native';
20
26
  import {
21
- demoBanner, describeHost, draftsLine, draftsOf, fieldTop, inAppApi, markOf, markPoint, markRect, namedHosts,
22
- newReply, offerOf, overlayConfig, pollEvery, sendBody, smallestAt, ssbLinkOf,
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,
23
29
  } from './core.js';
24
30
 
25
31
  const h = React.createElement;
26
- const BLUE = '#2f5bea', INK = '#161a23', MUTED = '#6b7280', LINE = '#eef0f3', RED = '#e5484d';
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
27
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');
28
44
 
29
45
  // React keeps the fiber on the host instance (Fabric: __internalInstanceHandle).
30
46
  const fiberOf = (instance) => instance?.__internalInstanceHandle ?? instance?._internalInstanceHandle ?? null;
@@ -49,11 +65,11 @@ const frames = (n = 2) => new Promise((resolve) => {
49
65
  step(n);
50
66
  });
51
67
 
52
- export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, insets, children }) {
68
+ export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, children }) {
53
69
  const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
54
70
  useSsbLinks(notifications);
55
71
  if (!config || typeof getToken !== 'function') return children ?? null;
56
- return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
72
+ return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
57
73
  }
58
74
 
59
75
  // A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
@@ -69,30 +85,43 @@ function useSsbLinks(notifications) {
69
85
  }, [notifications]);
70
86
  }
71
87
 
72
- function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, insets, children }) {
88
+ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, children }) {
73
89
  const screen = useWindowDimensions();
74
90
  const tokenRef = useRef(getToken);
75
91
  tokenRef.current = getToken;
76
92
  const ssb = useMemo(() => inAppApi(config, () => tokenRef.current()), [config]);
77
93
  const rootRef = useRef(null);
94
+ const screenRef = useRef(screen);
95
+ screenRef.current = screen;
96
+ const insetsRef = useRef(insets);
97
+ insetsRef.current = insets;
78
98
  const chromeRef = useRef(null);
79
99
  const storeKey = `ssb-overlay:chat:${config.appId}`;
80
100
 
81
101
  const [gate, setGate] = useState(null); // {show, person, app, canJob} once SSB has answered
82
- const [mode, setMode] = useState('idle'); // idle | sheet | marking | noting | capturing
102
+ const [mode, setMode] = useState('idle'); // idle | sheet | drawing | capturing
83
103
  const [shot, setShot] = useState(null); // the screen as it was when the button was tapped
84
- const [attach, setAttach] = useState(true);
85
- const [mark, setMark] = useState(null);
86
- const [element, setElement] = useState(null);
87
- const [drag, setDrag] = useState(null);
88
- const dragRef = useRef(null);
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;
89
118
  const [note, setNote] = useState('');
90
119
  const [draft, setDraft] = useState('');
91
120
  const [chatId, setChatId] = useState(null);
92
121
  const [chat, setChat] = useState(null);
93
122
  const [busy, setBusy] = useState(false);
94
123
  const [error, setError] = useState(null);
95
- const [showDrafts, setShowDrafts] = useState(false);
124
+ const [work, setWork] = useState([]); // the app's work in flight (GET /api/in-app/work)
96
125
  const [seen, setSeen] = useState(null);
97
126
  const [kbTop, setKbTop] = useState(screen.height);
98
127
 
@@ -110,6 +139,10 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
110
139
  return () => { live = false; };
111
140
  }, [ssb, sessionKey]);
112
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]);
113
146
  useEffect(() => {
114
147
  if (!storage?.getItem) return;
115
148
  Promise.resolve(storage.getItem(storeKey)).then((id) => { if (id) setChatId(id); }).catch(() => {});
@@ -125,16 +158,16 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
125
158
  return () => { show.remove(); hide.remove(); };
126
159
  }, [screen.height]);
127
160
 
128
- // A new screen makes a waiting mark and the picture stale (the extension's
129
- // rule): the mark is dropped and its words move to the composer.
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.
130
163
  const lastRoute = useRef(route);
131
164
  useEffect(() => {
132
165
  if (lastRoute.current === route) return;
133
166
  lastRoute.current = route;
134
167
  setShot(null);
135
- if (mode === 'marking' || mode === 'noting') {
168
+ if (mode === 'drawing') {
136
169
  if (note.trim()) setDraft((d) => (d ? `${d}\n${note}` : note));
137
- setMark(null); setElement(null); setNote(''); setMode('sheet');
170
+ setItems([]); setNote(''); setMode('sheet');
138
171
  }
139
172
  }, [route]);
140
173
 
@@ -147,19 +180,27 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
147
180
 
148
181
  const open = mode !== 'idle';
149
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]);
150
191
  useEffect(() => {
151
192
  if (!gate || !chatId) return undefined;
152
- const every = pollEvery({ open, thinking: !!chat?.thinking || chat?.demo?.status === 'preparing' });
193
+ const every = talk ? 2000 : pollEvery({ open, thinking: !!chat?.thinking || chat?.demo?.status === 'preparing' });
153
194
  if (!every) return undefined;
154
195
  const t = setInterval(load, every);
155
196
  return () => clearInterval(t);
156
- }, [gate, chatId, open, chat?.thinking, load]);
197
+ }, [gate, chatId, open, chat?.thinking, load, !!talk]);
157
198
 
158
199
  const lastReplyId = (c) => [...(c?.messages || [])].reverse().find((m) => m.role !== 'user' && !m.streaming)?.id ?? null;
159
200
  const [seenDemo, setSeenDemo] = useState(null);
160
201
  const unread = !open && chat && (newReply(chat, seen) || (chat.demo?.status === 'ready' && chat.demo.url !== seenDemo));
161
202
 
162
- // ---- open, mark, send ----------------------------------------------------
203
+ // ---- open, draw, send ----------------------------------------------------
163
204
  const grab = useCallback(async () => {
164
205
  if (!capture) return null;
165
206
  await frames(2);
@@ -170,15 +211,23 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
170
211
  setError(null);
171
212
  setMode('capturing');
172
213
  const pic = await grab();
173
- setShot(pic); setAttach(!!pic);
214
+ setShot(pic);
174
215
  setMode('sheet');
175
216
  }, [grab]);
176
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, fallback) => {
221
+ if (!url) return; Keyboard.dismiss(); setMode('idle');
222
+ Linking.openURL(url).catch(() => (fallback ? Linking.openURL(fallback).catch(() => {}) : null));
223
+ }, []);
224
+ const openRef = useRef(openSheet);
225
+ openRef.current = openSheet;
177
226
  const close = useCallback(() => {
178
227
  Keyboard.dismiss();
179
228
  setSeen(lastReplyId(chat));
180
229
  setSeenDemo(chat?.demo?.url ?? null);
181
- setMark(null); setElement(null); setNote(''); setShowDrafts(false);
230
+ setItems([]); setNote('');
182
231
  setMode('idle');
183
232
  }, [chat]);
184
233
 
@@ -199,101 +248,207 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
199
248
  const sendDraft = useCallback(async () => {
200
249
  const text = draft.trim();
201
250
  if (!text || busy) return;
202
- const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, screenshot: attach ? shot : null }));
203
- if (ok) { setDraft(''); if (attach) setAttach(false); }
204
- }, [draft, busy, deliver, chatId, route, screen, attach, shot]);
205
-
206
- const placed = useCallback(async (g) => {
207
- const m = markOf(g, screen);
208
- setMark(m); setDrag(null); setMode('noting');
209
- // What the app has under the mark, named the way the app names it.
251
+ // Always the screen: the picture taken when the button was tapped.
252
+ const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, screenshot: shot }));
253
+ if (ok) setDraft('');
254
+ }, [draft, busy, deliver, chatId, route, screen, shot]);
255
+
256
+ // What the app has under the scribbles, named the way the app names it.
257
+ const elementUnder = useCallback(async (pt) => {
258
+ if (!pt) return null;
210
259
  try {
211
- const root = fiberOf(rootRef.current);
212
- const skip = fiberOf(chromeRef.current);
213
- const hosts = namedHosts(root, skip);
260
+ const hosts = namedHosts(fiberOf(rootRef.current), fiberOf(chromeRef.current));
214
261
  const cands = await Promise.all(hosts.map(async (fiber) => ({ fiber, rect: await measure(fiber) })));
215
- const hit = smallestAt(cands, markPoint(m, screen));
216
- setElement(hit ? describeHost(hit.fiber) : null);
217
- } catch { setElement(null); }
218
- }, [screen]);
262
+ const hit = smallestAt(cands, pt);
263
+ return hit ? describeHost(hit.fiber) : null;
264
+ } catch { return null; }
265
+ }, []);
219
266
 
220
- const sendMark = useCallback(async () => {
267
+ const sendDrawing = useCallback(async () => {
221
268
  const text = note.trim();
222
- if (!text || busy || !mark) return;
269
+ if (!text || busy || !items.length) return;
223
270
  Keyboard.dismiss();
224
- setMode('capturing'); // only the mark stays on screen
271
+ const element = await elementUnder(drawingPoint(items));
272
+ setMode('capturing'); // only the drawing stays on screen
225
273
  const pic = await grab();
226
274
  setMode('sheet');
227
- const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, mark, element, screenshot: pic }));
228
- if (ok) { setMark(null); setElement(null); setNote(''); setAttach(false); }
275
+ const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, mark: drawingMark(items, screen), element, screenshot: pic || shot }));
276
+ if (ok) { setItems([]); setNote(''); }
229
277
  else setDraft((d) => d || text);
230
- }, [note, busy, mark, grab, deliver, chatId, route, screen, element]);
278
+ }, [note, busy, items, elementUnder, grab, deliver, chatId, route, screen, shot]);
279
+
280
+ // ---- 🎙 Talk ------------------------------------------------------------------
281
+ const endTalk = useCallback(async () => {
282
+ const room = roomRef.current;
283
+ roomRef.current = null;
284
+ setTalk(null);
285
+ try { await room?.disconnect(); } catch { /* gone */ }
286
+ try { await voice?.stopAudio?.(); } catch { /* not ours */ }
287
+ load();
288
+ }, [voice, load]);
289
+
290
+ const startTalk = useCallback(async () => {
291
+ if (!voice?.Room || roomRef.current || talk) return;
292
+ setError(null);
293
+ setTalk({ status: 'connecting', muted: false });
294
+ try {
295
+ const r = await ssb.voice({ chatId, route, platform: Platform.OS, page: { title: `In the app (${Platform.OS}): ${route || '/'}` }, ...(shot ? { screenshot: shot } : {}) });
296
+ if (r.chatId) rememberChat(r.chatId);
297
+ if (!r.configured) { setTalk(null); setError(r.say || 'Voice is not set up yet.'); return; }
298
+ await voice.startAudio?.();
299
+ const room = new voice.Room({ adaptiveStream: true, dynacast: true });
300
+ roomRef.current = room;
301
+ let shown = false;
302
+ const show = () => {
303
+ if (shown || !roomRef.current) return;
304
+ shown = true;
305
+ room.localParticipant.sendText(snapshotOf({ route, screenshot: shot, first: true }), { topic: SNAPSHOT_TOPIC }).catch(() => {});
306
+ setTalk((t) => (t ? { ...t, status: 'live' } : t));
307
+ };
308
+ room.on('participantConnected', show);
309
+ room.on('disconnected', () => { if (roomRef.current === room) endTalk(); });
310
+ await room.connect(r.url, r.token);
311
+ await room.localParticipant.setMicrophoneEnabled(true);
312
+ if (room.remoteParticipants?.size) show(); else setTalk((t) => (t ? { ...t, status: 'waiting' } : t));
313
+ // VOICE MODE shows the app: SSB steps aside to a pill, so they can move
314
+ // around while they talk (and SSB is told each screen they reach).
315
+ Keyboard.dismiss();
316
+ setMode('idle');
317
+ } catch (e) {
318
+ setError(`Could not start talking: ${String(e.message || e).split('\n')[0]}`);
319
+ endTalk();
320
+ }
321
+ }, [voice, talk, ssb, chatId, route, shot, rememberChat, endTalk]);
322
+
323
+ const toggleMute = useCallback(async () => {
324
+ const room = roomRef.current;
325
+ if (!room || !talk) return;
326
+ const muted = !talk.muted;
327
+ try { await room.localParticipant.setMicrophoneEnabled(!muted); setTalk({ ...talk, muted }); } catch { /* left as it was */ }
328
+ }, [talk]);
329
+
330
+ // A new screen while talking: SSB is told where they went.
331
+ useEffect(() => {
332
+ const room = roomRef.current;
333
+ if (room && talk?.status === 'live') room.localParticipant.sendText(snapshotOf({ route }), { topic: SNAPSHOT_TOPIC }).catch(() => {});
334
+ }, [route]);
335
+ // The person leaves (signs out, the gate closes, the host unmounts): the talk ends.
336
+ useEffect(() => () => { const room = roomRef.current; roomRef.current = null; room?.disconnect?.(); voice?.stopAudio?.(); }, []);
337
+ useEffect(() => { if (!gate && roomRef.current) endTalk(); }, [gate]);
231
338
 
232
339
  const decide = useCallback(async (p, action) => {
233
340
  setBusy(true); setError(null);
234
341
  try {
235
342
  await ssb.decide(chatId, p.id, action, action === 'raise' ? { kind: p.kind, title: p.title, detail: p.detail, severity: p.severity } : {});
236
343
  await load();
344
+ loadWork();
237
345
  return true;
238
346
  } catch (e) { setError(`${p.title}: ${e.message}`); return false; }
239
347
  finally { setBusy(false); }
240
- }, [ssb, chatId, load]);
348
+ }, [ssb, chatId, load, loadWork]);
241
349
 
242
350
  const startAll = useCallback(async () => {
243
351
  for (const p of draftsOf(chat).waiting) if (!(await decide(p, 'raise'))) break;
244
352
  }, [chat, decide]);
245
353
 
354
+ // The pencil and the box. The item lives in a ref (a move must not wait on a render); state only draws it.
355
+ const finish = () => {
356
+ const it = liveRef.current; liveRef.current = null; setLive(null);
357
+ const done = it?.kind === 'box' ? boxItem(it) : it?.points?.length ? it : null;
358
+ if (done) setItems((all) => [...all, done]);
359
+ };
246
360
  const pan = useMemo(() => PanResponder.create({
247
361
  onStartShouldSetPanResponder: () => true,
248
362
  onMoveShouldSetPanResponder: () => true,
249
- // The gesture lives in a ref (the release must not run inside a state updater); state only draws it.
250
- onPanResponderGrant: (e) => { const { pageX, pageY } = e.nativeEvent; dragRef.current = { x0: pageX, y0: pageY, x1: pageX, y1: pageY }; setDrag(dragRef.current); },
251
- onPanResponderMove: (e) => { const d = dragRef.current; if (!d) return; dragRef.current = { ...d, x1: e.nativeEvent.pageX, y1: e.nativeEvent.pageY }; setDrag(dragRef.current); },
252
- onPanResponderRelease: (e) => { const d = dragRef.current; dragRef.current = null; if (d) placed({ ...d, x1: e.nativeEvent.pageX, y1: e.nativeEvent.pageY }); },
253
- onPanResponderTerminate: () => { dragRef.current = null; setDrag(null); },
254
- }), [placed]);
363
+ onPanResponderGrant: (e) => {
364
+ const { pageX: x, pageY: y } = e.nativeEvent;
365
+ liveRef.current = toolRef.current === 'box' ? { kind: 'box', x0: x, y0: y, x1: x, y1: y } : { kind: 'stroke', points: [{ x, y }] };
366
+ setLive(liveRef.current);
367
+ },
368
+ onPanResponderMove: (e) => {
369
+ const it = liveRef.current;
370
+ if (!it) return;
371
+ const p = { x: e.nativeEvent.pageX, y: e.nativeEvent.pageY };
372
+ if (it.kind === 'box') { liveRef.current = { ...it, x1: p.x, y1: p.y }; setLive(liveRef.current); return; }
373
+ const points = addPoint(it.points, p);
374
+ if (points !== it.points) { liveRef.current = { ...it, points }; setLive(liveRef.current); }
375
+ },
376
+ onPanResponderRelease: finish,
377
+ onPanResponderTerminate: finish,
378
+ }), []);
379
+
380
+ // The button: a drag moves it, a tap opens SSB. Its start is kept for the gesture.
381
+ const fabStart = useRef(null);
382
+ const fabPan = useMemo(() => PanResponder.create({
383
+ onStartShouldSetPanResponder: () => true,
384
+ onPanResponderGrant: () => { fabStart.current = fabRef.current ?? fabHome(screenRef.current, insetsRef.current); },
385
+ onPanResponderMove: (_e, g) => {
386
+ if (!fabStart.current || !isDrag(g.dx, g.dy)) return;
387
+ setFab(clampFab({ x: fabStart.current.x + g.dx, y: fabStart.current.y + g.dy }, screenRef.current, insetsRef.current));
388
+ },
389
+ onPanResponderRelease: (_e, g) => {
390
+ fabStart.current = null;
391
+ if (!isDrag(g.dx, g.dy)) { openRef.current(); return; }
392
+ const at = fabRef.current;
393
+ if (at && storage?.setItem) Promise.resolve(storage.setItem(fabKey, JSON.stringify(at))).catch(() => {});
394
+ },
395
+ onPanResponderTerminate: () => { fabStart.current = null; },
396
+ }), [fabKey, storage]);
255
397
 
256
398
  // ---- drawing ------------------------------------------------------------
257
- const markView = (m, key = 'mark') => {
258
- const r = markRect(m, screen);
259
- return m.kind === 'box'
260
- ? h(View, { key, pointerEvents: 'none', style: [s.box, r] })
261
- : h(View, { key, pointerEvents: 'none', style: [s.pin, r] }, h(Text, { style: s.pinText }, '1'));
262
- };
399
+ const drawingViews = (list) => list.flatMap((it, i) => (it.kind === 'box'
400
+ ? [h(View, { key: `b${i}`, pointerEvents: 'none', style: [s.box, itemRect(it)] })]
401
+ : segmentsOf(it.points, INK_W).map((g, j) => h(View, {
402
+ key: `${i}-${j}`, pointerEvents: 'none',
403
+ style: [s.ink, { left: g.left, top: g.top, width: g.width, transform: [{ rotate: `${g.angle}rad` }] }],
404
+ }))));
263
405
 
264
406
  const chrome = (() => {
265
407
  if (!gate) return null;
266
- if (mode === 'capturing') return mark ? markView(mark) : null;
267
- if (mode === 'idle') {
268
- return h(Pressable, {
269
- testID: 'ssb-button', accessibilityRole: 'button', accessibilityLabel: 'Ask SSB about this screen',
270
- onPress: openSheet, style: ({ pressed }) => [s.fab, { bottom: insets.bottom + 16 }, pressed && s.pressed],
271
- }, h(Text, { style: s.fabText }, 'SSB'), unread ? h(View, { style: s.dot }) : null);
408
+ if (mode === 'capturing') return items.length ? h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'none' }, ...drawingViews(items)) : null;
409
+ if (mode === 'idle' && talk) {
410
+ return h(View, { style: [s.pill, { top: insets.top + 8 }], testID: 'ssb-talk-pill' },
411
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
412
+ h(Text, { style: s.pillText }, talkLine(talk)),
413
+ h(Pressable, { onPress: toggleMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.pillBtn }, h(Text, { style: s.pillBtnText }, talk.muted ? 'Unmute' : 'Mute')),
414
+ h(Pressable, { testID: 'ssb-talk-open', onPress: () => setMode('sheet'), accessibilityRole: 'button', accessibilityLabel: 'Open SSB', style: s.pillBtn }, h(Text, { style: s.pillBtnText }, 'Open')),
415
+ 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')));
272
416
  }
273
- if (mode === 'marking') {
274
- const live = drag ? markOf(drag, screen) : null;
275
- return h(View, { style: StyleSheet.absoluteFill, ...pan.panHandlers, testID: 'ssb-mark-surface' },
276
- live ? markView(live, 'live') : null,
277
- h(View, { style: [s.hint, { top: insets.top + 8 }] },
278
- h(Text, { style: s.hintText }, 'Tap or drag on what this is about'),
279
- h(Pressable, { onPress: () => setMode('sheet'), accessibilityRole: 'button', accessibilityLabel: 'Cancel marking' }, h(Text, { style: s.hintLink }, 'Cancel'))));
417
+ if (mode === 'idle') {
418
+ const at = clampFab(fab ?? fabHome(screen, insets), screen, insets); // a rotation can strand a kept spot
419
+ return h(View, {
420
+ testID: 'ssb-button', accessible: true, accessibilityRole: 'button', accessibilityLabel: 'Ask SSB about this screen',
421
+ accessibilityHint: 'Drag to move it', onAccessibilityTap: openSheet,
422
+ style: [s.fab, { left: at.x, top: at.y }, talk && s.fabTalking], ...fabPan.panHandlers,
423
+ }, h(Image, { source: LOGO, style: s.fabLogo }), unread ? h(View, { style: s.dot }) : null);
280
424
  }
281
- if (mode === 'noting' && mark) {
282
- const top = fieldTop({ rect: markRect(mark, screen), screen, keyboardTop: kbTop, insets });
425
+ if (mode === 'drawing') {
426
+ const box = drawingBox(items);
427
+ const top = box ? fieldTop({ rect: box, screen, keyboardTop: kbTop, insets }) : null;
283
428
  return h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none' },
284
- markView(mark),
285
- h(View, { style: [s.field, { top }] },
429
+ // The surface first, so the hint and the field above it still take their own taps.
430
+ h(View, { style: StyleSheet.absoluteFill, ...pan.panHandlers, testID: 'ssb-draw-surface' },
431
+ ...drawingViews(live ? [...items, live] : items)),
432
+ h(View, { style: [s.hint, { top: insets.top + 8 }] },
433
+ ...['pencil', 'box'].map((t) => h(Pressable, {
434
+ key: t, onPress: () => setTool(t), accessibilityRole: 'button', accessibilityState: { selected: tool === t },
435
+ accessibilityLabel: t === 'box' ? 'Box: drag a box round it' : 'Pencil: scribble on it', style: [s.toolBtn, tool === t && s.toolOn],
436
+ }, h(Text, { style: [s.toolText, tool === t && s.toolTextOn] }, t === 'box' ? '▭ Box' : '✎ Pencil'))),
437
+ h(View, { style: s.hintSep }),
438
+ 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,
439
+ h(Pressable, { onPress: () => { Keyboard.dismiss(); setItems([]); setMode('sheet'); }, accessibilityRole: 'button', accessibilityLabel: 'Stop drawing', style: s.hintBtn }, h(Text, { style: s.hintLink }, 'Cancel'))),
440
+ top != null ? h(View, { style: [s.field, { top }] },
286
441
  h(TextInput, {
287
- testID: 'ssb-mark-note', autoFocus: true, value: note, onChangeText: setNote, placeholder: 'What should change here?',
288
- placeholderTextColor: MUTED, style: s.fieldInput, returnKeyType: 'send', onSubmitEditing: sendMark, blurOnSubmit: false,
442
+ testID: 'ssb-draw-note', value: note, onChangeText: setNote, placeholder: 'What should change here?',
443
+ placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.fieldInput, returnKeyType: 'send', onSubmitEditing: sendDrawing, blurOnSubmit: false,
289
444
  }),
290
- h(Pressable, { onPress: () => { setMark(null); setNote(''); setMode('sheet'); }, accessibilityRole: 'button', accessibilityLabel: 'Drop the mark', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕')),
291
- h(Pressable, { testID: 'ssb-mark-send', onPress: sendMark, disabled: !note.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.sendRound, (!note.trim() || busy) && s.disabled] }, h(Text, { style: s.sendRoundText }, '→'))));
445
+ 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);
292
446
  }
293
- if (mode === 'sheet') return h(Sheet, {
294
- gate, chat, screen, insets, shot, attach, setAttach, draft, setDraft, busy, error, showDrafts, setShowDrafts,
295
- onSend: sendDraft, onMark: () => { Keyboard.dismiss(); setMode('marking'); }, onClose: close,
296
- onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll,
447
+ if (mode === 'sheet') return h(Panel, {
448
+ gate, chat, work, screen, insets, draft, setDraft, busy, error,
449
+ onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
450
+ canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
451
+ onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
297
452
  });
298
453
  return null;
299
454
  })();
@@ -303,115 +458,169 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
303
458
  h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
304
459
  }
305
460
 
306
- function Sheet({ gate, chat, screen, insets, shot, attach, setAttach, draft, setDraft, busy, error, showDrafts, setShowDrafts, onSend, onMark, onClose, onNew, onDecide, onStartAll }) {
461
+ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
307
462
  const scroll = useRef(null);
308
- const drafts = draftsOf(chat);
309
- const line = draftsLine(drafts);
463
+ const [open, setOpen] = useState(null); // the draft whose detail is shown
464
+ const { flying, waiting, sent } = workView({ inFlight: work, chat });
310
465
  const offer = offerOf(chat);
311
466
  const banner = demoBanner(chat);
312
467
  const msgs = chat?.messages || [];
313
- return h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: s.sheetWrap, pointerEvents: 'box-none' },
314
- h(View, { style: [s.sheet, { maxHeight: screen.height * 0.78, paddingBottom: Math.max(12, insets.bottom - 20) }], testID: 'ssb-sheet' },
468
+ const hasWork = flying.length || waiting.length || sent.length || banner;
469
+ const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
470
+ return h(View, { style: [s.panel, { paddingTop: insets.top }], testID: 'ssb-panel' },
471
+ h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1 } },
315
472
  h(View, { style: s.header },
473
+ h(View, { style: s.headLogoRing }, h(Image, { source: LOGO, style: s.headLogo, accessibilityLabel: 'SSB' })),
316
474
  h(View, { style: { flex: 1 } },
317
475
  h(Text, { style: s.title }, 'Tell SSB'),
318
476
  h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
319
477
  chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
320
478
  h(Pressable, { onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
321
- banner ? h(Pressable, {
322
- testID: 'ssb-demo', disabled: !banner.url, onPress: () => banner.url && Linking.openURL(banner.url).catch(() => {}),
323
- accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
324
- }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
325
- line ? h(View, { style: s.bar },
326
- h(Pressable, { onPress: () => setShowDrafts(!showDrafts), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: line }, h(Text, { style: s.barText }, `${line} ${showDrafts ? '▴' : '▾'}`)),
327
- drafts.waiting.length ? h(Pressable, { testID: 'ssb-start-all', onPress: onStartAll, disabled: busy, style: [s.primary, busy && s.disabled] }, h(Text, { style: s.primaryText }, 'Start working')) : null) : null,
328
- showDrafts ? h(ScrollView, { style: s.drafts },
329
- ...drafts.waiting.map((p) => h(View, { key: p.id, style: s.draft },
330
- h(Text, { style: s.draftTitle }, p.title),
331
- p.detail ? h(Text, { style: s.draftBody, numberOfLines: 4 }, p.detail) : null,
332
- h(View, { style: s.row },
333
- h(Pressable, { onPress: () => onDecide(p, 'dismiss'), disabled: busy, style: s.quiet }, h(Text, { style: s.quietText }, 'Dismiss')),
334
- h(Pressable, { onPress: () => onDecide(p, 'raise'), disabled: busy, style: [s.primary, busy && s.disabled] }, h(Text, { style: s.primaryText }, 'Start'))))),
335
- ...drafts.sent.map((p) => h(View, { key: p.id, style: s.draft },
336
- h(Text, { style: s.draftTitle }, `✓ ${p.title}`),
337
- p.jobUrl ? h(Pressable, { onPress: () => Linking.openURL(p.jobUrl).catch(() => {}) }, h(Text, { style: s.link }, 'Watch the job →')) : null))) : null,
338
- h(ScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingVertical: 8 }, onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
339
- msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Mark to point at it.'),
479
+ // THE WORK, at the top: in flight, then drafts to start, then what this chat started.
480
+ hasWork ? h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
481
+ banner ? h(Pressable, {
482
+ testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
483
+ accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
484
+ }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
485
+ flying.length ? h(Text, { style: s.section, testID: 'ssb-section-flight' }, 'In flight') : null,
486
+ // A row opens its job in the SimonSays app, the console when that app
487
+ // is not on the phone (functions/in-app.mjs jobLinksFor) — for those
488
+ // who can open it there; anyone else's rows stay plain.
489
+ ...flying.map((x) => h(x.links ? Pressable : View, {
490
+ key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}`,
491
+ ...(x.links ? { onPress: () => onOpenLink(x.links.app, x.links.web), accessibilityRole: 'link', accessibilityLabel: `Open in SimonSays: ${x.title}` } : {}),
492
+ },
493
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, x.links ? `${x.title} ›` : x.title),
494
+ showMe(x)
495
+ ? 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'))
496
+ : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
497
+ waiting.length ? h(View, { style: s.sectionRow },
498
+ h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
499
+ 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,
500
+ ...waiting.map((p) => h(View, { key: `d${p.id}`, style: s.draftRow, testID: `ssb-draft-${p.id}` },
501
+ h(View, { style: s.workLine },
502
+ h(Pressable, { onPress: () => setOpen(open === p.id ? null : p.id), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: p.title },
503
+ h(Text, { style: s.workTitle, numberOfLines: open === p.id ? undefined : 2 }, p.title)),
504
+ 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'))),
505
+ open === p.id ? h(View, null,
506
+ p.detail ? h(Text, { style: s.draftBody }, p.detail) : null,
507
+ 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)),
508
+ ...sent.map((p) => h(View, { key: `s${p.id}`, style: s.workRow, testID: `ssb-sent-${p.id}` },
509
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, `✓ ${p.title}`),
510
+ 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,
511
+ // THE CONVERSATION, below.
512
+ h(ScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingVertical: 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
513
+ msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Draw to scribble on it.'),
340
514
  ...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
341
515
  m.image ? h(Image, { source: { uri: m.image }, style: s.thumb, resizeMode: 'contain' }) : null,
342
516
  h(Text, { style: m.role === 'user' ? s.mineText : s.theirsText }, m.text),
343
517
  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)),
344
518
  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),
345
519
  error ? h(Text, { style: s.error }, error) : null,
346
- h(View, { style: s.composer },
347
- h(TextInput, {
520
+ // THE COMPOSER, or while talking the voice controls in its place: voice is
521
+ // the other way to say it, so Hang up sits where Send does.
522
+ h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }] },
523
+ talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
524
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
525
+ h(Text, { style: s.talkText }, talkLine(talk))) : h(TextInput, {
348
526
  testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
349
- placeholderTextColor: MUTED, style: s.input,
527
+ placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
350
528
  }),
351
529
  h(View, { style: s.row },
352
- shot ? h(Pressable, { onPress: () => setAttach(!attach), accessibilityRole: 'checkbox', accessibilityState: { checked: attach }, accessibilityLabel: 'Attach this screen', style: [s.chip, attach && s.chipOn] },
353
- h(Image, { source: { uri: shot }, style: s.chipThumb }),
354
- h(Text, { style: [s.chipText, attach && { color: BLUE }] }, attach ? 'Screen attached' : 'Attach screen')) : null,
355
- h(Pressable, { testID: 'ssb-mark', onPress: onMark, accessibilityRole: 'button', accessibilityLabel: 'Mark the screen', style: s.chip }, h(Text, { style: s.chipText }, '◎ Mark')),
530
+ h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
531
+ 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,
532
+ talk ? h(Pressable, { testID: 'ssb-mute', onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.chip }, h(Text, { style: s.chipText }, talk.muted ? 'Unmute' : 'Mute')) : null,
356
533
  h(View, { style: { flex: 1 } }),
357
- 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] },
358
- busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
534
+ talk
535
+ ? h(Pressable, { testID: 'ssb-hangup', onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.primary, s.hangup] }, h(Text, { style: s.primaryText }, 'Hang up'))
536
+ : 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] },
537
+ busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
359
538
  }
360
539
 
361
- const shadow = { shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 18, shadowOffset: { width: 0, height: 6 }, elevation: 12 };
540
+ const shadow = { shadowColor: '#000', shadowOpacity: 0.45, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 12 };
541
+ const glow = (c, r = 12) => ({ shadowColor: c, shadowOpacity: 0.55, shadowRadius: r, shadowOffset: { width: 0, height: 0 } });
362
542
  const s = StyleSheet.create({
363
543
  root: { flex: 1 },
364
544
  pressed: { opacity: 0.7 },
365
- disabled: { opacity: 0.5 },
366
- fab: { position: 'absolute', right: 16, width: 48, height: 48, borderRadius: 24, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', ...shadow },
367
- fabText: { color: '#fff', fontSize: 13, fontWeight: '800', letterSpacing: 0.5 },
368
- dot: { position: 'absolute', top: 2, right: 2, width: 12, height: 12, borderRadius: 6, backgroundColor: RED, borderWidth: 2, borderColor: '#fff' },
369
- pin: { position: 'absolute', borderRadius: 14, backgroundColor: RED, borderWidth: 2, borderColor: '#fff', alignItems: 'center', justifyContent: 'center', ...shadow },
370
- pinText: { color: '#fff', fontSize: 13, fontWeight: '700' },
371
- box: { position: 'absolute', borderWidth: 3, borderColor: BLUE, borderRadius: 6, backgroundColor: 'rgba(47,91,234,0.08)' },
372
- hint: { position: 'absolute', alignSelf: 'center', flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: INK, ...shadow },
373
- hintText: { color: '#fff', fontSize: 14, fontWeight: '600', marginRight: 12 },
374
- hintLink: { color: '#9db1ff', fontSize: 14, fontWeight: '600' },
375
- field: { position: 'absolute', left: 12, right: 12, height: 56, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 6, borderRadius: 14, backgroundColor: '#fff', ...shadow },
376
- fieldInput: { flex: 1, fontSize: 16, color: INK },
377
- sendRound: { width: 40, height: 40, borderRadius: 20, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', marginLeft: 4 },
545
+ disabled: { opacity: 0.45 },
546
+ fab: { position: 'absolute', width: FAB, height: FAB, borderRadius: FAB / 2, alignItems: 'center', justifyContent: 'center', ...shadow },
547
+ fabLogo: { width: FAB, height: FAB, borderRadius: FAB / 2 },
548
+ fabTalking: { borderWidth: 3, borderColor: GREEN, ...glow(GREEN, 14) },
549
+ dot: { position: 'absolute', top: 2, right: 2, width: 12, height: 12, borderRadius: 6, backgroundColor: RED, borderWidth: 2, borderColor: BG, ...glow(RED, 6) },
550
+ // Drawing on the live screen.
551
+ ink: { position: 'absolute', height: INK_W, borderRadius: INK_W / 2, backgroundColor: RED },
552
+ box: { position: 'absolute', borderWidth: 3, borderColor: RED, borderRadius: 6, backgroundColor: 'rgba(255,77,90,0.08)' },
553
+ hint: { position: 'absolute', alignSelf: 'center', flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: 12, borderRadius: 20, backgroundColor: INK, borderWidth: 1, borderColor: LINE, ...shadow },
554
+ toolBtn: { paddingVertical: 5, paddingHorizontal: 10, borderRadius: 12, marginRight: 4 },
555
+ toolOn: { backgroundColor: RED, ...glow(RED, 8) },
556
+ toolText: { color: TEXT, fontSize: 14, fontWeight: '600' },
557
+ toolTextOn: { color: '#fff' },
558
+ hintSep: { width: 1, height: 18, backgroundColor: LINE, marginHorizontal: 6 },
559
+ hintLink: { color: '#8fb3ff', fontSize: 14, fontWeight: '600' },
560
+ hintBtn: { paddingHorizontal: 6, paddingVertical: 2 },
561
+ 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 },
562
+ fieldInput: { flex: 1, fontSize: 16, color: TEXT },
563
+ sendRound: { width: 40, height: 40, borderRadius: 20, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', marginLeft: 4, ...glow(BLUE, 10) },
378
564
  sendRoundText: { color: '#fff', fontSize: 20, fontWeight: '700' },
379
- sheetWrap: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end' },
380
- sheet: { backgroundColor: '#fff', borderTopLeftRadius: 18, borderTopRightRadius: 18, ...shadow },
381
- header: { flexDirection: 'row', alignItems: 'center', paddingTop: 14, paddingHorizontal: 16, paddingBottom: 10, borderBottomWidth: 1, borderBottomColor: LINE },
382
- title: { fontSize: 16, fontWeight: '700', color: INK },
565
+ // The panel. Spelled out: StyleSheet.absoluteFillObject is gone in React
566
+ // Native 0.86, and spreading `undefined` left the panel with no size at all
567
+ // (the harness caught it).
568
+ panel: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: BG },
569
+ header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingTop: 12, paddingHorizontal: 16, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: LINE },
570
+ headLogoRing: { width: 40, height: 40, borderRadius: 20, ...glow(BLUE, 10) },
571
+ headLogo: { width: 40, height: 40, borderRadius: 20 },
572
+ title: { fontSize: 17, fontWeight: '800', color: TEXT, letterSpacing: 0.3 },
383
573
  sub: { fontSize: 12, color: MUTED, marginTop: 2 },
384
- bar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, backgroundColor: '#fffaf0', borderBottomWidth: 1, borderBottomColor: '#f5d38a' },
385
- barText: { fontSize: 13, fontWeight: '600', color: INK },
386
- demo: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#f0fdf4', borderBottomWidth: 1, borderBottomColor: LINE },
387
- demoReady: { backgroundColor: BLUE },
388
- demoText: { flex: 1, fontSize: 13, fontWeight: '600', color: INK },
389
- demoGo: { color: '#fff', fontSize: 14, fontWeight: '800', marginLeft: 8 },
390
- drafts: { maxHeight: 260, borderBottomWidth: 1, borderBottomColor: LINE },
391
- draft: { paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: LINE },
392
- draftTitle: { fontSize: 14, fontWeight: '600', color: INK },
393
- draftBody: { fontSize: 13, color: MUTED, marginTop: 4, lineHeight: 18 },
394
- link: { color: BLUE, fontSize: 13, marginTop: 6 },
395
- thread: { paddingHorizontal: 12, flexGrow: 0, minHeight: 80 },
396
- empty: { color: MUTED, fontSize: 14, textAlign: 'center', paddingVertical: 18, paddingHorizontal: 12 },
397
- bubble: { maxWidth: '86%', borderRadius: 14, paddingVertical: 8, paddingHorizontal: 12, marginVertical: 4 },
398
- mine: { alignSelf: 'flex-end', backgroundColor: INK },
399
- theirs: { alignSelf: 'flex-start', backgroundColor: '#f1f3f6' },
400
- mineText: { color: '#fff', fontSize: 15, lineHeight: 20 },
401
- theirsText: { color: INK, fontSize: 15, lineHeight: 20 },
402
- thumb: { width: 140, height: 220, borderRadius: 8, marginBottom: 6, backgroundColor: '#0002' },
574
+ // While talking, in the composer's place.
575
+ talkBar: { minHeight: 44, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 13, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(47,210,122,0.45)', backgroundColor: 'rgba(47,210,122,0.08)' },
576
+ talkDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: GREEN, marginRight: 8, ...glow(GREEN, 6) },
577
+ talkText: { flex: 1, fontSize: 15, fontWeight: '600', color: TEXT },
578
+ talkEnd: { backgroundColor: RED, borderColor: RED },
579
+ hangup: { backgroundColor: RED, ...glow(RED, 10) },
580
+ // The work, at the top.
581
+ work: { flexGrow: 0, borderBottomWidth: 1, borderBottomColor: LINE, backgroundColor: SURFACE },
582
+ section: { fontSize: 11, fontWeight: '800', letterSpacing: 1.4, textTransform: 'uppercase', color: MUTED, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 6 },
583
+ sectionRow: { flexDirection: 'row', alignItems: 'center', paddingRight: 16 },
584
+ workRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 11, gap: 10, borderTopWidth: 1, borderTopColor: LINE },
585
+ draftRow: { paddingHorizontal: 16, paddingVertical: 11, borderTopWidth: 1, borderTopColor: LINE, borderLeftWidth: 3, borderLeftColor: YELLOW, backgroundColor: 'rgba(246,197,68,0.06)' },
586
+ workLine: { flexDirection: 'row', alignItems: 'center', gap: 10 },
587
+ workTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: TEXT },
588
+ state: { fontSize: 12, fontWeight: '800', letterSpacing: 0.3, color: MUTED, paddingVertical: 4, paddingHorizontal: 9, borderRadius: 9, backgroundColor: RAISED, borderWidth: 1, borderColor: LINE, overflow: 'hidden' },
589
+ state_ready: { color: BG, backgroundColor: GREEN, borderColor: GREEN },
590
+ state_building: { color: '#9cc0ff', backgroundColor: 'rgba(61,123,255,0.14)', borderColor: 'rgba(61,123,255,0.5)' },
591
+ state_checking: { color: '#9cc0ff', backgroundColor: 'rgba(61,123,255,0.14)', borderColor: 'rgba(61,123,255,0.5)' },
592
+ state_blocked: { color: '#fff', backgroundColor: RED, borderColor: RED },
593
+ demo: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 11, backgroundColor: 'rgba(47,210,122,0.08)' },
594
+ demoReady: { backgroundColor: 'rgba(47,210,122,0.16)' },
595
+ demoText: { flex: 1, fontSize: 13, fontWeight: '600', color: TEXT },
596
+ demoGo: { color: GREEN, fontSize: 14, fontWeight: '800', marginLeft: 8 },
597
+ draftBody: { fontSize: 13, color: MUTED, marginTop: 6, lineHeight: 19 },
598
+ link: { color: '#8fb3ff', fontSize: 13, fontWeight: '600' },
599
+ // The conversation takes what the work and the composer leave.
600
+ thread: { flex: 1, paddingHorizontal: 12 },
601
+ empty: { color: MUTED, fontSize: 14, textAlign: 'center', paddingVertical: 24, paddingHorizontal: 20, lineHeight: 20 },
602
+ bubble: { maxWidth: '86%', borderRadius: 16, paddingVertical: 9, paddingHorizontal: 13, marginVertical: 4 },
603
+ mine: { alignSelf: 'flex-end', backgroundColor: BLUE, borderBottomRightRadius: 4 },
604
+ theirs: { alignSelf: 'flex-start', backgroundColor: RAISED, borderWidth: 1, borderColor: LINE, borderBottomLeftRadius: 4 },
605
+ mineText: { color: '#fff', fontSize: 15, lineHeight: 21 },
606
+ theirsText: { color: TEXT, fontSize: 15, lineHeight: 21 },
607
+ thumb: { width: 140, height: 220, borderRadius: 8, marginBottom: 6, backgroundColor: '#0006' },
403
608
  error: { color: RED, fontSize: 13, paddingHorizontal: 16, paddingTop: 6 },
404
- composer: { paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: LINE },
405
- input: { minHeight: 44, maxHeight: 120, fontSize: 16, color: INK, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, paddingHorizontal: 12, paddingTop: 10, paddingBottom: 10 },
609
+ composer: { paddingHorizontal: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: LINE, backgroundColor: SURFACE },
610
+ input: { minHeight: 44, maxHeight: 120, fontSize: 16, color: TEXT, backgroundColor: BG, borderWidth: 1, borderColor: LINE, borderRadius: 14, paddingHorizontal: 13, paddingTop: 11, paddingBottom: 11 },
406
611
  row: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 8 },
407
- chip: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 10, borderRadius: 10, borderWidth: 1, borderColor: '#d1d5db' },
408
- chipOn: { borderColor: BLUE, backgroundColor: '#e8eeff' },
409
- chipThumb: { width: 16, height: 28, borderRadius: 3, marginRight: 6, backgroundColor: '#0002' },
410
- chipText: { fontSize: 13, color: INK, fontWeight: '600' },
411
- primary: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 10, backgroundColor: BLUE, minWidth: 64, alignItems: 'center' },
412
- primaryText: { color: '#fff', fontSize: 14, fontWeight: '700' },
612
+ chip: { flexDirection: 'row', alignItems: 'center', paddingVertical: 7, paddingHorizontal: 11, borderRadius: 12, borderWidth: 1, borderColor: LINE, backgroundColor: RAISED },
613
+ chipText: { fontSize: 13, color: TEXT, fontWeight: '600' },
614
+ primary: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 12, backgroundColor: BLUE, minWidth: 64, alignItems: 'center', ...glow(BLUE, 10) },
615
+ primaryText: { color: '#fff', fontSize: 14, fontWeight: '800', letterSpacing: 0.2 },
616
+ small: { paddingVertical: 6, paddingHorizontal: 12, minWidth: 0 },
413
617
  quiet: { paddingVertical: 6, paddingHorizontal: 10 },
414
618
  quietText: { color: MUTED, fontSize: 14, fontWeight: '600' },
415
- iconBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
416
- iconText: { color: MUTED, fontSize: 18 },
619
+ iconBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: 18, backgroundColor: RAISED },
620
+ iconText: { color: TEXT, fontSize: 16 },
621
+ // Voice mode: a pill over the app.
622
+ 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 },
623
+ pillText: { flex: 1, color: TEXT, fontSize: 14, fontWeight: '600' },
624
+ pillBtn: { paddingVertical: 6, paddingHorizontal: 10, borderRadius: 14, backgroundColor: RAISED, marginLeft: 6 },
625
+ pillBtnText: { color: TEXT, fontSize: 13, fontWeight: '700' },
417
626
  });