@simon-says-build/demo-player 0.0.0-stage → 0.1.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/README.md +24 -2
- package/index.d.ts +17 -0
- package/index.js +2 -0
- package/package.json +15 -4
- package/src/DemoHost.js +186 -0
- package/src/api.js +29 -0
- package/src/core.js +120 -0
- package/src/step-place.js +55 -0
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @simon-says-build/demo-player
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The mobile half of SimonSays demos (docs/MOBILE-DEMO.md): a job's preview, opened in the app's dev client, walks the viewer through the change.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { router } from "expo-router";
|
|
7
|
+
import { DemoHost } from "@simon-says-build/demo-player";
|
|
8
|
+
|
|
9
|
+
<DemoHost
|
|
10
|
+
router={router}
|
|
11
|
+
demo={{
|
|
12
|
+
id: process.env.EXPO_PUBLIC_SSB_DEMO_ID,
|
|
13
|
+
token: process.env.EXPO_PUBLIC_SSB_DEMO_TOKEN,
|
|
14
|
+
api: process.env.EXPO_PUBLIC_SSB_API,
|
|
15
|
+
}}
|
|
16
|
+
>
|
|
17
|
+
{/* the app */}
|
|
18
|
+
</DemoHost>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Pass the `process.env.EXPO_PUBLIC_*` reads in the app's own code. Expo inlines them where the app references them, so the values only exist in a bundle a demo preview served.
|
|
22
|
+
|
|
23
|
+
Without all three it renders its children and nothing else. Elements are found by `testID`, then `accessibilityLabel`, then visible text, by walking React's tree. No component needs wrapping.
|
|
24
|
+
|
|
25
|
+
`npm test` runs the pure half.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** The demo this bundle carries: pass the three EXPO_PUBLIC_SSB_DEMO_* values. Any missing = no demo. */
|
|
4
|
+
export type DemoBundle = { id?: string; token?: string; api?: string };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrap the app's root. Renders `children` unchanged unless `demo` is complete,
|
|
8
|
+
* so production and ordinary dev builds are unaffected.
|
|
9
|
+
* `router` (expo-router's) opens a step's in-app path; anything else goes to Linking.
|
|
10
|
+
*/
|
|
11
|
+
export function DemoHost(props: {
|
|
12
|
+
demo?: DemoBundle;
|
|
13
|
+
router?: { push: (path: string) => void };
|
|
14
|
+
/** Keep the card off the status bar (top) and tab bar + home indicator (bottom). Default {top: 60, bottom: 100}. */
|
|
15
|
+
insets?: { top?: number; bottom?: number };
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
}): ReactNode;
|
package/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/demo-player",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"files": ["index.js", "index.d.ts", "src/", "README.md"],
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"react": ">=18",
|
|
11
|
+
"react-native": ">=0.76"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/"
|
|
15
|
+
},
|
|
16
|
+
"license": "UNLICENSED"
|
|
17
|
+
}
|
package/src/DemoHost.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// <DemoHost>: the mobile demo player (docs/MOBILE-DEMO.md).
|
|
2
|
+
//
|
|
3
|
+
// Renders its children and nothing else unless the bundle carries a demo
|
|
4
|
+
// (EXPO_PUBLIC_SSB_DEMO_ID / _TOKEN / _API, which only a preview Metro started
|
|
5
|
+
// for a demo sets). With one, it loads the demo's tour from SimonSays and walks
|
|
6
|
+
// the viewer through it: a ring on each step's element, a caption card with
|
|
7
|
+
// Back / Next, "Do it for me" on taps and typing, and a pill to tuck it away.
|
|
8
|
+
//
|
|
9
|
+
// No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
|
|
10
|
+
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 { demoApi, tourOf } from './api.js';
|
|
14
|
+
|
|
15
|
+
const h = React.createElement;
|
|
16
|
+
const FIND_FOR_MS = 8000; // a step's element may appear after a navigation settles
|
|
17
|
+
const TRACK_EVERY_MS = 400; // follow scrolling and layout while a step is shown
|
|
18
|
+
|
|
19
|
+
// React keeps the fiber on the host instance (Fabric: __internalInstanceHandle).
|
|
20
|
+
const fiberOf = (instance) => instance?.__internalInstanceHandle ?? instance?._internalInstanceHandle ?? null;
|
|
21
|
+
|
|
22
|
+
let publicInstanceOf = (fiber) => fiber?.stateNode?.canonical?.publicInstance ?? null;
|
|
23
|
+
try {
|
|
24
|
+
// The renderer's own accessor creates the public instance when React has not yet.
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
26
|
+
const impl = require('react-native/Libraries/ReactNative/RendererImplementation');
|
|
27
|
+
if (typeof impl.getPublicInstanceFromInternalInstanceHandle === 'function') {
|
|
28
|
+
const fallback = publicInstanceOf;
|
|
29
|
+
publicInstanceOf = (fiber) => {
|
|
30
|
+
try { return impl.getPublicInstanceFromInternalInstanceHandle(fiber) ?? fallback(fiber); } catch { return fallback(fiber); }
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
} catch { /* an older renderer: the fallback above */ }
|
|
34
|
+
|
|
35
|
+
const measure = (fiber) => new Promise((resolve) => {
|
|
36
|
+
const inst = publicInstanceOf(fiber);
|
|
37
|
+
if (!inst?.measureInWindow) return resolve(null);
|
|
38
|
+
inst.measureInWindow((x, y, width, height) => resolve(width || height ? { left: x, top: y, width, height, right: x + width, bottom: y + height } : null));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Where the card must not go: the status bar above, a tab bar and the home
|
|
42
|
+
// indicator below. An app passes its real insets (useSafeAreaInsets plus its
|
|
43
|
+
// tab bar); these defaults fit a notched iPhone with a tab bar.
|
|
44
|
+
const DEFAULT_INSETS = { top: 60, bottom: 100 };
|
|
45
|
+
|
|
46
|
+
export function DemoHost({ demo, router, insets, children }) {
|
|
47
|
+
const config = useMemo(() => demoConfig(demo || {}), [demo?.id, demo?.token, demo?.api]);
|
|
48
|
+
if (!config) return children ?? null;
|
|
49
|
+
return h(Player, { config, router, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Player({ config, router, insets, children }) {
|
|
53
|
+
const rootRef = useRef(null);
|
|
54
|
+
const screen = useWindowDimensions();
|
|
55
|
+
const api = useMemo(() => demoApi(config), [config]);
|
|
56
|
+
const [steps, setSteps] = useState(null);
|
|
57
|
+
const [error, setError] = useState(null);
|
|
58
|
+
const [index, setIndex] = useState(0);
|
|
59
|
+
const [rect, setRect] = useState(null);
|
|
60
|
+
const [found, setFound] = useState(null); // the step's host fiber, for "Do it for me"
|
|
61
|
+
const [card, setCard] = useState({ w: 320, h: 140 });
|
|
62
|
+
const [tucked, setTucked] = useState(false);
|
|
63
|
+
const [done, setDone] = useState(false);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
let live = true;
|
|
67
|
+
api.load()
|
|
68
|
+
.then((d) => { if (live) setSteps(phoneSteps(tourOf(d)).concat()); })
|
|
69
|
+
.catch((e) => { if (live) setError(e.message); });
|
|
70
|
+
return () => { live = false; };
|
|
71
|
+
}, [api]);
|
|
72
|
+
|
|
73
|
+
const step = steps?.[index] ?? null;
|
|
74
|
+
|
|
75
|
+
// Find and follow the step's element.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
setRect(null); setFound(null);
|
|
78
|
+
if (!step || done) return undefined;
|
|
79
|
+
const target = step.target;
|
|
80
|
+
if (!target) return undefined;
|
|
81
|
+
if (target.point && !target.testId && !target.label && !target.text) {
|
|
82
|
+
const p = scalePoint(target.point, screen);
|
|
83
|
+
setRect({ left: p.x - 22, top: p.y - 22, width: 44, height: 44, right: p.x + 22, bottom: p.y + 22 });
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
let live = true;
|
|
87
|
+
const started = Date.now();
|
|
88
|
+
const tick = async () => {
|
|
89
|
+
const f = findTarget(fiberOf(rootRef.current), target);
|
|
90
|
+
const r = f ? await measure(f) : null;
|
|
91
|
+
if (!live) return;
|
|
92
|
+
setFound(f);
|
|
93
|
+
setRect(r);
|
|
94
|
+
if (!r && Date.now() - started > FIND_FOR_MS && target.point) {
|
|
95
|
+
const p = scalePoint(target.point, screen);
|
|
96
|
+
setRect({ left: p.x - 22, top: p.y - 22, width: 44, height: 44, right: p.x + 22, bottom: p.y + 22 });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
tick();
|
|
100
|
+
const t = setInterval(tick, TRACK_EVERY_MS);
|
|
101
|
+
return () => { live = false; clearInterval(t); };
|
|
102
|
+
}, [step, done, screen.width, screen.height]);
|
|
103
|
+
|
|
104
|
+
const finish = useCallback(() => {
|
|
105
|
+
setDone(true);
|
|
106
|
+
api.progress({ status: 'done' }).catch(() => {});
|
|
107
|
+
}, [api]);
|
|
108
|
+
|
|
109
|
+
const next = useCallback(() => {
|
|
110
|
+
if (!steps) return;
|
|
111
|
+
if (step?.kind === 'goto' && step.path) {
|
|
112
|
+
if (step.path.startsWith('/') && router?.push) router.push(step.path);
|
|
113
|
+
else Linking.openURL(step.path).catch(() => {});
|
|
114
|
+
}
|
|
115
|
+
if (index + 1 >= steps.length) finish();
|
|
116
|
+
else setIndex(index + 1);
|
|
117
|
+
}, [steps, step, index, router, finish]);
|
|
118
|
+
|
|
119
|
+
const doIt = useCallback(() => {
|
|
120
|
+
if (!step || !found) return;
|
|
121
|
+
if (step.kind === 'click') handlerFor(found, 'onPress')?.({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} });
|
|
122
|
+
if (step.kind === 'fill' && step.value != null) handlerFor(found, 'onChangeText')?.(step.value);
|
|
123
|
+
next();
|
|
124
|
+
}, [step, found, next]);
|
|
125
|
+
|
|
126
|
+
const overlay = (() => {
|
|
127
|
+
if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
|
|
128
|
+
if (!steps) return null;
|
|
129
|
+
if (tucked || done) {
|
|
130
|
+
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}`));
|
|
132
|
+
}
|
|
133
|
+
if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
|
|
134
|
+
const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
|
|
135
|
+
return h(React.Fragment, null,
|
|
136
|
+
rect ? h(View, { pointerEvents: 'none', style: [s.ring, { left: rect.left - 6, top: rect.top - 6, width: rect.width + 12, height: rect.height + 12 }] }) : null,
|
|
137
|
+
h(Card, {
|
|
138
|
+
at: rect, place: step.place, screen, insets, card, setCard,
|
|
139
|
+
title: `${index + 1} of ${steps.length}`, body: step.caption,
|
|
140
|
+
actions: [
|
|
141
|
+
{ label: 'Hide', onPress: () => setTucked(true) },
|
|
142
|
+
index > 0 ? { label: 'Back', onPress: () => setIndex(index - 1) } : null,
|
|
143
|
+
canDo ? { label: step.kind === 'fill' ? 'Type it for me' : 'Do it for me', onPress: doIt } : null,
|
|
144
|
+
{ label: index + 1 >= steps.length ? 'Finish' : step.kind === 'goto' ? 'Take me there' : 'Next', onPress: next, primary: true },
|
|
145
|
+
].filter(Boolean),
|
|
146
|
+
}));
|
|
147
|
+
})();
|
|
148
|
+
|
|
149
|
+
return h(View, { ref: rootRef, style: s.root, collapsable: false },
|
|
150
|
+
children,
|
|
151
|
+
h(View, { pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, overlay));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function Card({ at, place, screen, insets, card, setCard, title, body, actions = [] }) {
|
|
155
|
+
const box = cardBox(place, at, screen, insets, card);
|
|
156
|
+
return h(View, {
|
|
157
|
+
style: [s.card, { left: box.left, top: box.top, maxWidth: screen.width - 32 }],
|
|
158
|
+
onLayout: (e) => {
|
|
159
|
+
const { width, height } = e.nativeEvent.layout;
|
|
160
|
+
if (Math.abs(width - card.w) > 1 || Math.abs(height - card.h) > 1) setCard({ w: width, h: height });
|
|
161
|
+
},
|
|
162
|
+
accessibilityViewIsModal: false,
|
|
163
|
+
},
|
|
164
|
+
h(Text, { style: s.title }, title),
|
|
165
|
+
h(Text, { style: s.body }, body),
|
|
166
|
+
actions.length ? h(View, { style: s.actions }, ...actions.map((a) => h(Pressable, {
|
|
167
|
+
key: a.label, onPress: a.onPress, accessibilityRole: 'button', accessibilityLabel: a.label,
|
|
168
|
+
style: ({ pressed }) => [s.button, a.primary && s.primary, pressed && s.pressed],
|
|
169
|
+
}, h(Text, { style: [s.buttonText, a.primary && s.primaryText] }, a.label)))) : null);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const s = StyleSheet.create({
|
|
173
|
+
root: { flex: 1 },
|
|
174
|
+
ring: { position: 'absolute', borderRadius: 12, borderWidth: 3, borderColor: '#FFFFFF', shadowColor: '#000', shadowOpacity: 0.6, shadowRadius: 8, shadowOffset: { width: 0, height: 0 } },
|
|
175
|
+
card: { position: 'absolute', width: 320, padding: 16, borderRadius: 16, backgroundColor: '#1C1C1E', shadowColor: '#000', shadowOpacity: 0.35, shadowRadius: 16, shadowOffset: { width: 0, height: 6 } },
|
|
176
|
+
title: { color: '#AEAEB2', fontSize: 12, fontWeight: '600', marginBottom: 6 },
|
|
177
|
+
body: { color: '#FFFFFF', fontSize: 16, lineHeight: 22 },
|
|
178
|
+
actions: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 14, gap: 8 },
|
|
179
|
+
button: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 10, backgroundColor: '#2C2C2E' },
|
|
180
|
+
primary: { backgroundColor: '#FFFFFF' },
|
|
181
|
+
pressed: { opacity: 0.7 },
|
|
182
|
+
buttonText: { color: '#FFFFFF', fontSize: 14, fontWeight: '600' },
|
|
183
|
+
primaryText: { color: '#1C1C1E' },
|
|
184
|
+
pill: { position: 'absolute', right: 16, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: '#1C1C1E' },
|
|
185
|
+
pillText: { color: '#FFFFFF', fontSize: 13, fontWeight: '600' },
|
|
186
|
+
});
|
package/src/api.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The demo API the web extension already calls (functions/index.mjs
|
|
2
|
+
// /api/demo/:id/*), authenticated by the demo's own token.
|
|
3
|
+
export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
|
|
4
|
+
const call = async (path, init = {}) => {
|
|
5
|
+
const res = await fetchImpl(`${api}/api/demo/${encodeURIComponent(id)}${path}`, {
|
|
6
|
+
...init,
|
|
7
|
+
headers: { 'content-type': 'application/json', 'x-demo-token': token, ...(init.headers || {}) },
|
|
8
|
+
});
|
|
9
|
+
const json = await res.json().catch(() => ({}));
|
|
10
|
+
if (!res.ok) throw new Error(json.error || `demo API ${res.status}`);
|
|
11
|
+
return json;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
load: () => call(''),
|
|
15
|
+
progress: (body) => call('/progress', { method: 'POST', body: JSON.stringify(body) }),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The tour a phone plays: every actor's steps, each actor introduced by who to be. */
|
|
20
|
+
export function tourOf(demo) {
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const [actorIndex, a] of (demo?.actors || []).entries()) {
|
|
23
|
+
const steps = a.tour?.steps || [];
|
|
24
|
+
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 });
|
|
26
|
+
for (const s of steps) out.push({ ...s, actorIndex });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The player's pure half: no React Native here, so node --test runs it.
|
|
2
|
+
//
|
|
3
|
+
// A step's element is found in React's own tree (fibers: child / sibling /
|
|
4
|
+
// return, props on memoizedProps), the way docs/MOBILE-DEMO.md describes, so
|
|
5
|
+
// no app component has to be wrapped. React's tags for the two host kinds a
|
|
6
|
+
// phone renders: a host component (View, Pressable's view, TextInput…) and a
|
|
7
|
+
// raw text node.
|
|
8
|
+
import { bubbleBox } from './step-place.js';
|
|
9
|
+
|
|
10
|
+
export const HOST = 5;
|
|
11
|
+
export const TEXT = 6;
|
|
12
|
+
|
|
13
|
+
// The screen the iOS drive records on (drivers/ios.sh refuses points off it).
|
|
14
|
+
export const RECORDED_SCREEN = { width: 393, height: 852 };
|
|
15
|
+
|
|
16
|
+
// What a phone can perform. `press` (a keyboard key), `select` (a web
|
|
17
|
+
// <select>) and `run` (a terminal demo) have no phone equivalent.
|
|
18
|
+
const PHONE_KINDS = new Set(['say', 'goto', 'show', 'click', 'fill']);
|
|
19
|
+
|
|
20
|
+
const lower = (s) => String(s ?? '').trim().toLowerCase();
|
|
21
|
+
const hiddenHost = (f) => {
|
|
22
|
+
const style = f.memoizedProps?.style;
|
|
23
|
+
const flat = Array.isArray(style) ? Object.assign({}, ...style.flat(Infinity).filter(Boolean)) : style;
|
|
24
|
+
return flat?.display === 'none';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function* walk(root) {
|
|
28
|
+
// Iterative depth-first, skipping a display:none host and everything in it.
|
|
29
|
+
const stack = root ? [root] : [];
|
|
30
|
+
while (stack.length) {
|
|
31
|
+
const f = stack.pop();
|
|
32
|
+
if (f.tag === HOST && hiddenHost(f)) continue;
|
|
33
|
+
yield f;
|
|
34
|
+
const kids = [];
|
|
35
|
+
for (let c = f.child; c; c = c.sibling) kids.push(c);
|
|
36
|
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const nearestHost = (f) => {
|
|
41
|
+
for (let p = f; p; p = p.return) if (p.tag === HOST) return p;
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const textOf = (f) => (typeof f.memoizedProps === 'string' ? f.memoizedProps : String(f.memoizedProps?.text ?? ''));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The host fiber a step's target names, or null. Preference follows the
|
|
49
|
+
* contract: testID, then accessibility label (or aria-label), then visible
|
|
50
|
+
* text (the host that holds it). A `point` is not resolved here: the player
|
|
51
|
+
* draws it where it is.
|
|
52
|
+
*/
|
|
53
|
+
export function findTarget(root, target) {
|
|
54
|
+
if (!target) return null;
|
|
55
|
+
if (target.testId) {
|
|
56
|
+
for (const f of walk(root)) if (f.tag === HOST && f.memoizedProps?.testID === target.testId) return f;
|
|
57
|
+
}
|
|
58
|
+
if (target.label) {
|
|
59
|
+
const want = lower(target.label);
|
|
60
|
+
for (const f of walk(root)) {
|
|
61
|
+
if (f.tag !== HOST) continue;
|
|
62
|
+
const p = f.memoizedProps || {};
|
|
63
|
+
if (lower(p.accessibilityLabel) === want || lower(p['aria-label']) === want) return f;
|
|
64
|
+
}
|
|
65
|
+
// The iOS drive records the text it SEARCHED by, a case-insensitive
|
|
66
|
+
// substring of the label (drivers/ios-find.py), so an exact miss falls
|
|
67
|
+
// back to the first label that contains it.
|
|
68
|
+
for (const f of walk(root)) {
|
|
69
|
+
if (f.tag !== HOST) continue;
|
|
70
|
+
const p = f.memoizedProps || {};
|
|
71
|
+
if (lower(p.accessibilityLabel).includes(want) || lower(p['aria-label']).includes(want)) return f;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (target.text) {
|
|
75
|
+
const want = lower(target.text);
|
|
76
|
+
// The host that holds the text (on a phone, its Text view): the ring sits
|
|
77
|
+
// on the words themselves, and "Do it for me" finds the press above them.
|
|
78
|
+
for (const f of walk(root)) if (f.tag === TEXT && lower(textOf(f)).includes(want)) return nearestHost(f.return);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The nearest `name` handler at or above `f` (onPress, onChangeText), or null. */
|
|
84
|
+
export function handlerFor(f, name) {
|
|
85
|
+
for (let p = f; p; p = p.return) {
|
|
86
|
+
const h = p.memoizedProps?.[name];
|
|
87
|
+
if (typeof h === 'function') return h;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A recorded point, scaled from the recording screen to `screen`. */
|
|
93
|
+
export function scalePoint(point, screen, from = RECORDED_SCREEN) {
|
|
94
|
+
return {
|
|
95
|
+
x: Math.round((point.x * screen.width) / from.width),
|
|
96
|
+
y: Math.round((point.y * screen.height) / from.height),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The steps of a tour a phone can play, in order. A step with no caption says nothing and is dropped. */
|
|
101
|
+
export function phoneSteps(steps) {
|
|
102
|
+
return (steps || []).filter((s) => s && PHONE_KINDS.has(s.kind) && String(s.caption || '').trim());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The demo this bundle carries (EXPO_PUBLIC_SSB_DEMO_*), or null when it carries none. */
|
|
106
|
+
export function demoConfig({ id, token, api } = {}) {
|
|
107
|
+
if (!id || !token || !api) return null;
|
|
108
|
+
return { id: String(id), token: String(token), api: String(api).replace(/\/+$/, '') };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The card's box, inside the band between the insets. With no element and no
|
|
112
|
+
// placement it sits in the middle of that band: a card pinned to an edge
|
|
113
|
+
// covers the tab bar or a sheet's content, and the viewer may need either.
|
|
114
|
+
export function cardBox(place, at, screen, insets, card) {
|
|
115
|
+
const band = { w: screen.width, h: Math.max(card.h + 32, screen.height - insets.top - insets.bottom) };
|
|
116
|
+
if (!at && !place) return { left: Math.max(16, (screen.width - card.w) / 2), top: insets.top + Math.max(16, (band.h - card.h) / 2) };
|
|
117
|
+
const el = at ? { ...at, top: at.top - insets.top, bottom: at.bottom - insets.top } : null;
|
|
118
|
+
const box = bubbleBox(place, el, band, { w: card.w, h: card.h });
|
|
119
|
+
return { left: box.left, top: box.top + insets.top };
|
|
120
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A COPY of schemas/step-place.mjs, so the package stands alone in an app.
|
|
2
|
+
// test/parity.test.mjs fails if the two drift: change schemas/step-place.mjs
|
|
3
|
+
// and copy it here.
|
|
4
|
+
// WHERE A DEMO STEP'S WORDS SIT ON THE SCREEN (docs/LIVE-DEMO.md "Placement",
|
|
5
|
+
// 2026-10-14). The tour's caption is a bubble beside the element it is about —
|
|
6
|
+
// and a bubble can cover exactly what the step is showing. The rehearsal agent
|
|
7
|
+
// SEES the screen when it records a step, so it says where the words go:
|
|
8
|
+
//
|
|
9
|
+
// pinTo below | above | left | right beside the element, `offset` px away
|
|
10
|
+
// top | bottom pinned to that edge of the screen,
|
|
11
|
+
// `offset` px in (an arrow points at the element)
|
|
12
|
+
// offset 0–600 px (default 14 beside an element, 24 at an edge)
|
|
13
|
+
//
|
|
14
|
+
// No placement is the old rule: below the element when it fits, else above.
|
|
15
|
+
// Pure and browser-safe: demo-step writes it, the rehearsal keeps it, the
|
|
16
|
+
// player (extension/overlay.js) reads it.
|
|
17
|
+
export const PIN_TO = ['below', 'above', 'left', 'right', 'top', 'bottom'];
|
|
18
|
+
export const MAX_OFFSET = 600;
|
|
19
|
+
|
|
20
|
+
/** A placement, cleaned; null when there is none (or it is not one). */
|
|
21
|
+
export function cleanPlace(raw) {
|
|
22
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
23
|
+
const pinTo = String(raw.pinTo || '').toLowerCase();
|
|
24
|
+
if (!PIN_TO.includes(pinTo)) return null;
|
|
25
|
+
const n = Number(raw.offset);
|
|
26
|
+
const offset = Number.isFinite(n) ? Math.max(0, Math.min(MAX_OFFSET, Math.round(n))) : undefined;
|
|
27
|
+
return { pinTo, ...(offset !== undefined ? { offset } : {}) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The bubble's box for a placement. Pure geometry the player and its test share.
|
|
32
|
+
* el the element's rect {left, top, right, bottom, width, height}, or null
|
|
33
|
+
* view {w, h} — the screen, less the card
|
|
34
|
+
* bubble {w, h} — the bubble's size
|
|
35
|
+
* → {left, top} in px, kept on screen.
|
|
36
|
+
*/
|
|
37
|
+
export function bubbleBox(place, el, view, bubble) {
|
|
38
|
+
const p = cleanPlace(place);
|
|
39
|
+
const clampX = (x) => Math.max(16, Math.min(x, view.w - bubble.w - 16));
|
|
40
|
+
const clampY = (y) => Math.max(16, Math.min(y, view.h - bubble.h - 16));
|
|
41
|
+
if (p?.pinTo === 'top' || p?.pinTo === 'bottom' || !el) {
|
|
42
|
+
const off = p?.offset ?? (el ? 24 : 48);
|
|
43
|
+
const left = clampX(el ? el.left + el.width / 2 - bubble.w / 2 : (view.w - bubble.w) / 2);
|
|
44
|
+
return { left, top: p?.pinTo === 'top' ? clampY(off) : clampY(view.h - bubble.h - off) };
|
|
45
|
+
}
|
|
46
|
+
const off = p?.offset ?? 14;
|
|
47
|
+
const pin = p?.pinTo || (el.bottom + off + bubble.h <= view.h - 16 ? 'below' : 'above');
|
|
48
|
+
if (pin === 'below') return { left: clampX(el.left), top: clampY(el.bottom + off) };
|
|
49
|
+
if (pin === 'above') return { left: clampX(el.left), top: clampY(el.top - off - bubble.h) };
|
|
50
|
+
if (pin === 'left') return { left: clampX(el.left - off - bubble.w), top: clampY(el.top) };
|
|
51
|
+
return { left: clampX(el.right + off), top: clampY(el.top) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Does the bubble sit away from its element (an edge pin), so an arrow should point? */
|
|
55
|
+
export const pointsAt = (place) => ['top', 'bottom'].includes(cleanPlace(place)?.pinTo);
|