@simon-says-build/ssb-overlay 0.9.1 → 0.9.3

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.1",
3
+ "version": "0.9.3",
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,6 +144,7 @@ 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)
135
149
  const [openShot, setOpenShot] = useState(null); // the message whose full screen is showing (its preview otherwise)
136
150
  const [live, setLive] = useState(null); // the item under the finger
@@ -155,6 +169,8 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
155
169
  const [work, setWork] = useState([]); // the app's work in flight (GET /api/in-app/work)
156
170
  const [seen, setSeen] = useState(null);
157
171
  const [kbTop, setKbTop] = useState(screen.height);
172
+ const kbUpRef = useRef(null);
173
+ kbUpRef.current = kbTop < screen.height; // is a keyboard up
158
174
 
159
175
  // ---- the gate: ask once per login --------------------------------------
160
176
  useEffect(() => {
@@ -198,7 +214,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
198
214
  setShot(null);
199
215
  if (mode === 'drawing') {
200
216
  if (note.trim()) setDraft((d) => (d ? `${d}\n${note}` : note));
201
- setItems([]); setNote(''); setMode('sheet');
217
+ setItems([]); setNote(''); setFrozen(null); setMode('sheet');
202
218
  }
203
219
  }, [route]);
204
220
 
@@ -270,7 +286,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
270
286
  Keyboard.dismiss();
271
287
  setSeen(lastReplyId(chat));
272
288
  setSeenDemo(chat?.demo?.url ?? null);
273
- setItems([]); setNote('');
289
+ setItems([]); setNote(''); setFrozen(null);
274
290
  setMode('idle');
275
291
  }, [chat]);
276
292
 
@@ -297,28 +313,51 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
297
313
  }, [draft, busy, deliver, chatId, route, screen, shot]);
298
314
 
299
315
  // What the app has under the scribbles, named the way the app names it.
300
- const elementUnder = useCallback(async (pt) => {
301
- if (!pt) return null;
316
+ // Measured as the screen freezes, so they match the picture the drawing is on.
317
+ const measureHosts = useCallback(async () => {
302
318
  try {
303
319
  const hosts = namedHosts(fiberOf(rootRef.current), fiberOf(chromeRef.current));
304
- const cands = await Promise.all(hosts.map(async (fiber) => ({ fiber, rect: await measure(fiber) })));
305
- 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);
306
327
  return hit ? describeHost(hit.fiber) : null;
307
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');
308
346
  }, []);
309
347
 
310
348
  const sendDrawing = useCallback(async () => {
311
349
  const text = note.trim();
312
350
  if (!text || busy || !items.length) return;
313
351
  Keyboard.dismiss();
314
- const element = await elementUnder(drawingPoint(items));
315
- 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
316
354
  const pic = await grab();
317
355
  setMode('sheet');
356
+ setFrozen(null);
318
357
  const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, mark: drawingMark(items, screen), element, screenshot: pic || shot }));
319
358
  if (ok) { setItems([]); setNote(''); }
320
359
  else setDraft((d) => d || text);
321
- }, [note, busy, items, elementUnder, grab, deliver, chatId, route, screen, shot]);
360
+ }, [note, busy, items, frozen, elementUnder, grab, deliver, chatId, route, screen, shot]);
322
361
 
323
362
  // ---- 🎙 Talk ------------------------------------------------------------------
324
363
  const endTalk = useCallback(async () => {
@@ -448,7 +487,9 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
448
487
 
449
488
  const chrome = (() => {
450
489
  if (!gate) return null;
451
- 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;
452
493
  if (mode === 'idle' && talk) {
453
494
  return h(View, { style: [s.pill, { top: insets.top + 8 }], testID: 'ssb-talk-pill' },
454
495
  h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
@@ -471,15 +512,18 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
471
512
  return h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none' },
472
513
  // The surface first, so the hint and the field above it still take their own taps.
473
514
  h(View, { style: StyleSheet.absoluteFill, ...pan.panHandlers, testID: 'ssb-draw-surface' },
474
- ...drawingViews(live ? [...items, live] : items)),
515
+ still, ...drawingViews(live ? [...items, live] : items)),
475
516
  h(View, { style: [s.hint, { top: insets.top + 8 }] },
476
517
  ...['pencil', 'box'].map((t) => h(Pressable, {
477
518
  key: t, onPress: () => setTool(t), accessibilityRole: 'button', accessibilityState: { selected: tool === t },
478
519
  accessibilityLabel: t === 'box' ? 'Box: drag a box round it' : 'Pencil: scribble on it', style: [s.toolBtn, tool === t && s.toolOn],
479
520
  }, h(Text, { style: [s.toolText, tool === t && s.toolTextOn] }, t === 'box' ? '▭ Box' : '✎ Pencil'))),
480
521
  h(View, { style: s.hintSep }),
481
- 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,
482
- h(Pressable, { onPress: () => { Keyboard.dismiss(); setItems([]); setMode('sheet'); }, accessibilityRole: 'button', accessibilityLabel: 'Stop drawing', style: s.hintBtn }, h(Text, { style: s.hintLink }, 'Cancel'))),
522
+ // Undo and Cancel are buttons, always there (Michael, 2026-10-26: two
523
+ // small links, Undo only after a mark, read as missing). Undo takes
524
+ // the last mark off; Cancel drops them all and goes back to the panel.
525
+ h(Pressable, { testID: 'ssb-draw-undo', onPress: () => setItems((all) => all.slice(0, -1)), disabled: !items.length, accessibilityRole: 'button', accessibilityLabel: 'Undo the last one', accessibilityState: { disabled: !items.length }, style: [s.hintBtn, !items.length && s.disabled] }, h(Text, { style: s.hintBtnText }, '↶ Undo')),
526
+ h(Pressable, { testID: 'ssb-draw-cancel', onPress: stopDrawing, accessibilityRole: 'button', accessibilityLabel: 'Stop drawing', style: [s.hintBtn, s.hintCancel] }, h(Text, { style: s.hintBtnText }, '✕ Cancel'))),
483
527
  top != null ? h(View, { style: [s.field, { top }] },
484
528
  h(TextInput, {
485
529
  testID: 'ssb-draw-note', value: note, onChangeText: setNote, placeholder: 'What should change here?',
@@ -490,7 +534,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
490
534
  if (mode === 'sheet') return h(Panel, {
491
535
  gate, chat, work, screen, insets, draft, setDraft, busy, error, elsewhere,
492
536
  onDemo: typeof playDemo === 'function' ? startDemo : null,
493
- onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
537
+ onSend: sendDraft, onDraw: startDrawing, onClose: close,
494
538
  canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
495
539
  onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
496
540
  });
@@ -659,8 +703,9 @@ const s = StyleSheet.create({
659
703
  toolText: { color: TEXT, fontSize: 14, fontWeight: '600' },
660
704
  toolTextOn: { color: '#fff' },
661
705
  hintSep: { width: 1, height: 18, backgroundColor: LINE, marginHorizontal: 6 },
662
- hintLink: { color: '#8fb3ff', fontSize: 14, fontWeight: '600' },
663
- hintBtn: { paddingHorizontal: 6, paddingVertical: 2 },
706
+ hintBtn: { paddingVertical: 5, paddingHorizontal: 10, borderRadius: 12, marginLeft: 4, borderWidth: 1, borderColor: LINE, backgroundColor: SURFACE },
707
+ hintCancel: { borderColor: 'rgba(255,77,90,0.5)' },
708
+ hintBtnText: { color: TEXT, fontSize: 14, fontWeight: '600' },
664
709
  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 },
665
710
  fieldInput: { flex: 1, fontSize: 16, color: TEXT },
666
711
  sendRound: { width: 40, height: 40, borderRadius: 20, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', marginLeft: 4, ...glow(BLUE, 10) },
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.1';
8
+ export const OVERLAY_VERSION = '0.9.3';
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