@supersuit/session-player 0.5.0 → 0.6.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 +7 -0
- package/README.md +20 -0
- package/package.json +3 -2
- package/src/embed.mjs +2 -0
- package/src/loader.js +72 -0
- package/src/player.mjs +36 -2
- package/src/serve.mjs +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- 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.
|
|
8
|
+
- The README opens with both embed forms.
|
|
9
|
+
|
|
3
10
|
## 0.5.0 (2026-10-11)
|
|
4
11
|
|
|
5
12
|
- **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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/session-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,8 @@
|
|
|
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"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"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/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) {
|