@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/assets/ssb-logo.png +0 -0
- package/index.d.ts +6 -0
- package/index.js +1 -1
- package/install/cli.mjs +26 -0
- package/install/install.mjs +343 -0
- package/package.json +18 -3
- package/src/SsbHost.js +376 -167
- package/src/core.js +124 -18
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
|
|
7
|
-
// a picture of the screen as it is and opens
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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,
|
|
22
|
-
|
|
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
|
-
|
|
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 |
|
|
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 [
|
|
85
|
-
const [
|
|
86
|
-
const
|
|
87
|
-
const [
|
|
88
|
-
|
|
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 [
|
|
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
|
|
129
|
-
// rule): the
|
|
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 === '
|
|
168
|
+
if (mode === 'drawing') {
|
|
136
169
|
if (note.trim()) setDraft((d) => (d ? `${d}\n${note}` : note));
|
|
137
|
-
|
|
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,
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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,
|
|
216
|
-
|
|
217
|
-
} catch {
|
|
218
|
-
}, [
|
|
262
|
+
const hit = smallestAt(cands, pt);
|
|
263
|
+
return hit ? describeHost(hit.fiber) : null;
|
|
264
|
+
} catch { return null; }
|
|
265
|
+
}, []);
|
|
219
266
|
|
|
220
|
-
const
|
|
267
|
+
const sendDrawing = useCallback(async () => {
|
|
221
268
|
const text = note.trim();
|
|
222
|
-
if (!text || busy || !
|
|
269
|
+
if (!text || busy || !items.length) return;
|
|
223
270
|
Keyboard.dismiss();
|
|
224
|
-
|
|
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) {
|
|
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,
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
:
|
|
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
|
|
267
|
-
if (mode === 'idle') {
|
|
268
|
-
return h(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 === '
|
|
274
|
-
const
|
|
275
|
-
return h(View, {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 === '
|
|
282
|
-
const
|
|
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
|
-
|
|
285
|
-
h(View, { style:
|
|
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-
|
|
288
|
-
placeholderTextColor: MUTED, style: s.fieldInput, returnKeyType: 'send', onSubmitEditing:
|
|
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, {
|
|
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(
|
|
294
|
-
gate, chat, screen, insets,
|
|
295
|
-
onSend: sendDraft,
|
|
296
|
-
|
|
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
|
|
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
|
|
309
|
-
const
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
h(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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.
|
|
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.
|
|
366
|
-
fab: { position: 'absolute',
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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:
|
|
405
|
-
input: { minHeight: 44, maxHeight: 120, fontSize: 16, color:
|
|
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:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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:
|
|
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
|
});
|