@simon-says-build/demo-player 0.2.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
@@ -24,3 +24,8 @@ export function DemoHost(props: {
24
24
  export function playDemo(demo: DemoBundle | null): boolean;
25
25
  export function currentDemo(): { id: string; token: string; api: string } | null;
26
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, playDemo, currentDemo, onDemo } 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/demo-player",
3
- "version": "0.2.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
5
  "repository": {
6
6
  "type": "git",
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, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, 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;
@@ -65,11 +67,13 @@ function Player({ config, router, insets, onClose, children }) {
65
67
  const [card, setCard] = useState({ w: 320, h: 140 });
66
68
  const [tucked, setTucked] = useState(false);
67
69
  const [done, setDone] = useState(false);
70
+ const [changes, setChanges] = useState([]); // what the last card reviews (reviewChangesOf)
71
+ const [reviewing, setReviewing] = useState(false);
68
72
 
69
73
  useEffect(() => {
70
74
  let live = true;
71
75
  api.load()
72
- .then((d) => { if (live) setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); })
76
+ .then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); } })
73
77
  .catch((e) => { if (live) setError(e.message); });
74
78
  return () => { live = false; };
75
79
  }, [api]);
@@ -106,6 +110,7 @@ function Player({ config, router, insets, onClose, children }) {
106
110
  }, [step, done, screen.width, screen.height]);
107
111
 
108
112
  const finish = useCallback(() => {
113
+ setReviewing(false);
109
114
  setDone(true);
110
115
  api.progress({ status: 'done' }).catch(() => {});
111
116
  }, [api]);
@@ -116,9 +121,9 @@ function Player({ config, router, insets, onClose, children }) {
116
121
  if (step.path.startsWith('/') && router?.push) router.push(step.path);
117
122
  else Linking.openURL(step.path).catch(() => {});
118
123
  }
119
- if (index + 1 >= steps.length) finish();
124
+ if (index + 1 >= steps.length) { if (changes.length) setReviewing(true); else finish(); }
120
125
  else setIndex(index + 1);
121
- }, [steps, step, index, router, finish]);
126
+ }, [steps, step, index, router, finish, changes]);
122
127
 
123
128
  const doIt = useCallback(() => {
124
129
  if (!step || !found) return;
@@ -130,14 +135,16 @@ function Player({ config, router, insets, onClose, children }) {
130
135
  const overlay = (() => {
131
136
  if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
132
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) });
133
139
  if (tucked || done) {
134
140
  // A demo started at run time closes when it is done (▶ Demo again replays it);
135
141
  // one baked into a preview stays, to be replayed from its pill.
136
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' },
137
143
  h(Text, { style: s.pillText }, 'Demo done · close'));
138
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' },
139
- 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}`));
140
146
  }
147
+ if (!step && changes.length) return h(Review, { api, changes, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
141
148
  if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
142
149
  const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
143
150
  return h(React.Fragment, null,
@@ -159,6 +166,55 @@ function Player({ config, router, insets, onClose, children }) {
159
166
  h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
160
167
  }
161
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
+
162
218
  function Card({ at, place, screen, insets, card, setCard, title, body, actions = [] }) {
163
219
  const box = cardBox(place, at, screen, insets, card);
164
220
  return h(View, {
@@ -189,6 +245,14 @@ const s = StyleSheet.create({
189
245
  pressed: { opacity: 0.7 },
190
246
  buttonText: { color: '#FFFFFF', fontSize: 14, fontWeight: '600' },
191
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 },
192
256
  pill: { position: 'absolute', right: 16, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: '#1C1C1E' },
193
257
  pillText: { color: '#FFFFFF', fontSize: 13, fontWeight: '600' },
194
258
  });
package/src/api.js CHANGED
@@ -13,6 +13,10 @@ 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
 
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
 
@@ -133,3 +138,23 @@ export function playDemo(demo) {
133
138
  }
134
139
  export const currentDemo = () => runtime;
135
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
+ }