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

fix(ui,publisher): kill theme/color flickers and translation prompts

Browse files

- Published page and its banner iframe now inline a synchronous theme
bootstrap in <head>, so the first paint already uses the correct
data-theme (no dark -> light flicker on navigation).
- <meta name="google|robots" content="notranslate"> on both the
editor (frontend/index.html) and the published article so Chrome
stops proposing to translate the page to French.
- CollaborationCursorV3: silence a peer's caret + label (via the
.collaboration-cursor--silenced class) when that same peer also
broadcasts an agentFocus. Fixes the duplicate "<Name>" + "<Name>
agent" labels that stacked while a chat action was active.
- Embed Studio preview container pinned to --page-bg and the iframe
element forced to background: transparent + color-scheme: light
dark, so the preview tracks dark mode instead of falling through
to the browser's default white.
- App.tsx primaryHue sync: when the Yjs settings map has no
primaryHue (fresh state or after reset-article), drop the inline
--primary-base override so the CSS default from _variables.css
wins. No more unmotivated hue jump at demo boot.
- Boot overlay spinner (.spinner--lg) painted with --muted-color
instead of --primary-color so it no longer flashes from the
default warm yellow to the article's overridden hue while Yjs
is syncing.
- html-renderer snapshot regenerated to reflect the theme bootstrap
and notranslate metas.

Made-with: Cursor

backend/src/publisher/html-renderer.ts CHANGED
@@ -74,11 +74,48 @@ export async function renderArticleHTML(
74
  const safeDesc = escapeHtml(meta.description);
75
 
76
  let result = `<!DOCTYPE html>
77
- <html lang="en" data-theme="dark">
78
  <head>
79
  <meta charset="UTF-8">
80
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 
 
 
 
 
 
 
 
 
 
81
  <title>${safeTitle}</title>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  <meta name="description" content="${safeDesc}">
83
  <meta name="author" content="${escapeHtml(authorsStr)}">
84
 
 
74
  const safeDesc = escapeHtml(meta.description);
75
 
76
  let result = `<!DOCTYPE html>
77
+ <html lang="en">
78
  <head>
79
  <meta charset="UTF-8">
80
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
81
+ <!--
82
+ Opt out of Chrome/Edge/Firefox in-browser translation. The article
83
+ body is authored in English and contains inline math / code that
84
+ the translator would happily garble. These metas tell the browser
85
+ to never surface the translate prompt even on non-English OS
86
+ locales. The google-named tag is the historical Chrome opt-out;
87
+ most other engines read the same tag.
88
+ -->
89
+ <meta name="google" content="notranslate">
90
+ <meta name="robots" content="notranslate">
91
  <title>${safeTitle}</title>
92
+
93
+ <!--
94
+ Blocking theme bootstrap (runs before any CSS is parsed).
95
+ Sets <html data-theme> to the user's preferred mode so the
96
+ FIRST paint already uses the right palette - no dark->light
97
+ flash on a "light preference" browser. Must stay:
98
+ - inline (no network dependency)
99
+ - synchronous (no async/defer)
100
+ - placed BEFORE <style> in <head>
101
+ The rest of the theme logic (toggle button, icon swap, live
102
+ media-query sync) still runs at end-of-body where it is less
103
+ critical.
104
+ -->
105
+ <script>
106
+ (function() {
107
+ try {
108
+ var saved = localStorage.getItem('theme');
109
+ var prefersDark = window.matchMedia &&
110
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
111
+ var mode = saved || (prefersDark ? 'dark' : 'light');
112
+ document.documentElement.setAttribute('data-theme', mode);
113
+ document.documentElement.style.colorScheme = mode;
114
+ } catch (e) {
115
+ document.documentElement.setAttribute('data-theme', 'dark');
116
+ }
117
+ })();
118
+ </script>
119
  <meta name="description" content="${safeDesc}">
120
  <meta name="author" content="${escapeHtml(authorsStr)}">
121
 
backend/src/shared/embed-doc.ts CHANGED
@@ -114,6 +114,51 @@ const HEIGHT_REPORTER = `
114
  })();
115
  `.trim();
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  /**
118
  * Theme listener: swap CSS variables via [data-theme] without reloading.
119
  * Also supports live primary-color updates and invokes window.__chartRerender
@@ -235,6 +280,11 @@ export function buildEmbedSrcdoc(htmlFragment: string, opts: BuildEmbedSrcdocOpt
235
  `<html data-theme="${themeAttr}">`,
236
  "<head>",
237
  '<meta charset="UTF-8">',
 
 
 
 
 
238
  `<style>${BASE_STYLES}${inlinePrimary}${extraStyles}</style>`,
239
  `<script>${COLOR_PALETTES_POLYFILL}<\/script>`,
240
  "</head>",
 
114
  })();
115
  `.trim();
116
 
117
+ /**
118
+ * Synchronous pre-paint theme bootstrap.
119
+ *
120
+ * Runs BEFORE the iframe's own <style> applies, so the first paint already
121
+ * uses the correct theme + primary color and the banner/chart never flashes
122
+ * from the default (light / #4e79a7) to the user-selected theme.
123
+ *
124
+ * Strategy (tried in order, first hit wins):
125
+ * 1. parent.documentElement[data-theme] + parent's computed --primary-color
126
+ * → works in published articles, the editor, and any embed sharing
127
+ * origin with its host (we rely on `allow-same-origin` being set).
128
+ * 2. localStorage.theme (same origin as parent).
129
+ * 3. matchMedia('(prefers-color-scheme: dark)').
130
+ *
131
+ * All accesses are wrapped in try/catch: if the iframe is cross-origin or
132
+ * sandboxed, we silently fall back to the server-provided defaults baked
133
+ * into the srcdoc (`data-theme`, `--primary-color`) and the postMessage
134
+ * setTheme path still works as a safety net.
135
+ */
136
+ const THEME_BOOTSTRAP = `
137
+ (function(){
138
+ try {
139
+ var theme = null;
140
+ var primary = null;
141
+ try {
142
+ var parentDoc = window.parent && window.parent.document;
143
+ if (parentDoc) {
144
+ theme = parentDoc.documentElement.getAttribute('data-theme') || null;
145
+ var pc = getComputedStyle(parentDoc.documentElement).getPropertyValue('--primary-color');
146
+ if (pc) primary = pc.trim();
147
+ }
148
+ } catch(e) {}
149
+ if (!theme) {
150
+ try { theme = window.localStorage && window.localStorage.getItem('theme'); } catch(e) {}
151
+ }
152
+ if (!theme) {
153
+ theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
154
+ }
155
+ document.documentElement.setAttribute('data-theme', theme);
156
+ document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light';
157
+ if (primary) document.documentElement.style.setProperty('--primary-color', primary);
158
+ } catch(e) {}
159
+ })();
160
+ `.trim();
161
+
162
  /**
163
  * Theme listener: swap CSS variables via [data-theme] without reloading.
164
  * Also supports live primary-color updates and invokes window.__chartRerender
 
280
  `<html data-theme="${themeAttr}">`,
281
  "<head>",
282
  '<meta charset="UTF-8">',
283
+ // Pre-paint theme bootstrap MUST come before the <style> block so the
284
+ // correct [data-theme] attribute is in place when the default tokens
285
+ // cascade applies. Otherwise the iframe flashes from server-default to
286
+ // client theme once the postMessage listener kicks in post-load.
287
+ `<script>${THEME_BOOTSTRAP}<\/script>`,
288
  `<style>${BASE_STYLES}${inlinePrimary}${extraStyles}</style>`,
289
  `<script>${COLOR_PALETTES_POLYFILL}<\/script>`,
290
  "</head>",
backend/tests/__snapshots__/html-renderer-snapshot.test.ts.snap CHANGED
@@ -2,11 +2,48 @@
2
 
3
  exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
4
  "<!DOCTYPE html>
5
- <html lang="en" data-theme="dark">
6
  <head>
7
  <meta charset="UTF-8">
8
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 
 
 
 
 
 
 
 
 
 
9
  <title>Test Article</title>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  <meta name="description" content="A test article">
11
  <meta name="author" content="Alice">
12
 
 
2
 
3
  exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
4
  "<!DOCTYPE html>
5
+ <html lang="en">
6
  <head>
7
  <meta charset="UTF-8">
8
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
9
+ <!--
10
+ Opt out of Chrome/Edge/Firefox in-browser translation. The article
11
+ body is authored in English and contains inline math / code that
12
+ the translator would happily garble. These metas tell the browser
13
+ to never surface the translate prompt even on non-English OS
14
+ locales. The google-named tag is the historical Chrome opt-out;
15
+ most other engines read the same tag.
16
+ -->
17
+ <meta name="google" content="notranslate">
18
+ <meta name="robots" content="notranslate">
19
  <title>Test Article</title>
20
+
21
+ <!--
22
+ Blocking theme bootstrap (runs before any CSS is parsed).
23
+ Sets <html data-theme> to the user's preferred mode so the
24
+ FIRST paint already uses the right palette - no dark->light
25
+ flash on a "light preference" browser. Must stay:
26
+ - inline (no network dependency)
27
+ - synchronous (no async/defer)
28
+ - placed BEFORE <style> in <head>
29
+ The rest of the theme logic (toggle button, icon swap, live
30
+ media-query sync) still runs at end-of-body where it is less
31
+ critical.
32
+ -->
33
+ <script>
34
+ (function() {
35
+ try {
36
+ var saved = localStorage.getItem('theme');
37
+ var prefersDark = window.matchMedia &&
38
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
39
+ var mode = saved || (prefersDark ? 'dark' : 'light');
40
+ document.documentElement.setAttribute('data-theme', mode);
41
+ document.documentElement.style.colorScheme = mode;
42
+ } catch (e) {
43
+ document.documentElement.setAttribute('data-theme', 'dark');
44
+ }
45
+ })();
46
+ </script>
47
  <meta name="description" content="A test article">
48
  <meta name="author" content="Alice">
49
 
frontend/index.html CHANGED
@@ -3,6 +3,16 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
 
 
 
 
 
 
 
 
 
6
  <title>Research Article Template Editor</title>
7
  <script>
8
  (function() {
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <!--
7
+ Opt out of browser in-page translation. The editor chrome is
8
+ English, and translating it mid-demo would rewrite slash-menu
9
+ labels, toolbar tooltips, and the document body - which would
10
+ corrupt the Yjs state. These metas silence the "Translate to X?"
11
+ prompt on non-English OS locales (Chrome reads the `google` tag,
12
+ other engines read `robots`).
13
+ -->
14
+ <meta name="google" content="notranslate" />
15
+ <meta name="robots" content="notranslate" />
16
  <title>Research Article Template Editor</title>
17
  <script>
18
  (function() {
frontend/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useRef, useState, useCallback, useEffect } from "react";
2
  import { Editor as TiptapEditor } from "@tiptap/core";
3
  import { UndoManager } from "yjs";
4
  import type * as Y from "yjs";
@@ -19,6 +19,7 @@ import { usePublishStatus } from "./hooks/usePublishStatus";
19
  import type { CommentStore } from "./editor/comments";
20
  import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
21
  import type { EmbedStore } from "./editor/embeds/embed-store";
 
22
  import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
23
  import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer";
24
  import { EditorFooter } from "./editor/EditorFooter";
@@ -72,11 +73,11 @@ export default function App() {
72
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
73
  const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
74
  const [embedStore, setEmbedStore] = useState<EmbedStore | null>(null);
 
75
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
76
  const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
77
 
78
  const [isEditorReady, setIsEditorReady] = useState(false);
79
- const [hasSelection, setHasSelection] = useState(false);
80
 
81
  // --- UI state ---------------------------------------------------------
82
 
@@ -84,6 +85,12 @@ export default function App() {
84
  const [settingsOpen, setSettingsOpen] = useState(false);
85
  const [chatOpen, setChatOpen] = useState(false);
86
  const [embedStudioSrc, setEmbedStudioSrc] = useState<string | null>(null);
 
 
 
 
 
 
87
  const [tocSidebarOpen, setTocSidebarOpen] = useState(false);
88
  const [tocAutoCollapse, setTocAutoCollapse] = useState(false);
89
 
@@ -168,6 +175,13 @@ export default function App() {
168
  // Only override --primary-base: _variables.css derives --primary-color
169
  // and --primary-color-hover from it via CSS relative-color syntax.
170
  document.documentElement.style.setProperty("--primary-base", oklchFromHue(h));
 
 
 
 
 
 
 
171
  }
172
  };
173
  sync();
@@ -179,22 +193,75 @@ export default function App() {
179
  useEffect(() => {
180
  const handler = (e: Event) => {
181
  const src = (e as CustomEvent).detail?.src;
182
- if (src) setEmbedStudioSrc(src);
 
 
 
183
  };
184
  window.addEventListener("open-embed-studio", handler);
185
  return () => window.removeEventListener("open-embed-studio", handler);
186
  }, []);
187
 
188
- // Track whether the editor has a non-empty selection (for chat quick actions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  useEffect(() => {
190
  if (!editorInstance) return;
191
- const handler = () => {
 
 
 
 
 
192
  const { from, to } = editorInstance.state.selection;
193
- setHasSelection(from !== to);
 
194
  };
195
- editorInstance.on("selectionUpdate", handler);
196
- return () => { editorInstance.off("selectionUpdate", handler); };
197
- }, [editorInstance]);
 
 
 
 
198
 
199
  // --- Chat / agent -----------------------------------------------------
200
 
@@ -228,7 +295,7 @@ export default function App() {
228
  }, [chatUserId]);
229
 
230
  // Dev-only hook: let the demo recording script script-drive the main
231
- // chat panel. The Playwright trio script dispatches `__demo-chat` to:
232
  // - open the floating chat panel,
233
  // - inject a fake user prompt + assistant reply,
234
  // - optionally rewrite a paragraph in the editor body,
@@ -240,7 +307,38 @@ export default function App() {
240
  | {
241
  open?: boolean;
242
  messages?: UIMessage[];
243
- replace?: { from: string; to: string };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  }
245
  | undefined;
246
  if (typeof detail?.open === "boolean") setChatOpen(detail.open);
@@ -251,132 +349,477 @@ export default function App() {
251
  const editor = editorRef.current;
252
  const target = detail.replace.from;
253
  const replacement = detail.replace.to;
254
-
255
- // Robust target lookup: ProseMirror text can be split across
256
- // multiple text nodes within the same block (marks, Yjs splits),
257
- // so we walk textblocks and search the joined textContent rather
258
- // than individual text nodes. `textBetween` on the block gives
259
- // us a char-accurate string whose offsets map directly to PM
260
- // positions inside that block.
261
- let from = -1;
262
- let to = -1;
263
- editor.state.doc.descendants((node, pos) => {
264
- if (from !== -1) return false;
265
- if (node.isTextblock) {
266
- const text = node.textBetween(0, node.content.size, "\n", "\n");
267
- const idx = text.indexOf(target);
268
- if (idx !== -1) {
269
- from = pos + 1 + idx;
270
- to = from + target.length;
271
- return false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  }
273
  }
274
- return true;
275
- });
276
 
277
- if (from === -1 || to === -1) {
278
- console.warn(
279
- "[__demo-chat] replace target not found:",
280
- target.slice(0, 40) + (target.length > 40 ? "..." : ""),
281
- );
282
- return;
283
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
- // Resolve the paragraph DOM element once so we can both scroll
286
- // it into view AND flag it with a highlight class for the
287
- // duration of the animation (see `.demo-agent-rewriting` in
288
- // _ui.css). Without that visual cue the rewrite can be hard
289
- // to spot when the chat panel is open and the viewer's eyes
290
- // are in the chat.
291
- //
292
- // If the demo script previously marked this paragraph as
293
- // `.demo-agent-pending` (badge + breathing border) we prefer
294
- // that element: it's the exact node the viewer has been looking
295
- // at while the chat was streaming. Falling back to a PM
296
- // position lookup keeps the handler usable outside the demo.
297
- let paragraphEl: HTMLElement | null =
298
- document.querySelector<HTMLElement>(".demo-agent-pending");
299
- if (!paragraphEl) {
300
  try {
301
- const domAt = editor.view.domAtPos(from);
302
  let el: HTMLElement | null =
303
  domAt.node instanceof HTMLElement
304
  ? domAt.node
305
  : domAt.node.parentElement;
306
- while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) {
 
 
 
 
307
  el = el.parentElement;
308
  }
309
- paragraphEl = el;
 
 
310
  } catch {
311
  /* non-fatal */
312
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
- // Animated rewrite: highlight target first so the viewer sees
316
- // what the agent is about to change, then delete and retype the
317
- // replacement character by character for a visible, live feel.
318
- editor.chain().focus().setTextSelection({ from, to }).run();
319
-
320
- if (paragraphEl) {
321
- // Swap pending -> rewriting so the badge stays visible but
322
- // the border animation ramps up from "thinking" to "writing".
323
- paragraphEl.classList.remove("demo-agent-pending");
324
- paragraphEl.classList.add("demo-agent-rewriting");
325
- if (typeof paragraphEl.scrollIntoView === "function") {
326
- paragraphEl.scrollIntoView({
327
- behavior: "smooth",
328
- block: "center",
329
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  }
 
 
331
  }
 
 
 
 
 
 
 
332
 
333
- const startPos = from;
334
- const originalLength = to - from;
335
- window.setTimeout(() => {
336
- if (!editorRef.current) return;
337
- editorRef.current
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  .chain()
339
- .focus()
340
- .setTextSelection({ from: startPos, to: startPos + originalLength })
341
- .deleteSelection()
 
342
  .run();
343
- let i = 0;
344
- const total = replacement.length;
345
- const step = () => {
346
- if (!editorRef.current) return;
347
- if (i >= total) {
348
- // Animation done: remove the highlight after a brief
349
- // settle delay so the viewer can see the final result
350
- // glow once before it fades back to normal.
351
- if (paragraphEl) {
352
- window.setTimeout(
353
- () => paragraphEl?.classList.remove("demo-agent-rewriting"),
354
- 700,
355
- );
356
- }
357
- return;
358
- }
359
- const nextChunk = Math.min(
360
- total - i,
361
- 1 + Math.floor(Math.random() * 2),
362
- );
363
- const slice = replacement.slice(i, i + nextChunk);
364
- editorRef.current
365
- .chain()
366
- .focus()
367
- .insertContentAt(startPos + i, slice)
368
- .run();
369
- i += nextChunk;
370
- window.setTimeout(step, 22 + Math.random() * 26);
371
- };
372
- step();
373
- }, 520);
374
  }
375
  };
376
  window.addEventListener("__demo-chat", handler);
377
  return () => window.removeEventListener("__demo-chat", handler);
378
  }, []);
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  // --- Editor lifecycle callbacks ---------------------------------------
381
 
382
  const editorContainerCallback = useCallback((node: HTMLDivElement | null) => {
@@ -387,6 +830,14 @@ export default function App() {
387
  const onEditorReady = useCallback((editor: TiptapEditor | null) => {
388
  editorRef.current = editor;
389
  setEditorInstance(editor);
 
 
 
 
 
 
 
 
390
  }, []);
391
 
392
  const onProviderReady = useCallback((provider: HocuspocusProvider) => {
@@ -398,6 +849,10 @@ export default function App() {
398
  const onCommentStoreReady = useCallback((store: CommentStore) => setCommentStore(store), []);
399
  const onFrontmatterStoreReady = useCallback((store: FrontmatterStore) => setFrontmatterStore(store), []);
400
  const onEmbedStoreReady = useCallback((store: EmbedStore) => setEmbedStore(store), []);
 
 
 
 
401
  const onSettingsMapReady = useCallback((map: Y.Map<any>) => setSettingsMap(map), []);
402
 
403
  // --- Publish flow -----------------------------------------------------
@@ -422,6 +877,26 @@ export default function App() {
422
  };
423
  }, []);
424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  const handlePublish = useCallback(async () => {
426
  setPublishState("loading");
427
  setPublishError("");
@@ -539,7 +1014,26 @@ export default function App() {
539
  // --- Render -----------------------------------------------------------
540
 
541
  return (
542
- <div className="editor-app">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  <TopBar
544
  editorInstance={editorInstance}
545
  providerRef={providerRef}
@@ -587,6 +1081,7 @@ export default function App() {
587
  onCommentStoreReady={onCommentStoreReady}
588
  onFrontmatterStoreReady={onFrontmatterStoreReady}
589
  onEmbedStoreReady={onEmbedStoreReady}
 
590
  onSettingsMapReady={onSettingsMapReady}
591
  onEditorReady={onEditorReady}
592
  onUndoManagerReady={setUndoManager}
@@ -610,12 +1105,10 @@ export default function App() {
610
  isLoading={agentChat.isLoading}
611
  error={agentChat.error}
612
  input={agentChat.input}
613
- hasSelection={hasSelection}
614
  models={models}
615
  selectedModel={selectedModel}
616
  onModelChange={handleModelChange}
617
  onSend={agentChat.sendMessage}
618
- onQuickAction={agentChat.sendQuickAction}
619
  onSetInput={agentChat.setInput}
620
  onStop={agentChat.stop}
621
  onNewChat={() => agentChat.clearMessages()}
@@ -626,6 +1119,7 @@ export default function App() {
626
  <Tooltip title="AI Assistant" placement="right">
627
  <button
628
  className={`chat-fab ${agentChat.isLoading ? "badge-dot" : "badge-dot badge-dot--hidden"}`}
 
629
  onClick={() => setChatOpen(true)}
630
  aria-label="AI Assistant"
631
  >
@@ -649,12 +1143,17 @@ export default function App() {
649
 
650
  {embedStudioSrc && (
651
  <EmbedStudio
652
- key={embedStudioSrc}
653
  src={embedStudioSrc}
654
  embedStore={embedStore}
 
655
  modelRef={modelRef}
656
  userId={chatUserId}
657
- onClose={() => setEmbedStudioSrc(null)}
 
 
 
 
658
  />
659
  )}
660
 
 
1
+ import React, { useRef, useState, useCallback, useEffect } from "react";
2
  import { Editor as TiptapEditor } from "@tiptap/core";
3
  import { UndoManager } from "yjs";
4
  import type * as Y from "yjs";
 
19
  import type { CommentStore } from "./editor/comments";
20
  import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
21
  import type { EmbedStore } from "./editor/embeds/embed-store";
22
+ import type { EmbedDataStore } from "./editor/embeds/embed-data-store";
23
  import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
24
  import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer";
25
  import { EditorFooter } from "./editor/EditorFooter";
 
73
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
74
  const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
75
  const [embedStore, setEmbedStore] = useState<EmbedStore | null>(null);
76
+ const [embedDataStore, setEmbedDataStore] = useState<EmbedDataStore | null>(null);
77
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
78
  const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
79
 
80
  const [isEditorReady, setIsEditorReady] = useState(false);
 
81
 
82
  // --- UI state ---------------------------------------------------------
83
 
 
85
  const [settingsOpen, setSettingsOpen] = useState(false);
86
  const [chatOpen, setChatOpen] = useState(false);
87
  const [embedStudioSrc, setEmbedStudioSrc] = useState<string | null>(null);
88
+ // Stable session id for the currently-open EmbedStudio instance.
89
+ // Used as the React key so the component does NOT remount when the
90
+ // agent renames the chart file mid-session (rename flips
91
+ // `embedStudioSrc`, but the same studio instance keeps its chat
92
+ // state).
93
+ const [embedStudioSession, setEmbedStudioSession] = useState<string | null>(null);
94
  const [tocSidebarOpen, setTocSidebarOpen] = useState(false);
95
  const [tocAutoCollapse, setTocAutoCollapse] = useState(false);
96
 
 
175
  // Only override --primary-base: _variables.css derives --primary-color
176
  // and --primary-color-hover from it via CSS relative-color syntax.
177
  document.documentElement.style.setProperty("--primary-base", oklchFromHue(h));
178
+ } else {
179
+ // When the Yjs settings map has no primaryHue (fresh state or after
180
+ // reset-article), drop the inline override so the CSS default from
181
+ // _variables.css wins. Otherwise a stale hue from a previous session
182
+ // survives the reset visually, and the demo has to re-paint it at
183
+ // boot time (which looks like an unmotivated color jump).
184
+ document.documentElement.style.removeProperty("--primary-base");
185
  }
186
  };
187
  sync();
 
193
  useEffect(() => {
194
  const handler = (e: Event) => {
195
  const src = (e as CustomEvent).detail?.src;
196
+ if (src) {
197
+ setEmbedStudioSrc(src);
198
+ setEmbedStudioSession(`es-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`);
199
+ }
200
  };
201
  window.addEventListener("open-embed-studio", handler);
202
  return () => window.removeEventListener("open-embed-studio", handler);
203
  }, []);
204
 
205
+ // Rename every htmlEmbed node referencing `oldSrc` to `newSrc` in the
206
+ // ProseMirror doc, then lift the new src into state so the
207
+ // EmbedStudio props and the document stay consistent. The agent
208
+ // triggers this from createEmbed({ filename }) inside useEmbedChat.
209
+ const handleEmbedRename = useCallback(
210
+ (oldSrc: string, newSrc: string) => {
211
+ const editor = editorRef.current;
212
+ if (editor) {
213
+ const { state } = editor;
214
+ const tr = state.tr;
215
+ let changed = false;
216
+ state.doc.descendants((node, pos) => {
217
+ if (node.type.name === "htmlEmbed" && node.attrs.src === oldSrc) {
218
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc });
219
+ changed = true;
220
+ }
221
+ });
222
+ if (changed) editor.view.dispatch(tr);
223
+ }
224
+ setEmbedStudioSrc((prev) => (prev === oldSrc ? newSrc : prev));
225
+ },
226
+ [],
227
+ );
228
+
229
+ // When the user opens the chat with an active selection, broadcast it
230
+ // via Yjs awareness so every collaborator (including us) sees a
231
+ // persistent highlight "my agent is working on this range". The
232
+ // AgentFocus extension handles CRDT-safe position tracking; we just
233
+ // toggle it on/off based on chat state. Safe w.r.t. undo: the
234
+ // extension only writes to awareness, it never emits a Yjs-tracked
235
+ // transaction.
236
+ //
237
+ // We listen to `selectionUpdate` while the chat is open so that:
238
+ // - if the selection was not yet readable at chat-open time
239
+ // (focus/blur race when clicking the FAB), the first selection
240
+ // update still syncs the highlight;
241
+ // - if the user selects new text while the chat is open, the
242
+ // highlight follows the new range.
243
+ // On collapsed selection we leave the previously broadcast focus in
244
+ // place - the user may have clicked in the chat textarea without
245
+ // wanting to lose the agent's current target.
246
  useEffect(() => {
247
  if (!editorInstance) return;
248
+ if (!chatOpen) {
249
+ editorInstance.commands.clearAgentFocus();
250
+ return;
251
+ }
252
+
253
+ const syncAgentFocus = () => {
254
  const { from, to } = editorInstance.state.selection;
255
+ if (from === to) return;
256
+ editorInstance.commands.setAgentFocus({ from, to });
257
  };
258
+
259
+ syncAgentFocus();
260
+ editorInstance.on("selectionUpdate", syncAgentFocus);
261
+ return () => {
262
+ editorInstance.off("selectionUpdate", syncAgentFocus);
263
+ };
264
+ }, [chatOpen, editorInstance]);
265
 
266
  // --- Chat / agent -----------------------------------------------------
267
 
 
295
  }, [chatUserId]);
296
 
297
  // Dev-only hook: let the demo recording script script-drive the main
298
+ // chat panel. The Playwright showcase script dispatches `__demo-chat` to:
299
  // - open the floating chat panel,
300
  // - inject a fake user prompt + assistant reply,
301
  // - optionally rewrite a paragraph in the editor body,
 
307
  | {
308
  open?: boolean;
309
  messages?: UIMessage[];
310
+ replace?: {
311
+ from: string;
312
+ to: string;
313
+ /**
314
+ * Explicit PM positions to replace. When provided we use
315
+ * them as-is and skip the (fragile) string search below.
316
+ * Required for paragraphs that contain non-text leaf
317
+ * nodes (inline math, mentions, images...) because their
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
324
+ * explicit PM range. Used by the demo to showcase the
325
+ * agent calling a simple "make this bold" tool - the same
326
+ * path a real agent's `toggleMark` tool call would take.
327
+ */
328
+ format?: {
329
+ mark: "bold" | "italic" | "strike" | "code";
330
+ range: { from: number; to: number };
331
+ };
332
+ /**
333
+ * Wrap a PM range in a Link mark. Same pipeline as `format`
334
+ * (explicit PM range, go through Tiptap's chain) so the
335
+ * new mark lands collaboratively via Yjs and participates
336
+ * in the normal undo stack.
337
+ */
338
+ link?: {
339
+ url: string;
340
+ range: { from: number; to: number };
341
+ };
342
  }
343
  | undefined;
344
  if (typeof detail?.open === "boolean") setChatOpen(detail.open);
 
349
  const editor = editorRef.current;
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,
356
+ // then setTimeout(520) into `agentRewriteRange`. Problem: the
357
+ // assistant reply was still streaming AND remote peers kept
358
+ // typing during those 520ms. Any insert before the cached
359
+ // range would silently shift Alice's target by N chars, and
360
+ // the typewriter would then eat Bob's sentence or the wrong
361
+ // half of Alice's own paragraph. Running the lookup inside
362
+ // the setTimeout reads the LIVE doc at the actual kickoff
363
+ // moment, so concurrent edits can't invalidate it.
364
+ window.setTimeout(() => {
365
+ const ed = editorRef.current;
366
+ if (!ed) return;
367
+
368
+ let from = -1;
369
+ let to = -1;
370
+
371
+ // Preferred path: explicit PM range from the caller. Must
372
+ // still be clamped against the LIVE doc size in case remote
373
+ // edits shrank the doc since the event fired.
374
+ if (explicitRange) {
375
+ const size = ed.state.doc.content.size;
376
+ const a = Math.max(0, Math.min(explicitRange.from, size));
377
+ const b = Math.max(0, Math.min(explicitRange.to, size));
378
+ if (b > a) {
379
+ from = a;
380
+ to = b;
381
+ } else {
382
+ console.error(
383
+ "[__demo-chat] replace.range is empty or inverted:",
384
+ explicitRange,
385
+ );
386
  }
387
  }
 
 
388
 
389
+ // Fallback: walk the LIVE doc and find the first textblock
390
+ // whose content contains `target`. Because this runs at
391
+ // dispatch time (not at event-receipt time) no remote edit
392
+ // can race the result.
393
+ if (from === -1) {
394
+ ed.state.doc.descendants((node, pos) => {
395
+ if (from !== -1) return false;
396
+ if (node.isTextblock) {
397
+ const text = node.textBetween(
398
+ 0,
399
+ node.content.size,
400
+ "\n",
401
+ "\n",
402
+ );
403
+ const idx = text.indexOf(target);
404
+ if (idx !== -1) {
405
+ from = pos + 1 + idx;
406
+ to = from + target.length;
407
+ return false;
408
+ }
409
+ }
410
+ return true;
411
+ });
412
+ }
413
+
414
+ if (from === -1 || to === -1) {
415
+ console.error(
416
+ "[__demo-chat] replace target not found:",
417
+ target.slice(0, 60) + (target.length > 60 ? "..." : ""),
418
+ "- pass `replace.range` to target paragraphs with inline atoms",
419
+ );
420
+ return;
421
+ }
422
 
423
+ // Opportunistic scroll: AgentRewrite maps its own cursor
424
+ // through every subsequent tr, so this DOM lookup only
425
+ // needs to be approximately right.
 
 
 
 
 
 
 
 
 
 
 
 
426
  try {
427
+ const domAt = ed.view.domAtPos(from);
428
  let el: HTMLElement | null =
429
  domAt.node instanceof HTMLElement
430
  ? domAt.node
431
  : domAt.node.parentElement;
432
+ while (
433
+ el &&
434
+ el.nodeName !== "P" &&
435
+ !/^H[1-6]$/.test(el.nodeName)
436
+ ) {
437
  el = el.parentElement;
438
  }
439
+ if (el && typeof el.scrollIntoView === "function") {
440
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
441
+ }
442
  } catch {
443
  /* non-fatal */
444
  }
445
+
446
+ ed.commands.agentRewriteRange({
447
+ from,
448
+ to,
449
+ text: replacement,
450
+ animation: "typewriter",
451
+ });
452
+ }, 520);
453
+ }
454
+
455
+ // ---- Format action: bold / italic / strike / code ----------------
456
+ // The agent's simplest possible tool call: "apply mark M to
457
+ // range [from, to]". We scroll the target into view and route
458
+ // through Tiptap's own `setMark` commands so the operation goes
459
+ // through the real editor pipeline (Yjs syncs, undo step, mark
460
+ // extension hooks) - same path a production agent tool would
461
+ // take. No custom animation: the viewer sees the text turn
462
+ // bold in a single frame, which reads as an instant agent edit.
463
+ if (detail?.format && editorRef.current) {
464
+ const editor = editorRef.current;
465
+ const size = editor.state.doc.content.size;
466
+ const from = Math.max(0, Math.min(detail.format.range.from, size));
467
+ const to = Math.max(0, Math.min(detail.format.range.to, size));
468
+ if (to <= from) {
469
+ console.error(
470
+ "[__demo-chat] format range empty or inverted:",
471
+ detail.format.range,
472
+ );
473
+ return;
474
+ }
475
+ try {
476
+ const domAt = editor.view.domAtPos(from);
477
+ let el: HTMLElement | null =
478
+ domAt.node instanceof HTMLElement
479
+ ? domAt.node
480
+ : domAt.node.parentElement;
481
+ while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) {
482
+ el = el.parentElement;
483
+ }
484
+ if (el && typeof el.scrollIntoView === "function") {
485
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
486
+ }
487
+ } catch {
488
+ /* non-fatal */
489
  }
490
+ const chain = editor
491
+ .chain()
492
+ .focus()
493
+ .setTextSelection({ from, to });
494
+ switch (detail.format.mark) {
495
+ case "bold":
496
+ chain.setBold().run();
497
+ break;
498
+ case "italic":
499
+ chain.setItalic().run();
500
+ break;
501
+ case "strike":
502
+ chain.setStrike().run();
503
+ break;
504
+ case "code":
505
+ chain.setCode().run();
506
+ break;
507
+ default:
508
+ console.error(
509
+ "[__demo-chat] unknown format mark:",
510
+ detail.format.mark,
511
+ );
512
+ }
513
+ }
514
 
515
+ // ---- Link action: wrap a PM range in a Link mark ------------------
516
+ // Goes through Tiptap's `setLink` command (same path the bubble
517
+ // toolbar uses) so the mark syncs over Yjs, respects safe-URL
518
+ // validation, and lands in the undo stack. Scrolls the target
519
+ // into view before applying so the viewer sees the link underline
520
+ // appear on the actual phrase.
521
+ //
522
+ // Drift-proof resolution
523
+ // ----------------------
524
+ // The caller MAY pass an explicit PM `range`, but doc positions
525
+ // are fragile under concurrent edits (another peer inserting
526
+ // content earlier in the doc will shift every subsequent
527
+ // position). So we prefer the substring-based path when the
528
+ // caller provides `paragraphSnippet` + `substring`: walk the
529
+ // LIVE doc at dispatch time, find the matching text node, and
530
+ // resolve fresh PM positions. The explicit range only wins if
531
+ // the text at [from..to] still matches `substring`. Otherwise
532
+ // we fall back to the search, which is always correct as long
533
+ // as the target text hasn't been edited away.
534
+ const detailLink = (detail as unknown as {
535
+ link?: {
536
+ url: string;
537
+ range?: { from: number; to: number };
538
+ paragraphSnippet?: string;
539
+ substring?: string;
540
+ };
541
+ }).link;
542
+ if (detailLink && editorRef.current) {
543
+ const editor = editorRef.current;
544
+ const size = editor.state.doc.content.size;
545
+ const url = (detailLink.url || "").trim();
546
+ if (!url) {
547
+ console.error("[__demo-chat] link URL missing:", detailLink);
548
+ return;
549
+ }
550
+
551
+ const resolveBySubstring = (): { from: number; to: number } | null => {
552
+ if (!detailLink.paragraphSnippet || !detailLink.substring) return null;
553
+ let found: { from: number; to: number } | null = null;
554
+ editor.state.doc.descendants((node, pos) => {
555
+ if (found) return false;
556
+ if (!node.isTextblock) return true;
557
+ const text = node.textBetween(0, node.content.size, "\n", "\n");
558
+ if (!text.includes(detailLink.paragraphSnippet!)) return true;
559
+ const idx = text.indexOf(detailLink.substring!);
560
+ if (idx === -1) return false;
561
+ found = {
562
+ from: pos + 1 + idx,
563
+ to: pos + 1 + idx + detailLink.substring!.length,
564
+ };
565
+ return false;
566
+ });
567
+ return found;
568
+ };
569
+
570
+ let from = -1;
571
+ let to = -1;
572
+ if (detailLink.range) {
573
+ const tentativeFrom = Math.max(0, Math.min(detailLink.range.from, size));
574
+ const tentativeTo = Math.max(0, Math.min(detailLink.range.to, size));
575
+ if (tentativeTo > tentativeFrom) {
576
+ const textAtRange = editor.state.doc.textBetween(
577
+ tentativeFrom,
578
+ tentativeTo,
579
+ );
580
+ const rangeStillMatches =
581
+ !detailLink.substring || textAtRange === detailLink.substring;
582
+ if (rangeStillMatches) {
583
+ from = tentativeFrom;
584
+ to = tentativeTo;
585
+ }
586
+ }
587
+ }
588
+ if (from === -1) {
589
+ const resolved = resolveBySubstring();
590
+ if (resolved) {
591
+ from = resolved.from;
592
+ to = resolved.to;
593
+ }
594
+ }
595
+ if (from === -1 || to <= from) {
596
+ console.error(
597
+ "[__demo-chat] link target not resolvable:",
598
+ detailLink,
599
+ );
600
+ return;
601
+ }
602
+ try {
603
+ const domAt = editor.view.domAtPos(from);
604
+ let el: HTMLElement | null =
605
+ domAt.node instanceof HTMLElement
606
+ ? domAt.node
607
+ : domAt.node.parentElement;
608
+ while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) {
609
+ el = el.parentElement;
610
+ }
611
+ if (el && typeof el.scrollIntoView === "function") {
612
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
613
  }
614
+ } catch {
615
+ /* non-fatal */
616
  }
617
+ editor
618
+ .chain()
619
+ .focus()
620
+ .setTextSelection({ from, to })
621
+ .setLink({ href: url })
622
+ .run();
623
+ }
624
 
625
+ // ---- Citation action: seed an entry in the shared citations map
626
+ // then insert an inline citation node at a PM position, and make
627
+ // sure a <bibliography> block exists at the end of the doc.
628
+ //
629
+ // Same pipeline as the real CitationPanel (see CitationPanel.tsx):
630
+ // write a CSL-JSON entry into `citationsMap` with a key, dispatch
631
+ // `insertCitation(key)` on the editor, then append a bibliography
632
+ // section if the doc doesn't have one yet. This exercises the
633
+ // production code path end to end - chip auto-labelling included.
634
+ const detailCitation = (detail as unknown as {
635
+ citation?: {
636
+ key: string;
637
+ entry: unknown;
638
+ at?: number;
639
+ paragraphSnippet?: string;
640
+ anchor?: "end" | "start";
641
+ };
642
+ }).citation;
643
+ if (detailCitation && editorRef.current) {
644
+ const editor = editorRef.current;
645
+ const size = editor.state.doc.content.size;
646
+
647
+ // Drift-proof anchor resolution: if `paragraphSnippet` is
648
+ // provided, walk the LIVE doc for the matching paragraph and
649
+ // return its start or end position. Otherwise fall back to the
650
+ // explicit `at` (which can be stale under concurrent upstream
651
+ // edits). Same philosophy as the link resolver above.
652
+ const resolveByParagraph = (): number | null => {
653
+ if (!detailCitation.paragraphSnippet) return null;
654
+ const anchor = detailCitation.anchor ?? "end";
655
+ let found: number | null = null;
656
+ editor.state.doc.descendants((node, pos) => {
657
+ if (found !== null) return false;
658
+ if (!node.isTextblock) return true;
659
+ const text = node.textBetween(0, node.content.size, "\n", "\n");
660
+ if (!text.includes(detailCitation.paragraphSnippet!)) return true;
661
+ found = anchor === "end" ? pos + node.nodeSize - 1 : pos + 1;
662
+ return false;
663
+ });
664
+ return found;
665
+ };
666
+
667
+ let at = -1;
668
+ const resolved = resolveByParagraph();
669
+ if (resolved !== null) {
670
+ at = resolved;
671
+ } else if (typeof detailCitation.at === "number") {
672
+ at = Math.max(0, Math.min(detailCitation.at, size));
673
+ }
674
+ if (at < 0) {
675
+ console.error(
676
+ "[__demo-chat] citation target not resolvable:",
677
+ detailCitation,
678
+ );
679
+ return;
680
+ }
681
+ const citationsMap = (editor.storage as unknown as Record<string, unknown>)
682
+ .citation as { citationsMap?: { set: (k: string, v: unknown) => void } } | undefined;
683
+ if (!citationsMap?.citationsMap) {
684
+ console.error(
685
+ "[__demo-chat] citationsMap not attached to editor storage",
686
+ );
687
+ return;
688
+ }
689
+ citationsMap.citationsMap.set(detailCitation.key, detailCitation.entry);
690
+ try {
691
+ const domAt = editor.view.domAtPos(at);
692
+ let el: HTMLElement | null =
693
+ domAt.node instanceof HTMLElement
694
+ ? domAt.node
695
+ : domAt.node.parentElement;
696
+ while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) {
697
+ el = el.parentElement;
698
+ }
699
+ if (el && typeof el.scrollIntoView === "function") {
700
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
701
+ }
702
+ } catch {
703
+ /* non-fatal */
704
+ }
705
+ editor
706
+ .chain()
707
+ .focus()
708
+ .setTextSelection(at)
709
+ .insertCitation(detailCitation.key)
710
+ .run();
711
+ let hasBibliography = false;
712
+ editor.state.doc.descendants((node) => {
713
+ if (node.type.name === "bibliography") hasBibliography = true;
714
+ });
715
+ if (!hasBibliography) {
716
+ const endPos = editor.state.doc.content.size;
717
+ editor
718
  .chain()
719
+ .insertContentAt(endPos, [
720
+ { type: "paragraph" },
721
+ { type: "bibliography" },
722
+ ])
723
  .run();
724
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  }
726
  };
727
  window.addEventListener("__demo-chat", handler);
728
  return () => window.removeEventListener("__demo-chat", handler);
729
  }, []);
730
 
731
+ // Dev-only: imperative settings mutation for the demo (primary hue).
732
+ // Writing straight to the shared Yjs `settings` map gives every peer
733
+ // the updated colour via the normal observer path - exactly what
734
+ // moving the HueSlider does, minus the UI detour.
735
+ useEffect(() => {
736
+ if (!import.meta.env.DEV) return;
737
+ if (!settingsMap) return;
738
+ const handler = (event: Event) => {
739
+ const detail = (event as CustomEvent).detail as
740
+ | { hue?: number }
741
+ | undefined;
742
+ if (typeof detail?.hue === "number") {
743
+ const clamped = Math.max(0, Math.min(360, Math.round(detail.hue)));
744
+ settingsMap.set("primaryHue", clamped);
745
+ }
746
+ };
747
+ window.addEventListener("__demo-settings", handler);
748
+ return () => window.removeEventListener("__demo-settings", handler);
749
+ }, [settingsMap]);
750
+
751
+ // Dev-only: seed or clear data files in the EmbedDataStore. Lets the
752
+ // showcase demo populate the FilesSidebar with a realistic CSV before the
753
+ // agent "reads" it, without requiring a real drag-and-drop upload.
754
+ useEffect(() => {
755
+ if (!import.meta.env.DEV) return;
756
+ if (!embedDataStore) return;
757
+ const handler = (event: Event) => {
758
+ const detail = (event as CustomEvent).detail as
759
+ | {
760
+ clear?: boolean;
761
+ file?: {
762
+ name: string;
763
+ ext?: string;
764
+ content: string;
765
+ columns?: string[];
766
+ rowCount?: number;
767
+ uploader?: string;
768
+ };
769
+ }
770
+ | undefined;
771
+ if (!detail) return;
772
+ if (detail.clear) {
773
+ for (const key of embedDataStore.keys()) embedDataStore.remove(key);
774
+ return;
775
+ }
776
+ if (detail.file) {
777
+ const f = detail.file;
778
+ const ext =
779
+ (f.ext ?? f.name.split(".").pop() ?? "txt").toLowerCase();
780
+ embedDataStore.set({
781
+ meta: {
782
+ name: f.name,
783
+ ext,
784
+ size: new Blob([f.content]).size,
785
+ uploader: f.uploader ?? "demo",
786
+ addedAt: Date.now(),
787
+ rowCount: f.rowCount,
788
+ columns: f.columns,
789
+ },
790
+ content: f.content,
791
+ });
792
+ }
793
+ };
794
+ window.addEventListener("__demo-embed-data", handler);
795
+ return () => window.removeEventListener("__demo-embed-data", handler);
796
+ }, [embedDataStore]);
797
+
798
+ // Dev-only: write arbitrary embed HTML (for charts beyond the banner)
799
+ // and optionally rename an existing src so the demo can showcase the
800
+ // agent-picked filename live. Mirrors what the real createEmbed tool
801
+ // does via useEmbedChat when the agent provides a `filename`.
802
+ useEffect(() => {
803
+ if (!import.meta.env.DEV) return;
804
+ if (!embedStore) return;
805
+ const handler = (event: Event) => {
806
+ const detail = (event as CustomEvent).detail as
807
+ | { src?: string; html?: string; renameFrom?: string }
808
+ | undefined;
809
+ if (!detail?.src || typeof detail.html !== "string") return;
810
+ const { src, html, renameFrom } = detail;
811
+ if (renameFrom && renameFrom !== src) {
812
+ embedStore.set(src, html);
813
+ embedStore.remove(renameFrom);
814
+ handleEmbedRename(renameFrom, src);
815
+ } else {
816
+ embedStore.set(src, html);
817
+ }
818
+ };
819
+ window.addEventListener("__demo-embed-set", handler);
820
+ return () => window.removeEventListener("__demo-embed-set", handler);
821
+ }, [embedStore, handleEmbedRename]);
822
+
823
  // --- Editor lifecycle callbacks ---------------------------------------
824
 
825
  const editorContainerCallback = useCallback((node: HTMLDivElement | null) => {
 
830
  const onEditorReady = useCallback((editor: TiptapEditor | null) => {
831
  editorRef.current = editor;
832
  setEditorInstance(editor);
833
+ // Dev-only: expose the Tiptap editor for the demo script so it can
834
+ // drive PM selection natively (which the collaboration-cursor
835
+ // extension then broadcasts via Yjs awareness so Bob and Carol see
836
+ // Alice's selected range in her persona color).
837
+ if (import.meta.env.DEV) {
838
+ (window as unknown as { __demoEditor?: TiptapEditor | null }).__demoEditor =
839
+ editor;
840
+ }
841
  }, []);
842
 
843
  const onProviderReady = useCallback((provider: HocuspocusProvider) => {
 
849
  const onCommentStoreReady = useCallback((store: CommentStore) => setCommentStore(store), []);
850
  const onFrontmatterStoreReady = useCallback((store: FrontmatterStore) => setFrontmatterStore(store), []);
851
  const onEmbedStoreReady = useCallback((store: EmbedStore) => setEmbedStore(store), []);
852
+ const onEmbedDataStoreReady = useCallback(
853
+ (store: EmbedDataStore) => setEmbedDataStore(store),
854
+ [],
855
+ );
856
  const onSettingsMapReady = useCallback((map: Y.Map<any>) => setSettingsMap(map), []);
857
 
858
  // --- Publish flow -----------------------------------------------------
 
877
  };
878
  }, []);
879
 
880
+ // Dev-only: demo hook that opens the Publish dialog without hitting
881
+ // the real server. The viewer sees the modal slide in at the very
882
+ // end of the scenario so the final frame is "hero agent ready to
883
+ // ship the article" - without actually kicking off a publish job.
884
+ useEffect(() => {
885
+ if (!import.meta.env.DEV) return;
886
+ const handler = (event: Event) => {
887
+ const detail = (event as CustomEvent).detail as
888
+ | { open?: boolean }
889
+ | undefined;
890
+ if (detail?.open) {
891
+ openPublishDialog();
892
+ } else if (detail?.open === false) {
893
+ publishDialogRef.current?.close();
894
+ }
895
+ };
896
+ window.addEventListener("__demo-publish", handler);
897
+ return () => window.removeEventListener("__demo-publish", handler);
898
+ }, [openPublishDialog]);
899
+
900
  const handlePublish = useCallback(async () => {
901
  setPublishState("loading");
902
  setPublishError("");
 
1014
  // --- Render -----------------------------------------------------------
1015
 
1016
  return (
1017
+ <div
1018
+ className={`editor-app${chatOpen ? " editor-app--chat-open" : ""}`}
1019
+ style={
1020
+ {
1021
+ // Expose the current user's color (with ~44% alpha) as a CSS
1022
+ // variable so the global `::selection` rule can recolor every
1023
+ // native selection in the app with the user's identity color.
1024
+ // Using the native selection keeps the full line-height of the
1025
+ // selected text (no more "shorter than native" custom tint).
1026
+ //
1027
+ // When `editor-app--chat-open` is active the native selection
1028
+ // is hidden *inside the editor only* (see `_ui.css`) so the
1029
+ // AgentFocus PM decoration can take over as the single source
1030
+ // of truth for the selected range - this avoids stacking two
1031
+ // tints when the editor still has DOM focus after opening the
1032
+ // chat panel.
1033
+ "--local-selection-bg": `${user.color}70`,
1034
+ } as React.CSSProperties
1035
+ }
1036
+ >
1037
  <TopBar
1038
  editorInstance={editorInstance}
1039
  providerRef={providerRef}
 
1081
  onCommentStoreReady={onCommentStoreReady}
1082
  onFrontmatterStoreReady={onFrontmatterStoreReady}
1083
  onEmbedStoreReady={onEmbedStoreReady}
1084
+ onEmbedDataStoreReady={onEmbedDataStoreReady}
1085
  onSettingsMapReady={onSettingsMapReady}
1086
  onEditorReady={onEditorReady}
1087
  onUndoManagerReady={setUndoManager}
 
1105
  isLoading={agentChat.isLoading}
1106
  error={agentChat.error}
1107
  input={agentChat.input}
 
1108
  models={models}
1109
  selectedModel={selectedModel}
1110
  onModelChange={handleModelChange}
1111
  onSend={agentChat.sendMessage}
 
1112
  onSetInput={agentChat.setInput}
1113
  onStop={agentChat.stop}
1114
  onNewChat={() => agentChat.clearMessages()}
 
1119
  <Tooltip title="AI Assistant" placement="right">
1120
  <button
1121
  className={`chat-fab ${agentChat.isLoading ? "badge-dot" : "badge-dot badge-dot--hidden"}`}
1122
+ onMouseDown={(e) => e.preventDefault()}
1123
  onClick={() => setChatOpen(true)}
1124
  aria-label="AI Assistant"
1125
  >
 
1143
 
1144
  {embedStudioSrc && (
1145
  <EmbedStudio
1146
+ key={embedStudioSession ?? embedStudioSrc}
1147
  src={embedStudioSrc}
1148
  embedStore={embedStore}
1149
+ dataStore={embedDataStore}
1150
  modelRef={modelRef}
1151
  userId={chatUserId}
1152
+ onClose={() => {
1153
+ setEmbedStudioSrc(null);
1154
+ setEmbedStudioSession(null);
1155
+ }}
1156
+ onRename={handleEmbedRename}
1157
  />
1158
  )}
1159
 
frontend/src/editor/extensions/collaboration-cursor-v3.ts CHANGED
@@ -79,17 +79,35 @@ export const CollaborationCursorV3 = Extension.create<CollaborationCursorOptions
79
  },
80
 
81
  addProseMirrorPlugins() {
82
- this.options.provider.awareness.setLocalStateField("user", this.options.user);
83
- this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states);
84
- this.options.provider.awareness.on("update", () => {
85
- this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states);
 
86
  });
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  return [
89
  yCursorPlugin(
90
- this.options.provider.awareness,
91
  {
92
- cursorBuilder: this.options.render ?? defaultCursorBuilder,
93
  selectionBuilder: this.options.selectionRender ?? defaultSelectionBuilder,
94
  },
95
  ),
 
79
  },
80
 
81
  addProseMirrorPlugins() {
82
+ const awareness = this.options.provider.awareness;
83
+ awareness.setLocalStateField("user", this.options.user);
84
+ this.storage.users = awarenessStatesToArray(awareness.states);
85
+ awareness.on("update", () => {
86
+ this.storage.users = awarenessStatesToArray(awareness.states);
87
  });
88
 
89
+ // Wrap the cursor builder so we can silence a peer's caret+label when
90
+ // that same peer already broadcasts an `agentFocus` in awareness. The
91
+ // AgentFocus extension renders its own "<Name> agent" label on the
92
+ // range the AI is working on, and without this guard we end up with
93
+ // two overlapping badges for the same user (e.g. "Bob Mercier" caret
94
+ // label + "Bob Mercier agent" focus label). y-tiptap re-invokes the
95
+ // builder on every awareness change, so this check stays live.
96
+ const userBuilder = this.options.render ?? defaultCursorBuilder;
97
+ const cursorBuilder = (user: CursorUser, clientId: number): HTMLElement => {
98
+ const el = userBuilder(user);
99
+ const peerState = awareness.getStates().get(clientId);
100
+ if (peerState?.agentFocus) {
101
+ el.classList.add("collaboration-cursor--silenced");
102
+ }
103
+ return el;
104
+ };
105
+
106
  return [
107
  yCursorPlugin(
108
+ awareness,
109
  {
110
+ cursorBuilder,
111
  selectionBuilder: this.options.selectionRender ?? defaultSelectionBuilder,
112
  },
113
  ),
frontend/src/styles/_ui.css CHANGED
@@ -146,6 +146,14 @@
146
  width: 36px;
147
  height: 36px;
148
  border-width: 3px;
 
 
 
 
 
 
 
 
149
  }
150
  @keyframes ed-spin { to { transform: rotate(360deg); } }
151
 
@@ -571,6 +579,77 @@ textarea.form-input { resize: vertical; min-height: 60px; }
571
  gap: 4px;
572
  }
573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  /* ---- Chat panel (floating) ---- */
575
  .chat-floating {
576
  position: fixed;
@@ -621,7 +700,7 @@ dialog.ed-dialog.ed-dialog--author { max-width: 480px; }
621
  Two cooperating states visualize the AI agent's work on a selected
622
  paragraph during the recorded demo:
623
 
624
- 1. `.demo-agent-pending` - added by the demo script (trio.ts) the
625
  moment the user selects a paragraph and asks the chat to rewrite
626
  it. Stays until the actual rewrite animation begins. Shows a
627
  "Agent is working..." badge so the viewer understands that
@@ -698,6 +777,104 @@ dialog.ed-dialog.ed-dialog--author { max-width: 480px; }
698
  animation: demo-agent-pulse 1.2s ease-out 1;
699
  }
700
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
  /* ---- Settings drawer ---- */
702
  .settings-drawer { padding: 20px; display: flex; flex-direction: column; height: 100%; }
703
  .settings-drawer__header {
@@ -801,14 +978,6 @@ dialog.ed-dialog.ed-dialog--author { max-width: 480px; }
801
  font-size: 0.75rem;
802
  color: var(--ed-error);
803
  }
804
- .chat-panel__actions {
805
- display: flex;
806
- flex-wrap: wrap;
807
- gap: 4px;
808
- padding: 6px 12px;
809
- border-top: 1px solid var(--ed-border);
810
- flex-shrink: 0;
811
- }
812
  .chat-panel__input {
813
  padding: 8px 12px;
814
  border-top: 1px solid var(--ed-border);
 
146
  width: 36px;
147
  height: 36px;
148
  border-width: 3px;
149
+ /* The large spinner is only used in the boot overlay, before Yjs has
150
+ synced the article's primaryHue. Using --primary-color here would
151
+ paint the spinner in the default warm-yellow palette for a beat,
152
+ then snap to the article's overridden hue once the settings map
153
+ arrives - a jarring color jump. Neutralising the large spinner
154
+ sidesteps that transition entirely; the "branded" colour only
155
+ kicks in for post-boot spinners (sync indicator, publish, etc.). */
156
+ border-top-color: var(--muted-color);
157
  }
158
  @keyframes ed-spin { to { transform: rotate(360deg); } }
159
 
 
579
  gap: 4px;
580
  }
581
 
582
+ .top-bar__menu {
583
+ position: relative;
584
+ display: inline-flex;
585
+ }
586
+
587
+ .top-bar__menu-pop {
588
+ position: absolute;
589
+ top: calc(100% + 6px);
590
+ right: 0;
591
+ min-width: 260px;
592
+ background: var(--bg-surface, var(--ed-surface, #fff));
593
+ border: 1px solid var(--border-color, var(--ed-border, #ddd));
594
+ border-radius: 10px;
595
+ padding: 0.35rem;
596
+ box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.18));
597
+ z-index: 50;
598
+ }
599
+
600
+ .top-bar__menu-item {
601
+ display: flex;
602
+ align-items: flex-start;
603
+ gap: 0.6rem;
604
+ width: 100%;
605
+ padding: 0.45rem 0.6rem;
606
+ border-radius: 6px;
607
+ border: none;
608
+ background: none;
609
+ cursor: pointer;
610
+ color: var(--text-color, inherit);
611
+ font: inherit;
612
+ text-align: left;
613
+ }
614
+
615
+ .top-bar__menu-item:hover,
616
+ .top-bar__menu-item:focus-visible {
617
+ background: var(--code-bg, rgba(0, 0, 0, 0.05));
618
+ }
619
+
620
+ .top-bar__menu-item svg {
621
+ margin-top: 2px;
622
+ color: var(--muted-color, #888);
623
+ flex-shrink: 0;
624
+ }
625
+
626
+ .top-bar__menu-item-content {
627
+ display: flex;
628
+ flex-direction: column;
629
+ min-width: 0;
630
+ }
631
+
632
+ .top-bar__menu-item-title {
633
+ font-size: 0.85rem;
634
+ font-weight: 500;
635
+ color: var(--text-color);
636
+ }
637
+
638
+ .top-bar__menu-item-desc {
639
+ font-size: 0.72rem;
640
+ color: var(--muted-color);
641
+ }
642
+
643
+ .top-bar__menu-item--danger:hover,
644
+ .top-bar__menu-item--danger:focus-visible {
645
+ background: rgba(220, 53, 69, 0.1);
646
+ }
647
+
648
+ .top-bar__menu-item--danger svg,
649
+ .top-bar__menu-item--danger .top-bar__menu-item-title {
650
+ color: #dc3545;
651
+ }
652
+
653
  /* ---- Chat panel (floating) ---- */
654
  .chat-floating {
655
  position: fixed;
 
700
  Two cooperating states visualize the AI agent's work on a selected
701
  paragraph during the recorded demo:
702
 
703
+ 1. `.demo-agent-pending` - added by the demo script (showcase.ts) the
704
  moment the user selects a paragraph and asks the chat to rewrite
705
  it. Stays until the actual rewrite animation begins. Shows a
706
  "Agent is working..." badge so the viewer understands that
 
777
  animation: demo-agent-pulse 1.2s ease-out 1;
778
  }
779
 
780
+ /* ---- Native selection recolored per user ----------------------------
781
+ Every native text selection anywhere in the app is painted with the
782
+ current user's identity color (same hue as their cursor, agent focus
783
+ and avatar ring). The color is fed via `--local-selection-bg`, set
784
+ on `.editor-app` from `user.color` + `70` alpha. Using the native
785
+ `::selection` pseudo-element (instead of a PM decoration) guarantees
786
+ the highlight matches the full line-height of the text - exactly
787
+ like a default browser selection, just in the user's color. */
788
+ .editor-app ::selection,
789
+ .editor-app::selection,
790
+ .editor-app *::selection {
791
+ background-color: var(--local-selection-bg, #b4d5fe);
792
+ color: inherit;
793
+ }
794
+ .editor-app ::-moz-selection,
795
+ .editor-app::-moz-selection,
796
+ .editor-app *::-moz-selection {
797
+ background-color: var(--local-selection-bg, #b4d5fe);
798
+ color: inherit;
799
+ }
800
+
801
+ /* While the chat is open the AgentFocus PM decoration owns the
802
+ visual for the selected range (it's broadcast via awareness to the
803
+ other peers, and it keeps the range visible even when the editor
804
+ loses DOM focus to the chat textarea). We hide the native
805
+ `::selection` *inside the editor only* to avoid stacking two tints
806
+ when the editor still has focus. Elsewhere (sidebars, dialogs, chat)
807
+ the recolored native selection keeps working normally. */
808
+ .editor-app--chat-open .ProseMirror ::selection,
809
+ .editor-app--chat-open .ProseMirror::selection,
810
+ .editor-app--chat-open .ProseMirror *::selection {
811
+ background: transparent;
812
+ color: inherit;
813
+ }
814
+ .editor-app--chat-open .ProseMirror ::-moz-selection,
815
+ .editor-app--chat-open .ProseMirror::-moz-selection,
816
+ .editor-app--chat-open .ProseMirror *::-moz-selection {
817
+ background: transparent;
818
+ color: inherit;
819
+ }
820
+
821
+ /* ---- Agent focus (shared across collaborators) ----------------------
822
+ Inline decoration injected by the AgentFocus extension when a user
823
+ opens the AI chat with an active selection. The range is broadcast
824
+ via Yjs awareness, and every peer - including self - renders the
825
+ owner's highlight tinted with their color (inline style
826
+ `background-color: ${color}70`). This is what keeps the range
827
+ visible on the owner's screen after the editor loses DOM focus to
828
+ the chat textarea. */
829
+ .agent-focus {
830
+ transition: background-color 150ms ease-out;
831
+ border-radius: 2px;
832
+ }
833
+
834
+ /* Floating label anchored at the start of the range, mirroring the
835
+ collaboration cursor label pattern. The anchor is a zero-width
836
+ inline span that provides a positioning context for the label. */
837
+ /* Zero-impact inline anchor, positioned relative so the absolute label
838
+ hangs from it. Using plain `display: inline` (not `inline-block`)
839
+ keeps the anchor on the same baseline as the surrounding text so
840
+ `top: -1.4em` on the label lands where a cursor label would. */
841
+ .agent-focus__anchor {
842
+ position: relative;
843
+ display: inline;
844
+ pointer-events: none;
845
+ }
846
+ /* Match `.collaboration-cursor__label` so an agent badge reads the
847
+ same as a real collaborator's cursor label - only the "'s agent"
848
+ suffix tells them apart. */
849
+ .agent-focus__label {
850
+ position: absolute;
851
+ top: -1.4em;
852
+ left: -1px;
853
+ display: inline-flex;
854
+ align-items: center;
855
+ gap: 3px;
856
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
857
+ font-size: 0.65rem;
858
+ font-weight: 600;
859
+ padding: 0.1rem 0.35rem;
860
+ border-radius: 3px 3px 3px 0;
861
+ white-space: nowrap;
862
+ color: #000;
863
+ user-select: none;
864
+ pointer-events: none;
865
+ z-index: 2;
866
+ }
867
+ .agent-focus__avatar {
868
+ width: 14px;
869
+ height: 14px;
870
+ border-radius: 50%;
871
+ object-fit: cover;
872
+ flex-shrink: 0;
873
+ }
874
+ .agent-focus__text {
875
+ line-height: 1;
876
+ }
877
+
878
  /* ---- Settings drawer ---- */
879
  .settings-drawer { padding: 20px; display: flex; flex-direction: column; height: 100%; }
880
  .settings-drawer__header {
 
978
  font-size: 0.75rem;
979
  color: var(--ed-error);
980
  }
 
 
 
 
 
 
 
 
981
  .chat-panel__input {
982
  padding: 8px 12px;
983
  border-top: 1px solid var(--ed-border);
frontend/src/styles/editor/_chrome.css CHANGED
@@ -105,6 +105,13 @@
105
  flex-shrink: 0;
106
  }
107
 
 
 
 
 
 
 
 
108
  /* ---- Placeholder ---- */
109
 
110
  .tiptap p.is-editor-empty:first-child::before {
 
105
  flex-shrink: 0;
106
  }
107
 
108
+ /* When a peer also broadcasts an `agentFocus`, the AgentFocus decoration
109
+ already renders a "<Name> agent" label on the working range. We hide
110
+ the plain caret+label here so the same user isn't shown twice. */
111
+ .collaboration-cursor--silenced {
112
+ display: none !important;
113
+ }
114
+
115
  /* ---- Placeholder ---- */
116
 
117
  .tiptap p.is-editor-empty:first-child::before {