@supersuit/session-player 0.4.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 +12 -0
- package/README.md +20 -4
- package/package.json +3 -2
- package/src/embed.mjs +3 -7
- package/src/loader.js +72 -0
- package/src/playback.mjs +3 -15
- package/src/player-css.mjs +2 -24
- package/src/player.mjs +57 -55
- package/src/serve.mjs +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.5.0 (2026-10-11)
|
|
11
|
+
|
|
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.
|
|
13
|
+
- Kept: the `session-player` name and CLI (and the `simulator` alias), and `recast`'s handling of a spec's `highlight` and `close.handled`, so scenarios recast for 0.3.x still build and their handled lines are still covered by `replace` and `forbid`. The player ignores both fields.
|
|
14
|
+
|
|
3
15
|
## 0.4.0 (2026-10-11)
|
|
4
16
|
|
|
5
17
|
- **Renamed `@supersuit/session-player`** (Gary, 2026-10-11: it plays recorded sessions; case studies and the coming Helm demo are content it plays). Same code as 0.3.2. Swap the package name in your imports; the exports, the scenario format, `SimulatorPlayer` and `<supersuit-simulator>` are unchanged. The CLI is `session-player`, and `simulator` still works. The repository is SupersuitUp/session-player (GitHub redirects the old address).
|
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
|
|
@@ -70,10 +90,6 @@ A page that runs modules can use the tag instead (it brings its own React throug
|
|
|
70
90
|
<script type="module">import { defineSimulatorElement } from '@supersuit/session-player/embed'; defineSimulatorElement();</script>
|
|
71
91
|
```
|
|
72
92
|
|
|
73
|
-
## A highlights cut
|
|
74
|
-
|
|
75
|
-
For a short page demo (about 40 seconds), pass `speed` (work between beats runs that many times faster), `highlights` (each turn shows only where it landed, its answer's first paragraph, with real reading time, and the panel ticks off what got handled), `moments` (one screen per moment instead of a scrolling log) and `steps` (how many steps a turn keeps). Same names as props, embed query switches (`?speed=3&highlights&moments&steps=1`) and tag attributes. A recast spec may name a turn's landing answer with `highlight: { "t6": 1 }`, and a scenario's `close.handled` lists what Freeda handled, in measured facts. The Start card still waits for the viewer.
|
|
76
|
-
|
|
77
93
|
## Device framing
|
|
78
94
|
|
|
79
95
|
`device` is `auto` (the default), `iphone` or `macbook`: the `device` prop, `?device=` on the embed URL, or the `device` attribute on the tag. The viewer can also switch it on the demo (Auto / iPhone / MacBook).
|
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
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// rather than in an iframe. The page supplies React through an import map (or a bundler).
|
|
3
3
|
//
|
|
4
4
|
// <supersuit-simulator src="/simulator/scenarios" scenario="low-tide-tapes"
|
|
5
|
-
// artifacts="off" timeline="off" autoplay="off" height="620px" device="auto"
|
|
6
|
-
// speed="3" highlights moments steps="1"></supersuit-simulator>
|
|
5
|
+
// artifacts="off" timeline="off" autoplay="off" height="620px" device="auto"></supersuit-simulator>
|
|
7
6
|
// <script type="module">import { defineSimulatorElement } from '@supersuit/session-player/embed'; defineSimulatorElement();</script>
|
|
8
7
|
//
|
|
9
8
|
// `src` returns a JSON array of scenarios (or one scenario). Image paths in it resolve against
|
|
@@ -20,11 +19,8 @@ export function propsFromAttributes(a) {
|
|
|
20
19
|
height: a.height || undefined,
|
|
21
20
|
device: a.device || undefined,
|
|
22
21
|
start: a.start || undefined,
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
highlights: a.highlights != null && a.highlights !== 'off',
|
|
26
|
-
moments: a.moments != null && a.moments !== 'off',
|
|
27
|
-
maxSteps: Number(a.steps) || undefined,
|
|
22
|
+
// 0.6.0: transparent unless the host names a colour or 'theme'.
|
|
23
|
+
background: a.background || a.bg || undefined,
|
|
28
24
|
};
|
|
29
25
|
}
|
|
30
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/playback.mjs
CHANGED
|
@@ -64,24 +64,13 @@ export function summarize(sc) {
|
|
|
64
64
|
// One entry per turn: the command the viewer sends, then the events that follow it in the
|
|
65
65
|
// order they really happened, each with a playback offset `t`. `length` is the turn's
|
|
66
66
|
// playback time.
|
|
67
|
-
|
|
68
|
-
// cut can show only that and give the viewer time to read it rather than a fixed beat.
|
|
69
|
-
export function lead(text) {
|
|
70
|
-
return String(text).split(/\n\s*\n/)[0].trim();
|
|
71
|
-
}
|
|
72
|
-
export function readMs(text) {
|
|
73
|
-
const words = String(text).trim().split(/\s+/).filter(Boolean).length;
|
|
74
|
-
return Math.max(900, Math.min(2800, 600 + 50 * words));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function script(sc, { maxSteps = 5, speed = 1, leadOnly = false } = {}) {
|
|
67
|
+
export function script(sc, { maxSteps = 5 } = {}) {
|
|
78
68
|
const byId = new Map((sc.artifacts ?? []).map((a) => [a.id, a]));
|
|
79
69
|
return sc.turns.map((turn) => {
|
|
80
70
|
const steps = condense(turn.steps, maxSteps);
|
|
81
71
|
const items = [
|
|
82
72
|
...steps.map((s) => ({ kind: 'step', real: s.at ?? turn.at, step: s })),
|
|
83
|
-
|
|
84
|
-
...(leadOnly ? [turn.replies[turn.highlight ?? turn.replies.length - 1]].filter(Boolean) : turn.replies).map((r) => ({ kind: 'reply', real: r.at, reply: leadOnly ? { ...r, text: lead(r.text) } : r })),
|
|
73
|
+
...turn.replies.map((r) => ({ kind: 'reply', real: r.at, reply: r })),
|
|
85
74
|
...turn.artifacts.map((id) => byId.get(id)).filter(Boolean).map((a) => ({ kind: 'artifact', real: a.at ?? turn.at, artifact: a })),
|
|
86
75
|
].map((e, i) => ({ ...e, i }));
|
|
87
76
|
// Stable by real time; within one instant, steps before replies before artifacts.
|
|
@@ -90,8 +79,7 @@ export function script(sc, { maxSteps = 5, speed = 1, leadOnly = false } = {}) {
|
|
|
90
79
|
let t = 0;
|
|
91
80
|
const events = items.map(({ i, ...e }) => {
|
|
92
81
|
const at = t;
|
|
93
|
-
|
|
94
|
-
t += e.kind === 'step' ? Math.round(pace(e.step.ms) / speed) : e.kind === 'reply' ? (leadOnly ? readMs(e.reply.text) : Math.round(900 / speed)) : Math.round(500 / speed);
|
|
82
|
+
t += e.kind === 'step' ? pace(e.step.ms) : e.kind === 'reply' ? 900 : 500;
|
|
95
83
|
return { ...e, t: at };
|
|
96
84
|
});
|
|
97
85
|
return { id: turn.id, command: { text: turn.human.text, intervention: turn.intervention, images: turn.human.images }, events, length: t };
|
package/src/player-css.mjs
CHANGED
|
@@ -14,23 +14,6 @@ export const PLAYER_CSS = `
|
|
|
14
14
|
.ss-titles b{font-family:Fraunces,Georgia,serif;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
15
15
|
.ss-titles small{color:#7a6d5a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
16
16
|
.ss-auto{display:flex;align-items:center;gap:4px;font-size:12px;color:#7a6d5a}
|
|
17
|
-
.ss-moment{flex:1;min-height:0;display:flex;flex-direction:column;gap:10px;padding:16px 18px;overflow-y:auto;animation:ss-in .35s ease-out}
|
|
18
|
-
.ss-moment>*{flex:none}
|
|
19
|
-
.ss-mcount{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;opacity:.6}
|
|
20
|
-
.ss-mcmd{margin:0;font-size:14px;opacity:.75;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
|
21
|
-
.ss-mbig{margin:auto 0;font-size:clamp(16px,2.6cqi,22px);line-height:1.3;font-weight:600;overflow:hidden;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;animation:ss-in .4s ease-out}
|
|
22
|
-
.ss-mwork{margin:auto 0;opacity:.6;font-size:15px}
|
|
23
|
-
.ss-mticks{list-style:none;margin:0;padding:8px 0 0;border-top:1px dashed rgba(127,127,127,.35);display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12.5px}
|
|
24
|
-
.ss-mticks li{position:relative;padding-left:20px}
|
|
25
|
-
.ss-mticks li::before{content:"\\2713";position:absolute;left:0;color:#2f8f5b;font-weight:800}
|
|
26
|
-
.ss-mticks li.ss-new{font-weight:700;animation:ss-in .4s ease-out}
|
|
27
|
-
.ss-tick{display:flex;gap:10px;align-items:flex-start;animation:ss-in .35s ease-out}
|
|
28
|
-
.ss-check{flex:none;width:22px;height:22px;border-radius:50%;background:#2f8f5b;color:#fff;display:grid;place-items:center;font-size:13px;font-weight:700}
|
|
29
|
-
.ss-handled{text-align:left;margin:10px 0 4px}
|
|
30
|
-
.ss-handled small{display:block;font-weight:700;letter-spacing:.04em;text-transform:uppercase;font-size:11px;color:#7a6d5a;margin-bottom:4px}
|
|
31
|
-
.ss-handled ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}
|
|
32
|
-
.ss-handled li{position:relative;padding-left:24px;opacity:0;animation:ss-in .3s ease-out forwards;animation-delay:calc(var(--i) * 180ms)}
|
|
33
|
-
.ss-handled li::before{content:"\\2713";position:absolute;left:0;top:0;color:#2f8f5b;font-weight:800}
|
|
34
17
|
.ss-counter{display:flex;justify-content:space-between;gap:8px;padding:8px 14px;background:var(--ss-ink);color:var(--ss-cream);font-size:13px;font-variant-numeric:tabular-nums}
|
|
35
18
|
.ss-counter b{color:var(--ss-mustard)}
|
|
36
19
|
.ss-log-wrap{position:relative;flex:1;min-height:0;display:flex;flex-direction:column}
|
|
@@ -153,10 +136,6 @@ export const PLAYER_CSS = `
|
|
|
153
136
|
.ss-burst i{position:absolute;width:7px;height:7px;border-radius:2px;background:var(--ss-mustard);transform:rotate(calc(var(--i) * 25.7deg)) translateY(0);animation:ss-pop 1.1s ease-out forwards;opacity:0}
|
|
154
137
|
.ss-burst i:nth-child(3n){background:var(--ss-tomato)}.ss-burst i:nth-child(3n+1){background:var(--ss-aqua)}
|
|
155
138
|
.ss-kicker{display:block;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--ss-tomato);text-align:center}
|
|
156
|
-
.ss-close-tight{padding:14px 16px 12px}
|
|
157
|
-
.ss-close-tight h3{font-size:clamp(16px,2.6cqi,20px)!important;margin:2px 0 8px!important}
|
|
158
|
-
.ss-close-tight .ss-handled li{font-size:12.5px;line-height:1.35}
|
|
159
|
-
.ss-close-tight .ss-split{display:none}
|
|
160
139
|
.ss-close h3{font-family:Fraunces,Georgia,serif;font-size:24px;margin:4px 0 14px;text-align:center}
|
|
161
140
|
.ss-split{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
|
|
162
141
|
.ss-split>div{background:#fff;border:1px solid #eadcbc;border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:1px}
|
|
@@ -282,13 +261,12 @@ export const PLAYER_CSS = `
|
|
|
282
261
|
.ss-starttitle{font-family:Fraunces,Georgia,serif;font-size:20px;line-height:1.25}
|
|
283
262
|
.ss-startcard p{margin:0;color:#4a4238;font-size:14px}
|
|
284
263
|
.ss-startcard small{color:#7a6d5a;font-size:12px}
|
|
285
|
-
.ss-sim .ss-startbtn{margin-top:6px;
|
|
286
|
-
.ss-startplay{width:76px;height:76px;border-radius:50%;background:var(--ss-tomato);color:#fff;display:grid;place-items:center;font-size:30px;padding-left:6px;box-sizing:border-box;box-shadow:0 8px 24px rgba(219,55,31,.35);animation:ss-pulse 1.6s infinite}
|
|
264
|
+
.ss-sim .ss-startbtn{margin-top:6px;border:0;border-radius:999px;background:var(--ss-tomato);color:#fff;font-weight:700;font-size:16px;padding:12px 26px;box-shadow:0 6px 18px rgba(219,55,31,.3)}
|
|
287
265
|
.ss-sim .ss-startbtn:focus-visible{outline:3px solid var(--ss-petrol);outline-offset:2px}
|
|
288
266
|
.ss-startauto{display:flex;align-items:center;gap:6px;font-size:14px;font-weight:600}
|
|
289
267
|
.ss-term .ss-start{background:rgba(26,25,24,.86)}
|
|
290
268
|
.ss-term .ss-startcard p{color:#cfc8bd}.ss-term .ss-startcard small{color:#8f887d}.ss-term .ss-starttitle,.ss-term .ss-startauto{color:#e6e1d9}
|
|
291
|
-
.ss-term .ss-
|
|
269
|
+
.ss-term .ss-startbtn{background:#d97757}
|
|
292
270
|
.ss-devhint{font-size:inherit;opacity:.85}
|
|
293
271
|
/* Toasts float over the top of the demo: no layout shift, gone after a few seconds. */
|
|
294
272
|
.ss-toast{position:absolute;z-index:60;left:50%;top:40px;transform:translateX(-50%);width:max-content;max-width:min(92%,420px);pointer-events:none}
|
package/src/player.mjs
CHANGED
|
@@ -41,7 +41,7 @@ function Reply({ text }) {
|
|
|
41
41
|
// the composer, one question at a time, a tab per question. The viewer picks; the pick is
|
|
42
42
|
// answered with what the person really chose, then the next question, then the replay goes
|
|
43
43
|
// on along the real path (a recording has one path, so no branch is ever invented).
|
|
44
|
-
export function Board({ ask, person, auto = false, onPick, images, onToast
|
|
44
|
+
export function Board({ ask, person, auto = false, onPick, images, onToast }) {
|
|
45
45
|
const [idx, setIdx] = useState(0);
|
|
46
46
|
const [picks, setPicks] = useState({});
|
|
47
47
|
const q = ask.questions[idx];
|
|
@@ -53,12 +53,12 @@ export function Board({ ask, person, auto = false, onPick, images, onToast, spee
|
|
|
53
53
|
if (!mine) return undefined;
|
|
54
54
|
const id = setTimeout(() => {
|
|
55
55
|
if (idx + 1 < ask.questions.length) setIdx(idx + 1); else onPick?.(picks);
|
|
56
|
-
},
|
|
56
|
+
}, 1300);
|
|
57
57
|
return () => clearTimeout(id);
|
|
58
58
|
}, [mine, idx]);
|
|
59
59
|
useEffect(() => {
|
|
60
60
|
if (!auto || mine) return undefined;
|
|
61
|
-
const id = setTimeout(() => setPicks((p) => ({ ...p, [idx]: real })),
|
|
61
|
+
const id = setTimeout(() => setPicks((p) => ({ ...p, [idx]: real })), 1200);
|
|
62
62
|
return () => clearTimeout(id);
|
|
63
63
|
}, [auto, idx, mine]);
|
|
64
64
|
|
|
@@ -124,23 +124,7 @@ function StepLine({ step }) {
|
|
|
124
124
|
h('span', { className: 'ss-dur' }, step.stalled ? 'timed out' : duration(step.ms)));
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
// the person's line small, where Freeda landed large, and what is handled so far, with no log
|
|
129
|
-
// to scroll. Everything shown is the recording's own text; nothing is summarised for it.
|
|
130
|
-
export function Moment({ plan, turn, log, made, person, phase }) {
|
|
131
|
-
const reply = [...log].reverse().find((l) => l.kind === 'reply' && l.key.startsWith(`${turn}-`));
|
|
132
|
-
const done = made.filter((a) => a.kind === 'other');
|
|
133
|
-
const here = done.find((a) => a.turnIndex === turn) ?? null;
|
|
134
|
-
return h('div', { className: 'ss-moment', key: turn },
|
|
135
|
-
h('div', { className: 'ss-mcount' }, `${turn + 1} of ${plan.length}`),
|
|
136
|
-
h('p', { className: 'ss-mcmd' }, h('b', null, `${person}: `), plain(plan[turn].command.text).slice(0, 160)),
|
|
137
|
-
reply ? h('p', { className: 'ss-mbig', key: reply.key }, plain(reply.reply.text))
|
|
138
|
-
: h('p', { className: 'ss-mwork' }, phase === 'working' ? 'Freeda is on it\u2026' : ''),
|
|
139
|
-
done.length > 0 && h('ul', { className: 'ss-mticks' }, done.map((a) => h('li', { key: a.id, className: a.id === here?.id ? 'ss-new' : '' }, a.title))));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function ArtifactCard({ a, tick = false }) {
|
|
143
|
-
if (tick) return h('div', { className: 'ss-artifact ss-tick' }, h('span', { className: 'ss-check', 'aria-hidden': true }, '\u2713'), h('span', { className: 'ss-atitle' }, a.title));
|
|
127
|
+
function ArtifactCard({ a }) {
|
|
144
128
|
return h('div', { className: 'ss-artifact' },
|
|
145
129
|
h('span', { className: `ss-kind ss-${a.kind}` }, a.kind === 'link' ? 'page' : a.kind === 'other' ? 'done' : 'file'),
|
|
146
130
|
h('span', { className: 'ss-atitle' }, a.title));
|
|
@@ -191,21 +175,16 @@ export function autoSendReady({ auto, phase, started }) {
|
|
|
191
175
|
// measured. Freeda's work against the person's own attention, the things made, the edge
|
|
192
176
|
// points the counter printed, and any chore the workflow catalog says is now off their hands.
|
|
193
177
|
// Never "time saved": nobody measured the version where they did it by hand.
|
|
194
|
-
export function CloseCard({ sc, person, onReplay, onBack
|
|
178
|
+
export function CloseCard({ sc, person, onReplay, onBack }) {
|
|
195
179
|
const t = summarize(sc);
|
|
196
180
|
const close = sc.close ?? {};
|
|
197
181
|
const edge = close.edge;
|
|
198
182
|
const layers = edge ? ['fueled', 'encoded', 'built', 'retired'].filter((k) => edge.layers?.[k]) : [];
|
|
199
183
|
return h('div', { className: 'ss-close-wrap' },
|
|
200
|
-
h('div', { className:
|
|
184
|
+
h('div', { className: 'ss-close', role: 'dialog', 'aria-label': 'Session closed' },
|
|
201
185
|
h('div', { className: 'ss-burst', 'aria-hidden': true }, Array.from({ length: 14 }, (_, i) => h('i', { key: i, style: { '--i': i } }))),
|
|
202
186
|
h('span', { className: 'ss-kicker' }, 'Session closed'),
|
|
203
187
|
h('h3', null, close.outcome ?? sc.title),
|
|
204
|
-
// What the agent carried, in the session's own facts (close.handled): the weight that came
|
|
205
|
-
// off the person, never a time-saved figure nobody measured.
|
|
206
|
-
close.handled?.length > 0 && h('div', { className: 'ss-handled' },
|
|
207
|
-
h('small', null, `What Freeda handled for ${person === 'You' ? 'you' : person}`),
|
|
208
|
-
h('ul', null, close.handled.map((x, i) => h('li', { key: i, style: { '--i': i } }, x)))),
|
|
209
188
|
h('div', { className: 'ss-split' },
|
|
210
189
|
h('div', null, h('b', null, duration(t.agentMs)), h('small', null, 'of work by Freeda'), h('small', null, plural(t.steps, 'step'))),
|
|
211
190
|
h('div', null, h('b', null, plural(t.commands, 'command')), h('small', null, `from ${person === 'You' ? 'you' : person}`),
|
|
@@ -214,7 +193,7 @@ export function CloseCard({ sc, person, onReplay, onBack, highlights = false })
|
|
|
214
193
|
edge && edge.total > 0 && h('div', { className: 'ss-edge' },
|
|
215
194
|
h('b', null, plural(edge.total, 'edge point')), ' earned',
|
|
216
195
|
h('span', { className: 'ss-layers' }, layers.map((k) => h('span', { key: k }, `${edge.layers[k]} ${k}`)))),
|
|
217
|
-
|
|
196
|
+
sc.artifacts?.length > 0 && h('div', { className: 'ss-close-made' },
|
|
218
197
|
h('small', null, `${plural(sc.artifacts.length, 'thing')} made`),
|
|
219
198
|
h('ul', null, sc.artifacts.slice(0, 6).map((a) => h('li', { key: a.id }, a.title)))),
|
|
220
199
|
t.idleMs > 60000 && h('p', { className: 'ss-idle' }, `${duration(t.idleMs)} of idle time (hangs, timeouts, nobody at the keyboard) is not counted.`),
|
|
@@ -223,10 +202,38 @@ export function CloseCard({ sc, person, onReplay, onBack, highlights = false })
|
|
|
223
202
|
onBack && h('button', { className: 'ss-chip ss-ghost', onClick: onBack }, 'Step into someone else'))));
|
|
224
203
|
}
|
|
225
204
|
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
export function
|
|
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), []);
|
|
230
237
|
const list = scenarios ?? (scenario ? [scenario] : []);
|
|
231
238
|
// An embed may name one scenario from a list and skip the picker.
|
|
232
239
|
const start = scenarioId != null ? Math.max(0, list.findIndex((s) => s.id === scenarioId)) : list.length > 1 ? null : 0;
|
|
@@ -239,9 +246,11 @@ export function SimulatorPlayer({ scenario, scenarios, scenarioId, height, autop
|
|
|
239
246
|
const [seen, setSeen] = useState(() => viewer ?? readViewer());
|
|
240
247
|
useEffect(() => (viewer ? undefined : watchViewer(setSeen)), [viewer]);
|
|
241
248
|
const sc = pick == null ? null : list[pick];
|
|
242
|
-
|
|
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 },
|
|
243
252
|
h('style', null, PLAYER_CSS),
|
|
244
|
-
sc ? h(Run, { key: sc.id, sc, autoplay, start: startMode, artifacts, timeline, maxSteps,
|
|
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 })
|
|
245
254
|
: h(Picker, { scenarios: list, onPick: setPick }));
|
|
246
255
|
}
|
|
247
256
|
|
|
@@ -308,13 +317,14 @@ function TitleBar() {
|
|
|
308
317
|
h('span', { className: 'ss-wtitle' }, 'Claude Code'));
|
|
309
318
|
}
|
|
310
319
|
|
|
311
|
-
function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps,
|
|
320
|
+
function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps, onBack, mode = 'auto', setMode, viewer = 'desktop' }) {
|
|
312
321
|
const { frame, label } = resolveDevice({ mode, viewer, device: sc.device });
|
|
313
|
-
const plan = useMemo(() => script(sc, { maxSteps
|
|
322
|
+
const plan = useMemo(() => script(sc, { maxSteps }), [sc, maxSteps]);
|
|
314
323
|
const totals = useMemo(() => summarize(sc), [sc]);
|
|
315
324
|
const person = sc.persona?.name ?? 'You';
|
|
316
325
|
const [turn, setTurn] = useState(0);
|
|
317
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]);
|
|
318
328
|
const [log, setLog] = useState([]);
|
|
319
329
|
const [made, setMade] = useState([]);
|
|
320
330
|
const [work, setWork] = useState({ ms: 0, steps: 0 });
|
|
@@ -357,9 +367,9 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
357
367
|
if (e.kind === 'step' && e.step.ask) resumeRef.current = (picks) => {
|
|
358
368
|
setAnswers((a) => ({ ...a, [key]: picks }));
|
|
359
369
|
setWaiting(null);
|
|
360
|
-
playFrom(ti, i + 1,
|
|
370
|
+
playFrom(ti, i + 1, 500);
|
|
361
371
|
};
|
|
362
|
-
if (e.kind === 'artifact') setMade((m) => [...m,
|
|
372
|
+
if (e.kind === 'artifact') setMade((m) => [...m, e.artifact]);
|
|
363
373
|
if (e.kind === 'step' && !e.step.ask) setWork((w) => ({ ms: w.ms + e.step.ms, steps: w.steps + (e.step.count ?? 1) }));
|
|
364
374
|
if (e.kind === 'step' && e.step.ask) { setWaiting(key); return; }
|
|
365
375
|
const next = p.events[i + 1];
|
|
@@ -382,12 +392,12 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
382
392
|
const sendNow = () => {
|
|
383
393
|
setLog((l) => [...l, { kind: 'command', key: `c${turn}`, command: plan[turn].command }]);
|
|
384
394
|
setPhase('working');
|
|
385
|
-
playFrom(turn, 0,
|
|
395
|
+
playFrom(turn, 0, 450);
|
|
386
396
|
};
|
|
387
397
|
|
|
388
398
|
useEffect(() => {
|
|
389
399
|
if (!autoSendReady({ auto, phase, started })) return undefined;
|
|
390
|
-
const id = setTimeout(send,
|
|
400
|
+
const id = setTimeout(send, 1400);
|
|
391
401
|
return () => clearTimeout(id);
|
|
392
402
|
});
|
|
393
403
|
|
|
@@ -398,10 +408,8 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
398
408
|
};
|
|
399
409
|
|
|
400
410
|
const next = plan[turn]?.command;
|
|
401
|
-
// A highlights cut shows the load coming off: only what got handled, ticked as it lands.
|
|
402
|
-
const shown = highlights ? made.filter((a) => a.kind === 'other') : made;
|
|
403
411
|
const artifactList = h('div', { className: 'ss-artifact-list' },
|
|
404
|
-
|
|
412
|
+
made.length ? made.map((a) => h(ArtifactCard, { key: a.id, a })) : h('p', { className: 'ss-empty' }, 'What Freeda makes will collect here.'));
|
|
405
413
|
|
|
406
414
|
const phone = h('div', { className: 'ss-phone' },
|
|
407
415
|
frame === 'iphone' && h(StatusBar),
|
|
@@ -414,7 +422,6 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
414
422
|
h('span', null, work.ms ? [h('b', { key: 'd' }, duration(work.ms)), ` of work done for ${person}`] : 'No work yet'),
|
|
415
423
|
h('span', null, `${work.steps} of ${totals.steps} steps`)),
|
|
416
424
|
h('div', { className: 'ss-log-wrap' },
|
|
417
|
-
moments && log.length > 0 ? h(Moment, { plan, turn, log, made, person, phase }) :
|
|
418
425
|
h('div', { className: 'ss-log', ref: logRef },
|
|
419
426
|
log.length === 0 && h('p', { className: 'ss-intro' }, `You are ${person}. Send the first command and watch what gets done.`),
|
|
420
427
|
log.map((item) => {
|
|
@@ -426,8 +433,7 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
426
433
|
}
|
|
427
434
|
if (item.kind === 'step') return h(Step, { key: item.key, step: item.step, person, live: waiting === item.key, picks: answers[item.key] });
|
|
428
435
|
if (item.kind === 'reply') return h(Reply, { key: item.key, text: item.reply.text });
|
|
429
|
-
|
|
430
|
-
return h('div', { key: item.key, className: 'ss-made' }, h('span', null, highlights ? '\u2713 handled' : 'made'), ' ', item.artifact.title);
|
|
436
|
+
return h('div', { key: item.key, className: 'ss-made' }, h('span', null, 'made'), ' ', item.artifact.title);
|
|
431
437
|
}),
|
|
432
438
|
phase === 'working' && !waiting && h('div', { className: 'ss-typing' }, h('i'), h('i'), h('i'))),
|
|
433
439
|
showArtifacts && !waiting && h('button', { className: 'ss-tray-handle', onClick: () => setTray(!tray) }, `${made.length} made`, h('span', null, tray ? '⌄' : '⌃')),
|
|
@@ -436,12 +442,10 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
436
442
|
h('div', { className: 'ss-startcard' },
|
|
437
443
|
h('b', { className: 'ss-starttitle' }, sc.title),
|
|
438
444
|
h('p', null, startLine(sc, person)),
|
|
439
|
-
|
|
440
|
-
// button, "Start demo" its words, Autoplay the choice beside it. Nothing else plays.
|
|
441
|
-
h('button', { type: 'button', className: 'ss-startbtn', onClick: () => setStarted(true) }, h('span', { className: 'ss-startplay', 'aria-hidden': true }, '\u25B6'), 'Start demo'),
|
|
445
|
+
h('button', { type: 'button', className: 'ss-startbtn', onClick: () => setStarted(true) }, 'Start demo'),
|
|
442
446
|
h('label', { className: 'ss-startauto' }, h('input', { type: 'checkbox', checked: auto, onChange: (e) => toggleAuto(e.target.checked) }), 'Autoplay'),
|
|
443
447
|
h('small', null, auto ? 'It plays itself. Untick Autoplay to play the human.' : `You play ${person}: tap to send each message and make each call.`))),
|
|
444
|
-
waiting ? h(Board, {
|
|
448
|
+
waiting ? h(Board, { key: waiting, ask: log.find((l) => l.key === waiting).step.ask, images: log.find((l) => l.key === waiting).step.images, person, auto, onToast: auto ? undefined : showToast, onPick: (picks) => resumeRef.current?.(picks) }) :
|
|
445
449
|
h('footer', { className: 'ss-composer' },
|
|
446
450
|
phase === 'done'
|
|
447
451
|
? h('div', { className: 'ss-end' },
|
|
@@ -450,14 +454,12 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
450
454
|
: h(Fragment, null,
|
|
451
455
|
h('button', { className: 'ss-chip', disabled: phase !== 'ready', onClick: send },
|
|
452
456
|
h('small', null, phase === 'working' ? 'Freeda is working…' : phase === 'saying' ? 'listening…' : auto ? 'sending on autoplay…' : `you are ${person}: tap to send, or tap the mic`),
|
|
453
|
-
|
|
454
|
-
// the answer needs in a short MacBook window.
|
|
455
|
-
phase === 'saying' ? spoken : moments ? null : next && plain(next.text).slice(0, 140)),
|
|
457
|
+
phase === 'saying' ? spoken : next && plain(next.text).slice(0, 140)),
|
|
456
458
|
h('button', { className: 'ss-send', disabled: phase !== 'ready', 'aria-label': 'Send', onClick: send }, '\u2191'),
|
|
457
459
|
h('button', {
|
|
458
460
|
className: `ss-mic${phase === 'saying' ? ' ss-live' : ''}`, disabled: phase !== 'ready', 'aria-label': 'Say it out loud', onClick: say,
|
|
459
461
|
}, '\u{1F3A4}'))));
|
|
460
|
-
const panel = showArtifacts && h('aside', { className: 'ss-panel' }, h('h3', null,
|
|
462
|
+
const panel = showArtifacts && h('aside', { className: 'ss-panel' }, h('h3', null, `Made for ${person}`, h('span', null, made.length)), artifactList);
|
|
461
463
|
const bar = h('div', { className: 'ss-devicebar' },
|
|
462
464
|
label ? h('span', { className: frame === 'macbook-compact' ? 'ss-simpill' : 'ss-simlabel' }, label) : h('span'),
|
|
463
465
|
setMode && h(DeviceSwitch, { mode, setMode, hint: autoHint({ viewer, device: sc.device }), onToast: (m) => showToast(deviceToast({ mode: m, viewer, device: sc.device })) }));
|
|
@@ -489,5 +491,5 @@ function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps
|
|
|
489
491
|
device,
|
|
490
492
|
inGrid && panel,
|
|
491
493
|
timeline && h(Timeline, { sc, turnIndex: turn, person }),
|
|
492
|
-
phase === 'done' && !closed && h(CloseCard, { sc, person, onReplay: restart, onBack
|
|
494
|
+
phase === 'done' && !closed && h(CloseCard, { sc, person, onReplay: restart, onBack })));
|
|
493
495
|
}
|
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) {
|