@simon-says-build/demo-player 0.4.0 → 0.6.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,6 @@ 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.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.
26
+
25
27
  `npm test` runs the pure half.
package/index.d.ts CHANGED
@@ -31,3 +31,13 @@ export const PLAYER_VERSION: string;
31
31
  export function reviewChangesOf(demo: unknown): { jobId: string; title: string }[];
32
32
  /** A release's demo (0.4.0): the session merge its review card releases, or null. */
33
33
  export function releaseOf(demo: unknown): { jobId: string; label: string; note: string } | null;
34
+
35
+ /**
36
+ * React Navigation (an app without expo-router): a path is the chain of route
37
+ * names to the focused screen, "/MainStack/Tabs/Dashboard". `nav` is the
38
+ * NavigationContainer's ref (or the container), read at call time.
39
+ */
40
+ export function navigationRoute(nav: unknown): string;
41
+ export function navigationRouter(nav: unknown): { push: (path: string) => boolean };
42
+ export function navigateArgs(path: string): [string] | [string, object] | null;
43
+ export function pathOfState(state: unknown): string;
package/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { DemoHost } from './src/DemoHost.js';
2
2
  export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, PLAYER_VERSION } from './src/core.js';
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.4.0",
3
+ "version": "0.6.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 } 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) => Linking.openURL(url),
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.4.0';
13
+ export const PLAYER_VERSION = '0.6.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) => {
@@ -0,0 +1,52 @@
1
+ // REACT NAVIGATION, for an app without expo-router (2026-10-22, SuperSub's
2
+ // Center and Sub apps). A demo's `goto` step carries an in-app PATH, and the
3
+ // in-app overlay reports the screen a person is on as one. For an
4
+ // expo-router app that is its URL path. For a React Navigation app it is the
5
+ // chain of route NAMES from the root to the focused screen:
6
+ //
7
+ // /MainStack/Tabs/Dashboard
8
+ //
9
+ // navigationRoute() reads that off the navigation container, and
10
+ // navigationRouter() replays it with nested navigate() calls, so a path the
11
+ // overlay recorded is one the demo player can open. Both take the container
12
+ // (or a ref to it, read at call time). Pure apart from the container.
13
+
14
+ const containerOf = (nav) => (nav && 'current' in nav ? nav.current : nav);
15
+
16
+ /** The focused route's path, "/A/B/C", from a navigation state; "" when there is none. */
17
+ export function pathOfState(state) {
18
+ const names = [];
19
+ for (let s = state; s && Array.isArray(s.routes) && s.routes.length;) {
20
+ const r = s.routes[Number.isInteger(s.index) ? s.index : s.routes.length - 1];
21
+ if (!r?.name) break;
22
+ names.push(r.name);
23
+ s = r.state;
24
+ }
25
+ return names.length ? `/${names.map(encodeURIComponent).join('/')}` : '';
26
+ }
27
+
28
+ /** What navigate() is called with to open a path: [name, params], or null for a path that names nothing. */
29
+ export function navigateArgs(path) {
30
+ const names = String(path || '').split('?')[0].split('/').filter(Boolean).map((n) => { try { return decodeURIComponent(n); } catch { return n; } });
31
+ if (!names.length) return null;
32
+ let params;
33
+ for (let i = names.length - 1; i >= 1; i--) params = params ? { screen: names[i], params } : { screen: names[i] };
34
+ return params ? [names[0], params] : [names[0]];
35
+ }
36
+
37
+ /** The current screen's path on a React Navigation container ("" before it is ready). */
38
+ export function navigationRoute(nav) {
39
+ try { return pathOfState(containerOf(nav)?.getRootState?.()); } catch { return ''; }
40
+ }
41
+
42
+ /** A `router` for <DemoHost> on a React Navigation app: push(path) opens it. */
43
+ export function navigationRouter(nav) {
44
+ return {
45
+ push(path) {
46
+ const c = containerOf(nav);
47
+ const args = navigateArgs(path);
48
+ if (!c?.navigate || !args) return false;
49
+ try { c.navigate(...args); return true; } catch { return false; }
50
+ },
51
+ };
52
+ }
package/src/signin.js ADDED
@@ -0,0 +1,35 @@
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
+ /** Whether a demo signs its people in itself: a phone's private copy, not a build the viewer is already in. */
15
+ export const signsIn = (demo, { here = false } = {}) => !here && demo?.platform === 'ios' && demo?.backend === 'sandbox';
16
+
17
+ /**
18
+ * Sign `actorIndex` in, then resolve once the app has settled. `openURL` is
19
+ * Linking.openURL; `settled()` says the first screen is up (its element is
20
+ * found). → { ok: true } | { ok: false, error } (the player falls back to
21
+ * saying who to sign in as).
22
+ */
23
+ export async function signInFirst({ api, actorIndex, openURL, settled = () => true, sleep = pause, minMs = SETTLE_MIN_MS, maxMs = SETTLE_MAX_MS }) {
24
+ let link;
25
+ try {
26
+ ({ link } = await api.signin(actorIndex));
27
+ if (typeof link !== 'string' || !link) throw new Error('no sign-in link');
28
+ await openURL(link);
29
+ } catch (e) {
30
+ return { ok: false, error: e?.message || String(e) };
31
+ }
32
+ await sleep(minMs);
33
+ for (let waited = minMs; waited < maxMs && !settled(); waited += POLL_MS) await sleep(POLL_MS);
34
+ return { ok: true };
35
+ }
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) };