@simon-says-build/demo-player 0.12.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.12.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
@@ -15,7 +15,7 @@
15
15
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
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';
@@ -162,6 +162,24 @@ function Player({ config, router, insets, onClose, children }) {
162
162
  return () => { live = false; clearInterval(t); };
163
163
  }, [step, done, screen.width, screen.height, rescue?.status, index]);
164
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
+
165
183
  const finish = useCallback(() => {
166
184
  setReviewing(false);
167
185
  setDone(true);
@@ -190,8 +208,16 @@ function Player({ config, router, insets, onClose, children }) {
190
208
  advance();
191
209
  }, [steps, step, router, advance, flash]);
192
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
+
193
218
  const doIt = useCallback(() => {
194
219
  if (!step || !found) return;
220
+ if (step.kind === 'try') { handlerFor(found, 'onPress')?.({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} }); return; }
195
221
  if (step.kind === 'click') handlerFor(found, 'onPress')?.({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} });
196
222
  if (step.kind === 'fill' && step.value != null) handlerFor(found, 'onChangeText')?.(step.value);
197
223
  next();
@@ -248,13 +274,14 @@ function Player({ config, router, insets, onClose, children }) {
248
274
  if (!steps) return null;
249
275
  if (intro && !tucked && !done) {
250
276
  return h(Card, {
251
- 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),
252
278
  actions: [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Start the review', onPress: () => setIntro(null), primary: true }],
253
279
  });
254
280
  }
255
281
  if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
256
282
  if (flash) return h(View, { pointerEvents: 'none', style: [s.flash, { top: insets.top + screen.height * 0.4 }], testID: 'ssb-demo-flash' },
257
283
  h(Text, { style: s.flashText, accessibilityLiveRegion: 'polite' }, flash));
284
+ if (tucked && !done && overlayAnnounced()) return null; // the SSB button resumes it (0.13.0)
258
285
  if (tucked || done) {
259
286
  // A demo started at run time closes when it is done (▶ Demo again replays it);
260
287
  // one baked into a preview stays, to be replayed from its pill.
@@ -271,23 +298,35 @@ function Player({ config, router, insets, onClose, children }) {
271
298
  // there is no Next: Try again, or Hide.
272
299
  if (step.kind === 'event') {
273
300
  return h(Card, {
274
- at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
301
+ at: null, screen, insets, card, setCard, onCollapse: () => setTucked(true), title: `${index + 1} of ${steps.length}`,
275
302
  body: event ? `The review couldn't set this up, and the rest of it depends on it: ${event.error}` : step.caption,
276
303
  actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setEventTry((n) => n + 1), primary: true }] : [],
277
304
  });
278
305
  }
279
306
  if (step.kind === 'signin') {
280
307
  return h(Card, {
281
- at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
308
+ at: null, screen, insets, card, setCard, onCollapse: () => setTucked(true), title: `${index + 1} of ${steps.length}`,
282
309
  body: signIn ? `SSB couldn't sign you in, and the review needs it: ${signIn.error}.` : step.caption,
283
310
  actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setSignInTry((n) => n + 1), primary: true }] : [],
284
311
  });
285
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
+ }
286
325
  const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
287
326
  return h(React.Fragment, null,
288
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,
289
328
  h(Card, {
290
- at: rect, place: step.place, screen, insets, card, setCard,
329
+ at: rect, place: step.place, screen, insets, card, setCard, onCollapse: () => setTucked(true),
291
330
  title: stepTitle(step, index, steps.length), body: rescueNote(step.caption, rescue),
292
331
  actions: [
293
332
  { label: 'Hide', onPress: () => setTucked(true) },
@@ -422,17 +461,23 @@ const button = (label, onPress, primary = false) => h(Pressable, {
422
461
  style: ({ pressed }) => [s.button, primary && s.primary, pressed && s.pressed],
423
462
  }, h(Text, { style: [s.buttonText, primary && s.primaryText] }, label));
424
463
 
425
- function Card({ at, place, screen, insets, card, setCard, title, body, actions = [] }) {
426
- 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);
427
469
  return h(View, {
428
- 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 }],
429
471
  onLayout: (e) => {
430
472
  const { width, height } = e.nativeEvent.layout;
431
473
  if (Math.abs(width - card.w) > 1 || Math.abs(height - card.h) > 1) setCard({ w: width, h: height });
432
474
  },
433
475
  accessibilityViewIsModal: false,
434
476
  },
435
- 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),
436
481
  h(Text, { style: s.body }, body),
437
482
  actions.length ? h(View, { style: s.actions }, ...actions.map((a) => h(Pressable, {
438
483
  key: a.label, onPress: a.onPress, accessibilityRole: 'button', accessibilityLabel: a.label,
@@ -448,6 +493,10 @@ const s = StyleSheet.create({
448
493
  ring: { position: 'absolute', borderRadius: 12, borderWidth: 3, borderColor: '#FFFFFF', shadowColor: '#000', shadowOpacity: 0.6, shadowRadius: 8, shadowOffset: { width: 0, height: 0 } },
449
494
  card: { position: 'absolute', width: 320, padding: 16, borderRadius: 16, backgroundColor: '#1C1C1E', shadowColor: '#000', shadowOpacity: 0.35, shadowRadius: 16, shadowOffset: { width: 0, height: 6 } },
450
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 },
451
500
  body: { color: '#FFFFFF', fontSize: 16, lineHeight: 22 },
452
501
  actions: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 14, gap: 8 },
453
502
  button: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 10, backgroundColor: '#2C2C2E' },
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.12.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