@supersuit/session-player 0.5.0 → 0.7.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/CHANGELOG.md +12 -0
- package/README.md +21 -1
- package/package.json +4 -2
- package/src/embed.mjs +2 -0
- package/src/loader.js +72 -0
- package/src/player.mjs +36 -2
- package/src/recast.mjs +7 -1
- package/src/serve.mjs +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
- A recast spec may state `device` (`iphone` or `macbook`) when the source could not tell where the real session ran. A Remote Control or SDK session reads the same from a Mac and a phone, so the converter leaves `device` unset and Auto drew an iPhone for a session that really ran on a MacBook (Callum's case study). The spec wins over the source; without it the source's device is kept, as before.
|
|
6
|
+
- `@supersuit/session-player/playback` exports `summarize` and `duration`, so a host page can print the measured who-did-what beside the player without importing the converter.
|
|
7
|
+
|
|
8
|
+
## 0.6.0 (2026-10-11)
|
|
9
|
+
|
|
10
|
+
- **Transparent by default** (Gary, 2026-10-11: "render with NO background colour ... so it sits on any site"). Nothing is painted outside the device frame; the frame and the screen keep their own backgrounds. A host that wants one passes `background` (prop or tag attribute) or `?bg=` on the embed URL: any CSS colour, or `theme` for the player's cream. A value that is not a colour is ignored.
|
|
11
|
+
- **Embed it anywhere with one tag** (Gary: "make it easier to embed in any website"): `<script src=".../simulator/embed.js" async></script>` then `<session-player scenario="...">`. A plain script (`src/loader.js`, exported as `@supersuit/session-player/loader.js`, served at `/embed.js` by `session-player serve`) that turns each tag into an iframe of the embed page sized to its content. Defaults: Auto device, Autoplay on, waits for Start.
|
|
12
|
+
- In an iframe the player posts its height (`session-player:height`) and the end of a session (`session-player:done`) to the host page; the loader uses both, and fires `session-player:done` on the iframe so a host can offer the next case study.
|
|
13
|
+
- The README opens with both embed forms.
|
|
14
|
+
|
|
3
15
|
## 0.5.0 (2026-10-11)
|
|
4
16
|
|
|
5
17
|
- **The player is the 0.2.3 experience again** (Gary, 2026-10-11: "really not loving the post-Wilson-merge case study widget, revert the UX"). Wilson's 0.3.0 changes and the 0.3.1 moments fix are reverted with `git revert`, so his commits stay in history: the Start card is back as it was (title, measured line, Start demo button, Autoplay choice; nothing plays on load; `start: 'auto'` opts in), Autoplay is on by default and playing the person when off, and device framing (Auto / iPhone / MacBook, the narrow-MacBook fallback, "Simulating a MacBook" on a phone, toasts) is unchanged from 0.2.3. Gone: `speed`, `highlights`, `moments`, `steps`, the highlights close card and the "Handled for" panel. An embed, tag or prop that still passes them is ignored rather than refused, so existing embeds keep working.
|
package/README.md
CHANGED
|
@@ -4,6 +4,26 @@ Plays recorded agent sessions (case studies, and the coming Helm demo): step int
|
|
|
4
4
|
|
|
5
5
|
Renamed from `@supersuit/simulator` in 0.4.0 (2026-10-11): it plays recorded sessions, and case studies and the coming Helm demo are content it plays. To move, swap the package name in your imports; the exports, the scenario format (`supersuit-simulator/1`), `SimulatorPlayer` and `<supersuit-simulator>` are unchanged, and the CLI answers to both `session-player` and the old `simulator`.
|
|
6
6
|
|
|
7
|
+
## Embed it in any website
|
|
8
|
+
|
|
9
|
+
One tag, any page, any framework or none. Paste this where the case study should appear:
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<script src="https://continentalworks.ai/simulator/embed.js" async></script>
|
|
13
|
+
<session-player scenario="phone-rescue"></session-player>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The player sizes itself to its content (no fixed height), is transparent around the device so it sits on your page's own background, picks the device for the viewer (Auto), plays on Autoplay, and waits for the viewer to press Start. Optional attributes: `device` (`auto`, `iphone`, `macbook`), `autoplay="off"` (the viewer plays the person), `start="auto"` (start without the Start card), `background` (a CSS colour, or `theme` for the player's cream), `artifacts="off"`, `timeline="off"`, `height` (the device's height, e.g. `640px`), `title`.
|
|
17
|
+
|
|
18
|
+
No scripts allowed? Use the iframe on its own, with a fixed height:
|
|
19
|
+
|
|
20
|
+
```html
|
|
21
|
+
<iframe src="https://continentalworks.ai/simulator/embed?scenario=phone-rescue" title="Case study"
|
|
22
|
+
style="width:100%;height:860px;border:0;background:transparent" loading="lazy"></iframe>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The same query switches work on the URL (`device`, `autoplay=off`, `start=auto`, `bg`, `artifacts=off`, `timeline=off`, `height`). Inside an iframe the player posts `{ type: 'session-player:height', height }` to its parent whenever its height changes, and `{ type: 'session-player:done', scenario }` when a session ends, so the script above (or your own listener) can size the frame and offer what comes next. Any host serving the player can serve the loader: it is `src/loader.js` (export `@supersuit/session-player/loader.js`), and `session-player serve` serves it at `/embed.js`.
|
|
26
|
+
|
|
7
27
|
This package holds the **scenario format**, the **converter** that drafts a scenario from a Claude Code session transcript, and the **player**, a React component that plays scenarios on a simulated phone.
|
|
8
28
|
|
|
9
29
|
## Install
|
|
@@ -40,7 +60,7 @@ simulator serve album.json # watch it at http://127.0.0.1:4460/
|
|
|
40
60
|
| `artifacts[]` | `{ id, kind: file \| link \| other, title, path \| url, turn, at }` |
|
|
41
61
|
| `cuts[]` | `{ turn, what, reason }`: every edit, so nothing is changed silently |
|
|
42
62
|
|
|
43
|
-
`device` (optional) is where the real session ran: `iphone` or `macbook`. The converter sets `macbook` when the person typed into a terminal (or the desktop app) on a Mac, and leaves it out when it cannot tell (Remote Control and SDK sessions); a scenario without it is shown as an iPhone.
|
|
63
|
+
`device` (optional) is where the real session ran: `iphone` or `macbook`. The converter sets `macbook` when the person typed into a terminal (or the desktop app) on a Mac, and leaves it out when it cannot tell (Remote Control and SDK sessions); a scenario without it is shown as an iPhone. Because unset can mean either, whoever ran the session should state it: `convert --device`, or `device` in a recast spec (0.7.0), which overrides the source.
|
|
44
64
|
|
|
45
65
|
`intervention` is one of `null`, `redirect`, `correct`, `approve`, `decide`. The converter sets only `redirect`; the rest are set by the person reviewing the draft.
|
|
46
66
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/session-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Plays recorded agent sessions (case studies) on a simulated phone or MacBook: a scenario format, a converter from Claude Code transcripts, and a React player anyone can step into.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
".": "./src/index.mjs",
|
|
12
12
|
"./player": "./src/player.mjs",
|
|
13
13
|
"./embed": "./src/embed.mjs",
|
|
14
|
-
"./package.json": "./package.json"
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./loader.js": "./src/loader.js",
|
|
16
|
+
"./playback": "./src/playback.mjs"
|
|
15
17
|
},
|
|
16
18
|
"files": [
|
|
17
19
|
"bin/",
|
package/src/embed.mjs
CHANGED
|
@@ -19,6 +19,8 @@ export function propsFromAttributes(a) {
|
|
|
19
19
|
height: a.height || undefined,
|
|
20
20
|
device: a.device || undefined,
|
|
21
21
|
start: a.start || undefined,
|
|
22
|
+
// 0.6.0: transparent unless the host names a colour or 'theme'.
|
|
23
|
+
background: a.background || a.bg || undefined,
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
|
package/src/loader.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The one-tag embed (0.6.0, Gary, 2026-10-11: "make it easier to embed in any website").
|
|
2
|
+
//
|
|
3
|
+
// <script src="https://continentalworks.ai/simulator/embed.js" async></script>
|
|
4
|
+
// <session-player scenario="phone-rescue"></session-player>
|
|
5
|
+
//
|
|
6
|
+
// A plain script with no imports and no React: it works on any page, in any framework or none.
|
|
7
|
+
// Each <session-player> becomes an iframe of the embed page that served this script, sized to the
|
|
8
|
+
// player's content (the player posts its height; no fixed 720px), transparent around the device.
|
|
9
|
+
// Defaults: Auto device, Autoplay on, waits for Start. Attributes, all optional: scenario, device
|
|
10
|
+
// (auto|iphone|macbook), autoplay ("off"), start ("auto"), background or bg (a CSS colour or
|
|
11
|
+
// "theme"), artifacts ("off"), timeline ("off"), height (the device's height, e.g. "640px"),
|
|
12
|
+
// title, and src (another embed page). A plain <iframe> of the embed page on the same page is
|
|
13
|
+
// resized too. Each iframe also fires `session-player:done` (bubbling) when its session ends.
|
|
14
|
+
(function () {
|
|
15
|
+
'use strict';
|
|
16
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
17
|
+
if (window.__sessionPlayerLoader) return;
|
|
18
|
+
window.__sessionPlayerLoader = true;
|
|
19
|
+
|
|
20
|
+
var me = document.currentScript;
|
|
21
|
+
var base = (me && me.getAttribute('data-embed')) || (me && me.src ? new URL('embed', me.src).href : '/simulator/embed');
|
|
22
|
+
var PASS = { scenario: 'scenario', device: 'device', autoplay: 'autoplay', start: 'start', background: 'bg', bg: 'bg', artifacts: 'artifacts', timeline: 'timeline' };
|
|
23
|
+
|
|
24
|
+
// The device's height, from the HOST's window: inside the iframe 100svh would be the iframe's
|
|
25
|
+
// own height, which this script sets from the content, so it would chase itself.
|
|
26
|
+
function deviceHeight() {
|
|
27
|
+
var v = window.innerHeight || 800;
|
|
28
|
+
return Math.max(480, Math.min(760, v - 120));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function srcFor(el) {
|
|
32
|
+
var u = new URL(el.getAttribute('src') || base, location.href);
|
|
33
|
+
for (var k in PASS) {
|
|
34
|
+
var v = el.getAttribute(k);
|
|
35
|
+
if (v != null) u.searchParams.set(PASS[k], v);
|
|
36
|
+
}
|
|
37
|
+
if (!u.searchParams.has('height')) u.searchParams.set('height', el.getAttribute('height') || deviceHeight() + 'px');
|
|
38
|
+
return u.href;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mount(el) {
|
|
42
|
+
if (el.__sessionPlayerFrame) return;
|
|
43
|
+
var f = document.createElement('iframe');
|
|
44
|
+
f.src = srcFor(el);
|
|
45
|
+
f.title = el.getAttribute('title') || 'Case study';
|
|
46
|
+
f.setAttribute('loading', 'lazy');
|
|
47
|
+
f.setAttribute('allow', 'clipboard-write');
|
|
48
|
+
f.setAttribute('allowtransparency', 'true');
|
|
49
|
+
// color-scheme normal keeps the frame transparent on a dark-mode host page.
|
|
50
|
+
f.style.cssText = 'display:block;width:100%;border:0;background:transparent;color-scheme:normal;height:' + (deviceHeight() + 90) + 'px';
|
|
51
|
+
el.style.display = 'block';
|
|
52
|
+
el.appendChild(f);
|
|
53
|
+
el.__sessionPlayerFrame = f;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
window.addEventListener('message', function (e) {
|
|
57
|
+
var d = e.data;
|
|
58
|
+
if (!d || typeof d !== 'object' || typeof d.type !== 'string' || d.type.indexOf('session-player:') !== 0) return;
|
|
59
|
+
var frames = document.getElementsByTagName('iframe');
|
|
60
|
+
for (var i = 0; i < frames.length; i++) {
|
|
61
|
+
if (frames[i].contentWindow !== e.source) continue;
|
|
62
|
+
if (d.type === 'session-player:height' && d.height > 0) frames[i].style.height = Math.ceil(d.height) + 'px';
|
|
63
|
+
frames[i].dispatchEvent(new CustomEvent(d.type, { detail: d, bubbles: true }));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (window.customElements && !window.customElements.get('session-player')) {
|
|
68
|
+
window.customElements.define('session-player', class extends HTMLElement {
|
|
69
|
+
connectedCallback() { mount(this); }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
})();
|
package/src/player.mjs
CHANGED
|
@@ -202,7 +202,38 @@ export function CloseCard({ sc, person, onReplay, onBack }) {
|
|
|
202
202
|
onBack && h('button', { className: 'ss-chip ss-ghost', onClick: onBack }, 'Step into someone else'))));
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
// The space around the device is transparent unless a host asks for a colour (0.6.0, Gary,
|
|
206
|
+
// 2026-10-11: "render with NO background colour ... so it sits on any site"). 'theme' is the
|
|
207
|
+
// player's own cream; anything else is a CSS colour. The device frame and the screen keep theirs.
|
|
208
|
+
export function backgroundFor(bg) {
|
|
209
|
+
if (bg == null) return undefined;
|
|
210
|
+
const v = String(bg).trim();
|
|
211
|
+
if (!v || v === 'transparent' || v === 'none') return undefined;
|
|
212
|
+
if (v === 'theme') return 'var(--ss-cream)';
|
|
213
|
+
// A colour, never a declaration: no semicolons, braces or url().
|
|
214
|
+
return /^[#a-zA-Z0-9(),.%\s-]+$/.test(v) && !/url\(/i.test(v) ? v : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Inside an iframe, tell the host page how tall the player is and when a session ends, so an
|
|
218
|
+
// embed can size itself to its content (the loader does) and offer what comes next.
|
|
219
|
+
export const MESSAGE = { height: 'session-player:height', done: 'session-player:done' };
|
|
220
|
+
export function reportToHost(el, win = typeof window === 'undefined' ? undefined : window, RO = typeof ResizeObserver === 'undefined' ? undefined : ResizeObserver) {
|
|
221
|
+
if (!el || !win || !win.parent || win.parent === win || !RO) return () => {};
|
|
222
|
+
let last = 0;
|
|
223
|
+
const post = () => {
|
|
224
|
+
const height = Math.ceil(el.getBoundingClientRect().height);
|
|
225
|
+
if (height > 0 && height !== last) { last = height; win.parent.postMessage({ type: MESSAGE.height, height }, '*'); }
|
|
226
|
+
};
|
|
227
|
+
const ro = new RO(post);
|
|
228
|
+
ro.observe(el);
|
|
229
|
+
post();
|
|
230
|
+
return () => ro.disconnect();
|
|
231
|
+
}
|
|
232
|
+
const tellHost = (msg) => { if (typeof window !== 'undefined' && window.parent && window.parent !== window) window.parent.postMessage(msg, '*'); };
|
|
233
|
+
|
|
234
|
+
export function SimulatorPlayer({ scenario, scenarios, scenarioId, height, autoplay = true, artifacts = true, timeline = true, maxSteps = 5, device, viewer, start: startMode, background }) {
|
|
235
|
+
const root = useRef(null);
|
|
236
|
+
useEffect(() => reportToHost(root.current), []);
|
|
206
237
|
const list = scenarios ?? (scenario ? [scenario] : []);
|
|
207
238
|
// An embed may name one scenario from a list and skip the picker.
|
|
208
239
|
const start = scenarioId != null ? Math.max(0, list.findIndex((s) => s.id === scenarioId)) : list.length > 1 ? null : 0;
|
|
@@ -215,7 +246,9 @@ export function SimulatorPlayer({ scenario, scenarios, scenarioId, height, autop
|
|
|
215
246
|
const [seen, setSeen] = useState(() => viewer ?? readViewer());
|
|
216
247
|
useEffect(() => (viewer ? undefined : watchViewer(setSeen)), [viewer]);
|
|
217
248
|
const sc = pick == null ? null : list[pick];
|
|
218
|
-
|
|
249
|
+
const bg = backgroundFor(background);
|
|
250
|
+
const style = height || bg ? { ...(height ? { '--ss-height': height } : {}), ...(bg ? { background: bg } : {}) } : undefined;
|
|
251
|
+
return h('div', { className: 'ss-sim', ref: root, style },
|
|
219
252
|
h('style', null, PLAYER_CSS),
|
|
220
253
|
sc ? h(Run, { key: sc.id, sc, autoplay, start: startMode, artifacts, timeline, maxSteps, mode, setMode, viewer: viewer ?? seen, onBack: list.length > 1 && scenarioId == null ? () => setPick(null) : null })
|
|
221
254
|
: h(Picker, { scenarios: list, onPick: setPick }));
|
|
@@ -291,6 +324,7 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
291
324
|
const person = sc.persona?.name ?? 'You';
|
|
292
325
|
const [turn, setTurn] = useState(0);
|
|
293
326
|
const [phase, setPhase] = useState('ready'); // ready | saying | working | done
|
|
327
|
+
useEffect(() => { if (phase === 'done') tellHost({ type: MESSAGE.done, scenario: sc.id }); }, [phase, sc.id]);
|
|
294
328
|
const [log, setLog] = useState([]);
|
|
295
329
|
const [made, setMade] = useState([]);
|
|
296
330
|
const [work, setWork] = useState({ ms: 0, steps: 0 });
|
package/src/recast.mjs
CHANGED
|
@@ -27,8 +27,14 @@ const where = (sc, obj) => sc.turns.find((t) => t.human === obj || t.steps.some(
|
|
|
27
27
|
|
|
28
28
|
import { allImages } from './scenario.mjs';
|
|
29
29
|
|
|
30
|
-
export function recast(source, { id, title, persona, keep, replace = [], forbid = [], images = {}, close, highlight = {} }) {
|
|
30
|
+
export function recast(source, { id, title, persona, keep, replace = [], forbid = [], images = {}, close, highlight = {}, device }) {
|
|
31
31
|
const sc = structuredClone(source);
|
|
32
|
+
// Where the real session ran, when the source could not say (0.7.0): a Remote Control or SDK
|
|
33
|
+
// session reads the same from a Mac and from a phone, so the person who ran it states it here.
|
|
34
|
+
if (device != null) {
|
|
35
|
+
if (!['iphone', 'macbook'].includes(device)) throw new Error(`device must be iphone or macbook, got ${JSON.stringify(device)}`);
|
|
36
|
+
sc.device = device;
|
|
37
|
+
}
|
|
32
38
|
const cuts = [];
|
|
33
39
|
if (keep) {
|
|
34
40
|
const kept = new Set(keep);
|
package/src/serve.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { SimulatorPlayer } from './src/player.mjs';
|
|
|
31
31
|
const scenarios = await (await fetch('./scenarios.json')).json();
|
|
32
32
|
const q = new URLSearchParams(location.search);
|
|
33
33
|
createRoot(document.getElementById('root')).render(h(SimulatorPlayer, {
|
|
34
|
-
scenarios, scenarioId: q.get('scenario') ?? undefined, height: q.get('height') ?? undefined, autoplay: q.get('autoplay') !== 'off', artifacts: q.get('artifacts') !== 'off', timeline: q.get('timeline') !== 'off', device: q.get('device') ?? undefined, start: q.get('start') ?? undefined,
|
|
34
|
+
scenarios, scenarioId: q.get('scenario') ?? undefined, height: q.get('height') ?? undefined, autoplay: q.get('autoplay') !== 'off', artifacts: q.get('artifacts') !== 'off', timeline: q.get('timeline') !== 'off', device: q.get('device') ?? undefined, start: q.get('start') ?? undefined, background: q.get('bg') ?? undefined,
|
|
35
35
|
}));
|
|
36
36
|
</script></body></html>`;
|
|
37
37
|
}
|
|
@@ -43,6 +43,8 @@ export function createServer({ scenarios, assetsRoot }) {
|
|
|
43
43
|
const send = (code, type, body) => { res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' }); res.end(body); };
|
|
44
44
|
if (path === '/' || path === '/index.html') return send(200, 'text/html; charset=utf-8', page({ title: scenarios.length === 1 ? scenarios[0].title : undefined }));
|
|
45
45
|
if (path === '/embed') return send(200, 'text/html; charset=utf-8', page({ embed: true }));
|
|
46
|
+
// The one-tag embed: <script src=".../embed.js"></script><session-player scenario="...">.
|
|
47
|
+
if (path === '/embed.js') return send(200, 'text/javascript; charset=utf-8', readFileSync(join(SRC, 'loader.js')));
|
|
46
48
|
if (path === '/scenarios.json') return send(200, 'application/json', JSON.stringify(scenarios));
|
|
47
49
|
const a = assetsRoot && path.match(/^\/assets\/([\w-]+)\/([\w.-]+\.(?:png|jpe?g|webp|gif))$/i);
|
|
48
50
|
if (a) {
|