@simon-says-build/demo-player 0.1.0 → 0.3.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,17 @@ 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;
27
+
28
+ /** This player's version (0.3.0+), sent by the SSB overlay when it registers for pushes. */
29
+ export const PLAYER_VERSION: string;
30
+ /** The changes a demo's last card offers to approve or discuss: one per `job:<id>` feature. */
31
+ export function reviewChangesOf(demo: unknown): { jobId: string; title: string }[];
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, reviewChangesOf, PLAYER_VERSION } 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.3.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
@@ -5,11 +5,13 @@
5
5
  // for a demo sets). With one, it loads the demo's tour from SimonSays and walks
6
6
  // the viewer through it: a ring on each step's element, a caption card with
7
7
  // Back / Next, "Do it for me" on taps and typing, and a pill to tuck it away.
8
+ // A demo of CHANGES ends on a review card (0.3.0): ✓ Approve or 💬 Discuss
9
+ // each one, the extension's guided review on the phone (docs/GUIDED-REVIEW.md).
8
10
  //
9
11
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
10
12
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
- import { Linking, Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
12
- import { cardBox, demoConfig, findTarget, handlerFor, phoneSteps, scalePoint } from './core.js';
13
+ import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
14
+ import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, reviewChangesOf, scalePoint } from './core.js';
13
15
  import { demoApi, tourOf } from './api.js';
14
16
 
15
17
  const h = React.createElement;
@@ -44,12 +46,16 @@ const measure = (fiber) => new Promise((resolve) => {
44
46
  const DEFAULT_INSETS = { top: 60, bottom: 100 };
45
47
 
46
48
  export function DemoHost({ demo, router, insets, children }) {
47
- const config = useMemo(() => demoConfig(demo || {}), [demo?.id, demo?.token, demo?.api]);
49
+ const baked = useMemo(() => demoConfig(demo || {}), [demo?.id, demo?.token, demo?.api]);
50
+ const [started, setStarted] = useState(currentDemo);
51
+ useEffect(() => onDemo(setStarted), []);
52
+ const config = baked || started;
48
53
  if (!config) return children ?? null;
49
- return h(Player, { config, router, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
54
+ // Keyed by the demo: a second ▶ Demo starts its own tour from the first step.
55
+ return h(Player, { key: config.id, config, router, insets: { ...DEFAULT_INSETS, ...(insets || {}) }, onClose: baked ? null : () => playDemo(null) }, children);
50
56
  }
51
57
 
52
- function Player({ config, router, insets, children }) {
58
+ function Player({ config, router, insets, onClose, children }) {
53
59
  const rootRef = useRef(null);
54
60
  const screen = useWindowDimensions();
55
61
  const api = useMemo(() => demoApi(config), [config]);
@@ -61,11 +67,13 @@ function Player({ config, router, insets, children }) {
61
67
  const [card, setCard] = useState({ w: 320, h: 140 });
62
68
  const [tucked, setTucked] = useState(false);
63
69
  const [done, setDone] = useState(false);
70
+ const [changes, setChanges] = useState([]); // what the last card reviews (reviewChangesOf)
71
+ const [reviewing, setReviewing] = useState(false);
64
72
 
65
73
  useEffect(() => {
66
74
  let live = true;
67
75
  api.load()
68
- .then((d) => { if (live) setSteps(phoneSteps(tourOf(d)).concat()); })
76
+ .then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); } })
69
77
  .catch((e) => { if (live) setError(e.message); });
70
78
  return () => { live = false; };
71
79
  }, [api]);
@@ -102,6 +110,7 @@ function Player({ config, router, insets, children }) {
102
110
  }, [step, done, screen.width, screen.height]);
103
111
 
104
112
  const finish = useCallback(() => {
113
+ setReviewing(false);
105
114
  setDone(true);
106
115
  api.progress({ status: 'done' }).catch(() => {});
107
116
  }, [api]);
@@ -112,9 +121,9 @@ function Player({ config, router, insets, children }) {
112
121
  if (step.path.startsWith('/') && router?.push) router.push(step.path);
113
122
  else Linking.openURL(step.path).catch(() => {});
114
123
  }
115
- if (index + 1 >= steps.length) finish();
124
+ if (index + 1 >= steps.length) { if (changes.length) setReviewing(true); else finish(); }
116
125
  else setIndex(index + 1);
117
- }, [steps, step, index, router, finish]);
126
+ }, [steps, step, index, router, finish, changes]);
118
127
 
119
128
  const doIt = useCallback(() => {
120
129
  if (!step || !found) return;
@@ -126,10 +135,16 @@ function Player({ config, router, insets, children }) {
126
135
  const overlay = (() => {
127
136
  if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
128
137
  if (!steps) return null;
138
+ if (reviewing && !tucked) return h(Review, { api, changes, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
129
139
  if (tucked || done) {
140
+ // A demo started at run time closes when it is done (▶ Demo again replays it);
141
+ // one baked into a preview stays, to be replayed from its pill.
142
+ 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' },
143
+ h(Text, { style: s.pillText }, 'Demo done · close'));
130
144
  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
- h(Text, { style: s.pillText }, done ? 'Demo done · replay' : `Demo ${index + 1}/${steps.length}`));
145
+ h(Text, { style: s.pillText }, done ? 'Demo done · replay' : reviewing ? 'Review the changes' : `Demo ${index + 1}/${steps.length}`));
132
146
  }
147
+ if (!step && changes.length) return h(Review, { api, changes, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
133
148
  if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
134
149
  const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
135
150
  return h(React.Fragment, null,
@@ -151,6 +166,55 @@ function Player({ config, router, insets, children }) {
151
166
  h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
152
167
  }
153
168
 
169
+ // The review card: every change of the demo, each approved here or discussed
170
+ // with SSB. A word on a change is said in the conversation it came from, so
171
+ // SSB revises THAT change (functions/demo.mjs reviewRef). Nothing here holds
172
+ // the tour: Done closes it whatever is left undecided.
173
+ const ROW = { idle: null, approving: 'Approving…', approved: '✓ Approved', sending: 'Sending…', sent: 'Sent to SSB — it will revise this' };
174
+ function Review({ api, changes, screen, insets, onBack, onDone, onHide }) {
175
+ const [rows, setRows] = useState({}); // jobId → { state, error, text }
176
+ const set = (id, patch) => setRows((r) => ({ ...r, [id]: { ...(r[id] || {}), ...patch } }));
177
+ const approve = (id) => {
178
+ set(id, { state: 'approving', error: null });
179
+ api.approve(id).then(() => set(id, { state: 'approved' })).catch((e) => set(id, { state: 'idle', error: e.message }));
180
+ };
181
+ const send = (id) => {
182
+ const text = String(rows[id]?.text || '').trim();
183
+ if (!text) return;
184
+ set(id, { state: 'sending', error: null });
185
+ api.discuss(id, text).then(() => set(id, { state: 'sent' })).catch((e) => set(id, { state: 'discussing', error: e.message }));
186
+ };
187
+ const left = changes.filter((c) => !['approved', 'sent'].includes(rows[c.jobId]?.state)).length;
188
+ 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' },
189
+ h(Text, { style: s.title }, changes.length === 1 ? 'Review the change' : `Review ${changes.length} changes`),
190
+ h(Text, { style: s.body }, 'Approve what looks right. Anything that should be different, say so — SSB revises it.'),
191
+ h(ScrollView, { style: s.changes, keyboardShouldPersistTaps: 'handled' }, ...changes.map((c) => {
192
+ const r = rows[c.jobId] || {};
193
+ const state = r.state || 'idle';
194
+ return h(View, { key: c.jobId, style: s.change },
195
+ h(Text, { style: s.changeTitle }, c.title),
196
+ ROW[state] ? h(Text, { style: s.changeState }, ROW[state]) : null,
197
+ r.error ? h(Text, { style: s.error }, r.error) : null,
198
+ state === 'discussing' ? h(View, null,
199
+ h(TextInput, { style: s.input, value: r.text || '', onChangeText: (text) => set(c.jobId, { text }), placeholder: 'What should be different?', placeholderTextColor: '#8E8E93', multiline: true, autoFocus: true, accessibilityLabel: `What should be different about ${c.title}`, testID: `ssb-review-note-${c.jobId}` }),
200
+ h(View, { style: s.actions },
201
+ button('Cancel', () => set(c.jobId, { state: 'idle', error: null })),
202
+ button('Send', () => send(c.jobId), true))) : null,
203
+ state === 'idle' ? h(View, { style: s.actions },
204
+ button('💬 Discuss', () => set(c.jobId, { state: 'discussing' })),
205
+ button('✓ Approve', () => approve(c.jobId), true)) : null);
206
+ })),
207
+ h(View, { style: [s.actions, s.reviewFoot] },
208
+ button('Hide', onHide),
209
+ onBack ? button('Back', onBack) : null,
210
+ button(left ? `Done (${left} undecided)` : 'Done', onDone, !left)));
211
+ }
212
+
213
+ const button = (label, onPress, primary = false) => h(Pressable, {
214
+ key: label, onPress, accessibilityRole: 'button', accessibilityLabel: label,
215
+ style: ({ pressed }) => [s.button, primary && s.primary, pressed && s.pressed],
216
+ }, h(Text, { style: [s.buttonText, primary && s.primaryText] }, label));
217
+
154
218
  function Card({ at, place, screen, insets, card, setCard, title, body, actions = [] }) {
155
219
  const box = cardBox(place, at, screen, insets, card);
156
220
  return h(View, {
@@ -181,6 +245,14 @@ const s = StyleSheet.create({
181
245
  pressed: { opacity: 0.7 },
182
246
  buttonText: { color: '#FFFFFF', fontSize: 14, fontWeight: '600' },
183
247
  primaryText: { color: '#1C1C1E' },
248
+ review: { width: 360 },
249
+ changes: { flexGrow: 0 },
250
+ reviewFoot: { marginTop: 16 },
251
+ change: { marginTop: 14, paddingTop: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#3A3A3C' },
252
+ changeTitle: { color: '#FFFFFF', fontSize: 15, fontWeight: '600' },
253
+ changeState: { color: '#AEAEB2', fontSize: 13, marginTop: 4 },
254
+ error: { color: '#FF6961', fontSize: 13, marginTop: 4 },
255
+ input: { marginTop: 10, minHeight: 64, padding: 10, borderRadius: 10, backgroundColor: '#2C2C2E', color: '#FFFFFF', fontSize: 15 },
184
256
  pill: { position: 'absolute', right: 16, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: '#1C1C1E' },
185
257
  pillText: { color: '#FFFFFF', fontSize: 13, fontWeight: '600' },
186
258
  });
package/src/api.js CHANGED
@@ -13,16 +13,22 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
13
13
  return {
14
14
  load: () => call(''),
15
15
  progress: (body) => call('/progress', { method: 'POST', body: JSON.stringify(body) }),
16
+ // Guided review (0.3.0): approve one change of this demo, or say something
17
+ // about it in the conversation it came from — SSB revises the change.
18
+ approve: (jobId) => call('/review', { method: 'POST', body: JSON.stringify({ jobId }) }),
19
+ discuss: (jobId, text) => call('/chat', { method: 'POST', body: JSON.stringify({ text, context: { about: jobId } }) }),
16
20
  };
17
21
  }
18
22
 
19
23
  /** The tour a phone plays: every actor's steps, each actor introduced by who to be. */
20
- export function tourOf(demo) {
24
+ // A demo played in the build the viewer is already using, signed in as
25
+ // themselves (`here`, the overlay's ▶ Demo), is not told who to sign in as.
26
+ export function tourOf(demo, { here = false } = {}) {
21
27
  const out = [];
22
28
  for (const [actorIndex, a] of (demo?.actors || []).entries()) {
23
29
  const steps = a.tour?.steps || [];
24
30
  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 });
31
+ 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
32
  for (const s of steps) out.push({ ...s, actorIndex });
27
33
  }
28
34
  return out;
package/src/core.js CHANGED
@@ -7,6 +7,11 @@
7
7
  // raw text node.
8
8
  import { bubbleBox } from './step-place.js';
9
9
 
10
+ // What this player is, said when the app registers for "Ready to see"
11
+ // (packages/ssb-overlay sends it; functions/in-app-notify.mjs clientOf). Kept
12
+ // equal to package.json by test/core.test.mjs.
13
+ export const PLAYER_VERSION = '0.3.0';
14
+
10
15
  export const HOST = 5;
11
16
  export const TEXT = 6;
12
17
 
@@ -103,9 +108,9 @@ export function phoneSteps(steps) {
103
108
  }
104
109
 
105
110
  /** The demo this bundle carries (EXPO_PUBLIC_SSB_DEMO_*), or null when it carries none. */
106
- export function demoConfig({ id, token, api } = {}) {
111
+ export function demoConfig({ id, token, api, here } = {}) {
107
112
  if (!id || !token || !api) return null;
108
- return { id: String(id), token: String(token), api: String(api).replace(/\/+$/, '') };
113
+ return { id: String(id), token: String(token), api: String(api).replace(/\/+$/, ''), ...(here ? { here: true } : {}) };
109
114
  }
110
115
 
111
116
  // The card's box, inside the band between the insets. With no element and no
@@ -118,3 +123,38 @@ export function cardBox(place, at, screen, insets, card) {
118
123
  const box = bubbleBox(place, el, band, { w: card.w, h: card.h });
119
124
  return { left: box.left, top: box.top + insets.top };
120
125
  }
126
+
127
+ // A DEMO STARTED AT RUN TIME (2026-10-20, the in-app overlay's ▶ Demo, "Ready
128
+ // is this build"): the bundle running IS the preview of the work, so nothing
129
+ // is baked into it — SimonSays hands the overlay a demo's id and token, and
130
+ // the overlay hands them here. <DemoHost> plays a baked demo first, else this.
131
+ let runtime = null;
132
+ const listeners = new Set();
133
+ /** Play this demo now ({ id, token, api }); null stops it. */
134
+ export function playDemo(demo) {
135
+ runtime = demo ? demoConfig(demo) : null;
136
+ for (const fn of listeners) fn(runtime);
137
+ return !!runtime || !demo;
138
+ }
139
+ export const currentDemo = () => runtime;
140
+ export function onDemo(fn) { listeners.add(fn); return () => listeners.delete(fn); }
141
+
142
+ // GUIDED REVIEW ON A PHONE (0.3.0, docs/GUIDED-REVIEW.md "On a phone"): the
143
+ // tour's last card lists the demo's CHANGES — each `job:<id>` feature, once,
144
+ // in the order the tour met them — to approve, or to discuss with SSB. A
145
+ // demo of no jobs (a feature demo) has nothing to review and just finishes.
146
+ export function reviewChangesOf(demo) {
147
+ const out = [];
148
+ const seen = new Set();
149
+ for (const a of demo?.actors || []) {
150
+ for (const sec of a.sections || []) {
151
+ for (const f of sec.features || []) {
152
+ const m = /^job:(.+)$/.exec(String(f?.featureId || ''));
153
+ if (!m || seen.has(m[1])) continue;
154
+ seen.add(m[1]);
155
+ out.push({ jobId: m[1], title: String(f.title || 'This change') });
156
+ }
157
+ }
158
+ }
159
+ return out;
160
+ }