@simon-says-build/demo-player 0.8.2 → 0.10.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.2",
3
+ "version": "0.10.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
@@ -15,7 +15,7 @@
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
17
  import { pageName } from './page-name.js';
18
- import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, stepTitle } from './core.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';
19
19
  import { demoApi, tourOf } from './api.js';
20
20
  import { signInFirst, openInApp } from './signin.js';
21
21
  import { runEvent, fillStep } from './events.js';
@@ -76,15 +76,18 @@ function Player({ config, router, insets, onClose, children }) {
76
76
  const [changes, setChanges] = useState([]); // what the last card reviews (reviewChangesOf)
77
77
  const [release, setRelease] = useState(null); // a release's demo ends on releasing it (releaseOf)
78
78
  const [reviewing, setReviewing] = useState(false);
79
+ const [intro, setIntro] = useState(null); // the first screen: who asked, what, signed in as (introOf)
79
80
  const [signIn, setSignIn] = useState(null); // a `signin` step: null while it runs, else { ok, error }
80
81
  const [event, setEvent] = useState(null); // an `event` step: null while it runs, else { ok, error }
81
82
  const [vars, setVars] = useState({}); // per person: what their events set up ({walkId} in later steps)
82
83
  const [flash, setFlash] = useState(null); // "Now navigating to the walk page", said before a goto opens it
84
+ const [rescued, setRescued] = useState(null); // { for: index, status: asking|moved|gone, target?, why? }
85
+ const asked = useRef(new Set()); // steps already rescued once (by index)
83
86
 
84
87
  useEffect(() => {
85
88
  let live = true;
86
89
  api.load()
87
- .then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); } })
90
+ .then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(d)); } })
88
91
  .catch((e) => { if (live) setError(e.message); });
89
92
  return () => { live = false; };
90
93
  }, [api]);
@@ -92,11 +95,34 @@ function Player({ config, router, insets, onClose, children }) {
92
95
  const step = useMemo(() => { const raw = steps?.[index] ?? null; return raw ? fillStep(raw, vars[raw.actorIndex]) : null; }, [steps, index, vars]);
93
96
 
94
97
 
98
+ // THE RESCUE (0.9.0, Michael 2026-11-01: "when the current UI differs
99
+ // substantially from the recorded one, maybe realtime AI should step in,
100
+ // instead of showing an error"): a step whose element never appears asks SSB
101
+ // once, with what IS on screen. It answers with one of those (renamed or
102
+ // moved: the step carries on there) or says what is different now, which the
103
+ // card says in SSB's words. The extension's rescue, on a phone.
104
+ const askRescue = useCallback(async (at, idx) => {
105
+ if (asked.current.has(idx) || at.stepIndex == null) return;
106
+ asked.current.add(idx);
107
+ setRescued({ for: idx, status: 'asking' });
108
+ try {
109
+ let r = await api.repair({ actorIndex: at.actorIndex, stepIndex: at.stepIndex, candidates: screenCandidates(fiberOf(rootRef.current)) });
110
+ for (let i = 0; r.status === 'pending' || r.status === 'running'; i++) {
111
+ if (i >= RESCUE_POLLS) throw new Error('no answer');
112
+ await new Promise((ok) => setTimeout(ok, 2500));
113
+ r = await api.repairStatus(r.id);
114
+ }
115
+ const target = r.pick != null ? targetOfKey(r.css) : null;
116
+ setRescued(target ? { for: idx, status: 'moved', target, why: r.why || '' } : { for: idx, status: 'gone', why: r.why || '' });
117
+ } catch { setRescued({ for: idx, status: 'gone', why: '' }); }
118
+ }, [api]);
119
+ const rescue = rescued?.for === index ? rescued : null;
120
+
95
121
  // Find and follow the step's element.
96
122
  useEffect(() => {
97
123
  setRect(null); setFound(null);
98
124
  if (!step || done) return undefined;
99
- const target = step.target;
125
+ const target = rescue?.status === 'moved' ? rescue.target : step.target;
100
126
  if (!target) return undefined;
101
127
  if (target.point && !target.testId && !target.label && !target.text) {
102
128
  const p = scalePoint(target.point, screen);
@@ -114,12 +140,12 @@ function Player({ config, router, insets, onClose, children }) {
114
140
  if (!r && Date.now() - started > FIND_FOR_MS && target.point) {
115
141
  const p = scalePoint(target.point, screen);
116
142
  setRect({ left: p.x - 22, top: p.y - 22, width: 44, height: 44, right: p.x + 22, bottom: p.y + 22 });
117
- }
143
+ } else if (!r && Date.now() - started > FIND_FOR_MS && !rescue) askRescue(step, index);
118
144
  };
119
145
  tick();
120
146
  const t = setInterval(tick, TRACK_EVERY_MS);
121
147
  return () => { live = false; clearInterval(t); };
122
- }, [step, done, screen.width, screen.height]);
148
+ }, [step, done, screen.width, screen.height, rescue?.status, index]);
123
149
 
124
150
  const finish = useCallback(() => {
125
151
  setReviewing(false);
@@ -196,6 +222,12 @@ function Player({ config, router, insets, onClose, children }) {
196
222
  const overlay = (() => {
197
223
  if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
198
224
  if (!steps) return null;
225
+ if (intro && !tucked && !done) {
226
+ return h(Card, {
227
+ at: null, screen, insets, card, setCard, title: 'Guided Review', body: introBody(intro),
228
+ actions: [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Start the review', onPress: () => setIntro(null), primary: true }],
229
+ });
230
+ }
199
231
  if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
200
232
  if (flash) return h(View, { pointerEvents: 'none', style: [s.flash, { top: insets.top + screen.height * 0.4 }], testID: 'ssb-demo-flash' },
201
233
  h(Text, { style: s.flashText, accessibilityLiveRegion: 'polite' }, flash));
@@ -228,7 +260,7 @@ function Player({ config, router, insets, onClose, children }) {
228
260
  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,
229
261
  h(Card, {
230
262
  at: rect, place: step.place, screen, insets, card, setCard,
231
- title: stepTitle(step, index, steps.length), body: step.caption,
263
+ title: stepTitle(step, index, steps.length), body: rescueNote(step.caption, rescue),
232
264
  actions: [
233
265
  { label: 'Hide', onPress: () => setTucked(true) },
234
266
  index > 0 && steps[index - 1].kind !== 'signin' ? { label: 'Back', onPress: () => setIndex(index - 1) } : null,
@@ -240,7 +272,7 @@ function Player({ config, router, insets, onClose, children }) {
240
272
 
241
273
  return h(View, { ref: rootRef, style: s.root, collapsable: false },
242
274
  children,
243
- h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
275
+ h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill, testID: OVERLAY_ID }, overlay));
244
276
  }
245
277
 
246
278
  // The review card: every change of the demo, each approved here or discussed
@@ -323,6 +355,7 @@ function Card({ at, place, screen, insets, card, setCard, title, body, actions =
323
355
  }, h(Text, { style: [s.buttonText, a.primary && s.primaryText] }, a.label)))) : null);
324
356
  }
325
357
 
358
+ const RESCUE_POLLS = 40; // ~100 s for the daemon's fallback answer, as the extension waits
326
359
  const FLASH_MS = 1100; // long enough to read "Now navigating to the walk page"
327
360
 
328
361
  const s = StyleSheet.create({
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.2';
13
+ export const PLAYER_VERSION = '0.10.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) {
@@ -175,3 +219,30 @@ export function releaseOf(demo) {
175
219
  if (!sm || typeof sm.jobId !== 'string' || !sm.jobId) return null;
176
220
  return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || '') };
177
221
  }
222
+
223
+ // THE FIRST SCREEN OF A REVIEW (0.10.0, Michael 2026-11-03): a very brief
224
+ // outline before anything plays — who asked for it, what they asked for, and
225
+ // who the review signs in as — then a button to start. Pure.
226
+ // → { requestedBy: [string], asks: [{ title, asked }], as: [string] } | null
227
+ const firstSentence = (t) => { const x = String(t || '').replace(/\s+/g, ' ').trim(); const m = /^(.{20,160}?[.!?])(\s|$)/.exec(x); return (m ? m[1] : x.slice(0, 140) + (x.length > 140 ? '…' : '')); };
228
+ export function introOf(demo) {
229
+ const asks = [], by = new Set(), as = [];
230
+ for (const a of demo?.actors || []) {
231
+ if (a?.label && !as.includes(a.label)) as.push(String(a.label));
232
+ for (const sec of a.sections || []) for (const f of sec.features || []) {
233
+ if (f?.requestedBy) by.add(String(f.requestedBy).trim());
234
+ if (!asks.some((x) => x.title === f.title)) asks.push({ title: String(f.title || 'This change'), ...(f.tryIt?.steps ? { asked: firstSentence(f.tryIt.steps) } : {}) });
235
+ }
236
+ }
237
+ return asks.length || as.length ? { requestedBy: [...by], asks, as } : null;
238
+ }
239
+ export function introBody(intro) {
240
+ if (!intro) return '';
241
+ const lines = [];
242
+ if (intro.requestedBy.length) lines.push(`Requested by ${intro.requestedBy.join(', ')}`);
243
+ const shown = intro.asks.slice(0, 3);
244
+ for (const a of shown) lines.push(`• ${a.title}`);
245
+ if (intro.asks.length > shown.length) lines.push(`…and ${intro.asks.length - shown.length} more`);
246
+ if (intro.as.length) lines.push(`Signed in as ${intro.as.join(', then ')}`);
247
+ return lines.join('\n');
248
+ }