@simon-says-build/demo-player 0.12.0 → 0.13.1

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.1",
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, tapInside, 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) },
@@ -298,7 +337,14 @@ function Player({ config, router, insets, onClose, children }) {
298
337
  }));
299
338
  })();
300
339
 
301
- return h(View, { ref: rootRef, style: s.root, collapsable: false },
340
+ // A try step's tap, seen as it passes (never intercepted): the touch that
341
+ // ends on the ringed element is the viewer trying it (core.js tapInside).
342
+ const onTouchEnd = (e) => {
343
+ if (step?.kind !== 'try' || tried || !rect) return;
344
+ const { pageX, pageY } = e.nativeEvent || {};
345
+ if (tapInside(rect, pageX, pageY)) setTimeout(() => setTried(true), 600);
346
+ };
347
+ return h(View, { ref: rootRef, style: s.root, collapsable: false, onTouchEnd },
302
348
  children,
303
349
  h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill, testID: OVERLAY_ID }, overlay));
304
350
  }
@@ -422,17 +468,23 @@ const button = (label, onPress, primary = false) => h(Pressable, {
422
468
  style: ({ pressed }) => [s.button, primary && s.primary, pressed && s.pressed],
423
469
  }, h(Text, { style: [s.buttonText, primary && s.primaryText] }, label));
424
470
 
425
- function Card({ at, place, screen, insets, card, setCard, title, body, actions = [] }) {
426
- const box = cardBox(place, at, screen, insets, card);
471
+ // Pinned across the top (0.13.0, core.js pinnedBox), with a collapse control
472
+ // in its corner when the player can be collapsed (`onCollapse`).
473
+ function Card({ at, place, screen, insets, card, setCard, title, body, actions = [], onCollapse = null }) {
474
+ void place; void cardBox;
475
+ const box = pinnedBox(at, screen, insets, card);
427
476
  return h(View, {
428
- style: [s.card, { left: box.left, top: box.top, maxWidth: screen.width - 32 }],
477
+ style: [s.card, { left: box.left, top: box.top, width: box.width }],
429
478
  onLayout: (e) => {
430
479
  const { width, height } = e.nativeEvent.layout;
431
480
  if (Math.abs(width - card.w) > 1 || Math.abs(height - card.h) > 1) setCard({ w: width, h: height });
432
481
  },
433
482
  accessibilityViewIsModal: false,
434
483
  },
435
- h(Text, { style: s.title }, title),
484
+ h(View, { style: s.head },
485
+ h(Text, { style: [s.title, { flex: 1 }] }, title),
486
+ onCollapse ? h(Pressable, { testID: 'ssb-demo-collapse', onPress: onCollapse, hitSlop: 12, accessibilityRole: 'button', accessibilityLabel: 'Collapse the review',
487
+ style: ({ pressed }) => [s.collapse, pressed && s.pressed] }, h(Text, { style: s.collapseText }, '⌃')) : null),
436
488
  h(Text, { style: s.body }, body),
437
489
  actions.length ? h(View, { style: s.actions }, ...actions.map((a) => h(Pressable, {
438
490
  key: a.label, onPress: a.onPress, accessibilityRole: 'button', accessibilityLabel: a.label,
@@ -448,6 +500,10 @@ const s = StyleSheet.create({
448
500
  ring: { position: 'absolute', borderRadius: 12, borderWidth: 3, borderColor: '#FFFFFF', shadowColor: '#000', shadowOpacity: 0.6, shadowRadius: 8, shadowOffset: { width: 0, height: 0 } },
449
501
  card: { position: 'absolute', width: 320, padding: 16, borderRadius: 16, backgroundColor: '#1C1C1E', shadowColor: '#000', shadowOpacity: 0.35, shadowRadius: 16, shadowOffset: { width: 0, height: 6 } },
450
502
  title: { color: '#AEAEB2', fontSize: 12, fontWeight: '600', marginBottom: 6 },
503
+ head: { flexDirection: 'row', alignItems: 'flex-start' },
504
+ collapse: { marginLeft: 8, marginTop: -6, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8, backgroundColor: '#2C2C2E' },
505
+ collapseText: { color: '#FFFFFF', fontSize: 16, fontWeight: '700' },
506
+ tryRing: { borderColor: '#FFD60A', borderWidth: 4 },
451
507
  body: { color: '#FFFFFF', fontSize: 16, lineHeight: 22 },
452
508
  actions: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 14, gap: 8 },
453
509
  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.1';
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,40 @@ 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
+ // A TAP ON THE RINGED ELEMENT (0.13.1): a `try` step is done when the viewer's
195
+ // touch lands on it (with a little slop for a finger). Watching for the element
196
+ // to leave the screen missed a modal: Connie Ho's profile opens OVER the walks
197
+ // list, the photo stays mounted, and the card kept offering "Show me" after
198
+ // Michael had tapped it (2026-11-05). Pure.
199
+ export function tapInside(rect, x, y, slop = 10) {
200
+ return !!rect && Number.isFinite(x) && Number.isFinite(y)
201
+ && x >= rect.left - slop && x <= rect.left + rect.width + slop
202
+ && y >= rect.top - slop && y <= rect.top + rect.height + slop;
203
+ }
204
+
205
+ // COLLAPSED, AND RESUMED FROM THE SSB BUTTON (0.13.0): collapsing leaves only
206
+ // the in-app SSB button (packages/ssb-overlay), whose tap resumes the review.
207
+ // The overlay says it is there (`announceOverlay`); without one the player
208
+ // keeps its own pill. `tuckedDemo()` is the resume function while collapsed.
209
+ let tuck = null;
210
+ let overlayHere = false;
211
+ export function setTuckedDemo(resume) { tuck = typeof resume === 'function' ? resume : null; }
212
+ export const tuckedDemo = () => tuck;
213
+ export function announceOverlay() { overlayHere = true; }
214
+ export const overlayAnnounced = () => overlayHere;
215
+
177
216
  // A DEMO STARTED AT RUN TIME (2026-10-20, the in-app overlay's ▶ Demo, "Ready
178
217
  // is this build"): the bundle running IS the preview of the work, so nothing
179
218
  // is baked into it — SimonSays hands the overlay a demo's id and token, and