@simon-says-build/demo-player 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/demo-player",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "SimonSays mobile demo player: walks a preview's viewer through a job's change, inside the app. Renders nothing unless the bundle carries a demo.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/DemoHost.js CHANGED
@@ -14,7 +14,8 @@
14
14
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
15
15
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
17
- import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, stepTitle } from './core.js';
17
+ import { pageName } from './page-name.js';
18
+ import { OVERLAY_ID, cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, rescueNote, screenCandidates, stepTitle, targetOfKey } from './core.js';
18
19
  import { demoApi, tourOf } from './api.js';
19
20
  import { signInFirst, openInApp } from './signin.js';
20
21
  import { runEvent, fillStep } from './events.js';
@@ -78,6 +79,9 @@ function Player({ config, router, insets, onClose, children }) {
78
79
  const [signIn, setSignIn] = useState(null); // a `signin` step: null while it runs, else { ok, error }
79
80
  const [event, setEvent] = useState(null); // an `event` step: null while it runs, else { ok, error }
80
81
  const [vars, setVars] = useState({}); // per person: what their events set up ({walkId} in later steps)
82
+ const [flash, setFlash] = useState(null); // "Now navigating to the walk page", said before a goto opens it
83
+ const [rescued, setRescued] = useState(null); // { for: index, status: asking|moved|gone, target?, why? }
84
+ const asked = useRef(new Set()); // steps already rescued once (by index)
81
85
 
82
86
  useEffect(() => {
83
87
  let live = true;
@@ -90,11 +94,34 @@ function Player({ config, router, insets, onClose, children }) {
90
94
  const step = useMemo(() => { const raw = steps?.[index] ?? null; return raw ? fillStep(raw, vars[raw.actorIndex]) : null; }, [steps, index, vars]);
91
95
 
92
96
 
97
+ // THE RESCUE (0.9.0, Michael 2026-11-01: "when the current UI differs
98
+ // substantially from the recorded one, maybe realtime AI should step in,
99
+ // instead of showing an error"): a step whose element never appears asks SSB
100
+ // once, with what IS on screen. It answers with one of those (renamed or
101
+ // moved: the step carries on there) or says what is different now, which the
102
+ // card says in SSB's words. The extension's rescue, on a phone.
103
+ const askRescue = useCallback(async (at, idx) => {
104
+ if (asked.current.has(idx) || at.stepIndex == null) return;
105
+ asked.current.add(idx);
106
+ setRescued({ for: idx, status: 'asking' });
107
+ try {
108
+ let r = await api.repair({ actorIndex: at.actorIndex, stepIndex: at.stepIndex, candidates: screenCandidates(fiberOf(rootRef.current)) });
109
+ for (let i = 0; r.status === 'pending' || r.status === 'running'; i++) {
110
+ if (i >= RESCUE_POLLS) throw new Error('no answer');
111
+ await new Promise((ok) => setTimeout(ok, 2500));
112
+ r = await api.repairStatus(r.id);
113
+ }
114
+ const target = r.pick != null ? targetOfKey(r.css) : null;
115
+ setRescued(target ? { for: idx, status: 'moved', target, why: r.why || '' } : { for: idx, status: 'gone', why: r.why || '' });
116
+ } catch { setRescued({ for: idx, status: 'gone', why: '' }); }
117
+ }, [api]);
118
+ const rescue = rescued?.for === index ? rescued : null;
119
+
93
120
  // Find and follow the step's element.
94
121
  useEffect(() => {
95
122
  setRect(null); setFound(null);
96
123
  if (!step || done) return undefined;
97
- const target = step.target;
124
+ const target = rescue?.status === 'moved' ? rescue.target : step.target;
98
125
  if (!target) return undefined;
99
126
  if (target.point && !target.testId && !target.label && !target.text) {
100
127
  const p = scalePoint(target.point, screen);
@@ -112,12 +139,12 @@ function Player({ config, router, insets, onClose, children }) {
112
139
  if (!r && Date.now() - started > FIND_FOR_MS && target.point) {
113
140
  const p = scalePoint(target.point, screen);
114
141
  setRect({ left: p.x - 22, top: p.y - 22, width: 44, height: 44, right: p.x + 22, bottom: p.y + 22 });
115
- }
142
+ } else if (!r && Date.now() - started > FIND_FOR_MS && !rescue) askRescue(step, index);
116
143
  };
117
144
  tick();
118
145
  const t = setInterval(tick, TRACK_EVERY_MS);
119
146
  return () => { live = false; clearInterval(t); };
120
- }, [step, done, screen.width, screen.height]);
147
+ }, [step, done, screen.width, screen.height, rescue?.status, index]);
121
148
 
122
149
  const finish = useCallback(() => {
123
150
  setReviewing(false);
@@ -125,15 +152,27 @@ function Player({ config, router, insets, onClose, children }) {
125
152
  api.progress({ status: 'done' }).catch(() => {});
126
153
  }, [api]);
127
154
 
155
+ const advance = useCallback(() => {
156
+ if (index + 1 >= steps.length) { if (changes.length) setReviewing(true); else finish(); }
157
+ else setIndex(index + 1);
158
+ }, [steps, index, finish, changes]);
159
+ // SAID BEFORE IT GOES (2026-11-01, Michael): a goto flashes "Now navigating
160
+ // to the walk page" for a moment, then opens it (schemas/page-name.mjs).
128
161
  const next = useCallback(() => {
129
- if (!steps) return;
162
+ if (!steps || flash) return;
130
163
  if (step?.kind === 'goto' && step.path) {
131
- if (step.path.startsWith('/') && router?.push) router.push(step.path);
132
- else Linking.openURL(step.path).catch(() => {});
164
+ const path = step.path;
165
+ setFlash(`Now navigating to ${pageName(path)}`);
166
+ setTimeout(() => {
167
+ if (path.startsWith('/') && router?.push) router.push(path);
168
+ else Linking.openURL(path).catch(() => {});
169
+ setFlash(null);
170
+ advance();
171
+ }, FLASH_MS);
172
+ return;
133
173
  }
134
- if (index + 1 >= steps.length) { if (changes.length) setReviewing(true); else finish(); }
135
- else setIndex(index + 1);
136
- }, [steps, step, index, router, finish, changes]);
174
+ advance();
175
+ }, [steps, step, router, advance, flash]);
137
176
 
138
177
  const doIt = useCallback(() => {
139
178
  if (!step || !found) return;
@@ -183,6 +222,8 @@ function Player({ config, router, insets, onClose, children }) {
183
222
  if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
184
223
  if (!steps) return null;
185
224
  if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
225
+ if (flash) return h(View, { pointerEvents: 'none', style: [s.flash, { top: insets.top + screen.height * 0.4 }], testID: 'ssb-demo-flash' },
226
+ h(Text, { style: s.flashText, accessibilityLiveRegion: 'polite' }, flash));
186
227
  if (tucked || done) {
187
228
  // A demo started at run time closes when it is done (▶ Demo again replays it);
188
229
  // one baked into a preview stays, to be replayed from its pill.
@@ -212,7 +253,7 @@ function Player({ config, router, insets, onClose, children }) {
212
253
  rect ? h(View, { pointerEvents: 'none', style: [s.ring, { left: rect.left - 6, top: rect.top - 6, width: rect.width + 12, height: rect.height + 12 }] }) : null,
213
254
  h(Card, {
214
255
  at: rect, place: step.place, screen, insets, card, setCard,
215
- title: stepTitle(step, index, steps.length), body: step.caption,
256
+ title: stepTitle(step, index, steps.length), body: rescueNote(step.caption, rescue),
216
257
  actions: [
217
258
  { label: 'Hide', onPress: () => setTucked(true) },
218
259
  index > 0 && steps[index - 1].kind !== 'signin' ? { label: 'Back', onPress: () => setIndex(index - 1) } : null,
@@ -224,7 +265,7 @@ function Player({ config, router, insets, onClose, children }) {
224
265
 
225
266
  return h(View, { ref: rootRef, style: s.root, collapsable: false },
226
267
  children,
227
- h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
268
+ h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill, testID: OVERLAY_ID }, overlay));
228
269
  }
229
270
 
230
271
  // The review card: every change of the demo, each approved here or discussed
@@ -307,6 +348,9 @@ function Card({ at, place, screen, insets, card, setCard, title, body, actions =
307
348
  }, h(Text, { style: [s.buttonText, a.primary && s.primaryText] }, a.label)))) : null);
308
349
  }
309
350
 
351
+ const RESCUE_POLLS = 40; // ~100 s for the daemon's fallback answer, as the extension waits
352
+ const FLASH_MS = 1100; // long enough to read "Now navigating to the walk page"
353
+
310
354
  const s = StyleSheet.create({
311
355
  root: { flex: 1 },
312
356
  ring: { position: 'absolute', borderRadius: 12, borderWidth: 3, borderColor: '#FFFFFF', shadowColor: '#000', shadowOpacity: 0.6, shadowRadius: 8, shadowOffset: { width: 0, height: 0 } },
@@ -327,6 +371,8 @@ const s = StyleSheet.create({
327
371
  changeState: { color: '#AEAEB2', fontSize: 13, marginTop: 4 },
328
372
  error: { color: '#FF6961', fontSize: 13, marginTop: 4 },
329
373
  input: { marginTop: 10, minHeight: 64, padding: 10, borderRadius: 10, backgroundColor: '#2C2C2E', color: '#FFFFFF', fontSize: 15 },
374
+ flash: { position: 'absolute', left: 32, right: 32, alignItems: 'center', paddingVertical: 14, paddingHorizontal: 18, borderRadius: 14, backgroundColor: 'rgba(28,28,30,0.92)' },
375
+ flashText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600', textAlign: 'center' },
330
376
  pill: { position: 'absolute', right: 16, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: '#1C1C1E' },
331
377
  pillText: { color: '#FFFFFF', fontSize: 13, fontWeight: '600' },
332
378
  });
package/src/api.js CHANGED
@@ -28,6 +28,11 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
28
28
  // A planned review (0.8.0): run a helper on the private copy, then read how it went.
29
29
  event: (actorIndex, helper, args) => call('/event', { method: 'POST', body: JSON.stringify({ actorIndex, helper, args }) }),
30
30
  eventStatus: (eventId) => call(`/event/${encodeURIComponent(eventId)}`),
31
+ // The rescue (0.9.0): a step whose element is not on screen asks where it
32
+ // went, with what IS on screen; the answer is an index into those, or why
33
+ // it is gone. The same route the extension calls.
34
+ repair: (body) => call('/repair', { method: 'POST', body: JSON.stringify(body) }),
35
+ repairStatus: (repairId) => call(`/repair/${encodeURIComponent(repairId)}`),
31
36
  discuss: (jobId, text) => call('/chat', { method: 'POST', body: JSON.stringify({ text, context: { about: jobId } }) }),
32
37
  };
33
38
  }
@@ -49,7 +54,9 @@ export function tourOf(demo, { here = false } = {}) {
49
54
  if (auto && hasState(a)) out.push({ kind: 'event', helper: '@state', caption: `Setting up ${a.label}'s part…`, actorIndex, intro: true });
50
55
  if (auto) out.push({ kind: 'signin', caption: `Signing you in as ${a.label}…`, fallback: ask, actorIndex, intro: true });
51
56
  else if (!here) out.push({ kind: 'say', caption: ask, actorIndex, intro: true });
52
- for (const s of auto ? skipAuthSteps(steps) : steps) out.push({ ...s, actorIndex });
57
+ // stepIndex: the step's place in the person's recorded tour, which the
58
+ // rescue names (/repair reads actor.tour.steps[stepIndex]).
59
+ for (const s of auto ? skipAuthSteps(steps) : steps) out.push({ ...s, actorIndex, stepIndex: steps.indexOf(s) });
53
60
  }
54
61
  return out;
55
62
  }
package/src/core.js CHANGED
@@ -10,7 +10,7 @@ import { bubbleBox } from './step-place.js';
10
10
  // What this player is, said when the app registers for "Ready to see"
11
11
  // (packages/ssb-overlay sends it; functions/in-app-notify.mjs clientOf). Kept
12
12
  // equal to package.json by test/core.test.mjs.
13
- export const PLAYER_VERSION = '0.8.1';
13
+ export const PLAYER_VERSION = '0.9.0';
14
14
 
15
15
  export const HOST = 5;
16
16
  export const TEXT = 6;
@@ -30,12 +30,13 @@ const hiddenHost = (f) => {
30
30
  return flat?.display === 'none';
31
31
  };
32
32
 
33
- function* walk(root) {
34
- // Iterative depth-first, skipping a display:none host and everything in it.
33
+ function* walk(root, skip = null) {
34
+ // Iterative depth-first, skipping a display:none host and everything in it
35
+ // (and any host `skip` names, with everything in it).
35
36
  const stack = root ? [root] : [];
36
37
  while (stack.length) {
37
38
  const f = stack.pop();
38
- if (f.tag === HOST && hiddenHost(f)) continue;
39
+ if (f.tag === HOST && (hiddenHost(f) || (skip && skip(f)))) continue;
39
40
  yield f;
40
41
  const kids = [];
41
42
  for (let c = f.child; c; c = c.sibling) kids.push(c);
@@ -86,6 +87,49 @@ export function findTarget(root, target) {
86
87
  return null;
87
88
  }
88
89
 
90
+ // WHAT IS ON SCREEN, for the rescue (0.9.0, as the extension's candidates()):
91
+ // every element a person could name, as the step's own target vocabulary. The
92
+ // key is what comes back: the server's rescue answers with an INDEX into
93
+ // these, never a target it wrote (functions/demo-rescue.mjs).
94
+ const keyOf = (t) => (t.testId ? `testId:${t.testId}` : t.label ? `label:${t.label}` : `text:${t.text}`);
95
+ /** The keyed target a rescue's answer names back ("label:Share" → { label: 'Share' }), or null. */
96
+ export function targetOfKey(key) {
97
+ const m = /^(testId|label|text):(.+)$/s.exec(String(key || ''));
98
+ return m ? { [m[1]]: m[2] } : null;
99
+ }
100
+ // The card's words while a step is rescued: SSB's, never an error. Pure.
101
+ export function rescueNote(caption, rescue) {
102
+ if (!rescue) return caption;
103
+ if (rescue.status === 'asking') return `${caption}\n\nSSB is looking for where this is now…`;
104
+ if (rescue.status === 'moved') return `${caption}\n\n${rescue.why ? `${rescue.why} ` : ''}SSB found it here.`;
105
+ return `${caption}\n\n${rescue.why || 'This screen has changed since the review was recorded.'} Tap Next to carry on, or look around yourself.`;
106
+ }
107
+ // The player's own layer: never a candidate (its card's words are not the app's).
108
+ export const OVERLAY_ID = 'ssb-demo-overlay';
109
+ /** Everything on screen a step could point at: [{ css: key, tag, name?, text? }], at most `max`. */
110
+ export function screenCandidates(root, max = 60) {
111
+ const out = [];
112
+ const seen = new Set();
113
+ const add = (t, tag, name, text) => {
114
+ const css = keyOf(t);
115
+ if (seen.has(css) || out.length >= max) return;
116
+ seen.add(css);
117
+ out.push({ css, tag, ...(name ? { name: name.slice(0, 200) } : {}), ...(text ? { text: text.slice(0, 200) } : {}) });
118
+ };
119
+ for (const f of walk(root, (h) => h.memoizedProps?.testID === OVERLAY_ID)) {
120
+ if (out.length >= max) break;
121
+ const p = f.memoizedProps || {};
122
+ if (f.tag === HOST && (p.testID || p.accessibilityLabel)) {
123
+ const label = String(p.accessibilityLabel || p['aria-label'] || '').trim();
124
+ add(p.testID ? { testId: String(p.testID) } : { label }, p.accessibilityRole || (typeof p.onChangeText === 'function' ? 'input' : 'view'), label, '');
125
+ } else if (f.tag === TEXT) {
126
+ const text = textOf(f).trim();
127
+ if (text && text.length <= 80) add({ text }, 'text', '', text);
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
89
133
  /** The nearest `name` handler at or above `f` (onPress, onChangeText), or null. */
90
134
  export function handlerFor(f, name) {
91
135
  for (let p = f; p; p = p.return) {
@@ -0,0 +1,23 @@
1
+ // THE PAGE A STEP OPENS, in words (2026-11-01, Michael: "before any navigation,
2
+ // can we show a brief flash showing 'Now navigating to [the X page]'"). Both
3
+ // players say it before they navigate: the phone's (packages/demo-player/src/
4
+ // page-name.js) and the extension's (extension/overlay.js) carry copies of
5
+ // pageName, held equal by bin/page-name.test.mjs. Pure.
6
+ //
7
+ // /walk/{walkId} → the walk page (an id or {placeholder} is not a name)
8
+ // /(app)/settings/profile → the profile page (an Expo Router group is not a name)
9
+ // /MainStack/Tabs/Dashboard → the dashboard page (a React Navigation chain)
10
+ // walk2gether://walk/abc → the walk page (a deep link)
11
+ // / → the home page
12
+ export function pageName(path) {
13
+ // A web address drops its host; a deep link's "host" is its first screen.
14
+ const rest = String(path || '').replace(/^https?:\/\/[^/]*/i, '').replace(/^[a-z][a-z0-9+.-]*:\/\//i, '/').split(/[?#]/)[0];
15
+ const isId = (s) => /^\{.*\}$/.test(s) || /^\[.*\]$/.test(s) || /^:/.test(s) || /^\(.*\)$/.test(s) || s === 'index'
16
+ || /^[0-9]+$/.test(s) || /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(s) || (s.length >= 16 && /[0-9]/.test(s) && /^[A-Za-z0-9_-]+$/.test(s));
17
+ const parts = rest.split('/').filter(Boolean).map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).filter((s) => !isId(s));
18
+ const last = parts[parts.length - 1];
19
+ if (!last) return 'the home page';
20
+ const words = last.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_.]+/g, ' ').trim().toLowerCase();
21
+ return `the ${words || 'home'} page`;
22
+ }
23
+ // END pageName