@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 +14 -0
- package/index.js +1 -1
- package/package.json +13 -3
- package/src/DemoHost.js +81 -9
- package/src/api.js +8 -2
- package/src/core.js +42 -2
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.
|
|
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": [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|