/** * 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

"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

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 }, ...]: *

The recipe

*

<- slot 0 *

<- slot 1 *

<- slot 2 *

Intuition

*

<- 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

ready for heading * text. */ async function pickHeading2ViaSlashMenu(page: Page): Promise { // 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 { 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

; each subsequent Enter appends another empty

. // For a non-last section we also need ONE extra trailing

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 { 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 `` insertion or Carol's `

` 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 {
  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 

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 { 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 (

->

 for code fences,
 * 

-> 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 { // 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?: (fn: T) => T }; if (typeof g.__name !== "function") g.__name = (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 }, ); }