@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 +1 -1
- package/src/DemoHost.js +32 -6
- package/src/api.js +8 -1
- package/src/core.js +48 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/demo-player",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
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.
|
|
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) {
|