@simon-says-build/demo-player 0.11.0 → 0.13.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/index.d.ts +5 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/DemoHost.js +96 -19
- package/src/build-gate.js +62 -0
- package/src/core.js +30 -2
- package/src/signin.js +10 -4
package/index.d.ts
CHANGED
|
@@ -45,3 +45,8 @@ export function navigationRoute(nav: unknown): string;
|
|
|
45
45
|
export function navigationRouter(nav: unknown): { push: (path: string) => boolean };
|
|
46
46
|
export function navigateArgs(path: string): [string] | [string, object] | null;
|
|
47
47
|
export function pathOfState(state: unknown): string;
|
|
48
|
+
|
|
49
|
+
/** While a review is collapsed (0.13.0): the function that resumes it, else null. */
|
|
50
|
+
export function tuckedDemo(): (() => void) | null;
|
|
51
|
+
/** The in-app SSB overlay says it is on screen, so a collapsed review leaves only its button. */
|
|
52
|
+
export function announceOverlay(): void;
|
package/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { DemoHost } from './src/DemoHost.js';
|
|
2
|
-
export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, releaseReviewsOf, PLAYER_VERSION } from './src/core.js';
|
|
2
|
+
export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, releaseReviewsOf, PLAYER_VERSION, tuckedDemo, announceOverlay } from './src/core.js';
|
|
3
3
|
export { navigationRoute, navigationRouter, navigateArgs, pathOfState } from './src/navigation.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/demo-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
//
|
|
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
|
-
import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
|
|
16
|
+
import { Linking, NativeModules, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
|
|
17
17
|
import { pageName } from './page-name.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';
|
|
18
|
+
import { OVERLAY_ID, cardBox, pinnedBox, setTuckedDemo, overlayAnnounced, 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';
|
|
22
|
+
import { nativeRuntime, buildGap, needLinks, openNeed, staleBody } from './build-gate.js';
|
|
22
23
|
|
|
23
24
|
const h = React.createElement;
|
|
24
25
|
const FIND_FOR_MS = 8000; // a step's element may appear after a navigation settles
|
|
@@ -83,11 +84,25 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
83
84
|
const [flash, setFlash] = useState(null); // "Now navigating to the walk page", said before a goto opens it
|
|
84
85
|
const [rescued, setRescued] = useState(null); // { for: index, status: asking|moved|gone, target?, why? }
|
|
85
86
|
const asked = useRef(new Set()); // steps already rescued once (by index)
|
|
87
|
+
const [stale, setStale] = useState(null); // { gap, links, appName }: this dev build is not the review's (build-gate.js)
|
|
86
88
|
|
|
87
89
|
useEffect(() => {
|
|
88
90
|
let live = true;
|
|
89
91
|
api.load()
|
|
90
|
-
.then((d) => {
|
|
92
|
+
.then((d) => {
|
|
93
|
+
if (!live) return;
|
|
94
|
+
// THE BUILD FIRST (0.12.0): a review made for another native build of
|
|
95
|
+
// this dev app never starts here — the person is sent to install the
|
|
96
|
+
// right one (build-gate.js). Nothing else runs: no sign-in, no steps.
|
|
97
|
+
const gap = config.here ? null : buildGap(d, nativeRuntime(globalThis.expo?.modules, NativeModules));
|
|
98
|
+
if (gap) {
|
|
99
|
+
const links = needLinks({ demo: d, gap, demoId: config.id, token: config.token });
|
|
100
|
+
setStale({ gap, links, appName: d.projectName || d.appName || 'the app' });
|
|
101
|
+
openNeed(links, (u) => Linking.openURL(u));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(d));
|
|
105
|
+
})
|
|
91
106
|
.catch((e) => { if (live) setError(e.message); });
|
|
92
107
|
return () => { live = false; };
|
|
93
108
|
}, [api]);
|
|
@@ -147,6 +162,24 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
147
162
|
return () => { live = false; clearInterval(t); };
|
|
148
163
|
}, [step, done, screen.width, screen.height, rescue?.status, index]);
|
|
149
164
|
|
|
165
|
+
// TRY IT (0.13.0): the viewer taps the ringed element; once it was on
|
|
166
|
+
// screen and is gone (the app moved on), that was the tap — said, and the
|
|
167
|
+
// tour carries on. "Show me" presses it for them; Skip moves on.
|
|
168
|
+
const triedSeen = useRef(false);
|
|
169
|
+
const [tried, setTried] = useState(false);
|
|
170
|
+
useEffect(() => { triedSeen.current = false; setTried(false); }, [index]);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (step?.kind !== 'try' || tried) return;
|
|
173
|
+
if (found) { triedSeen.current = true; return; }
|
|
174
|
+
if (triedSeen.current) setTried(true);
|
|
175
|
+
}, [found, step?.kind, tried]);
|
|
176
|
+
|
|
177
|
+
// Collapsed, the SSB button resumes it (core.js tuckedDemo).
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
setTuckedDemo(tucked && !done ? () => setTucked(false) : null);
|
|
180
|
+
return () => setTuckedDemo(null);
|
|
181
|
+
}, [tucked, done]);
|
|
182
|
+
|
|
150
183
|
const finish = useCallback(() => {
|
|
151
184
|
setReviewing(false);
|
|
152
185
|
setDone(true);
|
|
@@ -175,8 +208,16 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
175
208
|
advance();
|
|
176
209
|
}, [steps, step, router, advance, flash]);
|
|
177
210
|
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!tried) return undefined;
|
|
213
|
+
setFlash('✓ That\'s it');
|
|
214
|
+
const t = setTimeout(() => { setFlash(null); advance(); }, FLASH_MS);
|
|
215
|
+
return () => clearTimeout(t);
|
|
216
|
+
}, [tried]);
|
|
217
|
+
|
|
178
218
|
const doIt = useCallback(() => {
|
|
179
219
|
if (!step || !found) return;
|
|
220
|
+
if (step.kind === 'try') { handlerFor(found, 'onPress')?.({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} }); return; }
|
|
180
221
|
if (step.kind === 'click') handlerFor(found, 'onPress')?.({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} });
|
|
181
222
|
if (step.kind === 'fill' && step.value != null) handlerFor(found, 'onChangeText')?.(step.value);
|
|
182
223
|
next();
|
|
@@ -184,7 +225,8 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
184
225
|
|
|
185
226
|
// AND NOW… (an `event` step, 0.8.0): SSB runs the helper on the copy; its
|
|
186
227
|
// outputs fill `{name}` in this person's later steps. A failure stays on the
|
|
187
|
-
// card, said, with
|
|
228
|
+
// card, said, with Try again (0.12.0: never Next — what follows depends on it).
|
|
229
|
+
const [eventTry, setEventTry] = useState(0);
|
|
188
230
|
useEffect(() => {
|
|
189
231
|
setEvent(null);
|
|
190
232
|
if (step?.kind !== 'event' || done) return undefined;
|
|
@@ -197,11 +239,13 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
197
239
|
else setIndex(index + 1);
|
|
198
240
|
});
|
|
199
241
|
return () => { live = false; };
|
|
200
|
-
}, [step?.kind, step?.helper, index, done]);
|
|
242
|
+
}, [step?.kind, step?.helper, index, done, eventTry]);
|
|
201
243
|
|
|
202
244
|
// SIGN THIS PERSON IN FIRST (a `signin` step, tourOf): open the link, let
|
|
203
245
|
// the app sign in and route, then start their part on that screen. A link
|
|
204
|
-
// that can't be had or opened
|
|
246
|
+
// that can't be had or opened, or a sign-in whose first screen never comes
|
|
247
|
+
// up, stops the tour with Try again (0.12.0).
|
|
248
|
+
const [signInTry, setSignInTry] = useState(0); // "Try again" re-runs the sign-in
|
|
205
249
|
useEffect(() => {
|
|
206
250
|
setSignIn(null);
|
|
207
251
|
if (step?.kind !== 'signin' || done) return undefined;
|
|
@@ -217,20 +261,27 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
217
261
|
else setIndex(index + 1);
|
|
218
262
|
});
|
|
219
263
|
return () => { live = false; };
|
|
220
|
-
}, [step, done]);
|
|
264
|
+
}, [step, done, signInTry]);
|
|
221
265
|
|
|
222
266
|
const overlay = (() => {
|
|
223
267
|
if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
|
|
268
|
+
if (stale) {
|
|
269
|
+
return h(Card, {
|
|
270
|
+
at: null, screen, insets, card, setCard, title: 'Guided Review', body: staleBody(stale.gap, stale.appName),
|
|
271
|
+
actions: [{ label: 'Open SimonSays', onPress: () => openNeed(stale.links, (u) => Linking.openURL(u)), primary: true }],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
224
274
|
if (!steps) return null;
|
|
225
275
|
if (intro && !tucked && !done) {
|
|
226
276
|
return h(Card, {
|
|
227
|
-
at: null, screen, insets, card, setCard, title: 'Guided Review', body: introBody(intro),
|
|
277
|
+
at: null, screen, insets, card, setCard, onCollapse: () => setTucked(true), title: 'Guided Review', body: introBody(intro),
|
|
228
278
|
actions: [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Start the review', onPress: () => setIntro(null), primary: true }],
|
|
229
279
|
});
|
|
230
280
|
}
|
|
231
281
|
if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
|
|
232
282
|
if (flash) return h(View, { pointerEvents: 'none', style: [s.flash, { top: insets.top + screen.height * 0.4 }], testID: 'ssb-demo-flash' },
|
|
233
283
|
h(Text, { style: s.flashText, accessibilityLiveRegion: 'polite' }, flash));
|
|
284
|
+
if (tucked && !done && overlayAnnounced()) return null; // the SSB button resumes it (0.13.0)
|
|
234
285
|
if (tucked || done) {
|
|
235
286
|
// A demo started at run time closes when it is done (▶ Demo again replays it);
|
|
236
287
|
// one baked into a preview stays, to be replayed from its pill.
|
|
@@ -241,25 +292,41 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
241
292
|
}
|
|
242
293
|
if (!step && changes.length) return h(Review, { api, changes, release, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
|
|
243
294
|
if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
|
|
295
|
+
// A FAILED SETUP OR SIGN-IN STOPS THE TOUR (0.12.0, Michael 2026-11-05:
|
|
296
|
+
// "the tour is theoretically continuable… we need a way to make the tour
|
|
297
|
+
// aware that an important step failed"). What follows depends on it, so
|
|
298
|
+
// there is no Next: Try again, or Hide.
|
|
244
299
|
if (step.kind === 'event') {
|
|
245
300
|
return h(Card, {
|
|
246
|
-
at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
|
|
247
|
-
body: event ? `The
|
|
248
|
-
actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: '
|
|
301
|
+
at: null, screen, insets, card, setCard, onCollapse: () => setTucked(true), title: `${index + 1} of ${steps.length}`,
|
|
302
|
+
body: event ? `The review couldn't set this up, and the rest of it depends on it: ${event.error}` : step.caption,
|
|
303
|
+
actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setEventTry((n) => n + 1), primary: true }] : [],
|
|
249
304
|
});
|
|
250
305
|
}
|
|
251
306
|
if (step.kind === 'signin') {
|
|
252
307
|
return h(Card, {
|
|
253
|
-
at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
|
|
254
|
-
body: signIn ?
|
|
255
|
-
actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: '
|
|
308
|
+
at: null, screen, insets, card, setCard, onCollapse: () => setTucked(true), title: `${index + 1} of ${steps.length}`,
|
|
309
|
+
body: signIn ? `SSB couldn't sign you in, and the review needs it: ${signIn.error}.` : step.caption,
|
|
310
|
+
actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setSignInTry((n) => n + 1), primary: true }] : [],
|
|
256
311
|
});
|
|
257
312
|
}
|
|
313
|
+
if (step.kind === 'try') {
|
|
314
|
+
return h(React.Fragment, null,
|
|
315
|
+
rect ? h(View, { pointerEvents: 'none', style: [s.ring, s.tryRing, { left: rect.left - 6, top: rect.top - 6, width: rect.width + 12, height: rect.height + 12 }] }) : null,
|
|
316
|
+
h(Card, {
|
|
317
|
+
at: rect, screen, insets, card, setCard, onCollapse: () => setTucked(true),
|
|
318
|
+
title: `Try it · ${index + 1} of ${steps.length}`, body: rect ? step.caption : `${step.caption} (looking for it on this screen…)`,
|
|
319
|
+
actions: [
|
|
320
|
+
{ label: 'Skip', onPress: next },
|
|
321
|
+
found && handlerFor(found, 'onPress') ? { label: 'Show me', onPress: doIt, primary: true } : null,
|
|
322
|
+
].filter(Boolean),
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
258
325
|
const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
|
|
259
326
|
return h(React.Fragment, null,
|
|
260
327
|
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,
|
|
261
328
|
h(Card, {
|
|
262
|
-
at: rect, place: step.place, screen, insets, card, setCard,
|
|
329
|
+
at: rect, place: step.place, screen, insets, card, setCard, onCollapse: () => setTucked(true),
|
|
263
330
|
title: stepTitle(step, index, steps.length), body: rescueNote(step.caption, rescue),
|
|
264
331
|
actions: [
|
|
265
332
|
{ label: 'Hide', onPress: () => setTucked(true) },
|
|
@@ -394,17 +461,23 @@ const button = (label, onPress, primary = false) => h(Pressable, {
|
|
|
394
461
|
style: ({ pressed }) => [s.button, primary && s.primary, pressed && s.pressed],
|
|
395
462
|
}, h(Text, { style: [s.buttonText, primary && s.primaryText] }, label));
|
|
396
463
|
|
|
397
|
-
|
|
398
|
-
|
|
464
|
+
// Pinned across the top (0.13.0, core.js pinnedBox), with a collapse control
|
|
465
|
+
// in its corner when the player can be collapsed (`onCollapse`).
|
|
466
|
+
function Card({ at, place, screen, insets, card, setCard, title, body, actions = [], onCollapse = null }) {
|
|
467
|
+
void place; void cardBox;
|
|
468
|
+
const box = pinnedBox(at, screen, insets, card);
|
|
399
469
|
return h(View, {
|
|
400
|
-
style: [s.card, { left: box.left, top: box.top,
|
|
470
|
+
style: [s.card, { left: box.left, top: box.top, width: box.width }],
|
|
401
471
|
onLayout: (e) => {
|
|
402
472
|
const { width, height } = e.nativeEvent.layout;
|
|
403
473
|
if (Math.abs(width - card.w) > 1 || Math.abs(height - card.h) > 1) setCard({ w: width, h: height });
|
|
404
474
|
},
|
|
405
475
|
accessibilityViewIsModal: false,
|
|
406
476
|
},
|
|
407
|
-
h(
|
|
477
|
+
h(View, { style: s.head },
|
|
478
|
+
h(Text, { style: [s.title, { flex: 1 }] }, title),
|
|
479
|
+
onCollapse ? h(Pressable, { testID: 'ssb-demo-collapse', onPress: onCollapse, hitSlop: 12, accessibilityRole: 'button', accessibilityLabel: 'Collapse the review',
|
|
480
|
+
style: ({ pressed }) => [s.collapse, pressed && s.pressed] }, h(Text, { style: s.collapseText }, '⌃')) : null),
|
|
408
481
|
h(Text, { style: s.body }, body),
|
|
409
482
|
actions.length ? h(View, { style: s.actions }, ...actions.map((a) => h(Pressable, {
|
|
410
483
|
key: a.label, onPress: a.onPress, accessibilityRole: 'button', accessibilityLabel: a.label,
|
|
@@ -420,6 +493,10 @@ const s = StyleSheet.create({
|
|
|
420
493
|
ring: { position: 'absolute', borderRadius: 12, borderWidth: 3, borderColor: '#FFFFFF', shadowColor: '#000', shadowOpacity: 0.6, shadowRadius: 8, shadowOffset: { width: 0, height: 0 } },
|
|
421
494
|
card: { position: 'absolute', width: 320, padding: 16, borderRadius: 16, backgroundColor: '#1C1C1E', shadowColor: '#000', shadowOpacity: 0.35, shadowRadius: 16, shadowOffset: { width: 0, height: 6 } },
|
|
422
495
|
title: { color: '#AEAEB2', fontSize: 12, fontWeight: '600', marginBottom: 6 },
|
|
496
|
+
head: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
497
|
+
collapse: { marginLeft: 8, marginTop: -6, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8, backgroundColor: '#2C2C2E' },
|
|
498
|
+
collapseText: { color: '#FFFFFF', fontSize: 16, fontWeight: '700' },
|
|
499
|
+
tryRing: { borderColor: '#FFD60A', borderWidth: 4 },
|
|
423
500
|
body: { color: '#FFFFFF', fontSize: 16, lineHeight: 22 },
|
|
424
501
|
actions: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 14, gap: 8 },
|
|
425
502
|
button: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 10, backgroundColor: '#2C2C2E' },
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// THE DEV BUILD MUST MATCH THE REVIEW (0.12.0, docs/TOUR-LINKS.md "6. The
|
|
2
|
+
// bundle checks itself"). Pure, so node --test runs it.
|
|
3
|
+
//
|
|
4
|
+
// A phone review's copy is Metro serving the session's JS, made for ONE native
|
|
5
|
+
// build of the app's dev client: `demo.preview.fingerprint`, the build SSB put
|
|
6
|
+
// in the library for it. A dev client loads whatever JS it is pointed at, so
|
|
7
|
+
// on an older build the review's code ran anyway and failed half-way: Michael's
|
|
8
|
+
// phone (2026-11-05) opened Connie Ho's Profile photos review on a Walk2Gether
|
|
9
|
+
// Dev build older than c316 and its sign-in died "auth/network-request-failed".
|
|
10
|
+
// Every check before that point (the notification's install step, the
|
|
11
|
+
// overlay's ssb-tour gate) was skipped — no install record, a dev-client link.
|
|
12
|
+
//
|
|
13
|
+
// The structural answer is the bundle itself: the player knows what native
|
|
14
|
+
// build the review needs (the demo record) and what native build it is running
|
|
15
|
+
// on (expo-updates' runtimeVersion — under the fingerprint policy, the native
|
|
16
|
+
// fingerprint; the dev builds read it from EXUpdates.bundle/fingerprint). A
|
|
17
|
+
// mismatch never starts the review: it hands the person to the SimonSays app's
|
|
18
|
+
// "need" screen (`simonsays://need?…`, which offers the build SSB made), and
|
|
19
|
+
// the website's /need page when the app is not there. A build that cannot say
|
|
20
|
+
// its runtime, or a review that names none, plays as before.
|
|
21
|
+
|
|
22
|
+
/** This build's native runtime (expo-updates' runtimeVersion), or null. */
|
|
23
|
+
export function nativeRuntime(mods = globalThis.expo?.modules, nativeModules = {}) {
|
|
24
|
+
const u = mods?.ExpoUpdates || nativeModules?.ExpoUpdates || {};
|
|
25
|
+
return typeof u.runtimeVersion === 'string' && u.runtimeVersion && !u.runtimeVersion.startsWith('file:') ? u.runtimeVersion : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the review needs that this build is not: null = play it, else { need, have }. */
|
|
29
|
+
export function buildGap(demo, have) {
|
|
30
|
+
const need = demo?.platform === 'ios' && typeof demo?.preview?.fingerprint === 'string' ? demo.preview.fingerprint : null;
|
|
31
|
+
if (!need || !have) return null;
|
|
32
|
+
return need === have ? null : { need, have };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The dev app's scheme, from the link the copy was opened with
|
|
36
|
+
// (`walk2gether-dev://expo-development-client/?url=…`).
|
|
37
|
+
const schemeOf = (url) => /^([a-z][a-z0-9+.-]*):\/\//i.exec(String(url || ''))?.[1] || undefined;
|
|
38
|
+
const qs = (o) => Object.entries(o).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where to send the person: the SimonSays app's need screen, else the site's.
|
|
42
|
+
* The query is schemas/tour-link.mjs needUrl's (test/build-gate.test.mjs holds
|
|
43
|
+
* them equal): app, scheme, tour, rt, haveRt.
|
|
44
|
+
*/
|
|
45
|
+
export function needLinks({ demo = {}, gap, demoId, token, site = 'https://simonsays.build' }) {
|
|
46
|
+
const q = qs({
|
|
47
|
+
app: demo.appId, scheme: schemeOf(demo.preview?.url), tour: demoId && token ? `${demoId}.${token}` : undefined,
|
|
48
|
+
rt: gap?.need, haveRt: gap?.have || undefined,
|
|
49
|
+
});
|
|
50
|
+
return { app: `simonsays://need?${q}`, web: `${site.replace(/\/$/, '')}/need?${q}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Open the app's need screen, falling through to the site. `openURL` is Linking.openURL. */
|
|
54
|
+
export async function openNeed(links, openURL) {
|
|
55
|
+
try { await openURL(links.app); return 'app'; } catch { /* no SimonSays app */ }
|
|
56
|
+
try { await openURL(links.web); return 'web'; } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The card's words. */
|
|
60
|
+
export function staleBody(gap, appName = 'the app') {
|
|
61
|
+
return `This review needs a newer ${appName} Dev build (it needs ${gap.need.slice(0, 7)}; this phone has ${gap.have.slice(0, 7)}). Opening the SimonSays app to install it.`;
|
|
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.13.0';
|
|
14
14
|
|
|
15
15
|
export const HOST = 5;
|
|
16
16
|
export const TEXT = 6;
|
|
@@ -21,7 +21,7 @@ export const RECORDED_SCREEN = { width: 393, height: 852 };
|
|
|
21
21
|
// What a phone can perform. `press` (a keyboard key), `select` (a web
|
|
22
22
|
// <select>) and `run` (a terminal demo) have no phone equivalent. `signin` is
|
|
23
23
|
// the player's own (tourOf, 0.6.0): sign this person in by link.
|
|
24
|
-
const PHONE_KINDS = new Set(['say', 'goto', 'show', 'click', 'fill', 'signin', 'event']);
|
|
24
|
+
const PHONE_KINDS = new Set(['say', 'goto', 'show', 'click', 'fill', 'signin', 'event', 'try']);
|
|
25
25
|
|
|
26
26
|
const lower = (s) => String(s ?? '').trim().toLowerCase();
|
|
27
27
|
const hiddenHost = (f) => {
|
|
@@ -59,6 +59,11 @@ const textOf = (f) => (typeof f.memoizedProps === 'string' ? f.memoizedProps : S
|
|
|
59
59
|
*/
|
|
60
60
|
export function findTarget(root, target) {
|
|
61
61
|
if (!target) return null;
|
|
62
|
+
// A LIST ITEM by the start of its testID (0.13.0): `walk-card-participant-`
|
|
63
|
+
// is the first walker's photo, whoever the copy's seed made them.
|
|
64
|
+
if (target.testIdPrefix) {
|
|
65
|
+
for (const f of walk(root)) if (f.tag === HOST && String(f.memoizedProps?.testID ?? '').startsWith(target.testIdPrefix)) return f;
|
|
66
|
+
}
|
|
62
67
|
if (target.testId) {
|
|
63
68
|
for (const f of walk(root)) if (f.tag === HOST && f.memoizedProps?.testID === target.testId) return f;
|
|
64
69
|
}
|
|
@@ -174,6 +179,29 @@ export function cardBox(place, at, screen, insets, card) {
|
|
|
174
179
|
return { left: box.left, top: box.top + insets.top };
|
|
175
180
|
}
|
|
176
181
|
|
|
182
|
+
// PINNED TO THE TOP (0.13.0, Michael 2026-11-05: "the demo should maybe be
|
|
183
|
+
// pinned to the top by default, but with a collapse button"). The card sits
|
|
184
|
+
// across the top of the screen; only when it would cover the ringed element
|
|
185
|
+
// (`at`) does it move to the bottom. Pure.
|
|
186
|
+
export function pinnedBox(at, screen, insets, card) {
|
|
187
|
+
const left = 12, width = screen.width - 24;
|
|
188
|
+
const top = insets.top + 8;
|
|
189
|
+
const bottom = screen.height - insets.bottom - card.h - 8;
|
|
190
|
+
const covers = at && at.top < top + card.h + 8 && at.bottom > top;
|
|
191
|
+
return { left, width, top: covers ? Math.max(top, bottom) : top };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// COLLAPSED, AND RESUMED FROM THE SSB BUTTON (0.13.0): collapsing leaves only
|
|
195
|
+
// the in-app SSB button (packages/ssb-overlay), whose tap resumes the review.
|
|
196
|
+
// The overlay says it is there (`announceOverlay`); without one the player
|
|
197
|
+
// keeps its own pill. `tuckedDemo()` is the resume function while collapsed.
|
|
198
|
+
let tuck = null;
|
|
199
|
+
let overlayHere = false;
|
|
200
|
+
export function setTuckedDemo(resume) { tuck = typeof resume === 'function' ? resume : null; }
|
|
201
|
+
export const tuckedDemo = () => tuck;
|
|
202
|
+
export function announceOverlay() { overlayHere = true; }
|
|
203
|
+
export const overlayAnnounced = () => overlayHere;
|
|
204
|
+
|
|
177
205
|
// A DEMO STARTED AT RUN TIME (2026-10-20, the in-app overlay's ▶ Demo, "Ready
|
|
178
206
|
// is this build"): the bundle running IS the preview of the work, so nothing
|
|
179
207
|
// is baked into it — SimonSays hands the overlay a demo's id and token, and
|
package/src/signin.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// only then starts the tour, on that screen. Pure, so node --test runs it.
|
|
7
7
|
|
|
8
8
|
const SETTLE_MIN_MS = 1500; // the app's handler signs in and routes before anything is looked for
|
|
9
|
-
const SETTLE_MAX_MS = 12000; // then the
|
|
9
|
+
const SETTLE_MAX_MS = 12000; // and then the sign-in has not taken: said, the tour stops (0.12.0)
|
|
10
10
|
const POLL_MS = 250;
|
|
11
11
|
|
|
12
12
|
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -38,8 +38,8 @@ export const signsIn = (demo, { here = false } = {}) => !here && demo?.platform
|
|
|
38
38
|
/**
|
|
39
39
|
* Sign `actorIndex` in, then resolve once the app has settled. `openURL` is
|
|
40
40
|
* Linking.openURL; `settled()` says the first screen is up (its element is
|
|
41
|
-
* found). → { ok: true } | { ok: false, error } (the player
|
|
42
|
-
*
|
|
41
|
+
* found). → { ok: true } | { ok: false, error } (the player stops the tour
|
|
42
|
+
* with Try again).
|
|
43
43
|
*/
|
|
44
44
|
export async function signInFirst({ api, actorIndex, openURL, settled = () => true, sleep = pause, minMs = SETTLE_MIN_MS, maxMs = SETTLE_MAX_MS }) {
|
|
45
45
|
let link;
|
|
@@ -51,6 +51,12 @@ export async function signInFirst({ api, actorIndex, openURL, settled = () => tr
|
|
|
51
51
|
return { ok: false, error: e?.message || String(e) };
|
|
52
52
|
}
|
|
53
53
|
await sleep(minMs);
|
|
54
|
-
|
|
54
|
+
let ok = false;
|
|
55
|
+
for (let waited = minMs; waited < maxMs; waited += POLL_MS) { if ((ok = !!settled())) break; await sleep(POLL_MS); }
|
|
56
|
+
// CONFIRMED, NOT ASSUMED (0.12.0): the first screen past sign-in never came
|
|
57
|
+
// up, so the sign-in did not take — the app's own handler failed (2026-11-05:
|
|
58
|
+
// "Failed to verify token: [auth/network-request-failed]" while the tour
|
|
59
|
+
// carried on to Next and Take me there). Said, and the tour stops.
|
|
60
|
+
if (!ok) return { ok: false, error: "the app didn't sign in (its first screen never appeared)" };
|
|
55
61
|
return { ok: true };
|
|
56
62
|
}
|