@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 +7 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/DemoHost.js +85 -6
- package/src/api.js +7 -0
- package/src/core.js +36 -0
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.
|
|
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
|
+
}
|