@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/player.mjs
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// The Supersuit Simulator player: a phone you step into. The viewer sends each command
|
|
2
|
+
// (tap it, or hold the mic to "say" it), watches the work play out, and sees what got made
|
|
3
|
+
// collect beside the conversation. Plain React.createElement so it needs no build step.
|
|
4
|
+
//
|
|
5
|
+
// Switches: autoplay, artifacts, timeline, maxSteps, device (auto, iphone, macbook), and
|
|
6
|
+
// `scenarios` (more than one shows the persona picker). Mobile first; the side panel appears
|
|
7
|
+
// when the container is wide enough. On a desktop the demo is drawn inside a device (device.mjs).
|
|
8
|
+
|
|
9
|
+
import { createElement as h, useEffect, useMemo, useRef, useState, Fragment } from 'react';
|
|
10
|
+
import { script, summarize, duration, realPick, compare, dictate } from './playback.mjs';
|
|
11
|
+
import { PLAYER_CSS } from './player-css.mjs';
|
|
12
|
+
import { resolveDevice, normalizeMode, readViewer, watchViewer, autoHint, deviceToast, autoplayToast, choiceToast } from './device.mjs';
|
|
13
|
+
|
|
14
|
+
const plain = (s) => String(s).replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^#+\s*/gm, '');
|
|
15
|
+
const initial = (name = '?') => name.trim().charAt(0).toUpperCase();
|
|
16
|
+
|
|
17
|
+
// Images the person saw. Tap one to see it full screen; tap again to close.
|
|
18
|
+
function Gallery({ images, strip = false }) {
|
|
19
|
+
const [open, setOpen] = useState(null);
|
|
20
|
+
if (!images?.length) return null;
|
|
21
|
+
return h('div', { className: strip ? 'ss-strip' : 'ss-gallery' },
|
|
22
|
+
images.map((im, i) => h('button', { key: im.id ?? i, className: 'ss-thumb', onClick: () => setOpen(i), 'aria-label': im.caption ?? 'image' },
|
|
23
|
+
h('img', { src: im.src, alt: im.caption ?? '', loading: 'lazy' }),
|
|
24
|
+
im.caption && !strip && h('span', null, im.caption))),
|
|
25
|
+
open != null && h('button', { className: 'ss-lightbox', onClick: () => setOpen(null), 'aria-label': 'Close' },
|
|
26
|
+
h('img', { src: images[open].src, alt: images[open].caption ?? '' }),
|
|
27
|
+
images[open].caption && h('span', null, images[open].caption)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Reply({ text }) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const t = plain(text);
|
|
33
|
+
const long = t.length > 360;
|
|
34
|
+
return h('div', { className: 'ss-reply' },
|
|
35
|
+
h('span', { className: 'ss-who' }, 'Freeda'),
|
|
36
|
+
h('p', null, long && !open ? `${t.slice(0, 320).trimEnd()}…` : t),
|
|
37
|
+
long && h('button', { className: 'ss-more', onClick: () => setOpen(!open) }, open ? 'less' : 'more'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A decision, as the native question board: pinned to the bottom of the phone in place of
|
|
41
|
+
// the composer, one question at a time, a tab per question. The viewer picks; the pick is
|
|
42
|
+
// answered with what the person really chose, then the next question, then the replay goes
|
|
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, speed = 1 }) {
|
|
45
|
+
const [idx, setIdx] = useState(0);
|
|
46
|
+
const [picks, setPicks] = useState({});
|
|
47
|
+
const q = ask.questions[idx];
|
|
48
|
+
const mine = picks[idx];
|
|
49
|
+
const real = realPick(q, ask.dismissed);
|
|
50
|
+
const verdict = mine && compare(q, ask.dismissed, mine);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!mine) return undefined;
|
|
54
|
+
const id = setTimeout(() => {
|
|
55
|
+
if (idx + 1 < ask.questions.length) setIdx(idx + 1); else onPick?.(picks);
|
|
56
|
+
}, Math.round(1300 / speed));
|
|
57
|
+
return () => clearTimeout(id);
|
|
58
|
+
}, [mine, idx]);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!auto || mine) return undefined;
|
|
61
|
+
const id = setTimeout(() => setPicks((p) => ({ ...p, [idx]: real })), Math.round(1200 / speed));
|
|
62
|
+
return () => clearTimeout(id);
|
|
63
|
+
}, [auto, idx, mine]);
|
|
64
|
+
|
|
65
|
+
const choose = (pick) => {
|
|
66
|
+
if (picks[idx]) return;
|
|
67
|
+
onToast?.(choiceToast(person, compare(q, ask.dismissed, pick)));
|
|
68
|
+
setPicks((p) => (p[idx] ? p : { ...p, [idx]: pick }));
|
|
69
|
+
};
|
|
70
|
+
const same = (a, b) => a.kind === b.kind && (a.kind !== 'option' || a.index === b.index);
|
|
71
|
+
const row = (key, pick, label, description, image) => {
|
|
72
|
+
const isReal = mine && same(pick, real);
|
|
73
|
+
const isMine = mine && same(pick, mine);
|
|
74
|
+
const cls = `ss-opt${isReal ? ' ss-chosen' : ''}${isMine && !isReal ? ' ss-yours' : ''}`;
|
|
75
|
+
return h('button', { key, className: cls, disabled: Boolean(mine), onClick: () => choose(pick) },
|
|
76
|
+
h('span', { className: 'ss-onum' }, isReal ? '\u2713' : typeof key === 'number' ? key + 1 : '\u2026'),
|
|
77
|
+
image && h('img', { className: 'ss-oimg', src: image.src, alt: '' }),
|
|
78
|
+
h('span', null, h('b', null, label), description && h('small', null, description)));
|
|
79
|
+
};
|
|
80
|
+
return h('div', { className: 'ss-sheet', role: 'dialog', 'aria-label': 'Freeda is asking' },
|
|
81
|
+
ask.questions.length > 1 && h('div', { className: 'ss-tabs' }, ask.questions.map((qq, qi) => h('span', {
|
|
82
|
+
key: qi, className: `ss-tab${qi === idx ? ' ss-tab-now' : ''}${picks[qi] ? ' ss-tab-done' : ''}`,
|
|
83
|
+
}, qq.header || `Question ${qi + 1}`))),
|
|
84
|
+
ask.questions.length === 1 && q.header && h('span', { className: 'ss-bhead' }, q.header),
|
|
85
|
+
h('p', { className: 'ss-q' }, plain(q.question)),
|
|
86
|
+
idx === 0 && h(Gallery, { images, strip: true }),
|
|
87
|
+
h('div', { className: 'ss-opts' },
|
|
88
|
+
q.options.map((o, oi) => row(oi, { kind: 'option', index: oi }, o.label, o.description, o.image)),
|
|
89
|
+
real.kind === 'other' && row('other', { kind: 'other' }, mine ? plain(q.answer) : 'Other', mine ? `typed by ${person}` : 'type your own answer'),
|
|
90
|
+
real.kind === 'dismissed' && row('dismiss', { kind: 'dismissed' }, 'Type something else', 'close this and say what you want')),
|
|
91
|
+
h('p', { className: `ss-verdict${verdict?.same ? ' ss-same' : ''}` },
|
|
92
|
+
!verdict ? 'Your call.' : verdict.same ? `Same call as ${person}.` : `${person} ${real.kind === 'dismissed' ? '' : 'chose '}${verdict.real} in the real session, so that is where this goes.`));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The record a board leaves in the conversation once it is answered: each question and what
|
|
96
|
+
// was chosen, compact, the way the native transcript shows an answered question.
|
|
97
|
+
export function Asked({ ask, person, ms, picks = {} }) {
|
|
98
|
+
return h('div', { className: 'ss-asked' },
|
|
99
|
+
ask.questions.map((q, qi) => {
|
|
100
|
+
const real = realPick(q, ask.dismissed);
|
|
101
|
+
const mine = picks[qi];
|
|
102
|
+
const v = mine && compare(q, ask.dismissed, mine);
|
|
103
|
+
const mineLabel = mine && (mine.kind === 'option' ? q.options[mine.index]?.label : mine.kind === 'other' ? 'Other' : 'to type something else');
|
|
104
|
+
return h('div', { key: qi, className: 'ss-aq' },
|
|
105
|
+
h('p', { className: 'ss-q' }, q.header && h('span', { className: 'ss-bhead' }, q.header), ' ', plain(q.question)),
|
|
106
|
+
h('p', { className: 'ss-a' }, real.index != null && q.options[real.index]?.image && h('img', { className: 'ss-aimg', src: q.options[real.index].image.src, alt: '' }), real.kind === 'dismissed' ? '\u21b3 closed it' : `\u21b3 ${plain(real.label)}`),
|
|
107
|
+
v && !v.same && h('p', { className: 'ss-mine' }, `You picked ${mineLabel}`));
|
|
108
|
+
}),
|
|
109
|
+
h('div', { className: 'ss-bfoot' },
|
|
110
|
+
h('span', { className: 'ss-tag' }, ask.dismissed ? `${person} closed this and typed instead` : `${person} chose`),
|
|
111
|
+
h('span', { className: 'ss-dur' }, duration(ms))));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function Step({ step, person, picks, live }) {
|
|
115
|
+
if (step.ask) return live ? h('div', { className: 'ss-asking' }, 'Freeda is asking you\u2026') : h(Fragment, null, h(Gallery, { images: step.images }), h(Asked, { ask: step.ask, person, ms: step.ms, picks }));
|
|
116
|
+
if (step.images?.length) return h(Fragment, null, h(StepLine, { step }), h(Gallery, { images: step.images }));
|
|
117
|
+
return h(StepLine, { step });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function StepLine({ step }) {
|
|
121
|
+
return h('div', { className: `ss-step${step.group ? ' ss-group' : ''}` },
|
|
122
|
+
h('span', { className: 'ss-dot' }),
|
|
123
|
+
h('span', { className: 'ss-label' }, step.label),
|
|
124
|
+
h('span', { className: 'ss-dur' }, step.stalled ? 'timed out' : duration(step.ms)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// One moment per screen (Wilson, 2026-10-11: "the scroll down ... doesn't give me the TL;DR"):
|
|
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));
|
|
144
|
+
return h('div', { className: 'ss-artifact' },
|
|
145
|
+
h('span', { className: `ss-kind ss-${a.kind}` }, a.kind === 'link' ? 'page' : a.kind === 'other' ? 'done' : 'file'),
|
|
146
|
+
h('span', { className: 'ss-atitle' }, a.title));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function Timeline({ sc, turnIndex, person }) {
|
|
150
|
+
const rows = sc.turns.map((t) => {
|
|
151
|
+
const work = t.steps.filter((s) => !s.ask).reduce((n, s) => n + s.ms, 0);
|
|
152
|
+
const ask = t.steps.filter((s) => s.ask).reduce((n, s) => n + s.ms, 0);
|
|
153
|
+
return { id: t.id, work, ask };
|
|
154
|
+
});
|
|
155
|
+
// Square-root widths: honest in order, without one overnight wait flattening the rest.
|
|
156
|
+
const w = (ms) => Math.sqrt(ms / 1000);
|
|
157
|
+
const total = rows.reduce((n, r) => n + w(r.work) + w(r.ask) + 2, 0) || 1;
|
|
158
|
+
return h('div', { className: 'ss-timeline', 'aria-label': 'Who did what, in time' },
|
|
159
|
+
h('div', { className: 'ss-track' }, rows.map((r, i) => h(Fragment, { key: r.id },
|
|
160
|
+
h('span', { className: `ss-seg ss-human${i === turnIndex ? ' ss-now' : ''}`, style: { width: `${(2 / total) * 100}%` }, title: `${person}: command ${i + 1}` }),
|
|
161
|
+
r.ask > 0 && h('span', { className: 'ss-seg ss-ask', style: { width: `${(w(r.ask) / total) * 100}%` }, title: `${person} deciding, ${duration(r.ask)}` }),
|
|
162
|
+
h('span', { className: `ss-seg ss-agent${i < turnIndex ? ' ss-past' : ''}`, style: { width: `${(w(r.work) / total) * 100}%` }, title: `Freeda working, ${duration(r.work)}` })))),
|
|
163
|
+
h('div', { className: 'ss-legend' },
|
|
164
|
+
h('span', null, h('i', { className: 'ss-human' }), person),
|
|
165
|
+
h('span', null, h('i', { className: 'ss-ask' }), 'deciding'),
|
|
166
|
+
h('span', null, h('i', { className: 'ss-agent' }), 'Freeda working')));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function Picker({ scenarios, onPick }) {
|
|
170
|
+
return h('div', { className: 'ss-picker' },
|
|
171
|
+
h('h2', null, 'Step into someone’s day'),
|
|
172
|
+
scenarios.map((s, i) => h('button', { key: s.id, className: 'ss-persona', onClick: () => onPick(i) },
|
|
173
|
+
h('span', { className: 'ss-avatar' }, initial(s.persona?.name)),
|
|
174
|
+
h('span', null, h('b', null, s.persona?.name), s.persona?.role ? ` · ${s.persona.role}` : '', h('br'), h('small', null, s.title)))));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
178
|
+
|
|
179
|
+
// The one line under the title on the Start card: measured counts only.
|
|
180
|
+
export function startLine(sc, person) {
|
|
181
|
+
const t = summarize(sc);
|
|
182
|
+
return `${plural(t.commands, 'command')} from ${person}, ${duration(t.agentMs)} of work by Freeda.`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Autoplay sends the next command only once the viewer has pressed Start.
|
|
186
|
+
export function autoSendReady({ auto, phase, started }) {
|
|
187
|
+
return Boolean(auto && started && phase === 'ready');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The celebration at the close: what the session took off the person, in numbers that were
|
|
191
|
+
// measured. Freeda's work against the person's own attention, the things made, the edge
|
|
192
|
+
// points the counter printed, and any chore the workflow catalog says is now off their hands.
|
|
193
|
+
// Never "time saved": nobody measured the version where they did it by hand.
|
|
194
|
+
export function CloseCard({ sc, person, onReplay, onBack, highlights = false }) {
|
|
195
|
+
const t = summarize(sc);
|
|
196
|
+
const close = sc.close ?? {};
|
|
197
|
+
const edge = close.edge;
|
|
198
|
+
const layers = edge ? ['fueled', 'encoded', 'built', 'retired'].filter((k) => edge.layers?.[k]) : [];
|
|
199
|
+
return h('div', { className: 'ss-close-wrap' },
|
|
200
|
+
h('div', { className: `ss-close${highlights ? ' ss-close-tight' : ''}`, role: 'dialog', 'aria-label': 'Session closed' },
|
|
201
|
+
h('div', { className: 'ss-burst', 'aria-hidden': true }, Array.from({ length: 14 }, (_, i) => h('i', { key: i, style: { '--i': i } }))),
|
|
202
|
+
h('span', { className: 'ss-kicker' }, 'Session closed'),
|
|
203
|
+
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
|
+
h('div', { className: 'ss-split' },
|
|
210
|
+
h('div', null, h('b', null, duration(t.agentMs)), h('small', null, 'of work by Freeda'), h('small', null, plural(t.steps, 'step'))),
|
|
211
|
+
h('div', null, h('b', null, plural(t.commands, 'command')), h('small', null, `from ${person === 'You' ? 'you' : person}`),
|
|
212
|
+
h('small', null, t.decisions ? `${plural(t.decisions, 'decision')}, ${duration(t.decisionMs)} deciding` : 'no decisions needed'))),
|
|
213
|
+
(close.freed ?? []).map((f, i) => h('p', { key: i, className: 'ss-freed' }, `You permanently freed yourself from ${f.task}: ${f.cadence}.`)),
|
|
214
|
+
edge && edge.total > 0 && h('div', { className: 'ss-edge' },
|
|
215
|
+
h('b', null, plural(edge.total, 'edge point')), ' earned',
|
|
216
|
+
h('span', { className: 'ss-layers' }, layers.map((k) => h('span', { key: k }, `${edge.layers[k]} ${k}`)))),
|
|
217
|
+
!highlights && sc.artifacts?.length > 0 && h('div', { className: 'ss-close-made' },
|
|
218
|
+
h('small', null, `${plural(sc.artifacts.length, 'thing')} made`),
|
|
219
|
+
h('ul', null, sc.artifacts.slice(0, 6).map((a) => h('li', { key: a.id }, a.title)))),
|
|
220
|
+
t.idleMs > 60000 && h('p', { className: 'ss-idle' }, `${duration(t.idleMs)} of idle time (hangs, timeouts, nobody at the keyboard) is not counted.`),
|
|
221
|
+
h('div', { className: 'ss-close-actions' },
|
|
222
|
+
h('button', { className: 'ss-chip', onClick: onReplay }, 'Play again'),
|
|
223
|
+
onBack && h('button', { className: 'ss-chip ss-ghost', onClick: onBack }, 'Step into someone else'))));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// speed shortens the work between beats; highlights shows each turn's landing answer (first
|
|
227
|
+
// paragraph) with reading time, and ticks off what got handled (Wilson, 2026-10-11: "make it 40
|
|
228
|
+
// seconds", "I want the user to realize how much work, and how relieving it is").
|
|
229
|
+
export function SimulatorPlayer({ scenario, scenarios, scenarioId, height, autoplay = true, artifacts = true, timeline = true, maxSteps = 5, device, viewer, speed = 1, highlights = false, moments = false, start: startMode }) {
|
|
230
|
+
const list = scenarios ?? (scenario ? [scenario] : []);
|
|
231
|
+
// An embed may name one scenario from a list and skip the picker.
|
|
232
|
+
const start = scenarioId != null ? Math.max(0, list.findIndex((s) => s.id === scenarioId)) : list.length > 1 ? null : 0;
|
|
233
|
+
const [pick, setPick] = useState(start);
|
|
234
|
+
// The device mode starts where the embedder put it and the viewer may switch it.
|
|
235
|
+
const [mode, setMode] = useState(() => normalizeMode(device));
|
|
236
|
+
useEffect(() => { setMode(normalizeMode(device)); }, [device]);
|
|
237
|
+
// What the viewer is holding: read now (so nothing flashes), then again on resize or rotation.
|
|
238
|
+
// `viewer` forces it, for tests and for hosts that already know.
|
|
239
|
+
const [seen, setSeen] = useState(() => viewer ?? readViewer());
|
|
240
|
+
useEffect(() => (viewer ? undefined : watchViewer(setSeen)), [viewer]);
|
|
241
|
+
const sc = pick == null ? null : list[pick];
|
|
242
|
+
return h('div', { className: 'ss-sim', style: height ? { '--ss-height': height } : undefined },
|
|
243
|
+
h('style', null, PLAYER_CSS),
|
|
244
|
+
sc ? h(Run, { key: sc.id, sc, autoplay, start: startMode, artifacts, timeline, maxSteps, speed, highlights, moments, mode, setMode, viewer: viewer ?? seen, onBack: list.length > 1 && scenarioId == null ? () => setPick(null) : null })
|
|
245
|
+
: h(Picker, { scenarios: list, onPick: setPick }));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const MODE_NAMES = { auto: 'Auto', iphone: 'iPhone', macbook: 'MacBook' };
|
|
249
|
+
|
|
250
|
+
// The small switch on the demo itself, outside the device: Auto / iPhone / MacBook.
|
|
251
|
+
function DeviceSwitch({ mode, setMode, hint, onToast }) {
|
|
252
|
+
return h('div', { className: 'ss-devswitch', role: 'radiogroup', 'aria-label': 'Simulated device' },
|
|
253
|
+
Object.entries(MODE_NAMES).map(([m, name]) => h('button', {
|
|
254
|
+
key: m, role: 'radio', 'aria-checked': mode === m, type: 'button', className: 'ss-devopt', onClick: () => { setMode(m); onToast?.(m); },
|
|
255
|
+
}, name, m === 'auto' && mode === 'auto' && hint ? h('small', { className: 'ss-devhint' }, ` · ${hint}`) : null)));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// One quiet message at a time, over the demo rather than in its flow, read out politely.
|
|
259
|
+
function useToast() {
|
|
260
|
+
const [toast, setToast] = useState(null);
|
|
261
|
+
const timer = useRef(null);
|
|
262
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
263
|
+
const show = (text) => {
|
|
264
|
+
clearTimeout(timer.current);
|
|
265
|
+
setToast({ text, id: Date.now() });
|
|
266
|
+
timer.current = setTimeout(() => setToast(null), 3200);
|
|
267
|
+
};
|
|
268
|
+
return [toast, show];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// A clock that keeps real time, for the status bar and the menu bar.
|
|
272
|
+
function useNow() {
|
|
273
|
+
const [now, setNow] = useState(() => new Date());
|
|
274
|
+
useEffect(() => { const id = setInterval(() => setNow(new Date()), 20000); return () => clearInterval(id); }, []);
|
|
275
|
+
return now;
|
|
276
|
+
}
|
|
277
|
+
const hm = (d) => d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
278
|
+
|
|
279
|
+
function StatusBar() {
|
|
280
|
+
const now = useNow();
|
|
281
|
+
return h('div', { className: 'ss-status', 'aria-hidden': true },
|
|
282
|
+
h('span', { className: 'ss-status-time' }, hm(now).replace(/\s?[AP]M$/, '')),
|
|
283
|
+
h('span', { className: 'ss-island', 'aria-hidden': true }),
|
|
284
|
+
h('span', { className: 'ss-status-icons' },
|
|
285
|
+
h('svg', { viewBox: '0 0 18 12', width: 18, height: 12 }, [0, 1, 2, 3].map((i) => h('rect', { key: i, x: i * 4.6, y: 9 - i * 3, width: 3.2, height: 3 + i * 3, rx: 0.8 }))),
|
|
286
|
+
h('svg', { viewBox: '0 0 16 12', width: 16, height: 12 }, h('path', { d: 'M8 11.2 5.6 8.6a3.4 3.4 0 0 1 4.8 0L8 11.2Zm-4.3-4.6L2 4.8a8.5 8.5 0 0 1 12 0l-1.7 1.8a6 6 0 0 0-8.6 0Z' })),
|
|
287
|
+
h('svg', { viewBox: '0 0 27 12', width: 27, height: 12 },
|
|
288
|
+
h('rect', { x: 0.5, y: 0.5, width: 23, height: 11, rx: 3.2, fill: 'none', stroke: 'currentColor', opacity: 0.45 }),
|
|
289
|
+
h('rect', { x: 2, y: 2, width: 17, height: 8, rx: 1.8 }),
|
|
290
|
+
h('rect', { x: 24.6, y: 4, width: 1.6, height: 4, rx: 0.8, opacity: 0.45 }))));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function MenuBar() {
|
|
294
|
+
const now = useNow();
|
|
295
|
+
const day = now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '');
|
|
296
|
+
return h('div', { className: 'ss-menubar' },
|
|
297
|
+
h('span', { className: 'ss-menu-mark', 'aria-hidden': true }),
|
|
298
|
+
h('b', null, 'Claude'),
|
|
299
|
+
['File', 'Edit', 'View', 'Window', 'Help'].map((m) => h('span', { key: m, className: 'ss-menu-item', 'aria-hidden': true }, m)),
|
|
300
|
+
h('span', { className: 'ss-menu-right' },
|
|
301
|
+
h('svg', { viewBox: '0 0 16 12', width: 15, height: 11, 'aria-hidden': true }, h('path', { d: 'M8 11.2 5.6 8.6a3.4 3.4 0 0 1 4.8 0L8 11.2Zm-4.3-4.6L2 4.8a8.5 8.5 0 0 1 12 0l-1.7 1.8a6 6 0 0 0-8.6 0Z' })),
|
|
302
|
+
h('span', { className: 'ss-clock' }, `${day} ${hm(now)}`)));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function TitleBar() {
|
|
306
|
+
return h('div', { className: 'ss-titlebar' },
|
|
307
|
+
h('span', { className: 'ss-traffic', 'aria-hidden': true }, h('i'), h('i'), h('i')),
|
|
308
|
+
h('span', { className: 'ss-wtitle' }, 'Claude Code'));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function Run({ sc, autoplay, start, artifacts: showArtifacts, timeline, maxSteps, speed = 1, highlights = false, moments = false, onBack, mode = 'auto', setMode, viewer = 'desktop' }) {
|
|
312
|
+
const { frame, label } = resolveDevice({ mode, viewer, device: sc.device });
|
|
313
|
+
const plan = useMemo(() => script(sc, { maxSteps, speed, leadOnly: highlights }), [sc, maxSteps, speed, highlights]);
|
|
314
|
+
const totals = useMemo(() => summarize(sc), [sc]);
|
|
315
|
+
const person = sc.persona?.name ?? 'You';
|
|
316
|
+
const [turn, setTurn] = useState(0);
|
|
317
|
+
const [phase, setPhase] = useState('ready'); // ready | saying | working | done
|
|
318
|
+
const [log, setLog] = useState([]);
|
|
319
|
+
const [made, setMade] = useState([]);
|
|
320
|
+
const [work, setWork] = useState({ ms: 0, steps: 0 });
|
|
321
|
+
const [auto, setAuto] = useState(autoplay);
|
|
322
|
+
const [toast, showToast] = useToast();
|
|
323
|
+
// Nothing plays on load: the viewer presses Start (a host may opt in with start: 'auto').
|
|
324
|
+
const [started, setStarted] = useState(start === 'auto');
|
|
325
|
+
const toggleAuto = (on) => { setAuto(on); showToast(autoplayToast(on)); };
|
|
326
|
+
const [tray, setTray] = useState(false);
|
|
327
|
+
const timers = useRef([]);
|
|
328
|
+
const logRef = useRef(null);
|
|
329
|
+
|
|
330
|
+
const chapter = useMemo(() => {
|
|
331
|
+
let c = null;
|
|
332
|
+
for (const ch of sc.chapters ?? []) if (sc.turns.findIndex((t) => t.id === ch.turn) <= turn) c = ch.title;
|
|
333
|
+
return c;
|
|
334
|
+
}, [sc, turn]);
|
|
335
|
+
|
|
336
|
+
useEffect(() => () => timers.current.forEach(clearTimeout), []);
|
|
337
|
+
useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: 'smooth' }); }, [log]);
|
|
338
|
+
|
|
339
|
+
const [closed, setClosed] = useState(false);
|
|
340
|
+
const [waiting, setWaiting] = useState(null); // key of the board waiting on the viewer
|
|
341
|
+
const [answers, setAnswers] = useState({});
|
|
342
|
+
const resumeRef = useRef(null);
|
|
343
|
+
|
|
344
|
+
// Events play one after another; a board stops the clock until the viewer picks.
|
|
345
|
+
const playFrom = (ti, i, delay) => {
|
|
346
|
+
const p = plan[ti];
|
|
347
|
+
if (i >= p.events.length) {
|
|
348
|
+
timers.current.push(setTimeout(() => {
|
|
349
|
+
if (ti + 1 < plan.length) { setTurn(ti + 1); setPhase('ready'); } else setPhase('done');
|
|
350
|
+
}, 300));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const e = p.events[i];
|
|
354
|
+
timers.current.push(setTimeout(() => {
|
|
355
|
+
const key = `${ti}-${i}`;
|
|
356
|
+
setLog((l) => [...l, { ...e, key }]);
|
|
357
|
+
if (e.kind === 'step' && e.step.ask) resumeRef.current = (picks) => {
|
|
358
|
+
setAnswers((a) => ({ ...a, [key]: picks }));
|
|
359
|
+
setWaiting(null);
|
|
360
|
+
playFrom(ti, i + 1, Math.round(500 / speed));
|
|
361
|
+
};
|
|
362
|
+
if (e.kind === 'artifact') setMade((m) => [...m, { ...e.artifact, turnIndex: ti }]);
|
|
363
|
+
if (e.kind === 'step' && !e.step.ask) setWork((w) => ({ ms: w.ms + e.step.ms, steps: w.steps + (e.step.count ?? 1) }));
|
|
364
|
+
if (e.kind === 'step' && e.step.ask) { setWaiting(key); return; }
|
|
365
|
+
const next = p.events[i + 1];
|
|
366
|
+
playFrom(ti, i + 1, next ? next.t - e.t : 0);
|
|
367
|
+
}, delay));
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const [spoken, setSpoken] = useState('');
|
|
371
|
+
// The mic "says" the command: words stream into the composer like dictation, then it sends.
|
|
372
|
+
const say = () => {
|
|
373
|
+
if (phase !== 'ready') return;
|
|
374
|
+
setStarted(true);
|
|
375
|
+
setPhase('saying');
|
|
376
|
+
const frames = dictate(plain(plan[turn].command.text));
|
|
377
|
+
frames.forEach((f, i) => timers.current.push(setTimeout(() => setSpoken(f), 120 * i)));
|
|
378
|
+
timers.current.push(setTimeout(() => sendNow(), 120 * frames.length + 450));
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const send = () => { if (phase === 'ready') { setStarted(true); sendNow(); } };
|
|
382
|
+
const sendNow = () => {
|
|
383
|
+
setLog((l) => [...l, { kind: 'command', key: `c${turn}`, command: plan[turn].command }]);
|
|
384
|
+
setPhase('working');
|
|
385
|
+
playFrom(turn, 0, Math.round(450 / speed));
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (!autoSendReady({ auto, phase, started })) return undefined;
|
|
390
|
+
const id = setTimeout(send, Math.round(1400 / speed));
|
|
391
|
+
return () => clearTimeout(id);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const restart = () => {
|
|
395
|
+
timers.current.forEach(clearTimeout);
|
|
396
|
+
timers.current = [];
|
|
397
|
+
setTurn(0); setPhase('ready'); setLog([]); setMade([]); setWork({ ms: 0, steps: 0 }); setWaiting(null); setAnswers({}); setClosed(false);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
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
|
+
const artifactList = h('div', { className: 'ss-artifact-list' },
|
|
404
|
+
shown.length ? shown.map((a) => h(ArtifactCard, { key: a.id, a, tick: highlights })) : h('p', { className: 'ss-empty' }, highlights ? 'What gets handled will tick off here.' : 'What Freeda makes will collect here.'));
|
|
405
|
+
|
|
406
|
+
const phone = h('div', { className: 'ss-phone' },
|
|
407
|
+
frame === 'iphone' && h(StatusBar),
|
|
408
|
+
h('header', { className: 'ss-head' },
|
|
409
|
+
onBack && h('button', { className: 'ss-back', onClick: onBack, 'aria-label': 'Choose another person' }, '‹'),
|
|
410
|
+
h('span', { className: 'ss-avatar' }, initial(person)),
|
|
411
|
+
h('div', { className: 'ss-titles' }, h('b', null, sc.title), h('small', null, sc.source?.illustrative ? 'Illustrative: recast from a real session' : chapter ?? `as ${person}`)),
|
|
412
|
+
h('label', { className: 'ss-auto' }, h('input', { type: 'checkbox', checked: auto, onChange: (e) => toggleAuto(e.target.checked) }), 'Autoplay')),
|
|
413
|
+
h('div', { className: 'ss-counter', 'aria-live': 'polite' },
|
|
414
|
+
h('span', null, work.ms ? [h('b', { key: 'd' }, duration(work.ms)), ` of work done for ${person}`] : 'No work yet'),
|
|
415
|
+
h('span', null, `${work.steps} of ${totals.steps} steps`)),
|
|
416
|
+
h('div', { className: 'ss-log-wrap' },
|
|
417
|
+
moments && log.length > 0 ? h(Moment, { plan, turn, log, made, person, phase }) :
|
|
418
|
+
h('div', { className: 'ss-log', ref: logRef },
|
|
419
|
+
log.length === 0 && h('p', { className: 'ss-intro' }, `You are ${person}. Send the first command and watch what gets done.`),
|
|
420
|
+
log.map((item) => {
|
|
421
|
+
if (item.kind === 'command') {
|
|
422
|
+
return h('div', { key: item.key, className: 'ss-cmd-wrap' },
|
|
423
|
+
item.command.intervention && h('span', { className: 'ss-redirect' }, `${person} stepped in`),
|
|
424
|
+
item.command.images && h(Gallery, { images: item.command.images, strip: true }),
|
|
425
|
+
h('div', { className: 'ss-cmd' }, plain(item.command.text)));
|
|
426
|
+
}
|
|
427
|
+
if (item.kind === 'step') return h(Step, { key: item.key, step: item.step, person, live: waiting === item.key, picks: answers[item.key] });
|
|
428
|
+
if (item.kind === 'reply') return h(Reply, { key: item.key, text: item.reply.text });
|
|
429
|
+
if (highlights && item.artifact.kind !== 'other') return null;
|
|
430
|
+
return h('div', { key: item.key, className: 'ss-made' }, h('span', null, highlights ? '\u2713 handled' : 'made'), ' ', item.artifact.title);
|
|
431
|
+
}),
|
|
432
|
+
phase === 'working' && !waiting && h('div', { className: 'ss-typing' }, h('i'), h('i'), h('i'))),
|
|
433
|
+
showArtifacts && !waiting && h('button', { className: 'ss-tray-handle', onClick: () => setTray(!tray) }, `${made.length} made`, h('span', null, tray ? '⌄' : '⌃')),
|
|
434
|
+
showArtifacts && tray && !waiting && h('div', { className: 'ss-tray' }, artifactList)),
|
|
435
|
+
!started && h('div', { className: 'ss-start', role: 'dialog', 'aria-label': 'Start the demo' },
|
|
436
|
+
h('div', { className: 'ss-startcard' },
|
|
437
|
+
h('b', { className: 'ss-starttitle' }, sc.title),
|
|
438
|
+
h('p', null, startLine(sc, person)),
|
|
439
|
+
// One start (Wilson's big pulsing Play, Gary's Start demo card): the round Play is the
|
|
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'),
|
|
442
|
+
h('label', { className: 'ss-startauto' }, h('input', { type: 'checkbox', checked: auto, onChange: (e) => toggleAuto(e.target.checked) }), 'Autoplay'),
|
|
443
|
+
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, { speed, 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
|
+
h('footer', { className: 'ss-composer' },
|
|
446
|
+
phase === 'done'
|
|
447
|
+
? h('div', { className: 'ss-end' },
|
|
448
|
+
h('p', null, h('b', null, duration(totals.agentMs)), ` of work, ${totals.steps} steps, ${totals.artifacts} things made. ${person} sent ${totals.commands} commands and made ${totals.decisions} decisions.${totals.idleMs > 60000 ? ` ${duration(totals.idleMs)} of idle time (hangs, timeouts, nobody at the keyboard) is left out.` : ''}`),
|
|
449
|
+
h('button', { className: 'ss-chip', onClick: restart }, 'Play again'))
|
|
450
|
+
: h(Fragment, null,
|
|
451
|
+
h('button', { className: 'ss-chip', disabled: phase !== 'ready', onClick: send },
|
|
452
|
+
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
|
+
// In moments the line already sits on the moment's screen; repeating it here took the room
|
|
454
|
+
// the answer needs in a short MacBook window.
|
|
455
|
+
phase === 'saying' ? spoken : moments ? null : next && plain(next.text).slice(0, 140)),
|
|
456
|
+
h('button', { className: 'ss-send', disabled: phase !== 'ready', 'aria-label': 'Send', onClick: send }, '\u2191'),
|
|
457
|
+
h('button', {
|
|
458
|
+
className: `ss-mic${phase === 'saying' ? ' ss-live' : ''}`, disabled: phase !== 'ready', 'aria-label': 'Say it out loud', onClick: say,
|
|
459
|
+
}, '\u{1F3A4}'))));
|
|
460
|
+
const panel = showArtifacts && h('aside', { className: 'ss-panel' }, h('h3', null, `${highlights ? 'Handled' : 'Made'} for ${person}`, h('span', null, shown.length)), artifactList);
|
|
461
|
+
const bar = h('div', { className: 'ss-devicebar' },
|
|
462
|
+
label ? h('span', { className: frame === 'macbook-compact' ? 'ss-simpill' : 'ss-simlabel' }, label) : h('span'),
|
|
463
|
+
setMode && h(DeviceSwitch, { mode, setMode, hint: autoHint({ viewer, device: sc.device }), onToast: (m) => showToast(deviceToast({ mode: m, viewer, device: sc.device })) }));
|
|
464
|
+
// The device around the conversation. On a phone nothing is drawn: the demo is the phone.
|
|
465
|
+
let device = phone;
|
|
466
|
+
if (frame === 'iphone') {
|
|
467
|
+
device = h('div', { className: 'ss-iphone' },
|
|
468
|
+
h('span', { className: 'ss-btn ss-btn-a', 'aria-hidden': true }), h('span', { className: 'ss-btn ss-btn-v1', 'aria-hidden': true }),
|
|
469
|
+
h('span', { className: 'ss-btn ss-btn-v2', 'aria-hidden': true }), h('span', { className: 'ss-btn ss-btn-p', 'aria-hidden': true }),
|
|
470
|
+
phone);
|
|
471
|
+
} else if (frame === 'macbook-compact') {
|
|
472
|
+
// Forced MacBook on a phone: a window at phone width, never a laptop shrunk to fit.
|
|
473
|
+
device = h('div', { className: 'ss-window ss-term ss-window-compact' }, h(TitleBar), phone);
|
|
474
|
+
} else if (frame === 'macbook') {
|
|
475
|
+
device = h('div', { className: 'ss-mac' },
|
|
476
|
+
h('div', { className: 'ss-mac-lid' },
|
|
477
|
+
h('div', { className: 'ss-mac-screen' },
|
|
478
|
+
h('span', { className: 'ss-mac-notch', 'aria-hidden': true }),
|
|
479
|
+
h(MenuBar),
|
|
480
|
+
h('div', { className: 'ss-window ss-term' }, h(TitleBar),
|
|
481
|
+
h('div', { className: `ss-stage ss-in-window${showArtifacts ? ' ss-has-panel' : ''}` }, phone, panel)))),
|
|
482
|
+
h('div', { className: 'ss-mac-base', 'aria-hidden': true }));
|
|
483
|
+
}
|
|
484
|
+
const inGrid = frame !== 'macbook';
|
|
485
|
+
return h(Fragment, null,
|
|
486
|
+
h('div', { className: `ss-toast${toast ? ' ss-toast-on' : ''}`, role: 'status', 'aria-live': 'polite' }, toast ? h('span', { key: toast.id }, toast.text) : null),
|
|
487
|
+
bar,
|
|
488
|
+
h('div', { className: `ss-stage ss-frame-${frame}${inGrid && showArtifacts ? ' ss-has-panel' : ''}` },
|
|
489
|
+
device,
|
|
490
|
+
inGrid && panel,
|
|
491
|
+
timeline && h(Timeline, { sc, turnIndex: turn, person }),
|
|
492
|
+
phase === 'done' && !closed && h(CloseCard, { sc, person, onReplay: restart, onBack, highlights })));
|
|
493
|
+
}
|
package/src/recast.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Recasts a real run as a persona scenario: a different person and a different subject,
|
|
2
|
+
// with the same commands, the same steps, the same timing and the same decisions. This is
|
|
3
|
+
// how a scenario can show a story a viewer recognizes (an ambient album rather than one
|
|
4
|
+
// specific artist's) while every beat of the work is something that actually happened.
|
|
5
|
+
//
|
|
6
|
+
// Honesty is enforced, not hoped for: the scenario is marked illustrative and says which
|
|
7
|
+
// run it came from, the original wording is dropped rather than carried, turns off the
|
|
8
|
+
// story are cut on the record, and a `forbid` list of words from the original must not
|
|
9
|
+
// survive anywhere, or the recast refuses to finish.
|
|
10
|
+
|
|
11
|
+
const TEXT_FIELDS = (sc) => [
|
|
12
|
+
[sc, 'title'],
|
|
13
|
+
...sc.chapters.map((c) => [c, 'title']),
|
|
14
|
+
...sc.turns.flatMap((t) => [
|
|
15
|
+
[t.human, 'text'],
|
|
16
|
+
...t.steps.flatMap((s) => [[s, 'label'], ...(s.ask?.questions ?? []).flatMap((q) => [[q, 'header'], [q, 'question'], [q, 'answer'], ...q.options.flatMap((o) => [[o, 'label'], [o, 'description']])])]),
|
|
17
|
+
...t.replies.map((r) => [r, 'text']),
|
|
18
|
+
]),
|
|
19
|
+
...sc.artifacts.flatMap((a) => [[a, 'title'], [a, 'url'], [a, 'path']]),
|
|
20
|
+
...(sc.close ? [[sc.close, 'outcome'], ...(sc.close.handled ?? []).map((_, i) => [sc.close.handled, i]), ...(sc.close.edge ? [[sc.close.edge, 'source']] : []), ...(sc.close.freed ?? []).flatMap((f) => [[f, 'task'], [f, 'cadence'], [f, 'source']])] : []),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const inAsk = (s, obj) => (s.ask?.questions ?? []).some((q) => q === obj || q.options.includes(obj));
|
|
24
|
+
const where = (sc, obj) => sc.turns.find((t) => t.human === obj || t.steps.some((s) => s === obj || inAsk(s, obj)) || t.replies.includes(obj))?.id
|
|
25
|
+
?? sc.artifacts.find((a) => a === obj)?.id
|
|
26
|
+
?? (sc.close && (obj === sc.close || obj === sc.close.edge || (sc.close.freed ?? []).includes(obj)) ? 'the close card' : 'the title');
|
|
27
|
+
|
|
28
|
+
import { allImages } from './scenario.mjs';
|
|
29
|
+
|
|
30
|
+
export function recast(source, { id, title, persona, keep, replace = [], forbid = [], images = {}, close, highlight = {} }) {
|
|
31
|
+
const sc = structuredClone(source);
|
|
32
|
+
const cuts = [];
|
|
33
|
+
if (keep) {
|
|
34
|
+
const kept = new Set(keep);
|
|
35
|
+
for (const t of sc.turns) if (!kept.has(t.id)) cuts.push({ turn: t.id, what: 'turn', reason: 'turn dropped in recast' });
|
|
36
|
+
sc.turns = sc.turns.filter((t) => kept.has(t.id));
|
|
37
|
+
const live = new Set(sc.turns.flatMap((t) => t.artifacts));
|
|
38
|
+
sc.artifacts = sc.artifacts.filter((a) => live.has(a.id));
|
|
39
|
+
sc.chapters = sc.chapters.filter((c) => kept.has(c.turn));
|
|
40
|
+
}
|
|
41
|
+
for (const t of sc.turns) delete t.human.raw;
|
|
42
|
+
// Which answer a highlights cut shows for a turn, when its landing is not the last reply.
|
|
43
|
+
for (const [tid, index] of Object.entries(highlight)) {
|
|
44
|
+
const t = sc.turns.find((x) => x.id === tid);
|
|
45
|
+
if (!t || !t.replies[index]) throw new Error(`highlight ${tid}:${index} names no reply`);
|
|
46
|
+
t.highlight = index;
|
|
47
|
+
}
|
|
48
|
+
// Dropping turns changes what the session earned, so the spec may restate the measured close.
|
|
49
|
+
if (close) sc.close = { ...(sc.close ?? {}), ...close };
|
|
50
|
+
sc.title = title ?? sc.title;
|
|
51
|
+
for (const [obj, key] of TEXT_FIELDS(sc)) {
|
|
52
|
+
if (typeof obj[key] !== 'string') continue;
|
|
53
|
+
let s = obj[key];
|
|
54
|
+
for (const [from, to] of replace) s = from instanceof RegExp ? s.replace(from, to) : s.split(from).join(to);
|
|
55
|
+
obj[key] = s;
|
|
56
|
+
}
|
|
57
|
+
// An image is the subject at its most literal, so none of the original's may travel: each
|
|
58
|
+
// one is swapped for a replacement by id, or the recast refuses.
|
|
59
|
+
for (const img of allImages(sc)) {
|
|
60
|
+
if (!(img.id in images)) throw new Error(`image ${img.id} from the original has no replacement`);
|
|
61
|
+
const to = images[img.id];
|
|
62
|
+
img.src = typeof to === 'string' ? to : to.src;
|
|
63
|
+
if (to.caption) img.caption = to.caption; else delete img.caption;
|
|
64
|
+
}
|
|
65
|
+
// Image ids came from the original (often its subject: "chains-to-gold-dust") and become
|
|
66
|
+
// file names when bundled, so every image gets a neutral id; one picture keeps one id.
|
|
67
|
+
const newIds = new Map();
|
|
68
|
+
for (const img of allImages(sc)) {
|
|
69
|
+
if (!newIds.has(img.src)) newIds.set(img.src, `${id ?? `${sc.id}-recast`}-image-${newIds.size + 1}`);
|
|
70
|
+
img.id = newIds.get(img.src);
|
|
71
|
+
}
|
|
72
|
+
for (const word of forbid) {
|
|
73
|
+
// Anchored at a word start: "Eden" must not fire on "credentials", while "Christ" still
|
|
74
|
+
// catches "Christofuturist".
|
|
75
|
+
const re = new RegExp(`(^|[^\\p{L}\\p{N}])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'iu');
|
|
76
|
+
for (const [obj, key] of TEXT_FIELDS(sc)) {
|
|
77
|
+
if (typeof obj[key] === 'string' && re.test(obj[key])) throw new Error(`forbidden "${word}" survives in ${where(sc, obj)}: "${obj[key].slice(0, 120)}"`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...sc,
|
|
82
|
+
id: id ?? `${sc.id}-recast`,
|
|
83
|
+
persona: { name: 'the operator', role: '', blurb: '', ...persona },
|
|
84
|
+
source: { ...sc.source, kind: 'persona', illustrative: true, recastFrom: source.id },
|
|
85
|
+
cuts: [...cuts, { turn: null, what: 'scenario', reason: 'recast from a real run: subject and people changed, work and timing kept' }],
|
|
86
|
+
};
|
|
87
|
+
}
|