jevway 0.1.2 → 0.2.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/INSTALL-FOR-AGENTS.md +32 -38
- package/README.md +193 -150
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +74 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +47 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +173 -0
- package/dist/cli/session-commands.js +132 -0
- package/dist/daemon/client.js +175 -0
- package/dist/daemon/paths.js +35 -0
- package/dist/daemon/protocol.js +28 -0
- package/dist/daemon/server.js +320 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- package/dist/journeys/journey-doc.js +2 -1
- package/dist/journeys/journey-session.js +14 -3
- package/dist/journeys/render-viewer.js +163 -150
- package/dist/mcp/instructions.js +18 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +50 -0
- package/dist/mcp/task-tools.js +197 -0
- package/dist/mcp.js +10 -337
- package/dist/output/layout.js +50 -0
- package/dist/output/manifest.js +80 -0
- package/dist/output/run-folder.js +38 -0
- package/dist/recording/describe-step.js +66 -0
- package/dist/{task → recording}/recording-page.js +164 -18
- package/dist/{task → recording}/recording.js +33 -91
- package/dist/recording/run-page.js +155 -0
- package/dist/session/session-service.js +193 -0
- package/dist/setup/atomic-write.js +13 -0
- package/dist/setup/clients.js +180 -0
- package/dist/setup/command-output.js +15 -0
- package/dist/setup/doctor-command.js +16 -7
- package/dist/setup/doctor.js +17 -2
- package/dist/setup/env-file.js +22 -0
- package/dist/setup/setup-command.js +104 -0
- package/dist/setup/setup-wizard.js +216 -0
- package/dist/setup/skill-command.js +101 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/present.js +90 -0
- package/dist/task/run-task.js +74 -11
- package/dist/task/start-task.js +21 -5
- package/package.json +9 -7
- package/skill/jevway/SKILL.md +50 -14
- package/dist/cli.js +0 -112
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
const MANIFEST = 'manifest.json';
|
|
4
|
+
/** Every file under `dir`, relative and sorted; dotfiles and dot-folders (`.frames` while encoding) are left out. */
|
|
5
|
+
const filesUnder = (dir, base = dir) => readdirSync(dir, { withFileTypes: true })
|
|
6
|
+
.filter((entry) => !entry.name.startsWith('.'))
|
|
7
|
+
.flatMap((entry) => (entry.isDirectory() ? filesUnder(join(dir, entry.name), base) : [relative(base, join(dir, entry.name))]))
|
|
8
|
+
.map((path) => path.split('\\').join('/'))
|
|
9
|
+
.sort();
|
|
10
|
+
const readJson = (file) => {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const oneLine = (text) => text.replace(/\s+/g, ' ').trim();
|
|
19
|
+
/** What the folder's own records say about some of its files: step shots from `summary.json`, screens from `journey.json`. */
|
|
20
|
+
const notesIn = (dir, kind) => {
|
|
21
|
+
const notes = new Map();
|
|
22
|
+
if (kind === 'run') {
|
|
23
|
+
const steps = readJson(join(dir, 'summary.json'))?.steps ?? [];
|
|
24
|
+
steps.forEach((step, i) => {
|
|
25
|
+
if (!step.file || !step.description)
|
|
26
|
+
return;
|
|
27
|
+
const finished = step.description.startsWith('Finished:');
|
|
28
|
+
notes.set(step.file, finished ? step.description : `Before step ${i + 1}, its target outlined: ${step.description}`);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const screens = readJson(join(dir, 'journey.json'))?.screens ?? [];
|
|
33
|
+
for (const screen of screens)
|
|
34
|
+
if (screen.screenshot)
|
|
35
|
+
notes.set(screen.screenshot, `Screen ${screen.id} (${screen.type ?? 'unknown'}): ${screen.title ?? ''}`);
|
|
36
|
+
}
|
|
37
|
+
return notes;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* One file as a manifest line: its kind from its name, and a description from the folder's records where they say more.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* describeFile('calls/02-needs-input.jpg', 'run', new Map()) // { path, kind: 'screenshot', description: 'The page as call 2 ended: needs_input' }
|
|
44
|
+
*/
|
|
45
|
+
export const describeFile = (path, kind, notes) => {
|
|
46
|
+
const line = (fileKind, description) => ({ path, kind: fileKind, description: oneLine(description) });
|
|
47
|
+
const call = /^calls\/(\d+)-(.+)\.jpg$/.exec(path);
|
|
48
|
+
if (path === MANIFEST)
|
|
49
|
+
return line('manifest', "This list of the folder's files");
|
|
50
|
+
if (path === 'index.html') {
|
|
51
|
+
return kind === 'run'
|
|
52
|
+
? line('page', 'The run page for people: the video with every step on a timeline, and how each call ended')
|
|
53
|
+
: line('page', 'The journey map viewer: screens, flows and QA findings');
|
|
54
|
+
}
|
|
55
|
+
if (path === 'summary.json')
|
|
56
|
+
return line('summary', "Each call's summary (status, reason, steps, cost), plus the recorded steps when recorded");
|
|
57
|
+
if (path === 'journey.json')
|
|
58
|
+
return line('journey', 'The journey map as data: screens, steps, flows and QA findings');
|
|
59
|
+
if (path === 'run.mp4')
|
|
60
|
+
return line('video', 'Screencast of the whole session (idle stretches shortened)');
|
|
61
|
+
if (path === 'final.jpg')
|
|
62
|
+
return line('screenshot', 'The page as the latest call ended (viewport, JPEG)');
|
|
63
|
+
if (call)
|
|
64
|
+
return line('screenshot', `The page as call ${Number(call[1])} ended: ${call[2].replace(/-/g, '_')}`);
|
|
65
|
+
if (path.startsWith('steps/'))
|
|
66
|
+
return line('step', notes.get(path) ?? 'A recorded step, before its action');
|
|
67
|
+
if (path.startsWith('screens/'))
|
|
68
|
+
return line('screen', notes.get(path) ?? 'A journey screen');
|
|
69
|
+
return line('file', 'Not described');
|
|
70
|
+
};
|
|
71
|
+
/** Writes `manifest.json` for what `dir` holds now, itself included. */
|
|
72
|
+
export const writeManifest = (dir, kind) => {
|
|
73
|
+
const notes = notesIn(dir, kind);
|
|
74
|
+
const paths = [...new Set([...filesUnder(dir), MANIFEST])].sort();
|
|
75
|
+
const manifest = { kind, files: paths.map((path) => describeFile(path, kind, notes)) };
|
|
76
|
+
writeFileSync(join(dir, MANIFEST), JSON.stringify(manifest, null, 1));
|
|
77
|
+
return join(dir, MANIFEST);
|
|
78
|
+
};
|
|
79
|
+
/** Reads a folder's `manifest.json`. */
|
|
80
|
+
export const readManifest = (dir) => JSON.parse(readFileSync(join(dir, MANIFEST), 'utf8'));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { callShotName } from './layout.js';
|
|
4
|
+
import { writeManifest } from './manifest.js';
|
|
5
|
+
/**
|
|
6
|
+
* Writes what one call leaves in its run folder: its end page as `calls/NN-<status>.jpg` and `final.jpg` (a failed
|
|
7
|
+
* capture removes an older `final.jpg`, so it never shows another call's page), `summary.json` unless the recording
|
|
8
|
+
* wrote its own, and the manifest. Returns the folder's files.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* writeCall({ dir, call: 2, status: 'done', shot, summaries, recorded: false }) // { run_dir, screenshot: '…/final.jpg', manifest }
|
|
12
|
+
*/
|
|
13
|
+
export const writeCall = ({ dir, call, status, shot, summaries, recorded, }) => {
|
|
14
|
+
const root = resolve(dir);
|
|
15
|
+
mkdirSync(root, { recursive: true });
|
|
16
|
+
const final = join(root, 'final.jpg');
|
|
17
|
+
if (shot) {
|
|
18
|
+
const callShot = join(root, callShotName(call, status));
|
|
19
|
+
mkdirSync(dirname(callShot), { recursive: true });
|
|
20
|
+
writeFileSync(callShot, shot);
|
|
21
|
+
copyFileSync(callShot, final);
|
|
22
|
+
}
|
|
23
|
+
else
|
|
24
|
+
rmSync(final, { force: true });
|
|
25
|
+
// A recording that failed before writing its own leaves the folder without one.
|
|
26
|
+
if (!recorded || !existsSync(join(root, 'summary.json')))
|
|
27
|
+
writeFileSync(join(root, 'summary.json'), JSON.stringify({ summaries }, null, 1));
|
|
28
|
+
const manifest = writeManifest(root, 'run');
|
|
29
|
+
const page = join(root, 'index.html');
|
|
30
|
+
const video = join(root, 'run.mp4');
|
|
31
|
+
return {
|
|
32
|
+
run_dir: root,
|
|
33
|
+
...(shot && { screenshot: final }),
|
|
34
|
+
...(recorded && existsSync(page) && { page }),
|
|
35
|
+
...(recorded && existsSync(video) && { video }),
|
|
36
|
+
manifest,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The one wording of a step that people read: the recording's shots (what happens next) and a summary's story (what
|
|
2
|
+
// happened). Kept apart from the recording so the pages and the story share it without importing the recorder.
|
|
3
|
+
// Row labels carry a whole table row; a sentence needs its first words.
|
|
4
|
+
const shorten = (label, max = 70) => {
|
|
5
|
+
const text = label.replace(/\s+/g, ' ').trim();
|
|
6
|
+
return text.length > max ? `${text.slice(0, max).replace(/\s+\S*$/, '')}…` : text;
|
|
7
|
+
};
|
|
8
|
+
// Each verb as Jev is about to do it (`next`, the recording's step shots) and as done (`done`, a summary's story).
|
|
9
|
+
const VERBS = {
|
|
10
|
+
click: ['Click', 'Clicked'],
|
|
11
|
+
type: ['Type', 'Typed'],
|
|
12
|
+
choose: ['Choose', 'Chose'],
|
|
13
|
+
press: ['Press', 'Pressed'],
|
|
14
|
+
scroll: ['Scroll', 'Scrolled'],
|
|
15
|
+
wait: ['Wait', 'Waited'],
|
|
16
|
+
upload: ['Upload', 'Uploaded'],
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A step as one plain sentence for people reading a recording (`next`, what happens on this screen) or a summary's
|
|
20
|
+
* story (`done`, what happened).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* describeStep({ operation: 'TYPE_TEXT', element: 'Search traces', valueKey: 'search_query' })
|
|
24
|
+
* // 'Type the "search_query" value into "Search traces"'
|
|
25
|
+
* describeStep({ operation: 'CLICK', element: 'Search' }, 'done') // 'Clicked "Search"'
|
|
26
|
+
*/
|
|
27
|
+
export const describeStep = ({ operation, element = '', valueKey, fileKey, option }, tense = 'next') => {
|
|
28
|
+
const target = `"${shorten(element)}"`;
|
|
29
|
+
const verb = (name) => VERBS[name][tense === 'next' ? 0 : 1];
|
|
30
|
+
switch (operation) {
|
|
31
|
+
case 'CLICK':
|
|
32
|
+
return `${verb('click')} ${target}`;
|
|
33
|
+
case 'CLICK_AT':
|
|
34
|
+
return `${verb('click')} the control at ${target} by its on-screen position`;
|
|
35
|
+
case 'TYPE_TEXT':
|
|
36
|
+
return `${verb('type')} the "${valueKey}" value into ${target}`;
|
|
37
|
+
case 'UPLOAD':
|
|
38
|
+
return fileKey ? `${verb('upload')} the "${fileKey}" file into ${target}` : `${verb('upload')} a file into ${target}`;
|
|
39
|
+
case 'SELECT':
|
|
40
|
+
return option ? `${verb('choose')} "${option}" in ${target}` : `${verb('choose')} an option in ${target}`;
|
|
41
|
+
case 'PRESS_ENTER':
|
|
42
|
+
return `${verb('press')} Enter in ${target} to submit it`;
|
|
43
|
+
case 'PRESS_ESCAPE':
|
|
44
|
+
return `${verb('press')} Escape to close the open menu or popup`;
|
|
45
|
+
case 'SCROLL_DOWN':
|
|
46
|
+
return `${verb('scroll')} down to reveal more of the page`;
|
|
47
|
+
case 'SCROLL_UP':
|
|
48
|
+
return `${verb('scroll')} back up`;
|
|
49
|
+
case 'WAIT':
|
|
50
|
+
return `${verb('wait')} for the page to finish updating`;
|
|
51
|
+
default:
|
|
52
|
+
return `${operation} ${target}`;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const PAST = new Map(Object.values(VERBS));
|
|
56
|
+
/**
|
|
57
|
+
* A recorded step's description (the `next` tense) in the past tense, as a sentence.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* pastTense('Type the "email" value into "Email"') // 'Typed the "email" value into "Email".'
|
|
61
|
+
*/
|
|
62
|
+
export const pastTense = (description) => {
|
|
63
|
+
const [verb] = description.split(' ', 1);
|
|
64
|
+
const told = PAST.has(verb) ? `${PAST.get(verb)}${description.slice(verb.length)}` : description;
|
|
65
|
+
return /[.!?]$/.test(told) ? told : `${told}.`;
|
|
66
|
+
};
|
|
@@ -1,9 +1,42 @@
|
|
|
1
|
+
// The design every recording page shares (Prima Persona: one 4-column grid, monochrome, hairline rows, Switzer):
|
|
2
|
+
// a run's own index.html, the jobs report and the agent report. The player (the video, its timeline and the step strip,
|
|
3
|
+
// kept on the same step) is here too, so a run looks the same wherever it is shown.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { storyLine } from '../task/present.js';
|
|
6
|
+
import { pastTense } from './describe-step.js';
|
|
1
7
|
export const escapeHtml = (text) => text.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
2
8
|
const escape = escapeHtml;
|
|
3
9
|
/** Switzer from Fontshare; the system stack takes over when it can't load (offline, file://). */
|
|
4
10
|
export const FONT_LINK = 'https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap';
|
|
11
|
+
// --- what every page's head carries
|
|
12
|
+
/** The installed jevway's version (`package.json` sits two folders up from here in `src/` and in `dist/`). */
|
|
13
|
+
export const JEVWAY_VERSION = (() => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return 'unknown';
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
/** Names the page's maker for an agent that fetched it: `<meta name="generator" content="jevway 0.4.0">`. */
|
|
22
|
+
export const GENERATOR_META = `<meta name="generator" content="jevway ${JEVWAY_VERSION}">`;
|
|
23
|
+
/** The web font, optional: fetched without holding the first paint, so an offline page draws at once in the system stack. */
|
|
24
|
+
export const FONT_STYLESHEET = `<link rel="stylesheet" href="${FONT_LINK}" media="print" onload="this.media='all'">`;
|
|
25
|
+
/**
|
|
26
|
+
* Data an agent reads without scraping the markup: JSON in an inert script element, `<` escaped so no string in it can
|
|
27
|
+
* close the element.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* embeddedJson('jevway-summary', record) // '<script type="application/json" id="jevway-summary">{…}</script>'
|
|
31
|
+
*/
|
|
32
|
+
export const embeddedJson = (id, data) => `<script type="application/json" id="${escape(id)}">${JSON.stringify(data).replace(/</g, '\\u003c')}</script>`;
|
|
5
33
|
// --- words and numbers a reader compares
|
|
6
34
|
export const statusWord = (status) => status.replaceAll('_', ' ');
|
|
35
|
+
const STATUS_LABELS = { done: 'Done', needs_input: 'Needs input', needs_confirmation: 'Needs confirmation', uncertain: 'Uncertain', blocked: 'Blocked', step_budget: 'Out of budget' };
|
|
36
|
+
/** A status as a person says it: "Needs input", "Out of budget". */
|
|
37
|
+
export const statusLabel = (status) => STATUS_LABELS[status] ?? statusWord(status).replace(/^./, (c) => c.toUpperCase());
|
|
38
|
+
/** How a status reads at a glance: done held, a hand-back is the caller's turn (reached), a stop missed. */
|
|
39
|
+
export const statusTone = (status) => (status === 'done' ? 'held' : ['needs_input', 'needs_confirmation', 'uncertain'].includes(status) ? 'reached' : 'missed');
|
|
7
40
|
export const clock = (s) => `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`;
|
|
8
41
|
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
9
42
|
export const indexLabel = (i) => String(i + 1).padStart(2, '0');
|
|
@@ -22,6 +55,78 @@ export const pathOf = (url) => {
|
|
|
22
55
|
};
|
|
23
56
|
/** A run's Finished shots carry the status (lowercase) as their operation; actions are uppercase. */
|
|
24
57
|
export const isFinished = (step) => step.operation === step.operation.toLowerCase();
|
|
58
|
+
const lowerFirst = (text) => text.charAt(0).toLowerCase() + text.slice(1);
|
|
59
|
+
const withoutStop = (text) => text.replace(/\.$/, '');
|
|
60
|
+
// A recorded shot's own wording when no summary step tells it: past tense, or what kept it from running.
|
|
61
|
+
const toldFromShot = (step) => step.unreachable
|
|
62
|
+
? `Did not ${lowerFirst(step.description)}: something covered its target${(step.attempts ?? 1) > 1 ? ` (${step.attempts} tries)` : ''}.`
|
|
63
|
+
: pastTense(step.description);
|
|
64
|
+
// A shot and a summary step are the same step when their operation and wording agree: the summary's sentence starts
|
|
65
|
+
// with the shot's description in the past tense (or "Did not …" for a target that was never reached).
|
|
66
|
+
const sameStep = (shot, step, sentence) => {
|
|
67
|
+
if (shot.operation !== step.operation)
|
|
68
|
+
return false;
|
|
69
|
+
if (shot.unreachable)
|
|
70
|
+
return sentence.startsWith(`Did not ${lowerFirst(shot.description)}`);
|
|
71
|
+
if (sentence.startsWith('Did not '))
|
|
72
|
+
return false;
|
|
73
|
+
return shot.operation === 'PRESS_ESCAPE' || sentence.startsWith(withoutStop(pastTense(shot.description)));
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* The recorded steps told the way a summary's `story` tells them (`storyLine`), one story per call: the recording's
|
|
77
|
+
* Finished shots split the calls, and each shot takes the sentence of the summary step it matches, in order. A summary
|
|
78
|
+
* step with no shot (a rejected done, a covered target fed back) keeps its place without one; a shot no summary step
|
|
79
|
+
* matches is told from its own description.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* storyOfRecording(steps, summaries)[0].entries[0] // { sentence: 'Clicked "New issue".', shot: 0 }
|
|
83
|
+
*/
|
|
84
|
+
export const storyOfRecording = (steps, summaries) => {
|
|
85
|
+
const calls = [{ shots: [] }];
|
|
86
|
+
steps.forEach((step, i) => {
|
|
87
|
+
const call = calls.at(-1);
|
|
88
|
+
if (isFinished(step)) {
|
|
89
|
+
call.end = i;
|
|
90
|
+
calls.push({ shots: [] });
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
call.shots.push(i);
|
|
94
|
+
});
|
|
95
|
+
if (!calls.at(-1).shots.length && calls.length > 1)
|
|
96
|
+
calls.pop();
|
|
97
|
+
return calls.map(({ shots, end }, n) => {
|
|
98
|
+
const told = summaries[n]?.steps ?? [];
|
|
99
|
+
// A summary saved by older code may lack a step's element.
|
|
100
|
+
const sentences = told.map((step) => storyLine({ ...step, element: step.element ?? '' }));
|
|
101
|
+
const entries = [];
|
|
102
|
+
let cursor = 0;
|
|
103
|
+
for (const shot of shots) {
|
|
104
|
+
const match = told.findIndex((step, j) => j >= cursor && sameStep(steps[shot], step, sentences[j]));
|
|
105
|
+
if (match < 0) {
|
|
106
|
+
entries.push({ sentence: toldFromShot(steps[shot]), shot });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
entries.push(...sentences.slice(cursor, match).map((sentence) => ({ sentence })), { sentence: sentences[match], shot });
|
|
110
|
+
cursor = match + 1;
|
|
111
|
+
}
|
|
112
|
+
entries.push(...sentences.slice(cursor).map((sentence) => ({ sentence })));
|
|
113
|
+
if (end === undefined)
|
|
114
|
+
return { entries };
|
|
115
|
+
const status = steps[end].operation;
|
|
116
|
+
const reason = summaries[n]?.reason ?? steps[end].description.replace(/^Finished: [a-z_]+\.\s*/, '');
|
|
117
|
+
entries.push({ sentence: `${statusLabel(status)}: ${reason}`, shot: end });
|
|
118
|
+
return { status, entries };
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
/** Each recorded step's sentence, by its index: what its card and its alt text say. */
|
|
122
|
+
export const sentencesOf = (stories) => {
|
|
123
|
+
const sentences = [];
|
|
124
|
+
for (const { entries } of stories)
|
|
125
|
+
for (const { sentence, shot } of entries)
|
|
126
|
+
if (shot !== undefined)
|
|
127
|
+
sentences[shot] = sentence;
|
|
128
|
+
return sentences;
|
|
129
|
+
};
|
|
25
130
|
/** One hairline row of a fact list: the label in column 1, the body across columns 2–4. */
|
|
26
131
|
export const fact = (label, body) => `<div class="fact grid4"><dt>${label}</dt><dd class="c2s3">${body}</dd></div>`;
|
|
27
132
|
/**
|
|
@@ -43,13 +148,14 @@ export const stripOpening = (label, viewport) => {
|
|
|
43
148
|
return `<div class="strip${shape}" aria-label="Steps of ${label}">`;
|
|
44
149
|
};
|
|
45
150
|
/**
|
|
46
|
-
* One step as a card: the shot, then its number, video time and confidence, what happened
|
|
47
|
-
* Finished shots (lowercase operation, the status) read
|
|
48
|
-
* `href` the whole card is a link (the overview); otherwise it seeks the
|
|
151
|
+
* One step as a card: the shot, then its number, video time and confidence, what happened (`sentence`, the story's
|
|
152
|
+
* wording, else the recorded description), and where. Finished shots (lowercase operation, the status) read
|
|
153
|
+
* "Finished: <status>" without a sentence. With `href` the whole card is a link (the overview); otherwise it seeks the
|
|
154
|
+
* player it sits in, and a link named after the shot's file opens it full size.
|
|
49
155
|
*/
|
|
50
|
-
export const shotCard = (step, i, dir, open) => {
|
|
156
|
+
export const shotCard = (step, i, dir, open, sentence) => {
|
|
51
157
|
const finished = isFinished(step);
|
|
52
|
-
const label = finished ? `Finished: ${statusWord(step.operation)}` : step.description;
|
|
158
|
+
const label = sentence ?? (finished ? `Finished: ${statusWord(step.operation)}` : step.description);
|
|
53
159
|
const timed = 'video' in open && open.video && step.video_at !== undefined;
|
|
54
160
|
const meta = [
|
|
55
161
|
indexLabel(i),
|
|
@@ -57,15 +163,16 @@ export const shotCard = (step, i, dir, open) => {
|
|
|
57
163
|
...(step.unreachable ? [`<span class="missed">not reached${(step.attempts ?? 1) > 1 ? ` ×${step.attempts}` : ''}: covered</span>`] : step.confidence !== undefined && !finished ? [step.confidence.toFixed(2)] : []),
|
|
58
164
|
].join(' · ');
|
|
59
165
|
const src = `${dir}${step.file}`;
|
|
60
|
-
const image = `<img src="${escape(src)}" alt="${escape(step.description)}" loading="lazy" decoding="async" width="1120" height="780">`;
|
|
166
|
+
const image = `<img src="${escape(src)}" alt="${escape(sentence ?? step.description)}" loading="lazy" decoding="async" width="1120" height="780">`;
|
|
61
167
|
const text = `<span class="what">${escape(label)}</span><span class="where">${escape(pathOf(step.url))}</span>`;
|
|
62
168
|
const className = `shot${finished ? ' finished' : ''}`;
|
|
63
169
|
if ('href' in open)
|
|
64
170
|
return `<a class="${className}" href="${escape(open.href)}">${image}<span class="caption"><span class="meta num">${meta}</span>${text}</span></a>`;
|
|
65
|
-
// In a player the whole card seeks (the button gives it a keyboard stop); the
|
|
171
|
+
// In a player the whole card seeks (the button gives it a keyboard stop); the file's own link sits outside the button.
|
|
66
172
|
const data = timed ? ` data-at="${step.video_at}"` : ` data-src="${escape(src)}"`;
|
|
173
|
+
const name = step.file.split('/').pop();
|
|
67
174
|
return `<div class="${className}" data-index="${i}"${data}><button type="button" class="seek" aria-label="Show step ${i + 1}">${image}</button>
|
|
68
|
-
<span class="caption"><span class="meta-line"><span class="meta num">${meta}</span><a class="full-size" href="${escape(src)}" target="_blank" rel="noopener"
|
|
175
|
+
<span class="caption"><span class="meta-line"><span class="meta num">${meta}</span><a class="full-size" href="${escape(src)}" target="_blank" rel="noopener" title="Open the shot full size">${escape(name)}</a></span>${text}</span></div>`;
|
|
69
176
|
};
|
|
70
177
|
/**
|
|
71
178
|
* The video (or the first shot when there is no video), its timeline with one tick per step, the `bar` (a run switcher
|
|
@@ -75,16 +182,16 @@ export const shotCard = (step, i, dir, open) => {
|
|
|
75
182
|
* @example
|
|
76
183
|
* playerMarkup({ dir: 'run-1/', video: 'run.mp4', steps, label: 'run 1', featured: true, viewport: { width: 1120, height: 780 }, bar })
|
|
77
184
|
*/
|
|
78
|
-
export const playerMarkup = ({ dir, video, steps, label, featured, viewport, bar, preload = 'none', }) => {
|
|
185
|
+
export const playerMarkup = ({ dir, video, steps, label, featured, viewport, bar, preload = 'none', sentences = [], }) => {
|
|
79
186
|
const screen = screenFrame(viewport);
|
|
80
187
|
const strip = stripOpening(label, viewport);
|
|
81
188
|
const poster = `${dir}${steps[0].file}`;
|
|
82
189
|
const timed = steps.filter((s) => s.video_at !== undefined);
|
|
83
190
|
if (!video || !timed.length) {
|
|
84
|
-
return `${screen}<img data-frame src="${escape(poster)}" alt="${escape(steps[0].description)}"></div>
|
|
191
|
+
return `${screen}<img data-frame src="${escape(poster)}" alt="${escape(sentences[0] ?? steps[0].description)}"></div>
|
|
85
192
|
<p class="small screen-note">No video for this run (it needs ffmpeg): pick a step to see its shot.</p>
|
|
86
193
|
${bar}
|
|
87
|
-
${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: false })).join('')}</div>`;
|
|
194
|
+
${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: false }, sentences[i])).join('')}</div>`;
|
|
88
195
|
}
|
|
89
196
|
// The last frame holds 1.5 s (recording.ts), so this is the video's length until its metadata says otherwise.
|
|
90
197
|
const duration = Math.max(...timed.map((s) => s.video_at)) + 1.5;
|
|
@@ -99,7 +206,7 @@ ${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: false })).join('')}</
|
|
|
99
206
|
return `${screen}<video src="${escape(`${dir}${video}`)}" preload="${preload}"${posterAttribute} controls muted playsinline aria-label="Screencast of ${label}"></video></div>
|
|
100
207
|
<div class="timeline-scroll"><div class="timeline" style="min-width:${width.toFixed(1)}rem"><div class="track"></div><div class="played"></div><div class="ticks">${ticks}</div></div></div>
|
|
101
208
|
${bar}
|
|
102
|
-
${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: true })).join('')}</div>`;
|
|
209
|
+
${strip}${steps.map((s, i) => shotCard(s, i, dir, { video: true }, sentences[i])).join('')}</div>`;
|
|
103
210
|
};
|
|
104
211
|
// --- the stylesheet: tokens, the grid, type, the player and fact rows
|
|
105
212
|
export const BASE_STYLE = `
|
|
@@ -108,9 +215,10 @@ export const BASE_STYLE = `
|
|
|
108
215
|
--foreground: oklch(0.205 0 0);
|
|
109
216
|
--muted: oklch(0.922 0 0);
|
|
110
217
|
--muted-foreground: oklch(0.47 0 0);
|
|
111
|
-
|
|
112
|
-
--
|
|
113
|
-
--
|
|
218
|
+
/* Text-safe on the background: at least 4.5:1 */
|
|
219
|
+
--held: oklch(0.5 0.13 150);
|
|
220
|
+
--reached: oklch(0.52 0.12 65);
|
|
221
|
+
--missed: oklch(0.5 0.19 27);
|
|
114
222
|
--font-sans: "Switzer", ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
|
115
223
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
116
224
|
--wrap: 85rem;
|
|
@@ -132,8 +240,8 @@ export const BASE_STYLE = `
|
|
|
132
240
|
*, *::before, *::after { box-sizing: border-box; }
|
|
133
241
|
html { -webkit-text-size-adjust: 100%; }
|
|
134
242
|
body { margin: 0; background: var(--background); color: var(--foreground); font: 400 1rem/1.5 var(--font-sans); -webkit-font-smoothing: antialiased; }
|
|
135
|
-
h1, h2, h3, p, dl, dd, ul, figure { margin: 0; }
|
|
136
|
-
ul { padding: 0; list-style: none; }
|
|
243
|
+
h1, h2, h3, p, dl, dd, ul, ol, figure { margin: 0; }
|
|
244
|
+
ul, ol { padding: 0; list-style: none; }
|
|
137
245
|
a { color: inherit; text-decoration: none; }
|
|
138
246
|
a:hover { color: var(--muted-foreground); }
|
|
139
247
|
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; text-align: inherit; }
|
|
@@ -221,10 +329,27 @@ a.shot:hover img { opacity: 0.85; }
|
|
|
221
329
|
.kv { color: var(--muted-foreground); margin-right: 0.375rem; }
|
|
222
330
|
.address { color: var(--muted-foreground); font-family: var(--font-mono); font-size: 0.8125rem; overflow-wrap: anywhere; }
|
|
223
331
|
|
|
332
|
+
/* The top bar: the page's name and its in-page links */
|
|
333
|
+
nav.top { position: sticky; top: 0; z-index: 5; background: var(--background); }
|
|
334
|
+
nav.top .wrapper { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding-block: 0.75rem; font-size: 0.75rem; }
|
|
335
|
+
nav.top .links { display: flex; gap: 1rem; color: var(--muted-foreground); }
|
|
336
|
+
|
|
224
337
|
/* Footer */
|
|
225
338
|
footer .wrapper { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.5rem 1rem; padding-block: 2rem; border-top: 1px solid var(--muted); font-size: 0.75rem; line-height: 1rem; color: var(--muted-foreground); }
|
|
226
339
|
footer span { overflow-wrap: anywhere; }
|
|
227
340
|
|
|
341
|
+
/* Print: the story and the shots on paper, no player */
|
|
342
|
+
@media print {
|
|
343
|
+
:root { --background: #fff; --foreground: #000; --muted: #ccc; --muted-foreground: #444; color-scheme: light; }
|
|
344
|
+
video, .screen, .screen-note, .timeline-scroll, .player-bar, .full-size, nav.top, .needs-js { display: none !important; }
|
|
345
|
+
.strip { flex-wrap: wrap; overflow: visible; }
|
|
346
|
+
.shot { flex: 0 0 calc((100% - 3 * var(--gap)) / 4); break-inside: avoid; }
|
|
347
|
+
.player .shot img { opacity: 1; }
|
|
348
|
+
li, figure, article, .fact { break-inside: avoid; }
|
|
349
|
+
h1, h2, h3, .block-head { break-after: avoid; }
|
|
350
|
+
a { text-decoration: underline; }
|
|
351
|
+
}
|
|
352
|
+
|
|
228
353
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } }
|
|
229
354
|
`;
|
|
230
355
|
/**
|
|
@@ -244,6 +369,9 @@ export const PLAYER_SCRIPT = `<script>
|
|
|
244
369
|
const ticks = [...panel.querySelectorAll('.tick')];
|
|
245
370
|
const shots = [...panel.querySelectorAll('.strip .shot')];
|
|
246
371
|
const played = panel.querySelector('.played');
|
|
372
|
+
// A run page's story list: one entry per shot, marked with the shot it tells (outside the strip).
|
|
373
|
+
const entries = [...panel.querySelectorAll('li[data-shot]')];
|
|
374
|
+
const screen = panel.querySelector('.screen');
|
|
247
375
|
const clockText = panel.querySelector('.clock');
|
|
248
376
|
let current = -1;
|
|
249
377
|
const reveal = (shot) => {
|
|
@@ -255,6 +383,7 @@ export const PLAYER_SCRIPT = `<script>
|
|
|
255
383
|
if (index === current) return;
|
|
256
384
|
current = index;
|
|
257
385
|
for (const list of [ticks, shots]) list.forEach((el, n) => (n === index ? el.setAttribute('aria-current', 'step') : el.removeAttribute('aria-current')));
|
|
386
|
+
entries.forEach((el) => (Number(el.dataset.shot) === index ? el.setAttribute('aria-current', 'step') : el.removeAttribute('aria-current')));
|
|
258
387
|
reveal(shots[index]);
|
|
259
388
|
const tick = ticks[index];
|
|
260
389
|
if (tick && timeline && timeline.scrollWidth > timeline.clientWidth) {
|
|
@@ -279,17 +408,34 @@ export const PLAYER_SCRIPT = `<script>
|
|
|
279
408
|
if (clockText) clockText.textContent = clock(Number(shot.dataset.at));
|
|
280
409
|
}
|
|
281
410
|
};
|
|
282
|
-
// A reader's seek: plays from there, and the address names the step so it can be shared (#<view>-r<run>-s<step>
|
|
411
|
+
// A reader's seek: plays from there, and the address names the step so it can be shared (#<view>-r<run>-s<step>
|
|
412
|
+
// in a report, #s<step> on a run page).
|
|
283
413
|
const choose = (index) => {
|
|
284
414
|
seek(index, true);
|
|
285
415
|
const view = panel.closest('.job-view');
|
|
286
416
|
if (view && panel.dataset.run) history.replaceState(null, '', '#' + view.id + '-r' + panel.dataset.run + '-s' + (index + 1));
|
|
417
|
+
else if (panel.hasAttribute('data-step-links')) history.replaceState(null, '', '#s' + (index + 1));
|
|
287
418
|
};
|
|
288
419
|
ticks.forEach((tick) => tick.addEventListener('click', () => choose(Number(tick.dataset.index))));
|
|
289
420
|
// A click anywhere on a card seeks, except on its full-size link.
|
|
290
421
|
shots.forEach((shot, n) => shot.addEventListener('click', (event) => { if (!event.target.closest('a')) choose(n); }));
|
|
422
|
+
// A story entry's time: play from there, with the video in view.
|
|
423
|
+
panel.querySelectorAll('a.at[data-seek]').forEach((link) => link.addEventListener('click', (event) => {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
choose(Number(link.dataset.seek));
|
|
426
|
+
if (screen && screen.getBoundingClientRect().top < 0) screen.scrollIntoView({ block: 'start', behavior: reduceMotion ? 'auto' : 'smooth' });
|
|
427
|
+
}));
|
|
291
428
|
// A link to a step (the router): go there without playing.
|
|
292
429
|
panel.addEventListener('seek-step', (event) => seek(event.detail.index, false));
|
|
430
|
+
// A run page opened at #s<step> (a shared link): that step, without playing.
|
|
431
|
+
if (panel.hasAttribute('data-step-links')) {
|
|
432
|
+
const open = () => {
|
|
433
|
+
const step = /^s(\\d+)$/.exec(location.hash.slice(1));
|
|
434
|
+
if (step && shots[Number(step[1]) - 1]) seek(Number(step[1]) - 1, false);
|
|
435
|
+
};
|
|
436
|
+
addEventListener('hashchange', open);
|
|
437
|
+
open();
|
|
438
|
+
}
|
|
293
439
|
if (!video) return;
|
|
294
440
|
const place = () => {
|
|
295
441
|
if (!video.duration || !isFinite(video.duration)) return;
|