carbon-tokenization / backend /demo /lib /scaffolding.ts
tfrere's picture
tfrere HF Staff
feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations
f469961
Raw
History Blame Contribute Delete
13.8 kB
/**
* lib/scaffolding.ts
*
* Document scaffolding helpers for the showcase demo.
*
* The scaffold is what lets 3 personas type concurrently without
* stepping on each other's paragraphs: one persona pre-creates a
* Heading 2 + N empty <p> "slots" per section, then agents target
* specific slots by (heading, index) instead of blindly pressing Enter.
* Pressing Enter during the parallel phase is what causes agent
* overlap (one persona's trailing text gets absorbed into another's
* code fence).
*/
import type { Page } from "playwright";
import { humanType, pause } from "../human-typing.js";
import { humanMove } from "./mouse-cursor.js";
export interface ScaffoldSection {
heading: string;
/** Number of empty <p> slots to pre-create below the heading. */
slots: number;
}
/**
* Build the article scaffold in one persona pass: each section gets a
* Heading 2 and `slots` pre-created empty paragraphs underneath.
*
* Example output for [{ heading: "The recipe", slots: 3 }, ...]:
* <h2>The recipe</h2>
* <p></p> <- slot 0
* <p></p> <- slot 1
* <p></p> <- slot 2
* <h2>Intuition</h2>
* <p></p> <- slot 0
* ...
*/
/**
* Open the slash-menu ("/") suggestion popover and pick "Heading 2"
* using keyboard-only navigation - no filter text is typed, so if
* the popover fails to mount we don't leak garbage ("/heading 2")
* into the editor.
*
* Flat menu order (used by the suggestion plugin for ArrowUp /
* ArrowDown, independent of the grouped render): Heading 1 (index
* 0), Heading 2 (index 1), ... so ONE ArrowDown from the default
* selection lands on "Heading 2".
*
* Used for the FIRST section of the scaffold so viewers see the
* block-command popover fire and close cleanly. Subsequent
* headings use the faster Markdown `## ` input rule for variety.
*
* The menu's own command deletes the "/" trigger before running
* `toggleHeading({ level: 2 })`, so after this helper returns the
* caret is already parked inside an empty <h2> ready for heading
* text.
*/
async function pickHeading2ViaSlashMenu(page: Page): Promise<boolean> {
// Move the visible cursor over the editor BEFORE typing "/" so the
// viewer sees the pointer parked on the paragraph where the slash
// menu is about to bloom. Otherwise the menu opens while the
// cursor is stranded on whatever button Alice last clicked
// (typically the "Add author" confirm), which reads as a ghost
// interaction.
try {
const rect = await page.evaluate(() => {
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
if (!root) return null;
const sel = window.getSelection();
// Prefer the caret's own rect so the cursor lands exactly where
// the "/" will be typed. Fall back to the last paragraph if the
// caret is detached.
if (sel && sel.rangeCount > 0) {
const r = sel.getRangeAt(0).getClientRects()[0];
if (r && r.width >= 0 && r.height > 0) {
return { x: r.left + 6, y: r.top + r.height / 2 };
}
}
const paras = root.querySelectorAll("p");
const last = paras[paras.length - 1] as HTMLElement | undefined;
if (!last) return null;
const lr = last.getBoundingClientRect();
return { x: lr.left + 24, y: lr.top + lr.height / 2 };
});
if (rect) await humanMove(page, rect.x, rect.y, { steps: 18 });
} catch {
/* non-fatal, typing still works without the cursor following */
}
await pause(80, 140);
await page.keyboard.type("/", { delay: 30 });
// Wait for the tippy-mounted popover to appear. The Suggestion
// plugin schedules a React render + tippy mount after the "/"
// lands in state, which can take two animation frames.
const popoverLocator = page.locator(".slash-menu").first();
try {
await popoverLocator.waitFor({ state: "visible", timeout: 1_200 });
} catch {
// Popover never mounted (race with initial focus / input
// handler). Backspace the stray "/" so the caller's fallback
// doesn't stack it in front of "## ".
await page.keyboard.press("Backspace");
await pause(40, 80);
return false;
}
// Pause so the viewer actually sees the menu open before we
// navigate.
await pause(420, 640);
// Default selectedIndex is 0 = "Heading 1"; one ArrowDown lands
// on "Heading 2". We do this purely via arrow keys so NO
// filter text is typed into the doc if the suggestion plugin
// unexpectedly closes mid-sequence.
await page.keyboard.press("ArrowDown");
await pause(160, 260);
await page.keyboard.press("Enter");
await pause(140, 220);
return true;
}
export async function writeScaffold(
page: Page,
sections: ScaffoldSection[],
speed: number,
): Promise<void> {
for (let s = 0; s < sections.length; s++) {
const section = sections[s];
const isLast = s === sections.length - 1;
// Use the slash menu for the first heading so the demo shows
// the block-command popover at least once. The remaining
// sections stick to the Markdown `## ` shortcut to keep the
// scaffold phase short.
let createdViaSlash = false;
if (s === 0) {
createdViaSlash = await pickHeading2ViaSlashMenu(page);
}
if (!createdViaSlash) {
await page.keyboard.type("## ", { delay: 22 });
await pause(40, 80);
}
await humanType(page, section.heading, {
speed: speed * 2.2,
typoRate: 0,
thinkRate: 0.01,
});
// Enter budget explanation: the first Enter exits the heading into a
// fresh empty <p>; each subsequent Enter appends another empty <p>.
// For a non-last section we also need ONE extra trailing <p> that
// will be consumed by the next heading's "## " input rule, otherwise
// we'd lose the section's last slot. The last section doesn't need
// that extra trailing paragraph.
const enters = isLast ? section.slots : section.slots + 1;
for (let i = 0; i < enters; i++) {
await page.keyboard.press("Enter");
await pause(40, 80);
}
}
}
export async function waitForHeading2(
page: Page,
text: string,
timeout = 60_000,
): Promise<void> {
await page
.locator(".ProseMirror h2")
.filter({ hasText: text })
.first()
.waitFor({ state: "visible", timeout });
}
/**
* Wait for the OTHER peers' final contributions to have landed in
* the shared doc, then give the browser one settle frame before
* returning. Use this as a pre-bold sync point so the layout is
* frozen when we compute and drag over the target phrase: if we
* skip it, Bob's `<blockMath>` insertion or Carol's `<pre>` code
* block can still be reshaping the doc during the drag, which
* shifts the Intuition paragraph out from under the measured
* rects and the selection sweep picks up the heading above.
*
* The markers below are the LAST things each peer writes:
* - Bob: `.ProseMirror math-display` node (the attention
* formula in The recipe[1])
* - Carol: `.ProseMirror pre code` with "import torch" as text
* (the eight-line PyTorch snippet)
*
* Both are idempotent: if the peer finished earlier, the locator
* resolves immediately. The extra `layoutSettleMs` beat after
* resolution lets Tiptap's decoration plugin repaint so
* `getClientRects()` returns the final geometry.
*/
export async function waitForPeersSettled(
page: Page,
opts: {
timeout?: number;
layoutSettleMs?: number;
} = {},
): Promise<void> {
const timeout = opts.timeout ?? 45_000;
const layoutSettleMs = opts.layoutSettleMs ?? 700;
const bobMarker = page
.locator(".ProseMirror [data-type='block-math']")
.first();
const carolMarker = page
.locator(".ProseMirror pre")
.filter({ hasText: /import\s+torch/i })
.first();
try {
await Promise.all([
bobMarker.waitFor({ state: "attached", timeout }),
carolMarker.waitFor({ state: "attached", timeout }),
]);
} catch (err) {
console.warn(
"[sync] waitForPeersSettled timed out:",
err instanceof Error ? err.message : err,
);
}
// Layout settle: even after both markers exist, Tiptap may emit
// one more transaction frame to finalize decorations. Pause to
// let the scroll height stabilise before any rect measurements.
await page.waitForTimeout(layoutSettleMs);
}
/**
* Place this browser's caret at the END of the first <p> directly after
* the heading whose text matches `headingText`. Used so each persona can
* type into its OWN section concurrently without stepping on the others.
*
* Returns false if the heading or following paragraph cannot be found.
*/
export async function focusParagraphAfterHeading(
page: Page,
headingText: string,
): Promise<boolean> {
return await page.evaluate((text) => {
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
if (!root) return false;
const headings = Array.from(root.querySelectorAll("h2"));
const wanted = text.toLowerCase();
const heading = headings.find(
(h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
);
if (!heading) return false;
let para: Element | null = heading.nextElementSibling;
while (para && para.tagName !== "P") para = para.nextElementSibling;
if (!para) return false;
(para as HTMLElement).focus();
const range = document.createRange();
range.selectNodeContents(para);
range.collapse(false);
const sel = window.getSelection();
if (!sel) return false;
sel.removeAllRanges();
sel.addRange(range);
return true;
}, headingText);
}
/**
* Place the caret at the end of the Nth slot following the heading whose
* text matches `headingText`. "Slots" are any non-heading sibling of the
* heading up to the next heading, so a slot index stays stable even if
* its element gets transformed in place (<p> -> <pre> for code fences,
* <p> -> math block, etc.).
*
* Uses synthesized mousedown+mouseup+click events on the target's
* bounding rect - ProseMirror listens to these natively and updates its
* internal selection state. Also sets the DOM Range as a belt-and-braces
* fallback. Single page.evaluate call: no shared CSS marker class to
* race on between concurrent personas.
*/
export async function focusNthParagraphInSection(
page: Page,
headingText: string,
slotIndex: number,
): Promise<boolean> {
// Phase A: locate the slot and compute the click point, but DON'T
// dispatch events yet. We want to animate the visible cursor to the
// target first so the viewer sees the pointer land on the paragraph
// before the caret jumps there.
const rect = await page.evaluate(
({ text, n }) => {
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
if (!root) return null;
const headings = Array.from(root.querySelectorAll("h2"));
const wanted = text.toLowerCase();
const heading = headings.find(
(h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
);
if (!heading) return null;
const slots: HTMLElement[] = [];
let node: Element | null = heading.nextElementSibling;
while (node) {
if (/^H[1-6]$/.test(node.tagName)) break;
slots.push(node as HTMLElement);
node = node.nextElementSibling;
}
const target = slots[n];
if (!target) return null;
target.scrollIntoView({ behavior: "auto", block: "center" });
const r = target.getBoundingClientRect();
return {
x: Math.max(r.left + 1, r.right - 4),
y: r.top + r.height / 2,
};
},
{ text: headingText, n: slotIndex },
);
if (!rect) return false;
// Let the auto-scroll settle before we move onto the target,
// otherwise the humanMove aim is stale.
await pause(120, 220);
await humanMove(page, rect.x, rect.y, { steps: 20 });
// Phase B: now place the caret. Dispatch synthesized mouse events
// at the exact coordinates we just moved to (ProseMirror listens
// for these), and set the DOM Range as a belt-and-braces fallback.
return await page.evaluate(
({ text, n, clickX, clickY }) => {
const g = globalThis as unknown as { __name?: <T>(fn: T) => T };
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
if (!root) return false;
const headings = Array.from(root.querySelectorAll("h2"));
const wanted = text.toLowerCase();
const heading = headings.find(
(h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
);
if (!heading) return false;
const slots: HTMLElement[] = [];
let node: Element | null = heading.nextElementSibling;
while (node) {
if (/^H[1-6]$/.test(node.tagName)) break;
slots.push(node as HTMLElement);
node = node.nextElementSibling;
}
const target = slots[n];
if (!target) return false;
const mk = (type: string) =>
new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window,
clientX: clickX,
clientY: clickY,
button: 0,
buttons: type === "mousedown" ? 1 : 0,
});
target.dispatchEvent(mk("mousedown"));
target.dispatchEvent(mk("mouseup"));
target.dispatchEvent(mk("click"));
const range = document.createRange();
range.selectNodeContents(target);
range.collapse(false);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
return true;
},
{ text: headingText, n: slotIndex, clickX: rect.x, clickY: rect.y },
);
}