@simon-says-build/demo-player 0.2.0 → 0.4.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,10 @@ 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 }[];
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;
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, releaseOf, 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.4.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,14 @@
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
+ // (a RELEASE's demo, 0.4.0: one Approve & release for all of them)
10
+ // each one, the extension's guided review on the phone (docs/GUIDED-REVIEW.md).
8
11
  //
9
12
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
10
13
  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';
14
+ import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
15
+ import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint } from './core.js';
13
16
  import { demoApi, tourOf } from './api.js';
14
17
 
15
18
  const h = React.createElement;
@@ -65,11 +68,14 @@ function Player({ config, router, insets, onClose, children }) {
65
68
  const [card, setCard] = useState({ w: 320, h: 140 });
66
69
  const [tucked, setTucked] = useState(false);
67
70
  const [done, setDone] = useState(false);
71
+ const [changes, setChanges] = useState([]); // what the last card reviews (reviewChangesOf)
72
+ const [release, setRelease] = useState(null); // a release's demo ends on releasing it (releaseOf)
73
+ const [reviewing, setReviewing] = useState(false);
68
74
 
69
75
  useEffect(() => {
70
76
  let live = true;
71
77
  api.load()
72
- .then((d) => { if (live) setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); })
78
+ .then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); } })
73
79
  .catch((e) => { if (live) setError(e.message); });
74
80
  return () => { live = false; };
75
81
  }, [api]);
@@ -106,6 +112,7 @@ function Player({ config, router, insets, onClose, children }) {
106
112
  }, [step, done, screen.width, screen.height]);
107
113
 
108
114
  const finish = useCallback(() => {
115
+ setReviewing(false);
109
116
  setDone(true);
110
117
  api.progress({ status: 'done' }).catch(() => {});
111
118
  }, [api]);
@@ -116,9 +123,9 @@ function Player({ config, router, insets, onClose, children }) {
116
123
  if (step.path.startsWith('/') && router?.push) router.push(step.path);
117
124
  else Linking.openURL(step.path).catch(() => {});
118
125
  }
119
- if (index + 1 >= steps.length) finish();
126
+ if (index + 1 >= steps.length) { if (changes.length) setReviewing(true); else finish(); }
120
127
  else setIndex(index + 1);
121
- }, [steps, step, index, router, finish]);
128
+ }, [steps, step, index, router, finish, changes]);
122
129
 
123
130
  const doIt = useCallback(() => {
124
131
  if (!step || !found) return;
@@ -130,14 +137,16 @@ function Player({ config, router, insets, onClose, children }) {
130
137
  const overlay = (() => {
131
138
  if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
132
139
  if (!steps) return null;
140
+ if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
133
141
  if (tucked || done) {
134
142
  // A demo started at run time closes when it is done (▶ Demo again replays it);
135
143
  // one baked into a preview stays, to be replayed from its pill.
136
144
  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
145
  h(Text, { style: s.pillText }, 'Demo done · close'));
138
146
  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}`));
147
+ h(Text, { style: s.pillText }, done ? 'Demo done · replay' : reviewing ? 'Review the changes' : `Demo ${index + 1}/${steps.length}`));
140
148
  }
149
+ if (!step && changes.length) return h(Review, { api, changes, release, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
141
150
  if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
142
151
  const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
143
152
  return h(React.Fragment, null,
@@ -159,6 +168,68 @@ function Player({ config, router, insets, onClose, children }) {
159
168
  h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
160
169
  }
161
170
 
171
+ // The review card: every change of the demo, each approved here or discussed
172
+ // with SSB. A word on a change is said in the conversation it came from, so
173
+ // SSB revises THAT change (functions/demo.mjs reviewRef). Nothing here holds
174
+ // the tour: Done closes it whatever is left undecided.
175
+ const ROW = { idle: null, approving: 'Approving…', approved: '✓ Approved', sending: 'Sending…', sent: 'Sent to SSB — it will revise this' };
176
+ function Review({ api, changes, release, screen, insets, onBack, onDone, onHide }) {
177
+ const [rows, setRows] = useState({}); // jobId → { state, error, text }
178
+ // A RELEASE (0.4.0): the changes go out together, so they are approved
179
+ // together — one button for the release; each change can still be discussed.
180
+ const [rel, setRel] = useState({ state: 'idle', error: null });
181
+ const doRelease = () => {
182
+ setRel({ state: 'releasing', error: null });
183
+ api.release().then(() => setRel({ state: 'released', error: null })).catch((e) => setRel({ state: 'idle', error: e.message }));
184
+ };
185
+ const set = (id, patch) => setRows((r) => ({ ...r, [id]: { ...(r[id] || {}), ...patch } }));
186
+ const approve = (id) => {
187
+ set(id, { state: 'approving', error: null });
188
+ api.approve(id).then(() => set(id, { state: 'approved' })).catch((e) => set(id, { state: 'idle', error: e.message }));
189
+ };
190
+ const send = (id) => {
191
+ const text = String(rows[id]?.text || '').trim();
192
+ if (!text) return;
193
+ set(id, { state: 'sending', error: null });
194
+ api.discuss(id, text).then(() => set(id, { state: 'sent' })).catch((e) => set(id, { state: 'discussing', error: e.message }));
195
+ };
196
+ const left = release ? 0 : changes.filter((c) => !['approved', 'sent'].includes(rows[c.jobId]?.state)).length;
197
+ 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' },
198
+ h(Text, { style: s.title }, release ? (changes.length === 1 ? 'The next release: 1 change' : `The next release: ${changes.length} changes`)
199
+ : changes.length === 1 ? 'Review the change' : `Review ${changes.length} changes`),
200
+ 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.`
201
+ : 'Approve what looks right. Anything that should be different, say so — SSB revises it.'),
202
+ h(ScrollView, { style: s.changes, keyboardShouldPersistTaps: 'handled' }, ...changes.map((c) => {
203
+ const r = rows[c.jobId] || {};
204
+ const state = r.state || 'idle';
205
+ return h(View, { key: c.jobId, style: s.change },
206
+ h(Text, { style: s.changeTitle }, c.title),
207
+ ROW[state] ? h(Text, { style: s.changeState }, ROW[state]) : null,
208
+ r.error ? h(Text, { style: s.error }, r.error) : null,
209
+ state === 'discussing' ? h(View, null,
210
+ 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}` }),
211
+ h(View, { style: s.actions },
212
+ button('Cancel', () => set(c.jobId, { state: 'idle', error: null })),
213
+ button('Send', () => send(c.jobId), true))) : null,
214
+ state === 'idle' ? h(View, { style: s.actions },
215
+ button('💬 Discuss', () => set(c.jobId, { state: 'discussing' })),
216
+ release ? null : button('✓ Approve', () => approve(c.jobId), true)) : null);
217
+ })),
218
+ 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,
219
+ release && rel.error ? h(Text, { style: s.error }, rel.error) : null,
220
+ h(View, { style: [s.actions, s.reviewFoot] },
221
+ button('Hide', onHide),
222
+ onBack ? button('Back', onBack) : null,
223
+ release && rel.state !== 'released'
224
+ ? button(rel.state === 'releasing' ? 'Approving…' : release.label, rel.state === 'releasing' ? () => {} : doRelease, true)
225
+ : button(left ? `Done (${left} undecided)` : 'Done', onDone, !left)));
226
+ }
227
+
228
+ const button = (label, onPress, primary = false) => h(Pressable, {
229
+ key: label, onPress, accessibilityRole: 'button', accessibilityLabel: label,
230
+ style: ({ pressed }) => [s.button, primary && s.primary, pressed && s.pressed],
231
+ }, h(Text, { style: [s.buttonText, primary && s.primaryText] }, label));
232
+
162
233
  function Card({ at, place, screen, insets, card, setCard, title, body, actions = [] }) {
163
234
  const box = cardBox(place, at, screen, insets, card);
164
235
  return h(View, {
@@ -189,6 +260,14 @@ const s = StyleSheet.create({
189
260
  pressed: { opacity: 0.7 },
190
261
  buttonText: { color: '#FFFFFF', fontSize: 14, fontWeight: '600' },
191
262
  primaryText: { color: '#1C1C1E' },
263
+ review: { width: 360 },
264
+ changes: { flexGrow: 0 },
265
+ reviewFoot: { marginTop: 16 },
266
+ change: { marginTop: 14, paddingTop: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#3A3A3C' },
267
+ changeTitle: { color: '#FFFFFF', fontSize: 15, fontWeight: '600' },
268
+ changeState: { color: '#AEAEB2', fontSize: 13, marginTop: 4 },
269
+ error: { color: '#FF6961', fontSize: 13, marginTop: 4 },
270
+ input: { marginTop: 10, minHeight: 64, padding: 10, borderRadius: 10, backgroundColor: '#2C2C2E', color: '#FFFFFF', fontSize: 15 },
192
271
  pill: { position: 'absolute', right: 16, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: '#1C1C1E' },
193
272
  pillText: { color: '#FFFFFF', fontSize: 13, fontWeight: '600' },
194
273
  });
package/src/api.js CHANGED
@@ -13,6 +13,13 @@ 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
+ // A release's demo (0.4.0): approve and release the session, as the person
20
+ // the demo was made for, after the five-minute cool-off.
21
+ release: () => call('/release', { method: 'POST', body: '{}' }),
22
+ discuss: (jobId, text) => call('/chat', { method: 'POST', body: JSON.stringify({ text, context: { about: jobId } }) }),
16
23
  };
17
24
  }
18
25
 
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.4.0';
14
+
10
15
  export const HOST = 5;
11
16
  export const TEXT = 6;
12
17
 
@@ -133,3 +138,34 @@ 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
+ }
161
+
162
+ // A RELEASE'S DEMO (0.4.0, docs/SESSION-BRANCHES.md "Told when the release is
163
+ // ready"): a demo of one session's changes carries its merge job
164
+ // (`sessionMerge`), and the review card ends on releasing them together —
165
+ // the button saying what the app's release does ("Approve & release", or
166
+ // "Approve" where the app has a release cycle of its own). Null otherwise.
167
+ export function releaseOf(demo) {
168
+ const sm = demo?.sessionMerge;
169
+ if (!sm || typeof sm.jobId !== 'string' || !sm.jobId) return null;
170
+ return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || '') };
171
+ }