@simon-says-build/ssb-overlay 0.9.0 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "SSB inside a native app SimonSays controls: the SSB button for the people who change the app (screenshot, mark, ask for a change), and Help for everyone who uses it.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/SsbHost.js CHANGED
@@ -13,7 +13,10 @@
13
13
  // make it an option"). **Draw** puts a pencil and a box on the live screen:
14
14
  // scribble on or box what should change (as much as it takes, Undo), say what,
15
15
  // send — the drawing is captured ON the screen, with the field and our chrome
16
- // hidden. (It was **Mark**, one pin or one box, until 0.2.0.) SSB replies on the thread and proposes jobs; the drafts bar
16
+ // hidden. The screen is FROZEN while they draw (2026-10-25): a picture of it,
17
+ // taken as Draw opens, covers the app, so the keyboard the field raises (an
18
+ // app's KeyboardAvoidingView), a scroll or a live update can't slide the app
19
+ // out from under the drawing. (It was **Mark**, one pin or one box, until 0.2.0.) SSB replies on the thread and proposes jobs; the drafts bar
17
20
  // starts them. The same conversation is on the console.
18
21
  //
19
22
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
@@ -70,6 +73,16 @@ const frames = (n = 2) => new Promise((resolve) => {
70
73
  step(n);
71
74
  });
72
75
 
76
+ // Until the keyboard is down (and the app back where it sits without it), or
77
+ // half a second: a picture taken mid-slide would freeze the app shifted.
78
+ const keyboardGone = (up) => new Promise((resolve) => {
79
+ if (!up.current) { resolve(); return; }
80
+ let sub = null;
81
+ const done = () => { sub?.remove(); clearTimeout(t); frames(2).then(resolve); };
82
+ const t = setTimeout(done, 500);
83
+ sub = Keyboard.addListener('keyboardDidHide', done);
84
+ });
85
+
73
86
  // Where this app's JS was loaded from: a preview's tunnel, or a file.
74
87
  const scriptURL = () => {
75
88
  try { const sc = NativeModules.SourceCode; return sc?.getConstants?.()?.scriptURL ?? sc?.scriptURL ?? ''; } catch { return ''; }
@@ -131,7 +144,9 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
131
144
  const [gate, setGate] = useState(null); // {show, person, app, canJob} once SSB has answered
132
145
  const [mode, setMode] = useState('idle'); // idle | sheet | drawing | capturing
133
146
  const [shot, setShot] = useState(null); // the screen as it was when the button was tapped
147
+ const [frozen, setFrozen] = useState(null); // while drawing: { pic, hosts } — the screen held still under the pencil
134
148
  const [items, setItems] = useState([]); // what is drawn: strokes and boxes, in screen points (core.js)
149
+ const [openShot, setOpenShot] = useState(null); // the message whose full screen is showing (its preview otherwise)
135
150
  const [live, setLive] = useState(null); // the item under the finger
136
151
  const liveRef = useRef(null);
137
152
  const [tool, setTool] = useState('pencil'); // pencil | box
@@ -154,6 +169,8 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
154
169
  const [work, setWork] = useState([]); // the app's work in flight (GET /api/in-app/work)
155
170
  const [seen, setSeen] = useState(null);
156
171
  const [kbTop, setKbTop] = useState(screen.height);
172
+ const kbUpRef = useRef(null);
173
+ kbUpRef.current = kbTop < screen.height; // is a keyboard up
157
174
 
158
175
  // ---- the gate: ask once per login --------------------------------------
159
176
  useEffect(() => {
@@ -197,7 +214,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
197
214
  setShot(null);
198
215
  if (mode === 'drawing') {
199
216
  if (note.trim()) setDraft((d) => (d ? `${d}\n${note}` : note));
200
- setItems([]); setNote(''); setMode('sheet');
217
+ setItems([]); setNote(''); setFrozen(null); setMode('sheet');
201
218
  }
202
219
  }, [route]);
203
220
 
@@ -269,7 +286,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
269
286
  Keyboard.dismiss();
270
287
  setSeen(lastReplyId(chat));
271
288
  setSeenDemo(chat?.demo?.url ?? null);
272
- setItems([]); setNote('');
289
+ setItems([]); setNote(''); setFrozen(null);
273
290
  setMode('idle');
274
291
  }, [chat]);
275
292
 
@@ -296,28 +313,51 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
296
313
  }, [draft, busy, deliver, chatId, route, screen, shot]);
297
314
 
298
315
  // What the app has under the scribbles, named the way the app names it.
299
- const elementUnder = useCallback(async (pt) => {
300
- if (!pt) return null;
316
+ // Measured as the screen freezes, so they match the picture the drawing is on.
317
+ const measureHosts = useCallback(async () => {
301
318
  try {
302
319
  const hosts = namedHosts(fiberOf(rootRef.current), fiberOf(chromeRef.current));
303
- const cands = await Promise.all(hosts.map(async (fiber) => ({ fiber, rect: await measure(fiber) })));
304
- const hit = smallestAt(cands, pt);
320
+ return await Promise.all(hosts.map(async (fiber) => ({ fiber, rect: await measure(fiber) })));
321
+ } catch { return null; }
322
+ }, []);
323
+ const elementUnder = useCallback(async (pt, held) => {
324
+ if (!pt) return null;
325
+ try {
326
+ const hit = smallestAt(held || (await measureHosts()) || [], pt);
305
327
  return hit ? describeHost(hit.fiber) : null;
306
328
  } catch { return null; }
329
+ }, [measureHosts]);
330
+
331
+ // ✎ Draw: the sheet steps aside, the keyboard goes (its app may have moved
332
+ // for it), and the screen is taken and held under the pencil. With no
333
+ // `capture`, they draw on the live screen as before.
334
+ const startDrawing = useCallback(async () => {
335
+ setError(null);
336
+ Keyboard.dismiss();
337
+ setMode('capturing');
338
+ await keyboardGone(kbUpRef);
339
+ const [pic, hosts] = await Promise.all([grab(), measureHosts()]);
340
+ setFrozen(pic ? { pic, hosts } : null);
341
+ if (pic) setShot(pic);
342
+ setMode('drawing');
343
+ }, [grab, measureHosts]);
344
+ const stopDrawing = useCallback(() => {
345
+ Keyboard.dismiss(); setItems([]); setFrozen(null); setMode('sheet');
307
346
  }, []);
308
347
 
309
348
  const sendDrawing = useCallback(async () => {
310
349
  const text = note.trim();
311
350
  if (!text || busy || !items.length) return;
312
351
  Keyboard.dismiss();
313
- const element = await elementUnder(drawingPoint(items));
314
- setMode('capturing'); // only the drawing stays on screen
352
+ const element = await elementUnder(drawingPoint(items), frozen?.hosts);
353
+ setMode('capturing'); // only the drawing (on the held screen) stays
315
354
  const pic = await grab();
316
355
  setMode('sheet');
356
+ setFrozen(null);
317
357
  const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, mark: drawingMark(items, screen), element, screenshot: pic || shot }));
318
358
  if (ok) { setItems([]); setNote(''); }
319
359
  else setDraft((d) => d || text);
320
- }, [note, busy, items, elementUnder, grab, deliver, chatId, route, screen, shot]);
360
+ }, [note, busy, items, frozen, elementUnder, grab, deliver, chatId, route, screen, shot]);
321
361
 
322
362
  // ---- 🎙 Talk ------------------------------------------------------------------
323
363
  const endTalk = useCallback(async () => {
@@ -447,7 +487,9 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
447
487
 
448
488
  const chrome = (() => {
449
489
  if (!gate) return null;
450
- if (mode === 'capturing') return items.length ? h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'none' }, ...drawingViews(items)) : null;
490
+ // The held screen, under the drawing, in place of the live app.
491
+ const still = frozen ? h(Image, { source: { uri: frozen.pic }, style: StyleSheet.absoluteFill, resizeMode: 'stretch', testID: 'ssb-draw-frozen' }) : null;
492
+ if (mode === 'capturing') return items.length || still ? h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'none' }, still, ...drawingViews(items)) : null;
451
493
  if (mode === 'idle' && talk) {
452
494
  return h(View, { style: [s.pill, { top: insets.top + 8 }], testID: 'ssb-talk-pill' },
453
495
  h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
@@ -470,7 +512,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
470
512
  return h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none' },
471
513
  // The surface first, so the hint and the field above it still take their own taps.
472
514
  h(View, { style: StyleSheet.absoluteFill, ...pan.panHandlers, testID: 'ssb-draw-surface' },
473
- ...drawingViews(live ? [...items, live] : items)),
515
+ still, ...drawingViews(live ? [...items, live] : items)),
474
516
  h(View, { style: [s.hint, { top: insets.top + 8 }] },
475
517
  ...['pencil', 'box'].map((t) => h(Pressable, {
476
518
  key: t, onPress: () => setTool(t), accessibilityRole: 'button', accessibilityState: { selected: tool === t },
@@ -478,7 +520,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
478
520
  }, h(Text, { style: [s.toolText, tool === t && s.toolTextOn] }, t === 'box' ? '▭ Box' : '✎ Pencil'))),
479
521
  h(View, { style: s.hintSep }),
480
522
  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,
481
- h(Pressable, { onPress: () => { Keyboard.dismiss(); setItems([]); setMode('sheet'); }, accessibilityRole: 'button', accessibilityLabel: 'Stop drawing', style: s.hintBtn }, h(Text, { style: s.hintLink }, 'Cancel'))),
523
+ h(Pressable, { onPress: stopDrawing, accessibilityRole: 'button', accessibilityLabel: 'Stop drawing', style: s.hintBtn }, h(Text, { style: s.hintLink }, 'Cancel'))),
482
524
  top != null ? h(View, { style: [s.field, { top }] },
483
525
  h(TextInput, {
484
526
  testID: 'ssb-draw-note', value: note, onChangeText: setNote, placeholder: 'What should change here?',
@@ -489,7 +531,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
489
531
  if (mode === 'sheet') return h(Panel, {
490
532
  gate, chat, work, screen, insets, draft, setDraft, busy, error, elsewhere,
491
533
  onDemo: typeof playDemo === 'function' ? startDemo : null,
492
- onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
534
+ onSend: sendDraft, onDraw: startDrawing, onClose: close,
493
535
  canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
494
536
  onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
495
537
  });
@@ -626,7 +668,11 @@ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, set
626
668
  h(BottomSheetScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingTop: 8, paddingBottom: footH + 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
627
669
  msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Draw to scribble on it.'),
628
670
  ...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
629
- m.image ? h(Image, { source: { uri: m.image }, style: s.thumb, resizeMode: 'contain' }) : null,
671
+ // The marked part as the preview; a tap shows the full screen
672
+ // (2026-10-23, functions/shot-crop.mjs — the console and the
673
+ // extension do the same).
674
+ m.image ? h(Pressable, { onPress: () => setOpenShot(openShot === m.id ? null : m.id), accessibilityRole: 'imagebutton', accessibilityLabel: openShot === m.id ? 'Show the marked part' : 'Show the full screen' },
675
+ h(Image, { source: { uri: openShot === m.id || !m.preview ? m.image : m.preview }, style: openShot === m.id ? s.shotFull : s.thumb, resizeMode: 'contain' })) : null,
630
676
  h(Text, { style: m.role === 'user' ? s.mineText : s.theirsText }, m.text),
631
677
  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)),
632
678
  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,
@@ -707,6 +753,7 @@ const s = StyleSheet.create({
707
753
  mineText: { color: '#fff', fontSize: 15, lineHeight: 21 },
708
754
  theirsText: { color: TEXT, fontSize: 15, lineHeight: 21 },
709
755
  thumb: { width: 140, height: 220, borderRadius: 8, marginBottom: 6, backgroundColor: '#0006' },
756
+ shotFull: { width: 260, height: 460, borderRadius: 8, marginBottom: 6, backgroundColor: '#0006' },
710
757
  error: { color: RED, fontSize: 13, paddingHorizontal: 16, paddingTop: 6 },
711
758
  composer: { paddingHorizontal: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: LINE, backgroundColor: SURFACE },
712
759
  input: { minHeight: 44, maxHeight: 120, fontSize: 16, color: TEXT, backgroundColor: BG, borderWidth: 1, borderColor: LINE, borderRadius: 14, paddingHorizontal: 13, paddingTop: 11, paddingBottom: 11 },
package/src/core.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export const DEFAULT_API = 'https://simonsays.build';
6
6
  // What this overlay is, said when it registers for pushes (clientInfo below).
7
7
  // Kept equal to package.json by test/core.test.mjs.
8
- export const OVERLAY_VERSION = '0.9.0';
8
+ export const OVERLAY_VERSION = '0.9.2';
9
9
  export const HOST = 5;
10
10
  export const TEXT = 6;
11
11
  const PIN_SLOP = 12; // a drag shorter than this is a tap: a pin, not a box