@simon-says-build/demo-player 0.5.0 → 0.7.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/README.md CHANGED
@@ -22,4 +22,8 @@ Pass the `process.env.EXPO_PUBLIC_*` reads in the app's own code. Expo inlines t
22
22
 
23
23
  Without all three it renders its children and nothing else. Elements are found by `testID`, then `accessibilityLabel`, then visible text, by walking React's tree. No component needs wrapping.
24
24
 
25
+ **0.7.0: the sign-in link opens through the app's router** (`openInApp`), not `Linking.openURL`. The link names app.json's scheme, and a review plays in the dev build, whose scheme can differ (Walk2Gether's is `walk2gether-dev`), so the OS opened the production app or nothing. Without a `router`, the OS opens it as before.
26
+
27
+ **0.6.0: SSB signs the viewer in** on a phone's private, seeded copy (docs/MOBILE-DEMO.md "Sign-in (stage 2: SSB signs in)"). Before each person's part the player asks the demo API for their sign-in link (`POST /api/demo/:id/signin`), opens it with `Linking.openURL`, waits for the app to settle, and starts on the screen the link opened. The walk's own sign-in steps are skipped. The app must handle `<scheme>://auth?token=<custom token>&next=<path>`: sign in with the custom token, then route to `next`. Where there is no link (the app's real backend, no URL scheme, the copy not up), the card says who to sign in as, as before.
28
+
25
29
  `npm test` runs the pure half.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/demo-player",
3
- "version": "0.5.0",
3
+ "version": "0.7.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
@@ -8,12 +8,15 @@
8
8
  // A demo of CHANGES ends on a review card (0.3.0): ✓ Approve or 💬 Discuss
9
9
  // (a RELEASE's demo, 0.4.0: one Approve & release for all of them)
10
10
  // each one, the extension's guided review on the phone (docs/GUIDED-REVIEW.md).
11
+ // On a phone's private copy (0.6.0) it signs each person in first, by the
12
+ // app's own auth deep link, and starts their part on the screen it opens.
11
13
  //
12
14
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
13
15
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
16
  import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
15
17
  import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint } from './core.js';
16
18
  import { demoApi, tourOf } from './api.js';
19
+ import { signInFirst, openInApp } from './signin.js';
17
20
 
18
21
  const h = React.createElement;
19
22
  const FIND_FOR_MS = 8000; // a step's element may appear after a navigation settles
@@ -71,6 +74,7 @@ function Player({ config, router, insets, onClose, children }) {
71
74
  const [changes, setChanges] = useState([]); // what the last card reviews (reviewChangesOf)
72
75
  const [release, setRelease] = useState(null); // a release's demo ends on releasing it (releaseOf)
73
76
  const [reviewing, setReviewing] = useState(false);
77
+ const [signIn, setSignIn] = useState(null); // a `signin` step: null while it runs, else { ok, error }
74
78
 
75
79
  useEffect(() => {
76
80
  let live = true;
@@ -82,6 +86,7 @@ function Player({ config, router, insets, onClose, children }) {
82
86
 
83
87
  const step = steps?.[index] ?? null;
84
88
 
89
+
85
90
  // Find and follow the step's element.
86
91
  useEffect(() => {
87
92
  setRect(null); setFound(null);
@@ -134,6 +139,26 @@ function Player({ config, router, insets, onClose, children }) {
134
139
  next();
135
140
  }, [step, found, next]);
136
141
 
142
+ // SIGN THIS PERSON IN FIRST (a `signin` step, tourOf): open the link, let
143
+ // the app sign in and route, then start their part on that screen. A link
144
+ // that can't be had or opened leaves the card saying who to be (stage 1).
145
+ useEffect(() => {
146
+ setSignIn(null);
147
+ if (step?.kind !== 'signin' || done) return undefined;
148
+ let live = true;
149
+ const first = steps[index + 1];
150
+ signInFirst({
151
+ api, actorIndex: step.actorIndex, openURL: (url) => openInApp(url, { router, openURL: (u) => Linking.openURL(u) }),
152
+ settled: () => !live || !first?.target || first.target.point || !!findTarget(fiberOf(rootRef.current), first.target),
153
+ }).then((r) => {
154
+ if (!live) return;
155
+ if (!r.ok) { setSignIn(r); return; }
156
+ if (index + 1 >= steps.length) { if (changes.length) setReviewing(true); else finish(); }
157
+ else setIndex(index + 1);
158
+ });
159
+ return () => { live = false; };
160
+ }, [step, done]);
161
+
137
162
  const overlay = (() => {
138
163
  if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
139
164
  if (!steps) return null;
@@ -148,6 +173,13 @@ function Player({ config, router, insets, onClose, children }) {
148
173
  }
149
174
  if (!step && changes.length) return h(Review, { api, changes, release, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
150
175
  if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
176
+ if (step.kind === 'signin') {
177
+ return h(Card, {
178
+ at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
179
+ body: signIn ? step.fallback : step.caption,
180
+ actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Next', onPress: next, primary: true }] : [],
181
+ });
182
+ }
151
183
  const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
152
184
  return h(React.Fragment, null,
153
185
  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,
@@ -156,7 +188,7 @@ function Player({ config, router, insets, onClose, children }) {
156
188
  title: `${index + 1} of ${steps.length}`, body: step.caption,
157
189
  actions: [
158
190
  { label: 'Hide', onPress: () => setTucked(true) },
159
- index > 0 ? { label: 'Back', onPress: () => setIndex(index - 1) } : null,
191
+ index > 0 && steps[index - 1].kind !== 'signin' ? { label: 'Back', onPress: () => setIndex(index - 1) } : null,
160
192
  canDo ? { label: step.kind === 'fill' ? 'Type it for me' : 'Do it for me', onPress: doIt } : null,
161
193
  { label: index + 1 >= steps.length ? 'Finish' : step.kind === 'goto' ? 'Take me there' : 'Next', onPress: next, primary: true },
162
194
  ].filter(Boolean),
package/src/api.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { skipAuthSteps } from './auth-steps.js';
2
+ import { signsIn } from './signin.js';
3
+
1
4
  // The demo API the web extension already calls (functions/index.mjs
2
5
  // /api/demo/:id/*), authenticated by the demo's own token.
3
6
  export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
@@ -19,6 +22,8 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
19
22
  // A release's demo (0.4.0): approve and release the session, as the person
20
23
  // the demo was made for, after the five-minute cool-off.
21
24
  release: () => call('/release', { method: 'POST', body: '{}' }),
25
+ // A phone's private copy (0.6.0): the link that signs this person in.
26
+ signin: (actorIndex) => call('/signin', { method: 'POST', body: JSON.stringify({ actorIndex }) }),
22
27
  discuss: (jobId, text) => call('/chat', { method: 'POST', body: JSON.stringify({ text, context: { about: jobId } }) }),
23
28
  };
24
29
  }
@@ -26,13 +31,19 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
26
31
  /** The tour a phone plays: every actor's steps, each actor introduced by who to be. */
27
32
  // A demo played in the build the viewer is already using, signed in as
28
33
  // themselves (`here`, the overlay's ▶ Demo), is not told who to sign in as.
34
+ // One that SIGNS IN ITSELF (0.6.0, `signsIn`: a phone's private copy) opens
35
+ // each person with a `signin` step instead — the player signs them in, the
36
+ // card saying who to be only if that fails — and drops the walk's own sign-in.
29
37
  export function tourOf(demo, { here = false } = {}) {
38
+ const auto = signsIn(demo, { here });
30
39
  const out = [];
31
40
  for (const [actorIndex, a] of (demo?.actors || []).entries()) {
32
41
  const steps = a.tour?.steps || [];
33
42
  if (!steps.length) continue;
34
- if (!here) out.push({ kind: 'say', caption: `This part is as ${a.label}. Sign in as ${a.email} if you aren't already, then tap Next.`, actorIndex, intro: true });
35
- for (const s of steps) out.push({ ...s, actorIndex });
43
+ const ask = `This part is as ${a.label}. Sign in as ${a.email} if you aren't already, then tap Next.`;
44
+ if (auto) out.push({ kind: 'signin', caption: `Signing you in as ${a.label}…`, fallback: ask, actorIndex, intro: true });
45
+ else if (!here) out.push({ kind: 'say', caption: ask, actorIndex, intro: true });
46
+ for (const s of auto ? skipAuthSteps(steps) : steps) out.push({ ...s, actorIndex });
36
47
  }
37
48
  return out;
38
49
  }
@@ -0,0 +1,56 @@
1
+ // THE WALK'S OWN SIGN-IN, SKIPPED (0.6.0, docs/MOBILE-DEMO.md "Sign-in (stage
2
+ // 2: SSB signs in)"): a player that signs the viewer in by link has no use for
3
+ // the taps and typing the recording made to sign in or up. They are found by
4
+ // what they ARE — the screen's path, the element's testID / label / text, the
5
+ // credential typed — never by a caption's wording, and only at the head of a
6
+ // person's steps, before the first screen past sign-in.
7
+
8
+ // Kept equal to functions/sandbox-signin.mjs AUTH_PATH (the server's `next`
9
+ // skips the same screens): expo-router's `(auth)` group, a sign-in, sign-up,
10
+ // login, register or verify-code screen.
11
+ export const AUTH_PATH = /(^|[/(])(auth|sign-?in|sign-?up|log-?in|register|verify(-[a-z]+)?|otp)([/)?#]|$)/i;
12
+ // An element of a sign-in screen, by its testID / accessibility label / text.
13
+ const AUTH_TARGET = /sign[\s_-]?in|sign[\s_-]?up|log[\s_-]?in|register|password|passcode|otp|verif|\bauth|phone|\bcode\b/i;
14
+ // What a sign-in types: an email, a phone number, a texted code (the walk's
15
+ // recorded credential, as schemas/verify-walk.mjs signInOf reads an email).
16
+ const CREDENTIAL = /^([^\s@]+@[^\s@]+\.[^\s@]+|\+?[\d\s().-]{7,}|\d{4,8})$/;
17
+
18
+ const pathOf = (s) => (typeof s?.path === 'string' ? s.path.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '/') : '');
19
+ const targetWords = (t) => (t && typeof t === 'object' ? [t.testId, t.label, t.text, t.name, t.placeholder].filter((v) => typeof v === 'string').join(' ') : '');
20
+
21
+ /** A step that signs in or up: on an auth screen, at an auth element, or typing a credential. */
22
+ export function isAuthStep(s) {
23
+ if (!s) return false;
24
+ if (pathOf(s) && AUTH_PATH.test(pathOf(s))) return true;
25
+ if (AUTH_TARGET.test(targetWords(s.target))) return true;
26
+ return s.kind === 'fill' && CREDENTIAL.test(String(s.value ?? '').trim());
27
+ }
28
+
29
+ /**
30
+ * The index of the first step past the walk's sign-in (0 when it has none).
31
+ * The head ends at the first screen past sign-in: a `show` of the change, or a
32
+ * `goto` to a screen that is not an auth one. A tap or key right after the
33
+ * last credential typed is its submit.
34
+ */
35
+ export function authStepsEnd(steps = []) {
36
+ let end = 0;
37
+ let typedAt = -1;
38
+ for (let i = 0; i < steps.length; i++) {
39
+ const s = steps[i];
40
+ if (!s || s.kind === 'show') break;
41
+ if (s.kind === 'goto' && !isAuthStep(s)) break;
42
+ if (isAuthStep(s)) {
43
+ end = i + 1;
44
+ if (s.kind === 'fill') typedAt = i;
45
+ } else if ((s.kind === 'click' || s.kind === 'press') && typedAt >= 0 && typedAt === i - 1) {
46
+ end = i + 1;
47
+ }
48
+ }
49
+ return end;
50
+ }
51
+
52
+ /** A person's steps without the walk's sign-in. A caption card (`say`) in it stays. */
53
+ export function skipAuthSteps(steps = []) {
54
+ const end = authStepsEnd(steps);
55
+ return steps.filter((s, i) => i >= end || s?.kind === 'say');
56
+ }
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.5.0';
13
+ export const PLAYER_VERSION = '0.7.0';
14
14
 
15
15
  export const HOST = 5;
16
16
  export const TEXT = 6;
@@ -19,8 +19,9 @@ export const TEXT = 6;
19
19
  export const RECORDED_SCREEN = { width: 393, height: 852 };
20
20
 
21
21
  // What a phone can perform. `press` (a keyboard key), `select` (a web
22
- // <select>) and `run` (a terminal demo) have no phone equivalent.
23
- const PHONE_KINDS = new Set(['say', 'goto', 'show', 'click', 'fill']);
22
+ // <select>) and `run` (a terminal demo) have no phone equivalent. `signin` is
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']);
24
25
 
25
26
  const lower = (s) => String(s ?? '').trim().toLowerCase();
26
27
  const hiddenHost = (f) => {
package/src/signin.js ADDED
@@ -0,0 +1,56 @@
1
+ // SSB SIGNS THE VIEWER IN (0.6.0, docs/MOBILE-DEMO.md "Sign-in (stage 2)").
2
+ // On a private, seeded copy the demo API answers a person's sign-in link,
3
+ // `<scheme>://auth?token=…&next=<first screen>` (functions/sandbox-signin.mjs).
4
+ // The player opens it FIRST; the app's own auth deep-link handler signs in
5
+ // with the token and routes to `next`; the player waits for that to settle and
6
+ // only then starts the tour, on that screen. Pure, so node --test runs it.
7
+
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
10
+ const POLL_MS = 250;
11
+
12
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
13
+
14
+ /**
15
+ * The app path of a link in the app's own deep-link form: `x://auth?token=…`
16
+ * → `/auth?token=…`. The player opens its sign-in through the app's ROUTER
17
+ * (0.7.0), never the OS: the link names the app's scheme as app.json says it,
18
+ * and a review plays in the DEV build, whose scheme differs (walk2gether-dev),
19
+ * so Linking.openURL opened the production app, or nothing (2026-10-31). The
20
+ * router is always the app the player runs in. Pure; null for anything else.
21
+ */
22
+ export function inAppPath(link) {
23
+ const m = /^[a-z][a-z0-9+.-]*:\/\/(.*)$/i.exec(String(link || ''));
24
+ if (!m || /^https?:/i.test(String(link))) return null;
25
+ return `/${m[1].replace(/^\/+/, '')}`;
26
+ }
27
+
28
+ /** Open a sign-in link in THIS app: its router when it has one, else the OS. */
29
+ export async function openInApp(link, { router, openURL }) {
30
+ const p = inAppPath(link);
31
+ if (p && router?.push) { router.push(p); return; }
32
+ await openURL(link);
33
+ }
34
+
35
+ /** Whether a demo signs its people in itself: a phone's private copy, not a build the viewer is already in. */
36
+ export const signsIn = (demo, { here = false } = {}) => !here && demo?.platform === 'ios' && demo?.backend === 'sandbox';
37
+
38
+ /**
39
+ * Sign `actorIndex` in, then resolve once the app has settled. `openURL` is
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).
43
+ */
44
+ export async function signInFirst({ api, actorIndex, openURL, settled = () => true, sleep = pause, minMs = SETTLE_MIN_MS, maxMs = SETTLE_MAX_MS }) {
45
+ let link;
46
+ try {
47
+ ({ link } = await api.signin(actorIndex));
48
+ if (typeof link !== 'string' || !link) throw new Error('no sign-in link');
49
+ await openURL(link);
50
+ } catch (e) {
51
+ return { ok: false, error: e?.message || String(e) };
52
+ }
53
+ await sleep(minMs);
54
+ for (let waited = minMs; waited < maxMs && !settled(); waited += POLL_MS) await sleep(POLL_MS);
55
+ return { ok: true };
56
+ }
package/src/step-place.js CHANGED
@@ -9,13 +9,18 @@
9
9
  // pinTo below | above | left | right beside the element, `offset` px away
10
10
  // top | bottom pinned to that edge of the screen,
11
11
  // `offset` px in (an arrow points at the element)
12
- // offset 0–600 px (default 14 beside an element, 24 at an edge)
12
+ // offset 0–600 px (default 44 beside an element, 24 at an edge)
13
+ //
14
+ // Beside an element the bubble stands OFF it by default, far enough that the
15
+ // player draws its arrow to the ring (2026-10-26, Simon's budget guide: a
16
+ // bubble flush against a drag handle sat on the track he was dragging along).
13
17
  //
14
18
  // No placement is the old rule: below the element when it fits, else above.
15
19
  // Pure and browser-safe: demo-step writes it, the rehearsal keeps it, the
16
20
  // player (extension/overlay.js) reads it.
17
21
  export const PIN_TO = ['below', 'above', 'left', 'right', 'top', 'bottom'];
18
22
  export const MAX_OFFSET = 600;
23
+ export const BESIDE_OFFSET = 44;
19
24
 
20
25
  /** A placement, cleaned; null when there is none (or it is not one). */
21
26
  export function cleanPlace(raw) {
@@ -43,7 +48,7 @@ export function bubbleBox(place, el, view, bubble) {
43
48
  const left = clampX(el ? el.left + el.width / 2 - bubble.w / 2 : (view.w - bubble.w) / 2);
44
49
  return { left, top: p?.pinTo === 'top' ? clampY(off) : clampY(view.h - bubble.h - off) };
45
50
  }
46
- const off = p?.offset ?? 14;
51
+ const off = p?.offset ?? BESIDE_OFFSET;
47
52
  const pin = p?.pinTo || (el.bottom + off + bubble.h <= view.h - 16 ? 'below' : 'above');
48
53
  if (pin === 'below') return { left: clampX(el.left), top: clampY(el.bottom + off) };
49
54
  if (pin === 'above') return { left: clampX(el.left), top: clampY(el.top - off - bubble.h) };