tfrere HF Staff commited on
Commit
f469961
·
1 Parent(s): 08e4c7a

feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations

Browse files

Demo orchestration:
- Visible mouse cursor overlay (popover top-layer, MutationObserver to
stay above native dialogs) + humanMove/humanClick/humanHover helpers
- Yjs Node.js clients for Bob and Carol with awareness cursors and a
set of high-level helpers (writeBlockMathAtSlot, setAffiliationUrl,
waitForAffiliationCount, etc.) so peer interactions never collide
- Pre-bold sync barrier: Alice waits for Bob's block math and Carol's
code block to land before doing the bubble-toolbar bold so layout is
frozen during the drag selection (no more cross-paragraph selections)
- Progressive cursor-driven text selection (visible glide + step-wise
setTextSelection) for bold and rephrase actions
- Custom rAF-eased scroll-to-top on the actual .editor-scroll container
before the hue drag so the banner retint reads on camera

Embed Studio:
- New architecture.json companion data file (transformer-block layers)
seeded alongside model-accuracy.csv, so the agent has something
concrete to read and turn into the visualization
- Beefed-up chat scene that mirrors a real round-trip: readEmbed,
listDataFiles, readDataFile (json), readDataFile (csv), then a
detailed createEmbed - the description matches the rendered banner

Neural-network banner:
- Trim every link by node radius + margin so synapses no longer poke
through the ring (clear "links below nodes" layering)
- Hardcoded translucent white strokes with vector-effect:non-scaling-stroke
for visibility on every host surface (preview, editor hero, article)
- Ring + node strokes also non-scaling so they stay crisp at any size

Frontmatter / authors:
- Alice now adds her own huggingface.co profile URL alongside the
Hugging Face affiliation she seeds
- Bob backfills the affiliation's URL to https://huggingface.co and
adds two co-authors (Bob Mercier, Mira Patel) reusing affiliation
index 1, each with their own huggingface.co profile URL - byline
renders three co-authors all under Hugging Face with linkable name
and affiliation chips

Publish flow:
- Monotonic progress bar (useRef-backed, never goes backward)
- Restyled success state with URL chip and full-width "View published
article" button + ExternalLink icon
- Explicit humanClick on the link (after stripping target/rel) so the
navigation reads as a real user action

Agent rewrite extension:
- Fix cancelToken race that left replaced sentences empty: token is
now read from the initial command state, cursor mapped through every
intermediate transaction so the typewriter never bails early
- Atomic "none" animation path for the rephrase, with re-seeded
AgentFocus so the highlight stays on the new text through linger

UI polish:
- Publish dialog: softer shadows, color-mix accents, sparkling note
icon, gradient progress bar with shimmer, success URL chip
- Auto-close chat panel after rephrase action lands

Made-with: Cursor

backend/demo/alice.ts CHANGED
@@ -26,7 +26,6 @@ import {
26
  bubbleToolbarBold,
27
  requestRephraseAction,
28
  } from "./lib/chat-actions.js";
29
- import { clearAgentFocus } from "./lib/selection.js";
30
  import { createNeuralNetworkBanner } from "./lib/embed-studio.js";
31
  import {
32
  claimSlot,
@@ -34,7 +33,7 @@ import {
34
  typeInSlot,
35
  } from "./lib/positions.js";
36
  import { applyHueChange, triggerPublish } from "./lib/publish-flow.js";
37
- import { writeScaffold } from "./lib/scaffolding.js";
38
 
39
  export async function runAlice(page: Page, speed: number) {
40
  console.log("[alice] ready");
@@ -78,6 +77,7 @@ export async function runAlice(page: Page, speed: number) {
78
  // showcases a multi-institution collaboration on the byline
79
  // (MIT, ETH Zurich, Stanford).
80
  await addAuthor(page, "Alice Renoir", speed, {
 
81
  newAffiliation: "Hugging Face",
82
  });
83
  await pause(200, 360);
@@ -96,6 +96,10 @@ export async function runAlice(page: Page, speed: number) {
96
  await writeScaffold(
97
  page,
98
  [
 
 
 
 
99
  { heading: "The recipe", slots: 4 },
100
  { heading: "Intuition", slots: 3 },
101
  { heading: "In Python", slots: 3 },
@@ -130,31 +134,6 @@ export async function runAlice(page: Page, speed: number) {
130
 
131
  await pause(180, 260);
132
 
133
- // --- UI action: Alice selects a phrase in slot 0 and clicks Bold
134
- // from the contextual bubble toolbar. This is the "I highlighted
135
- // text and pressed B" path every end-user recognises - pure
136
- // editor UX, no agent roundtrip. Happens MID-flow so the bolded
137
- // text stays visible for the rest of the demo.
138
- try {
139
- const bolded = await bubbleToolbarBold(
140
- page,
141
- "Imagine each token",
142
- "which other tokens matter right now",
143
- );
144
- if (!bolded) {
145
- console.warn("[alice] bubble-toolbar bold skipped");
146
- } else {
147
- console.log("[alice] bubble-toolbar bold applied");
148
- }
149
- } catch (err) {
150
- console.warn(
151
- "[alice] bubble-toolbar bold aborted:",
152
- err instanceof Error ? err.message : err,
153
- );
154
- }
155
-
156
- await pause(140, 220);
157
-
158
  // Slot 1: short closing line that ties the intuition to the
159
  // mechanism. Intentionally a bit bland so the rephrase agent
160
  // action below has something meaningful to tighten up (viewers
@@ -189,11 +168,27 @@ export async function runAlice(page: Page, speed: number) {
189
  from: rephraseOriginal,
190
  to: rephraseRewrite,
191
  speed,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  });
193
- // The AgentRewrite typewriter takes ~2-3s on a paragraph this
194
- // long; leave room so the hue drag doesn't stomp on it.
195
- await pause(2_200, 2_800);
196
- await clearAgentFocus(page);
197
  } catch (err) {
198
  console.warn(
199
  "[alice] rephrase action aborted:",
@@ -220,24 +215,85 @@ export async function runAlice(page: Page, speed: number) {
220
 
221
  await pause(220, 340);
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  // Before the hue drag, scroll Alice's viewport back to the top of
224
  // the article. The neural-network banner lives at the very top
225
  // (hero section) and its embed reacts to the shared `primaryHue`
226
  // via postMessage - retinting every stroke live. If we stay at
227
  // the bottom of the doc during the slider drag the viewer can't
228
- // see that, so we pay a short scroll beat to plant the camera on
229
- // the banner before the rainbow sweep lands.
 
 
 
 
 
 
 
 
230
  try {
231
- await page.evaluate(() => {
232
- window.scrollTo({ top: 0, behavior: "smooth" });
233
- });
234
- // Smooth-scroll duration is variable (~500-900ms for a full
235
- // page on Chromium). Wait for the scroll to actually settle
236
- // by polling `scrollY === 0` with a short cap, rather than a
237
- // blind sleep.
238
- await page
239
- .waitForFunction(() => window.scrollY < 4, null, { timeout: 2_000 })
240
- .catch(() => undefined);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  } catch (err) {
242
  console.warn(
243
  "[alice] scroll-to-top aborted:",
 
26
  bubbleToolbarBold,
27
  requestRephraseAction,
28
  } from "./lib/chat-actions.js";
 
29
  import { createNeuralNetworkBanner } from "./lib/embed-studio.js";
30
  import {
31
  claimSlot,
 
33
  typeInSlot,
34
  } from "./lib/positions.js";
35
  import { applyHueChange, triggerPublish } from "./lib/publish-flow.js";
36
+ import { waitForPeersSettled, writeScaffold } from "./lib/scaffolding.js";
37
 
38
  export async function runAlice(page: Page, speed: number) {
39
  console.log("[alice] ready");
 
77
  // showcases a multi-institution collaboration on the byline
78
  // (MIT, ETH Zurich, Stanford).
79
  await addAuthor(page, "Alice Renoir", speed, {
80
+ url: "https://huggingface.co/arenoir",
81
  newAffiliation: "Hugging Face",
82
  });
83
  await pause(200, 360);
 
96
  await writeScaffold(
97
  page,
98
  [
99
+ // "The recipe" gets 4 slots: Bob fills the middle one (slot 1)
100
+ // with a <blockMath> atom carrying the canonical attention
101
+ // formula, so the section reads as definition -> formula ->
102
+ // softmax explanation -> multi-head teaser.
103
  { heading: "The recipe", slots: 4 },
104
  { heading: "Intuition", slots: 3 },
105
  { heading: "In Python", slots: 3 },
 
134
 
135
  await pause(180, 260);
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  // Slot 1: short closing line that ties the intuition to the
138
  // mechanism. Intentionally a bit bland so the rephrase agent
139
  // action below has something meaningful to tighten up (viewers
 
168
  from: rephraseOriginal,
169
  to: rephraseRewrite,
170
  speed,
171
+ // Atomic swap + lingering agent label so it reads as an AI
172
+ // edit, not as the user typing. App.tsx re-seeds the focus
173
+ // on the replacement range and clears it after 1400ms.
174
+ animation: "none",
175
+ lingerAgentFocusMs: 1_400,
176
+ });
177
+ // Wait for the swap (520ms App-side delay before dispatch) and
178
+ // the lingering agent-focus label (1400ms) to finish before
179
+ // moving on, so the hue drag doesn't stomp on it.
180
+ await pause(2_000, 2_200);
181
+
182
+ // Close the chat panel now that Alice is done talking to the
183
+ // agent. Keeping it open through the hue drag / publish would
184
+ // look like Alice forgot to dismiss her conversation, and
185
+ // covers part of the editor body during the rest of the demo.
186
+ await page.evaluate(() => {
187
+ window.dispatchEvent(
188
+ new CustomEvent("__demo-chat", { detail: { open: false } }),
189
+ );
190
  });
191
+ await pause(240, 360);
 
 
 
192
  } catch (err) {
193
  console.warn(
194
  "[alice] rephrase action aborted:",
 
215
 
216
  await pause(220, 340);
217
 
218
+ // --- UI action: bubble-toolbar Bold on an earlier phrase in slot 0.
219
+ // This is deliberately the LAST editor edit Alice does before the
220
+ // hue drag / publish: placing it here means Bob and Carol are
221
+ // both guaranteed done (their final contributions - the block
222
+ // math formula and the `import torch` code block - are already
223
+ // in the doc), so the layout is frozen when we measure and
224
+ // drag over the target text. Doing it mid-flow while peers are
225
+ // still inserting content would shift the Intuition paragraph
226
+ // under the measured rects and the selection sweep would pick
227
+ // up the heading above or the math atom below.
228
+ await waitForPeersSettled(page);
229
+ try {
230
+ const bolded = await bubbleToolbarBold(
231
+ page,
232
+ "Imagine each token",
233
+ "which other tokens matter right now",
234
+ );
235
+ if (!bolded) {
236
+ console.warn("[alice] bubble-toolbar bold skipped");
237
+ } else {
238
+ console.log("[alice] bubble-toolbar bold applied");
239
+ }
240
+ } catch (err) {
241
+ console.warn(
242
+ "[alice] bubble-toolbar bold aborted:",
243
+ err instanceof Error ? err.message : err,
244
+ );
245
+ }
246
+
247
+ await pause(200, 320);
248
+
249
  // Before the hue drag, scroll Alice's viewport back to the top of
250
  // the article. The neural-network banner lives at the very top
251
  // (hero section) and its embed reacts to the shared `primaryHue`
252
  // via postMessage - retinting every stroke live. If we stay at
253
  // the bottom of the doc during the slider drag the viewer can't
254
+ // see that, so we pay a scroll beat to plant the camera on the
255
+ // banner before the rainbow sweep lands.
256
+ //
257
+ // We drive the scroll ourselves (rAF + cubic ease-in-out) rather
258
+ // than using the browser's native `behavior: "smooth"`, because
259
+ // that one blasts back to 0 in ~500-700ms and reads as a
260
+ // "teleport" on camera. A hand scroll is ~2s and accelerates
261
+ // into a decelerating finish, which is what this loop emulates:
262
+ // the viewer sees the doc flow upward past the camera, and the
263
+ // hero banner slides down into view with visible inertia.
264
  try {
265
+ await page.evaluate(
266
+ (durationMs) =>
267
+ new Promise<void>((resolve) => {
268
+ // The editor is NOT scrolled on the window - it lives
269
+ // inside a `.editor-scroll` container that owns the
270
+ // overflow. Pick it first, fall back to documentElement
271
+ // if the layout shifts underneath us.
272
+ const container =
273
+ (document.querySelector(".editor-scroll") as HTMLElement | null) ??
274
+ (document.scrollingElement as HTMLElement | null) ??
275
+ document.documentElement;
276
+ const from = container.scrollTop;
277
+ if (from <= 2) {
278
+ resolve();
279
+ return;
280
+ }
281
+ const start = performance.now();
282
+ const easeInOut = (t: number) =>
283
+ t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
284
+ const step = (now: number) => {
285
+ const t = Math.min(1, (now - start) / durationMs);
286
+ container.scrollTop = from * (1 - easeInOut(t));
287
+ if (t < 1) requestAnimationFrame(step);
288
+ else {
289
+ container.scrollTop = 0;
290
+ resolve();
291
+ }
292
+ };
293
+ requestAnimationFrame(step);
294
+ }),
295
+ 2_100,
296
+ );
297
  } catch (err) {
298
  console.warn(
299
  "[alice] scroll-to-top aborted:",
backend/demo/banners.ts CHANGED
@@ -31,8 +31,17 @@ export const NEURAL_NETWORK_BANNER_HTML = `<div class="d3-banner-nn" aria-label=
31
  display: block;
32
  }
33
  .d3-banner-nn .nn-link {
34
- stroke: color-mix(in oklab, var(--text-color, #111) 14%, transparent);
35
- stroke-width: 0.6;
 
 
 
 
 
 
 
 
 
36
  fill: none;
37
  }
38
  .d3-banner-nn .nn-node {
@@ -42,7 +51,8 @@ export const NEURAL_NETWORK_BANNER_HTML = `<div class="d3-banner-nn" aria-label=
42
  .d3-banner-nn .nn-node-ring {
43
  fill: none;
44
  stroke: color-mix(in oklab, var(--primary-color, #4f46e5) 60%, transparent);
45
- stroke-width: 0.8;
 
46
  }
47
  .d3-banner-nn .nn-pulse {
48
  fill: var(--primary-color, #4f46e5);
@@ -95,14 +105,30 @@ export const NEURAL_NETWORK_BANNER_HTML = `<div class="d3-banner-nn" aria-label=
95
  a.forEach((na) => b.forEach((nb) => links.push([na, nb])));
96
  }
97
 
 
 
 
 
 
 
 
 
 
 
 
98
  const gLinks = document.createElementNS(NS, 'g');
99
  links.forEach(([a, b]) => {
 
 
 
 
 
100
  const line = document.createElementNS(NS, 'line');
101
  line.setAttribute('class', 'nn-link');
102
- line.setAttribute('x1', String(a.x));
103
- line.setAttribute('y1', String(a.y));
104
- line.setAttribute('x2', String(b.x));
105
- line.setAttribute('y2', String(b.y));
106
  gLinks.appendChild(line);
107
  });
108
  svg.appendChild(gLinks);
@@ -203,6 +229,35 @@ export const MODEL_ACCURACY_CSV_META = {
203
  rowCount: 8,
204
  };
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  /**
207
  * Inline line chart rendered once Carol asks the agent for a
208
  * visualization. Self-contained (no ColorPalettes lookup to keep the
 
31
  display: block;
32
  }
33
  .d3-banner-nn .nn-link {
34
+ /* Hardcoded translucent white, deliberately decoupled from the
35
+ host theme. The hero surface that sits behind this banner is
36
+ always tinted (primary-colored / dark), regardless of whether
37
+ the editor body itself is in light or dark mode, so a fixed
38
+ light stroke is the most reliable readability target. Binding
39
+ the stroke to --text-color flipped between near-black and
40
+ near-white per theme and vanished whenever the iframe theme
41
+ did not match the host hero (e.g. light editor + dark hero). */
42
+ stroke: rgba(255, 255, 255, 0.4);
43
+ stroke-width: 1;
44
+ vector-effect: non-scaling-stroke;
45
  fill: none;
46
  }
47
  .d3-banner-nn .nn-node {
 
51
  .d3-banner-nn .nn-node-ring {
52
  fill: none;
53
  stroke: color-mix(in oklab, var(--primary-color, #4f46e5) 60%, transparent);
54
+ stroke-width: 1.2;
55
+ vector-effect: non-scaling-stroke;
56
  }
57
  .d3-banner-nn .nn-pulse {
58
  fill: var(--primary-color, #4f46e5);
 
105
  a.forEach((na) => b.forEach((nb) => links.push([na, nb])));
106
  }
107
 
108
+ // Trim each link so its endpoints sit just OUTSIDE the node ring
109
+ // (radius 7 in viewBox units, plus a small margin). This guarantees
110
+ // the lines never poke through the inside of the ring, which used
111
+ // to make them read as if they sat on top of the nodes even though
112
+ // gLinks is appended before gNodes in DOM order. With trimming the
113
+ // visual layering is unambiguous: the ring contains a clean gap on
114
+ // every spoke. We do this analytically rather than relying on a
115
+ // background fill, which would fight with the transparent banner.
116
+ const NODE_R = 7;
117
+ const LINK_GAP = 1.5;
118
+ const trim = NODE_R + LINK_GAP;
119
  const gLinks = document.createElementNS(NS, 'g');
120
  links.forEach(([a, b]) => {
121
+ const dx = b.x - a.x;
122
+ const dy = b.y - a.y;
123
+ const dist = Math.hypot(dx, dy) || 1;
124
+ const nx = dx / dist;
125
+ const ny = dy / dist;
126
  const line = document.createElementNS(NS, 'line');
127
  line.setAttribute('class', 'nn-link');
128
+ line.setAttribute('x1', String(a.x + nx * trim));
129
+ line.setAttribute('y1', String(a.y + ny * trim));
130
+ line.setAttribute('x2', String(b.x - nx * trim));
131
+ line.setAttribute('y2', String(b.y - ny * trim));
132
  gLinks.appendChild(line);
133
  });
134
  svg.appendChild(gLinks);
 
229
  rowCount: 8,
230
  };
231
 
232
+ // ---------------------------------------------------------------------------
233
+ // Companion architecture descriptor for the neural-network banner. Seeded
234
+ // alongside the CSV so the agent has something concrete to "discover" and
235
+ // turn into the visualization. The layer counts here are the SAME ones
236
+ // hardcoded in NEURAL_NETWORK_BANNER_HTML above (6 → 10 → 14 → 10 → 6),
237
+ // which keeps the demo deterministic: the agent reads the JSON, narrates
238
+ // the topology, then drops the matching banner.
239
+ // ---------------------------------------------------------------------------
240
+
241
+ export const ARCHITECTURE_JSON = `{
242
+ "name": "transformer-block",
243
+ "layers": [
244
+ { "name": "tokens", "kind": "input", "units": 6 },
245
+ { "name": "embedding", "kind": "projection","units": 10 },
246
+ { "name": "attention", "kind": "self-attn", "units": 14 },
247
+ { "name": "values", "kind": "projection","units": 10 },
248
+ { "name": "logits", "kind": "output", "units": 6 }
249
+ ],
250
+ "connectivity": "fully-connected",
251
+ "activation": "softmax"
252
+ }`;
253
+
254
+ export const ARCHITECTURE_JSON_META = {
255
+ name: "architecture.json",
256
+ ext: "json",
257
+ columns: ["name", "kind", "units"],
258
+ rowCount: 5,
259
+ };
260
+
261
  /**
262
  * Inline line chart rendered once Carol asks the agent for a
263
  * visualization. Self-contained (no ColorPalettes lookup to keep the
backend/demo/bob.ts CHANGED
@@ -1,199 +1,281 @@
1
  /**
2
- * Bob - headless collaborator for the showcase demo.
 
 
 
 
 
 
3
  *
4
  * Bob owns the "The recipe" section (top of doc). His flow:
5
- * 1. Wait for Alice's first author to land, then append two
6
- * university collaborators (MIT, ETH Zurich).
7
- * 2. Wait for the "The recipe" heading scaffold.
8
- * 3. Type the definition paragraph with inline Q/K/V math.
9
- * 4. Fire the Vaswani 2017 citation tool call through the chat
10
- * (writes entry to citationsMap + inserts a chip + spawns
11
- * the Bibliography section).
12
- * 5. Drop the block-math formula.
13
- * 6. Close with a softmax explanation line.
 
 
 
 
 
 
14
  */
15
- import type { Page } from "playwright";
16
- import { pause } from "./human-typing.js";
17
- import { addAuthor } from "./lib/editor-actions.js";
18
- import { requestCitationAction } from "./lib/chat-actions.js";
19
  import {
20
- claimSlot,
21
- installPositionTracker,
22
- typeInSlot,
23
- } from "./lib/positions.js";
24
- import { waitForHeading2 } from "./lib/scaffolding.js";
25
-
26
- export async function runBob(page: Page, speed: number) {
27
- console.log("[bob] joined, waiting for Alice's first author");
28
- await installPositionTracker(page);
29
- await pause(800, 1_200);
30
-
31
- // 1) Authors: Alice seeds the byline first (principal agent), then
32
- // Bob appends two collaborators from big universities. We gate
33
- // on the "+" icon button appearing, which only renders after
34
- // the authors list has at least one entry - a reliable Yjs-
35
- // synced signal that Alice's insertion has landed on Bob's
36
- // page. This avoids the old brittle "Bob creates all three"
37
- // flow and shows the byline growing collaboratively, with
38
- // varied affiliations (MIT, ETH Zurich) that make the article
39
- // feel like a multi-lab paper.
40
- try {
41
- await page
42
- .locator('button.icon-btn[aria-label="Add author"]')
43
- .first()
44
- .waitFor({ state: "visible", timeout: 30_000 });
45
- } catch {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  console.warn("[bob] Alice's first author never appeared, proceeding");
47
  }
48
- await pause(250, 500);
 
 
 
 
 
 
 
49
 
50
- await addAuthor(page, "Bob Mercier", speed, { newAffiliation: "MIT" });
51
- await pause(200, 380);
52
- await addAuthor(page, "Mira Patel", speed, {
53
- newAffiliation: "ETH Zurich",
 
 
 
 
 
 
54
  });
55
- console.log("[bob] authors added");
56
-
57
- // 2) Wait for Alice's scaffold to ship the "The recipe" heading.
58
- // Bob OWNS this top section (swap from the old layout where
59
- // Alice wrote it): he types the definition with inline math,
60
- // the block-math formula, the softmax explanation, AND an
61
- // italic agent action - giving him enough content to keep his
62
- // cursor visibly typing through most of the 25s budget, in
63
- // concert with Alice (middle) and Carol (bottom).
64
- try {
65
- await waitForHeading2(page, "The recipe", 60_000);
66
- } catch {
67
- console.warn("[bob] 'The recipe' heading not found, skipping body");
 
68
  return;
69
  }
70
- await pause(120, 240);
71
 
72
- const bobSpeed = speed * 1.8;
73
- const bobIntro = { speed: bobSpeed, typoRate: 0, thinkRate: 0.02 };
74
- const bobMath = { speed: bobSpeed, typoRate: 0, thinkRate: 0 };
75
-
76
- // Slot 0: definition with queries / keys / values inline math.
77
- const recipeSlot0 = await claimSlot(page, "The recipe", 0);
78
- if (!recipeSlot0) {
79
- console.warn("[bob] could not focus slot 0 of 'The recipe'");
 
80
  return;
81
  }
82
- await pause(60, 120);
83
- await typeInSlot(
84
- page,
85
- recipeSlot0,
 
 
86
  "Attention lets a token look at every other token, scoring each pair through queries ",
87
- bobIntro,
 
 
88
  );
89
- await typeInSlot(page, recipeSlot0, "$$Q$$", bobMath);
90
- await typeInSlot(page, recipeSlot0, ", keys ", bobMath);
91
- await typeInSlot(page, recipeSlot0, "$$K$$", bobMath);
92
- await typeInSlot(page, recipeSlot0, " and values ", bobMath);
93
- await typeInSlot(page, recipeSlot0, "$$V$$", bobMath);
94
- await typeInSlot(page, recipeSlot0, ".", bobMath);
95
-
96
- await pause(160, 240);
97
-
98
- // Mid-cycle agent action: Bob asks his own chat to add a proper
99
- // bibliographic citation of the Vaswani 2017 paper right after
100
- // his definition sentence. The chat dispatches a `citation`
101
- // tool call: the entry is written to the shared `citationsMap`,
102
- // a citation node lands at the caret (currently at the end of
103
- // slot 0), and the Bibliography section materializes at the
104
- // bottom of the doc the first time. Shows off the real academic-
105
- // writing workflow on camera (chip renders as `(Vaswani et al.,
106
- // 2017)` in APA).
107
- try {
108
- await pause(220, 340);
109
- await requestCitationAction(page, {
110
- prompt: "Cite the original Vaswani 2017 paper here.",
111
- reply: "Added [Vaswani et al., 2017] and the bibliography section.",
112
- paragraphSnippet: "Attention lets a token",
113
- anchor: "end",
114
- key: "vaswani2017attention",
115
- entry: {
116
- id: "vaswani2017attention",
117
- "citation-key": "vaswani2017attention",
118
- type: "article-journal",
119
- title: "Attention Is All You Need",
120
- author: [
121
- { family: "Vaswani", given: "Ashish" },
122
- { family: "Shazeer", given: "Noam" },
123
- { family: "Parmar", given: "Niki" },
124
- { family: "Uszkoreit", given: "Jakob" },
125
- { family: "Jones", given: "Llion" },
126
- { family: "Gomez", given: "Aidan N." },
127
- { family: "Kaiser", given: "Lukasz" },
128
- { family: "Polosukhin", given: "Illia" },
129
- ],
130
- issued: { "date-parts": [[2017]] },
131
- "container-title": "Advances in Neural Information Processing Systems",
132
- volume: "30",
133
- URL: "https://arxiv.org/abs/1706.03762",
134
- DOI: "10.48550/arXiv.1706.03762",
135
- },
136
- speed,
137
- });
138
- await pause(260, 420);
139
- } catch (err) {
140
- console.warn(
141
- "[bob] citation action aborted:",
142
- err instanceof Error ? err.message : err,
143
- );
144
- }
145
 
146
- await pause(160, 240);
147
-
148
- // Slot 1: canonical block-math formula. The $$$...$$$ input rule
149
- // converts the empty <p> into a math node in place; the slot
150
- // position tracks that conversion through the Y.js mapping.
151
- const recipeSlot1 = await claimSlot(page, "The recipe", 1);
152
- if (recipeSlot1) {
153
- await pause(60, 120);
154
- await typeInSlot(
155
- page,
156
- recipeSlot1,
157
- "$$$\\text{Attention}(Q, K, V) = \\text{softmax}\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right)V$$$",
158
- bobMath,
159
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  }
 
161
 
162
- await pause(140, 240);
163
-
164
- // Slot 2: softmax explanation. Short, punchy, ties the formula
165
- // back to the definition.
166
- const recipeSlot2 = await claimSlot(page, "The recipe", 2);
167
- if (recipeSlot2) {
168
- await pause(60, 120);
169
- await typeInSlot(
170
- page,
171
- recipeSlot2,
172
- "The softmax turns those scores into weights, and the weighted sum over values produces one contextual vector per token.",
173
- { speed: speed * 2.2, typoRate: 0, thinkRate: 0.01 },
174
  );
 
 
175
  }
176
 
177
- await pause(140, 220);
178
-
179
- // Slot 3: multi-head teaser. Closes the section on a forward
180
- // pointer to the next chapter (multi-head attention = same
181
- // operation run in parallel with independent projections). Keeps
182
- // Bob visibly typing during Alice's rephrase + hue drag phase and
183
- // gives "The recipe" a proper four-paragraph body instead of
184
- // stopping right after the formula.
185
- const recipeSlot3 = await claimSlot(page, "The recipe", 3);
186
- if (recipeSlot3) {
187
- await pause(60, 120);
188
- await typeInSlot(
189
- page,
190
- recipeSlot3,
191
  "In practice the same operation runs in parallel through several independent projections, one per head, and the outputs are concatenated - that is multi-head attention.",
192
- { speed: speed * 2.2, typoRate: 0, thinkRate: 0.01 },
 
 
193
  );
 
 
194
  }
195
 
196
- await pause(180, 300);
197
-
 
 
198
  console.log("[bob] scenario complete");
199
  }
 
1
  /**
2
+ * Bob - Yjs-only collaborator for the showcase demo.
3
+ *
4
+ * Bob is NOT a browser. He's a Node process that speaks the same
5
+ * Yjs CRDT protocol as the editor tabs and manipulates the shared
6
+ * document directly via Y.XmlElement / Y.XmlText. Every write shows
7
+ * up in Alice's Tiptap editor in real time without any DOM typing,
8
+ * input-rule surprises, or PM position races.
9
  *
10
  * Bob owns the "The recipe" section (top of doc). His flow:
11
+ * 1. Wait for Alice's first author to land, then push two
12
+ * university co-authors (MIT, ETH Zurich).
13
+ * 2. Wait for the "The recipe" H2 to appear in the shared
14
+ * fragment (Alice writes the scaffold from her browser).
15
+ * 3. Fill slot 0 with the definition paragraph + inline Q/K/V
16
+ * math atoms.
17
+ * 4. Register the Vaswani 2017 paper in the shared citations
18
+ * map, append a citation chip at the end of slot 0, and
19
+ * spawn a Bibliography block at the bottom of the doc if
20
+ * none exists.
21
+ * 5. Drop the canonical attention formula as a `<blockMath>`
22
+ * atom into slot 1. That's a different agent (not Alice)
23
+ * contributing a display-math equation to the article.
24
+ * 6. Fill slot 2 with the softmax explanation.
25
+ * 7. Fill slot 3 with the multi-head teaser.
26
  */
27
+ import * as Y from "yjs";
 
 
 
28
  import {
29
+ addAuthorRecord,
30
+ appendInline,
31
+ broadcastCursorAtEnd,
32
+ claimSlotAfterHeading,
33
+ clearAwarenessCursor,
34
+ findHeadingIndex,
35
+ insertInlineNode,
36
+ registerCitation,
37
+ setAffiliationUrl,
38
+ sleep,
39
+ typeIntoParagraph,
40
+ waitForAffiliationCount,
41
+ waitForAuthorCount,
42
+ waitForCondition,
43
+ writeBlockMathAtSlot,
44
+ type PersonaHandle,
45
+ } from "./lib/yjs-client.js";
46
+
47
+ function jitter(minMs: number, maxMs: number): number {
48
+ return minMs + Math.random() * (maxMs - minMs);
49
+ }
50
+
51
+ /**
52
+ * Ensure a `<bibliography />` atom exists at the end of the shared
53
+ * fragment. If one is already there (e.g. from a previous run that
54
+ * wasn't reset) we keep it; otherwise we push a fresh, empty one
55
+ * and let `BibliographyView` on Alice's side fill `renderedHtml`
56
+ * via the formatting endpoint.
57
+ */
58
+ function ensureBibliographySection(handle: PersonaHandle): void {
59
+ const existing = handle.fragment
60
+ .toArray()
61
+ .find((c) => c instanceof Y.XmlElement && c.nodeName === "bibliography");
62
+ if (existing) return;
63
+
64
+ handle.ydoc.transact(() => {
65
+ const bib = new Y.XmlElement("bibliography");
66
+ handle.fragment.push([bib]);
67
+ bib.setAttribute("renderedHtml", "");
68
+ // Tiptap doesn't require an explicit trailing paragraph after
69
+ // the bibliography, but the original doc shape has one so the
70
+ // cursor always has a landing spot past the biblio in Alice's
71
+ // editor.
72
+ const trailing = new Y.XmlElement("paragraph");
73
+ handle.fragment.push([trailing]);
74
+ });
75
+ }
76
+
77
+ export async function runBob(
78
+ bob: PersonaHandle,
79
+ speed: number,
80
+ ): Promise<void> {
81
+ console.log("[bob] connected as Yjs peer, waiting for Alice's first author");
82
+
83
+ // Give Alice a beat to start typing the title before we pile on
84
+ // the author list; looks more natural on camera.
85
+ await sleep(jitter(800, 1200));
86
+
87
+ // 1) Authors -----------------------------------------------------
88
+ // Alice seeds the byline with herself + the "Hugging Face"
89
+ // affiliation (index 1) but the modal she drives doesn't expose a
90
+ // URL field for the affiliation. Bob then:
91
+ // - waits for that affiliation to land in the shared Y.Array,
92
+ // - patches its `url` to https://huggingface.co so the byline
93
+ // renders the affiliation as a real link,
94
+ // - appends two more authors that REUSE affiliation index 1
95
+ // instead of creating fresh institutions, each with a personal
96
+ // huggingface.co profile URL.
97
+ // Net effect on the byline: three co-authors (Alice + Bob's two)
98
+ // all under the same Hugging Face affiliation, with both the
99
+ // affiliation chip and each author name linkable.
100
+ const seeded = await waitForAuthorCount(bob, 1, 30_000);
101
+ if (!seeded) {
102
  console.warn("[bob] Alice's first author never appeared, proceeding");
103
  }
104
+ const affSeeded = await waitForAffiliationCount(bob, 1, 30_000);
105
+ if (!affSeeded) {
106
+ console.warn("[bob] Hugging Face affiliation never appeared, proceeding");
107
+ }
108
+ await sleep(jitter(250, 500));
109
+
110
+ setAffiliationUrl(bob, 1, "https://huggingface.co");
111
+ await sleep(jitter(180, 320));
112
 
113
+ addAuthorRecord(bob, {
114
+ name: "Bob Mercier",
115
+ url: "https://huggingface.co/bmercier",
116
+ existingAffiliationIndices: [1],
117
+ });
118
+ await sleep(jitter(300, 550));
119
+ addAuthorRecord(bob, {
120
+ name: "Mira Patel",
121
+ url: "https://huggingface.co/mpatel",
122
+ existingAffiliationIndices: [1],
123
  });
124
+ console.log("[bob] authors added (both at Hugging Face)");
125
+
126
+ // 2) Wait for scaffold ------------------------------------------
127
+ // Alice's Playwright scaffold emits the H2 headings + empty
128
+ // paragraph slots. We gate on the "The recipe" heading showing
129
+ // up in our synced fragment before we start writing body text
130
+ // so `claimSlotAfterHeading` finds real anchors.
131
+ const hasHeading = await waitForCondition(
132
+ bob.fragment,
133
+ () => findHeadingIndex(bob.fragment, "The recipe") !== -1,
134
+ 60_000,
135
+ );
136
+ if (!hasHeading) {
137
+ console.warn("[bob] 'The recipe' heading never arrived, skipping body");
138
  return;
139
  }
140
+ await sleep(jitter(150, 300));
141
 
142
+ // 3) Slot 0 - definition + inline math --------------------------
143
+ // We type the prose into the paragraph via `typeIntoParagraph`
144
+ // (chunked Y.XmlText inserts with human cadence so Alice sees
145
+ // text stream in), then splice in Y.XmlElement("inlineMath")
146
+ // atoms for Q, K, V. Each insert is a single atomic CRDT op,
147
+ // no input-rule interpretation involved.
148
+ const slot0 = claimSlotAfterHeading(bob.fragment, "The recipe", 0);
149
+ if (!slot0) {
150
+ console.warn("[bob] could not locate slot 0 of 'The recipe'");
151
  return;
152
  }
153
+ await sleep(jitter(80, 160));
154
+
155
+ const bodyCps = 38 / Math.max(0.5, speed / 2);
156
+ const fastCps = 48 / Math.max(0.5, speed / 2);
157
+ await typeIntoParagraph(
158
+ slot0,
159
  "Attention lets a token look at every other token, scoring each pair through queries ",
160
+ { cps: bodyCps, chunkSize: 2 },
161
+ undefined,
162
+ bob,
163
  );
164
+ await insertInlineNode(slot0, { type: "inlineMath", latex: "Q" }, 200, bob);
165
+ await typeIntoParagraph(slot0, ", keys ", { cps: bodyCps }, undefined, bob);
166
+ await insertInlineNode(slot0, { type: "inlineMath", latex: "K" }, 160, bob);
167
+ await typeIntoParagraph(
168
+ slot0,
169
+ " and values ",
170
+ { cps: bodyCps },
171
+ undefined,
172
+ bob,
173
+ );
174
+ await insertInlineNode(slot0, { type: "inlineMath", latex: "V" }, 160, bob);
175
+ await typeIntoParagraph(slot0, ".", { cps: bodyCps }, undefined, bob);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
+ await sleep(jitter(240, 400));
178
+
179
+ // 4) Citation + bibliography -----------------------------------
180
+ // Register the entry in the shared Y.Map("citations"). The
181
+ // frontend's citation NodeView listens on that map, so any chip
182
+ // with matching `key` auto-resolves its label from the entry.
183
+ //
184
+ // We still write the `label` attr ourselves so the chip renders
185
+ // "(Vaswani et al., 2017)" as soon as it lands even if the
186
+ // NodeView observer hasn't fired yet (APA-ish placeholder).
187
+ const citationKey = "vaswani2017attention";
188
+ registerCitation(bob, citationKey, {
189
+ id: citationKey,
190
+ "citation-key": citationKey,
191
+ type: "article-journal",
192
+ title: "Attention Is All You Need",
193
+ author: [
194
+ { family: "Vaswani", given: "Ashish" },
195
+ { family: "Shazeer", given: "Noam" },
196
+ { family: "Parmar", given: "Niki" },
197
+ { family: "Uszkoreit", given: "Jakob" },
198
+ { family: "Jones", given: "Llion" },
199
+ { family: "Gomez", given: "Aidan N." },
200
+ { family: "Kaiser", given: "Lukasz" },
201
+ { family: "Polosukhin", given: "Illia" },
202
+ ],
203
+ issued: { "date-parts": [[2017]] },
204
+ "container-title": "Advances in Neural Information Processing Systems",
205
+ volume: "30",
206
+ URL: "https://arxiv.org/abs/1706.03762",
207
+ DOI: "10.48550/arXiv.1706.03762",
208
+ });
209
+ appendInline(slot0, [
210
+ {
211
+ type: "citation",
212
+ key: citationKey,
213
+ label: "(Vaswani et al., 2017)",
214
+ },
215
+ ]);
216
+ broadcastCursorAtEnd(bob, slot0);
217
+ ensureBibliographySection(bob);
218
+ console.log("[bob] citation + bibliography inserted");
219
+
220
+ await sleep(jitter(320, 520));
221
+
222
+ // 5) Slot 1 - block math (the attention formula) --------------
223
+ // Atomic CRDT replace: the empty scaffold paragraph is swapped
224
+ // for a `<blockMath>` atom carrying the canonical scaled-dot-
225
+ // product-attention formula. Alice sees the KaTeX render pop
226
+ // in the moment the sync hits her editor - crisp "another
227
+ // agent dropped an equation" moment on camera. Short beat
228
+ // before and after so the viewer registers the transition
229
+ // from prose to display math.
230
+ await sleep(jitter(220, 380));
231
+ const mathNode = writeBlockMathAtSlot(
232
+ bob.fragment,
233
+ "The recipe",
234
+ 1,
235
+ "\\text{Attention}(Q, K, V) = \\text{softmax}\\!\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right) V",
236
+ );
237
+ if (mathNode) {
238
+ console.log("[bob] block math equation inserted");
239
+ } else {
240
+ console.warn("[bob] could not insert block math");
241
  }
242
+ await sleep(jitter(320, 500));
243
 
244
+ // 6) Slot 2 - softmax explanation ------------------------------
245
+ const slot2 = claimSlotAfterHeading(bob.fragment, "The recipe", 2);
246
+ if (slot2) {
247
+ await sleep(jitter(80, 150));
248
+ await typeIntoParagraph(
249
+ slot2,
250
+ "The softmax turns those similarity scores into weights, and the weighted sum over values produces one contextual vector per token.",
251
+ { cps: fastCps, chunkSize: 2 },
252
+ undefined,
253
+ bob,
 
 
254
  );
255
+ } else {
256
+ console.warn("[bob] could not locate slot 2");
257
  }
258
 
259
+ await sleep(jitter(200, 320));
260
+
261
+ // 7) Slot 3 - multi-head teaser --------------------------------
262
+ const slot3 = claimSlotAfterHeading(bob.fragment, "The recipe", 3);
263
+ if (slot3) {
264
+ await sleep(jitter(80, 150));
265
+ await typeIntoParagraph(
266
+ slot3,
 
 
 
 
 
 
267
  "In practice the same operation runs in parallel through several independent projections, one per head, and the outputs are concatenated - that is multi-head attention.",
268
+ { cps: fastCps, chunkSize: 2 },
269
+ undefined,
270
+ bob,
271
  );
272
+ } else {
273
+ console.warn("[bob] could not locate slot 3");
274
  }
275
 
276
+ await sleep(jitter(200, 320));
277
+ // Clear Bob's caret so it doesn't linger past the last paragraph
278
+ // he wrote; Alice gets a clean frame for the hue drag / publish.
279
+ clearAwarenessCursor(bob);
280
  console.log("[bob] scenario complete");
281
  }
backend/demo/carol.ts CHANGED
@@ -1,145 +1,252 @@
1
  /**
2
- * Carol - headless collaborator for the showcase demo.
3
  *
4
- * Carol owns the "In Python" section (bottom of doc). Her flow:
5
- * 1. Wait for Alice's first author, then append a Stanford
6
- * co-author.
7
- * 2. Wait for the "In Python" heading scaffold.
8
- * 3. Type the narrated intro line in slot 0.
9
- * 4. Fire a link action through the chat on "PyTorch" -
10
- * pointing at the concrete `scaled_dot_product_attention`
11
- * docs. This is the counterpart to Bob's citation at the
12
- * top of the article.
13
- * 5. Drop an 8-line PyTorch code block in slot 1 via the
14
- * ```python input rule.
15
- * 6. Close with a Vaswani reference line in slot 2.
 
 
16
  */
17
- import type { Page } from "playwright";
18
- import { pause } from "./human-typing.js";
19
- import { addAuthor, insertCodeBlock } from "./lib/editor-actions.js";
20
- import { requestLinkAction } from "./lib/chat-actions.js";
21
- import {
22
- clearAgentFocus,
23
- selectSubstringInParagraph,
24
- } from "./lib/selection.js";
25
- import {
26
- claimSlot,
27
- installPositionTracker,
28
- typeInSlot,
29
- } from "./lib/positions.js";
30
  import {
31
- focusNthParagraphInSection,
32
- waitForHeading2,
33
- } from "./lib/scaffolding.js";
34
-
35
- export async function runCarol(page: Page, speed: number) {
36
- console.log("[carol] joined, will add a Stanford co-author then code");
37
- await installPositionTracker(page);
38
- await pause(1_200, 1_800);
39
-
40
- // Authors: like Bob, Carol waits for Alice's first author to be
41
- // synced (signalled by the "+" icon button replacing the empty
42
- // placeholder), then appends herself with a third distinct
43
- // affiliation. Running this here (not after the heading wait)
44
- // means the byline is fully populated before the body is written,
45
- // and the three modals openings are staggered across Alice / Bob /
46
- // Carol for a natural collaborative rhythm on camera.
47
- try {
48
- await page
49
- .locator('button.icon-btn[aria-label="Add author"]')
50
- .first()
51
- .waitFor({ state: "visible", timeout: 30_000 });
52
- } catch {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  console.warn("[carol] Alice's first author never appeared, proceeding");
54
  }
55
- await pause(400, 700);
56
 
57
- await addAuthor(page, "Carol Dubois", speed, {
58
- newAffiliation: "Stanford University",
 
59
  });
60
  console.log("[carol] author added");
61
 
62
- // Wait for Alice's scaffold to ship the "In Python" heading (and
63
- // its three pre-allocated slots), then fill slots 0 / 1 / 2 in
64
- // sequence. Carol never presses Enter during the parallel phase:
65
- // jumping between pre-allocated slots via
66
- // focusNthParagraphInSection is what avoids the previous overlap
67
- // bug where her ```python code fence would occasionally absorb
68
- // stray text from Bob or Alice mid-typing.
69
- try {
70
- await waitForHeading2(page, "In Python", 60_000);
71
- } catch {
72
- console.warn("[carol] 'In Python' heading not found, skipping section");
73
  return;
74
  }
75
- await pause(150, 300);
 
 
76
 
77
- // Slot 0: narrated intro line. Anchored so Alice/Bob typing in
78
- // parallel can't push Carol's caret out of her own paragraph.
79
- const pythonSlot0 = await claimSlot(page, "In Python", 0);
80
- if (!pythonSlot0) {
81
- console.warn("[carol] could not focus slot 0 of 'In Python'");
82
  return;
83
  }
84
- await pause(80, 160);
85
- await typeInSlot(
86
- page,
87
- pythonSlot0,
88
  "The whole recipe fits in eight lines of PyTorch:",
89
- { speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
 
 
90
  );
 
91
 
92
- await pause(160, 240);
93
-
94
- // Mid-cycle agent action: Carol points at the concrete PyTorch
95
- // reference BEFORE she even writes the code, so when the snippet
96
- // lands under the paragraph it already feels grounded. The link
97
- // goes to PyTorch's own scaled_dot_product_attention docs (the
98
- // fused, production-grade implementation of what the snippet
99
- // below does by hand). Bottom-of-article link, distinct spot and
100
- // distinct target from Bob's top-of-article arXiv citation.
101
- try {
102
- const linkRange = await selectSubstringInParagraph(
103
- page,
104
- "The whole recipe",
105
- "PyTorch",
106
- );
107
- if (linkRange) {
108
- await pause(220, 360);
109
- await requestLinkAction(page, {
110
- prompt: "Link to the PyTorch attention docs.",
111
- reply:
112
- "Pointed at torch.nn.functional.scaled_dot_product_attention.",
113
- url:
114
- "https://pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention.html",
115
- speed,
116
- paragraphSnippet: "The whole recipe",
117
- substring: "PyTorch",
118
- });
119
- await pause(260, 420);
120
- await clearAgentFocus(page);
121
- }
122
- } catch (err) {
123
- console.warn(
124
- "[carol] link action aborted:",
125
- err instanceof Error ? err.message : err,
126
- );
127
  }
128
 
129
- await pause(120, 200);
130
-
131
- // Slot 1: the code block. We DON'T anchor here - the ```python
132
- // input rule transforms the <p> into a <pre><code> node, and any
133
- // MappablePosition created against the old empty paragraph stops
134
- // being meaningful. We just focus the slot and let PM manage
135
- // selection internally while Carol fills the code.
136
- const codeFocused = await focusNthParagraphInSection(page, "In Python", 1);
137
- if (!codeFocused) {
138
- console.warn("[carol] could not focus slot 1 of 'In Python'");
139
- return;
140
- }
141
- await pause(80, 160);
142
 
 
 
 
 
 
 
143
  const code =
144
  "import torch\n" +
145
  "import torch.nn.functional as F\n" +
@@ -149,26 +256,36 @@ export async function runCarol(page: Page, speed: number) {
149
  " scores = Q @ K.transpose(-2, -1) / d_k ** 0.5\n" +
150
  " weights = F.softmax(scores, dim=-1)\n" +
151
  " return weights @ V";
152
- await insertCodeBlock(page, "python", code, speed);
153
-
154
- // Slot 2: closing reference paragraph AFTER the code block.
155
- // Claimed fresh so we get a clean MappablePosition past the code
156
- // node.
157
- const pythonSlot2 = await claimSlot(page, "In Python", 2);
158
- if (!pythonSlot2) {
159
- console.warn("[carol] could not focus slot 2 of 'In Python'");
160
- console.log("[carol] scenario complete (partial)");
161
- return;
 
162
  }
163
- await pause(80, 160);
164
- await typeInSlot(
165
- page,
166
- pythonSlot2,
167
- "Every modern LLM you have used runs some flavor of this snippet - fused, batched and tiled on the GPU, but conceptually the same eight lines.",
168
- { speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
169
- );
170
 
171
- await pause(180, 280);
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
 
 
173
  console.log("[carol] scenario complete");
174
  }
 
1
  /**
2
+ * Carol - Yjs-only collaborator for the showcase demo.
3
  *
4
+ * Carol is a Node-side Yjs peer (no browser). She owns the "In
5
+ * Python" section at the bottom of the doc.
6
+ *
7
+ * Flow:
8
+ * 1. Wait for Alice's first author to land, then append herself
9
+ * with a Stanford affiliation.
10
+ * 2. Wait for the "In Python" H2 to appear in the shared
11
+ * fragment (Alice writes the scaffold).
12
+ * 3. Fill slot 0 with a narrated intro line and splice a link
13
+ * mark directly onto the word "PyTorch" so it points at the
14
+ * `scaled_dot_product_attention` docs.
15
+ * 4. Replace slot 1 with a `<codeBlock language="python">` node
16
+ * carrying the 8-line reference implementation.
17
+ * 5. Fill slot 2 with the closing paragraph.
18
  */
19
+ import * as Y from "yjs";
 
 
 
 
 
 
 
 
 
 
 
 
20
  import {
21
+ addAuthorRecord,
22
+ broadcastCursorAtEnd,
23
+ buildEmptyParagraph,
24
+ claimSlotAfterHeading,
25
+ clearAwarenessCursor,
26
+ findHeadingIndex,
27
+ setAwarenessCursor,
28
+ sleep,
29
+ typeIntoParagraph,
30
+ waitForAuthorCount,
31
+ waitForCondition,
32
+ type PersonaHandle,
33
+ } from "./lib/yjs-client.js";
34
+
35
+ function jitter(minMs: number, maxMs: number): number {
36
+ return minMs + Math.random() * (maxMs - minMs);
37
+ }
38
+
39
+ /**
40
+ * Apply a `link` mark to the FIRST occurrence of `substring` inside
41
+ * a paragraph's Y.XmlText children. We iterate children in document
42
+ * order and, when we find the substring (possibly straddling two
43
+ * adjacent text runs), re-emit it with the link attributes instead
44
+ * of trying to mutate a mark in place (Y.XmlText's format API is
45
+ * append-only friendly; retrofitting a mark mid-string is what
46
+ * caused the old Playwright link flow to drift the caret).
47
+ */
48
+ function linkifyInParagraph(
49
+ paragraph: Y.XmlElement,
50
+ substring: string,
51
+ href: string,
52
+ ): boolean {
53
+ const children = paragraph.toArray();
54
+ for (let i = 0; i < children.length; i++) {
55
+ const child = children[i];
56
+ if (!(child instanceof Y.XmlText)) continue;
57
+ const text = child.toString();
58
+ const idx = text.indexOf(substring);
59
+ if (idx === -1) continue;
60
+
61
+ // Slice the run into [before, linked, after] and re-emit the
62
+ // middle with link attributes. `delta`-level insertion lets us
63
+ // mint all three in a single Yjs op so the formatter doesn't
64
+ // see intermediate half-linked states.
65
+ const before = text.slice(0, idx);
66
+ const after = text.slice(idx + substring.length);
67
+ paragraph.doc?.transact(() => {
68
+ child.delete(0, text.length);
69
+ if (before) child.insert(0, before);
70
+ child.insert(before.length, substring, {
71
+ link: {
72
+ href,
73
+ target: "_blank",
74
+ rel: "noopener noreferrer nofollow",
75
+ class: "editor-link",
76
+ title: null,
77
+ },
78
+ });
79
+ if (after) child.insert(before.length + substring.length, after);
80
+ });
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * Replace the paragraph at fragment index `slotIndex` within a
88
+ * section with an empty codeBlock node, ready for streaming inserts.
89
+ * Returns the Y.XmlText child so the caller can type into it at
90
+ * whatever cadence they want.
91
+ *
92
+ * Splitting the "create codeBlock shell" from "type code into it"
93
+ * lets the viewer actually SEE the code materialize line by line
94
+ * instead of popping in atomically, which is what made the old
95
+ * `writeCodeBlock` look unnatural on camera.
96
+ */
97
+ function openCodeBlock(
98
+ frag: Y.XmlFragment,
99
+ headingText: string,
100
+ slotIndex: number,
101
+ language: string,
102
+ ): Y.XmlText | null {
103
+ const headingIdx = findHeadingIndex(frag, headingText);
104
+ if (headingIdx === -1) return null;
105
+
106
+ // Walk from the heading forward, collecting slot positions.
107
+ const children = frag.toArray();
108
+ const slots: number[] = [];
109
+ for (let i = headingIdx + 1; i < children.length; i++) {
110
+ const c = children[i];
111
+ if (!(c instanceof Y.XmlElement)) continue;
112
+ if (c.nodeName === "heading") break;
113
+ slots.push(i);
114
+ }
115
+ const targetIdx =
116
+ slots[slotIndex] ??
117
+ (slots.length ? slots[slots.length - 1] + 1 : headingIdx + 1);
118
+
119
+ const block = new Y.XmlElement("codeBlock");
120
+ let txt: Y.XmlText | null = null;
121
+
122
+ frag.doc?.transact(() => {
123
+ if (slots[slotIndex] !== undefined) {
124
+ frag.delete(targetIdx, 1);
125
+ frag.insert(targetIdx, [block]);
126
+ } else {
127
+ const pad = slotIndex - slots.length;
128
+ const filler: Y.XmlElement[] = [];
129
+ for (let k = 0; k < pad; k++) filler.push(buildEmptyParagraph());
130
+ filler.push(block);
131
+ frag.insert(targetIdx, filler);
132
+ }
133
+ block.setAttribute("language", language);
134
+ txt = new Y.XmlText();
135
+ block.push([txt]);
136
+ });
137
+ return txt;
138
+ }
139
+
140
+ /**
141
+ * Stream `code` into a codeBlock's Y.XmlText at a human pace. Each
142
+ * chunk is a real Yjs insert with an awareness cursor ping (via
143
+ * `setAwarenessCursor`), so Alice sees Carol's caret walk down the
144
+ * source file as it fills in - the same look-and-feel as typing
145
+ * into a paragraph.
146
+ *
147
+ * Characters per second is tuned per language:
148
+ * - prose : 24-38 cps (fingers typing English)
149
+ * - code : 60-90 cps (humans "think in tokens" for code and
150
+ * paste / autocomplete reduces effective keystrokes)
151
+ * so the code fills fast enough to stay on-camera without looking
152
+ * like the block popped in instantly.
153
+ */
154
+ async function typeIntoCodeBlock(
155
+ handle: PersonaHandle,
156
+ txt: Y.XmlText,
157
+ code: string,
158
+ options: { cps?: number; chunkSize?: number; jitter?: number } = {},
159
+ ): Promise<void> {
160
+ const cps = options.cps ?? 80;
161
+ const jitter = options.jitter ?? 0.3;
162
+ const chunkSize = Math.max(1, options.chunkSize ?? 3);
163
+ const baseDelay = 1000 / cps;
164
+
165
+ let i = 0;
166
+ while (i < code.length) {
167
+ const take = Math.min(chunkSize, code.length - i);
168
+ const chunk = code.slice(i, i + take);
169
+ txt.insert(txt.length, chunk);
170
+ setAwarenessCursor(handle, txt, txt.length);
171
+ i += take;
172
+ const jitterMul = 1 + (Math.random() * 2 - 1) * jitter;
173
+ await sleep(Math.max(3, baseDelay * jitterMul));
174
+ }
175
+ }
176
+
177
+ export async function runCarol(
178
+ carol: PersonaHandle,
179
+ speed: number,
180
+ ): Promise<void> {
181
+ console.log("[carol] connected as Yjs peer, waiting for Alice's first author");
182
+
183
+ // Slight offset vs Bob's join so the two author pushes don't
184
+ // stack on top of each other in Alice's byline on camera.
185
+ await sleep(jitter(1_200, 1_800));
186
+
187
+ // 1) Authors ---------------------------------------------------
188
+ const seeded = await waitForAuthorCount(carol, 1, 30_000);
189
+ if (!seeded) {
190
  console.warn("[carol] Alice's first author never appeared, proceeding");
191
  }
192
+ await sleep(jitter(400, 700));
193
 
194
+ addAuthorRecord(carol, {
195
+ name: "Carol Dubois",
196
+ newAffiliation: { name: "Stanford University" },
197
  });
198
  console.log("[carol] author added");
199
 
200
+ // 2) Wait for scaffold ----------------------------------------
201
+ const hasHeading = await waitForCondition(
202
+ carol.fragment,
203
+ () => findHeadingIndex(carol.fragment, "In Python") !== -1,
204
+ 60_000,
205
+ );
206
+ if (!hasHeading) {
207
+ console.warn("[carol] 'In Python' heading never arrived, skipping body");
 
 
 
208
  return;
209
  }
210
+ await sleep(jitter(150, 300));
211
+
212
+ const fastCps = 50 / Math.max(0.5, speed / 2);
213
 
214
+ // 3) Slot 0 - intro line + link on "PyTorch" ------------------
215
+ const slot0 = claimSlotAfterHeading(carol.fragment, "In Python", 0);
216
+ if (!slot0) {
217
+ console.warn("[carol] could not locate slot 0 of 'In Python'");
 
218
  return;
219
  }
220
+ await sleep(jitter(80, 150));
221
+ await typeIntoParagraph(
222
+ slot0,
 
223
  "The whole recipe fits in eight lines of PyTorch:",
224
+ { cps: fastCps, chunkSize: 2 },
225
+ undefined,
226
+ carol,
227
  );
228
+ await sleep(jitter(160, 260));
229
 
230
+ const linked = linkifyInParagraph(
231
+ slot0,
232
+ "PyTorch",
233
+ "https://pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention.html",
234
+ );
235
+ broadcastCursorAtEnd(carol, slot0);
236
+ if (!linked) {
237
+ console.warn("[carol] could not apply PyTorch link mark");
238
+ } else {
239
+ console.log("[carol] PyTorch link inserted");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  }
241
 
242
+ await sleep(jitter(180, 300));
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
+ // 4) Slot 1 - 8-line code block -------------------------------
245
+ // Open an empty codeBlock first so Alice sees the "frame" land
246
+ // (with the language tag), then stream the code line by line at
247
+ // a human coding cadence. The atomic "whole block pops in" feel
248
+ // is replaced by a visible tap-tap-tap typing animation that
249
+ // matches the other paragraphs.
250
  const code =
251
  "import torch\n" +
252
  "import torch.nn.functional as F\n" +
 
256
  " scores = Q @ K.transpose(-2, -1) / d_k ** 0.5\n" +
257
  " weights = F.softmax(scores, dim=-1)\n" +
258
  " return weights @ V";
259
+ const codeTxt = openCodeBlock(carol.fragment, "In Python", 1, "python");
260
+ if (!codeTxt) {
261
+ console.warn("[carol] could not open code block");
262
+ } else {
263
+ console.log("[carol] code block opened, streaming source...");
264
+ const codeCps = 70 / Math.max(0.5, speed / 2);
265
+ await typeIntoCodeBlock(carol, codeTxt, code, {
266
+ cps: codeCps,
267
+ chunkSize: 3,
268
+ });
269
+ console.log("[carol] code block complete");
270
  }
271
+ await sleep(jitter(300, 480));
 
 
 
 
 
 
272
 
273
+ // 5) Slot 2 - closing paragraph -------------------------------
274
+ const slot2 = claimSlotAfterHeading(carol.fragment, "In Python", 2);
275
+ if (slot2) {
276
+ await sleep(jitter(80, 160));
277
+ await typeIntoParagraph(
278
+ slot2,
279
+ "Every modern LLM you have used runs some flavor of this snippet - fused, batched and tiled on the GPU, but conceptually the same eight lines.",
280
+ { cps: fastCps, chunkSize: 2 },
281
+ undefined,
282
+ carol,
283
+ );
284
+ } else {
285
+ console.warn("[carol] could not locate slot 2");
286
+ }
287
 
288
+ await sleep(jitter(200, 320));
289
+ clearAwarenessCursor(carol);
290
  console.log("[carol] scenario complete");
291
  }
backend/demo/human-typing.ts CHANGED
@@ -9,6 +9,7 @@
9
  * person writing", not deterministic test output.
10
  */
11
  import type { Page, Locator } from "playwright";
 
12
 
13
  export interface HumanTypingOptions {
14
  /** Baseline delay between keystrokes (ms). Actual delay is jittered. */
@@ -141,7 +142,7 @@ export async function humanTypeInto(
141
  options: HumanTypingOptions = {}
142
  ): Promise<void> {
143
  await target.waitFor({ state: "visible" });
144
- await target.click();
145
  await sleep(rand(250, 550));
146
  await humanType(page, text, options);
147
  }
 
9
  * person writing", not deterministic test output.
10
  */
11
  import type { Page, Locator } from "playwright";
12
+ import { humanClick } from "./lib/mouse-cursor.js";
13
 
14
  export interface HumanTypingOptions {
15
  /** Baseline delay between keystrokes (ms). Actual delay is jittered. */
 
142
  options: HumanTypingOptions = {}
143
  ): Promise<void> {
144
  await target.waitFor({ state: "visible" });
145
+ await humanClick(page, target);
146
  await sleep(rand(250, 550));
147
  await humanType(page, text, options);
148
  }
backend/demo/lib/chat-actions.ts CHANGED
@@ -33,8 +33,10 @@
33
  */
34
  import type { Page } from "playwright";
35
  import { humanType, pause } from "../human-typing.js";
 
36
  import {
37
  clearAgentFocus,
 
38
  getCurrentPmSelection,
39
  selectSubstringInParagraph,
40
  } from "./selection.js";
@@ -69,6 +71,20 @@ export async function streamFakeExchange(
69
  * silently fails. See `App.tsx` `__demo-chat` handler.
70
  */
71
  range?: { from: number; to: number };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  };
73
  /**
74
  * Optional formatting action: after the assistant reply has fully
@@ -250,10 +266,11 @@ async function openChatAndTypePrompt(
250
  page: Page,
251
  prompt: string,
252
  speed: number,
 
253
  ) {
254
  const fab = page.getByRole("button", { name: /AI Assistant/i }).first();
255
  if (await fab.isVisible().catch(() => false)) {
256
- await fab.click();
257
  await pause(220, 380);
258
  }
259
 
@@ -262,7 +279,21 @@ async function openChatAndTypePrompt(
262
  .first();
263
  try {
264
  await textarea.waitFor({ state: "visible", timeout: 3_000 });
265
- await textarea.click();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  await humanType(page, prompt, {
267
  speed: speed * 2.2,
268
  typoRate: 0,
@@ -271,6 +302,16 @@ async function openChatAndTypePrompt(
271
  await pause(100, 180);
272
  } catch {
273
  console.warn("[chat] textarea not found, will inject anyway");
 
 
 
 
 
 
 
 
 
 
274
  }
275
  try {
276
  await textarea.fill("");
@@ -354,9 +395,63 @@ export async function requestRephraseAction(
354
  to: string;
355
  range?: { from: number; to: number };
356
  speed: number;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  },
358
  ) {
359
- await openChatAndTypePrompt(page, args.prompt, args.speed);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
 
361
  await streamFakeExchange(page, {
362
  eventName: "__demo-chat",
@@ -367,10 +462,131 @@ export async function requestRephraseAction(
367
  from: args.from,
368
  to: args.to,
369
  range: args.range,
 
 
370
  },
371
  });
372
  }
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  /**
375
  * Twin of `requestFormatAction`, but for link insertion.
376
  *
@@ -537,7 +753,9 @@ export async function bubbleToolbarBold(
537
  // dance a couple of times before giving up, with an explicit
538
  // `editor.commands.focus()` each pass to re-assert ownership of
539
  // the caret against remote transactions.
540
- const seedSelection = async (): Promise<{
 
 
541
  from: number;
542
  to: number;
543
  } | null> => {
@@ -549,6 +767,21 @@ export async function bubbleToolbarBold(
549
  ).__demoEditor;
550
  editor?.commands.focus();
551
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  return await selectSubstringInParagraph(
553
  page,
554
  paragraphSnippet,
@@ -563,7 +796,7 @@ export async function bubbleToolbarBold(
563
  let range: { from: number; to: number } | null = null;
564
  let toolbarReady = false;
565
  for (let attempt = 0; attempt < 3; attempt++) {
566
- range = await seedSelection();
567
  if (!range) {
568
  await pause(180, 280);
569
  continue;
@@ -627,13 +860,13 @@ export async function bubbleToolbarBold(
627
 
628
  const [hoverMin, hoverMax] = opts.hoverMs ?? [380, 620];
629
  try {
630
- await boldBtn.hover();
631
  } catch {
632
  /* non-fatal */
633
  }
634
  await pause(hoverMin, hoverMax);
635
  try {
636
- await boldBtn.click({ force: true });
637
  } catch (err) {
638
  console.warn(
639
  "[bubble-bold] click failed:",
 
33
  */
34
  import type { Page } from "playwright";
35
  import { humanType, pause } from "../human-typing.js";
36
+ import { humanClick, humanHover } from "./mouse-cursor.js";
37
  import {
38
  clearAgentFocus,
39
+ dragSelectSubstringInParagraph,
40
  getCurrentPmSelection,
41
  selectSubstringInParagraph,
42
  } from "./selection.js";
 
71
  * silently fails. See `App.tsx` `__demo-chat` handler.
72
  */
73
  range?: { from: number; to: number };
74
+ /**
75
+ * "none" for atomic swap (reads as AI edit), "typewriter" for
76
+ * streamed chars (reads as user typing). Default typewriter
77
+ * to match the original demo's behaviour.
78
+ */
79
+ animation?: "none" | "typewriter";
80
+ /**
81
+ * Keep the AgentFocus highlight on the replacement range for
82
+ * `lingerAgentFocusMs` after the swap, then clear. Lets the
83
+ * viewer see the "<Name> agent" label sit over the new
84
+ * sentence instead of the decoration collapsing at delete
85
+ * time.
86
+ */
87
+ lingerAgentFocusMs?: number;
88
  };
89
  /**
90
  * Optional formatting action: after the assistant reply has fully
 
266
  page: Page,
267
  prompt: string,
268
  speed: number,
269
+ opts: { onPanelOpen?: () => Promise<void> } = {},
270
  ) {
271
  const fab = page.getByRole("button", { name: /AI Assistant/i }).first();
272
  if (await fab.isVisible().catch(() => false)) {
273
+ await humanClick(page, fab);
274
  await pause(220, 380);
275
  }
276
 
 
279
  .first();
280
  try {
281
  await textarea.waitFor({ state: "visible", timeout: 3_000 });
282
+ // Panel is now open. Callers (requestRephraseAction) can fire
283
+ // a callback here so side-effects that should read as "triggered
284
+ // by opening the chat" - the AgentFocus highlight in particular
285
+ // - land at the exact frame the panel becomes visible.
286
+ if (opts.onPanelOpen) {
287
+ try {
288
+ await opts.onPanelOpen();
289
+ } catch (err) {
290
+ console.warn(
291
+ "[chat] onPanelOpen hook failed:",
292
+ err instanceof Error ? err.message : err,
293
+ );
294
+ }
295
+ }
296
+ await humanClick(page, textarea);
297
  await humanType(page, prompt, {
298
  speed: speed * 2.2,
299
  typoRate: 0,
 
302
  await pause(100, 180);
303
  } catch {
304
  console.warn("[chat] textarea not found, will inject anyway");
305
+ // Fallback: the panel never actually opened visually. Run the
306
+ // hook anyway so the highlight still appears (better than silent
307
+ // skip if tippy/panel layout shifted mid-demo).
308
+ if (opts.onPanelOpen) {
309
+ try {
310
+ await opts.onPanelOpen();
311
+ } catch {
312
+ /* non-fatal */
313
+ }
314
+ }
315
  }
316
  try {
317
  await textarea.fill("");
 
395
  to: string;
396
  range?: { from: number; to: number };
397
  speed: number;
398
+ /**
399
+ * "none" = atomic swap, the phrase flips in one transaction and
400
+ * the AgentFocus decoration carries over to the new range - the
401
+ * viewer unambiguously reads it as an AI edit.
402
+ *
403
+ * "typewriter" = stream chars one by one. Looks like Alice is
404
+ * typing herself, which defeats the purpose of showing an agent
405
+ * tool call - avoid unless you explicitly want that effect.
406
+ *
407
+ * Default: "none".
408
+ */
409
+ animation?: "none" | "typewriter";
410
+ /**
411
+ * How long the AgentFocus highlight lingers on the new sentence
412
+ * after the swap before clearing. Default 1400ms - long enough
413
+ * for the viewer to read the new text under the agent badge,
414
+ * short enough to not stall the recording.
415
+ */
416
+ lingerAgentFocusMs?: number;
417
  },
418
  ) {
419
+ const animation = args.animation ?? "none";
420
+ const lingerAgentFocusMs = args.lingerAgentFocusMs ?? 1400;
421
+
422
+ // Story beat: Alice FIRST selects the target sentence herself
423
+ // (visible drag-glide + PM selection paint), pauses so the viewer
424
+ // registers "this is the phrase I want to work on", THEN opens
425
+ // the chat. Without this visible selection, the agent highlight
426
+ // appears "out of nowhere" when the panel opens, which reads as
427
+ // unexplained magic.
428
+ //
429
+ // Note: we pass setAgentFocus=false here - the user selection
430
+ // should look like a regular user selection (no "<Name> agent"
431
+ // label yet). The label appears shortly after when the chat
432
+ // actually opens and onPanelOpen fires below, visually
433
+ // transforming the user selection into an agent-scoped range.
434
+ await dragSelectSubstringInParagraph(
435
+ page,
436
+ args.from,
437
+ args.from,
438
+ { setAgentFocus: false },
439
+ );
440
+ await pause(420, 620);
441
+
442
+ // Timing rule: the AgentFocus highlight upgrades the user's
443
+ // selection to an agent-scoped range at the exact frame the chat
444
+ // panel becomes visible. openChatAndTypePrompt fires onPanelOpen
445
+ // between "panel is open" and "prompt starts typing" so the
446
+ // label pops in sync with the chat UI, not 3 seconds before.
447
+ await openChatAndTypePrompt(page, args.prompt, args.speed, {
448
+ onPanelOpen: async () => {
449
+ await seedAgentFocusOnTarget(page, {
450
+ targetText: args.from,
451
+ explicitRange: args.range,
452
+ });
453
+ },
454
+ });
455
 
456
  await streamFakeExchange(page, {
457
  eventName: "__demo-chat",
 
462
  from: args.from,
463
  to: args.to,
464
  range: args.range,
465
+ animation,
466
+ lingerAgentFocusMs,
467
  },
468
  });
469
  }
470
 
471
+ /**
472
+ * Resolve the PM range for a rephrase target against the LIVE doc
473
+ * and broadcast an AgentFocus highlight on it. Mirrors the lookup
474
+ * the App-side `__demo-chat` handler does at dispatch time
475
+ * (explicit range first, text-search fallback) so the highlight
476
+ * lands on the EXACT same characters the typewriter will rewrite
477
+ * a couple hundred ms later. If neither resolution path matches
478
+ * (e.g. Bob just typed into the target paragraph and rebased the
479
+ * text), the helper silently skips - the typewriter itself will
480
+ * still find the range from its own live lookup.
481
+ */
482
+ async function seedAgentFocusOnTarget(
483
+ page: Page,
484
+ args: {
485
+ targetText: string;
486
+ explicitRange?: { from: number; to: number };
487
+ },
488
+ ): Promise<void> {
489
+ await page.evaluate(
490
+ ({ target, explicit }) => {
491
+ const editor = (
492
+ window as unknown as {
493
+ __demoEditor?: {
494
+ state: {
495
+ doc: {
496
+ content: { size: number };
497
+ descendants: (
498
+ cb: (
499
+ node: {
500
+ isTextblock: boolean;
501
+ content: { size: number };
502
+ textBetween: (
503
+ a: number,
504
+ b: number,
505
+ s1: string,
506
+ s2: string,
507
+ ) => string;
508
+ },
509
+ pos: number,
510
+ ) => boolean | void,
511
+ ) => void;
512
+ };
513
+ };
514
+ commands: {
515
+ setAgentFocus: (args: { from: number; to: number }) => boolean;
516
+ };
517
+ view: {
518
+ domAtPos: (pos: number) => { node: Node };
519
+ };
520
+ };
521
+ }
522
+ ).__demoEditor;
523
+ if (!editor) return;
524
+
525
+ let from = -1;
526
+ let to = -1;
527
+
528
+ if (explicit) {
529
+ const size = editor.state.doc.content.size;
530
+ const a = Math.max(0, Math.min(explicit.from, size));
531
+ const b = Math.max(0, Math.min(explicit.to, size));
532
+ if (b > a) {
533
+ from = a;
534
+ to = b;
535
+ }
536
+ }
537
+
538
+ if (from === -1) {
539
+ editor.state.doc.descendants((node, pos) => {
540
+ if (from !== -1) return false;
541
+ if (node.isTextblock) {
542
+ const text = node.textBetween(
543
+ 0,
544
+ node.content.size,
545
+ "\n",
546
+ "\n",
547
+ );
548
+ const idx = text.indexOf(target);
549
+ if (idx !== -1) {
550
+ from = pos + 1 + idx;
551
+ to = from + target.length;
552
+ return false;
553
+ }
554
+ }
555
+ return true;
556
+ });
557
+ }
558
+
559
+ if (from === -1 || to === -1) return;
560
+
561
+ // Scroll the target paragraph into view so the highlight is
562
+ // on-camera when the chat panel reply starts streaming.
563
+ try {
564
+ const domAt = editor.view.domAtPos(from);
565
+ let el: HTMLElement | null =
566
+ domAt.node instanceof HTMLElement
567
+ ? domAt.node
568
+ : (domAt.node as Node).parentElement;
569
+ while (
570
+ el &&
571
+ el.nodeName !== "P" &&
572
+ !/^H[1-6]$/.test(el.nodeName)
573
+ ) {
574
+ el = el.parentElement;
575
+ }
576
+ el?.scrollIntoView({ behavior: "smooth", block: "center" });
577
+ } catch {
578
+ /* non-fatal */
579
+ }
580
+
581
+ editor.commands.setAgentFocus({ from, to });
582
+ },
583
+ {
584
+ target: args.targetText,
585
+ explicit: args.explicitRange,
586
+ },
587
+ );
588
+ }
589
+
590
  /**
591
  * Twin of `requestFormatAction`, but for link insertion.
592
  *
 
753
  // dance a couple of times before giving up, with an explicit
754
  // `editor.commands.focus()` each pass to re-assert ownership of
755
  // the caret against remote transactions.
756
+ const seedSelection = async (
757
+ attempt: number,
758
+ ): Promise<{
759
  from: number;
760
  to: number;
761
  } | null> => {
 
767
  ).__demoEditor;
768
  editor?.commands.focus();
769
  });
770
+ // First attempt: animate a real mouse drag across the target
771
+ // phrase so the viewer SEES Alice select the text (cursor glides
772
+ // over the words while the selection paint follows). Subsequent
773
+ // retries fall back to the purely programmatic path - we don't
774
+ // want to loop dragging if concurrent Yjs sync keeps shifting
775
+ // the text under us, the programmatic selection is rock solid
776
+ // on exact PM positions.
777
+ if (attempt === 0) {
778
+ const dragged = await dragSelectSubstringInParagraph(
779
+ page,
780
+ paragraphSnippet,
781
+ substring,
782
+ );
783
+ if (dragged) return dragged;
784
+ }
785
  return await selectSubstringInParagraph(
786
  page,
787
  paragraphSnippet,
 
796
  let range: { from: number; to: number } | null = null;
797
  let toolbarReady = false;
798
  for (let attempt = 0; attempt < 3; attempt++) {
799
+ range = await seedSelection(attempt);
800
  if (!range) {
801
  await pause(180, 280);
802
  continue;
 
860
 
861
  const [hoverMin, hoverMax] = opts.hoverMs ?? [380, 620];
862
  try {
863
+ await humanHover(page, boldBtn);
864
  } catch {
865
  /* non-fatal */
866
  }
867
  await pause(hoverMin, hoverMax);
868
  try {
869
+ await humanClick(page, boldBtn);
870
  } catch (err) {
871
  console.warn(
872
  "[bubble-bold] click failed:",
backend/demo/lib/chromium.ts CHANGED
@@ -22,6 +22,7 @@ import { mkdtempSync, rmSync } from "node:fs";
22
  import { tmpdir } from "node:os";
23
  import { join } from "node:path";
24
  import { FALLBACK_USER_KEY, type Persona } from "../personas.js";
 
25
 
26
  export interface PersonaHandle {
27
  browser: Browser;
@@ -131,6 +132,12 @@ export async function launchPersona(
131
  ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
132
  });
133
 
 
 
 
 
 
 
134
  // Inject persona identity for any future navigation. The first --app
135
  // load already happened, so we set localStorage on the live page and
136
  // then reload once to get a clean boot under the persona identity.
@@ -163,6 +170,13 @@ export async function launchPersona(
163
  await page.reload({ waitUntil: "domcontentloaded", timeout: 20_000 });
164
  await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
165
 
 
 
 
 
 
 
 
166
  // Wrap context.close() into the same Browser-shaped object the rest of
167
  // the script expects so we don't have to branch on close everywhere.
168
  const browser = {
@@ -209,6 +223,8 @@ export async function launchPersona(
209
  : { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 },
210
  );
211
 
 
 
212
  await context.addInitScript(
213
  ({ key, value }) => {
214
  try {
@@ -230,6 +246,13 @@ export async function launchPersona(
230
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
231
  await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
232
 
 
 
 
 
 
 
 
233
  return { browser, context, page };
234
  }
235
 
 
22
  import { tmpdir } from "node:os";
23
  import { join } from "node:path";
24
  import { FALLBACK_USER_KEY, type Persona } from "../personas.js";
25
+ import { injectMouseCursor, verifyMouseCursor } from "./mouse-cursor.js";
26
 
27
  export interface PersonaHandle {
28
  browser: Browser;
 
132
  ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
133
  });
134
 
135
+ // Draw a visible mouse pointer on Alice's recorded window. Needs
136
+ // to run before the first nav so the overlay is live for the
137
+ // boot and every in-page navigation (e.g. opening the published
138
+ // article).
139
+ await injectMouseCursor(context);
140
+
141
  // Inject persona identity for any future navigation. The first --app
142
  // load already happened, so we set localStorage on the live page and
143
  // then reload once to get a clean boot under the persona identity.
 
170
  await page.reload({ waitUntil: "domcontentloaded", timeout: 20_000 });
171
  await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
172
 
173
+ const cursorMounted = await verifyMouseCursor(page);
174
+ console.log(
175
+ cursorMounted
176
+ ? "[showcase] mouse cursor overlay mounted"
177
+ : "[showcase] WARN: mouse cursor overlay did NOT mount",
178
+ );
179
+
180
  // Wrap context.close() into the same Browser-shaped object the rest of
181
  // the script expects so we don't have to branch on close everywhere.
182
  const browser = {
 
223
  : { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 },
224
  );
225
 
226
+ await injectMouseCursor(context);
227
+
228
  await context.addInitScript(
229
  ({ key, value }) => {
230
  try {
 
246
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
247
  await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
248
 
249
+ const cursorMounted = await verifyMouseCursor(page);
250
+ console.log(
251
+ cursorMounted
252
+ ? "[showcase] mouse cursor overlay mounted"
253
+ : "[showcase] WARN: mouse cursor overlay did NOT mount",
254
+ );
255
+
256
  return { browser, context, page };
257
  }
258
 
backend/demo/lib/editor-actions.ts CHANGED
@@ -11,6 +11,7 @@
11
  */
12
  import type { Page } from "playwright";
13
  import { humanType, pause } from "../human-typing.js";
 
14
 
15
  /**
16
  * Move the caret to the very end of the ProseMirror editor, regardless
@@ -118,12 +119,12 @@ export async function addAuthor(
118
  if (await placeholderTrigger.first().isVisible().catch(() => false)) {
119
  await placeholderTrigger.first().scrollIntoViewIfNeeded();
120
  await pause(80, 160);
121
- await placeholderTrigger.first().click();
122
  opened = true;
123
  } else if (await iconTrigger.first().isVisible().catch(() => false)) {
124
  await iconTrigger.first().scrollIntoViewIfNeeded();
125
  await pause(80, 160);
126
- await iconTrigger.first().click();
127
  opened = true;
128
  }
129
  if (!opened) {
@@ -143,7 +144,7 @@ export async function addAuthor(
143
  const fastSpeed = speed * 1.4;
144
 
145
  const nameInput = dialog.getByPlaceholder("Name");
146
- await nameInput.click();
147
  await humanType(page, name, {
148
  speed: fastSpeed,
149
  typoRate: 0,
@@ -153,7 +154,7 @@ export async function addAuthor(
153
 
154
  if (opts.url) {
155
  const urlInput = dialog.getByPlaceholder("URL (optional)");
156
- await urlInput.click();
157
  await humanType(page, opts.url, {
158
  speed: fastSpeed,
159
  typoRate: 0,
@@ -169,7 +170,7 @@ export async function addAuthor(
169
  .filter({ hasText: new RegExp(`^${idx}\\.`) })
170
  .first();
171
  if (await chip.isVisible().catch(() => false)) {
172
- await chip.click();
173
  await pause(60, 140);
174
  }
175
  }
@@ -177,7 +178,7 @@ export async function addAuthor(
177
 
178
  if (opts.newAffiliation) {
179
  const newAffInput = dialog.getByPlaceholder(/New affiliation/i);
180
- await newAffInput.click();
181
  await humanType(page, opts.newAffiliation, {
182
  speed: fastSpeed,
183
  typoRate: 0,
@@ -187,7 +188,7 @@ export async function addAuthor(
187
  }
188
 
189
  const submit = dialog.locator("button.btn--primary");
190
- await submit.click();
191
 
192
  try {
193
  await dialog.waitFor({ state: "hidden", timeout: 3_000 });
 
11
  */
12
  import type { Page } from "playwright";
13
  import { humanType, pause } from "../human-typing.js";
14
+ import { humanClick } from "./mouse-cursor.js";
15
 
16
  /**
17
  * Move the caret to the very end of the ProseMirror editor, regardless
 
119
  if (await placeholderTrigger.first().isVisible().catch(() => false)) {
120
  await placeholderTrigger.first().scrollIntoViewIfNeeded();
121
  await pause(80, 160);
122
+ await humanClick(page, placeholderTrigger.first());
123
  opened = true;
124
  } else if (await iconTrigger.first().isVisible().catch(() => false)) {
125
  await iconTrigger.first().scrollIntoViewIfNeeded();
126
  await pause(80, 160);
127
+ await humanClick(page, iconTrigger.first());
128
  opened = true;
129
  }
130
  if (!opened) {
 
144
  const fastSpeed = speed * 1.4;
145
 
146
  const nameInput = dialog.getByPlaceholder("Name");
147
+ await humanClick(page, nameInput);
148
  await humanType(page, name, {
149
  speed: fastSpeed,
150
  typoRate: 0,
 
154
 
155
  if (opts.url) {
156
  const urlInput = dialog.getByPlaceholder("URL (optional)");
157
+ await humanClick(page, urlInput);
158
  await humanType(page, opts.url, {
159
  speed: fastSpeed,
160
  typoRate: 0,
 
170
  .filter({ hasText: new RegExp(`^${idx}\\.`) })
171
  .first();
172
  if (await chip.isVisible().catch(() => false)) {
173
+ await humanClick(page, chip);
174
  await pause(60, 140);
175
  }
176
  }
 
178
 
179
  if (opts.newAffiliation) {
180
  const newAffInput = dialog.getByPlaceholder(/New affiliation/i);
181
+ await humanClick(page, newAffInput);
182
  await humanType(page, opts.newAffiliation, {
183
  speed: fastSpeed,
184
  typoRate: 0,
 
188
  }
189
 
190
  const submit = dialog.locator("button.btn--primary");
191
+ await humanClick(page, submit);
192
 
193
  try {
194
  await dialog.waitFor({ state: "hidden", timeout: 3_000 });
backend/demo/lib/embed-studio.ts CHANGED
@@ -16,7 +16,10 @@
16
  */
17
  import type { Page } from "playwright";
18
  import { humanType, pause } from "../human-typing.js";
 
19
  import {
 
 
20
  MODEL_ACCURACY_CSV,
21
  MODEL_ACCURACY_CSV_META,
22
  NEURAL_NETWORK_BANNER_HTML,
@@ -73,11 +76,11 @@ async function seedEmbedDataFile(
73
  async function closeEmbedStudio(page: Page) {
74
  const closeBtn = page.getByRole("button", { name: /^Save & Close$/i }).first();
75
  if (await closeBtn.isVisible().catch(() => false)) {
76
- await closeBtn.click();
77
  } else {
78
  const xBtn = page.getByRole("button", { name: /Close Embed Studio/i }).first();
79
  if (await xBtn.isVisible().catch(() => false)) {
80
- await xBtn.click();
81
  } else {
82
  await page.keyboard.press("Escape");
83
  }
@@ -194,10 +197,23 @@ export async function createNeuralNetworkBanner(page: Page, speed: number) {
194
  return;
195
  }
196
 
197
- // Seed one data file BEFORE opening the studio so the FilesSidebar
198
- // shows an existing dataset the agent can reference. Two purposes:
199
  // 1. visually establishes that the studio now handles data files;
200
- // 2. gives the listDataFiles tool call a real output to surface.
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  await seedEmbedDataFile(page, {
202
  name: MODEL_ACCURACY_CSV_META.name,
203
  ext: MODEL_ACCURACY_CSV_META.ext,
@@ -209,14 +225,15 @@ export async function createNeuralNetworkBanner(page: Page, speed: number) {
209
 
210
  await createBtn.scrollIntoViewIfNeeded();
211
  await pause(120, 220);
212
- await createBtn.click();
213
 
214
  const promptArea = page.getByPlaceholder("Describe your chart...");
215
- const promptText = "A neural network banner - soft pulses, primary color.";
 
216
  try {
217
  await promptArea.waitFor({ state: "visible", timeout: 4_000 });
218
  await pause(260, 420);
219
- await promptArea.click();
220
  await pause(180, 280);
221
  await humanType(page, promptText, {
222
  speed: speed * 1.6,
@@ -229,35 +246,111 @@ export async function createNeuralNetworkBanner(page: Page, speed: number) {
229
  console.warn("[alice] embed studio textarea not found, faking anyway");
230
  }
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  await runEmbedScene(page, [
233
  { kind: "user-text", text: promptText },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  {
235
  kind: "assistant-tool",
236
  toolName: "listDataFiles",
237
  input: {},
238
  output:
 
 
239
  `- ${MODEL_ACCURACY_CSV_META.name} (csv, ${MODEL_ACCURACY_CSV.length} bytes) ` +
240
  `rows=${MODEL_ACCURACY_CSV_META.rowCount} ` +
241
  `columns=[${MODEL_ACCURACY_CSV_META.columns.join(", ")}]`,
242
- thinkMs: [480, 760],
243
  },
244
  {
245
  kind: "assistant-text",
246
- text: "Data files noted - going abstract for the banner.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  },
248
  {
249
  kind: "assistant-tool",
250
  toolName: "createEmbed",
251
  input: {
 
252
  title: "Neural network banner",
 
 
253
  },
254
- output: "chart created (134 lines, 3.8 KB)",
255
- thinkMs: [620, 980],
256
  },
257
  {
258
  kind: "assistant-text",
259
  text:
260
- " Layered network with subtle pulses along the synapses, tinted with the article primary color.",
 
261
  },
262
  ]);
263
 
 
16
  */
17
  import type { Page } from "playwright";
18
  import { humanType, pause } from "../human-typing.js";
19
+ import { humanClick } from "./mouse-cursor.js";
20
  import {
21
+ ARCHITECTURE_JSON,
22
+ ARCHITECTURE_JSON_META,
23
  MODEL_ACCURACY_CSV,
24
  MODEL_ACCURACY_CSV_META,
25
  NEURAL_NETWORK_BANNER_HTML,
 
76
  async function closeEmbedStudio(page: Page) {
77
  const closeBtn = page.getByRole("button", { name: /^Save & Close$/i }).first();
78
  if (await closeBtn.isVisible().catch(() => false)) {
79
+ await humanClick(page, closeBtn);
80
  } else {
81
  const xBtn = page.getByRole("button", { name: /Close Embed Studio/i }).first();
82
  if (await xBtn.isVisible().catch(() => false)) {
83
+ await humanClick(page, xBtn);
84
  } else {
85
  await page.keyboard.press("Escape");
86
  }
 
197
  return;
198
  }
199
 
200
+ // Seed two data files BEFORE opening the studio so the FilesSidebar
201
+ // shows existing datasets the agent can reference. Three purposes:
202
  // 1. visually establishes that the studio now handles data files;
203
+ // 2. gives the listDataFiles tool call a real output to surface;
204
+ // 3. one of the files (architecture.json) is directly relevant to
205
+ // the banner the agent is about to build, so the demo can show
206
+ // the agent reading the data, extracting the layer topology,
207
+ // and feeding it back into the visualization rather than just
208
+ // ignoring the dataset and dropping a hardcoded picture.
209
+ await seedEmbedDataFile(page, {
210
+ name: ARCHITECTURE_JSON_META.name,
211
+ ext: ARCHITECTURE_JSON_META.ext,
212
+ content: ARCHITECTURE_JSON,
213
+ columns: ARCHITECTURE_JSON_META.columns,
214
+ rowCount: ARCHITECTURE_JSON_META.rowCount,
215
+ uploader: "alice",
216
+ });
217
  await seedEmbedDataFile(page, {
218
  name: MODEL_ACCURACY_CSV_META.name,
219
  ext: MODEL_ACCURACY_CSV_META.ext,
 
225
 
226
  await createBtn.scrollIntoViewIfNeeded();
227
  await pause(120, 220);
228
+ await humanClick(page, createBtn);
229
 
230
  const promptArea = page.getByPlaceholder("Describe your chart...");
231
+ const promptText =
232
+ "A neural network banner that captures the spirit of the article. Use the architecture I uploaded to drive the layout - layered graph with soft pulses flowing along the synapses, like attention scores rippling between tokens.";
233
  try {
234
  await promptArea.waitFor({ state: "visible", timeout: 4_000 });
235
  await pause(260, 420);
236
+ await humanClick(page, promptArea);
237
  await pause(180, 280);
238
  await humanType(page, promptText, {
239
  speed: speed * 1.6,
 
246
  console.warn("[alice] embed studio textarea not found, faking anyway");
247
  }
248
 
249
+ // The scene mirrors a realistic agent round-trip and narrates
250
+ // the artifact we're actually about to inject, so when the
251
+ // viewer flips to the preview everything the agent promised is
252
+ // visibly there: 5 layers with the exact counts, pulses on a
253
+ // random synapse, transparent background, retinted via
254
+ // `--primary-color`.
255
+ //
256
+ // The sequence is:
257
+ // readEmbed - look at what's already in the banner slot
258
+ // (placeholder HTML from "Create chart" click)
259
+ // listDataFiles + readDataFile - confirm there IS a dataset
260
+ // but decide it's better saved for inline charts
261
+ // createEmbed - write the real banner with a descriptive
262
+ // filename and title, and a spec that matches
263
+ // the actual NEURAL_NETWORK_BANNER_HTML we ship
264
+ const csvPreviewOutput =
265
+ `${MODEL_ACCURACY_CSV_META.columns.join(",")}\n` +
266
+ MODEL_ACCURACY_CSV.split("\n").slice(1, 4).join("\n") +
267
+ "\n... 5 more rows";
268
+
269
  await runEmbedScene(page, [
270
  { kind: "user-text", text: promptText },
271
+ {
272
+ kind: "assistant-text",
273
+ text:
274
+ "Good - a hero banner. Let me look at what's already in the slot and what data you've uploaded so I can pick the right shape.",
275
+ chunkMs: [22, 48],
276
+ },
277
+ {
278
+ kind: "assistant-tool",
279
+ toolName: "readEmbed",
280
+ input: { src: "banner.html" },
281
+ output:
282
+ "banner.html is the placeholder stub (14 lines). Safe to replace.",
283
+ thinkMs: [420, 640],
284
+ },
285
  {
286
  kind: "assistant-tool",
287
  toolName: "listDataFiles",
288
  input: {},
289
  output:
290
+ `- ${ARCHITECTURE_JSON_META.name} (json, ${ARCHITECTURE_JSON.length} bytes) ` +
291
+ `keys=[${ARCHITECTURE_JSON_META.columns.join(", ")}]\n` +
292
  `- ${MODEL_ACCURACY_CSV_META.name} (csv, ${MODEL_ACCURACY_CSV.length} bytes) ` +
293
  `rows=${MODEL_ACCURACY_CSV_META.rowCount} ` +
294
  `columns=[${MODEL_ACCURACY_CSV_META.columns.join(", ")}]`,
295
+ thinkMs: [440, 680],
296
  },
297
  {
298
  kind: "assistant-text",
299
+ text:
300
+ ` Two files. \`${ARCHITECTURE_JSON_META.name}\` looks directly relevant - that's what should drive the topology. Reading it first.`,
301
+ chunkMs: [20, 46],
302
+ },
303
+ {
304
+ kind: "assistant-tool",
305
+ toolName: "readDataFile",
306
+ input: { name: ARCHITECTURE_JSON_META.name },
307
+ output: ARCHITECTURE_JSON,
308
+ thinkMs: [520, 780],
309
+ },
310
+ {
311
+ kind: "assistant-text",
312
+ text:
313
+ " Got it - five layers (tokens 6 → embedding 10 → attention 14 → values 10 → logits 6), fully-connected, softmax activation. That maps perfectly to a layered graph.",
314
+ chunkMs: [22, 50],
315
+ chunkSize: [1, 3],
316
+ },
317
+ {
318
+ kind: "assistant-tool",
319
+ toolName: "readDataFile",
320
+ input: { name: MODEL_ACCURACY_CSV_META.name, limit: 4 },
321
+ output: csvPreviewOutput,
322
+ thinkMs: [380, 560],
323
+ },
324
+ {
325
+ kind: "assistant-text",
326
+ text:
327
+ " The accuracy CSV is great for an inline chart later in the article, but for the hero I'll stick with the architecture diagram.",
328
+ chunkMs: [22, 48],
329
+ },
330
+ {
331
+ kind: "assistant-text",
332
+ text:
333
+ " Plan: nodes spaced per layer count from the JSON, edges tracing every fully-connected pair, soft dots traveling a random synapse every ~200ms so the network feels active without competing with the title. Pure SVG, transparent background, respects `prefers-reduced-motion`.",
334
+ chunkMs: [22, 48],
335
+ chunkSize: [1, 3],
336
  },
337
  {
338
  kind: "assistant-tool",
339
  toolName: "createEmbed",
340
  input: {
341
+ filename: "banner.html",
342
  title: "Neural network banner",
343
+ description:
344
+ "Layered graph derived from architecture.json (5 layers: 6/10/14/10/6, fully-connected). Pulses flow along random synapses; pure SVG, transparent background.",
345
  },
346
+ output: "wrote banner.html (182 lines, 3.8 KB, 0 external deps)",
347
+ thinkMs: [720, 1_080],
348
  },
349
  {
350
  kind: "assistant-text",
351
  text:
352
+ " Done - layer counts come from the JSON, the hero container drives the size, and the network rerolls a new burst pattern every cycle.",
353
+ chunkMs: [22, 50],
354
  },
355
  ]);
356
 
backend/demo/lib/mouse-cursor.ts ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * lib/mouse-cursor.ts
3
+ *
4
+ * Draws a visible mouse cursor in Alice's recorded window and provides
5
+ * human-feel movement helpers.
6
+ *
7
+ * Playwright drives real native mouse events but the OS pointer is NOT
8
+ * captured by the browser's screen recorder - so without this overlay
9
+ * the viewer sees clicks happen "by themselves". This module injects a
10
+ * small DOM overlay (SVG arrow + click ripple) that listens to the
11
+ * `mousemove`, `mousedown` and `mouseup` events Playwright already
12
+ * generates, so the cursor follows every pointer move naturally.
13
+ *
14
+ * On top of that, `humanMove` / `humanClick` / `humanHover` perform the
15
+ * move itself with intermediate steps and a cubic ease so the cursor
16
+ * glides to its target instead of teleporting, which is what Playwright's
17
+ * default `locator.click()` does (single-step move).
18
+ */
19
+ import type { Locator, Page } from "playwright";
20
+
21
+ const OVERLAY_ID = "__demo-mouse-cursor";
22
+
23
+ /**
24
+ * JS source that installs the overlay inside the page. Kept as a plain
25
+ * string (content-script) so Playwright doesn't have to serialize a
26
+ * TypeScript function - the tsx transpile pipeline occasionally kept
27
+ * type assertions in the `.toString()` output which quietly broke
28
+ * execution. A string is final and cannot drift.
29
+ */
30
+ const OVERLAY_SCRIPT = `
31
+ (() => {
32
+ const OVERLAY_ID = ${JSON.stringify(OVERLAY_ID)};
33
+ try {
34
+ if (window.top !== window) return;
35
+ } catch (_) { /* cross-origin top; still install */ }
36
+
37
+ const mount = () => {
38
+ if (document.getElementById(OVERLAY_ID)) return;
39
+
40
+ const host = document.createElement("div");
41
+ host.id = OVERLAY_ID;
42
+ host.setAttribute("aria-hidden", "true");
43
+ // Use the HTML popover API so the overlay sits in the top-layer,
44
+ // above every native <dialog showModal()>. Regular z-index would
45
+ // lose to the browser's top-layer stacking order. Falls back to
46
+ // plain fixed positioning if the user agent is too old to
47
+ // support popover (our Chromium is Chrome 114+, fine).
48
+ try { host.setAttribute("popover", "manual"); } catch (_) { /* ignore */ }
49
+ host.style.position = "fixed";
50
+ host.style.inset = "0";
51
+ host.style.top = "0";
52
+ host.style.left = "0";
53
+ host.style.width = "100vw";
54
+ host.style.height = "100vh";
55
+ host.style.pointerEvents = "none";
56
+ host.style.zIndex = "2147483646";
57
+ host.style.margin = "0";
58
+ host.style.padding = "0";
59
+ host.style.border = "0";
60
+ host.style.background = "transparent";
61
+ host.style.overflow = "visible";
62
+
63
+ const NS = "http://www.w3.org/2000/svg";
64
+ const pointer = document.createElementNS(NS, "svg");
65
+ pointer.setAttribute("width", "26");
66
+ pointer.setAttribute("height", "26");
67
+ pointer.setAttribute("viewBox", "0 0 26 26");
68
+ pointer.setAttribute("fill", "none");
69
+ pointer.style.position = "absolute";
70
+ pointer.style.left = "0";
71
+ pointer.style.top = "0";
72
+ pointer.style.transform = "translate3d(-100px, -100px, 0)";
73
+ pointer.style.filter = "drop-shadow(0 2px 4px rgba(0,0,0,0.45))";
74
+ pointer.style.willChange = "transform";
75
+
76
+ const arrow = document.createElementNS(NS, "path");
77
+ arrow.setAttribute(
78
+ "d",
79
+ "M4 3 L4 20 L9 15.5 L12 22 L14.8 20.8 L11.9 14.3 L19 14.3 Z"
80
+ );
81
+ arrow.setAttribute("fill", "#ffffff");
82
+ arrow.setAttribute("stroke", "#0a0a0a");
83
+ arrow.setAttribute("stroke-width", "1.4");
84
+ arrow.setAttribute("stroke-linejoin", "round");
85
+ pointer.appendChild(arrow);
86
+
87
+ const ripple = document.createElement("div");
88
+ ripple.style.position = "absolute";
89
+ ripple.style.left = "0";
90
+ ripple.style.top = "0";
91
+ ripple.style.width = "22px";
92
+ ripple.style.height = "22px";
93
+ ripple.style.borderRadius = "50%";
94
+ ripple.style.border = "2px solid rgba(255, 186, 70, 0.95)";
95
+ ripple.style.background = "rgba(255, 200, 80, 0.22)";
96
+ ripple.style.transform = "translate3d(-100px, -100px, 0) scale(0)";
97
+ ripple.style.opacity = "0";
98
+ ripple.style.pointerEvents = "none";
99
+
100
+ const style = document.createElement("style");
101
+ style.textContent =
102
+ "@keyframes __demo-mouse-ripple {" +
103
+ " 0% { transform: translate3d(var(--rx), var(--ry), 0) scale(0.15); opacity: 0.95; }" +
104
+ " 80% { opacity: 0.35; }" +
105
+ " 100% { transform: translate3d(var(--rx), var(--ry), 0) scale(2.6); opacity: 0; }" +
106
+ "}" +
107
+ "#" + OVERLAY_ID + " [data-ripple-active] {" +
108
+ " animation: __demo-mouse-ripple 0.6s cubic-bezier(0.2, 0.6, 0.2, 1) forwards;" +
109
+ "}";
110
+
111
+ host.appendChild(style);
112
+ host.appendChild(pointer);
113
+ host.appendChild(ripple);
114
+ document.documentElement.appendChild(host);
115
+
116
+ // Promote into the top-layer so the cursor stays above any
117
+ // <dialog showModal()>, popovers, or other top-layer elements.
118
+ try {
119
+ const anyHost = host;
120
+ if (typeof anyHost.showPopover === "function") {
121
+ anyHost.showPopover();
122
+ }
123
+ } catch (_) { /* fall back to plain fixed positioning */ }
124
+
125
+ let x = -100;
126
+ let y = -100;
127
+ const updatePointer = () => {
128
+ pointer.style.transform = "translate3d(" + x + "px, " + y + "px, 0)";
129
+ };
130
+ updatePointer();
131
+
132
+ window.addEventListener("mousemove", (ev) => {
133
+ x = ev.clientX;
134
+ y = ev.clientY;
135
+ updatePointer();
136
+ }, true);
137
+
138
+ window.addEventListener("mousedown", () => {
139
+ ripple.removeAttribute("data-ripple-active");
140
+ ripple.style.setProperty("--rx", (x - 11) + "px");
141
+ ripple.style.setProperty("--ry", (y - 11) + "px");
142
+ // Force reflow so the animation restarts on every click.
143
+ void ripple.offsetWidth;
144
+ ripple.setAttribute("data-ripple-active", "true");
145
+ }, true);
146
+
147
+ // Breadcrumb so callers can verify the overlay mounted via the
148
+ // DOM (page.evaluate) without needing access to the page console.
149
+ document.documentElement.setAttribute("data-demo-mouse-cursor", "on");
150
+
151
+ // Re-promote ourselves in the top-layer whenever another element
152
+ // joins it (a <dialog showModal()> or a sibling popover). The
153
+ // browser stacks top-layer elements in LIFO order, so the last
154
+ // one promoted sits on top. Without this, our cursor would end
155
+ // up UNDER the author dialog as soon as Alice opens it.
156
+ const repromote = () => {
157
+ try {
158
+ const anyHost = host;
159
+ if (typeof anyHost.hidePopover === "function") {
160
+ anyHost.hidePopover();
161
+ }
162
+ if (typeof anyHost.showPopover === "function") {
163
+ anyHost.showPopover();
164
+ }
165
+ } catch (_) { /* ignore */ }
166
+ };
167
+
168
+ const isTopLayerCandidate = (el) => {
169
+ if (!el || el === host) return false;
170
+ if (el.nodeName === "DIALOG" && el.hasAttribute("open")) return true;
171
+ if (el.hasAttribute && el.hasAttribute("popover")) return true;
172
+ return false;
173
+ };
174
+
175
+ const domObserver = new MutationObserver((mutations) => {
176
+ for (const m of mutations) {
177
+ if (m.type === "attributes") {
178
+ if (isTopLayerCandidate(m.target)) {
179
+ repromote();
180
+ return;
181
+ }
182
+ } else if (m.type === "childList") {
183
+ for (const n of Array.from(m.addedNodes)) {
184
+ if (n.nodeType === 1 && isTopLayerCandidate(n)) {
185
+ repromote();
186
+ return;
187
+ }
188
+ }
189
+ }
190
+ }
191
+ });
192
+ try {
193
+ domObserver.observe(document.documentElement, {
194
+ subtree: true,
195
+ childList: true,
196
+ attributes: true,
197
+ attributeFilter: ["open", "popover"],
198
+ });
199
+ } catch (_) { /* ignore */ }
200
+ };
201
+
202
+ if (document.readyState === "loading") {
203
+ document.addEventListener("DOMContentLoaded", mount, { once: true });
204
+ } else {
205
+ mount();
206
+ }
207
+
208
+ // Re-mount on history/SPA navigations that don't reload the document
209
+ // (e.g. the published article link kept inside the same SPA tree).
210
+ // We don't have such cases today, but the check is cheap.
211
+ const observer = new MutationObserver(() => {
212
+ if (!document.getElementById(OVERLAY_ID) && document.documentElement) {
213
+ mount();
214
+ }
215
+ });
216
+ try {
217
+ observer.observe(document.documentElement, { childList: true });
218
+ } catch (_) { /* ignore */ }
219
+ })();
220
+ `;
221
+
222
+ /**
223
+ * Register the cursor overlay as an init script on the given context
224
+ * (or page). Must run BEFORE the first navigation so the overlay is
225
+ * live on every document the page loads (including the published
226
+ * article the demo opens in the same tab).
227
+ */
228
+ export async function injectMouseCursor(target: {
229
+ addInitScript: (
230
+ script: { content: string } | string | ((arg: unknown) => unknown),
231
+ arg?: unknown,
232
+ ) => Promise<void>;
233
+ }): Promise<void> {
234
+ await target.addInitScript({ content: OVERLAY_SCRIPT });
235
+ }
236
+
237
+ /**
238
+ * Verify the overlay actually mounted on the given page. Returns true
239
+ * if the breadcrumb attribute is present on `<html>`. Callers log a
240
+ * warning otherwise so a silent failure doesn't eat the whole demo.
241
+ */
242
+ export async function verifyMouseCursor(page: Page): Promise<boolean> {
243
+ try {
244
+ return await page.evaluate(
245
+ () => document.documentElement.getAttribute("data-demo-mouse-cursor") === "on",
246
+ );
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Movement helpers
254
+ // ---------------------------------------------------------------------------
255
+
256
+ interface MoveOpts {
257
+ /** Total move duration in ms. Defaults depend on distance. */
258
+ durationMs?: number;
259
+ /** Steps used by `page.mouse.move(x, y, { steps })`. */
260
+ steps?: number;
261
+ /** Random offset (px) added to the target to feel less robotic. */
262
+ jitter?: number;
263
+ }
264
+
265
+ let lastX = 40;
266
+ let lastY = 40;
267
+
268
+ function easeInOutCubic(t: number): number {
269
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
270
+ }
271
+
272
+ /**
273
+ * Move the mouse from its last known position to `(x, y)` with a cubic
274
+ * ease so the overlay cursor glides instead of teleporting. We track
275
+ * the last position ourselves because Playwright doesn't expose the
276
+ * current mouse coordinates.
277
+ */
278
+ export async function humanMove(
279
+ page: Page,
280
+ x: number,
281
+ y: number,
282
+ opts: MoveOpts = {},
283
+ ): Promise<void> {
284
+ const jitter = opts.jitter ?? 2;
285
+ const tx = x + (Math.random() * 2 - 1) * jitter;
286
+ const ty = y + (Math.random() * 2 - 1) * jitter;
287
+
288
+ const dx = tx - lastX;
289
+ const dy = ty - lastY;
290
+ const dist = Math.hypot(dx, dy);
291
+
292
+ const steps =
293
+ opts.steps ?? Math.max(10, Math.min(40, Math.round(dist / 18)));
294
+ const duration = opts.durationMs ?? Math.max(260, Math.min(700, dist * 0.9));
295
+ const stepDelay = duration / steps;
296
+
297
+ // Quadratic Bezier control point offset perpendicular to the
298
+ // straight line, so the path arcs slightly. Side alternates per
299
+ // move so consecutive trips don't bend the same way.
300
+ const perp = { x: -dy, y: dx };
301
+ const perpLen = Math.hypot(perp.x, perp.y) || 1;
302
+ const curveSide = Math.random() < 0.5 ? 1 : -1;
303
+ const curveAmp = Math.min(60, dist * 0.12) * curveSide;
304
+ const ctrl = {
305
+ x: lastX + dx * 0.5 + (perp.x / perpLen) * curveAmp,
306
+ y: lastY + dy * 0.5 + (perp.y / perpLen) * curveAmp,
307
+ };
308
+
309
+ for (let i = 1; i <= steps; i++) {
310
+ const t = easeInOutCubic(i / steps);
311
+ const u = 1 - t;
312
+ const px = u * u * lastX + 2 * u * t * ctrl.x + t * t * tx;
313
+ const py = u * u * lastY + 2 * u * t * ctrl.y + t * t * ty;
314
+ await page.mouse.move(px, py);
315
+ if (stepDelay > 0) {
316
+ await new Promise((r) => setTimeout(r, stepDelay));
317
+ }
318
+ }
319
+
320
+ lastX = tx;
321
+ lastY = ty;
322
+ }
323
+
324
+ /**
325
+ * Move the mouse to the center of `locator` with `humanMove`. Uses
326
+ * `boundingBox()` so the helper works for any element, including
327
+ * overlays like the chat panel or the settings drawer.
328
+ */
329
+ export async function humanHover(
330
+ page: Page,
331
+ locator: Locator,
332
+ opts: MoveOpts = {},
333
+ ): Promise<boolean> {
334
+ const handle = await locator.first().elementHandle();
335
+ if (!handle) return false;
336
+ const box = await handle.boundingBox();
337
+ if (!box) return false;
338
+ const cx = box.x + box.width / 2;
339
+ const cy = box.y + box.height / 2;
340
+ await humanMove(page, cx, cy, opts);
341
+ return true;
342
+ }
343
+
344
+ /**
345
+ * Smoothly move the mouse to `locator`, pause briefly, then click.
346
+ * Falls back to `locator.click()` if the element has no bounding box
347
+ * (detached, display:none, etc.) so callers don't have to branch.
348
+ */
349
+ export async function humanClick(
350
+ page: Page,
351
+ locator: Locator,
352
+ opts: MoveOpts & { settleMs?: [number, number] } = {},
353
+ ): Promise<void> {
354
+ const moved = await humanHover(page, locator, opts);
355
+ if (!moved) {
356
+ await locator.click();
357
+ return;
358
+ }
359
+ const [lo, hi] = opts.settleMs ?? [60, 140];
360
+ const pause = lo + Math.random() * (hi - lo);
361
+ await new Promise((r) => setTimeout(r, pause));
362
+ await page.mouse.down();
363
+ await new Promise((r) => setTimeout(r, 30 + Math.random() * 30));
364
+ await page.mouse.up();
365
+ }
366
+
367
+ /**
368
+ * Reset the cached mouse position. Useful at the start of a new run.
369
+ */
370
+ export function resetMouseState(x = 40, y = 40): void {
371
+ lastX = x;
372
+ lastY = y;
373
+ }
backend/demo/lib/publish-flow.ts CHANGED
@@ -10,6 +10,7 @@
10
  */
11
  import type { Page } from "playwright";
12
  import { pause } from "../human-typing.js";
 
13
 
14
  /**
15
  * Drag the primary-hue slider in the Settings drawer from its current
@@ -50,9 +51,7 @@ export async function applyHueChange(
50
  const cog = page.getByRole("button", { name: "Article settings" }).first();
51
  try {
52
  await cog.waitFor({ state: "visible", timeout: 5_000 });
53
- await cog.hover().catch(() => undefined);
54
- await pause(40, 80);
55
- await cog.click();
56
  } catch {
57
  console.warn("[hue] settings cog not found");
58
  return;
@@ -92,9 +91,9 @@ export async function applyHueChange(
92
  // the viewer's eye follows the mouse into the drawer before
93
  // the press-and-drag begins. Kept short so the whole interaction
94
  // reads as "a snappy human edit" rather than a slow ceremony.
95
- await page.mouse.move(startX - 40, y - 20, { steps: 3 });
96
- await pause(30, 60);
97
- await page.mouse.move(startX, y, { steps: 4 });
98
  await pause(40, 70);
99
 
100
  // 4) Press down ON the slider. `setPointerCapture` locks further
@@ -121,6 +120,9 @@ export async function applyHueChange(
121
  }
122
 
123
  await page.mouse.up();
 
 
 
124
  await pause(120, 180);
125
 
126
  // 6) Close the drawer so the final state is "clean article + new
@@ -130,7 +132,7 @@ export async function applyHueChange(
130
  .locator(".settings-drawer button[aria-label='Close']")
131
  .first();
132
  if (await closeDrawer.isVisible().catch(() => false)) {
133
- await closeDrawer.click();
134
  } else {
135
  await page.keyboard.press("Escape").catch(() => undefined);
136
  }
@@ -177,7 +179,7 @@ export async function triggerPublish(
177
  .first();
178
  try {
179
  await publishBtn.waitFor({ state: "visible", timeout: 4_000 });
180
- await publishBtn.click();
181
  console.log("[publish] clicked Publish, waiting for success...");
182
  } catch {
183
  console.warn("[publish] primary button not found in dialog");
@@ -207,42 +209,82 @@ export async function triggerPublish(
207
  // Tiny beat so the "Published!" title is readable before the click.
208
  await pause(600, 900);
209
 
210
- // 4) Navigate to the published article IN THE SAME TAB.
211
- // The link is rendered as `target="_blank"` for real users, which
212
- // is the right UX outside of the demo. But in --app=/fullscreen
213
- // record mode, target="_blank" spawns a regular windowed Chrome
214
- // (with tabs + URL bar, NOT fullscreen) because app windows
215
- // can't host tabs. That regular window also re-hydrates
216
- // Chrome's translate heuristics and pops a "Translate to French"
217
- // bubble over the recording on OS-French systems.
 
 
 
 
 
 
 
 
 
218
  //
219
- // Reading `href` + `page.goto` keeps us in Alice's chromeless
220
- // fullscreen window: no new process, no translate prompt, no
221
- // visible URL bar. Exactly the frame the recording needs.
 
 
 
222
  const publishedHref = await viewLink.getAttribute("href");
223
  if (!publishedHref) {
224
  console.warn("[publish] view link has no href, cannot navigate");
225
  return;
226
  }
227
 
228
- // Resolve relative URLs against the current page origin so
229
- // `/published/xyz/index.html` works unchanged.
230
- const targetUrl = new URL(publishedHref, page.url()).toString();
231
  try {
232
- await page.goto(targetUrl, {
233
- waitUntil: "domcontentloaded",
234
- timeout: 15_000,
235
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  await page
237
  .waitForLoadState("load", { timeout: 5_000 })
238
  .catch(() => undefined);
239
  console.log("[publish] viewing published article (in-place)");
240
  } catch (err) {
 
 
 
 
 
241
  console.warn(
242
- "[publish] navigation to published article failed:",
243
  err instanceof Error ? err.message : err,
244
  );
245
- return;
 
 
 
 
 
 
 
 
 
 
 
 
246
  }
247
 
248
  await pause(lingerMin, lingerMax);
 
10
  */
11
  import type { Page } from "playwright";
12
  import { pause } from "../human-typing.js";
13
+ import { humanClick, humanMove, resetMouseState } from "./mouse-cursor.js";
14
 
15
  /**
16
  * Drag the primary-hue slider in the Settings drawer from its current
 
51
  const cog = page.getByRole("button", { name: "Article settings" }).first();
52
  try {
53
  await cog.waitFor({ state: "visible", timeout: 5_000 });
54
+ await humanClick(page, cog);
 
 
55
  } catch {
56
  console.warn("[hue] settings cog not found");
57
  return;
 
91
  // the viewer's eye follows the mouse into the drawer before
92
  // the press-and-drag begins. Kept short so the whole interaction
93
  // reads as "a snappy human edit" rather than a slow ceremony.
94
+ // `humanMove` produces a curved, eased path the overlay cursor
95
+ // renders; straight `page.mouse.move` would teleport on camera.
96
+ await humanMove(page, startX, y, { durationMs: 420 });
97
  await pause(40, 70);
98
 
99
  // 4) Press down ON the slider. `setPointerCapture` locks further
 
120
  }
121
 
122
  await page.mouse.up();
123
+ // Sync the tracked cursor position to where the drag left off, so
124
+ // the next `humanMove` doesn't animate from a stale origin.
125
+ resetMouseState(endX, y);
126
  await pause(120, 180);
127
 
128
  // 6) Close the drawer so the final state is "clean article + new
 
132
  .locator(".settings-drawer button[aria-label='Close']")
133
  .first();
134
  if (await closeDrawer.isVisible().catch(() => false)) {
135
+ await humanClick(page, closeDrawer);
136
  } else {
137
  await page.keyboard.press("Escape").catch(() => undefined);
138
  }
 
179
  .first();
180
  try {
181
  await publishBtn.waitFor({ state: "visible", timeout: 4_000 });
182
+ await humanClick(page, publishBtn);
183
  console.log("[publish] clicked Publish, waiting for success...");
184
  } catch {
185
  console.warn("[publish] primary button not found in dialog");
 
209
  // Tiny beat so the "Published!" title is readable before the click.
210
  await pause(600, 900);
211
 
212
+ // 4) Visibly click "View published article" and navigate IN
213
+ // THE SAME TAB.
214
+ //
215
+ // Why the gymnastics: the link is rendered `target="_blank"`
216
+ // for real users (correct UX outside the demo). But in
217
+ // --app=/fullscreen record mode, target="_blank" spawns a
218
+ // regular windowed Chrome (with tabs + URL bar, NOT fullscreen)
219
+ // because app windows cannot host tabs. That stray window
220
+ // also re-hydrates Chrome's translate heuristics and pops a
221
+ // "Translate to French" bubble over the recording on French
222
+ // OSes. We want neither.
223
+ //
224
+ // The old approach read `href` and called `page.goto`
225
+ // directly - no click, no cursor motion, the "button press"
226
+ // never happened on camera. That reads as "the demo ended
227
+ // and something magic happened". Users want to SEE Alice
228
+ // click the button.
229
  //
230
+ // Fix: neutralise the target attribute via evaluate so the
231
+ // link navigates in-place, then humanClick it for real. The
232
+ // click triggers the native <a href> navigation which keeps
233
+ // Alice in her chromeless fullscreen window (no tabs, no
234
+ // URL bar, no translate prompt) while still showing the
235
+ // cursor land on the button.
236
  const publishedHref = await viewLink.getAttribute("href");
237
  if (!publishedHref) {
238
  console.warn("[publish] view link has no href, cannot navigate");
239
  return;
240
  }
241
 
 
 
 
242
  try {
243
+ await viewLink.evaluate((el: HTMLAnchorElement) => {
244
+ el.removeAttribute("target");
245
+ el.removeAttribute("rel");
246
  });
247
+ } catch {
248
+ /* non-fatal, the goto fallback below handles it */
249
+ }
250
+
251
+ try {
252
+ // `waitForNavigation` must be armed BEFORE the click fires,
253
+ // otherwise the navigation event can land before we start
254
+ // awaiting it and the promise hangs on the next URL.
255
+ const navigation = page.waitForURL(
256
+ (url) => url.pathname.includes("/published/"),
257
+ { timeout: 15_000 },
258
+ );
259
+ await humanClick(page, viewLink);
260
+ await navigation;
261
  await page
262
  .waitForLoadState("load", { timeout: 5_000 })
263
  .catch(() => undefined);
264
  console.log("[publish] viewing published article (in-place)");
265
  } catch (err) {
266
+ // Click-driven navigation failed (link handler ate the event,
267
+ // target attribute somehow survived, ...). Fall back to the
268
+ // old direct-goto so the demo still lands on the published
269
+ // output - losing the cursor motion on the button, but the
270
+ // alternative is a blank recording finale.
271
  console.warn(
272
+ "[publish] click-driven navigation failed, falling back to goto:",
273
  err instanceof Error ? err.message : err,
274
  );
275
+ const targetUrl = new URL(publishedHref, page.url()).toString();
276
+ try {
277
+ await page.goto(targetUrl, {
278
+ waitUntil: "domcontentloaded",
279
+ timeout: 15_000,
280
+ });
281
+ } catch (err2) {
282
+ console.warn(
283
+ "[publish] navigation to published article failed:",
284
+ err2 instanceof Error ? err2.message : err2,
285
+ );
286
+ return;
287
+ }
288
  }
289
 
290
  await pause(lingerMin, lingerMax);
backend/demo/lib/scaffolding.ts CHANGED
@@ -13,6 +13,7 @@
13
  */
14
  import type { Page } from "playwright";
15
  import { humanType, pause } from "../human-typing.js";
 
16
 
17
  export interface ScaffoldSection {
18
  heading: string;
@@ -33,6 +34,87 @@ export interface ScaffoldSection {
33
  * <p></p> <- slot 0
34
  * ...
35
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  export async function writeScaffold(
37
  page: Page,
38
  sections: ScaffoldSection[],
@@ -42,8 +124,18 @@ export async function writeScaffold(
42
  const section = sections[s];
43
  const isLast = s === sections.length - 1;
44
 
45
- await page.keyboard.type("## ", { delay: 22 });
46
- await pause(40, 80);
 
 
 
 
 
 
 
 
 
 
47
  await humanType(page, section.heading, {
48
  speed: speed * 2.2,
49
  typoRate: 0,
@@ -75,6 +167,63 @@ export async function waitForHeading2(
75
  .waitFor({ state: "visible", timeout });
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  /**
79
  * Place this browser's caret at the END of the first <p> directly after
80
  * the heading whose text matches `headingText`. Used so each persona can
@@ -128,8 +277,54 @@ export async function focusNthParagraphInSection(
128
  headingText: string,
129
  slotIndex: number,
130
  ): Promise<boolean> {
131
- return await page.evaluate(
 
 
 
 
132
  ({ text, n }) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  const g = globalThis as unknown as { __name?: <T>(fn: T) => T };
134
  if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
135
 
@@ -152,22 +347,14 @@ export async function focusNthParagraphInSection(
152
 
153
  const target = slots[n];
154
  if (!target) return false;
155
- target.scrollIntoView({ behavior: "auto", block: "center" });
156
-
157
- // Pick a click point at the END of the paragraph's visible box so
158
- // the caret lands after any existing content (still safe for
159
- // empty paragraphs).
160
- const rect = target.getBoundingClientRect();
161
- const x = Math.max(rect.left + 1, rect.right - 4);
162
- const y = rect.top + rect.height / 2;
163
 
164
  const mk = (type: string) =>
165
  new MouseEvent(type, {
166
  bubbles: true,
167
  cancelable: true,
168
  view: window,
169
- clientX: x,
170
- clientY: y,
171
  button: 0,
172
  buttons: type === "mousedown" ? 1 : 0,
173
  });
@@ -186,6 +373,6 @@ export async function focusNthParagraphInSection(
186
  }
187
  return true;
188
  },
189
- { text: headingText, n: slotIndex },
190
  );
191
  }
 
13
  */
14
  import type { Page } from "playwright";
15
  import { humanType, pause } from "../human-typing.js";
16
+ import { humanMove } from "./mouse-cursor.js";
17
 
18
  export interface ScaffoldSection {
19
  heading: string;
 
34
  * <p></p> <- slot 0
35
  * ...
36
  */
37
+ /**
38
+ * Open the slash-menu ("/") suggestion popover and pick "Heading 2"
39
+ * using keyboard-only navigation - no filter text is typed, so if
40
+ * the popover fails to mount we don't leak garbage ("/heading 2")
41
+ * into the editor.
42
+ *
43
+ * Flat menu order (used by the suggestion plugin for ArrowUp /
44
+ * ArrowDown, independent of the grouped render): Heading 1 (index
45
+ * 0), Heading 2 (index 1), ... so ONE ArrowDown from the default
46
+ * selection lands on "Heading 2".
47
+ *
48
+ * Used for the FIRST section of the scaffold so viewers see the
49
+ * block-command popover fire and close cleanly. Subsequent
50
+ * headings use the faster Markdown `## ` input rule for variety.
51
+ *
52
+ * The menu's own command deletes the "/" trigger before running
53
+ * `toggleHeading({ level: 2 })`, so after this helper returns the
54
+ * caret is already parked inside an empty <h2> ready for heading
55
+ * text.
56
+ */
57
+ async function pickHeading2ViaSlashMenu(page: Page): Promise<boolean> {
58
+ // Move the visible cursor over the editor BEFORE typing "/" so the
59
+ // viewer sees the pointer parked on the paragraph where the slash
60
+ // menu is about to bloom. Otherwise the menu opens while the
61
+ // cursor is stranded on whatever button Alice last clicked
62
+ // (typically the "Add author" confirm), which reads as a ghost
63
+ // interaction.
64
+ try {
65
+ const rect = await page.evaluate(() => {
66
+ const root = document.querySelector(".ProseMirror") as HTMLElement | null;
67
+ if (!root) return null;
68
+ const sel = window.getSelection();
69
+ // Prefer the caret's own rect so the cursor lands exactly where
70
+ // the "/" will be typed. Fall back to the last paragraph if the
71
+ // caret is detached.
72
+ if (sel && sel.rangeCount > 0) {
73
+ const r = sel.getRangeAt(0).getClientRects()[0];
74
+ if (r && r.width >= 0 && r.height > 0) {
75
+ return { x: r.left + 6, y: r.top + r.height / 2 };
76
+ }
77
+ }
78
+ const paras = root.querySelectorAll("p");
79
+ const last = paras[paras.length - 1] as HTMLElement | undefined;
80
+ if (!last) return null;
81
+ const lr = last.getBoundingClientRect();
82
+ return { x: lr.left + 24, y: lr.top + lr.height / 2 };
83
+ });
84
+ if (rect) await humanMove(page, rect.x, rect.y, { steps: 18 });
85
+ } catch {
86
+ /* non-fatal, typing still works without the cursor following */
87
+ }
88
+ await pause(80, 140);
89
+ await page.keyboard.type("/", { delay: 30 });
90
+ // Wait for the tippy-mounted popover to appear. The Suggestion
91
+ // plugin schedules a React render + tippy mount after the "/"
92
+ // lands in state, which can take two animation frames.
93
+ const popoverLocator = page.locator(".slash-menu").first();
94
+ try {
95
+ await popoverLocator.waitFor({ state: "visible", timeout: 1_200 });
96
+ } catch {
97
+ // Popover never mounted (race with initial focus / input
98
+ // handler). Backspace the stray "/" so the caller's fallback
99
+ // doesn't stack it in front of "## ".
100
+ await page.keyboard.press("Backspace");
101
+ await pause(40, 80);
102
+ return false;
103
+ }
104
+ // Pause so the viewer actually sees the menu open before we
105
+ // navigate.
106
+ await pause(420, 640);
107
+ // Default selectedIndex is 0 = "Heading 1"; one ArrowDown lands
108
+ // on "Heading 2". We do this purely via arrow keys so NO
109
+ // filter text is typed into the doc if the suggestion plugin
110
+ // unexpectedly closes mid-sequence.
111
+ await page.keyboard.press("ArrowDown");
112
+ await pause(160, 260);
113
+ await page.keyboard.press("Enter");
114
+ await pause(140, 220);
115
+ return true;
116
+ }
117
+
118
  export async function writeScaffold(
119
  page: Page,
120
  sections: ScaffoldSection[],
 
124
  const section = sections[s];
125
  const isLast = s === sections.length - 1;
126
 
127
+ // Use the slash menu for the first heading so the demo shows
128
+ // the block-command popover at least once. The remaining
129
+ // sections stick to the Markdown `## ` shortcut to keep the
130
+ // scaffold phase short.
131
+ let createdViaSlash = false;
132
+ if (s === 0) {
133
+ createdViaSlash = await pickHeading2ViaSlashMenu(page);
134
+ }
135
+ if (!createdViaSlash) {
136
+ await page.keyboard.type("## ", { delay: 22 });
137
+ await pause(40, 80);
138
+ }
139
  await humanType(page, section.heading, {
140
  speed: speed * 2.2,
141
  typoRate: 0,
 
167
  .waitFor({ state: "visible", timeout });
168
  }
169
 
170
+ /**
171
+ * Wait for the OTHER peers' final contributions to have landed in
172
+ * the shared doc, then give the browser one settle frame before
173
+ * returning. Use this as a pre-bold sync point so the layout is
174
+ * frozen when we compute and drag over the target phrase: if we
175
+ * skip it, Bob's `<blockMath>` insertion or Carol's `<pre>` code
176
+ * block can still be reshaping the doc during the drag, which
177
+ * shifts the Intuition paragraph out from under the measured
178
+ * rects and the selection sweep picks up the heading above.
179
+ *
180
+ * The markers below are the LAST things each peer writes:
181
+ * - Bob: `.ProseMirror math-display` node (the attention
182
+ * formula in The recipe[1])
183
+ * - Carol: `.ProseMirror pre code` with "import torch" as text
184
+ * (the eight-line PyTorch snippet)
185
+ *
186
+ * Both are idempotent: if the peer finished earlier, the locator
187
+ * resolves immediately. The extra `layoutSettleMs` beat after
188
+ * resolution lets Tiptap's decoration plugin repaint so
189
+ * `getClientRects()` returns the final geometry.
190
+ */
191
+ export async function waitForPeersSettled(
192
+ page: Page,
193
+ opts: {
194
+ timeout?: number;
195
+ layoutSettleMs?: number;
196
+ } = {},
197
+ ): Promise<void> {
198
+ const timeout = opts.timeout ?? 45_000;
199
+ const layoutSettleMs = opts.layoutSettleMs ?? 700;
200
+
201
+ const bobMarker = page
202
+ .locator(".ProseMirror [data-type='block-math']")
203
+ .first();
204
+ const carolMarker = page
205
+ .locator(".ProseMirror pre")
206
+ .filter({ hasText: /import\s+torch/i })
207
+ .first();
208
+
209
+ try {
210
+ await Promise.all([
211
+ bobMarker.waitFor({ state: "attached", timeout }),
212
+ carolMarker.waitFor({ state: "attached", timeout }),
213
+ ]);
214
+ } catch (err) {
215
+ console.warn(
216
+ "[sync] waitForPeersSettled timed out:",
217
+ err instanceof Error ? err.message : err,
218
+ );
219
+ }
220
+
221
+ // Layout settle: even after both markers exist, Tiptap may emit
222
+ // one more transaction frame to finalize decorations. Pause to
223
+ // let the scroll height stabilise before any rect measurements.
224
+ await page.waitForTimeout(layoutSettleMs);
225
+ }
226
+
227
  /**
228
  * Place this browser's caret at the END of the first <p> directly after
229
  * the heading whose text matches `headingText`. Used so each persona can
 
277
  headingText: string,
278
  slotIndex: number,
279
  ): Promise<boolean> {
280
+ // Phase A: locate the slot and compute the click point, but DON'T
281
+ // dispatch events yet. We want to animate the visible cursor to the
282
+ // target first so the viewer sees the pointer land on the paragraph
283
+ // before the caret jumps there.
284
+ const rect = await page.evaluate(
285
  ({ text, n }) => {
286
+ const root = document.querySelector(".ProseMirror") as HTMLElement | null;
287
+ if (!root) return null;
288
+ const headings = Array.from(root.querySelectorAll("h2"));
289
+ const wanted = text.toLowerCase();
290
+ const heading = headings.find(
291
+ (h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
292
+ );
293
+ if (!heading) return null;
294
+
295
+ const slots: HTMLElement[] = [];
296
+ let node: Element | null = heading.nextElementSibling;
297
+ while (node) {
298
+ if (/^H[1-6]$/.test(node.tagName)) break;
299
+ slots.push(node as HTMLElement);
300
+ node = node.nextElementSibling;
301
+ }
302
+
303
+ const target = slots[n];
304
+ if (!target) return null;
305
+ target.scrollIntoView({ behavior: "auto", block: "center" });
306
+
307
+ const r = target.getBoundingClientRect();
308
+ return {
309
+ x: Math.max(r.left + 1, r.right - 4),
310
+ y: r.top + r.height / 2,
311
+ };
312
+ },
313
+ { text: headingText, n: slotIndex },
314
+ );
315
+
316
+ if (!rect) return false;
317
+
318
+ // Let the auto-scroll settle before we move onto the target,
319
+ // otherwise the humanMove aim is stale.
320
+ await pause(120, 220);
321
+ await humanMove(page, rect.x, rect.y, { steps: 20 });
322
+
323
+ // Phase B: now place the caret. Dispatch synthesized mouse events
324
+ // at the exact coordinates we just moved to (ProseMirror listens
325
+ // for these), and set the DOM Range as a belt-and-braces fallback.
326
+ return await page.evaluate(
327
+ ({ text, n, clickX, clickY }) => {
328
  const g = globalThis as unknown as { __name?: <T>(fn: T) => T };
329
  if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
330
 
 
347
 
348
  const target = slots[n];
349
  if (!target) return false;
 
 
 
 
 
 
 
 
350
 
351
  const mk = (type: string) =>
352
  new MouseEvent(type, {
353
  bubbles: true,
354
  cancelable: true,
355
  view: window,
356
+ clientX: clickX,
357
+ clientY: clickY,
358
  button: 0,
359
  buttons: type === "mousedown" ? 1 : 0,
360
  });
 
373
  }
374
  return true;
375
  },
376
+ { text: headingText, n: slotIndex, clickX: rect.x, clickY: rect.y },
377
  );
378
  }
backend/demo/lib/selection.ts CHANGED
@@ -9,6 +9,8 @@
9
  * concrete range.
10
  */
11
  import type { Page } from "playwright";
 
 
12
 
13
  /**
14
  * Read the current ProseMirror selection range. Returns null if the
@@ -65,18 +67,25 @@ export async function getCurrentPmSelection(
65
  *
66
  * Side effects
67
  * ------------
68
- * On success, also seeds PM's selection AND broadcasts the range as
69
- * the local user's `AgentFocus` so every peer sees a `<Name> agent`
70
- * tinted range (same UX as when the chat panel opens over a manual
71
- * selection).
 
 
 
 
 
72
  */
73
  export async function selectSubstringInParagraph(
74
  page: Page,
75
  paragraphSnippet: string,
76
  substring: string,
 
77
  ): Promise<{ from: number; to: number } | null> {
 
78
  return await page.evaluate(
79
- ({ paraText, substrText }) => {
80
  const g = globalThis as unknown as {
81
  __name?: <T>(fn: T, name?: string) => T;
82
  };
@@ -147,11 +156,274 @@ export async function selectSubstringInParagraph(
147
  }
148
 
149
  editor.commands.setTextSelection({ from, to });
150
- editor.commands.setAgentFocus({ from, to });
151
  return { from, to };
152
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  { paraText: paragraphSnippet, substrText: substring },
154
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  }
156
 
157
  /**
 
9
  * concrete range.
10
  */
11
  import type { Page } from "playwright";
12
+ import { pause } from "../human-typing.js";
13
+ import { humanMove, resetMouseState } from "./mouse-cursor.js";
14
 
15
  /**
16
  * Read the current ProseMirror selection range. Returns null if the
 
67
  *
68
  * Side effects
69
  * ------------
70
+ * On success, always seeds PM's text selection. If `setAgentFocus`
71
+ * is true, ALSO broadcasts the range as the local user's AgentFocus
72
+ * so every peer sees a `<Name> agent` tinted range - that's the
73
+ * correct behaviour for chat-driven actions (format / link /
74
+ * citation via the assistant), where the viewer should perceive
75
+ * the agent as owning the range. For pure editor UX paths (bubble
76
+ * toolbar bold, plain text selection before typing), leave it
77
+ * false so the agent label doesn't pop up out of nowhere while the
78
+ * chat panel is closed.
79
  */
80
  export async function selectSubstringInParagraph(
81
  page: Page,
82
  paragraphSnippet: string,
83
  substring: string,
84
+ opts: { setAgentFocus?: boolean } = {},
85
  ): Promise<{ from: number; to: number } | null> {
86
+ const setFocus = opts.setAgentFocus ?? false;
87
  return await page.evaluate(
88
+ ({ paraText, substrText, setFocus: doFocus }) => {
89
  const g = globalThis as unknown as {
90
  __name?: <T>(fn: T, name?: string) => T;
91
  };
 
156
  }
157
 
158
  editor.commands.setTextSelection({ from, to });
159
+ if (doFocus) editor.commands.setAgentFocus({ from, to });
160
  return { from, to };
161
  },
162
+ {
163
+ paraText: paragraphSnippet,
164
+ substrText: substring,
165
+ setFocus,
166
+ },
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Select a substring by simulating a real mouse drag across it.
172
+ *
173
+ * Unlike `selectSubstringInParagraph`, which performs a purely
174
+ * programmatic PM selection, this helper moves the visible cursor
175
+ * overlay from one viewport coordinate to the next and fires actual
176
+ * `mousedown` / `mousemove` / `mouseup` events. Viewers watching a
177
+ * recorded demo see the cursor travel over the phrase while the
178
+ * selection paint follows along - this is what the user expects when
179
+ * they say "I want to *see* Alice select text".
180
+ *
181
+ * Robustness:
182
+ * - The drag can end up off by a glyph at the very edge (subpixel
183
+ * hit testing differs per platform), so after `mouseup` we always
184
+ * correct the PM selection to the exact {from, to} computed from
185
+ * the DOM TreeWalker mapping. That way the bold / link / agent-
186
+ * focus action always operates on the intended range.
187
+ * - Returns null if the paragraph or substring can't be found; the
188
+ * caller can then fall back to `selectSubstringInParagraph`.
189
+ *
190
+ * The `setAgentFocus` flag matches the sibling helper: leave it
191
+ * false for user-driven bubble toolbar interactions so the agent
192
+ * label doesn't flash up while the chat panel is closed.
193
+ */
194
+ export async function dragSelectSubstringInParagraph(
195
+ page: Page,
196
+ paragraphSnippet: string,
197
+ substring: string,
198
+ opts: { setAgentFocus?: boolean } = {},
199
+ ): Promise<{ from: number; to: number } | null> {
200
+ const setFocus = opts.setAgentFocus ?? false;
201
+
202
+ const rect = await page.evaluate(
203
+ ({ paraText, substrText }) => {
204
+ const root = document.querySelector(".ProseMirror") as HTMLElement | null;
205
+ if (!root) return null;
206
+ const para =
207
+ (Array.from(root.querySelectorAll("p")).find((p) =>
208
+ (p.textContent || "").includes(paraText),
209
+ ) as HTMLElement | undefined) ?? null;
210
+ if (!para) return null;
211
+ // "auto" (instant) rather than "smooth": smooth scroll is
212
+ // still animating when our caller's humanMove lands on the
213
+ // drag-start coordinates, which means the rects we compute
214
+ // here reference an onscreen position that has already
215
+ // shifted - the pointer ends up a few lines above/below the
216
+ // phrase and the browser-native selection sweep picks up
217
+ // the heading above or the math block below. Instant scroll
218
+ // pins the layout BEFORE we measure.
219
+ para.scrollIntoView({ behavior: "auto", block: "center" });
220
+
221
+ const text = para.textContent || "";
222
+ const idx = text.indexOf(substrText);
223
+ if (idx === -1) return null;
224
+
225
+ const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);
226
+ let walked = 0;
227
+ let startNode: Text | null = null;
228
+ let startOffset = 0;
229
+ let endNode: Text | null = null;
230
+ let endOffset = 0;
231
+ while (walker.nextNode()) {
232
+ const node = walker.currentNode as Text;
233
+ const len = node.nodeValue?.length ?? 0;
234
+ if (!startNode && walked + len > idx) {
235
+ startNode = node;
236
+ startOffset = idx - walked;
237
+ }
238
+ if (walked + len >= idx + substrText.length) {
239
+ endNode = node;
240
+ endOffset = idx + substrText.length - walked;
241
+ break;
242
+ }
243
+ walked += len;
244
+ }
245
+ if (!startNode || !endNode) return null;
246
+
247
+ const range = document.createRange();
248
+ try {
249
+ range.setStart(startNode, startOffset);
250
+ range.setEnd(endNode, endOffset);
251
+ } catch {
252
+ return null;
253
+ }
254
+ // Some layouts split the range across two lines; we always
255
+ // take the first client rect as the start and the last as
256
+ // the end, so the drag follows the visual reading order.
257
+ const rects = Array.from(range.getClientRects());
258
+ if (rects.length === 0) return null;
259
+ const first = rects[0];
260
+ const last = rects[rects.length - 1];
261
+
262
+ const editor = (
263
+ window as unknown as {
264
+ __demoEditor?: {
265
+ view: {
266
+ posAtDOM: (node: Node, offset: number, bias?: number) => number;
267
+ };
268
+ };
269
+ }
270
+ ).__demoEditor;
271
+ if (!editor) return null;
272
+ let from: number;
273
+ let to: number;
274
+ try {
275
+ from = editor.view.posAtDOM(startNode, startOffset, -1);
276
+ to = editor.view.posAtDOM(endNode, endOffset, 1);
277
+ } catch {
278
+ return null;
279
+ }
280
+ if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from)) {
281
+ return null;
282
+ }
283
+
284
+ return {
285
+ from,
286
+ to,
287
+ startX: first.left + 2,
288
+ startY: first.top + first.height / 2,
289
+ endX: last.right - 2,
290
+ endY: last.top + last.height / 2,
291
+ };
292
+ },
293
  { paraText: paragraphSnippet, substrText: substring },
294
  );
295
+
296
+ if (!rect) return null;
297
+
298
+ // Post-scroll beat so the layout is 100% settled. Even with
299
+ // behavior: "auto", a scroll triggers a synchronous reflow but
300
+ // the next paint can still shift things by a frame.
301
+ await page.waitForTimeout(180);
302
+
303
+ // Phase 1: visible-cursor approach to the start of the phrase.
304
+ // NO mousedown - we are NOT doing a browser-native drag. A real
305
+ // drag is fragile: mouse.down 1px outside the first glyph can
306
+ // land on the paragraph margin and the sweep to the endpoint
307
+ // picks up the heading above or the math atom below, which is
308
+ // exactly the "3 lines with bits of title and text" artefact
309
+ // users see. Instead, we animate the overlay cursor over the
310
+ // text and extend the PM selection step-by-step as the cursor
311
+ // moves, so the selection paint GROWS under the pointer like
312
+ // a real drag but without the pixel-brittleness.
313
+ await humanMove(page, rect.startX, rect.startY, {
314
+ steps: 22,
315
+ jitter: 0,
316
+ });
317
+ await pause(120, 180);
318
+
319
+ // Phase 2: collapse the selection at the start of the phrase. The
320
+ // viewer reads the cursor "clicking" here.
321
+ await page.evaluate(
322
+ ({ from }) => {
323
+ const editor = (
324
+ window as unknown as {
325
+ __demoEditor?: {
326
+ commands: {
327
+ focus: () => boolean;
328
+ setTextSelection: (args: { from: number; to: number }) => boolean;
329
+ };
330
+ };
331
+ }
332
+ ).__demoEditor;
333
+ if (!editor) return;
334
+ editor.commands.focus();
335
+ editor.commands.setTextSelection({ from, to: from });
336
+ },
337
+ { from: rect.from },
338
+ );
339
+
340
+ // Phase 3: progressive drag - interpolate cursor position AND
341
+ // selection endpoint together, so the highlight grows under the
342
+ // moving cursor in sync. `page.mouse.move` straight (no bezier)
343
+ // because a curved path during a drag would dip into adjacent
344
+ // lines. Each step updates the PM selection to
345
+ // {from, to: from + (to-from) * t} so the highlight progression
346
+ // maps 1:1 to pointer progression. Any character drift at the
347
+ // final step is patched by the canonical setTextSelection below.
348
+ const dx = rect.endX - rect.startX;
349
+ const dy = rect.endY - rect.startY;
350
+ const distance = Math.hypot(dx, dy);
351
+ const selLen = rect.to - rect.from;
352
+ const glideSteps = Math.max(18, Math.min(40, Math.round(distance / 14)));
353
+ for (let i = 1; i <= glideSteps; i++) {
354
+ const t = i / glideSteps;
355
+ const x = rect.startX + dx * t;
356
+ const y = rect.startY + dy * t;
357
+ await page.mouse.move(x, y, { steps: 2 });
358
+ const midTo = rect.from + Math.round(selLen * t);
359
+ await page.evaluate(
360
+ ({ from, to }) => {
361
+ const editor = (
362
+ window as unknown as {
363
+ __demoEditor?: {
364
+ commands: {
365
+ setTextSelection: (args: {
366
+ from: number;
367
+ to: number;
368
+ }) => boolean;
369
+ };
370
+ };
371
+ }
372
+ ).__demoEditor;
373
+ editor?.commands.setTextSelection({ from, to });
374
+ },
375
+ { from: rect.from, to: midTo },
376
+ );
377
+ await page.waitForTimeout(14 + Math.floor(Math.random() * 14));
378
+ }
379
+
380
+ // Phase 4: canonical setTextSelection to guarantee the final
381
+ // range is pixel-perfect (rounding during interpolation can land
382
+ // 1 char short).
383
+ await page.evaluate(
384
+ ({ from, to }) => {
385
+ const editor = (
386
+ window as unknown as {
387
+ __demoEditor?: {
388
+ commands: {
389
+ setTextSelection: (args: { from: number; to: number }) => boolean;
390
+ };
391
+ };
392
+ }
393
+ ).__demoEditor;
394
+ editor?.commands.setTextSelection({ from, to });
395
+ },
396
+ { from: rect.from, to: rect.to },
397
+ );
398
+
399
+ // Phase 5: optional AgentFocus broadcast (chat-driven paths set
400
+ // this, pure-editor paths leave it false so the agent label
401
+ // doesn't pop up out of context).
402
+ if (setFocus) {
403
+ await page.evaluate(
404
+ ({ from, to }) => {
405
+ const editor = (
406
+ window as unknown as {
407
+ __demoEditor?: {
408
+ commands: {
409
+ setAgentFocus: (args: { from: number; to: number }) => boolean;
410
+ };
411
+ };
412
+ }
413
+ ).__demoEditor;
414
+ editor?.commands.setAgentFocus({ from, to });
415
+ },
416
+ { from: rect.from, to: rect.to },
417
+ );
418
+ }
419
+
420
+ // Resync cursor tracker. The progressive loop used page.mouse.move
421
+ // directly, which bypasses humanMove's internal lastX/lastY
422
+ // updates. Without this reset the next humanMove would glide
423
+ // from a stale origin.
424
+ resetMouseState(rect.endX, rect.endY);
425
+
426
+ return { from: rect.from, to: rect.to };
427
  }
428
 
429
  /**
backend/demo/lib/yjs-client.ts ADDED
@@ -0,0 +1,788 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * lib/yjs-client.ts
3
+ *
4
+ * Robust Yjs-level peer for demo personas (bob, carol).
5
+ *
6
+ * Why this exists
7
+ * ---------------
8
+ * The original demo drove Bob and Carol through Playwright + Chromium:
9
+ * - each persona ran an actual editor in a real browser,
10
+ * - Playwright "typed" characters into the DOM,
11
+ * - positions were tracked through MappablePosition / slot claims.
12
+ *
13
+ * That works but is fragile: ProseMirror input rules, concurrent
14
+ * AgentRewrite animations, and DOM-level selection changes all
15
+ * introduce races that occasionally drop Bob's text into the wrong
16
+ * slot or delete half a paragraph.
17
+ *
18
+ * Yjs (the CRDT Tiptap uses for collaboration) is built exactly for
19
+ * this kind of headless peer. A Node process can open a
20
+ * `HocuspocusProvider` against the same server the browsers connect
21
+ * to, sync the shared `Y.Doc`, and mutate it directly through
22
+ * `Y.XmlElement` / `Y.XmlText` APIs. Every mutation is atomic at
23
+ * the CRDT level and shows up in Alice's editor in real time, with
24
+ * zero input-rule surprises and zero PM position drift.
25
+ *
26
+ * This module exposes:
27
+ * - `connectPersona()` - open a Hocuspocus connection as a named
28
+ * peer, wait for initial sync, return a handle with the shared
29
+ * fragment, citations map, and frontmatter helpers.
30
+ * - a small DOM-like helper set to build paragraphs, headings,
31
+ * inline math, citations, and to type text character by
32
+ * character (so Alice visually sees the peer typing).
33
+ */
34
+ import { WebSocket } from "ws";
35
+ import { HocuspocusProvider } from "@hocuspocus/provider";
36
+ import * as Y from "yjs";
37
+
38
+ // The Hocuspocus provider expects a DOM `WebSocket` global; Node
39
+ // doesn't ship one so we plumb `ws` in before any provider is
40
+ // constructed. Idempotent.
41
+ if (!(globalThis as unknown as { WebSocket?: unknown }).WebSocket) {
42
+ (globalThis as unknown as { WebSocket: unknown }).WebSocket = WebSocket;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface PersonaUser {
50
+ name: string;
51
+ color: string;
52
+ avatarUrl?: string;
53
+ }
54
+
55
+ export interface PersonaHandle {
56
+ ydoc: Y.Doc;
57
+ provider: HocuspocusProvider;
58
+ /**
59
+ * Shared editor content (Tiptap `field: "default"`).
60
+ */
61
+ fragment: Y.XmlFragment;
62
+ /**
63
+ * Citations keyed by citation-key (Vaswani2017 -> {title, authors, ...}).
64
+ */
65
+ citationsMap: Y.Map<unknown>;
66
+ /**
67
+ * Whole-article settings (primaryHue, etc.).
68
+ */
69
+ settingsMap: Y.Map<unknown>;
70
+ /**
71
+ * Frontmatter arrays (authors, affiliations, links).
72
+ */
73
+ frontmatter: {
74
+ scalars: Y.Map<unknown>;
75
+ authors: Y.Array<Author>;
76
+ affiliations: Y.Array<Affiliation>;
77
+ links: Y.Array<unknown>;
78
+ };
79
+ /**
80
+ * Cancel the awareness ticker / close the connection.
81
+ */
82
+ destroy: () => void;
83
+ }
84
+
85
+ export interface Author {
86
+ name: string;
87
+ url?: string;
88
+ affiliations: number[];
89
+ }
90
+
91
+ export interface Affiliation {
92
+ name: string;
93
+ url?: string;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Connection
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Open a Hocuspocus connection as a named peer and resolve once the
102
+ * server has finished the initial sync (so the Y.Doc mirrors what
103
+ * Alice / the disk state contain).
104
+ */
105
+ export async function connectPersona(args: {
106
+ url: string;
107
+ docName: string;
108
+ user: PersonaUser;
109
+ token?: string;
110
+ syncTimeoutMs?: number;
111
+ }): Promise<PersonaHandle> {
112
+ const ydoc = new Y.Doc();
113
+ const provider = new HocuspocusProvider({
114
+ url: args.url,
115
+ name: args.docName,
116
+ document: ydoc,
117
+ token: args.token ?? "",
118
+ });
119
+
120
+ // Broadcast ourselves as a collaborator so the other peers see a
121
+ // name + color in their byline / awareness list immediately. The
122
+ // Tiptap CollaborationCursor extension reads awareness.user on
123
+ // every update, so this is all that's needed for the avatar bubble
124
+ // to appear.
125
+ provider.awareness?.setLocalStateField("user", args.user);
126
+
127
+ await new Promise<void>((resolve, reject) => {
128
+ const timer = setTimeout(
129
+ () => reject(new Error(`persona "${args.user.name}" sync timeout`)),
130
+ args.syncTimeoutMs ?? 15_000,
131
+ );
132
+ provider.on("synced", () => {
133
+ clearTimeout(timer);
134
+ resolve();
135
+ });
136
+ });
137
+
138
+ const fragment = ydoc.getXmlFragment("default");
139
+ const citationsMap = ydoc.getMap("citations");
140
+ const settingsMap = ydoc.getMap("settings");
141
+ const frontmatter = {
142
+ scalars: ydoc.getMap("frontmatter"),
143
+ authors: ydoc.getArray<Author>("frontmatter.authors"),
144
+ affiliations: ydoc.getArray<Affiliation>("frontmatter.affiliations"),
145
+ links: ydoc.getArray<unknown>("frontmatter.links"),
146
+ };
147
+
148
+ const destroy = () => {
149
+ try {
150
+ provider.destroy();
151
+ } catch {
152
+ /* provider may already be detached */
153
+ }
154
+ try {
155
+ ydoc.destroy();
156
+ } catch {
157
+ /* noop */
158
+ }
159
+ };
160
+
161
+ return {
162
+ ydoc,
163
+ provider,
164
+ fragment,
165
+ citationsMap,
166
+ settingsMap,
167
+ frontmatter,
168
+ destroy,
169
+ };
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Inline content builders
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Describes a chunk of inline content. `text` entries can carry
178
+ * marks like `bold` or `link`; `inlineMath` entries render as a
179
+ * KaTeX node.
180
+ */
181
+ export type InlineItem =
182
+ | { type: "text"; text: string; marks?: InlineMarks }
183
+ | { type: "inlineMath"; latex: string }
184
+ | { type: "citation"; key: string; label: string };
185
+
186
+ export interface InlineMarks {
187
+ bold?: boolean;
188
+ italic?: boolean;
189
+ underline?: boolean;
190
+ code?: boolean;
191
+ link?: {
192
+ href: string;
193
+ target?: string;
194
+ rel?: string;
195
+ class?: string;
196
+ title?: string | null;
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Convert our plain JS `InlineMarks` into the Yjs attribute object
202
+ * expected by `Y.XmlText.insert(index, text, attrs)`. Mirrors the
203
+ * shape Tiptap's y-prosemirror binding serializes (empty objects
204
+ * for booleans, full attr maps for parametrized marks).
205
+ */
206
+ function marksToAttrs(
207
+ marks: InlineMarks | undefined,
208
+ ): Record<string, unknown> | undefined {
209
+ if (!marks) return undefined;
210
+ const out: Record<string, unknown> = {};
211
+ if (marks.bold) out.bold = {};
212
+ if (marks.italic) out.italic = {};
213
+ if (marks.underline) out.underline = {};
214
+ if (marks.code) out.code = {};
215
+ if (marks.link) {
216
+ out.link = {
217
+ href: marks.link.href,
218
+ target: marks.link.target ?? "_blank",
219
+ rel: marks.link.rel ?? "noopener noreferrer nofollow",
220
+ class: marks.link.class ?? "editor-link",
221
+ title: marks.link.title ?? null,
222
+ };
223
+ }
224
+ return Object.keys(out).length ? out : undefined;
225
+ }
226
+
227
+ /**
228
+ * Append inline content to the END of a paragraph Y.XmlElement.
229
+ * Text chunks merge into adjacent Y.XmlText when possible (so marks
230
+ * stay contiguous); non-text items are inserted as standalone
231
+ * children.
232
+ */
233
+ export function appendInline(
234
+ paragraph: Y.XmlElement,
235
+ items: InlineItem[],
236
+ ): void {
237
+ for (const item of items) {
238
+ if (item.type === "text") {
239
+ const attrs = marksToAttrs(item.marks);
240
+ // Try to reuse the trailing Y.XmlText node - merging marks
241
+ // with a previous text run gives Tiptap a cleaner delta and
242
+ // avoids spurious splits that the ProseMirror renderer has
243
+ // to collapse afterwards.
244
+ const last = paragraph.toArray().at(-1);
245
+ if (last instanceof Y.XmlText) {
246
+ last.insert(last.length, item.text, attrs);
247
+ } else {
248
+ // Attach first, then insert - Y.XmlText emits observer
249
+ // events on `.insert`, and doing that while detached
250
+ // triggers "Invalid access: Add Yjs type to a document
251
+ // before reading data".
252
+ const t = new Y.XmlText();
253
+ paragraph.push([t]);
254
+ t.insert(0, item.text, attrs);
255
+ }
256
+ } else if (item.type === "inlineMath") {
257
+ const el = new Y.XmlElement("inlineMath");
258
+ paragraph.push([el]);
259
+ el.setAttribute("latex", item.latex);
260
+ } else if (item.type === "citation") {
261
+ const el = new Y.XmlElement("citation");
262
+ paragraph.push([el]);
263
+ el.setAttribute("key", item.key);
264
+ el.setAttribute("label", item.label);
265
+ }
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Block builders
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * Create an empty, detached `<paragraph/>` Y.XmlElement. Caller
275
+ * must attach it (via `frag.push([p])` / `frag.insert(n, [p])`)
276
+ * before any content insertion. `appendInline` and
277
+ * `typeIntoParagraph` take care of creating the first Y.XmlText
278
+ * child lazily after attachment, so an empty paragraph is a valid
279
+ * seed for all writer helpers below.
280
+ */
281
+ export function buildEmptyParagraph(): Y.XmlElement {
282
+ return new Y.XmlElement("paragraph");
283
+ }
284
+
285
+ /**
286
+ * Build a paragraph pre-populated with inline content. Requires a
287
+ * parent to insert into so the Y.XmlText children can be attached
288
+ * immediately (a detached Y.XmlText insert would throw the
289
+ * "Invalid access" warning). The new paragraph is pushed onto the
290
+ * end of `parent`.
291
+ */
292
+ export function pushParagraphInto(
293
+ parent: Y.XmlFragment | Y.XmlElement,
294
+ items: InlineItem[] = [],
295
+ ): Y.XmlElement {
296
+ const el = new Y.XmlElement("paragraph");
297
+ parent.push([el]);
298
+ if (items.length) appendInline(el, items);
299
+ return el;
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Top-level navigation
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Find the index of the nth occurrence of an H2 whose textContent
308
+ * matches `text` (case-insensitive). Returns -1 if not found.
309
+ */
310
+ export function findHeadingIndex(
311
+ frag: Y.XmlFragment,
312
+ text: string,
313
+ level: number = 2,
314
+ ): number {
315
+ const wanted = text.trim().toLowerCase();
316
+ const children = frag.toArray();
317
+ for (let i = 0; i < children.length; i++) {
318
+ const c = children[i];
319
+ if (!(c instanceof Y.XmlElement)) continue;
320
+ if (c.nodeName !== "heading") continue;
321
+ if (Number(c.getAttribute("level")) !== level) continue;
322
+ const textContent = c
323
+ .toArray()
324
+ .filter((n): n is Y.XmlText => n instanceof Y.XmlText)
325
+ .map((n) => n.toString())
326
+ .join("");
327
+ if (textContent.trim().toLowerCase() === wanted) return i;
328
+ }
329
+ return -1;
330
+ }
331
+
332
+ /**
333
+ * Return the Nth non-heading child that sits right after
334
+ * `headingText`, scanning top-level fragment children. Creates
335
+ * empty paragraphs on demand to satisfy `slotIndex` if the section
336
+ * exists but doesn't have enough slots yet (useful when a peer
337
+ * lands ahead of the scaffold writer).
338
+ */
339
+ export function claimSlotAfterHeading(
340
+ frag: Y.XmlFragment,
341
+ headingText: string,
342
+ slotIndex: number,
343
+ ): Y.XmlElement | null {
344
+ const headingIdx = findHeadingIndex(frag, headingText);
345
+ if (headingIdx === -1) return null;
346
+
347
+ const children = frag.toArray();
348
+ // Collect section slots up to the next H-level boundary.
349
+ const slots: { el: Y.XmlElement; fragIndex: number }[] = [];
350
+ for (let i = headingIdx + 1; i < children.length; i++) {
351
+ const c = children[i];
352
+ if (!(c instanceof Y.XmlElement)) continue;
353
+ if (c.nodeName === "heading") break;
354
+ slots.push({ el: c, fragIndex: i });
355
+ }
356
+
357
+ if (slots[slotIndex]) return slots[slotIndex].el;
358
+
359
+ // Section is present but too few slots. Grow it by inserting
360
+ // empty paragraphs right after the last slot we found (or right
361
+ // after the heading if the section was bare). Elements are
362
+ // minted detached, then pushed as a single batch via Y.insert
363
+ // so attribute writes don't emit detached-node warnings.
364
+ const insertAt = slots.length
365
+ ? slots[slots.length - 1].fragIndex + 1
366
+ : headingIdx + 1;
367
+ const needed = slotIndex + 1 - slots.length;
368
+ const fresh: Y.XmlElement[] = [];
369
+ for (let i = 0; i < needed; i++) {
370
+ fresh.push(new Y.XmlElement("paragraph"));
371
+ }
372
+ frag.insert(insertAt, fresh);
373
+ return fresh[needed - 1];
374
+ }
375
+
376
+ /**
377
+ * Replace (or create) the paragraph at fragment index `slotIndex`
378
+ * within a section with a `blockMath` atom carrying the given
379
+ * LaTeX. `blockMath` is a Tiptap atom: nodeName="blockMath", no
380
+ * children, single `latex` attribute (matches
381
+ * @tiptap/extension-mathematics/BlockMath.ts). The frontend
382
+ * NodeView picks it up and renders KaTeX the moment the CRDT
383
+ * sync lands in Alice's editor.
384
+ *
385
+ * Using an atomic replace (delete slot + insert blockMath) keeps
386
+ * the insertion visually "crisp" on camera: no half-parsed LaTeX
387
+ * intermediate state, no input-rule roundtrip.
388
+ */
389
+ export function writeBlockMathAtSlot(
390
+ frag: Y.XmlFragment,
391
+ headingText: string,
392
+ slotIndex: number,
393
+ latex: string,
394
+ ): Y.XmlElement | null {
395
+ const headingIdx = findHeadingIndex(frag, headingText);
396
+ if (headingIdx === -1) return null;
397
+
398
+ const children = frag.toArray();
399
+ // Collect slot fragment indices up to the next heading boundary.
400
+ const slots: number[] = [];
401
+ for (let i = headingIdx + 1; i < children.length; i++) {
402
+ const c = children[i];
403
+ if (!(c instanceof Y.XmlElement)) continue;
404
+ if (c.nodeName === "heading") break;
405
+ slots.push(i);
406
+ }
407
+
408
+ const block = new Y.XmlElement("blockMath");
409
+ frag.doc?.transact(() => {
410
+ if (slots[slotIndex] !== undefined) {
411
+ frag.delete(slots[slotIndex], 1);
412
+ frag.insert(slots[slotIndex], [block]);
413
+ } else {
414
+ // Section exists but doesn't have a slot at slotIndex yet.
415
+ // Pad with empty paragraphs then append the blockMath so
416
+ // downstream slot indices stay aligned with the scaffold.
417
+ const insertAt = slots.length
418
+ ? slots[slots.length - 1] + 1
419
+ : headingIdx + 1;
420
+ const pad = slotIndex - slots.length;
421
+ const batch: Y.XmlElement[] = [];
422
+ for (let k = 0; k < pad; k++) batch.push(new Y.XmlElement("paragraph"));
423
+ batch.push(block);
424
+ frag.insert(insertAt, batch);
425
+ }
426
+ block.setAttribute("latex", latex);
427
+ });
428
+ return block;
429
+ }
430
+
431
+ /**
432
+ * Wait until `predicate(fragment)` returns true, polling on every
433
+ * fragment update. Used to gate a peer on "Alice has written the
434
+ * scaffold" (`findHeadingIndex(frag, "The recipe") !== -1`).
435
+ */
436
+ export async function waitForCondition(
437
+ frag: Y.XmlFragment,
438
+ predicate: () => boolean,
439
+ timeoutMs: number = 30_000,
440
+ ): Promise<boolean> {
441
+ if (predicate()) return true;
442
+ return new Promise<boolean>((resolve) => {
443
+ let done = false;
444
+ const finish = (v: boolean) => {
445
+ if (done) return;
446
+ done = true;
447
+ frag.unobserveDeep(onChange);
448
+ clearTimeout(timer);
449
+ resolve(v);
450
+ };
451
+ const onChange = () => {
452
+ try {
453
+ if (predicate()) finish(true);
454
+ } catch {
455
+ /* predicate may temporarily throw while the doc is mid-
456
+ * mutation; just retry on the next event */
457
+ }
458
+ };
459
+ frag.observeDeep(onChange);
460
+ const timer = setTimeout(() => finish(false), timeoutMs);
461
+ });
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Text "typing" (human-paced)
466
+ // ---------------------------------------------------------------------------
467
+
468
+ export interface TypingOptions {
469
+ /**
470
+ * Characters per second (roughly). Default 24 cps ≈ a fast touch
471
+ * typist.
472
+ */
473
+ cps?: number;
474
+ /**
475
+ * Jitter on the inter-keystroke delay (0..1). Default 0.35.
476
+ */
477
+ jitter?: number;
478
+ /**
479
+ * If set, group keystrokes of that many chars into one Y
480
+ * transaction to keep Alice's render smooth without killing the
481
+ * "typing" feel. Default 1 (every char is its own tick).
482
+ */
483
+ chunkSize?: number;
484
+ }
485
+
486
+ export function sleep(ms: number): Promise<void> {
487
+ return new Promise((r) => setTimeout(r, ms));
488
+ }
489
+
490
+ /**
491
+ * Broadcast a Tiptap-compatible cursor position through awareness.
492
+ *
493
+ * `yCursorPlugin` (from `@tiptap/y-tiptap`) reads `awareness.cursor`
494
+ * as `{anchor, head}`, each being a JSON-serialized Yjs relative
495
+ * position. Any peer that sets those two fields will have its caret
496
+ * rendered inside every other peer's editor - that's exactly the
497
+ * "floating Bob Mercier / Carol Dubois cursors" Alice sees during
498
+ * normal human collaboration.
499
+ *
500
+ * We build the relative position from the target Y.XmlText node +
501
+ * offset directly (no ProseMirror state involved), which is safe for
502
+ * headless Node peers that don't have an editor.
503
+ */
504
+ export function setAwarenessCursor(
505
+ handle: PersonaHandle,
506
+ node: Y.XmlText,
507
+ offset: number,
508
+ ): void {
509
+ try {
510
+ const rel = Y.createRelativePositionFromTypeIndex(node, offset, -1);
511
+ const json = Y.relativePositionToJSON(rel);
512
+ handle.provider.awareness?.setLocalStateField("cursor", {
513
+ anchor: json,
514
+ head: json,
515
+ });
516
+ } catch {
517
+ /* type not yet attached to the doc; skip this tick, caller will
518
+ * retry on the next chunk */
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Clear the broadcast cursor (peer has nothing selected / is idle).
524
+ * Call this between scenes so Alice doesn't see a ghost caret
525
+ * lingering after a persona finishes typing.
526
+ */
527
+ export function clearAwarenessCursor(handle: PersonaHandle): void {
528
+ handle.provider.awareness?.setLocalStateField("cursor", null);
529
+ }
530
+
531
+ /**
532
+ * Insert `chunk` at the end of `paragraph`, reusing the trailing
533
+ * Y.XmlText run when possible. Returns the Y.XmlText the chunk
534
+ * landed in plus the offset AFTER the insert, so callers can
535
+ * broadcast an up-to-date cursor position right after the write.
536
+ */
537
+ function pushChunkIntoParagraph(
538
+ paragraph: Y.XmlElement,
539
+ chunk: string,
540
+ attrs: Record<string, unknown> | undefined,
541
+ ): { node: Y.XmlText; offset: number } {
542
+ const last = paragraph.toArray().at(-1);
543
+ if (last instanceof Y.XmlText) {
544
+ const base = last.length;
545
+ last.insert(base, chunk, attrs);
546
+ return { node: last, offset: base + chunk.length };
547
+ }
548
+ // Attach the XmlText BEFORE inserting: Y.XmlText.insert emits
549
+ // observer events, and firing those while detached throws the
550
+ // "Invalid access" warning even though the end result is
551
+ // correct.
552
+ const t = new Y.XmlText();
553
+ paragraph.push([t]);
554
+ t.insert(0, chunk, attrs);
555
+ return { node: t, offset: chunk.length };
556
+ }
557
+
558
+ /**
559
+ * Append text, chunk by chunk, to a paragraph's trailing Y.XmlText
560
+ * node with human-like cadence. Each chunk is a real Yjs insert
561
+ * that propagates to Alice, so she sees the text stream in at a
562
+ * natural speed.
563
+ *
564
+ * Side effect: after every chunk we also broadcast the peer's
565
+ * cursor through awareness (if `handle` is provided). That's what
566
+ * makes Bob's / Carol's caret visibly travel along their paragraph
567
+ * in Alice's editor while they type, same as a human collaborator.
568
+ */
569
+ export async function typeIntoParagraph(
570
+ paragraph: Y.XmlElement,
571
+ text: string,
572
+ options: TypingOptions = {},
573
+ marks?: InlineMarks,
574
+ handle?: PersonaHandle,
575
+ ): Promise<void> {
576
+ const cps = options.cps ?? 24;
577
+ const jitter = options.jitter ?? 0.35;
578
+ const chunkSize = Math.max(1, options.chunkSize ?? 1);
579
+ const baseDelay = 1000 / cps;
580
+ const attrs = marksToAttrs(marks);
581
+
582
+ let i = 0;
583
+ while (i < text.length) {
584
+ const take = Math.min(chunkSize, text.length - i);
585
+ const chunk = text.slice(i, i + take);
586
+ const landed = pushChunkIntoParagraph(paragraph, chunk, attrs);
587
+ if (handle) setAwarenessCursor(handle, landed.node, landed.offset);
588
+ i += take;
589
+ const jitterMul = 1 + (Math.random() * 2 - 1) * jitter;
590
+ await sleep(Math.max(5, baseDelay * jitterMul));
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Like `typeIntoParagraph` but inserts an inline node (math,
596
+ * citation) as a single Yjs op after an optional small pause - so
597
+ * the viewer experiences the formula "clicking in" rather than
598
+ * appearing with no warning. When `handle` is provided the peer's
599
+ * awareness cursor is nudged to the end of the paragraph after the
600
+ * insert.
601
+ */
602
+ export async function insertInlineNode(
603
+ paragraph: Y.XmlElement,
604
+ item:
605
+ | { type: "inlineMath"; latex: string }
606
+ | { type: "citation"; key: string; label: string },
607
+ preDelayMs: number = 0,
608
+ handle?: PersonaHandle,
609
+ ): Promise<void> {
610
+ if (preDelayMs > 0) await sleep(preDelayMs);
611
+ appendInline(paragraph, [item]);
612
+ if (handle) broadcastCursorAtEnd(handle, paragraph);
613
+ }
614
+
615
+ /**
616
+ * Broadcast the peer's awareness cursor at the end of `paragraph`.
617
+ * Useful after bulk inline inserts (citation chip, inline math) that
618
+ * don't go through `typeIntoParagraph`'s per-chunk broadcasting.
619
+ */
620
+ export function broadcastCursorAtEnd(
621
+ handle: PersonaHandle,
622
+ paragraph: Y.XmlElement,
623
+ ): void {
624
+ const last = paragraph.toArray().at(-1);
625
+ if (last instanceof Y.XmlText) {
626
+ setAwarenessCursor(handle, last, last.length);
627
+ }
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Frontmatter helpers
632
+ // ---------------------------------------------------------------------------
633
+
634
+ /**
635
+ * Add an author + (optionally) a new affiliation atomically.
636
+ * Mirrors `frontmatterStore.addAuthor` in the frontend but runs
637
+ * directly against the shared Y.Arrays so there's no race between
638
+ * "affiliation index allocation" and "author push".
639
+ */
640
+ export function addAuthorRecord(
641
+ handle: PersonaHandle,
642
+ args: {
643
+ name: string;
644
+ url?: string;
645
+ newAffiliation?: { name: string; url?: string };
646
+ existingAffiliationIndices?: number[];
647
+ },
648
+ ): void {
649
+ const { ydoc, frontmatter } = handle;
650
+ ydoc.transact(() => {
651
+ let affIndices = args.existingAffiliationIndices ?? [];
652
+ if (args.newAffiliation) {
653
+ frontmatter.affiliations.push([args.newAffiliation]);
654
+ // addAffiliation returns 1-based index in the frontend store,
655
+ // so we match that convention: after push, length equals the
656
+ // new entry's 1-based index.
657
+ affIndices = [...affIndices, frontmatter.affiliations.length];
658
+ }
659
+ frontmatter.authors.push([
660
+ {
661
+ name: args.name,
662
+ url: args.url,
663
+ affiliations: affIndices,
664
+ },
665
+ ]);
666
+ });
667
+ }
668
+
669
+ /**
670
+ * Patch the URL of an existing affiliation by 1-based index, matching
671
+ * the convention used everywhere else in the frontmatter. Used by the
672
+ * "other" persona to backfill a `url` on the affiliation seeded by
673
+ * the main agent (e.g. Alice adds "Hugging Face" with no URL via the
674
+ * Playwright modal, then Bob attaches `https://huggingface.co` to it
675
+ * so the byline renders the affiliation as a real link).
676
+ *
677
+ * Done inside a single Yjs transaction so the change shows up atomically
678
+ * on every peer (no flicker between "name only" and "name + url").
679
+ */
680
+ export function setAffiliationUrl(
681
+ handle: PersonaHandle,
682
+ oneBasedIndex: number,
683
+ url: string,
684
+ ): void {
685
+ const { ydoc, frontmatter } = handle;
686
+ const idx = oneBasedIndex - 1;
687
+ const current = frontmatter.affiliations.get(idx);
688
+ if (!current) return;
689
+ ydoc.transact(() => {
690
+ frontmatter.affiliations.delete(idx, 1);
691
+ frontmatter.affiliations.insert(idx, [{ ...current, url }]);
692
+ });
693
+ }
694
+
695
+ /**
696
+ * Wait for at least `n` affiliations to be present in the shared
697
+ * frontmatter. Used by the "other" persona to gate on the main agent
698
+ * having seeded the Hugging Face affiliation before reusing its index.
699
+ */
700
+ export async function waitForAffiliationCount(
701
+ handle: PersonaHandle,
702
+ minCount: number,
703
+ timeoutMs: number = 30_000,
704
+ ): Promise<boolean> {
705
+ if (handle.frontmatter.affiliations.length >= minCount) return true;
706
+ return new Promise<boolean>((resolve) => {
707
+ let done = false;
708
+ const onChange = () => {
709
+ if (done) return;
710
+ if (handle.frontmatter.affiliations.length >= minCount) {
711
+ done = true;
712
+ handle.frontmatter.affiliations.unobserve(onChange);
713
+ clearTimeout(timer);
714
+ resolve(true);
715
+ }
716
+ };
717
+ handle.frontmatter.affiliations.observe(onChange);
718
+ const timer = setTimeout(() => {
719
+ if (done) return;
720
+ done = true;
721
+ handle.frontmatter.affiliations.unobserve(onChange);
722
+ resolve(false);
723
+ }, timeoutMs);
724
+ });
725
+ }
726
+
727
+ /**
728
+ * Wait for at least `n` authors to be present in the shared
729
+ * frontmatter. Useful for persona gating ("Bob waits for Alice to
730
+ * seed the first author before appending his own").
731
+ */
732
+ export async function waitForAuthorCount(
733
+ handle: PersonaHandle,
734
+ minCount: number,
735
+ timeoutMs: number = 30_000,
736
+ ): Promise<boolean> {
737
+ if (handle.frontmatter.authors.length >= minCount) return true;
738
+ return new Promise<boolean>((resolve) => {
739
+ let done = false;
740
+ const onChange = () => {
741
+ if (done) return;
742
+ if (handle.frontmatter.authors.length >= minCount) {
743
+ done = true;
744
+ handle.frontmatter.authors.unobserve(onChange);
745
+ clearTimeout(timer);
746
+ resolve(true);
747
+ }
748
+ };
749
+ handle.frontmatter.authors.observe(onChange);
750
+ const timer = setTimeout(() => {
751
+ if (done) return;
752
+ done = true;
753
+ handle.frontmatter.authors.unobserve(onChange);
754
+ resolve(false);
755
+ }, timeoutMs);
756
+ });
757
+ }
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // Citations
761
+ // ---------------------------------------------------------------------------
762
+
763
+ export interface CitationEntry {
764
+ id: string;
765
+ "citation-key": string;
766
+ type: string;
767
+ title?: string;
768
+ author?: Array<{ family: string; given: string }>;
769
+ issued?: { "date-parts": number[][] };
770
+ "container-title"?: string;
771
+ volume?: string;
772
+ URL?: string;
773
+ DOI?: string;
774
+ [key: string]: unknown;
775
+ }
776
+
777
+ /**
778
+ * Register a citation entry in the shared `citationsMap`. Tiptap's
779
+ * frontend listens on this map; a citation chip with matching
780
+ * `key` attr will resolve to the entry automatically.
781
+ */
782
+ export function registerCitation(
783
+ handle: PersonaHandle,
784
+ key: string,
785
+ entry: CitationEntry,
786
+ ): void {
787
+ handle.citationsMap.set(key, entry);
788
+ }
backend/demo/probe-ydoc.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * One-shot probe: connect to the live Hocuspocus server as an
3
+ * anonymous peer, dump the structure of the shared Y.XmlFragment
4
+ * so we know exactly what node names / attrs / marks Tiptap emits.
5
+ */
6
+ import { WebSocket } from "ws";
7
+ import { HocuspocusProvider } from "@hocuspocus/provider";
8
+ import * as Y from "yjs";
9
+
10
+ (globalThis as any).WebSocket = WebSocket;
11
+
12
+ function describeText(t: Y.XmlText): string {
13
+ // Y.XmlText.toDelta() returns [{insert: "Hello"}, {insert: "world", attributes: {bold: {}}}, ...]
14
+ // which is the richest format for marks.
15
+ return JSON.stringify((t as any).toDelta(), null, 2);
16
+ }
17
+
18
+ function describeElement(el: Y.XmlElement, indent: string = ""): void {
19
+ const attrs = el.getAttributes();
20
+ console.log(
21
+ `${indent}<${el.nodeName}${
22
+ Object.keys(attrs).length ? " " + JSON.stringify(attrs) : ""
23
+ }> (${el.length} children)`,
24
+ );
25
+ for (const child of el.toArray()) {
26
+ if (child instanceof Y.XmlElement) {
27
+ describeElement(child, indent + " ");
28
+ } else if (child instanceof Y.XmlText) {
29
+ console.log(`${indent} [text] ${describeText(child)}`);
30
+ }
31
+ }
32
+ }
33
+
34
+ async function run() {
35
+ const ydoc = new Y.Doc();
36
+ const provider = new HocuspocusProvider({
37
+ url: "ws://localhost:5678/collab",
38
+ name: "default",
39
+ document: ydoc,
40
+ token: "",
41
+ });
42
+
43
+ await new Promise<void>((resolve, reject) => {
44
+ const timer = setTimeout(() => reject(new Error("sync timeout")), 10_000);
45
+ provider.on("synced", () => {
46
+ clearTimeout(timer);
47
+ resolve();
48
+ });
49
+ });
50
+
51
+ console.log("=== synced ===");
52
+
53
+ const frag = ydoc.getXmlFragment("default");
54
+ console.log(`XmlFragment("default").length = ${frag.length}\n`);
55
+
56
+ for (const child of frag.toArray()) {
57
+ if (child instanceof Y.XmlElement) {
58
+ describeElement(child);
59
+ } else if (child instanceof Y.XmlText) {
60
+ console.log(`[text] ${describeText(child)}`);
61
+ }
62
+ }
63
+
64
+ provider.destroy();
65
+ ydoc.destroy();
66
+ process.exit(0);
67
+ }
68
+
69
+ run().catch((err) => {
70
+ console.error("probe failed:", err);
71
+ process.exit(1);
72
+ });
backend/demo/showcase.ts CHANGED
@@ -71,6 +71,7 @@ import {
71
  startScreenRecording,
72
  type ScreenRecording,
73
  } from "./lib/recording.js";
 
74
  import type { Page } from "playwright";
75
 
76
  // ---------------------------------------------------------------------------
@@ -92,23 +93,18 @@ const SLOT_EXPECTATIONS: Array<{
92
  mustInclude: string;
93
  owner: "alice" | "bob" | "carol";
94
  }> = [
95
- // Bob - "The recipe"
96
  {
97
  heading: "The recipe",
98
  slot: 0,
99
  mustInclude: "Attention lets a token",
100
  owner: "bob",
101
  },
102
- {
103
- heading: "The recipe",
104
- slot: 1,
105
- mustInclude: "softmax",
106
- owner: "bob",
107
- },
108
  {
109
  heading: "The recipe",
110
  slot: 2,
111
- mustInclude: "The softmax turns those scores",
112
  owner: "bob",
113
  },
114
  {
@@ -155,45 +151,113 @@ const SLOT_EXPECTATIONS: Array<{
155
  },
156
  ];
157
 
158
- async function verifyFinalDoc(page: Page): Promise<void> {
159
- const report = await page.evaluate(() => {
160
- const root = document.querySelector(".ProseMirror") as HTMLElement | null;
161
- if (!root) return null;
162
-
163
- // Group siblings by heading: for each h2, collect every
164
- // non-heading element until the next h2. Mirrors the slot
165
- // layout the scaffolding helper produced.
166
- const sections: Array<{
167
- heading: string;
168
- slots: string[];
169
- slotTags: string[];
170
- }> = [];
171
- let current: { heading: string; slots: string[]; slotTags: string[] } | null =
172
- null;
173
- for (const el of Array.from(root.children)) {
174
- if (el.tagName === "H2") {
175
- if (current) sections.push(current);
176
- current = {
177
- heading: (el.textContent || "").trim(),
178
- slots: [],
179
- slotTags: [],
180
- };
181
- } else if (current && el.tagName !== "H1") {
182
- current.slots.push((el.textContent || "").trim());
183
- current.slotTags.push(el.tagName);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  }
186
- if (current) sections.push(current);
 
187
 
188
- // Also grab the final doc as one big text blob so we can search
189
- // for fragments wherever they may have ended up.
190
- const flatText = (root.textContent || "").replace(/\s+/g, " ").trim();
 
 
191
 
192
- return { sections, flatText };
193
- });
194
 
195
- if (!report) {
196
- console.warn("[verify] no .ProseMirror root found on Bob's page");
197
  return;
198
  }
199
 
@@ -270,6 +334,27 @@ async function verifyFinalDoc(page: Page): Promise<void> {
270
  }
271
  }
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  // Bibliography should exist (Bob's citation action creates it).
274
  if (!report.flatText.includes("Vaswani")) {
275
  anomalies.push("bob: citation 'Vaswani' never landed in the doc");
@@ -311,8 +396,10 @@ interface CliArgs {
311
  * "demo duration + a few seconds of outro", and the script blocks
312
  * on the timer before exiting.
313
  *
314
- * Default 60s covers our ~45s demo with a comfortable buffer you
315
- * can trim in post. Bump if you expanded the scenario.
 
 
316
  */
317
  captureDuration: number;
318
  }
@@ -324,7 +411,7 @@ function parseArgs(argv: string[]): CliArgs {
324
  allVisible: false,
325
  record: false,
326
  capture: false,
327
- captureDuration: 60,
328
  };
329
  for (let i = 0; i < argv.length; i++) {
330
  const a = argv[i];
@@ -358,6 +445,10 @@ async function run() {
358
  // a later launch threw before we got to `Promise.all(runAlice,
359
  // ...)`.
360
  const handles: Array<PersonaHandle | null> = [];
 
 
 
 
361
  let recording: ScreenRecording | null = null;
362
  let alreadyCleanedUp = false;
363
 
@@ -373,6 +464,13 @@ async function run() {
373
  }
374
  recording = null;
375
  }
 
 
 
 
 
 
 
376
  await shutdownAllPersonas(handles);
377
  console.log("[showcase] done.");
378
  };
@@ -405,6 +503,17 @@ async function run() {
405
  recordMode: args.record,
406
  });
407
  handles.push(alice);
 
 
 
 
 
 
 
 
 
 
 
408
  console.log("[showcase] alice up");
409
 
410
  // Wipe the shared Yjs doc once on Alice's session BEFORE Bob /
@@ -414,19 +523,27 @@ async function run() {
414
  console.log("[showcase] resetting shared document");
415
  await resetDoc(alice.page);
416
 
417
- const bob = await launchPersona(args.url, {
418
- persona: PERSONAS.bob,
419
- headless: true,
 
 
 
 
 
 
 
420
  });
421
- handles.push(bob);
422
- console.log("[showcase] bob up");
423
 
424
- const carol = await launchPersona(args.url, {
425
- persona: PERSONAS.carol,
426
- headless: true,
 
427
  });
428
- handles.push(carol);
429
- console.log("[showcase] carol up");
430
 
431
  // Auto screen recording takes precedence over the manual
432
  // countdown: if --capture is on, we start `screencapture -v`
@@ -467,21 +584,29 @@ async function run() {
467
  const alicePromise = runAlice(alice.page, args.speed)
468
  .then(() => stamp("alice done"))
469
  .catch((err) => console.error("[alice] error:", err));
470
- const bobPromise = runBob(bob.page, args.speed)
471
  .then(() => stamp("bob done"))
472
  .catch((err) => console.error("[bob] error:", err));
473
- const carolPromise = runCarol(carol.page, args.speed)
474
  .then(() => stamp("carol done"))
475
  .catch((err) => console.error("[carol] error:", err));
476
 
477
  await Promise.all([bobPromise, carolPromise]);
478
 
479
- // Post-parallel doc verification: read the shared article from
480
- // Bob's page (still on the editor) and log anomalies. Cheap,
481
- // non-blocking; helps catch inter-persona collisions without
482
- // having to eyeball the recording frame by frame.
 
 
 
 
 
 
 
 
483
  try {
484
- await verifyFinalDoc(bob.page);
485
  } catch (err) {
486
  console.warn(
487
  "[verify] aborted:",
@@ -489,9 +614,6 @@ async function run() {
489
  );
490
  }
491
 
492
- await alicePromise;
493
- stamp("all personas finished");
494
-
495
  // Block on the screencapture timer so the user gets a complete,
496
  // playable .mov. Extra time after "all personas finished"
497
  // looks fine in the recording (final state of the editor,
 
71
  startScreenRecording,
72
  type ScreenRecording,
73
  } from "./lib/recording.js";
74
+ import { connectPersona, type PersonaHandle as YjsPersona } from "./lib/yjs-client.js";
75
  import type { Page } from "playwright";
76
 
77
  // ---------------------------------------------------------------------------
 
93
  mustInclude: string;
94
  owner: "alice" | "bob" | "carol";
95
  }> = [
96
+ // Bob - "The recipe" (4 slots: def, block-math, softmax, multi-head)
97
  {
98
  heading: "The recipe",
99
  slot: 0,
100
  mustInclude: "Attention lets a token",
101
  owner: "bob",
102
  },
103
+ // slot 1 is a <blockMath> atom; checked separately below.
 
 
 
 
 
104
  {
105
  heading: "The recipe",
106
  slot: 2,
107
+ mustInclude: "softmax turns those similarity scores",
108
  owner: "bob",
109
  },
110
  {
 
151
  },
152
  ];
153
 
154
+ /**
155
+ * Inspect the shared Y.XmlFragment directly (no browser needed)
156
+ * and return a flat "sections" view matching what the old
157
+ * DOM-based verifier produced, so `SLOT_EXPECTATIONS` keeps
158
+ * working unchanged.
159
+ */
160
+ async function verifyFinalDoc(bob: YjsPersona): Promise<void> {
161
+ // We import yjs lazily here to avoid a top-level dep circle with
162
+ // `./lib/yjs-client.ts`; the verifier only runs once at the end
163
+ // of a demo so the extra dynamic import is cheap.
164
+ const Y = await import("yjs");
165
+
166
+ const sections: Array<{
167
+ heading: string;
168
+ slots: string[];
169
+ slotTags: string[];
170
+ }> = [];
171
+ let current: {
172
+ heading: string;
173
+ slots: string[];
174
+ slotTags: string[];
175
+ } | null = null;
176
+
177
+ // textContent for a Y.XmlElement: concatenate every descendant
178
+ // Y.XmlText, skipping inline atom nodes (inlineMath, citation)
179
+ // which have no textContent of their own but carry meaningful
180
+ // attributes. We still keep their node name in the tag so the
181
+ // flat text search below can spot a citation chip by inference.
182
+ // Y.XmlText.toString() serializes marks as XML tags (`<bold>foo</bold>`,
183
+ // `<link href="...">bar</link>`), which breaks "expected substring"
184
+ // checks like `"eight lines of PyTorch"` when a mark like link wraps
185
+ // the trailing word. We read the Delta format instead and only keep
186
+ // the `insert` text values: gives us the plain concatenated text a
187
+ // reader would see, mark attributes ignored.
188
+ const xmlTextToPlain = (t: Y.XmlText): string => {
189
+ const delta = t.toDelta() as Array<{ insert?: unknown }>;
190
+ let out = "";
191
+ for (const op of delta) {
192
+ if (typeof op.insert === "string") out += op.insert;
193
+ }
194
+ return out;
195
+ };
196
+ const textOf = (el: Y.XmlElement): string => {
197
+ // Atom nodes (`blockMath`, `inlineMath`) carry their whole
198
+ // payload on the `latex` attribute and have no children, so
199
+ // the child-walker below would see an empty slot. Surface the
200
+ // LaTeX verbatim when the slot IS an atom.
201
+ if (el.nodeName === "blockMath" || el.nodeName === "inlineMath") {
202
+ const latex = el.getAttribute("latex");
203
+ return typeof latex === "string" ? latex.replace(/\s+/g, " ").trim() : "";
204
+ }
205
+
206
+ let out = "";
207
+ const visit = (node: Y.XmlElement | Y.XmlText) => {
208
+ if (node instanceof Y.XmlText) {
209
+ out += xmlTextToPlain(node);
210
+ } else if (node instanceof Y.XmlElement) {
211
+ if (node.nodeName === "citation") {
212
+ const label = node.getAttribute("label");
213
+ if (typeof label === "string") out += label;
214
+ } else if (node.nodeName === "inlineMath" || node.nodeName === "blockMath") {
215
+ const latex = node.getAttribute("latex");
216
+ if (typeof latex === "string") out += latex;
217
+ } else {
218
+ for (const child of node.toArray()) visit(child as Y.XmlElement | Y.XmlText);
219
+ }
220
  }
221
+ };
222
+ for (const child of el.toArray()) {
223
+ visit(child as Y.XmlElement | Y.XmlText);
224
+ }
225
+ return out.replace(/\s+/g, " ").trim();
226
+ };
227
+
228
+ for (const child of bob.fragment.toArray()) {
229
+ if (!(child instanceof Y.XmlElement)) continue;
230
+ if (child.nodeName === "heading" && Number(child.getAttribute("level")) === 2) {
231
+ if (current) sections.push(current);
232
+ current = {
233
+ heading: textOf(child),
234
+ slots: [],
235
+ slotTags: [],
236
+ };
237
+ } else if (current) {
238
+ const tagMap: Record<string, string> = {
239
+ paragraph: "P",
240
+ codeBlock: "PRE",
241
+ heading: "H",
242
+ bibliography: "BIB",
243
+ blockMath: "MATH",
244
+ };
245
+ current.slots.push(textOf(child));
246
+ current.slotTags.push(tagMap[child.nodeName] ?? child.nodeName.toUpperCase());
247
  }
248
+ }
249
+ if (current) sections.push(current);
250
 
251
+ const flatText = sections
252
+ .flatMap((s) => [s.heading, ...s.slots])
253
+ .join(" ")
254
+ .replace(/\s+/g, " ")
255
+ .trim();
256
 
257
+ const report = { sections, flatText };
 
258
 
259
+ if (!report.sections.length) {
260
+ console.warn("[verify] empty fragment, nothing to verify");
261
  return;
262
  }
263
 
 
334
  }
335
  }
336
 
337
+ // Block math sanity check: "The recipe" slot 1 should be a
338
+ // <blockMath> atom carrying the attention formula. We grep the
339
+ // LaTeX source for the two signature substrings the formula
340
+ // always contains ("Attention" + "softmax") so a typo in the
341
+ // source string doesn't silently pass.
342
+ const recipe = report.sections.find((s) =>
343
+ s.heading.toLowerCase().includes("the recipe"),
344
+ );
345
+ if (recipe) {
346
+ const mathTag = recipe.slotTags[1];
347
+ const mathText = recipe.slots[1] ?? "";
348
+ const looksLikeFormula =
349
+ mathText.includes("Attention") && mathText.includes("softmax");
350
+ if (mathTag !== "MATH" || !looksLikeFormula) {
351
+ anomalies.push(
352
+ `bob: block math in "The recipe"[1] not recognized ` +
353
+ `(got tag=${mathTag}, latex="${mathText.slice(0, 60)}...")`,
354
+ );
355
+ }
356
+ }
357
+
358
  // Bibliography should exist (Bob's citation action creates it).
359
  if (!report.flatText.includes("Vaswani")) {
360
  anomalies.push("bob: citation 'Vaswani' never landed in the doc");
 
396
  * "demo duration + a few seconds of outro", and the script blocks
397
  * on the timer before exiting.
398
  *
399
+ * Default 50s covers our ~45s demo + a ~5s outro on the published
400
+ * article (user wants the browser to quit shortly after the
401
+ * publish view, not linger for 30s). Bump if you expand the
402
+ * scenario.
403
  */
404
  captureDuration: number;
405
  }
 
411
  allVisible: false,
412
  record: false,
413
  capture: false,
414
+ captureDuration: 50,
415
  };
416
  for (let i = 0; i < argv.length; i++) {
417
  const a = argv[i];
 
445
  // a later launch threw before we got to `Promise.all(runAlice,
446
  // ...)`.
447
  const handles: Array<PersonaHandle | null> = [];
448
+ // Bob + Carol are now Node-side Yjs peers (no Chromium); kept
449
+ // separately so SIGINT / finally can tear their provider sockets
450
+ // down cleanly alongside the browsers.
451
+ const yjsPeers: YjsPersona[] = [];
452
  let recording: ScreenRecording | null = null;
453
  let alreadyCleanedUp = false;
454
 
 
464
  }
465
  recording = null;
466
  }
467
+ for (const peer of yjsPeers) {
468
+ try {
469
+ peer.destroy();
470
+ } catch {
471
+ /* provider already closed */
472
+ }
473
+ }
474
  await shutdownAllPersonas(handles);
475
  console.log("[showcase] done.");
476
  };
 
503
  recordMode: args.record,
504
  });
505
  handles.push(alice);
506
+
507
+ // Relay demo-related browser console messages to the Node log so
508
+ // we can diagnose silent frontend errors (e.g. "replace target
509
+ // not found") during a --capture run without having to open
510
+ // DevTools by hand.
511
+ alice.page.on("console", (msg) => {
512
+ const text = msg.text();
513
+ if (/__demo-chat|\[alice\]|agentRewrite/.test(text)) {
514
+ console.log(`[alice:console ${msg.type()}] ${text}`);
515
+ }
516
+ });
517
  console.log("[showcase] alice up");
518
 
519
  // Wipe the shared Yjs doc once on Alice's session BEFORE Bob /
 
523
  console.log("[showcase] resetting shared document");
524
  await resetDoc(alice.page);
525
 
526
+ // Bob and Carol connect as Node-side Yjs peers instead of
527
+ // full Chromium browsers. They appear in Alice's editor via
528
+ // normal collaboration sync (authors list, text edits,
529
+ // citations) without any DOM typing or PM position race.
530
+ // URL transformation: http:// -> ws:// on the /collab path.
531
+ const wsUrl = args.url.replace(/^http/, "ws") + "/collab";
532
+ const bob = await connectPersona({
533
+ url: wsUrl,
534
+ docName: "default",
535
+ user: { name: PERSONAS.bob.name, color: PERSONAS.bob.color },
536
  });
537
+ yjsPeers.push(bob);
538
+ console.log("[showcase] bob up (yjs peer)");
539
 
540
+ const carol = await connectPersona({
541
+ url: wsUrl,
542
+ docName: "default",
543
+ user: { name: PERSONAS.carol.name, color: PERSONAS.carol.color },
544
  });
545
+ yjsPeers.push(carol);
546
+ console.log("[showcase] carol up (yjs peer)");
547
 
548
  // Auto screen recording takes precedence over the manual
549
  // countdown: if --capture is on, we start `screencapture -v`
 
584
  const alicePromise = runAlice(alice.page, args.speed)
585
  .then(() => stamp("alice done"))
586
  .catch((err) => console.error("[alice] error:", err));
587
+ const bobPromise = runBob(bob, args.speed)
588
  .then(() => stamp("bob done"))
589
  .catch((err) => console.error("[bob] error:", err));
590
+ const carolPromise = runCarol(carol, args.speed)
591
  .then(() => stamp("carol done"))
592
  .catch((err) => console.error("[carol] error:", err));
593
 
594
  await Promise.all([bobPromise, carolPromise]);
595
 
596
+ // Wait for Alice too: her rephrase typewriter + slot 2 wrap-up
597
+ // keeps writing AFTER Bob/Carol finish. Running verify before
598
+ // Alice is done used to show phantom "missing fragments" simply
599
+ // because the rephrase animation had not landed yet.
600
+ await alicePromise;
601
+ stamp("all personas finished");
602
+
603
+ // Post-parallel doc verification: read the shared Y.XmlFragment
604
+ // directly from Bob's still-connected Yjs peer. Alice's browser
605
+ // has navigated to the published output by this point, but the
606
+ // Yjs provider keeps syncing regardless of which page is on
607
+ // screen, so Bob's handle always reflects the final CRDT state.
608
  try {
609
+ await verifyFinalDoc(bob);
610
  } catch (err) {
611
  console.warn(
612
  "[verify] aborted:",
 
614
  );
615
  }
616
 
 
 
 
617
  // Block on the screencapture timer so the user gets a complete,
618
  // playable .mov. Extra time after "all personas finished"
619
  // looks fine in the recording (final state of the editor,
backend/demo/test-yjs-client.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Standalone test for the Yjs-based peer client.
3
+ *
4
+ * Run with the dev backend + frontend running, and preferably with
5
+ * an Alice browser tab also open so you can watch the edits land
6
+ * live on her screen.
7
+ *
8
+ * Usage:
9
+ * npx tsx demo/test-yjs-client.ts
10
+ *
11
+ * What it does:
12
+ * - connects as a Yjs peer with name "Probe" ✔ visible in Alice's byline
13
+ * - adds an author "Probe Author" tied to a new affiliation
14
+ * - creates a heading "Yjs probe section" + an empty slot
15
+ * - types a paragraph with inline math atoms + a citation chip
16
+ * - registers the citation entry in the shared citations map
17
+ * - prints the resulting section structure
18
+ * - disconnects
19
+ */
20
+ import {
21
+ addAuthorRecord,
22
+ appendInline,
23
+ buildEmptyParagraph,
24
+ connectPersona,
25
+ insertInlineNode,
26
+ registerCitation,
27
+ typeIntoParagraph,
28
+ } from "./lib/yjs-client.js";
29
+ import * as Y from "yjs";
30
+
31
+ async function main() {
32
+ console.log("connecting to ws://localhost:5678/collab ...");
33
+ const probe = await connectPersona({
34
+ url: "ws://localhost:5678/collab",
35
+ docName: "default",
36
+ user: { name: "Probe", color: "#ff8800" },
37
+ });
38
+ console.log("synced");
39
+
40
+ // Author push
41
+ addAuthorRecord(probe, {
42
+ name: "Probe Author",
43
+ newAffiliation: { name: "Test Lab" },
44
+ });
45
+ console.log("author pushed, total authors:", probe.frontmatter.authors.length);
46
+
47
+ // Write a probe section at the end of the doc. Elements are
48
+ // created detached and attached in one transaction so the text
49
+ // child can be inserted from inside the attached heading.
50
+ const heading = new Y.XmlElement("heading");
51
+ heading.setAttribute("level", 2);
52
+ const paragraph = buildEmptyParagraph();
53
+ probe.ydoc.transact(() => {
54
+ probe.fragment.push([heading, paragraph]);
55
+ const headingText = new Y.XmlText();
56
+ heading.push([headingText]);
57
+ headingText.insert(0, "Yjs probe section");
58
+ });
59
+ console.log("heading + empty paragraph pushed");
60
+
61
+ await typeIntoParagraph(
62
+ paragraph,
63
+ "Typed via a Node-side Yjs peer: text, math ",
64
+ { cps: 30 },
65
+ );
66
+ await insertInlineNode(paragraph, { type: "inlineMath", latex: "e^{i\\pi}" }, 120);
67
+ await typeIntoParagraph(paragraph, ", and a citation chip ", { cps: 30 });
68
+
69
+ registerCitation(probe, "probe2026yjs", {
70
+ id: "probe2026yjs",
71
+ "citation-key": "probe2026yjs",
72
+ type: "article-journal",
73
+ title: "Probing Yjs via a Node peer",
74
+ author: [{ family: "Probe", given: "Test" }],
75
+ issued: { "date-parts": [[2026]] },
76
+ });
77
+ appendInline(paragraph, [
78
+ {
79
+ type: "citation",
80
+ key: "probe2026yjs",
81
+ label: "(Probe, 2026)",
82
+ },
83
+ ]);
84
+ await typeIntoParagraph(paragraph, ".", { cps: 30 });
85
+
86
+ // Dump the probe section back out as plain text
87
+ console.log("\nfinal paragraph text:");
88
+ const walk = (el: Y.XmlElement | Y.XmlText): string => {
89
+ if (el instanceof Y.XmlText) return el.toString();
90
+ if (el.nodeName === "inlineMath") return `$${el.getAttribute("latex")}$`;
91
+ if (el.nodeName === "citation") return `[${el.getAttribute("label")}]`;
92
+ return el
93
+ .toArray()
94
+ .map((c) => walk(c as Y.XmlElement | Y.XmlText))
95
+ .join("");
96
+ };
97
+ console.log(
98
+ paragraph
99
+ .toArray()
100
+ .map((c) => walk(c as Y.XmlElement | Y.XmlText))
101
+ .join(""),
102
+ );
103
+
104
+ console.log("\nclosing...");
105
+ probe.destroy();
106
+ process.exit(0);
107
+ }
108
+
109
+ main().catch((err) => {
110
+ console.error("probe failed:", err);
111
+ process.exit(1);
112
+ });
backend/package-lock.json CHANGED
@@ -15,6 +15,7 @@
15
  "@citation-js/plugin-csl": "^0.7.22",
16
  "@citation-js/plugin-doi": "^0.7.21",
17
  "@hocuspocus/extension-database": "^3.4.4",
 
18
  "@hocuspocus/server": "^3.4.4",
19
  "@hocuspocus/transformer": "^3.4.4",
20
  "@huggingface/hub": "^2.11.0",
@@ -689,6 +690,31 @@
689
  "yjs": "^13.6.8"
690
  }
691
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  "node_modules/@hocuspocus/server": {
693
  "version": "3.4.4",
694
  "resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-3.4.4.tgz",
@@ -753,6 +779,12 @@
753
  "dev": true,
754
  "license": "MIT"
755
  },
 
 
 
 
 
 
756
  "node_modules/@napi-rs/wasm-runtime": {
757
  "version": "1.1.3",
758
  "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
@@ -1313,7 +1345,6 @@
1313
  "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.3.tgz",
1314
  "integrity": "sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==",
1315
  "license": "MIT",
1316
- "peer": true,
1317
  "funding": {
1318
  "type": "github",
1319
  "url": "https://github.com/sponsors/ueberdosis"
@@ -3216,7 +3247,6 @@
3216
  "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
3217
  "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
3218
  "license": "BSD-3-Clause",
3219
- "peer": true,
3220
  "engines": {
3221
  "node": ">=12.0.0"
3222
  }
@@ -3701,7 +3731,6 @@
3701
  "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
3702
  "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
3703
  "license": "MIT",
3704
- "peer": true,
3705
  "dependencies": {
3706
  "@types/hast": "^3.0.0",
3707
  "devlop": "^1.0.0",
 
15
  "@citation-js/plugin-csl": "^0.7.22",
16
  "@citation-js/plugin-doi": "^0.7.21",
17
  "@hocuspocus/extension-database": "^3.4.4",
18
+ "@hocuspocus/provider": "^4.0.0",
19
  "@hocuspocus/server": "^3.4.4",
20
  "@hocuspocus/transformer": "^3.4.4",
21
  "@huggingface/hub": "^2.11.0",
 
690
  "yjs": "^13.6.8"
691
  }
692
  },
693
+ "node_modules/@hocuspocus/provider": {
694
+ "version": "4.0.0",
695
+ "resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-4.0.0.tgz",
696
+ "integrity": "sha512-08gpeNZ6x2pmRD6m4XwRD52yQKnTl32a0HS9VSXZ5A1dIBVqxMz/x8Z06XbkKM2X8sp6vWEUCZCtzAGFSsofgg==",
697
+ "license": "MIT",
698
+ "dependencies": {
699
+ "@hocuspocus/common": "^4.0.0",
700
+ "@lifeomic/attempt": "^3.0.2",
701
+ "lib0": "^0.2.87",
702
+ "ws": "^8.17.1"
703
+ },
704
+ "peerDependencies": {
705
+ "y-protocols": "^1.0.6",
706
+ "yjs": "^13.6.8"
707
+ }
708
+ },
709
+ "node_modules/@hocuspocus/provider/node_modules/@hocuspocus/common": {
710
+ "version": "4.0.0",
711
+ "resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-4.0.0.tgz",
712
+ "integrity": "sha512-7BE8TsKBkdiOZO6tfm3ny6bIHPbxkIZb3hsYdVn/X5xbXI8n8w9pnE6pXgEMKQhJm6zsWsa9IDRJIp/c9u+DmA==",
713
+ "license": "MIT",
714
+ "dependencies": {
715
+ "lib0": "^0.2.87"
716
+ }
717
+ },
718
  "node_modules/@hocuspocus/server": {
719
  "version": "3.4.4",
720
  "resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-3.4.4.tgz",
 
779
  "dev": true,
780
  "license": "MIT"
781
  },
782
+ "node_modules/@lifeomic/attempt": {
783
+ "version": "3.1.0",
784
+ "resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz",
785
+ "integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==",
786
+ "license": "MIT"
787
+ },
788
  "node_modules/@napi-rs/wasm-runtime": {
789
  "version": "1.1.3",
790
  "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
 
1345
  "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.3.tgz",
1346
  "integrity": "sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==",
1347
  "license": "MIT",
 
1348
  "funding": {
1349
  "type": "github",
1350
  "url": "https://github.com/sponsors/ueberdosis"
 
3247
  "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
3248
  "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
3249
  "license": "BSD-3-Clause",
 
3250
  "engines": {
3251
  "node": ">=12.0.0"
3252
  }
 
3731
  "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
3732
  "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
3733
  "license": "MIT",
 
3734
  "dependencies": {
3735
  "@types/hast": "^3.0.0",
3736
  "devlop": "^1.0.0",
backend/package.json CHANGED
@@ -19,6 +19,7 @@
19
  "@citation-js/plugin-csl": "^0.7.22",
20
  "@citation-js/plugin-doi": "^0.7.21",
21
  "@hocuspocus/extension-database": "^3.4.4",
 
22
  "@hocuspocus/server": "^3.4.4",
23
  "@hocuspocus/transformer": "^3.4.4",
24
  "@huggingface/hub": "^2.11.0",
 
19
  "@citation-js/plugin-csl": "^0.7.22",
20
  "@citation-js/plugin-doi": "^0.7.21",
21
  "@hocuspocus/extension-database": "^3.4.4",
22
+ "@hocuspocus/provider": "^4.0.0",
23
  "@hocuspocus/server": "^3.4.4",
24
  "@hocuspocus/transformer": "^3.4.4",
25
  "@huggingface/hub": "^2.11.0",
frontend/src/App.tsx CHANGED
@@ -318,6 +318,27 @@ export default function App() {
318
  * textContent does NOT match the `from` plain-string.
319
  */
320
  range?: { from: number; to: number };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  };
322
  /**
323
  * Apply a formatting mark (bold/italic/strike/code) to an
@@ -350,6 +371,8 @@ export default function App() {
350
  const target = detail.replace.from;
351
  const replacement = detail.replace.to;
352
  const explicitRange = detail.replace.range;
 
 
353
 
354
  // Resolve the target range AT DISPATCH TIME, not here. We used
355
  // to do the string search on event receipt, cache from/to,
@@ -447,8 +470,36 @@ export default function App() {
447
  from,
448
  to,
449
  text: replacement,
450
- animation: "typewriter",
451
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  }, 520);
453
  }
454
 
 
318
  * textContent does NOT match the `from` plain-string.
319
  */
320
  range?: { from: number; to: number };
321
+ /**
322
+ * "none" -> atomic replace in one transaction. The
323
+ * viewer sees the new phrase "snap in",
324
+ * unambiguously an AI edit.
325
+ * "typewriter" -> stream chars at human cadence. Looks
326
+ * like the owner is typing (which is
327
+ * NOT what we want for an agent action -
328
+ * use "none" for rephrase actions so
329
+ * they don't look like user input).
330
+ * Default: "typewriter" for backwards compatibility.
331
+ */
332
+ animation?: "none" | "typewriter";
333
+ /**
334
+ * Keep an AgentFocus decoration on the replacement
335
+ * range for `lingerAgentFocusMs` ms after the swap so
336
+ * the viewer sees the "<Name> agent" label hover over
337
+ * the new sentence - visual proof the agent owned that
338
+ * edit. Ignored when 0 / undefined (caller will manage
339
+ * focus clearing itself).
340
+ */
341
+ lingerAgentFocusMs?: number;
342
  };
343
  /**
344
  * Apply a formatting mark (bold/italic/strike/code) to an
 
371
  const target = detail.replace.from;
372
  const replacement = detail.replace.to;
373
  const explicitRange = detail.replace.range;
374
+ const animation = detail.replace.animation ?? "typewriter";
375
+ const lingerAgentFocusMs = detail.replace.lingerAgentFocusMs ?? 0;
376
 
377
  // Resolve the target range AT DISPATCH TIME, not here. We used
378
  // to do the string search on event receipt, cache from/to,
 
470
  from,
471
  to,
472
  text: replacement,
473
+ animation,
474
  });
475
+
476
+ // Re-seed the AgentFocus on the REPLACEMENT range after the
477
+ // swap so the "<Name> agent" label keeps hovering over the
478
+ // new sentence - otherwise the decoration collapses during
479
+ // the delete-then-insert, and viewers perceive the new
480
+ // phrase as user typing instead of an agent edit. For the
481
+ // atomic "none" animation the new text is present
482
+ // immediately; for the typewriter we wait long enough for
483
+ // the first chunk to land so PM positions are stable.
484
+ if (lingerAgentFocusMs > 0) {
485
+ const seedDelay = animation === "none" ? 0 : 80;
486
+ window.setTimeout(() => {
487
+ const live = editorRef.current;
488
+ if (!live) return;
489
+ const newFrom = from;
490
+ const newTo = from + replacement.length;
491
+ const size = live.state.doc.content.size;
492
+ const a = Math.max(0, Math.min(newFrom, size));
493
+ const b = Math.max(0, Math.min(newTo, size));
494
+ if (b > a) {
495
+ live.commands.setAgentFocus({ from: a, to: b });
496
+ }
497
+ window.setTimeout(() => {
498
+ const again = editorRef.current;
499
+ again?.commands.clearAgentFocus();
500
+ }, lingerAgentFocusMs);
501
+ }, seedDelay);
502
+ }
503
  }, 520);
504
  }
505
 
frontend/src/components/PublishDialog.tsx CHANGED
@@ -1,5 +1,12 @@
1
- import { forwardRef } from "react";
2
- import { Eye } from "lucide-react";
 
 
 
 
 
 
 
3
 
4
  export type PublishState = "idle" | "loading" | "success" | "error";
5
 
@@ -55,6 +62,22 @@ const STAGE_LABELS: Record<string, string> = {
55
  upload: "Uploading to storage",
56
  };
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  function formatStage(event: PublishStageEvent | null): string {
59
  if (!event) return "Publishing...";
60
  const base = STAGE_LABELS[event.stage] ?? "Publishing...";
@@ -64,6 +87,28 @@ function formatStage(event: PublishStageEvent | null): string {
64
  return base;
65
  }
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  /**
68
  * Native `<dialog>` shown when the user clicks "Publish" in the top bar.
69
  * Displays live stage progress during the long PDF pipeline (typically
@@ -75,67 +120,154 @@ export const PublishDialog = forwardRef<HTMLDialogElement, Props>(function Publi
75
  ) {
76
  const stageLabel = formatStage(stageEvent);
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  return (
79
- <dialog ref={ref} className="ed-dialog" onClose={onClose}>
80
- <h2 className="ed-dialog__title">
81
- {state === "success" ? "Published!" : "Publish article"}
82
- </h2>
83
- <div className="ed-dialog__body">
84
- {state === "idle" && (
85
- <p>
86
- This will generate a static HTML page, a print-optimized PDF,
87
- and a thumbnail from the current document. The process
88
- typically takes 15-25 seconds. Other collaborators can keep
89
- editing while you publish.
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  )}
 
92
  {state === "loading" && (
93
- <div style={{ display: "flex", flexDirection: "column", gap: 8, padding: "8px 0" }}>
94
- <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
95
- <span className="spinner" />
96
- <p style={{ margin: 0 }}>{stageLabel}</p>
 
 
 
 
 
 
 
 
97
  </div>
98
- <p style={{ margin: 0, fontSize: "0.85em", opacity: 0.7 }}>
99
- Rasterizing embeds and rendering a print-ready PDF can take
100
- a moment. Keep this tab open until it finishes.
101
  </p>
102
  </div>
103
  )}
 
104
  {state === "success" && (
105
- <>
106
- <p>
107
- Article published successfully. Visitors will now see
108
- the updated version.
109
- </p>
110
  <a
111
- href={`/published/${docName}/index.html`}
112
  target="_blank"
113
  rel="noopener noreferrer"
114
- className="btn btn--primary"
115
- style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none", marginTop: 4 }}
116
  >
117
  <Eye size={16} />
118
  View published article
 
119
  </a>
120
- </>
121
  )}
 
122
  {state === "error" && (
123
- <p style={{ color: "var(--ed-error)" }}>
124
  {error || "An error occurred while publishing."}
125
  </p>
126
  )}
127
  </div>
128
- <div className="ed-dialog__actions">
 
129
  {state === "idle" && (
130
  <>
131
  <button className="btn" onClick={onClose}>Cancel</button>
132
- <button className="btn btn--primary" onClick={onPublish}>Publish</button>
 
 
 
133
  </>
134
  )}
135
  {(state === "success" || state === "error") && (
136
  <button className="btn" onClick={onClose}>Close</button>
137
  )}
138
- </div>
139
  </dialog>
140
  );
141
  });
 
1
+ import { forwardRef, useEffect, useRef, useState } from "react";
2
+ import {
3
+ Eye,
4
+ UploadCloud,
5
+ CheckCircle2,
6
+ AlertTriangle,
7
+ Sparkles,
8
+ ExternalLink,
9
+ } from "lucide-react";
10
 
11
  export type PublishState = "idle" | "loading" | "success" | "error";
12
 
 
62
  upload: "Uploading to storage",
63
  };
64
 
65
+ // Canonical order used to compute a progress ratio. Stages not in
66
+ // this list still render their label but don't retract the bar.
67
+ const STAGE_ORDER: PublishStage[] = [
68
+ "extract",
69
+ "bibliography",
70
+ "render",
71
+ "load",
72
+ "thumbnail",
73
+ "pdf-setup",
74
+ "pdf-toc",
75
+ "rasterize",
76
+ "pdf-render",
77
+ "write",
78
+ "upload",
79
+ ];
80
+
81
  function formatStage(event: PublishStageEvent | null): string {
82
  if (!event) return "Publishing...";
83
  const base = STAGE_LABELS[event.stage] ?? "Publishing...";
 
87
  return base;
88
  }
89
 
90
+ /**
91
+ * Compute a 0..1 progress ratio for `event`, blending rasterize
92
+ * sub-progress. Returns `null` for unknown stages so the caller can
93
+ * hold the previous value instead of retracting the bar.
94
+ */
95
+ function computeProgress(event: PublishStageEvent | null): number | null {
96
+ if (!event) return 0.04;
97
+ const idx = STAGE_ORDER.indexOf(event.stage);
98
+ if (idx === -1) return null;
99
+ const slots = STAGE_ORDER.length + 1;
100
+ let base = (idx + 1) / slots;
101
+ if (
102
+ event.stage === "rasterize" &&
103
+ event.detail?.index &&
104
+ event.detail?.total
105
+ ) {
106
+ const rasterFrac = Math.min(1, event.detail.index / event.detail.total);
107
+ base = idx / slots + rasterFrac / slots;
108
+ }
109
+ return Math.max(0.04, Math.min(0.98, base));
110
+ }
111
+
112
  /**
113
  * Native `<dialog>` shown when the user clicks "Publish" in the top bar.
114
  * Displays live stage progress during the long PDF pipeline (typically
 
120
  ) {
121
  const stageLabel = formatStage(stageEvent);
122
 
123
+ // Monotonic progress. The backend can emit unknown stages or
124
+ // re-emit earlier ones during retries; the bar should never walk
125
+ // backwards on camera.
126
+ const progressRef = useRef<number>(0.04);
127
+ const [progress, setProgress] = useState<number>(0.04);
128
+ useEffect(() => {
129
+ if (state === "idle") {
130
+ progressRef.current = 0.04;
131
+ setProgress(0.04);
132
+ return;
133
+ }
134
+ if (state === "success") {
135
+ progressRef.current = 1;
136
+ setProgress(1);
137
+ return;
138
+ }
139
+ if (state === "error") return;
140
+ const next = computeProgress(stageEvent);
141
+ if (next == null) return;
142
+ if (next > progressRef.current) {
143
+ progressRef.current = next;
144
+ setProgress(next);
145
+ }
146
+ }, [stageEvent, state]);
147
+
148
+ const publishedUrl = `/published/${docName}/index.html`;
149
+ const displayUrl = publishedUrl.replace(/\/index\.html$/, "");
150
+
151
  return (
152
+ <dialog
153
+ ref={ref}
154
+ className={`ed-dialog ed-dialog--publish ed-dialog--publish-${state}`}
155
+ onClose={onClose}
156
+ >
157
+ <header className="publish-dialog__header">
158
+ <div className={`publish-dialog__icon publish-dialog__icon--${state}`}>
159
+ {state === "idle" && <UploadCloud size={22} strokeWidth={1.75} />}
160
+ {state === "loading" && <span className="spinner" aria-hidden />}
161
+ {state === "success" && <CheckCircle2 size={22} strokeWidth={1.75} />}
162
+ {state === "error" && <AlertTriangle size={22} strokeWidth={1.75} />}
163
+ </div>
164
+ <div className="publish-dialog__heading">
165
+ <h2 className="publish-dialog__title">
166
+ {state === "idle" && "Publish article"}
167
+ {state === "loading" && "Publishing your article"}
168
+ {state === "success" && "Published!"}
169
+ {state === "error" && "Publish failed"}
170
+ </h2>
171
+ <p className="publish-dialog__subtitle">
172
+ {state === "idle" && "Static HTML, print-ready PDF and thumbnail."}
173
+ {state === "loading" && stageLabel}
174
+ {state === "success" && "Your update is live for every reader."}
175
+ {state === "error" && "We couldn't finish the publish run."}
176
  </p>
177
+ </div>
178
+ </header>
179
+
180
+ <div className="publish-dialog__body">
181
+ {state === "idle" && (
182
+ <div className="publish-dialog__intro">
183
+ <ul className="publish-dialog__checklist">
184
+ <li>
185
+ <span className="publish-dialog__bullet" aria-hidden />
186
+ <span>
187
+ <strong>Static HTML</strong> page ready to share.
188
+ </span>
189
+ </li>
190
+ <li>
191
+ <span className="publish-dialog__bullet" aria-hidden />
192
+ <span>
193
+ <strong>Print-ready PDF</strong> with full typography.
194
+ </span>
195
+ </li>
196
+ <li>
197
+ <span className="publish-dialog__bullet" aria-hidden />
198
+ <span>
199
+ <strong>Social thumbnail</strong> captured automatically.
200
+ </span>
201
+ </li>
202
+ </ul>
203
+ <p className="publish-dialog__note">
204
+ <Sparkles size={12} strokeWidth={2} aria-hidden />
205
+ Takes 15-25s. Collaborators can keep editing in the meantime.
206
+ </p>
207
+ </div>
208
  )}
209
+
210
  {state === "loading" && (
211
+ <div className="publish-dialog__progress">
212
+ <div
213
+ className="publish-dialog__bar"
214
+ role="progressbar"
215
+ aria-valuemin={0}
216
+ aria-valuemax={100}
217
+ aria-valuenow={Math.round(progress * 100)}
218
+ >
219
+ <span
220
+ className="publish-dialog__bar-fill"
221
+ style={{ width: `${Math.round(progress * 100)}%` }}
222
+ />
223
  </div>
224
+ <p className="publish-dialog__hint">
225
+ Rasterizing embeds and rendering the PDF can take a moment. Keep
226
+ this tab open until it finishes.
227
  </p>
228
  </div>
229
  )}
230
+
231
  {state === "success" && (
232
+ <div className="publish-dialog__success">
233
+ <div className="publish-dialog__success-url">
234
+ <span className="publish-dialog__url-label">Live at</span>
235
+ <code className="publish-dialog__url-code">{displayUrl}</code>
236
+ </div>
237
  <a
238
+ href={publishedUrl}
239
  target="_blank"
240
  rel="noopener noreferrer"
241
+ className="btn btn--primary publish-dialog__view-btn"
 
242
  >
243
  <Eye size={16} />
244
  View published article
245
+ <ExternalLink size={13} strokeWidth={2} aria-hidden />
246
  </a>
247
+ </div>
248
  )}
249
+
250
  {state === "error" && (
251
+ <p className="publish-dialog__error">
252
  {error || "An error occurred while publishing."}
253
  </p>
254
  )}
255
  </div>
256
+
257
+ <footer className="publish-dialog__actions">
258
  {state === "idle" && (
259
  <>
260
  <button className="btn" onClick={onClose}>Cancel</button>
261
+ <button className="btn btn--primary" onClick={onPublish}>
262
+ <UploadCloud size={14} strokeWidth={2} aria-hidden />
263
+ Publish
264
+ </button>
265
  </>
266
  )}
267
  {(state === "success" || state === "error") && (
268
  <button className="btn" onClick={onClose}>Close</button>
269
  )}
270
+ </footer>
271
  </dialog>
272
  );
273
  });
frontend/src/editor/extensions/agent-rewrite.ts CHANGED
@@ -164,13 +164,55 @@ export const AgentRewrite = Extension.create({
164
 
165
  agentRewriteRange:
166
  (args) =>
167
- ({ editor, state, view, tr, dispatch }) => {
168
  const docSize = state.doc.content.size;
169
  const a = Math.max(0, Math.min(args.from, docSize));
170
  const b = Math.max(0, Math.min(args.to, docSize));
171
  if (b < a) return false;
172
  if (!dispatch) return true;
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  // Step 1: single atomic transaction that (a) cancels any
175
  // in-flight rewrite, (b) deletes the target range, and (c)
176
  // plants the insertion cursor at `a`. Done in one tr so the
@@ -183,7 +225,6 @@ export const AgentRewrite = Extension.create({
183
  if (b > a) tr.delete(a, b);
184
  dispatch(tr);
185
 
186
- const text = args.text ?? "";
187
  if (!text) {
188
  // Nothing to insert, just a deletion. Done.
189
  view.dispatch(
@@ -194,38 +235,39 @@ export const AgentRewrite = Extension.create({
194
  return true;
195
  }
196
 
197
- const animation = args.animation ?? "none";
198
- if (animation === "none") {
199
- // Synchronous, one-shot insert. Single tr -> one undo step.
200
- const cursor =
201
- agentRewriteKey.getState(editor.state)?.cursor ?? a;
202
- view.dispatch(
203
- view.state.tr
204
- .insertText(text, cursor)
205
- .setMeta(agentRewriteKey, {
206
- setCursor: null,
207
- } as AgentRewriteMeta),
208
- );
209
- return true;
210
- }
211
-
212
  // Typewriter: async stream of small insertText trs. Each step
213
  // reads the CURRENT cursor from plugin state (mapped through
214
  // every intervening tr, including remote Yjs updates), so the
215
  // animation is immune to concurrent edits.
216
  const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
217
  const [delayMin, delayMax] = args.chunkMs ?? [22, 48];
218
- const myToken =
219
- agentRewriteKey.getState(editor.state)?.cancelToken ?? 0;
220
  let i = 0;
221
 
 
 
 
 
 
 
 
 
 
 
222
  const step = () => {
223
  if (view.isDestroyed) return;
224
  const ps = agentRewriteKey.getState(view.state);
225
- if (!ps) return;
226
- // Another rewrite started (or cancel was called) -> stop.
227
- if (ps.cancelToken !== myToken) return;
228
- if (ps.cursor === null) return;
 
 
 
 
 
 
 
 
229
 
230
  if (i >= text.length) {
231
  // Done: clear the cursor marker so nothing lingers in
@@ -247,9 +289,11 @@ export const AgentRewrite = Extension.create({
247
  Math.floor(Math.random() * (maxTake - minTake + 1));
248
  const chunk = text.slice(i, i + take);
249
 
250
- // `insertText(chunk, cursor)` advances the cursor via the
251
- // plugin's `apply` mapping (see state.apply above).
252
- view.dispatch(view.state.tr.insertText(chunk, ps.cursor));
 
 
253
  i += chunk.length;
254
 
255
  const delay =
 
164
 
165
  agentRewriteRange:
166
  (args) =>
167
+ ({ state, view, tr, dispatch }) => {
168
  const docSize = state.doc.content.size;
169
  const a = Math.max(0, Math.min(args.from, docSize));
170
  const b = Math.max(0, Math.min(args.to, docSize));
171
  if (b < a) return false;
172
  if (!dispatch) return true;
173
 
174
+ // Capture `myToken` BEFORE the dispatch. Tiptap's
175
+ // CommandManager turns out to batch the `tr` we mutate here
176
+ // into its own dispatch cycle, so reading `view.state`
177
+ // immediately after calling the provided `dispatch(tr)` can
178
+ // return the PRE-commit state (cancelToken still = N,
179
+ // cursor still = null). The only reliable source is the
180
+ // state we started from (`state`): we know this command
181
+ // will bump cancelToken by 1 and seed the cursor at `a`,
182
+ // so we compute those values ourselves instead of reading
183
+ // them back from a potentially stale view.
184
+ const prevToken = agentRewriteKey.getState(state)?.cancelToken ?? 0;
185
+ const myToken = prevToken + 1;
186
+
187
+ const text = args.text ?? "";
188
+ const animation = args.animation ?? "none";
189
+
190
+ // ----- Atomic path ("none") -----------------------------------
191
+ // Do the whole replace in ONE transaction: cancel any
192
+ // in-flight rewrite, replace [a, b] with `text`, clear the
193
+ // cursor marker. Two separate dispatches produced double
194
+ // content (insert + zombie old text) because Tiptap's
195
+ // CommandManager does NOT update `view.state` between
196
+ // `dispatch(tr)` and the next `view.state.tr.insertText`
197
+ // call inside the same command - so the second tr
198
+ // insertText ran against the PRE-delete state, effectively
199
+ // adding the new phrase in front of the old one without
200
+ // deleting it.
201
+ if (animation === "none") {
202
+ tr.setMeta(agentRewriteKey, {
203
+ cancel: true,
204
+ setCursor: null,
205
+ } as AgentRewriteMeta);
206
+ if (text) {
207
+ tr.insertText(text, a, b);
208
+ } else if (b > a) {
209
+ tr.delete(a, b);
210
+ }
211
+ dispatch(tr);
212
+ return true;
213
+ }
214
+
215
+ // ----- Typewriter path -----------------------------------
216
  // Step 1: single atomic transaction that (a) cancels any
217
  // in-flight rewrite, (b) deletes the target range, and (c)
218
  // plants the insertion cursor at `a`. Done in one tr so the
 
225
  if (b > a) tr.delete(a, b);
226
  dispatch(tr);
227
 
 
228
  if (!text) {
229
  // Nothing to insert, just a deletion. Done.
230
  view.dispatch(
 
235
  return true;
236
  }
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  // Typewriter: async stream of small insertText trs. Each step
239
  // reads the CURRENT cursor from plugin state (mapped through
240
  // every intervening tr, including remote Yjs updates), so the
241
  // animation is immune to concurrent edits.
242
  const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
243
  const [delayMin, delayMax] = args.chunkMs ?? [22, 48];
 
 
244
  let i = 0;
245
 
246
+ // Closure-held cursor is our source of truth: seeded at `a`,
247
+ // then synced with plugin state before each insert so remote
248
+ // Yjs trs between our ticks remap the cursor correctly via
249
+ // the plugin's mapping logic. Using a closure (rather than
250
+ // reading plugin state blindly) guarantees the first step
251
+ // doesn't bail out if Tiptap's command dispatch hasn't yet
252
+ // committed the `setCursor: a` meta to the plugin state that
253
+ // `view.state` exposes.
254
+ let cursor = a;
255
+
256
  const step = () => {
257
  if (view.isDestroyed) return;
258
  const ps = agentRewriteKey.getState(view.state);
259
+ // Cancellation only: token mismatch means another rewrite
260
+ // started (or `cancelAgentRewrite` fired).
261
+ if (ps && ps.cancelToken !== myToken) {
262
+ // Another rewrite started or `cancelAgentRewrite` fired.
263
+ return;
264
+ }
265
+ // Pull the mapped cursor from plugin state if available -
266
+ // this accounts for any remote Yjs inserts that happened
267
+ // since the last step. Fall back to the closure value on
268
+ // the very first tick, where plugin state can still be
269
+ // null due to Tiptap's command dispatch batching.
270
+ if (ps && ps.cursor !== null) cursor = ps.cursor;
271
 
272
  if (i >= text.length) {
273
  // Done: clear the cursor marker so nothing lingers in
 
289
  Math.floor(Math.random() * (maxTake - minTake + 1));
290
  const chunk = text.slice(i, i + take);
291
 
292
+ // Insert at our closure-tracked cursor. The plugin's
293
+ // apply() will advance the stored cursor via mapping so
294
+ // subsequent remote trs remap it correctly.
295
+ view.dispatch(view.state.tr.insertText(chunk, cursor));
296
+ cursor += chunk.length;
297
  i += chunk.length;
298
 
299
  const delay =
frontend/src/styles/_ui.css CHANGED
@@ -214,6 +214,259 @@ dialog.ed-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
214
  .ed-dialog__body p { margin: 0; }
215
  .ed-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 20px 16px; }
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  /* ---- Drawer ---- */
218
  .drawer-backdrop {
219
  position: fixed;
 
214
  .ed-dialog__body p { margin: 0; }
215
  .ed-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 20px 16px; }
216
 
217
+ /* ---- Publish dialog ---- */
218
+ dialog.ed-dialog.ed-dialog--publish {
219
+ max-width: 460px;
220
+ overflow: hidden;
221
+ box-shadow:
222
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset,
223
+ 0 24px 48px -12px rgba(0, 0, 0, 0.35),
224
+ 0 8px 18px -8px rgba(0, 0, 0, 0.25);
225
+ }
226
+ dialog.ed-dialog.ed-dialog--publish::backdrop {
227
+ background: rgba(10, 10, 12, 0.55);
228
+ backdrop-filter: blur(2px);
229
+ }
230
+
231
+ .publish-dialog__header {
232
+ display: flex;
233
+ align-items: flex-start;
234
+ gap: 14px;
235
+ padding: 22px 24px 14px;
236
+ position: relative;
237
+ }
238
+ .publish-dialog__header::after {
239
+ content: "";
240
+ position: absolute;
241
+ left: 24px;
242
+ right: 24px;
243
+ bottom: 0;
244
+ height: 1px;
245
+ background: var(--ed-border);
246
+ opacity: 0.6;
247
+ }
248
+
249
+ .publish-dialog__icon {
250
+ flex-shrink: 0;
251
+ width: 40px;
252
+ height: 40px;
253
+ border-radius: 10px;
254
+ display: inline-flex;
255
+ align-items: center;
256
+ justify-content: center;
257
+ background: color-mix(in srgb, var(--primary-color) 14%, transparent);
258
+ color: var(--primary-color);
259
+ transition: background 0.25s ease, color 0.25s ease;
260
+ }
261
+ .publish-dialog__icon--success {
262
+ background: color-mix(in srgb, var(--ed-success) 16%, transparent);
263
+ color: var(--ed-success);
264
+ }
265
+ .publish-dialog__icon--error {
266
+ background: color-mix(in srgb, var(--ed-error) 16%, transparent);
267
+ color: var(--ed-error);
268
+ }
269
+ .publish-dialog__icon--loading .spinner {
270
+ width: 20px;
271
+ height: 20px;
272
+ border-width: 2px;
273
+ }
274
+
275
+ .publish-dialog__heading {
276
+ flex: 1;
277
+ display: flex;
278
+ flex-direction: column;
279
+ gap: 2px;
280
+ min-width: 0;
281
+ }
282
+ .publish-dialog__title {
283
+ margin: 0;
284
+ font-size: 1.05rem;
285
+ font-weight: 600;
286
+ letter-spacing: -0.005em;
287
+ color: var(--ed-text);
288
+ }
289
+ .publish-dialog__subtitle {
290
+ margin: 0;
291
+ font-size: 0.85rem;
292
+ color: var(--ed-text-secondary);
293
+ line-height: 1.45;
294
+ /* Stage name can grow during loading; avoid layout jump. */
295
+ min-height: 1.2em;
296
+ }
297
+
298
+ .publish-dialog__body {
299
+ padding: 18px 24px 6px;
300
+ }
301
+
302
+ .publish-dialog__intro {
303
+ display: flex;
304
+ flex-direction: column;
305
+ gap: 14px;
306
+ }
307
+
308
+ .publish-dialog__checklist {
309
+ list-style: none;
310
+ margin: 0;
311
+ padding: 14px 14px;
312
+ display: flex;
313
+ flex-direction: column;
314
+ gap: 10px;
315
+ font-size: 0.88rem;
316
+ color: var(--ed-text);
317
+ border: 1px solid var(--ed-border);
318
+ border-radius: var(--ed-radius-sm);
319
+ background: color-mix(in srgb, var(--primary-color) 5%, transparent);
320
+ }
321
+ .publish-dialog__checklist li {
322
+ display: flex;
323
+ align-items: flex-start;
324
+ gap: 10px;
325
+ line-height: 1.45;
326
+ }
327
+ .publish-dialog__checklist strong {
328
+ color: var(--ed-text);
329
+ font-weight: 600;
330
+ }
331
+ .publish-dialog__bullet {
332
+ flex-shrink: 0;
333
+ margin-top: 7px;
334
+ width: 6px;
335
+ height: 6px;
336
+ border-radius: 50%;
337
+ background: var(--primary-color);
338
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent);
339
+ }
340
+ .publish-dialog__note {
341
+ margin: 0;
342
+ display: inline-flex;
343
+ align-items: center;
344
+ gap: 6px;
345
+ font-size: 0.78rem;
346
+ color: var(--ed-text-disabled);
347
+ padding-left: 2px;
348
+ }
349
+ .publish-dialog__note svg {
350
+ color: var(--primary-color);
351
+ opacity: 0.8;
352
+ }
353
+
354
+ .publish-dialog__progress {
355
+ display: flex;
356
+ flex-direction: column;
357
+ gap: 12px;
358
+ }
359
+ .publish-dialog__bar {
360
+ position: relative;
361
+ height: 6px;
362
+ border-radius: 999px;
363
+ background: var(--ed-surface-hover);
364
+ overflow: hidden;
365
+ }
366
+ .publish-dialog__bar-fill {
367
+ position: absolute;
368
+ left: 0;
369
+ top: 0;
370
+ bottom: 0;
371
+ border-radius: 999px;
372
+ background: linear-gradient(
373
+ 90deg,
374
+ color-mix(in srgb, var(--primary-color) 55%, transparent),
375
+ var(--primary-color)
376
+ );
377
+ transition: width 0.45s cubic-bezier(0.4, 0, 0.2, 1);
378
+ box-shadow: 0 0 8px color-mix(in srgb, var(--primary-color) 45%, transparent);
379
+ }
380
+ .publish-dialog__bar-fill::after {
381
+ content: "";
382
+ position: absolute;
383
+ inset: 0;
384
+ background: linear-gradient(
385
+ 90deg,
386
+ transparent,
387
+ rgba(255, 255, 255, 0.18),
388
+ transparent
389
+ );
390
+ transform: translateX(-100%);
391
+ animation: publish-shimmer 1.6s ease-in-out infinite;
392
+ }
393
+ @keyframes publish-shimmer {
394
+ to { transform: translateX(100%); }
395
+ }
396
+ .publish-dialog__hint {
397
+ margin: 0;
398
+ font-size: 0.8rem;
399
+ color: var(--ed-text-disabled);
400
+ line-height: 1.5;
401
+ }
402
+
403
+ .publish-dialog__success {
404
+ display: flex;
405
+ flex-direction: column;
406
+ gap: 14px;
407
+ }
408
+ .publish-dialog__success-url {
409
+ display: flex;
410
+ align-items: center;
411
+ gap: 10px;
412
+ padding: 10px 12px;
413
+ border: 1px solid var(--ed-border);
414
+ border-radius: var(--ed-radius-sm);
415
+ background: color-mix(in srgb, var(--ed-success) 6%, transparent);
416
+ }
417
+ .publish-dialog__url-label {
418
+ font-size: 0.72rem;
419
+ font-weight: 600;
420
+ text-transform: uppercase;
421
+ letter-spacing: 0.04em;
422
+ color: var(--ed-success);
423
+ flex-shrink: 0;
424
+ }
425
+ .publish-dialog__url-code {
426
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
427
+ font-size: 0.82rem;
428
+ color: var(--ed-text);
429
+ overflow: hidden;
430
+ text-overflow: ellipsis;
431
+ white-space: nowrap;
432
+ flex: 1;
433
+ min-width: 0;
434
+ background: transparent;
435
+ padding: 0;
436
+ }
437
+
438
+ .publish-dialog__view-btn {
439
+ display: inline-flex;
440
+ align-items: center;
441
+ justify-content: center;
442
+ gap: 8px;
443
+ text-decoration: none;
444
+ width: 100%;
445
+ padding: 10px 16px;
446
+ font-weight: 600;
447
+ }
448
+ .publish-dialog__view-btn svg:last-of-type {
449
+ opacity: 0.8;
450
+ }
451
+
452
+ .publish-dialog__error {
453
+ margin: 0;
454
+ padding: 10px 12px;
455
+ border-radius: var(--ed-radius-sm);
456
+ background: color-mix(in srgb, var(--ed-error) 10%, transparent);
457
+ border: 1px solid color-mix(in srgb, var(--ed-error) 30%, transparent);
458
+ color: var(--ed-error);
459
+ font-size: 0.85rem;
460
+ line-height: 1.5;
461
+ }
462
+
463
+ .publish-dialog__actions {
464
+ display: flex;
465
+ justify-content: flex-end;
466
+ gap: 8px;
467
+ padding: 14px 24px 20px;
468
+ }
469
+
470
  /* ---- Drawer ---- */
471
  .drawer-backdrop {
472
  position: fixed;