@simon-says-build/demo-player 0.10.0 → 0.12.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
@@ -30,7 +30,11 @@ export const PLAYER_VERSION: string;
30
30
  /** The changes a demo's last card offers to approve or discuss: one per `job:<id>` feature. */
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
- export function releaseOf(demo: unknown): { jobId: string; label: string; note: string } | null;
33
+ export function releaseOf(demo: unknown): { jobId: string; label: string; note: string; reviews: ReleaseReviews } | null;
34
+ /** A release's review requests (0.11.0), read for the demo's person. */
35
+ export type ReleaseReviewRequest = { to: string; by: string; status: 'pending' | 'approved' | 'discussed' | 'withdrawn'; at?: string; note?: string; decisionNote?: string; revisedAt?: string; release?: boolean };
36
+ export type ReleaseReviews = { mode: 'off' | 'optional' | 'required'; requests: ReleaseReviewRequest[]; mine: ReleaseReviewRequest | null; waiting: string[]; candidates: string[]; canAsk: boolean; mustAsk: boolean; last: boolean };
37
+ export function releaseReviewsOf(demo: unknown): ReleaseReviews;
34
38
 
35
39
  /**
36
40
  * React Navigation (an app without expo-router): a path is the chain of route
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, PLAYER_VERSION } from './src/core.js';
2
+ export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, releaseReviewsOf, PLAYER_VERSION } 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.10.0",
3
+ "version": "0.12.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
@@ -13,12 +13,13 @@
13
13
  //
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
- import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
16
+ import { Linking, NativeModules, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
17
17
  import { pageName } from './page-name.js';
18
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';
22
+ import { nativeRuntime, buildGap, needLinks, openNeed, staleBody } from './build-gate.js';
22
23
 
23
24
  const h = React.createElement;
24
25
  const FIND_FOR_MS = 8000; // a step's element may appear after a navigation settles
@@ -83,11 +84,25 @@ function Player({ config, router, insets, onClose, children }) {
83
84
  const [flash, setFlash] = useState(null); // "Now navigating to the walk page", said before a goto opens it
84
85
  const [rescued, setRescued] = useState(null); // { for: index, status: asking|moved|gone, target?, why? }
85
86
  const asked = useRef(new Set()); // steps already rescued once (by index)
87
+ const [stale, setStale] = useState(null); // { gap, links, appName }: this dev build is not the review's (build-gate.js)
86
88
 
87
89
  useEffect(() => {
88
90
  let live = true;
89
91
  api.load()
90
- .then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(d)); } })
92
+ .then((d) => {
93
+ if (!live) return;
94
+ // THE BUILD FIRST (0.12.0): a review made for another native build of
95
+ // this dev app never starts here — the person is sent to install the
96
+ // right one (build-gate.js). Nothing else runs: no sign-in, no steps.
97
+ const gap = config.here ? null : buildGap(d, nativeRuntime(globalThis.expo?.modules, NativeModules));
98
+ if (gap) {
99
+ const links = needLinks({ demo: d, gap, demoId: config.id, token: config.token });
100
+ setStale({ gap, links, appName: d.projectName || d.appName || 'the app' });
101
+ openNeed(links, (u) => Linking.openURL(u));
102
+ return;
103
+ }
104
+ setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(d));
105
+ })
91
106
  .catch((e) => { if (live) setError(e.message); });
92
107
  return () => { live = false; };
93
108
  }, [api]);
@@ -184,7 +199,8 @@ function Player({ config, router, insets, onClose, children }) {
184
199
 
185
200
  // AND NOW… (an `event` step, 0.8.0): SSB runs the helper on the copy; its
186
201
  // outputs fill `{name}` in this person's later steps. A failure stays on the
187
- // card, said, with Next; never a silent skip.
202
+ // card, said, with Try again (0.12.0: never Next — what follows depends on it).
203
+ const [eventTry, setEventTry] = useState(0);
188
204
  useEffect(() => {
189
205
  setEvent(null);
190
206
  if (step?.kind !== 'event' || done) return undefined;
@@ -197,11 +213,13 @@ function Player({ config, router, insets, onClose, children }) {
197
213
  else setIndex(index + 1);
198
214
  });
199
215
  return () => { live = false; };
200
- }, [step?.kind, step?.helper, index, done]);
216
+ }, [step?.kind, step?.helper, index, done, eventTry]);
201
217
 
202
218
  // SIGN THIS PERSON IN FIRST (a `signin` step, tourOf): open the link, let
203
219
  // the app sign in and route, then start their part on that screen. A link
204
- // that can't be had or opened leaves the card saying who to be (stage 1).
220
+ // that can't be had or opened, or a sign-in whose first screen never comes
221
+ // up, stops the tour with Try again (0.12.0).
222
+ const [signInTry, setSignInTry] = useState(0); // "Try again" re-runs the sign-in
205
223
  useEffect(() => {
206
224
  setSignIn(null);
207
225
  if (step?.kind !== 'signin' || done) return undefined;
@@ -217,10 +235,16 @@ function Player({ config, router, insets, onClose, children }) {
217
235
  else setIndex(index + 1);
218
236
  });
219
237
  return () => { live = false; };
220
- }, [step, done]);
238
+ }, [step, done, signInTry]);
221
239
 
222
240
  const overlay = (() => {
223
241
  if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
242
+ if (stale) {
243
+ return h(Card, {
244
+ at: null, screen, insets, card, setCard, title: 'Guided Review', body: staleBody(stale.gap, stale.appName),
245
+ actions: [{ label: 'Open SimonSays', onPress: () => openNeed(stale.links, (u) => Linking.openURL(u)), primary: true }],
246
+ });
247
+ }
224
248
  if (!steps) return null;
225
249
  if (intro && !tucked && !done) {
226
250
  return h(Card, {
@@ -241,18 +265,22 @@ function Player({ config, router, insets, onClose, children }) {
241
265
  }
242
266
  if (!step && changes.length) return h(Review, { api, changes, release, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
243
267
  if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
268
+ // A FAILED SETUP OR SIGN-IN STOPS THE TOUR (0.12.0, Michael 2026-11-05:
269
+ // "the tour is theoretically continuable… we need a way to make the tour
270
+ // aware that an important step failed"). What follows depends on it, so
271
+ // there is no Next: Try again, or Hide.
244
272
  if (step.kind === 'event') {
245
273
  return h(Card, {
246
274
  at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
247
- body: event ? `The demo couldn't set this up: ${event.error}` : step.caption,
248
- actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Next', onPress: next, primary: true }] : [],
275
+ body: event ? `The review couldn't set this up, and the rest of it depends on it: ${event.error}` : step.caption,
276
+ actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setEventTry((n) => n + 1), primary: true }] : [],
249
277
  });
250
278
  }
251
279
  if (step.kind === 'signin') {
252
280
  return h(Card, {
253
281
  at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
254
- body: signIn ? step.fallback : step.caption,
255
- actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Next', onPress: next, primary: true }] : [],
282
+ body: signIn ? `SSB couldn't sign you in, and the review needs it: ${signIn.error}.` : step.caption,
283
+ actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setSignInTry((n) => n + 1), primary: true }] : [],
256
284
  });
257
285
  }
258
286
  const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
@@ -301,11 +329,66 @@ function Review({ api, changes, release, screen, insets, onBack, onDone, onHide
301
329
  api.discuss(id, text).then(() => set(id, { state: 'sent' })).catch((e) => set(id, { state: 'discussing', error: e.message }));
302
330
  };
303
331
  const left = release ? 0 : changes.filter((c) => !['approved', 'sent'].includes(rows[c.jobId]?.state)).length;
332
+ // REVIEW REQUESTS (0.11.0, docs/REVIEW-REQUESTS.md): asked to review it, the
333
+ // person answers here — Approve, or Discuss with a note, which holds the
334
+ // release until it is revised; otherwise they may approve and ask someone
335
+ // else first. `rq.state`: idle | picking | asking | discussing | sending | done.
336
+ const rv = release?.reviews || null;
337
+ const [rq, setRq] = useState({ state: 'idle', picked: [], note: '', error: null, said: '' });
338
+ const rqSet = (patch) => setRq((r) => ({ ...r, ...patch }));
339
+ const answer = (decision) => {
340
+ rqSet({ state: 'sending', error: null });
341
+ api.answerReview(decision, decision === 'discuss' ? rq.note.trim() : '')
342
+ .then((r) => rqSet({ state: 'done', said: decision === 'discuss' ? 'Sent — it waits for the revision and your approval.' : r?.released ? 'Approved — it is releasing now.' : r?.waiting ? `Approved. ${r.waiting}` : 'Approved.' }))
343
+ .catch((e) => rqSet({ state: decision === 'discuss' ? 'discussing' : 'idle', error: e.message }));
344
+ };
345
+ const askFor = () => {
346
+ if (!rq.picked.length) return;
347
+ rqSet({ state: 'sending', error: null });
348
+ api.approveAndAsk(rq.picked, rq.note.trim())
349
+ .then(() => rqSet({ state: 'done', said: `Approved — it goes live once ${rq.picked.join(', ')} ${rq.picked.length === 1 ? 'approves' : 'approve'}.` }))
350
+ .catch((e) => rqSet({ state: 'picking', error: e.message }));
351
+ };
352
+ const toggle = (e) => rqSet({ picked: rq.picked.includes(e) ? rq.picked.filter((x) => x !== e) : [...rq.picked, e] });
353
+ const reviewsBlock = !rv ? null : rq.state === 'done' ? h(Text, { style: s.changeState, testID: 'ssb-review-request-done' }, rq.said)
354
+ : rv.mine ? h(View, { style: s.change, testID: 'ssb-review-mine' },
355
+ h(Text, { style: s.changeTitle }, `${rv.mine.by} asked you to review this release.`),
356
+ rv.mine.note ? h(Text, { style: s.body }, `“${rv.mine.note}”`) : null,
357
+ rv.mine.status === 'discussed' && rv.mine.revisedAt ? h(Text, { style: s.body }, 'It was revised since you asked for changes.') : null,
358
+ rq.state === 'discussing' || (rq.state === 'sending' && rq.note) ? h(View, null,
359
+ h(TextInput, { style: s.input, value: rq.note, onChangeText: (note) => rqSet({ note }), placeholder: 'What should change before it goes live?', placeholderTextColor: '#8E8E93', multiline: true, autoFocus: true, accessibilityLabel: 'What should change before it goes live', testID: 'ssb-review-discuss-note' }),
360
+ h(View, { style: s.actions }, button('Cancel', () => rqSet({ state: 'idle', note: '' })), button(rq.state === 'sending' ? 'Sending…' : 'Send', rq.note.trim() && rq.state !== 'sending' ? () => answer('discuss') : () => {}, true))) : null)
361
+ : rv.waiting.length ? h(Text, { style: s.body, testID: 'ssb-review-waiting' }, `Waiting on ${rv.waiting.join(', ')}'s review before it goes live.`)
362
+ : rq.state === 'picking' || (rq.state === 'sending' && rq.picked.length) ? h(View, { style: s.change, testID: 'ssb-review-ask' },
363
+ h(Text, { style: s.changeTitle }, 'Request a review from…'),
364
+ ...rv.candidates.map((e) => h(Pressable, { key: e, onPress: () => toggle(e), accessibilityRole: 'checkbox', accessibilityState: { checked: rq.picked.includes(e) }, accessibilityLabel: e, style: s.pick },
365
+ h(Text, { style: s.body }, `${rq.picked.includes(e) ? '☑' : '☐'} ${e}`))),
366
+ h(TextInput, { style: s.input, value: rq.note, onChangeText: (note) => rqSet({ note }), placeholder: 'A note for them (optional)', placeholderTextColor: '#8E8E93', multiline: true, accessibilityLabel: 'A note for the reviewer' }),
367
+ h(View, { style: s.actions }, button('Cancel', () => rqSet({ state: 'idle', picked: [] })), button(rq.state === 'sending' ? 'Asking…' : 'Approve & ask', rq.picked.length && rq.state !== 'sending' ? askFor : () => {}, true)))
368
+ : rv.mustAsk ? h(Text, { style: s.body }, 'This project needs a review before every release — request one.') : null;
369
+ // What the foot's main button does on a release.
370
+ // What the foot's buttons are on a release, beside Hide (flat: one row).
371
+ const releaseMain = !release || rel.state === 'released' ? []
372
+ : rq.state === 'done' ? [button(left ? `Done (${left} undecided)` : 'Done', onDone, true)]
373
+ : rv?.mine ? (rq.state === 'discussing' ? [] : [
374
+ button('💬 Discuss', () => rqSet({ state: 'discussing' })),
375
+ button(rq.state === 'sending' ? 'Approving…' : rv.last ? release.label : 'Approve', rq.state === 'sending' ? () => {} : () => answer('approve'), true)])
376
+ : rv?.waiting.length ? [button('Done', onDone, true)]
377
+ : rq.state === 'picking' ? []
378
+ : [
379
+ rv?.canAsk ? button('Request a review', () => rqSet({ state: 'picking' }), !!rv?.mustAsk) : null,
380
+ rv?.mustAsk ? null : button(rel.state === 'releasing' ? 'Approving…' : release.label, rel.state === 'releasing' ? () => {} : doRelease, true),
381
+ ].filter(Boolean);
382
+ // The card's lead line, for where the release stands.
383
+ const lead = !release ? 'Approve what looks right. Anything that should be different, say so — SSB revises it.'
384
+ : rv?.mine ? 'Approve it, or say what should change — the release waits for your answer.'
385
+ : rv?.waiting.length ? 'It goes out once the review is in. Anything that should be different, say so — SSB revises it.'
386
+ : rv?.mustAsk ? 'This project needs a review before it goes out. Anything that should be different, say so — SSB revises it.'
387
+ : `Happy with it? ${release.label} sends them out together. Anything that should be different, say so — SSB revises it.`;
304
388
  return h(View, { style: [s.card, s.review, { top: insets.top + 12, maxHeight: screen.height - insets.top - insets.bottom - 24, maxWidth: screen.width - 32, left: Math.max(16, (screen.width - Math.min(360, screen.width - 32)) / 2) }], testID: 'ssb-demo-review' },
305
389
  h(Text, { style: s.title }, release ? (changes.length === 1 ? 'The next release: 1 change' : `The next release: ${changes.length} changes`)
306
390
  : changes.length === 1 ? 'Review the change' : `Review ${changes.length} changes`),
307
- h(Text, { style: s.body }, release ? `Happy with it? ${release.label} sends them out together. Anything that should be different, say so — SSB revises it.`
308
- : 'Approve what looks right. Anything that should be different, say so — SSB revises it.'),
391
+ h(Text, { style: s.body }, lead),
309
392
  h(ScrollView, { style: s.changes, keyboardShouldPersistTaps: 'handled' }, ...changes.map((c) => {
310
393
  const r = rows[c.jobId] || {};
311
394
  const state = r.state || 'idle';
@@ -318,18 +401,20 @@ function Review({ api, changes, release, screen, insets, onBack, onDone, onHide
318
401
  h(View, { style: s.actions },
319
402
  button('Cancel', () => set(c.jobId, { state: 'idle', error: null })),
320
403
  button('Send', () => send(c.jobId), true))) : null,
321
- state === 'idle' ? h(View, { style: s.actions },
404
+ state === 'idle' && !rv?.mine ? h(View, { style: s.actions },
322
405
  button('💬 Discuss', () => set(c.jobId, { state: 'discussing' })),
323
406
  release ? null : button('✓ Approve', () => approve(c.jobId), true)) : null);
324
407
  })),
408
+ reviewsBlock,
409
+ rq.error ? h(Text, { style: s.error }, rq.error) : null,
325
410
  release && rel.state === 'released' ? h(Text, { style: s.changeState, testID: 'ssb-release-done' }, 'Approved — it goes out in 5 minutes. Undo it on the release page in SimonSays if you need to.') : null,
326
411
  release && rel.error ? h(Text, { style: s.error }, rel.error) : null,
327
412
  h(View, { style: [s.actions, s.reviewFoot] },
328
413
  button('Hide', onHide),
329
414
  onBack ? button('Back', onBack) : null,
330
- release && rel.state !== 'released'
331
- ? button(rel.state === 'releasing' ? 'Approving…' : release.label, rel.state === 'releasing' ? () => {} : doRelease, true)
332
- : button(left ? `Done (${left} undecided)` : 'Done', onDone, !left)));
415
+ ...(release && rel.state !== 'released'
416
+ ? releaseMain
417
+ : [button(left ? `Done (${left} undecided)` : 'Done', onDone, !left)])));
333
418
  }
334
419
 
335
420
  const button = (label, onPress, primary = false) => h(Pressable, {
@@ -373,6 +458,7 @@ const s = StyleSheet.create({
373
458
  review: { width: 360 },
374
459
  changes: { flexGrow: 0 },
375
460
  reviewFoot: { marginTop: 16 },
461
+ pick: { flexDirection: 'row', paddingVertical: 6 },
376
462
  change: { marginTop: 14, paddingTop: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#3A3A3C' },
377
463
  changeTitle: { color: '#FFFFFF', fontSize: 15, fontWeight: '600' },
378
464
  changeState: { color: '#AEAEB2', fontSize: 13, marginTop: 4 },
package/src/api.js CHANGED
@@ -23,6 +23,11 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
23
23
  // A release's demo (0.4.0): approve and release the session, as the person
24
24
  // the demo was made for, after the five-minute cool-off.
25
25
  release: () => call('/release', { method: 'POST', body: '{}' }),
26
+ // REVIEW REQUESTS (0.11.0, docs/REVIEW-REQUESTS.md): approve and ask others
27
+ // to review it first (the last approval releases it); or, asked yourself,
28
+ // answer — approve, or discuss with a note, which holds it until revised.
29
+ approveAndAsk: (to, note) => call('/approve', { method: 'POST', body: JSON.stringify({ requestReview: { to, ...(note ? { note } : {}), release: true } }) }),
30
+ answerReview: (decision, note) => call('/review-requests/decide', { method: 'POST', body: JSON.stringify({ decision, ...(note ? { note } : {}) }) }),
26
31
  // A phone's private copy (0.6.0): the link that signs this person in.
27
32
  signin: (actorIndex) => call('/signin', { method: 'POST', body: JSON.stringify({ actorIndex }) }),
28
33
  // A planned review (0.8.0): run a helper on the private copy, then read how it went.
@@ -0,0 +1,62 @@
1
+ // THE DEV BUILD MUST MATCH THE REVIEW (0.12.0, docs/TOUR-LINKS.md "6. The
2
+ // bundle checks itself"). Pure, so node --test runs it.
3
+ //
4
+ // A phone review's copy is Metro serving the session's JS, made for ONE native
5
+ // build of the app's dev client: `demo.preview.fingerprint`, the build SSB put
6
+ // in the library for it. A dev client loads whatever JS it is pointed at, so
7
+ // on an older build the review's code ran anyway and failed half-way: Michael's
8
+ // phone (2026-11-05) opened Connie Ho's Profile photos review on a Walk2Gether
9
+ // Dev build older than c316 and its sign-in died "auth/network-request-failed".
10
+ // Every check before that point (the notification's install step, the
11
+ // overlay's ssb-tour gate) was skipped — no install record, a dev-client link.
12
+ //
13
+ // The structural answer is the bundle itself: the player knows what native
14
+ // build the review needs (the demo record) and what native build it is running
15
+ // on (expo-updates' runtimeVersion — under the fingerprint policy, the native
16
+ // fingerprint; the dev builds read it from EXUpdates.bundle/fingerprint). A
17
+ // mismatch never starts the review: it hands the person to the SimonSays app's
18
+ // "need" screen (`simonsays://need?…`, which offers the build SSB made), and
19
+ // the website's /need page when the app is not there. A build that cannot say
20
+ // its runtime, or a review that names none, plays as before.
21
+
22
+ /** This build's native runtime (expo-updates' runtimeVersion), or null. */
23
+ export function nativeRuntime(mods = globalThis.expo?.modules, nativeModules = {}) {
24
+ const u = mods?.ExpoUpdates || nativeModules?.ExpoUpdates || {};
25
+ return typeof u.runtimeVersion === 'string' && u.runtimeVersion && !u.runtimeVersion.startsWith('file:') ? u.runtimeVersion : null;
26
+ }
27
+
28
+ /** What the review needs that this build is not: null = play it, else { need, have }. */
29
+ export function buildGap(demo, have) {
30
+ const need = demo?.platform === 'ios' && typeof demo?.preview?.fingerprint === 'string' ? demo.preview.fingerprint : null;
31
+ if (!need || !have) return null;
32
+ return need === have ? null : { need, have };
33
+ }
34
+
35
+ // The dev app's scheme, from the link the copy was opened with
36
+ // (`walk2gether-dev://expo-development-client/?url=…`).
37
+ const schemeOf = (url) => /^([a-z][a-z0-9+.-]*):\/\//i.exec(String(url || ''))?.[1] || undefined;
38
+ const qs = (o) => Object.entries(o).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
39
+
40
+ /**
41
+ * Where to send the person: the SimonSays app's need screen, else the site's.
42
+ * The query is schemas/tour-link.mjs needUrl's (test/build-gate.test.mjs holds
43
+ * them equal): app, scheme, tour, rt, haveRt.
44
+ */
45
+ export function needLinks({ demo = {}, gap, demoId, token, site = 'https://simonsays.build' }) {
46
+ const q = qs({
47
+ app: demo.appId, scheme: schemeOf(demo.preview?.url), tour: demoId && token ? `${demoId}.${token}` : undefined,
48
+ rt: gap?.need, haveRt: gap?.have || undefined,
49
+ });
50
+ return { app: `simonsays://need?${q}`, web: `${site.replace(/\/$/, '')}/need?${q}` };
51
+ }
52
+
53
+ /** Open the app's need screen, falling through to the site. `openURL` is Linking.openURL. */
54
+ export async function openNeed(links, openURL) {
55
+ try { await openURL(links.app); return 'app'; } catch { /* no SimonSays app */ }
56
+ try { await openURL(links.web); return 'web'; } catch { return null; }
57
+ }
58
+
59
+ /** The card's words. */
60
+ export function staleBody(gap, appName = 'the app') {
61
+ return `This review needs a newer ${appName} Dev build (it needs ${gap.need.slice(0, 7)}; this phone has ${gap.have.slice(0, 7)}). Opening the SimonSays app to install it.`;
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.10.0';
13
+ export const PLAYER_VERSION = '0.12.0';
14
14
 
15
15
  export const HOST = 5;
16
16
  export const TEXT = 6;
@@ -217,7 +217,32 @@ export function reviewChangesOf(demo) {
217
217
  export function releaseOf(demo) {
218
218
  const sm = demo?.sessionMerge;
219
219
  if (!sm || typeof sm.jobId !== 'string' || !sm.jobId) return null;
220
- return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || '') };
220
+ return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || ''), reviews: releaseReviewsOf(demo) };
221
+ }
222
+
223
+ // REVIEW REQUESTS ON A RELEASE (0.11.0, docs/REVIEW-REQUESTS.md): the demo's
224
+ // `sessionMerge.reviews` (functions/review-requests.mjs releaseReviewsForDemo),
225
+ // read for the person the demo was made for (`createdBy`). Pure.
226
+ // mine the request addressed to them that is still theirs to answer, or null
227
+ // waiting others whose review it still waits on
228
+ // canAsk they may request a review (the setting is not off, someone to ask)
229
+ // mustAsk the project requires a review and none is approved: no release without one
230
+ // last their approval would be the last one, and a release was asked for
231
+ export function releaseReviewsOf(demo) {
232
+ const rv = demo?.sessionMerge?.reviews;
233
+ const me = String(demo?.createdBy || '').trim().toLowerCase();
234
+ const requests = Array.isArray(rv?.requests) ? rv.requests.filter((r) => r && typeof r.to === 'string') : [];
235
+ const open = requests.filter((r) => r.status === 'pending' || r.status === 'discussed');
236
+ const mine = open.find((r) => r.to === me) || null;
237
+ const waiting = open.filter((r) => r.to !== me).map((r) => r.to);
238
+ const candidates = (Array.isArray(rv?.candidates) ? rv.candidates : []).filter((e) => typeof e === 'string' && e !== me && !open.some((r) => r.to === e));
239
+ const mode = rv?.mode === 'off' || rv?.mode === 'required' ? rv.mode : 'optional';
240
+ return {
241
+ mode, requests, mine, waiting, candidates,
242
+ canAsk: mode !== 'off' && candidates.length > 0,
243
+ mustAsk: mode === 'required' && !requests.some((r) => r.status === 'approved') && !open.length,
244
+ last: !!mine && waiting.length === 0 && requests.some((r) => r.release),
245
+ };
221
246
  }
222
247
 
223
248
  // THE FIRST SCREEN OF A REVIEW (0.10.0, Michael 2026-11-03): a very brief
package/src/signin.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // only then starts the tour, on that screen. Pure, so node --test runs it.
7
7
 
8
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
9
+ const SETTLE_MAX_MS = 12000; // and then the sign-in has not taken: said, the tour stops (0.12.0)
10
10
  const POLL_MS = 250;
11
11
 
12
12
  const pause = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -38,8 +38,8 @@ export const signsIn = (demo, { here = false } = {}) => !here && demo?.platform
38
38
  /**
39
39
  * Sign `actorIndex` in, then resolve once the app has settled. `openURL` is
40
40
  * Linking.openURL; `settled()` says the first screen is up (its element is
41
- * found). → { ok: true } | { ok: false, error } (the player falls back to
42
- * saying who to sign in as).
41
+ * found). → { ok: true } | { ok: false, error } (the player stops the tour
42
+ * with Try again).
43
43
  */
44
44
  export async function signInFirst({ api, actorIndex, openURL, settled = () => true, sleep = pause, minMs = SETTLE_MIN_MS, maxMs = SETTLE_MAX_MS }) {
45
45
  let link;
@@ -51,6 +51,12 @@ export async function signInFirst({ api, actorIndex, openURL, settled = () => tr
51
51
  return { ok: false, error: e?.message || String(e) };
52
52
  }
53
53
  await sleep(minMs);
54
- for (let waited = minMs; waited < maxMs && !settled(); waited += POLL_MS) await sleep(POLL_MS);
54
+ let ok = false;
55
+ for (let waited = minMs; waited < maxMs; waited += POLL_MS) { if ((ok = !!settled())) break; await sleep(POLL_MS); }
56
+ // CONFIRMED, NOT ASSUMED (0.12.0): the first screen past sign-in never came
57
+ // up, so the sign-in did not take — the app's own handler failed (2026-11-05:
58
+ // "Failed to verify token: [auth/network-request-failed]" while the tour
59
+ // carried on to Next and Take me there). Said, and the tour stops.
60
+ if (!ok) return { ok: false, error: "the app didn't sign in (its first screen never appeared)" };
55
61
  return { ok: true };
56
62
  }