@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 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.11.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) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(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 Next; never a silent skip.
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 leaves the card saying who to be (stage 1).
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 demo couldn't set this up: ${event.error}` : step.caption,
248
- actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Next', onPress: next, primary: true }] : [],
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 ? step.fallback : step.caption,
255
- actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Next', onPress: next, primary: true }] : [],
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
- function Card({ at, place, screen, insets, card, setCard, title, body, actions = [] }) {
398
- const box = cardBox(place, at, screen, insets, card);
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, maxWidth: screen.width - 32 }],
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(Text, { style: s.title }, title),
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.11.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 tour starts anyway: a step finds its own element for 8s more
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 falls back to
42
- * saying who to sign in as).
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
- for (let waited = minMs; waited < maxMs && !settled(); waited += POLL_MS) await sleep(POLL_MS);
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
  }