@simon-says-build/demo-player 0.8.1 → 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 +58 -12
- package/src/api.js +8 -1
- package/src/core.js +48 -4
- package/src/page-name.js +23 -0
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
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
|
|
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
|
-
import {
|
|
17
|
+
import { pageName } from './page-name.js';
|
|
18
|
+
import { OVERLAY_ID, cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, rescueNote, screenCandidates, stepTitle, targetOfKey } from './core.js';
|
|
18
19
|
import { demoApi, tourOf } from './api.js';
|
|
19
20
|
import { signInFirst, openInApp } from './signin.js';
|
|
20
21
|
import { runEvent, fillStep } from './events.js';
|
|
@@ -78,6 +79,9 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
78
79
|
const [signIn, setSignIn] = useState(null); // a `signin` step: null while it runs, else { ok, error }
|
|
79
80
|
const [event, setEvent] = useState(null); // an `event` step: null while it runs, else { ok, error }
|
|
80
81
|
const [vars, setVars] = useState({}); // per person: what their events set up ({walkId} in later steps)
|
|
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)
|
|
81
85
|
|
|
82
86
|
useEffect(() => {
|
|
83
87
|
let live = true;
|
|
@@ -90,11 +94,34 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
90
94
|
const step = useMemo(() => { const raw = steps?.[index] ?? null; return raw ? fillStep(raw, vars[raw.actorIndex]) : null; }, [steps, index, vars]);
|
|
91
95
|
|
|
92
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
|
+
|
|
93
120
|
// Find and follow the step's element.
|
|
94
121
|
useEffect(() => {
|
|
95
122
|
setRect(null); setFound(null);
|
|
96
123
|
if (!step || done) return undefined;
|
|
97
|
-
const target = step.target;
|
|
124
|
+
const target = rescue?.status === 'moved' ? rescue.target : step.target;
|
|
98
125
|
if (!target) return undefined;
|
|
99
126
|
if (target.point && !target.testId && !target.label && !target.text) {
|
|
100
127
|
const p = scalePoint(target.point, screen);
|
|
@@ -112,12 +139,12 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
112
139
|
if (!r && Date.now() - started > FIND_FOR_MS && target.point) {
|
|
113
140
|
const p = scalePoint(target.point, screen);
|
|
114
141
|
setRect({ left: p.x - 22, top: p.y - 22, width: 44, height: 44, right: p.x + 22, bottom: p.y + 22 });
|
|
115
|
-
}
|
|
142
|
+
} else if (!r && Date.now() - started > FIND_FOR_MS && !rescue) askRescue(step, index);
|
|
116
143
|
};
|
|
117
144
|
tick();
|
|
118
145
|
const t = setInterval(tick, TRACK_EVERY_MS);
|
|
119
146
|
return () => { live = false; clearInterval(t); };
|
|
120
|
-
}, [step, done, screen.width, screen.height]);
|
|
147
|
+
}, [step, done, screen.width, screen.height, rescue?.status, index]);
|
|
121
148
|
|
|
122
149
|
const finish = useCallback(() => {
|
|
123
150
|
setReviewing(false);
|
|
@@ -125,15 +152,27 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
125
152
|
api.progress({ status: 'done' }).catch(() => {});
|
|
126
153
|
}, [api]);
|
|
127
154
|
|
|
155
|
+
const advance = useCallback(() => {
|
|
156
|
+
if (index + 1 >= steps.length) { if (changes.length) setReviewing(true); else finish(); }
|
|
157
|
+
else setIndex(index + 1);
|
|
158
|
+
}, [steps, index, finish, changes]);
|
|
159
|
+
// SAID BEFORE IT GOES (2026-11-01, Michael): a goto flashes "Now navigating
|
|
160
|
+
// to the walk page" for a moment, then opens it (schemas/page-name.mjs).
|
|
128
161
|
const next = useCallback(() => {
|
|
129
|
-
if (!steps) return;
|
|
162
|
+
if (!steps || flash) return;
|
|
130
163
|
if (step?.kind === 'goto' && step.path) {
|
|
131
|
-
|
|
132
|
-
|
|
164
|
+
const path = step.path;
|
|
165
|
+
setFlash(`Now navigating to ${pageName(path)}`);
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
if (path.startsWith('/') && router?.push) router.push(path);
|
|
168
|
+
else Linking.openURL(path).catch(() => {});
|
|
169
|
+
setFlash(null);
|
|
170
|
+
advance();
|
|
171
|
+
}, FLASH_MS);
|
|
172
|
+
return;
|
|
133
173
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}, [steps, step, index, router, finish, changes]);
|
|
174
|
+
advance();
|
|
175
|
+
}, [steps, step, router, advance, flash]);
|
|
137
176
|
|
|
138
177
|
const doIt = useCallback(() => {
|
|
139
178
|
if (!step || !found) return;
|
|
@@ -183,6 +222,8 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
183
222
|
if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
|
|
184
223
|
if (!steps) return null;
|
|
185
224
|
if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
|
|
225
|
+
if (flash) return h(View, { pointerEvents: 'none', style: [s.flash, { top: insets.top + screen.height * 0.4 }], testID: 'ssb-demo-flash' },
|
|
226
|
+
h(Text, { style: s.flashText, accessibilityLiveRegion: 'polite' }, flash));
|
|
186
227
|
if (tucked || done) {
|
|
187
228
|
// A demo started at run time closes when it is done (▶ Demo again replays it);
|
|
188
229
|
// one baked into a preview stays, to be replayed from its pill.
|
|
@@ -212,7 +253,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
212
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,
|
|
213
254
|
h(Card, {
|
|
214
255
|
at: rect, place: step.place, screen, insets, card, setCard,
|
|
215
|
-
title: stepTitle(step, index, steps.length), body: step.caption,
|
|
256
|
+
title: stepTitle(step, index, steps.length), body: rescueNote(step.caption, rescue),
|
|
216
257
|
actions: [
|
|
217
258
|
{ label: 'Hide', onPress: () => setTucked(true) },
|
|
218
259
|
index > 0 && steps[index - 1].kind !== 'signin' ? { label: 'Back', onPress: () => setIndex(index - 1) } : null,
|
|
@@ -224,7 +265,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
224
265
|
|
|
225
266
|
return h(View, { ref: rootRef, style: s.root, collapsable: false },
|
|
226
267
|
children,
|
|
227
|
-
h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
|
|
268
|
+
h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill, testID: OVERLAY_ID }, overlay));
|
|
228
269
|
}
|
|
229
270
|
|
|
230
271
|
// The review card: every change of the demo, each approved here or discussed
|
|
@@ -307,6 +348,9 @@ function Card({ at, place, screen, insets, card, setCard, title, body, actions =
|
|
|
307
348
|
}, h(Text, { style: [s.buttonText, a.primary && s.primaryText] }, a.label)))) : null);
|
|
308
349
|
}
|
|
309
350
|
|
|
351
|
+
const RESCUE_POLLS = 40; // ~100 s for the daemon's fallback answer, as the extension waits
|
|
352
|
+
const FLASH_MS = 1100; // long enough to read "Now navigating to the walk page"
|
|
353
|
+
|
|
310
354
|
const s = StyleSheet.create({
|
|
311
355
|
root: { flex: 1 },
|
|
312
356
|
ring: { position: 'absolute', borderRadius: 12, borderWidth: 3, borderColor: '#FFFFFF', shadowColor: '#000', shadowOpacity: 0.6, shadowRadius: 8, shadowOffset: { width: 0, height: 0 } },
|
|
@@ -327,6 +371,8 @@ const s = StyleSheet.create({
|
|
|
327
371
|
changeState: { color: '#AEAEB2', fontSize: 13, marginTop: 4 },
|
|
328
372
|
error: { color: '#FF6961', fontSize: 13, marginTop: 4 },
|
|
329
373
|
input: { marginTop: 10, minHeight: 64, padding: 10, borderRadius: 10, backgroundColor: '#2C2C2E', color: '#FFFFFF', fontSize: 15 },
|
|
374
|
+
flash: { position: 'absolute', left: 32, right: 32, alignItems: 'center', paddingVertical: 14, paddingHorizontal: 18, borderRadius: 14, backgroundColor: 'rgba(28,28,30,0.92)' },
|
|
375
|
+
flashText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600', textAlign: 'center' },
|
|
330
376
|
pill: { position: 'absolute', right: 16, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: '#1C1C1E' },
|
|
331
377
|
pillText: { color: '#FFFFFF', fontSize: 13, fontWeight: '600' },
|
|
332
378
|
});
|
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) {
|
package/src/page-name.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// THE PAGE A STEP OPENS, in words (2026-11-01, Michael: "before any navigation,
|
|
2
|
+
// can we show a brief flash showing 'Now navigating to [the X page]'"). Both
|
|
3
|
+
// players say it before they navigate: the phone's (packages/demo-player/src/
|
|
4
|
+
// page-name.js) and the extension's (extension/overlay.js) carry copies of
|
|
5
|
+
// pageName, held equal by bin/page-name.test.mjs. Pure.
|
|
6
|
+
//
|
|
7
|
+
// /walk/{walkId} → the walk page (an id or {placeholder} is not a name)
|
|
8
|
+
// /(app)/settings/profile → the profile page (an Expo Router group is not a name)
|
|
9
|
+
// /MainStack/Tabs/Dashboard → the dashboard page (a React Navigation chain)
|
|
10
|
+
// walk2gether://walk/abc → the walk page (a deep link)
|
|
11
|
+
// / → the home page
|
|
12
|
+
export function pageName(path) {
|
|
13
|
+
// A web address drops its host; a deep link's "host" is its first screen.
|
|
14
|
+
const rest = String(path || '').replace(/^https?:\/\/[^/]*/i, '').replace(/^[a-z][a-z0-9+.-]*:\/\//i, '/').split(/[?#]/)[0];
|
|
15
|
+
const isId = (s) => /^\{.*\}$/.test(s) || /^\[.*\]$/.test(s) || /^:/.test(s) || /^\(.*\)$/.test(s) || s === 'index'
|
|
16
|
+
|| /^[0-9]+$/.test(s) || /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(s) || (s.length >= 16 && /[0-9]/.test(s) && /^[A-Za-z0-9_-]+$/.test(s));
|
|
17
|
+
const parts = rest.split('/').filter(Boolean).map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).filter((s) => !isId(s));
|
|
18
|
+
const last = parts[parts.length - 1];
|
|
19
|
+
if (!last) return 'the home page';
|
|
20
|
+
const words = last.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_.]+/g, ' ').trim().toLowerCase();
|
|
21
|
+
return `the ${words || 'home'} page`;
|
|
22
|
+
}
|
|
23
|
+
// END pageName
|