@simon-says-build/demo-player 0.3.0 → 0.5.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 +12 -0
- package/index.js +2 -1
- package/package.json +1 -1
- package/src/DemoHost.js +25 -10
- package/src/api.js +3 -0
- package/src/core.js +12 -1
- package/src/navigation.js +52 -0
package/index.d.ts
CHANGED
|
@@ -29,3 +29,15 @@ export function onDemo(fn: (demo: { id: string; token: string; api: string } | n
|
|
|
29
29
|
export const PLAYER_VERSION: string;
|
|
30
30
|
/** The changes a demo's last card offers to approve or discuss: one per `job:<id>` feature. */
|
|
31
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;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* React Navigation (an app without expo-router): a path is the chain of route
|
|
37
|
+
* names to the focused screen, "/MainStack/Tabs/Dashboard". `nav` is the
|
|
38
|
+
* NavigationContainer's ref (or the container), read at call time.
|
|
39
|
+
*/
|
|
40
|
+
export function navigationRoute(nav: unknown): string;
|
|
41
|
+
export function navigationRouter(nav: unknown): { push: (path: string) => boolean };
|
|
42
|
+
export function navigateArgs(path: string): [string] | [string, object] | null;
|
|
43
|
+
export function pathOfState(state: unknown): string;
|
package/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { DemoHost } from './src/DemoHost.js';
|
|
2
|
-
export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, PLAYER_VERSION } from './src/core.js';
|
|
2
|
+
export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, PLAYER_VERSION } from './src/core.js';
|
|
3
|
+
export { navigationRoute, navigationRouter, navigateArgs, pathOfState } from './src/navigation.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.5.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
|
@@ -6,12 +6,13 @@
|
|
|
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
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)
|
|
9
10
|
// each one, the extension's guided review on the phone (docs/GUIDED-REVIEW.md).
|
|
10
11
|
//
|
|
11
12
|
// No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
|
|
12
13
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
14
|
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';
|
|
15
|
+
import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint } from './core.js';
|
|
15
16
|
import { demoApi, tourOf } from './api.js';
|
|
16
17
|
|
|
17
18
|
const h = React.createElement;
|
|
@@ -68,12 +69,13 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
68
69
|
const [tucked, setTucked] = useState(false);
|
|
69
70
|
const [done, setDone] = useState(false);
|
|
70
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)
|
|
71
73
|
const [reviewing, setReviewing] = useState(false);
|
|
72
74
|
|
|
73
75
|
useEffect(() => {
|
|
74
76
|
let live = true;
|
|
75
77
|
api.load()
|
|
76
|
-
.then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); } })
|
|
78
|
+
.then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); } })
|
|
77
79
|
.catch((e) => { if (live) setError(e.message); });
|
|
78
80
|
return () => { live = false; };
|
|
79
81
|
}, [api]);
|
|
@@ -135,7 +137,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
135
137
|
const overlay = (() => {
|
|
136
138
|
if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
|
|
137
139
|
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) });
|
|
140
|
+
if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
|
|
139
141
|
if (tucked || done) {
|
|
140
142
|
// A demo started at run time closes when it is done (▶ Demo again replays it);
|
|
141
143
|
// one baked into a preview stays, to be replayed from its pill.
|
|
@@ -144,7 +146,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
144
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' },
|
|
145
147
|
h(Text, { style: s.pillText }, done ? 'Demo done · replay' : reviewing ? 'Review the changes' : `Demo ${index + 1}/${steps.length}`));
|
|
146
148
|
}
|
|
147
|
-
if (!step && changes.length) return h(Review, { api, changes, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
|
|
149
|
+
if (!step && changes.length) return h(Review, { api, changes, release, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
|
|
148
150
|
if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
|
|
149
151
|
const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
|
|
150
152
|
return h(React.Fragment, null,
|
|
@@ -171,8 +173,15 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
171
173
|
// SSB revises THAT change (functions/demo.mjs reviewRef). Nothing here holds
|
|
172
174
|
// the tour: Done closes it whatever is left undecided.
|
|
173
175
|
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 }) {
|
|
176
|
+
function Review({ api, changes, release, screen, insets, onBack, onDone, onHide }) {
|
|
175
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
|
+
};
|
|
176
185
|
const set = (id, patch) => setRows((r) => ({ ...r, [id]: { ...(r[id] || {}), ...patch } }));
|
|
177
186
|
const approve = (id) => {
|
|
178
187
|
set(id, { state: 'approving', error: null });
|
|
@@ -184,10 +193,12 @@ function Review({ api, changes, screen, insets, onBack, onDone, onHide }) {
|
|
|
184
193
|
set(id, { state: 'sending', error: null });
|
|
185
194
|
api.discuss(id, text).then(() => set(id, { state: 'sent' })).catch((e) => set(id, { state: 'discussing', error: e.message }));
|
|
186
195
|
};
|
|
187
|
-
const left = changes.filter((c) => !['approved', 'sent'].includes(rows[c.jobId]?.state)).length;
|
|
196
|
+
const left = release ? 0 : changes.filter((c) => !['approved', 'sent'].includes(rows[c.jobId]?.state)).length;
|
|
188
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' },
|
|
189
|
-
h(Text, { style: s.title }, changes.length === 1 ? '
|
|
190
|
-
|
|
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.'),
|
|
191
202
|
h(ScrollView, { style: s.changes, keyboardShouldPersistTaps: 'handled' }, ...changes.map((c) => {
|
|
192
203
|
const r = rows[c.jobId] || {};
|
|
193
204
|
const state = r.state || 'idle';
|
|
@@ -202,12 +213,16 @@ function Review({ api, changes, screen, insets, onBack, onDone, onHide }) {
|
|
|
202
213
|
button('Send', () => send(c.jobId), true))) : null,
|
|
203
214
|
state === 'idle' ? h(View, { style: s.actions },
|
|
204
215
|
button('💬 Discuss', () => set(c.jobId, { state: 'discussing' })),
|
|
205
|
-
button('✓ Approve', () => approve(c.jobId), true)) : null);
|
|
216
|
+
release ? null : button('✓ Approve', () => approve(c.jobId), true)) : null);
|
|
206
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,
|
|
207
220
|
h(View, { style: [s.actions, s.reviewFoot] },
|
|
208
221
|
button('Hide', onHide),
|
|
209
222
|
onBack ? button('Back', onBack) : null,
|
|
210
|
-
|
|
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)));
|
|
211
226
|
}
|
|
212
227
|
|
|
213
228
|
const button = (label, onPress, primary = false) => h(Pressable, {
|
package/src/api.js
CHANGED
|
@@ -16,6 +16,9 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
|
|
|
16
16
|
// Guided review (0.3.0): approve one change of this demo, or say something
|
|
17
17
|
// about it in the conversation it came from — SSB revises the change.
|
|
18
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: '{}' }),
|
|
19
22
|
discuss: (jobId, text) => call('/chat', { method: 'POST', body: JSON.stringify({ text, context: { about: jobId } }) }),
|
|
20
23
|
};
|
|
21
24
|
}
|
package/src/core.js
CHANGED
|
@@ -10,7 +10,7 @@ import { bubbleBox } from './step-place.js';
|
|
|
10
10
|
// What this player is, said when the app registers for "Ready to see"
|
|
11
11
|
// (packages/ssb-overlay sends it; functions/in-app-notify.mjs clientOf). Kept
|
|
12
12
|
// equal to package.json by test/core.test.mjs.
|
|
13
|
-
export const PLAYER_VERSION = '0.
|
|
13
|
+
export const PLAYER_VERSION = '0.5.0';
|
|
14
14
|
|
|
15
15
|
export const HOST = 5;
|
|
16
16
|
export const TEXT = 6;
|
|
@@ -158,3 +158,14 @@ export function reviewChangesOf(demo) {
|
|
|
158
158
|
}
|
|
159
159
|
return out;
|
|
160
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// REACT NAVIGATION, for an app without expo-router (2026-10-22, SuperSub's
|
|
2
|
+
// Center and Sub apps). A demo's `goto` step carries an in-app PATH, and the
|
|
3
|
+
// in-app overlay reports the screen a person is on as one. For an
|
|
4
|
+
// expo-router app that is its URL path. For a React Navigation app it is the
|
|
5
|
+
// chain of route NAMES from the root to the focused screen:
|
|
6
|
+
//
|
|
7
|
+
// /MainStack/Tabs/Dashboard
|
|
8
|
+
//
|
|
9
|
+
// navigationRoute() reads that off the navigation container, and
|
|
10
|
+
// navigationRouter() replays it with nested navigate() calls, so a path the
|
|
11
|
+
// overlay recorded is one the demo player can open. Both take the container
|
|
12
|
+
// (or a ref to it, read at call time). Pure apart from the container.
|
|
13
|
+
|
|
14
|
+
const containerOf = (nav) => (nav && 'current' in nav ? nav.current : nav);
|
|
15
|
+
|
|
16
|
+
/** The focused route's path, "/A/B/C", from a navigation state; "" when there is none. */
|
|
17
|
+
export function pathOfState(state) {
|
|
18
|
+
const names = [];
|
|
19
|
+
for (let s = state; s && Array.isArray(s.routes) && s.routes.length;) {
|
|
20
|
+
const r = s.routes[Number.isInteger(s.index) ? s.index : s.routes.length - 1];
|
|
21
|
+
if (!r?.name) break;
|
|
22
|
+
names.push(r.name);
|
|
23
|
+
s = r.state;
|
|
24
|
+
}
|
|
25
|
+
return names.length ? `/${names.map(encodeURIComponent).join('/')}` : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What navigate() is called with to open a path: [name, params], or null for a path that names nothing. */
|
|
29
|
+
export function navigateArgs(path) {
|
|
30
|
+
const names = String(path || '').split('?')[0].split('/').filter(Boolean).map((n) => { try { return decodeURIComponent(n); } catch { return n; } });
|
|
31
|
+
if (!names.length) return null;
|
|
32
|
+
let params;
|
|
33
|
+
for (let i = names.length - 1; i >= 1; i--) params = params ? { screen: names[i], params } : { screen: names[i] };
|
|
34
|
+
return params ? [names[0], params] : [names[0]];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The current screen's path on a React Navigation container ("" before it is ready). */
|
|
38
|
+
export function navigationRoute(nav) {
|
|
39
|
+
try { return pathOfState(containerOf(nav)?.getRootState?.()); } catch { return ''; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A `router` for <DemoHost> on a React Navigation app: push(path) opens it. */
|
|
43
|
+
export function navigationRouter(nav) {
|
|
44
|
+
return {
|
|
45
|
+
push(path) {
|
|
46
|
+
const c = containerOf(nav);
|
|
47
|
+
const args = navigateArgs(path);
|
|
48
|
+
if (!c?.navigate || !args) return false;
|
|
49
|
+
try { c.navigate(...args); return true; } catch { return false; }
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|