@simon-says-build/demo-player 0.1.0 → 0.2.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
@@ -15,3 +15,12 @@ export function DemoHost(props: {
15
15
  insets?: { top?: number; bottom?: number };
16
16
  children?: ReactNode;
17
17
  }): ReactNode;
18
+
19
+ /**
20
+ * Play a demo now, in the bundle already running (the in-app SSB overlay's
21
+ * ▶ Demo hands SimonSays' answer here). `null` stops it. A demo baked into
22
+ * the bundle (`<DemoHost demo>`) outranks it. Returns false for an incomplete demo.
23
+ */
24
+ export function playDemo(demo: DemoBundle | null): boolean;
25
+ export function currentDemo(): { id: string; token: string; api: string } | null;
26
+ export function onDemo(fn: (demo: { id: string; token: string; api: string } | null) => void): () => void;
package/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { DemoHost } from './src/DemoHost.js';
2
- export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint } from './src/core.js';
2
+ export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo } from './src/core.js';
package/package.json CHANGED
@@ -1,17 +1,27 @@
1
1
  {
2
2
  "name": "@simon-says-build/demo-player",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/simon-says-studio/simon-says-build.git",
8
+ "directory": "packages/demo-player"
9
+ },
5
10
  "type": "module",
6
11
  "main": "index.js",
7
12
  "types": "index.d.ts",
8
- "files": ["index.js", "index.d.ts", "src/", "README.md"],
13
+ "files": [
14
+ "index.js",
15
+ "index.d.ts",
16
+ "src/",
17
+ "README.md"
18
+ ],
9
19
  "peerDependencies": {
10
20
  "react": ">=18",
11
21
  "react-native": ">=0.76"
12
22
  },
13
23
  "scripts": {
14
- "test": "node --test test/"
24
+ "test": "node --test test/*.test.mjs"
15
25
  },
16
26
  "license": "UNLICENSED"
17
27
  }
package/src/DemoHost.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
10
10
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
11
  import { Linking, Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
12
- import { cardBox, demoConfig, findTarget, handlerFor, phoneSteps, scalePoint } from './core.js';
12
+ import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, scalePoint } from './core.js';
13
13
  import { demoApi, tourOf } from './api.js';
14
14
 
15
15
  const h = React.createElement;
@@ -44,12 +44,16 @@ const measure = (fiber) => new Promise((resolve) => {
44
44
  const DEFAULT_INSETS = { top: 60, bottom: 100 };
45
45
 
46
46
  export function DemoHost({ demo, router, insets, children }) {
47
- const config = useMemo(() => demoConfig(demo || {}), [demo?.id, demo?.token, demo?.api]);
47
+ const baked = useMemo(() => demoConfig(demo || {}), [demo?.id, demo?.token, demo?.api]);
48
+ const [started, setStarted] = useState(currentDemo);
49
+ useEffect(() => onDemo(setStarted), []);
50
+ const config = baked || started;
48
51
  if (!config) return children ?? null;
49
- return h(Player, { config, router, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
52
+ // Keyed by the demo: a second ▶ Demo starts its own tour from the first step.
53
+ return h(Player, { key: config.id, config, router, insets: { ...DEFAULT_INSETS, ...(insets || {}) }, onClose: baked ? null : () => playDemo(null) }, children);
50
54
  }
51
55
 
52
- function Player({ config, router, insets, children }) {
56
+ function Player({ config, router, insets, onClose, children }) {
53
57
  const rootRef = useRef(null);
54
58
  const screen = useWindowDimensions();
55
59
  const api = useMemo(() => demoApi(config), [config]);
@@ -65,7 +69,7 @@ function Player({ config, router, insets, children }) {
65
69
  useEffect(() => {
66
70
  let live = true;
67
71
  api.load()
68
- .then((d) => { if (live) setSteps(phoneSteps(tourOf(d)).concat()); })
72
+ .then((d) => { if (live) setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); })
69
73
  .catch((e) => { if (live) setError(e.message); });
70
74
  return () => { live = false; };
71
75
  }, [api]);
@@ -127,6 +131,10 @@ function Player({ config, router, insets, children }) {
127
131
  if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
128
132
  if (!steps) return null;
129
133
  if (tucked || done) {
134
+ // A demo started at run time closes when it is done (▶ Demo again replays it);
135
+ // one baked into a preview stays, to be replayed from its pill.
136
+ if (done && onClose) return h(Pressable, { style: [s.pill, { bottom: insets.bottom + 12 }], onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close the demo', testID: 'ssb-demo-close' },
137
+ h(Text, { style: s.pillText }, 'Demo done · close'));
130
138
  return h(Pressable, { style: [s.pill, { bottom: insets.bottom + 12 }], onPress: () => { setTucked(false); if (done) { setDone(false); setIndex(0); } }, accessibilityRole: 'button', accessibilityLabel: 'Open the demo' },
131
139
  h(Text, { style: s.pillText }, done ? 'Demo done · replay' : `Demo ${index + 1}/${steps.length}`));
132
140
  }
package/src/api.js CHANGED
@@ -17,12 +17,14 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
17
17
  }
18
18
 
19
19
  /** The tour a phone plays: every actor's steps, each actor introduced by who to be. */
20
- export function tourOf(demo) {
20
+ // A demo played in the build the viewer is already using, signed in as
21
+ // themselves (`here`, the overlay's ▶ Demo), is not told who to sign in as.
22
+ export function tourOf(demo, { here = false } = {}) {
21
23
  const out = [];
22
24
  for (const [actorIndex, a] of (demo?.actors || []).entries()) {
23
25
  const steps = a.tour?.steps || [];
24
26
  if (!steps.length) continue;
25
- out.push({ kind: 'say', caption: `This part is as ${a.label}. Sign in as ${a.email} if you aren't already, then tap Next.`, actorIndex, intro: true });
27
+ if (!here) out.push({ kind: 'say', caption: `This part is as ${a.label}. Sign in as ${a.email} if you aren't already, then tap Next.`, actorIndex, intro: true });
26
28
  for (const s of steps) out.push({ ...s, actorIndex });
27
29
  }
28
30
  return out;
package/src/core.js CHANGED
@@ -103,9 +103,9 @@ export function phoneSteps(steps) {
103
103
  }
104
104
 
105
105
  /** The demo this bundle carries (EXPO_PUBLIC_SSB_DEMO_*), or null when it carries none. */
106
- export function demoConfig({ id, token, api } = {}) {
106
+ export function demoConfig({ id, token, api, here } = {}) {
107
107
  if (!id || !token || !api) return null;
108
- return { id: String(id), token: String(token), api: String(api).replace(/\/+$/, '') };
108
+ return { id: String(id), token: String(token), api: String(api).replace(/\/+$/, ''), ...(here ? { here: true } : {}) };
109
109
  }
110
110
 
111
111
  // The card's box, inside the band between the insets. With no element and no
@@ -118,3 +118,18 @@ export function cardBox(place, at, screen, insets, card) {
118
118
  const box = bubbleBox(place, el, band, { w: card.w, h: card.h });
119
119
  return { left: box.left, top: box.top + insets.top };
120
120
  }
121
+
122
+ // A DEMO STARTED AT RUN TIME (2026-10-20, the in-app overlay's ▶ Demo, "Ready
123
+ // is this build"): the bundle running IS the preview of the work, so nothing
124
+ // is baked into it — SimonSays hands the overlay a demo's id and token, and
125
+ // the overlay hands them here. <DemoHost> plays a baked demo first, else this.
126
+ let runtime = null;
127
+ const listeners = new Set();
128
+ /** Play this demo now ({ id, token, api }); null stops it. */
129
+ export function playDemo(demo) {
130
+ runtime = demo ? demoConfig(demo) : null;
131
+ for (const fn of listeners) fn(runtime);
132
+ return !!runtime || !demo;
133
+ }
134
+ export const currentDemo = () => runtime;
135
+ export function onDemo(fn) { listeners.add(fn); return () => listeners.delete(fn); }