tfrere's picture
tfrere HF Staff
feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations
f469961
Raw
History Blame Contribute Delete
23.9 kB
/**
* demo/showcase.ts
*
* Three-persona collaboration showcase for recording a mini product
* video. An empty article gets turned into a compact research piece
* on Transformer attention by three agents working in concert:
*
* - Bob writes "The recipe" (top): definition, block-math
* formula, softmax explanation. Mid-cycle he fires a
* chat citation tool call that adds the Vaswani 2017
* paper to the bibliography.
* - Alice (hero window) writes "Intuition" (middle): an analogy
* with inline $Q$ / $K$ / $V$, bolds a key phrase via the
* in-editor bubble toolbar, then asks the chat agent to
* rephrase the closing line (AgentRewrite typewriter).
* At the end she scrolls to the top, slowly drags the
* Hue slider (warm yellow -> cool blue, article retints
* live including the neural-net banner), clicks
* Publish, waits for the SSE pipeline to succeed, and
* finally clicks "View published article" so the
* recording ends on the real rendered output - the
* "ship it" payoff.
* - Carol writes "In Python" (bottom): intro line, link action
* on "PyTorch" (points at the real
* scaled_dot_product_attention docs), 8-line code
* block, Vaswani closing line.
*
* Three authors (Hugging Face, MIT/ETH Zurich, Stanford), a neural
* network banner, a live hue drag, three agent tool-calls (bubble
* bold + chat rephrase + citation + link), and a real publish
* (Publishing... Rendering HTML... Rasterizing embeds...
* Published!) land in a ~60s budget.
*
* By default:
* - Alice is the "hero" window (visible). This is what you record.
* - Bob and Carol run headless. Their yjs cursors, comments and
* edits appear in Alice's window as if real teammates joined.
*
* Flags:
* --all-visible Launch Bob and Carol visibly too (debug layout).
* --record Recording-friendly mode for Alice (no toolbar /
* tabs / URL bar / automation banner, OS
* fullscreen, persistent context). Bob and Carol
* are forced headless. A 5s countdown gives you
* time to start your screen recorder.
* --capture macOS only. Implies --record. Automatically
* starts `screencapture -v` before the demo and
* stops it when the scenario ends. The resulting
* .mov is saved under demo/recordings/. First run
* triggers a Screen Recording permission prompt;
* grant it to `Terminal` (or iTerm).
*
* Prereqs:
* - Frontend dev server on http://localhost:5678
* - Backend dev server on http://localhost:8080
*/
import { runAlice } from "./alice.js";
import { runBob } from "./bob.js";
import { runCarol } from "./carol.js";
import { resetDoc } from "./lib/editor-actions.js";
import { PERSONAS } from "./personas.js";
import {
killLeftoverChromiums,
launchPersona,
shutdownAllPersonas,
type PersonaHandle,
} from "./lib/chromium.js";
import {
buildRecordingPath,
recordingCountdown,
startScreenRecording,
type ScreenRecording,
} from "./lib/recording.js";
import { connectPersona, type PersonaHandle as YjsPersona } from "./lib/yjs-client.js";
import type { Page } from "playwright";
// ---------------------------------------------------------------------------
// Post-parallel doc verification
// ---------------------------------------------------------------------------
/**
* Expected text fragments (substrings, case-sensitive) for each slot
* a persona is supposed to own after the parallel phase. Strings not
* positions, because Yjs syncs can still be in flight when we read.
*
* If a fragment is missing in its expected slot, or shows up in a
* DIFFERENT section's slot, that's a collision - the kind of bug
* user reports as "agents stepping on each other's toes".
*/
const SLOT_EXPECTATIONS: Array<{
heading: string;
slot: number;
mustInclude: string;
owner: "alice" | "bob" | "carol";
}> = [
// Bob - "The recipe" (4 slots: def, block-math, softmax, multi-head)
{
heading: "The recipe",
slot: 0,
mustInclude: "Attention lets a token",
owner: "bob",
},
// slot 1 is a <blockMath> atom; checked separately below.
{
heading: "The recipe",
slot: 2,
mustInclude: "softmax turns those similarity scores",
owner: "bob",
},
{
heading: "The recipe",
slot: 3,
mustInclude: "multi-head",
owner: "bob",
},
// Alice - "Intuition"
{
heading: "Intuition",
slot: 0,
mustInclude: "Imagine each token asking",
owner: "alice",
},
{
heading: "Intuition",
slot: 1,
// Post-rephrase. If the rephrase did not run we still accept the
// original wording so we don't false-positive on a failed chat
// action (different bug category).
mustInclude: "every token",
owner: "alice",
},
{
heading: "Intuition",
slot: 2,
mustInclude: "fully differentiable",
owner: "alice",
},
// Carol - "In Python"
{
heading: "In Python",
slot: 0,
mustInclude: "eight lines of PyTorch",
owner: "carol",
},
// slot 1 is the code block; tested separately below
{
heading: "In Python",
slot: 2,
mustInclude: "Every modern LLM",
owner: "carol",
},
];
/**
* Inspect the shared Y.XmlFragment directly (no browser needed)
* and return a flat "sections" view matching what the old
* DOM-based verifier produced, so `SLOT_EXPECTATIONS` keeps
* working unchanged.
*/
async function verifyFinalDoc(bob: YjsPersona): Promise<void> {
// We import yjs lazily here to avoid a top-level dep circle with
// `./lib/yjs-client.ts`; the verifier only runs once at the end
// of a demo so the extra dynamic import is cheap.
const Y = await import("yjs");
const sections: Array<{
heading: string;
slots: string[];
slotTags: string[];
}> = [];
let current: {
heading: string;
slots: string[];
slotTags: string[];
} | null = null;
// textContent for a Y.XmlElement: concatenate every descendant
// Y.XmlText, skipping inline atom nodes (inlineMath, citation)
// which have no textContent of their own but carry meaningful
// attributes. We still keep their node name in the tag so the
// flat text search below can spot a citation chip by inference.
// Y.XmlText.toString() serializes marks as XML tags (`<bold>foo</bold>`,
// `<link href="...">bar</link>`), which breaks "expected substring"
// checks like `"eight lines of PyTorch"` when a mark like link wraps
// the trailing word. We read the Delta format instead and only keep
// the `insert` text values: gives us the plain concatenated text a
// reader would see, mark attributes ignored.
const xmlTextToPlain = (t: Y.XmlText): string => {
const delta = t.toDelta() as Array<{ insert?: unknown }>;
let out = "";
for (const op of delta) {
if (typeof op.insert === "string") out += op.insert;
}
return out;
};
const textOf = (el: Y.XmlElement): string => {
// Atom nodes (`blockMath`, `inlineMath`) carry their whole
// payload on the `latex` attribute and have no children, so
// the child-walker below would see an empty slot. Surface the
// LaTeX verbatim when the slot IS an atom.
if (el.nodeName === "blockMath" || el.nodeName === "inlineMath") {
const latex = el.getAttribute("latex");
return typeof latex === "string" ? latex.replace(/\s+/g, " ").trim() : "";
}
let out = "";
const visit = (node: Y.XmlElement | Y.XmlText) => {
if (node instanceof Y.XmlText) {
out += xmlTextToPlain(node);
} else if (node instanceof Y.XmlElement) {
if (node.nodeName === "citation") {
const label = node.getAttribute("label");
if (typeof label === "string") out += label;
} else if (node.nodeName === "inlineMath" || node.nodeName === "blockMath") {
const latex = node.getAttribute("latex");
if (typeof latex === "string") out += latex;
} else {
for (const child of node.toArray()) visit(child as Y.XmlElement | Y.XmlText);
}
}
};
for (const child of el.toArray()) {
visit(child as Y.XmlElement | Y.XmlText);
}
return out.replace(/\s+/g, " ").trim();
};
for (const child of bob.fragment.toArray()) {
if (!(child instanceof Y.XmlElement)) continue;
if (child.nodeName === "heading" && Number(child.getAttribute("level")) === 2) {
if (current) sections.push(current);
current = {
heading: textOf(child),
slots: [],
slotTags: [],
};
} else if (current) {
const tagMap: Record<string, string> = {
paragraph: "P",
codeBlock: "PRE",
heading: "H",
bibliography: "BIB",
blockMath: "MATH",
};
current.slots.push(textOf(child));
current.slotTags.push(tagMap[child.nodeName] ?? child.nodeName.toUpperCase());
}
}
if (current) sections.push(current);
const flatText = sections
.flatMap((s) => [s.heading, ...s.slots])
.join(" ")
.replace(/\s+/g, " ")
.trim();
const report = { sections, flatText };
if (!report.sections.length) {
console.warn("[verify] empty fragment, nothing to verify");
return;
}
console.log("[verify] ----- doc layout -----");
for (const s of report.sections) {
console.log(`[verify] ## ${s.heading} (${s.slots.length} slots)`);
s.slots.forEach((txt, i) => {
const tag = s.slotTags[i];
const trimmed = txt.length > 80 ? txt.slice(0, 80) + "..." : txt;
console.log(`[verify] [${i}] <${tag}> ${trimmed || "(empty)"}`);
});
}
const anomalies: string[] = [];
for (const exp of SLOT_EXPECTATIONS) {
const section = report.sections.find((s) =>
s.heading.toLowerCase().includes(exp.heading.toLowerCase()),
);
if (!section) {
anomalies.push(`missing section: "${exp.heading}"`);
continue;
}
const slotText = section.slots[exp.slot] ?? "";
if (!slotText.includes(exp.mustInclude)) {
// Did the expected fragment land elsewhere in the doc?
const leakedInto = report.sections
.flatMap((s) =>
s.slots.map((txt, i) => ({
heading: s.heading,
slot: i,
text: txt,
})),
)
.find(
(loc) =>
loc.text.includes(exp.mustInclude) &&
!(loc.heading === section.heading && loc.slot === exp.slot),
);
if (leakedInto) {
anomalies.push(
`${exp.owner}: "${exp.mustInclude}" expected in ` +
`${exp.heading}[${exp.slot}] but landed in ` +
`${leakedInto.heading}[${leakedInto.slot}] ` +
`- collision with neighbour section`,
);
} else if (report.flatText.includes(exp.mustInclude)) {
anomalies.push(
`${exp.owner}: "${exp.mustInclude}" present in doc but not in ` +
`${exp.heading}[${exp.slot}] (slot has "${slotText.slice(0, 40)}...")`,
);
} else {
anomalies.push(
`${exp.owner}: "${exp.mustInclude}" missing from the doc entirely`,
);
}
}
}
// Code block sanity check: "In Python" slot 1 should be a <pre> /
// codeblock node, and contain "def attention". Anything else means
// Carol's code got absorbed into the wrong node.
const inPython = report.sections.find((s) =>
s.heading.toLowerCase().includes("in python"),
);
if (inPython) {
const codeTag = inPython.slotTags[1];
const codeText = inPython.slots[1] ?? "";
if (codeTag !== "PRE" && !codeText.includes("def attention")) {
anomalies.push(
`carol: code block in "In Python"[1] not recognized ` +
`(got tag=${codeTag}, text="${codeText.slice(0, 40)}...")`,
);
}
}
// Block math sanity check: "The recipe" slot 1 should be a
// <blockMath> atom carrying the attention formula. We grep the
// LaTeX source for the two signature substrings the formula
// always contains ("Attention" + "softmax") so a typo in the
// source string doesn't silently pass.
const recipe = report.sections.find((s) =>
s.heading.toLowerCase().includes("the recipe"),
);
if (recipe) {
const mathTag = recipe.slotTags[1];
const mathText = recipe.slots[1] ?? "";
const looksLikeFormula =
mathText.includes("Attention") && mathText.includes("softmax");
if (mathTag !== "MATH" || !looksLikeFormula) {
anomalies.push(
`bob: block math in "The recipe"[1] not recognized ` +
`(got tag=${mathTag}, latex="${mathText.slice(0, 60)}...")`,
);
}
}
// Bibliography should exist (Bob's citation action creates it).
if (!report.flatText.includes("Vaswani")) {
anomalies.push("bob: citation 'Vaswani' never landed in the doc");
}
if (anomalies.length === 0) {
console.log("[verify] OK - no slot collisions detected");
} else {
console.log(`[verify] ${anomalies.length} anomalie(s) detected:`);
for (const a of anomalies) console.log(`[verify] - ${a}`);
}
}
interface CliArgs {
url: string;
speed: number;
allVisible: boolean;
/**
* Recording mode for Alice: chrome-less app window, true OS
* fullscreen, automation banner suppressed, and Bob/Carol forced
* headless so they never appear in the capture. Includes a short
* countdown before the demo starts so you can hit "record" on
* QuickTime / Loom / OBS.
*/
record: boolean;
/**
* macOS-only: auto-record the main display with `screencapture -v`
* while the demo runs. Implies record mode. Output goes to
* demo/recordings/.
*/
capture: boolean;
/**
* Recording duration in seconds (passed to `screencapture -V`).
*
* WHY this exists: sending SIGINT/SIGTERM/SIGHUP to
* `screencapture -v` aborts the capture WITHOUT flushing the .mov.
* The only reliable way to get a playable file is to let the
* `-V <seconds>` timer elapse naturally. So we size this to
* "demo duration + a few seconds of outro", and the script blocks
* on the timer before exiting.
*
* Default 50s covers our ~45s demo + a ~5s outro on the published
* article (user wants the browser to quit shortly after the
* publish view, not linger for 30s). Bump if you expand the
* scenario.
*/
captureDuration: number;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
url: "http://localhost:5678",
speed: 2,
allVisible: false,
record: false,
capture: false,
captureDuration: 50,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--url") args.url = argv[++i];
else if (a === "--speed") args.speed = Number(argv[++i]);
else if (a === "--all-visible") args.allVisible = true;
else if (a === "--record") args.record = true;
else if (a === "--capture") args.capture = true;
else if (a === "--capture-duration")
args.captureDuration = Number(argv[++i]);
}
// --capture only makes sense alongside --record.
if (args.capture) args.record = true;
return args;
}
async function run() {
const args = parseArgs(process.argv.slice(2));
console.log(
`[showcase] target=${args.url} speed=${args.speed}x allVisible=${args.allVisible} record=${args.record}`,
);
// Nuke any orphaned Chromium left over from a previous run (e.g.
// demo killed with Ctrl+C before it could close its browsers).
// Without this we get stale Yjs connections / extra windows
// cluttering the screen.
killLeftoverChromiums();
// Handles initialized lazily; kept in an array so SIGINT /
// finally can nuke every browser that did get launched, even if
// a later launch threw before we got to `Promise.all(runAlice,
// ...)`.
const handles: Array<PersonaHandle | null> = [];
// Bob + Carol are now Node-side Yjs peers (no Chromium); kept
// separately so SIGINT / finally can tear their provider sockets
// down cleanly alongside the browsers.
const yjsPeers: YjsPersona[] = [];
let recording: ScreenRecording | null = null;
let alreadyCleanedUp = false;
const cleanup = async (reason: string) => {
if (alreadyCleanedUp) return;
alreadyCleanedUp = true;
console.log(`[showcase] cleanup (${reason})`);
if (recording) {
try {
recording.abort();
} catch {
/* ignore */
}
recording = null;
}
for (const peer of yjsPeers) {
try {
peer.destroy();
} catch {
/* provider already closed */
}
}
await shutdownAllPersonas(handles);
console.log("[showcase] done.");
};
// Signal handlers: no matter the mode, Ctrl+C and SIGTERM always
// trigger full cleanup so the user never ends up with orphan
// Chromium windows consuming CPU / holding onto the Yjs server
// connection.
const onSignal = (sig: string) => {
cleanup(sig)
.catch(() => {})
.finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
};
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
try {
// Bob and Carol are ALWAYS headless. Showing them visibly (even
// outside record mode) steals focus from Alice's typing,
// causes keystrokes to race with window-manager animations,
// and generally makes the demo unreliable. --all-visible only
// controls Alice's layout now.
const alicePos = args.allVisible ? { x: 0, y: 0 } : undefined;
const alice = await launchPersona(args.url, {
persona: PERSONAS.alice,
headless: false,
windowPosition: alicePos,
fullscreen: !args.allVisible && !args.record,
recordMode: args.record,
});
handles.push(alice);
// Relay demo-related browser console messages to the Node log so
// we can diagnose silent frontend errors (e.g. "replace target
// not found") during a --capture run without having to open
// DevTools by hand.
alice.page.on("console", (msg) => {
const text = msg.text();
if (/__demo-chat|\[alice\]|agentRewrite/.test(text)) {
console.log(`[alice:console ${msg.type()}] ${text}`);
}
});
console.log("[showcase] alice up");
// Wipe the shared Yjs doc once on Alice's session BEFORE Bob /
// Carol join, so they connect to a clean slate (no leftover
// banner / body / title from a previous demo run). Idempotent:
// a no-op on a fresh server.
console.log("[showcase] resetting shared document");
await resetDoc(alice.page);
// Bob and Carol connect as Node-side Yjs peers instead of
// full Chromium browsers. They appear in Alice's editor via
// normal collaboration sync (authors list, text edits,
// citations) without any DOM typing or PM position race.
// URL transformation: http:// -> ws:// on the /collab path.
const wsUrl = args.url.replace(/^http/, "ws") + "/collab";
const bob = await connectPersona({
url: wsUrl,
docName: "default",
user: { name: PERSONAS.bob.name, color: PERSONAS.bob.color },
});
yjsPeers.push(bob);
console.log("[showcase] bob up (yjs peer)");
const carol = await connectPersona({
url: wsUrl,
docName: "default",
user: { name: PERSONAS.carol.name, color: PERSONAS.carol.color },
});
yjsPeers.push(carol);
console.log("[showcase] carol up (yjs peer)");
// Auto screen recording takes precedence over the manual
// countdown: if --capture is on, we start `screencapture -v`
// immediately and give the first frame a beat to settle
// before content starts typing.
if (args.capture) {
const outputPath = buildRecordingPath();
recording = await startScreenRecording(outputPath, args.captureDuration);
if (recording) {
console.log(
`[showcase] screen recording started (${args.captureDuration}s max) -> ${recording.outputPath}`,
);
await new Promise((r) => setTimeout(r, 1500));
} else {
console.warn("[showcase] falling back to manual countdown");
}
}
if (args.record && !recording) {
await recordingCountdown(5);
}
// T0 marks the start of the actual demo content (after the
// browsers and Yjs reset). Useful to measure the perceived
// "video length" independently of Playwright cold-start time.
const T0 = Date.now();
const stamp = (tag: string) =>
console.log(
`[showcase] +${((Date.now() - T0) / 1000).toFixed(2)}s ${tag}`,
);
stamp("demo phase start");
// Bob / Carol settle first; Alice keeps running through rephrase
// + hue + publish. We wait for Bob and Carol explicitly so we
// can inspect the shared doc from Bob's editor page WHILE Alice
// is still wrapping up. That way the diagnostic reads the post-
// parallel state (no publish navigation has hijacked Bob's page
// yet) and we can log any collision BEFORE the screencast ends.
const alicePromise = runAlice(alice.page, args.speed)
.then(() => stamp("alice done"))
.catch((err) => console.error("[alice] error:", err));
const bobPromise = runBob(bob, args.speed)
.then(() => stamp("bob done"))
.catch((err) => console.error("[bob] error:", err));
const carolPromise = runCarol(carol, args.speed)
.then(() => stamp("carol done"))
.catch((err) => console.error("[carol] error:", err));
await Promise.all([bobPromise, carolPromise]);
// Wait for Alice too: her rephrase typewriter + slot 2 wrap-up
// keeps writing AFTER Bob/Carol finish. Running verify before
// Alice is done used to show phantom "missing fragments" simply
// because the rephrase animation had not landed yet.
await alicePromise;
stamp("all personas finished");
// Post-parallel doc verification: read the shared Y.XmlFragment
// directly from Bob's still-connected Yjs peer. Alice's browser
// has navigated to the published output by this point, but the
// Yjs provider keeps syncing regardless of which page is on
// screen, so Bob's handle always reflects the final CRDT state.
try {
await verifyFinalDoc(bob);
} catch (err) {
console.warn(
"[verify] aborted:",
err instanceof Error ? err.message : err,
);
}
// Block on the screencapture timer so the user gets a complete,
// playable .mov. Extra time after "all personas finished"
// looks fine in the recording (final state of the editor,
// good outro frame). If the demo finished before the -V timer
// elapses, we wait here; otherwise this resolves immediately.
if (recording) {
const remainingMs = Math.max(0, recording.finishesAt - Date.now());
if (remainingMs > 0) {
const secs = (remainingMs / 1000).toFixed(1);
console.log(
`[showcase] waiting ${secs}s for screencapture to flush the .mov...`,
);
}
await recording.waitForFinish();
console.log(`[showcase] recording saved: ${recording.outputPath}`);
// Don't let `cleanup()` kill screencapture after it finished
// cleanly.
recording = null;
}
} finally {
// Always close browsers, regardless of mode or whether the demo
// succeeded. Previously dev mode kept browsers open "for
// inspection", but orphan Chromium windows after every run are
// more annoying than the occasional loss of a final doc state.
await cleanup("run complete");
}
}
run().catch(async (err) => {
console.error("[showcase] fatal:", err);
// Even on fatal errors, guarantee no leftover Chromium /
// screencapture.
try {
killLeftoverChromiums();
} catch {
/* ignore */
}
process.exit(1);
});