@simon-says-build/demo-player 0.8.2 → 0.10.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 +40 -7
- package/src/api.js +8 -1
- package/src/core.js +75 -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.10.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, introBody, introOf, 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';
|
|
@@ -76,15 +76,18 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
76
76
|
const [changes, setChanges] = useState([]); // what the last card reviews (reviewChangesOf)
|
|
77
77
|
const [release, setRelease] = useState(null); // a release's demo ends on releasing it (releaseOf)
|
|
78
78
|
const [reviewing, setReviewing] = useState(false);
|
|
79
|
+
const [intro, setIntro] = useState(null); // the first screen: who asked, what, signed in as (introOf)
|
|
79
80
|
const [signIn, setSignIn] = useState(null); // a `signin` step: null while it runs, else { ok, error }
|
|
80
81
|
const [event, setEvent] = useState(null); // an `event` step: null while it runs, else { ok, error }
|
|
81
82
|
const [vars, setVars] = useState({}); // per person: what their events set up ({walkId} in later steps)
|
|
82
83
|
const [flash, setFlash] = useState(null); // "Now navigating to the walk page", said before a goto opens it
|
|
84
|
+
const [rescued, setRescued] = useState(null); // { for: index, status: asking|moved|gone, target?, why? }
|
|
85
|
+
const asked = useRef(new Set()); // steps already rescued once (by index)
|
|
83
86
|
|
|
84
87
|
useEffect(() => {
|
|
85
88
|
let live = true;
|
|
86
89
|
api.load()
|
|
87
|
-
.then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); } })
|
|
90
|
+
.then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(d)); } })
|
|
88
91
|
.catch((e) => { if (live) setError(e.message); });
|
|
89
92
|
return () => { live = false; };
|
|
90
93
|
}, [api]);
|
|
@@ -92,11 +95,34 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
92
95
|
const step = useMemo(() => { const raw = steps?.[index] ?? null; return raw ? fillStep(raw, vars[raw.actorIndex]) : null; }, [steps, index, vars]);
|
|
93
96
|
|
|
94
97
|
|
|
98
|
+
// THE RESCUE (0.9.0, Michael 2026-11-01: "when the current UI differs
|
|
99
|
+
// substantially from the recorded one, maybe realtime AI should step in,
|
|
100
|
+
// instead of showing an error"): a step whose element never appears asks SSB
|
|
101
|
+
// once, with what IS on screen. It answers with one of those (renamed or
|
|
102
|
+
// moved: the step carries on there) or says what is different now, which the
|
|
103
|
+
// card says in SSB's words. The extension's rescue, on a phone.
|
|
104
|
+
const askRescue = useCallback(async (at, idx) => {
|
|
105
|
+
if (asked.current.has(idx) || at.stepIndex == null) return;
|
|
106
|
+
asked.current.add(idx);
|
|
107
|
+
setRescued({ for: idx, status: 'asking' });
|
|
108
|
+
try {
|
|
109
|
+
let r = await api.repair({ actorIndex: at.actorIndex, stepIndex: at.stepIndex, candidates: screenCandidates(fiberOf(rootRef.current)) });
|
|
110
|
+
for (let i = 0; r.status === 'pending' || r.status === 'running'; i++) {
|
|
111
|
+
if (i >= RESCUE_POLLS) throw new Error('no answer');
|
|
112
|
+
await new Promise((ok) => setTimeout(ok, 2500));
|
|
113
|
+
r = await api.repairStatus(r.id);
|
|
114
|
+
}
|
|
115
|
+
const target = r.pick != null ? targetOfKey(r.css) : null;
|
|
116
|
+
setRescued(target ? { for: idx, status: 'moved', target, why: r.why || '' } : { for: idx, status: 'gone', why: r.why || '' });
|
|
117
|
+
} catch { setRescued({ for: idx, status: 'gone', why: '' }); }
|
|
118
|
+
}, [api]);
|
|
119
|
+
const rescue = rescued?.for === index ? rescued : null;
|
|
120
|
+
|
|
95
121
|
// Find and follow the step's element.
|
|
96
122
|
useEffect(() => {
|
|
97
123
|
setRect(null); setFound(null);
|
|
98
124
|
if (!step || done) return undefined;
|
|
99
|
-
const target = step.target;
|
|
125
|
+
const target = rescue?.status === 'moved' ? rescue.target : step.target;
|
|
100
126
|
if (!target) return undefined;
|
|
101
127
|
if (target.point && !target.testId && !target.label && !target.text) {
|
|
102
128
|
const p = scalePoint(target.point, screen);
|
|
@@ -114,12 +140,12 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
114
140
|
if (!r && Date.now() - started > FIND_FOR_MS && target.point) {
|
|
115
141
|
const p = scalePoint(target.point, screen);
|
|
116
142
|
setRect({ left: p.x - 22, top: p.y - 22, width: 44, height: 44, right: p.x + 22, bottom: p.y + 22 });
|
|
117
|
-
}
|
|
143
|
+
} else if (!r && Date.now() - started > FIND_FOR_MS && !rescue) askRescue(step, index);
|
|
118
144
|
};
|
|
119
145
|
tick();
|
|
120
146
|
const t = setInterval(tick, TRACK_EVERY_MS);
|
|
121
147
|
return () => { live = false; clearInterval(t); };
|
|
122
|
-
}, [step, done, screen.width, screen.height]);
|
|
148
|
+
}, [step, done, screen.width, screen.height, rescue?.status, index]);
|
|
123
149
|
|
|
124
150
|
const finish = useCallback(() => {
|
|
125
151
|
setReviewing(false);
|
|
@@ -196,6 +222,12 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
196
222
|
const overlay = (() => {
|
|
197
223
|
if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
|
|
198
224
|
if (!steps) return null;
|
|
225
|
+
if (intro && !tucked && !done) {
|
|
226
|
+
return h(Card, {
|
|
227
|
+
at: null, screen, insets, card, setCard, title: 'Guided Review', body: introBody(intro),
|
|
228
|
+
actions: [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Start the review', onPress: () => setIntro(null), primary: true }],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
199
231
|
if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
|
|
200
232
|
if (flash) return h(View, { pointerEvents: 'none', style: [s.flash, { top: insets.top + screen.height * 0.4 }], testID: 'ssb-demo-flash' },
|
|
201
233
|
h(Text, { style: s.flashText, accessibilityLiveRegion: 'polite' }, flash));
|
|
@@ -228,7 +260,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
228
260
|
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
261
|
h(Card, {
|
|
230
262
|
at: rect, place: step.place, screen, insets, card, setCard,
|
|
231
|
-
title: stepTitle(step, index, steps.length), body: step.caption,
|
|
263
|
+
title: stepTitle(step, index, steps.length), body: rescueNote(step.caption, rescue),
|
|
232
264
|
actions: [
|
|
233
265
|
{ label: 'Hide', onPress: () => setTucked(true) },
|
|
234
266
|
index > 0 && steps[index - 1].kind !== 'signin' ? { label: 'Back', onPress: () => setIndex(index - 1) } : null,
|
|
@@ -240,7 +272,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
240
272
|
|
|
241
273
|
return h(View, { ref: rootRef, style: s.root, collapsable: false },
|
|
242
274
|
children,
|
|
243
|
-
h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
|
|
275
|
+
h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill, testID: OVERLAY_ID }, overlay));
|
|
244
276
|
}
|
|
245
277
|
|
|
246
278
|
// The review card: every change of the demo, each approved here or discussed
|
|
@@ -323,6 +355,7 @@ function Card({ at, place, screen, insets, card, setCard, title, body, actions =
|
|
|
323
355
|
}, h(Text, { style: [s.buttonText, a.primary && s.primaryText] }, a.label)))) : null);
|
|
324
356
|
}
|
|
325
357
|
|
|
358
|
+
const RESCUE_POLLS = 40; // ~100 s for the daemon's fallback answer, as the extension waits
|
|
326
359
|
const FLASH_MS = 1100; // long enough to read "Now navigating to the walk page"
|
|
327
360
|
|
|
328
361
|
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.10.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) {
|
|
@@ -175,3 +219,30 @@ export function releaseOf(demo) {
|
|
|
175
219
|
if (!sm || typeof sm.jobId !== 'string' || !sm.jobId) return null;
|
|
176
220
|
return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || '') };
|
|
177
221
|
}
|
|
222
|
+
|
|
223
|
+
// THE FIRST SCREEN OF A REVIEW (0.10.0, Michael 2026-11-03): a very brief
|
|
224
|
+
// outline before anything plays — who asked for it, what they asked for, and
|
|
225
|
+
// who the review signs in as — then a button to start. Pure.
|
|
226
|
+
// → { requestedBy: [string], asks: [{ title, asked }], as: [string] } | null
|
|
227
|
+
const firstSentence = (t) => { const x = String(t || '').replace(/\s+/g, ' ').trim(); const m = /^(.{20,160}?[.!?])(\s|$)/.exec(x); return (m ? m[1] : x.slice(0, 140) + (x.length > 140 ? '…' : '')); };
|
|
228
|
+
export function introOf(demo) {
|
|
229
|
+
const asks = [], by = new Set(), as = [];
|
|
230
|
+
for (const a of demo?.actors || []) {
|
|
231
|
+
if (a?.label && !as.includes(a.label)) as.push(String(a.label));
|
|
232
|
+
for (const sec of a.sections || []) for (const f of sec.features || []) {
|
|
233
|
+
if (f?.requestedBy) by.add(String(f.requestedBy).trim());
|
|
234
|
+
if (!asks.some((x) => x.title === f.title)) asks.push({ title: String(f.title || 'This change'), ...(f.tryIt?.steps ? { asked: firstSentence(f.tryIt.steps) } : {}) });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return asks.length || as.length ? { requestedBy: [...by], asks, as } : null;
|
|
238
|
+
}
|
|
239
|
+
export function introBody(intro) {
|
|
240
|
+
if (!intro) return '';
|
|
241
|
+
const lines = [];
|
|
242
|
+
if (intro.requestedBy.length) lines.push(`Requested by ${intro.requestedBy.join(', ')}`);
|
|
243
|
+
const shown = intro.asks.slice(0, 3);
|
|
244
|
+
for (const a of shown) lines.push(`• ${a.title}`);
|
|
245
|
+
if (intro.asks.length > shown.length) lines.push(`…and ${intro.asks.length - shown.length} more`);
|
|
246
|
+
if (intro.as.length) lines.push(`Signed in as ${intro.as.join(', then ')}`);
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|