@simon-says-build/demo-player 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/demo-player",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "SimonSays mobile demo player: walks a preview's viewer through a job's change, inside the app. Renders nothing unless the bundle carries a demo.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/DemoHost.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
17
17
  import { pageName } from './page-name.js';
18
- import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, stepTitle } from './core.js';
18
+ import { OVERLAY_ID, cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, rescueNote, screenCandidates, stepTitle, targetOfKey } from './core.js';
19
19
  import { demoApi, tourOf } from './api.js';
20
20
  import { signInFirst, openInApp } from './signin.js';
21
21
  import { runEvent, fillStep } from './events.js';
@@ -80,6 +80,8 @@ function Player({ config, router, insets, onClose, children }) {
80
80
  const [event, setEvent] = useState(null); // an `event` step: null while it runs, else { ok, error }
81
81
  const [vars, setVars] = useState({}); // per person: what their events set up ({walkId} in later steps)
82
82
  const [flash, setFlash] = useState(null); // "Now navigating to the walk page", said before a goto opens it
83
+ const [rescued, setRescued] = useState(null); // { for: index, status: asking|moved|gone, target?, why? }
84
+ const asked = useRef(new Set()); // steps already rescued once (by index)
83
85
 
84
86
  useEffect(() => {
85
87
  let live = true;
@@ -92,11 +94,34 @@ function Player({ config, router, insets, onClose, children }) {
92
94
  const step = useMemo(() => { const raw = steps?.[index] ?? null; return raw ? fillStep(raw, vars[raw.actorIndex]) : null; }, [steps, index, vars]);
93
95
 
94
96
 
97
+ // THE RESCUE (0.9.0, Michael 2026-11-01: "when the current UI differs
98
+ // substantially from the recorded one, maybe realtime AI should step in,
99
+ // instead of showing an error"): a step whose element never appears asks SSB
100
+ // once, with what IS on screen. It answers with one of those (renamed or
101
+ // moved: the step carries on there) or says what is different now, which the
102
+ // card says in SSB's words. The extension's rescue, on a phone.
103
+ const askRescue = useCallback(async (at, idx) => {
104
+ if (asked.current.has(idx) || at.stepIndex == null) return;
105
+ asked.current.add(idx);
106
+ setRescued({ for: idx, status: 'asking' });
107
+ try {
108
+ let r = await api.repair({ actorIndex: at.actorIndex, stepIndex: at.stepIndex, candidates: screenCandidates(fiberOf(rootRef.current)) });
109
+ for (let i = 0; r.status === 'pending' || r.status === 'running'; i++) {
110
+ if (i >= RESCUE_POLLS) throw new Error('no answer');
111
+ await new Promise((ok) => setTimeout(ok, 2500));
112
+ r = await api.repairStatus(r.id);
113
+ }
114
+ const target = r.pick != null ? targetOfKey(r.css) : null;
115
+ setRescued(target ? { for: idx, status: 'moved', target, why: r.why || '' } : { for: idx, status: 'gone', why: r.why || '' });
116
+ } catch { setRescued({ for: idx, status: 'gone', why: '' }); }
117
+ }, [api]);
118
+ const rescue = rescued?.for === index ? rescued : null;
119
+
95
120
  // Find and follow the step's element.
96
121
  useEffect(() => {
97
122
  setRect(null); setFound(null);
98
123
  if (!step || done) return undefined;
99
- const target = step.target;
124
+ const target = rescue?.status === 'moved' ? rescue.target : step.target;
100
125
  if (!target) return undefined;
101
126
  if (target.point && !target.testId && !target.label && !target.text) {
102
127
  const p = scalePoint(target.point, screen);
@@ -114,12 +139,12 @@ function Player({ config, router, insets, onClose, children }) {
114
139
  if (!r && Date.now() - started > FIND_FOR_MS && target.point) {
115
140
  const p = scalePoint(target.point, screen);
116
141
  setRect({ left: p.x - 22, top: p.y - 22, width: 44, height: 44, right: p.x + 22, bottom: p.y + 22 });
117
- }
142
+ } else if (!r && Date.now() - started > FIND_FOR_MS && !rescue) askRescue(step, index);
118
143
  };
119
144
  tick();
120
145
  const t = setInterval(tick, TRACK_EVERY_MS);
121
146
  return () => { live = false; clearInterval(t); };
122
- }, [step, done, screen.width, screen.height]);
147
+ }, [step, done, screen.width, screen.height, rescue?.status, index]);
123
148
 
124
149
  const finish = useCallback(() => {
125
150
  setReviewing(false);
@@ -228,7 +253,7 @@ function Player({ config, router, insets, onClose, children }) {
228
253
  rect ? h(View, { pointerEvents: 'none', style: [s.ring, { left: rect.left - 6, top: rect.top - 6, width: rect.width + 12, height: rect.height + 12 }] }) : null,
229
254
  h(Card, {
230
255
  at: rect, place: step.place, screen, insets, card, setCard,
231
- title: stepTitle(step, index, steps.length), body: step.caption,
256
+ title: stepTitle(step, index, steps.length), body: rescueNote(step.caption, rescue),
232
257
  actions: [
233
258
  { label: 'Hide', onPress: () => setTucked(true) },
234
259
  index > 0 && steps[index - 1].kind !== 'signin' ? { label: 'Back', onPress: () => setIndex(index - 1) } : null,
@@ -240,7 +265,7 @@ function Player({ config, router, insets, onClose, children }) {
240
265
 
241
266
  return h(View, { ref: rootRef, style: s.root, collapsable: false },
242
267
  children,
243
- h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
268
+ h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill, testID: OVERLAY_ID }, overlay));
244
269
  }
245
270
 
246
271
  // The review card: every change of the demo, each approved here or discussed
@@ -323,6 +348,7 @@ function Card({ at, place, screen, insets, card, setCard, title, body, actions =
323
348
  }, h(Text, { style: [s.buttonText, a.primary && s.primaryText] }, a.label)))) : null);
324
349
  }
325
350
 
351
+ const RESCUE_POLLS = 40; // ~100 s for the daemon's fallback answer, as the extension waits
326
352
  const FLASH_MS = 1100; // long enough to read "Now navigating to the walk page"
327
353
 
328
354
  const s = StyleSheet.create({
package/src/api.js CHANGED
@@ -28,6 +28,11 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
28
28
  // A planned review (0.8.0): run a helper on the private copy, then read how it went.
29
29
  event: (actorIndex, helper, args) => call('/event', { method: 'POST', body: JSON.stringify({ actorIndex, helper, args }) }),
30
30
  eventStatus: (eventId) => call(`/event/${encodeURIComponent(eventId)}`),
31
+ // The rescue (0.9.0): a step whose element is not on screen asks where it
32
+ // went, with what IS on screen; the answer is an index into those, or why
33
+ // it is gone. The same route the extension calls.
34
+ repair: (body) => call('/repair', { method: 'POST', body: JSON.stringify(body) }),
35
+ repairStatus: (repairId) => call(`/repair/${encodeURIComponent(repairId)}`),
31
36
  discuss: (jobId, text) => call('/chat', { method: 'POST', body: JSON.stringify({ text, context: { about: jobId } }) }),
32
37
  };
33
38
  }
@@ -49,7 +54,9 @@ export function tourOf(demo, { here = false } = {}) {
49
54
  if (auto && hasState(a)) out.push({ kind: 'event', helper: '@state', caption: `Setting up ${a.label}'s part…`, actorIndex, intro: true });
50
55
  if (auto) out.push({ kind: 'signin', caption: `Signing you in as ${a.label}…`, fallback: ask, actorIndex, intro: true });
51
56
  else if (!here) out.push({ kind: 'say', caption: ask, actorIndex, intro: true });
52
- for (const s of auto ? skipAuthSteps(steps) : steps) out.push({ ...s, actorIndex });
57
+ // stepIndex: the step's place in the person's recorded tour, which the
58
+ // rescue names (/repair reads actor.tour.steps[stepIndex]).
59
+ for (const s of auto ? skipAuthSteps(steps) : steps) out.push({ ...s, actorIndex, stepIndex: steps.indexOf(s) });
53
60
  }
54
61
  return out;
55
62
  }
package/src/core.js CHANGED
@@ -10,7 +10,7 @@ import { bubbleBox } from './step-place.js';
10
10
  // What this player is, said when the app registers for "Ready to see"
11
11
  // (packages/ssb-overlay sends it; functions/in-app-notify.mjs clientOf). Kept
12
12
  // equal to package.json by test/core.test.mjs.
13
- export const PLAYER_VERSION = '0.8.2';
13
+ export const PLAYER_VERSION = '0.9.0';
14
14
 
15
15
  export const HOST = 5;
16
16
  export const TEXT = 6;
@@ -30,12 +30,13 @@ const hiddenHost = (f) => {
30
30
  return flat?.display === 'none';
31
31
  };
32
32
 
33
- function* walk(root) {
34
- // Iterative depth-first, skipping a display:none host and everything in it.
33
+ function* walk(root, skip = null) {
34
+ // Iterative depth-first, skipping a display:none host and everything in it
35
+ // (and any host `skip` names, with everything in it).
35
36
  const stack = root ? [root] : [];
36
37
  while (stack.length) {
37
38
  const f = stack.pop();
38
- if (f.tag === HOST && hiddenHost(f)) continue;
39
+ if (f.tag === HOST && (hiddenHost(f) || (skip && skip(f)))) continue;
39
40
  yield f;
40
41
  const kids = [];
41
42
  for (let c = f.child; c; c = c.sibling) kids.push(c);
@@ -86,6 +87,49 @@ export function findTarget(root, target) {
86
87
  return null;
87
88
  }
88
89
 
90
+ // WHAT IS ON SCREEN, for the rescue (0.9.0, as the extension's candidates()):
91
+ // every element a person could name, as the step's own target vocabulary. The
92
+ // key is what comes back: the server's rescue answers with an INDEX into
93
+ // these, never a target it wrote (functions/demo-rescue.mjs).
94
+ const keyOf = (t) => (t.testId ? `testId:${t.testId}` : t.label ? `label:${t.label}` : `text:${t.text}`);
95
+ /** The keyed target a rescue's answer names back ("label:Share" → { label: 'Share' }), or null. */
96
+ export function targetOfKey(key) {
97
+ const m = /^(testId|label|text):(.+)$/s.exec(String(key || ''));
98
+ return m ? { [m[1]]: m[2] } : null;
99
+ }
100
+ // The card's words while a step is rescued: SSB's, never an error. Pure.
101
+ export function rescueNote(caption, rescue) {
102
+ if (!rescue) return caption;
103
+ if (rescue.status === 'asking') return `${caption}\n\nSSB is looking for where this is now…`;
104
+ if (rescue.status === 'moved') return `${caption}\n\n${rescue.why ? `${rescue.why} ` : ''}SSB found it here.`;
105
+ return `${caption}\n\n${rescue.why || 'This screen has changed since the review was recorded.'} Tap Next to carry on, or look around yourself.`;
106
+ }
107
+ // The player's own layer: never a candidate (its card's words are not the app's).
108
+ export const OVERLAY_ID = 'ssb-demo-overlay';
109
+ /** Everything on screen a step could point at: [{ css: key, tag, name?, text? }], at most `max`. */
110
+ export function screenCandidates(root, max = 60) {
111
+ const out = [];
112
+ const seen = new Set();
113
+ const add = (t, tag, name, text) => {
114
+ const css = keyOf(t);
115
+ if (seen.has(css) || out.length >= max) return;
116
+ seen.add(css);
117
+ out.push({ css, tag, ...(name ? { name: name.slice(0, 200) } : {}), ...(text ? { text: text.slice(0, 200) } : {}) });
118
+ };
119
+ for (const f of walk(root, (h) => h.memoizedProps?.testID === OVERLAY_ID)) {
120
+ if (out.length >= max) break;
121
+ const p = f.memoizedProps || {};
122
+ if (f.tag === HOST && (p.testID || p.accessibilityLabel)) {
123
+ const label = String(p.accessibilityLabel || p['aria-label'] || '').trim();
124
+ add(p.testID ? { testId: String(p.testID) } : { label }, p.accessibilityRole || (typeof p.onChangeText === 'function' ? 'input' : 'view'), label, '');
125
+ } else if (f.tag === TEXT) {
126
+ const text = textOf(f).trim();
127
+ if (text && text.length <= 80) add({ text }, 'text', '', text);
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
89
133
  /** The nearest `name` handler at or above `f` (onPress, onChangeText), or null. */
90
134
  export function handlerFor(f, name) {
91
135
  for (let p = f; p; p = p.return) {