@supersuit/session-player 0.0.0-stage → 0.4.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 +71 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/bin/simulator.mjs +117 -0
- package/package.json +55 -4
- package/src/bundle.mjs +56 -0
- package/src/convert.mjs +311 -0
- package/src/device.mjs +105 -0
- package/src/embed.mjs +45 -0
- package/src/index.mjs +7 -0
- package/src/playback.mjs +126 -0
- package/src/player-css.mjs +298 -0
- package/src/player.mjs +493 -0
- package/src/recast.mjs +87 -0
- package/src/scenario.mjs +80 -0
- package/src/serve.mjs +57 -0
- package/src/tidy.mjs +105 -0
package/src/scenario.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The scenario format: one persona, the commands they give, the work that follows, the
|
|
2
|
+
// artifacts it makes, every moment they stepped in, and a record of every cut. The player
|
|
3
|
+
// reads this and nothing else, so a scenario that validates is one the player can show.
|
|
4
|
+
|
|
5
|
+
export const FORMAT = 'supersuit-simulator/1';
|
|
6
|
+
export const INTERVENTIONS = [null, 'redirect', 'correct', 'approve', 'decide'];
|
|
7
|
+
export const VIA = ['typed', 'spoken', 'tapped'];
|
|
8
|
+
// `other`: something real that has no file or link, like a release submitted to a store.
|
|
9
|
+
export const ARTIFACT_KINDS = ['file', 'link', 'other'];
|
|
10
|
+
export const SOURCE_KINDS = ['session', 'persona'];
|
|
11
|
+
// Where the real session ran, when known. Optional: a scenario without it is shown as an iPhone.
|
|
12
|
+
export const SCENARIO_DEVICES = ['iphone', 'macbook'];
|
|
13
|
+
|
|
14
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
15
|
+
const isStr = (v) => typeof v === 'string' && v.length > 0;
|
|
16
|
+
const isMs = (v) => Number.isFinite(v) && v >= 0;
|
|
17
|
+
|
|
18
|
+
export function validate(s) {
|
|
19
|
+
if (!isObj(s)) return ['scenario must be an object'];
|
|
20
|
+
const p = [];
|
|
21
|
+
if (s.format !== FORMAT) p.push(`format must be ${FORMAT}, got ${JSON.stringify(s.format)}`);
|
|
22
|
+
for (const k of ['id', 'title']) if (!isStr(s[k])) p.push(`${k} is required`);
|
|
23
|
+
if (!isObj(s.persona) || !isStr(s.persona.name)) p.push('persona.name is required');
|
|
24
|
+
if (!isObj(s.source) || !SOURCE_KINDS.includes(s.source.kind)) p.push(`source.kind must be one of ${SOURCE_KINDS.join(', ')}`);
|
|
25
|
+
if (s.device != null && !SCENARIO_DEVICES.includes(s.device)) p.push(`device must be one of ${SCENARIO_DEVICES.join(', ')}, got ${JSON.stringify(s.device)}`);
|
|
26
|
+
|
|
27
|
+
const artifactIds = new Set();
|
|
28
|
+
for (const a of s.artifacts ?? []) {
|
|
29
|
+
if (!isStr(a.id)) { p.push('an artifact has no id'); continue; }
|
|
30
|
+
if (artifactIds.has(a.id)) p.push(`duplicate artifact id ${a.id}`);
|
|
31
|
+
artifactIds.add(a.id);
|
|
32
|
+
if (!ARTIFACT_KINDS.includes(a.kind)) p.push(`artifact ${a.id}: kind must be one of ${ARTIFACT_KINDS.join(', ')}`);
|
|
33
|
+
if (!isStr(a.title)) p.push(`artifact ${a.id}: title is required`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const turns = Array.isArray(s.turns) ? s.turns : [];
|
|
37
|
+
if (turns.length === 0) p.push('a scenario needs at least one turn');
|
|
38
|
+
const turnIds = new Set();
|
|
39
|
+
for (const t of turns) {
|
|
40
|
+
const id = t?.id ?? '?';
|
|
41
|
+
if (turnIds.has(id)) p.push(`duplicate turn id ${id}`);
|
|
42
|
+
turnIds.add(id);
|
|
43
|
+
if (!isMs(t.at)) p.push(`turn ${id}: at must be a non-negative number of ms`);
|
|
44
|
+
if (!isObj(t.human) || !isStr(t.human.text)) p.push(`turn ${id}: human.text is required`);
|
|
45
|
+
else if (!VIA.includes(t.human.via)) p.push(`turn ${id}: human.via must be one of ${VIA.join(', ')}`);
|
|
46
|
+
if (!INTERVENTIONS.includes(t.intervention)) p.push(`turn ${id}: intervention must be one of ${INTERVENTIONS.map(String).join(', ')}`);
|
|
47
|
+
for (const st of t.steps ?? []) {
|
|
48
|
+
if (!isStr(st.label)) p.push(`turn ${id}: a step has no label`);
|
|
49
|
+
if (!isMs(st.ms)) p.push(`turn ${id}: step "${st.label}" ms must be a non-negative number`);
|
|
50
|
+
}
|
|
51
|
+
for (const a of t.artifacts ?? []) if (!artifactIds.has(a)) p.push(`turn ${id}: unknown artifact ${a}`);
|
|
52
|
+
}
|
|
53
|
+
for (const c of s.chapters ?? []) if (!turnIds.has(c.turn)) p.push(`chapter "${c.title}" points at unknown turn ${c.turn}`);
|
|
54
|
+
if (s.close != null) {
|
|
55
|
+
// The close card says only what was measured: an edge block is the counter's output, so
|
|
56
|
+
// its parts must add up to its total, and nothing in it is an estimate.
|
|
57
|
+
if (!isObj(s.close)) p.push('close must be an object');
|
|
58
|
+
else if (s.close.edge) {
|
|
59
|
+
const L = s.close.edge.layers ?? {};
|
|
60
|
+
const sum = ['fueled', 'encoded', 'built', 'retired'].reduce((n, k) => n + (Number(L[k]) || 0), 0);
|
|
61
|
+
if (sum !== s.close.edge.total) p.push(`close: edge total ${s.close.edge.total} is not the sum of its four counts (${sum})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const img of allImages(s)) if (!isStr(img.src) || !isStr(img.id)) p.push(`an image is missing its id or src (${JSON.stringify(img).slice(0, 80)})`);
|
|
65
|
+
return p;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every image a scenario carries, wherever it sits: pasted with a command, shown on a step,
|
|
69
|
+
// or on one option of a board. One walker so tidy, recast, bundle and validate agree.
|
|
70
|
+
export function allImages(s) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const t of s.turns ?? []) {
|
|
73
|
+
out.push(...(t.human?.images ?? []));
|
|
74
|
+
for (const st of t.steps ?? []) {
|
|
75
|
+
out.push(...(st.images ?? []));
|
|
76
|
+
for (const q of st.ask?.questions ?? []) for (const o of q.options ?? []) if (o.image) out.push(o.image);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
package/src/serve.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// A small local server for watching scenarios in a browser: the player page, the scenarios,
|
|
2
|
+
// and the player's own modules. React loads from esm.sh, so the page needs no build.
|
|
3
|
+
// Local and tailnet viewing only; a public page serves scenarios through a gated route.
|
|
4
|
+
|
|
5
|
+
import { createServer as httpServer } from 'node:http';
|
|
6
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const SRC = fileURLToPath(new URL('.', import.meta.url));
|
|
11
|
+
|
|
12
|
+
export function page({ title = 'Supersuit Session demo', embed = false, react = 'https://esm.sh/react@19', reactDom = 'https://esm.sh/react-dom@19/client?deps=react@19' } = {}) {
|
|
13
|
+
// The embed page is bare (no margin, transparent) so an <iframe> in any page or book shows
|
|
14
|
+
// only the simulator; its switches come from the iframe's query string.
|
|
15
|
+
const body = embed ? 'html,body{margin:0;padding:0;background:transparent}body{margin:0;padding:0;background:transparent}' : 'html,body{margin:0;background:#F6E7C5}body{padding:16px 12px}@media(max-width:480px){body{padding:8px 6px}}';
|
|
16
|
+
return `<!doctype html>
|
|
17
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
18
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
19
|
+
<title>${title}</title>
|
|
20
|
+
<meta property="og:title" content="${title}">
|
|
21
|
+
<meta property="og:description" content="Step into someone's day and watch the work get done.">
|
|
22
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
23
|
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&display=swap" rel="stylesheet">
|
|
24
|
+
<script type="importmap">{"imports":{"react":"${react}","react-dom/client":"${reactDom}"}}</script>
|
|
25
|
+
<style>${body}</style>
|
|
26
|
+
</head><body><div id="root"></div>
|
|
27
|
+
<script type="module">
|
|
28
|
+
import { createElement as h } from 'react';
|
|
29
|
+
import { createRoot } from 'react-dom/client';
|
|
30
|
+
import { SimulatorPlayer } from './src/player.mjs';
|
|
31
|
+
const scenarios = await (await fetch('./scenarios.json')).json();
|
|
32
|
+
const q = new URLSearchParams(location.search);
|
|
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, speed: Number(q.get('speed')) || undefined, highlights: q.has('highlights'), moments: q.has('moments'), maxSteps: Number(q.get('steps')) || undefined,
|
|
35
|
+
}));
|
|
36
|
+
</script></body></html>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createServer({ scenarios, assetsRoot }) {
|
|
40
|
+
const modules = new Set(readdirSync(SRC).filter((f) => f.endsWith('.mjs')));
|
|
41
|
+
return httpServer((req, res) => {
|
|
42
|
+
const path = new URL(req.url, 'http://x').pathname;
|
|
43
|
+
const send = (code, type, body) => { res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' }); res.end(body); };
|
|
44
|
+
if (path === '/' || path === '/index.html') return send(200, 'text/html; charset=utf-8', page({ title: scenarios.length === 1 ? scenarios[0].title : undefined }));
|
|
45
|
+
if (path === '/embed') return send(200, 'text/html; charset=utf-8', page({ embed: true }));
|
|
46
|
+
if (path === '/scenarios.json') return send(200, 'application/json', JSON.stringify(scenarios));
|
|
47
|
+
const a = assetsRoot && path.match(/^\/assets\/([\w-]+)\/([\w.-]+\.(?:png|jpe?g|webp|gif))$/i);
|
|
48
|
+
if (a) {
|
|
49
|
+
const file = join(assetsRoot, 'assets', a[1], a[2]);
|
|
50
|
+
const type = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }[a[2].split('.').pop().toLowerCase()];
|
|
51
|
+
try { return send(200, type, readFileSync(file)); } catch { return send(404, 'text/plain', 'not found'); }
|
|
52
|
+
}
|
|
53
|
+
const m = path.match(/^\/src\/([\w-]+\.mjs)$/);
|
|
54
|
+
if (m && modules.has(m[1])) return send(200, 'text/javascript; charset=utf-8', readFileSync(join(SRC, m[1])));
|
|
55
|
+
return send(404, 'text/plain', 'not found');
|
|
56
|
+
});
|
|
57
|
+
}
|
package/src/tidy.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Cleans what the person typed for display, without putting words in their mouth.
|
|
2
|
+
//
|
|
3
|
+
// People type fast to an agent: "ckise" for close, "simkltaneouslf". A viewer should read
|
|
4
|
+
// what they meant, so a reviewer (a person, or the promote skill's agent) hands in fixes.
|
|
5
|
+
// The guard is what keeps this honest: a fix may correct spelling and punctuation, never
|
|
6
|
+
// rewrite the command, so any fix that moves too far from the original is refused. The
|
|
7
|
+
// original stays in `human.raw` and every change is recorded in `cuts`.
|
|
8
|
+
|
|
9
|
+
const MAX_CHANGE = 0.45;
|
|
10
|
+
|
|
11
|
+
// Screenshot and image paths in a command mean "this picture"; the path itself is noise
|
|
12
|
+
// and can leak a username or a temp folder.
|
|
13
|
+
const IMAGE_PATH = /(['"]?)(?:~|\/)[^'"\n]*?\.(?:png|jpe?g|gif|heic|webp)\1/gi;
|
|
14
|
+
|
|
15
|
+
function distance(a, b) {
|
|
16
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
17
|
+
for (let i = 1; i <= a.length; i++) {
|
|
18
|
+
let diag = prev[0];
|
|
19
|
+
prev[0] = i;
|
|
20
|
+
for (let j = 1; j <= b.length; j++) {
|
|
21
|
+
const up = prev[j];
|
|
22
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
23
|
+
diag = up;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return prev[b.length];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function tidy(scenario, fixes = {}) {
|
|
30
|
+
const sc = structuredClone(scenario);
|
|
31
|
+
const { artifacts: curation, images, close, ...textFixes } = fixes;
|
|
32
|
+
fixes = textFixes;
|
|
33
|
+
const ids = new Set(sc.turns.map((t) => t.id));
|
|
34
|
+
for (const id of Object.keys(fixes)) if (!ids.has(id)) throw new Error(`fix for unknown turn ${id}`);
|
|
35
|
+
if (curation) curate(sc, curation, ids);
|
|
36
|
+
if (images) attach(sc, images);
|
|
37
|
+
// The close card's facts come from tools, not from the reviewer's memory: `edge` is
|
|
38
|
+
// end-a-session's edge-earned output for this session, `freed` a catalog row.
|
|
39
|
+
if (close) sc.close = close;
|
|
40
|
+
for (const t of sc.turns) {
|
|
41
|
+
const original = t.human.raw ?? t.human.text;
|
|
42
|
+
let text = t.human.text.replace(IMAGE_PATH, () => {
|
|
43
|
+
sc.cuts.push({ turn: t.id, what: 'human', reason: 'local path replaced' });
|
|
44
|
+
return '[screenshot]';
|
|
45
|
+
});
|
|
46
|
+
const fix = fixes[t.id];
|
|
47
|
+
if (fix != null) {
|
|
48
|
+
const base = text.toLowerCase();
|
|
49
|
+
const ratio = distance(base, fix.toLowerCase()) / Math.max(base.length, 1);
|
|
50
|
+
// Very short commands ("ckise" -> "close") change a large share by nature; allow two edits.
|
|
51
|
+
if (ratio > MAX_CHANGE && distance(base, fix.toLowerCase()) > 2) throw new Error(`fix for ${t.id} changes too much of what was said (${Math.round(ratio * 100)}%): fix spelling, do not rewrite`);
|
|
52
|
+
if (fix !== text) sc.cuts.push({ turn: t.id, what: 'human', reason: 'typo fixed' });
|
|
53
|
+
text = fix;
|
|
54
|
+
}
|
|
55
|
+
if (text !== t.human.text) { t.human.raw = original; t.human.text = text; }
|
|
56
|
+
}
|
|
57
|
+
return sc;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The transcript cannot see everything a session made (a release submitted through a web
|
|
61
|
+
// form leaves no link) and sometimes names things that were not made. The reviewer decides.
|
|
62
|
+
function curate(sc, { drop = [], add = [] }, turnIds) {
|
|
63
|
+
for (const id of drop) {
|
|
64
|
+
if (!sc.artifacts.some((a) => a.id === id)) throw new Error(`cannot drop unknown artifact ${id}`);
|
|
65
|
+
sc.artifacts = sc.artifacts.filter((a) => a.id !== id);
|
|
66
|
+
for (const t of sc.turns) t.artifacts = t.artifacts.filter((a) => a !== id);
|
|
67
|
+
sc.cuts.push({ turn: null, what: id, reason: 'artifact dropped by reviewer' });
|
|
68
|
+
}
|
|
69
|
+
let n = sc.artifacts.reduce((m, a) => Math.max(m, Number(a.id.replace(/\D/g, '')) || 0), 0);
|
|
70
|
+
for (const a of add) {
|
|
71
|
+
if (!turnIds.has(a.turn)) throw new Error(`artifact "${a.title}" names unknown turn ${a.turn}`);
|
|
72
|
+
const id = `a${++n}`;
|
|
73
|
+
const turn = sc.turns.find((t) => t.id === a.turn);
|
|
74
|
+
sc.artifacts.push({ id, ...a, at: a.at ?? turn.at });
|
|
75
|
+
turn.artifacts.push(id);
|
|
76
|
+
sc.cuts.push({ turn: a.turn, what: id, reason: 'artifact added by reviewer' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What the person saw is often an image the transcript only names through a loop variable
|
|
81
|
+
// (covers opened in Preview, a comparison sheet in a temp folder), so the reviewer points at
|
|
82
|
+
// it: on a step ("step": part of its label), on a board option ("question" + "option"), or on
|
|
83
|
+
// the command itself ("human": true). Matching is by substring, and a miss is refused.
|
|
84
|
+
function attach(sc, images) {
|
|
85
|
+
for (const img of images) {
|
|
86
|
+
const turn = sc.turns.find((t) => t.id === img.turn);
|
|
87
|
+
if (!turn) throw new Error(`image ${img.id} names unknown turn ${img.turn}`);
|
|
88
|
+
const entry = { id: img.id, src: img.src, ...(img.caption ? { caption: img.caption } : {}) };
|
|
89
|
+
if (img.human) { (turn.human.images ??= []).push(entry); continue; }
|
|
90
|
+
if (img.question) {
|
|
91
|
+
const step = turn.steps.find((s) => s.ask?.questions.some((q) => q.question.includes(img.question)));
|
|
92
|
+
const q = step?.ask.questions.find((qq) => qq.question.includes(img.question));
|
|
93
|
+
if (!q) throw new Error(`no question matching "${img.question}" in ${img.turn}`);
|
|
94
|
+
if (img.option) {
|
|
95
|
+
const o = q.options.find((oo) => oo.label.includes(img.option));
|
|
96
|
+
if (!o) throw new Error(`no option matching "${img.option}" in "${q.question.slice(0, 40)}"`);
|
|
97
|
+
o.image = entry;
|
|
98
|
+
} else (step.images ??= []).push(entry);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const step = turn.steps.find((s) => s.label.includes(img.step ?? '\u0000'));
|
|
102
|
+
if (!step) throw new Error(`no step matching "${img.step}" in ${img.turn}`);
|
|
103
|
+
(step.images ??= []).push(entry);
|
|
104
|
+
}
|
|
105
|
+
}
|