tfrere HF Staff commited on
Commit
f5f4567
·
1 Parent(s): 6a34a48

feat(demo): add trio Playwright orchestration + seed embeds for the pitch video

Browse files

- backend/demo/trio.ts drives 3 real Chromium sessions (Alice/Bob/Carol) collaborating on a
fresh document in under 30s for the demo reel, with human-like typing and kill-leftovers
hygiene on every run
- Centralize the hand-crafted banner HTML and demo-specific embeds (confusion matrix, memory
bar) so the script stays deterministic for recordings
- Expose a `load-demo` entrypoint used by the dev-only script to hydrate the editor in one shot

Made-with: Cursor

backend/demo/banners.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pre-built banner HTML snippets used by the demo personas.
3
+ *
4
+ * They follow the same conventions as a real HtmlEmbed in the editor:
5
+ * - Single root element with a stable class
6
+ * - Scoped <style> using CSS variables for theming
7
+ * - IIFE <script> with a `data-mounted` guard
8
+ * - No globals, no external dependencies
9
+ *
10
+ * The Editor exposes a dev-only `__demo-set-banner` window event that writes
11
+ * the provided HTML straight into `embedStore` under the `banner.html` key,
12
+ * which is exactly what the "Create chart" button + Embed Studio pipeline
13
+ * would do once the agent finished generating. We use it from the demo
14
+ * script to make the recording deterministic and visually polished.
15
+ */
16
+
17
+ export const NEURAL_NETWORK_BANNER_HTML = `<div class="d3-banner-nn" aria-label="Neural network banner"></div>
18
+ <style>
19
+ .d3-banner-nn {
20
+ width: 100%;
21
+ height: 100%;
22
+ overflow: hidden;
23
+ position: relative;
24
+ /* Transparent on purpose: embeds (banner included) inherit the page
25
+ background, per the "no background by default" convention. */
26
+ background: transparent;
27
+ }
28
+ .d3-banner-nn svg {
29
+ width: 100%;
30
+ height: 100%;
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 {
39
+ fill: var(--primary-color, #4f46e5);
40
+ opacity: 0.85;
41
+ }
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);
49
+ }
50
+ </style>
51
+ <script>
52
+ (() => {
53
+ const bootstrap = () => {
54
+ const all = Array.from(document.querySelectorAll('.d3-banner-nn'));
55
+ const container = all.find((el) => el.dataset.mounted !== 'true');
56
+ if (!container) return;
57
+ container.dataset.mounted = 'true';
58
+
59
+ const NS = 'http://www.w3.org/2000/svg';
60
+ const W = 980;
61
+ const H = 392;
62
+
63
+ const svg = document.createElementNS(NS, 'svg');
64
+ svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
65
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
66
+ container.appendChild(svg);
67
+
68
+ // Layer sizes: input -> 3 hidden layers -> output.
69
+ const layers = [6, 10, 14, 10, 6];
70
+ const padX = 110;
71
+ const padY = 70;
72
+
73
+ const xs = layers.map((_, i) =>
74
+ padX + (i * (W - padX * 2)) / (layers.length - 1)
75
+ );
76
+
77
+ const nodes = [];
78
+ layers.forEach((count, li) => {
79
+ const usable = H - padY * 2;
80
+ const step = count > 1 ? usable / (count - 1) : 0;
81
+ for (let i = 0; i < count; i++) {
82
+ nodes.push({
83
+ layer: li,
84
+ idx: i,
85
+ x: xs[li],
86
+ y: padY + (count > 1 ? i * step : usable / 2),
87
+ });
88
+ }
89
+ });
90
+
91
+ const links = [];
92
+ for (let li = 0; li < layers.length - 1; li++) {
93
+ const a = nodes.filter((n) => n.layer === li);
94
+ const b = nodes.filter((n) => n.layer === li + 1);
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);
109
+
110
+ const gNodes = document.createElementNS(NS, 'g');
111
+ nodes.forEach((n) => {
112
+ const ring = document.createElementNS(NS, 'circle');
113
+ ring.setAttribute('class', 'nn-node-ring');
114
+ ring.setAttribute('cx', String(n.x));
115
+ ring.setAttribute('cy', String(n.y));
116
+ ring.setAttribute('r', '7');
117
+ gNodes.appendChild(ring);
118
+
119
+ const c = document.createElementNS(NS, 'circle');
120
+ c.setAttribute('class', 'nn-node');
121
+ c.setAttribute('cx', String(n.x));
122
+ c.setAttribute('cy', String(n.y));
123
+ c.setAttribute('r', '3.2');
124
+ gNodes.appendChild(c);
125
+ });
126
+ svg.appendChild(gNodes);
127
+
128
+ const gPulses = document.createElementNS(NS, 'g');
129
+ svg.appendChild(gPulses);
130
+
131
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
132
+ if (reduced) return;
133
+
134
+ let stopped = false;
135
+ container.__demoCleanup = () => { stopped = true; };
136
+
137
+ const fire = () => {
138
+ if (stopped || links.length === 0) return;
139
+ const [a, b] = links[Math.floor(Math.random() * links.length)];
140
+ const c = document.createElementNS(NS, 'circle');
141
+ c.setAttribute('class', 'nn-pulse');
142
+ c.setAttribute('r', '2.6');
143
+ c.setAttribute('cx', String(a.x));
144
+ c.setAttribute('cy', String(a.y));
145
+ c.style.opacity = '0.9';
146
+ gPulses.appendChild(c);
147
+
148
+ const start = performance.now();
149
+ const dur = 700 + Math.random() * 700;
150
+ const tick = (now) => {
151
+ if (stopped) { c.remove(); return; }
152
+ const t = Math.min(1, (now - start) / dur);
153
+ const x = a.x + (b.x - a.x) * t;
154
+ const y = a.y + (b.y - a.y) * t;
155
+ c.setAttribute('cx', String(x));
156
+ c.setAttribute('cy', String(y));
157
+ c.style.opacity = String(0.9 * (1 - t));
158
+ if (t < 1) requestAnimationFrame(tick);
159
+ else c.remove();
160
+ };
161
+ requestAnimationFrame(tick);
162
+ };
163
+
164
+ const loop = () => {
165
+ if (stopped) return;
166
+ const burst = 2 + Math.floor(Math.random() * 3);
167
+ for (let i = 0; i < burst; i++) fire();
168
+ setTimeout(loop, 160 + Math.random() * 180);
169
+ };
170
+ loop();
171
+ };
172
+
173
+ if (document.readyState === 'loading') {
174
+ document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
175
+ } else {
176
+ bootstrap();
177
+ }
178
+ })();
179
+ </script>`;
backend/demo/human-typing.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * human-typing.ts
3
+ *
4
+ * Helpers to make Playwright type like a real human: variable cadence,
5
+ * thinking pauses around punctuation, occasional typos that get corrected
6
+ * with backspace, short words sent as bursts, and longer words slowed down.
7
+ *
8
+ * Designed for demo/marketing videos where the goal is "this looks like a
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. */
15
+ baseDelay?: number;
16
+ /** Jitter range added on top of baseDelay (ms). */
17
+ jitter?: number;
18
+ /** Probability (0-1) of mistyping a character, then fixing it. */
19
+ typoRate?: number;
20
+ /** Probability (0-1) of a short "thinking" pause between words. */
21
+ thinkRate?: number;
22
+ /** Multiplier applied to all delays (1 = normal, 0.5 = fast, 2 = slow). */
23
+ speed?: number;
24
+ }
25
+
26
+ const DEFAULTS: Required<HumanTypingOptions> = {
27
+ baseDelay: 55,
28
+ jitter: 85,
29
+ typoRate: 0.025,
30
+ thinkRate: 0.18,
31
+ speed: 1,
32
+ };
33
+
34
+ function rand(min: number, max: number): number {
35
+ return min + Math.random() * (max - min);
36
+ }
37
+
38
+ function sleep(ms: number): Promise<void> {
39
+ return new Promise((r) => setTimeout(r, ms));
40
+ }
41
+
42
+ /** Picks a plausible typo character adjacent to the target on QWERTY. */
43
+ const TYPO_MAP: Record<string, string> = {
44
+ a: "s", b: "v", c: "v", d: "s", e: "r", f: "d", g: "h", h: "j",
45
+ i: "o", j: "k", k: "l", l: "k", m: "n", n: "m", o: "p", p: "o",
46
+ q: "w", r: "t", s: "a", t: "y", u: "i", v: "b", w: "e", x: "c",
47
+ y: "u", z: "x",
48
+ };
49
+
50
+ function typoFor(ch: string): string | null {
51
+ const lower = ch.toLowerCase();
52
+ const neighbor = TYPO_MAP[lower];
53
+ if (!neighbor) return null;
54
+ return ch === lower ? neighbor : neighbor.toUpperCase();
55
+ }
56
+
57
+ /**
58
+ * Type `text` into the currently focused element of `page` with human-like
59
+ * rhythm. Adds pauses after punctuation, thinking pauses between words,
60
+ * the occasional typo followed by a correction, and small speed variation.
61
+ */
62
+ export async function humanType(
63
+ page: Page,
64
+ text: string,
65
+ options: HumanTypingOptions = {}
66
+ ): Promise<void> {
67
+ const { baseDelay, jitter, typoRate, thinkRate, speed } = { ...DEFAULTS, ...options };
68
+ const scale = 1 / speed;
69
+
70
+ const tokens = text.split(/(\s+)/);
71
+
72
+ for (let t = 0; t < tokens.length; t++) {
73
+ const token = tokens[t];
74
+ if (!token) continue;
75
+
76
+ if (/^\s+$/.test(token)) {
77
+ await page.keyboard.type(token, { delay: 0 });
78
+ if (Math.random() < thinkRate) {
79
+ await sleep(rand(180, 520) * scale);
80
+ }
81
+ continue;
82
+ }
83
+
84
+ const wordSpeed = token.length > 7 ? rand(1.0, 1.3) : rand(0.7, 1.0);
85
+
86
+ for (let i = 0; i < token.length; i++) {
87
+ const ch = token[i];
88
+ const delay = (baseDelay + Math.random() * jitter) * wordSpeed * scale;
89
+
90
+ const shouldTypo = Math.random() < typoRate && /[a-zA-Z]/.test(ch);
91
+ if (shouldTypo) {
92
+ const wrong = typoFor(ch);
93
+ if (wrong) {
94
+ await page.keyboard.type(wrong, { delay });
95
+ await sleep(rand(90, 260) * scale);
96
+ await page.keyboard.press("Backspace");
97
+ await sleep(rand(60, 180) * scale);
98
+ }
99
+ }
100
+
101
+ await page.keyboard.type(ch, { delay });
102
+
103
+ if (/[,;:]/.test(ch)) {
104
+ await sleep(rand(280, 700) * scale);
105
+ } else if (/[.!?]/.test(ch)) {
106
+ await sleep(rand(600, 1400) * scale);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Focus a locator and type with `humanType`. Waits for visibility first.
114
+ */
115
+ export async function humanTypeInto(
116
+ page: Page,
117
+ target: Locator,
118
+ text: string,
119
+ options: HumanTypingOptions = {}
120
+ ): Promise<void> {
121
+ await target.waitFor({ state: "visible" });
122
+ await target.click();
123
+ await sleep(rand(250, 550));
124
+ await humanType(page, text, options);
125
+ }
126
+
127
+ /**
128
+ * Sleep for a human-ish "reading/thinking" pause.
129
+ */
130
+ export function pause(min: number, max: number = min): Promise<void> {
131
+ return sleep(rand(min, max));
132
+ }
backend/demo/personas.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * personas.ts
3
+ *
4
+ * Pre-defined collab identities used to seed `localStorage` before the page
5
+ * loads. The collab-editor frontend reads the key
6
+ * `collab-editor:fallback-user` on boot (see frontend/src/utils/user.ts) and
7
+ * uses it for the yjs awareness cursor name + color.
8
+ */
9
+ export interface Persona {
10
+ id: string;
11
+ name: string;
12
+ color: string;
13
+ }
14
+
15
+ export const PERSONAS = {
16
+ alice: { id: "alice", name: "Alice Chen", color: "#958DF1" },
17
+ bob: { id: "bob", name: "Bob Martin", color: "#70CFF8" },
18
+ carol: { id: "carol", name: "Carol Reyes", color: "#F98181" },
19
+ } as const satisfies Record<string, Persona>;
20
+
21
+ export const FALLBACK_USER_KEY = "collab-editor:fallback-user";
backend/demo/solo.ts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * demo/solo.ts
3
+ *
4
+ * Single-persona demo: opens the collab-editor as "Alice", types the title
5
+ * and one paragraph with human-like rhythm. Use this to visually validate
6
+ * that the typing feels natural before scaling to multiple personas.
7
+ *
8
+ * Prereqs:
9
+ * - Frontend dev server running on http://localhost:5678
10
+ * - Backend dev server running on http://localhost:8080
11
+ *
12
+ * Run:
13
+ * cd backend && npx tsx demo/solo.ts
14
+ *
15
+ * Tuning:
16
+ * - Pass `--speed 1.5` to type faster, `--speed 0.7` to slow it down.
17
+ * - Pass `--url http://localhost:5678` to override the target.
18
+ */
19
+ import { chromium } from "playwright";
20
+ import { humanType, humanTypeInto, pause } from "./human-typing.js";
21
+ import { PERSONAS, FALLBACK_USER_KEY } from "./personas.js";
22
+
23
+ interface CliArgs {
24
+ url: string;
25
+ speed: number;
26
+ headless: boolean;
27
+ }
28
+
29
+ function parseArgs(argv: string[]): CliArgs {
30
+ const args: CliArgs = {
31
+ url: "http://localhost:5678",
32
+ speed: 1,
33
+ headless: false,
34
+ };
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const arg = argv[i];
37
+ if (arg === "--url") args.url = argv[++i];
38
+ else if (arg === "--speed") args.speed = Number(argv[++i]);
39
+ else if (arg === "--headless") args.headless = true;
40
+ }
41
+ return args;
42
+ }
43
+
44
+ async function run() {
45
+ const args = parseArgs(process.argv.slice(2));
46
+ const persona = PERSONAS.alice;
47
+
48
+ console.log(`[demo/solo] launching as ${persona.name} (${persona.color})`);
49
+ console.log(`[demo/solo] target: ${args.url} speed: ${args.speed}x`);
50
+
51
+ const browser = await chromium.launch({
52
+ headless: args.headless,
53
+ args: ["--window-size=1280,800"],
54
+ });
55
+
56
+ const context = await browser.newContext({
57
+ viewport: { width: 1280, height: 800 },
58
+ deviceScaleFactor: 2,
59
+ });
60
+
61
+ await context.addInitScript(
62
+ ({ key, value }) => {
63
+ try {
64
+ localStorage.setItem(key, value);
65
+ } catch {
66
+ /* ignore */
67
+ }
68
+ },
69
+ {
70
+ key: FALLBACK_USER_KEY,
71
+ value: JSON.stringify({ name: persona.name, color: persona.color }),
72
+ }
73
+ );
74
+
75
+ const page = await context.newPage();
76
+
77
+ console.log(`[demo/solo] goto ${args.url}`);
78
+ try {
79
+ await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: 15_000 });
80
+ } catch (err) {
81
+ console.error(
82
+ `[demo/solo] cannot reach ${args.url}. Is the frontend dev server running?`
83
+ );
84
+ await browser.close();
85
+ process.exit(1);
86
+ }
87
+
88
+ await page.waitForSelector("[aria-label='Undo']", { timeout: 20_000 });
89
+ console.log(`[demo/solo] editor ready, starting scenario`);
90
+
91
+ await pause(1200, 2000);
92
+
93
+ const title = page.getByPlaceholder("Article title");
94
+ await humanTypeInto(page, title, "Climate signals in early 2026", {
95
+ speed: args.speed,
96
+ typoRate: 0.03,
97
+ });
98
+
99
+ await pause(900, 1600);
100
+
101
+ const body = page.locator(".ProseMirror").first();
102
+ await body.click();
103
+ await pause(600, 1200);
104
+
105
+ await humanType(
106
+ page,
107
+ "Over the last six months, a handful of monitoring stations picked up anomalies that deserve a closer look. ",
108
+ { speed: args.speed, typoRate: 0.035, thinkRate: 0.22 }
109
+ );
110
+
111
+ await pause(800, 1400);
112
+
113
+ await humanType(
114
+ page,
115
+ "We cross-checked three independent datasets before drawing any conclusions.",
116
+ { speed: args.speed, typoRate: 0.025 }
117
+ );
118
+
119
+ await pause(1500, 2500);
120
+
121
+ console.log(`[demo/solo] scenario complete. Leaving browser open for inspection.`);
122
+ console.log(`[demo/solo] press Ctrl+C to quit.`);
123
+
124
+ await new Promise(() => {});
125
+ }
126
+
127
+ run().catch((err) => {
128
+ console.error("[demo/solo] fatal:", err);
129
+ process.exit(1);
130
+ });
backend/demo/trio.ts ADDED
@@ -0,0 +1,1054 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * demo/trio.ts
3
+ *
4
+ * Three-persona collaboration demo for recording a mini product video.
5
+ * Scenario: an empty article gets turned into a small research piece on
6
+ * the famous Quake III "fast inverse square root" trick, with headings,
7
+ * a paragraph, inline + block math, a syntax-highlighted C snippet,
8
+ * and one review comment from a teammate.
9
+ *
10
+ * By default:
11
+ * - Alice is the "hero" window (visible). This is what you record.
12
+ * - Bob and Carol run headless. Their yjs cursors, comments and edits
13
+ * appear in Alice's window as if real teammates joined.
14
+ *
15
+ * With --all-visible, all three browsers launch visibly.
16
+ *
17
+ * Prereqs:
18
+ * - Frontend dev server on http://localhost:5678
19
+ * - Backend dev server on http://localhost:8080
20
+ */
21
+ import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
22
+ import { execSync } from "node:child_process";
23
+ import { humanType, humanTypeInto, pause } from "./human-typing.js";
24
+ import { PERSONAS, FALLBACK_USER_KEY, type Persona } from "./personas.js";
25
+ import { NEURAL_NETWORK_BANNER_HTML } from "./banners.js";
26
+
27
+ /**
28
+ * Kill any Chromium instance left over from a previous Playwright run.
29
+ *
30
+ * We match on the ms-playwright install path so we only target the
31
+ * automation browser - the user's regular Chrome/Chromium is untouched.
32
+ * Non-fatal: if pkill finds nothing (exit 1) or isn't available, we
33
+ * just move on and let Playwright launch fresh browsers.
34
+ */
35
+ function killLeftoverChromiums() {
36
+ // Match both the headful Chromium binary (Alice's window) and the
37
+ // headless-shell binary (Bob/Carol). The `ms-playwright` segment is
38
+ // unique to the Playwright install path so we never touch the user's
39
+ // real Chrome/Chromium.
40
+ const patterns = [
41
+ "ms-playwright/.*Chromium",
42
+ "ms-playwright/.*chrome-headless-shell",
43
+ ];
44
+ let killed = false;
45
+ for (const p of patterns) {
46
+ try {
47
+ execSync(`pkill -f '${p}'`, { stdio: "ignore" });
48
+ killed = true;
49
+ } catch {
50
+ // pkill exits 1 when no process matches - normal on a clean start.
51
+ }
52
+ }
53
+ if (killed) {
54
+ console.log("[trio] killed leftover Playwright Chromium instances");
55
+ // Give the OS a beat to reclaim ports / window handles before we
56
+ // spawn fresh browsers, otherwise launch can flake on macOS.
57
+ execSync("sleep 0.4");
58
+ }
59
+ }
60
+
61
+ interface CliArgs {
62
+ url: string;
63
+ speed: number;
64
+ allVisible: boolean;
65
+ }
66
+
67
+ function parseArgs(argv: string[]): CliArgs {
68
+ const args: CliArgs = {
69
+ url: "http://localhost:5678",
70
+ speed: 2,
71
+ allVisible: false,
72
+ };
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const a = argv[i];
75
+ if (a === "--url") args.url = argv[++i];
76
+ else if (a === "--speed") args.speed = Number(argv[++i]);
77
+ else if (a === "--all-visible") args.allVisible = true;
78
+ }
79
+ return args;
80
+ }
81
+
82
+ interface LaunchOptions {
83
+ persona: Persona;
84
+ headless: boolean;
85
+ windowPosition?: { x: number; y: number };
86
+ /**
87
+ * If true, Chromium starts in OS fullscreen (no window chrome, no menu bar
88
+ * on macOS). Used for Alice (the recorded persona) so the screencast looks
89
+ * clean. Forces `viewport: null` so the page uses the real window size.
90
+ */
91
+ fullscreen?: boolean;
92
+ }
93
+
94
+ async function launchPersona(
95
+ url: string,
96
+ opts: LaunchOptions
97
+ ): Promise<{ browser: Browser; context: BrowserContext; page: Page }> {
98
+ const launchArgs: string[] = [];
99
+ if (opts.fullscreen) {
100
+ launchArgs.push("--start-fullscreen");
101
+ } else {
102
+ launchArgs.push("--window-size=1280,800");
103
+ if (opts.windowPosition) {
104
+ launchArgs.push(
105
+ `--window-position=${opts.windowPosition.x},${opts.windowPosition.y}`
106
+ );
107
+ }
108
+ }
109
+
110
+ const browser = await chromium.launch({
111
+ headless: opts.headless,
112
+ args: launchArgs,
113
+ });
114
+
115
+ // Playwright forbids `deviceScaleFactor` when `viewport` is null (real
116
+ // window size). For fullscreen we accept the OS-native DPR.
117
+ const context = await browser.newContext(
118
+ opts.fullscreen
119
+ ? { viewport: null }
120
+ : { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }
121
+ );
122
+
123
+ await context.addInitScript(
124
+ ({ key, value }) => {
125
+ try {
126
+ localStorage.setItem(key, value);
127
+ } catch {
128
+ /* ignore */
129
+ }
130
+ },
131
+ {
132
+ key: FALLBACK_USER_KEY,
133
+ value: JSON.stringify({ name: opts.persona.name, color: opts.persona.color }),
134
+ }
135
+ );
136
+
137
+ const page = await context.newPage();
138
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
139
+ await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
140
+
141
+ return { browser, context, page };
142
+ }
143
+
144
+ // ----------------------------------------------------------------------------
145
+ // Editor helpers
146
+ // ----------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Move the caret to the very end of the ProseMirror editor, regardless of
150
+ * where it currently sits. More reliable than "body.click() + Ctrl+End":
151
+ * - body.click() lands at the geometric center and may drop the caret
152
+ * inside a heading, which then gets extended when typing.
153
+ * - Ctrl+End is not a "go to end of document" shortcut on macOS.
154
+ * We use the DOM Range API on the first .ProseMirror element.
155
+ */
156
+ async function focusEnd(page: Page) {
157
+ await page.evaluate(() => {
158
+ const root = document.querySelector(".ProseMirror") as HTMLElement | null;
159
+ if (!root) return;
160
+ root.focus();
161
+ const range = document.createRange();
162
+ range.selectNodeContents(root);
163
+ range.collapse(false);
164
+ const sel = window.getSelection();
165
+ if (!sel) return;
166
+ sel.removeAllRanges();
167
+ sel.addRange(range);
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Wipe everything so each run starts from a pristine doc:
173
+ * - body
174
+ * - frontmatter (title, subtitle, authors, ...)
175
+ * - banner (the previous run's neural network would otherwise persist)
176
+ * - citations + settings + all embeds
177
+ *
178
+ * We dispatch the `reset-article` window event the Editor already exposes
179
+ * for the `/Reset article` slash command, then wait for the empty-banner
180
+ * placeholder (and so the "Create chart" button) to come back.
181
+ */
182
+ async function resetDoc(page: Page) {
183
+ await page.evaluate(() => {
184
+ window.dispatchEvent(new CustomEvent("reset-article"));
185
+ });
186
+ // Give Yjs / React a beat to flush the wipe to the DOM.
187
+ await pause(500, 800);
188
+
189
+ // The empty-state placeholder reappears once banner === "". Wait for it
190
+ // explicitly so the next step (createNeuralNetworkBanner) is reliable.
191
+ try {
192
+ await page
193
+ .locator(".hero-banner--empty")
194
+ .first()
195
+ .waitFor({ state: "visible", timeout: 5_000 });
196
+ } catch {
197
+ // Non-fatal: createNeuralNetworkBanner has its own warning path.
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Insert a Heading 2 via the markdown shortcut "## ". This triggers the
203
+ * StarterKit input rule and the "##" tokens are transformed live into a
204
+ * styled heading - visually snappier than popping the slash menu.
205
+ */
206
+ async function insertHeading2(page: Page, text: string, speed: number) {
207
+ await page.keyboard.type("## ", { delay: 45 });
208
+ await pause(80, 160);
209
+ await humanType(page, text, { speed, typoRate: 0.015, thinkRate: 0.08 });
210
+ await page.keyboard.press("Enter");
211
+ await pause(150, 280);
212
+ }
213
+
214
+ /**
215
+ * Enter a code block via the markdown shortcut "```<lang> " then type the
216
+ * body. The regex in @tiptap/extension-code-block expects
217
+ * /^```([a-z]+)?[\s\n]$/
218
+ * so we close the fence with a trailing space, which triggers the rule.
219
+ */
220
+ async function insertCodeBlock(
221
+ page: Page,
222
+ language: string,
223
+ code: string,
224
+ speed: number
225
+ ) {
226
+ await page.keyboard.type("```" + language + " ", { delay: 30 });
227
+ await pause(80, 140);
228
+ await humanType(page, code, {
229
+ speed: speed * 2,
230
+ typoRate: 0,
231
+ thinkRate: 0.02,
232
+ });
233
+ await pause(180, 320);
234
+ await page.keyboard.press("ArrowDown");
235
+ await pause(80, 160);
236
+ }
237
+
238
+ /**
239
+ * Click "Create chart" in the empty banner placeholder, type a short prompt
240
+ * in the Embed Studio (purely visual - we don't wait for the real agent),
241
+ * then inject a pre-built neural network banner via the dev-only
242
+ * `__demo-set-banner` window event the Editor exposes for this script.
243
+ *
244
+ * Finally close the Embed Studio with "Save & Close" so Alice's editor
245
+ * area is fully visible again for the rest of the recording.
246
+ */
247
+ async function createNeuralNetworkBanner(page: Page, speed: number) {
248
+ const createBtn = page.getByRole("button", { name: /Create chart/i }).first();
249
+ try {
250
+ await createBtn.waitFor({ state: "visible", timeout: 5_000 });
251
+ } catch {
252
+ console.warn('[alice] "Create chart" button not found, skipping banner');
253
+ return;
254
+ }
255
+ await createBtn.scrollIntoViewIfNeeded();
256
+ await pause(120, 220);
257
+ await createBtn.click();
258
+
259
+ // Type the prompt for real so the viewer sees the user instruction
260
+ // forming in the chat textarea, then clear it (mimics submit reset).
261
+ const promptArea = page.getByPlaceholder("Describe your chart...");
262
+ const promptText = "A neural network illustration";
263
+ try {
264
+ await promptArea.waitFor({ state: "visible", timeout: 4_000 });
265
+ await promptArea.click();
266
+ await pause(120, 220);
267
+ await humanType(page, promptText, {
268
+ speed: speed * 1.5,
269
+ typoRate: 0,
270
+ thinkRate: 0.02,
271
+ });
272
+ await pause(150, 280);
273
+ await promptArea.fill("");
274
+ } catch {
275
+ console.warn("[alice] embed studio textarea not found, faking anyway");
276
+ }
277
+
278
+ // Stream a fake assistant reply so the recording shows tokens
279
+ // appearing one by one (real LLM feel), then drop the banner once
280
+ // the reply is fully visible.
281
+ await streamFakeExchange(page, {
282
+ eventName: "__demo-embed-chat",
283
+ prompt: promptText,
284
+ reply:
285
+ "Done - a layered network with subtle pulses along the synapses, themed with the article's primary color.",
286
+ });
287
+ await page.evaluate(
288
+ (html) => {
289
+ window.dispatchEvent(
290
+ new CustomEvent("__demo-set-banner", { detail: { html } }),
291
+ );
292
+ },
293
+ NEURAL_NETWORK_BANNER_HTML,
294
+ );
295
+ await pause(500, 800);
296
+
297
+ const closeBtn = page.getByRole("button", { name: /^Save & Close$/i }).first();
298
+ if (await closeBtn.isVisible().catch(() => false)) {
299
+ await closeBtn.click();
300
+ } else {
301
+ const xBtn = page.getByRole("button", { name: /Close Embed Studio/i }).first();
302
+ if (await xBtn.isVisible().catch(() => false)) {
303
+ await xBtn.click();
304
+ } else {
305
+ await page.keyboard.press("Escape");
306
+ }
307
+ }
308
+ await pause(150, 280);
309
+ }
310
+
311
+ /**
312
+ * Double-click a word, open the comment dialog from the bubble toolbar,
313
+ * type the comment and submit. Scoped to the currently-open dialog so we
314
+ * don't collide with the Publish dialog.
315
+ */
316
+ async function leaveCommentOn(
317
+ page: Page,
318
+ word: string,
319
+ commentText: string,
320
+ speed: number
321
+ ): Promise<boolean> {
322
+ const target = page
323
+ .locator(".ProseMirror p")
324
+ .getByText(word, { exact: false })
325
+ .first();
326
+ try {
327
+ await target.waitFor({ state: "visible", timeout: 45_000 });
328
+ } catch {
329
+ console.warn(`[comment] word "${word}" not found in paragraphs`);
330
+ return false;
331
+ }
332
+
333
+ await target.scrollIntoViewIfNeeded();
334
+ await target.hover();
335
+ await pause(400, 800);
336
+ await target.dblclick();
337
+ await pause(400, 700);
338
+
339
+ const commentBtn = page.locator('.bubble-toolbar button[aria-label="Comment"]');
340
+ try {
341
+ await commentBtn.waitFor({ state: "visible", timeout: 5_000 });
342
+ } catch {
343
+ console.warn(`[comment] bubble toolbar did not show for "${word}"`);
344
+ return false;
345
+ }
346
+ await commentBtn.click();
347
+
348
+ const dialog = page
349
+ .locator('dialog[open]')
350
+ .filter({ has: page.locator("textarea") })
351
+ .first();
352
+ const textarea = dialog.locator("textarea");
353
+ await textarea.waitFor({ state: "visible", timeout: 5_000 });
354
+ await textarea.click();
355
+ await pause(250, 500);
356
+
357
+ await humanType(page, commentText, { speed, typoRate: 0.015 });
358
+ await pause(500, 900);
359
+
360
+ await dialog.locator("button.btn--primary").click();
361
+ return true;
362
+ }
363
+
364
+ // ----------------------------------------------------------------------------
365
+ // Persona scripts
366
+ // ----------------------------------------------------------------------------
367
+
368
+ /**
369
+ * Open the "Add author" modal, fill it in, and submit.
370
+ *
371
+ * - Looks for the placeholder button first (no authors yet), falls back to
372
+ * the small "+" icon button next to the existing authors list.
373
+ * - Optionally creates a new affiliation OR selects an existing chip by its
374
+ * 1-based index (matches the ordered list shown in the form).
375
+ * - Submits via the primary button. Waits for the dialog to close before
376
+ * returning so the next call can find the trigger again.
377
+ */
378
+ interface AddAuthorOpts {
379
+ url?: string;
380
+ newAffiliation?: string;
381
+ selectAffiliations?: number[]; // 1-based indices
382
+ }
383
+
384
+ async function addAuthor(
385
+ page: Page,
386
+ name: string,
387
+ speed: number,
388
+ opts: AddAuthorOpts = {}
389
+ ): Promise<boolean> {
390
+ const placeholderTrigger = page.locator(".meta-placeholder-btn", {
391
+ hasText: /Add author/i,
392
+ });
393
+ const iconTrigger = page.locator('button.icon-btn[aria-label="Add author"]');
394
+
395
+ let opened = false;
396
+ if (await placeholderTrigger.first().isVisible().catch(() => false)) {
397
+ await placeholderTrigger.first().scrollIntoViewIfNeeded();
398
+ await pause(80, 160);
399
+ await placeholderTrigger.first().click();
400
+ opened = true;
401
+ } else if (await iconTrigger.first().isVisible().catch(() => false)) {
402
+ await iconTrigger.first().scrollIntoViewIfNeeded();
403
+ await pause(80, 160);
404
+ await iconTrigger.first().click();
405
+ opened = true;
406
+ }
407
+ if (!opened) {
408
+ console.warn("[author] no Add-author trigger visible");
409
+ return false;
410
+ }
411
+
412
+ const dialog = page.locator("dialog.ed-dialog--author");
413
+ try {
414
+ await dialog.waitFor({ state: "visible", timeout: 5_000 });
415
+ } catch {
416
+ console.warn("[author] modal did not open");
417
+ return false;
418
+ }
419
+ await pause(120, 220);
420
+
421
+ const fastSpeed = speed * 1.4;
422
+
423
+ const nameInput = dialog.getByPlaceholder("Name");
424
+ await nameInput.click();
425
+ await humanType(page, name, { speed: fastSpeed, typoRate: 0, thinkRate: 0.02 });
426
+ await pause(80, 160);
427
+
428
+ if (opts.url) {
429
+ const urlInput = dialog.getByPlaceholder("URL (optional)");
430
+ await urlInput.click();
431
+ await humanType(page, opts.url, { speed: fastSpeed, typoRate: 0, thinkRate: 0.02 });
432
+ await pause(80, 160);
433
+ }
434
+
435
+ if (opts.selectAffiliations && opts.selectAffiliations.length > 0) {
436
+ for (const idx of opts.selectAffiliations) {
437
+ const chip = dialog
438
+ .locator(".chip")
439
+ .filter({ hasText: new RegExp(`^${idx}\\.`) })
440
+ .first();
441
+ if (await chip.isVisible().catch(() => false)) {
442
+ await chip.click();
443
+ await pause(60, 140);
444
+ }
445
+ }
446
+ }
447
+
448
+ if (opts.newAffiliation) {
449
+ const newAffInput = dialog.getByPlaceholder(/New affiliation/i);
450
+ await newAffInput.click();
451
+ await humanType(page, opts.newAffiliation, {
452
+ speed: fastSpeed,
453
+ typoRate: 0,
454
+ thinkRate: 0.02,
455
+ });
456
+ await pause(80, 160);
457
+ }
458
+
459
+ const submit = dialog.locator("button.btn--primary");
460
+ await submit.click();
461
+
462
+ // Dialog unmounts on submit; wait for it to disappear so the next iteration
463
+ // can locate the trigger again.
464
+ try {
465
+ await dialog.waitFor({ state: "hidden", timeout: 3_000 });
466
+ } catch {
467
+ /* non-fatal */
468
+ }
469
+ await pause(100, 200);
470
+ return true;
471
+ }
472
+
473
+ /**
474
+ * Wait until a Heading 2 with the given text is rendered in the document.
475
+ * Used to coordinate handoffs between personas without hard-coded sleeps.
476
+ */
477
+ async function waitForHeading2(page: Page, text: string, timeout = 60_000) {
478
+ await page
479
+ .locator(".ProseMirror h2")
480
+ .filter({ hasText: text })
481
+ .first()
482
+ .waitFor({ state: "visible", timeout });
483
+ }
484
+
485
+ /**
486
+ * Place this browser's caret at the END of the first <p> directly after
487
+ * the heading whose text matches `headingText`. Used so each persona can
488
+ * type into its OWN section concurrently without stepping on the others.
489
+ *
490
+ * Returns false if the heading or following paragraph cannot be found.
491
+ */
492
+ async function focusParagraphAfterHeading(page: Page, headingText: string) {
493
+ return await page.evaluate((text) => {
494
+ const root = document.querySelector(".ProseMirror") as HTMLElement | null;
495
+ if (!root) return false;
496
+ const headings = Array.from(root.querySelectorAll("h2"));
497
+ const wanted = text.toLowerCase();
498
+ const heading = headings.find(
499
+ (h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
500
+ );
501
+ if (!heading) return false;
502
+ let para: Element | null = heading.nextElementSibling;
503
+ while (para && para.tagName !== "P") para = para.nextElementSibling;
504
+ if (!para) return false;
505
+ (para as HTMLElement).focus();
506
+ const range = document.createRange();
507
+ range.selectNodeContents(para);
508
+ range.collapse(false);
509
+ const sel = window.getSelection();
510
+ if (!sel) return false;
511
+ sel.removeAllRanges();
512
+ sel.addRange(range);
513
+ return true;
514
+ }, headingText);
515
+ }
516
+
517
+ /**
518
+ * Visibly select an entire paragraph containing the given text snippet.
519
+ * Used to stage an AI rewrite: the viewer sees Alice highlight Bob's
520
+ * paragraph before she opens the chat and asks for a reformulation.
521
+ *
522
+ * We do the selection via the DOM Selection API (rather than
523
+ * `locator.click({ clickCount: 3 })`) because Tiptap/ProseMirror can
524
+ * swallow synthetic triple-clicks. Selecting the full <p> contents
525
+ * yields the same visual result (blue highlight over the paragraph)
526
+ * and survives losing focus to the chat panel.
527
+ */
528
+ async function selectParagraphByText(
529
+ page: Page,
530
+ snippet: string,
531
+ ): Promise<boolean> {
532
+ return await page.evaluate((text) => {
533
+ const root = document.querySelector(".ProseMirror") as HTMLElement | null;
534
+ if (!root) return false;
535
+ const paragraphs = Array.from(root.querySelectorAll("p"));
536
+ const para = paragraphs.find((p) =>
537
+ (p.textContent || "").includes(text),
538
+ );
539
+ if (!para) return false;
540
+ para.scrollIntoView({ behavior: "smooth", block: "center" });
541
+ (para as HTMLElement).focus();
542
+ const range = document.createRange();
543
+ range.selectNodeContents(para);
544
+ const sel = window.getSelection();
545
+ if (!sel) return false;
546
+ sel.removeAllRanges();
547
+ sel.addRange(range);
548
+ // Stamp the paragraph as "claimed by the agent" so the viewer sees
549
+ // a persistent "Agent is working" badge through the chat prompt and
550
+ // the streaming reply. The App.tsx replace handler will swap this
551
+ // for `.demo-agent-rewriting` when the retype actually starts.
552
+ para.classList.add("demo-agent-pending");
553
+ return true;
554
+ }, snippet);
555
+ }
556
+
557
+ /**
558
+ * Stream a fake LLM exchange (user prompt + assistant reply) into a chat
559
+ * panel via the given dev-only window event name. The assistant text is
560
+ * appended chunk by chunk so the recording shows tokens streaming in,
561
+ * the way a real model would render.
562
+ *
563
+ * Optional `replace` triggers a find-and-replace on the editor body
564
+ * AFTER the assistant message is fully streamed - that way the viewer
565
+ * reads the suggestion before seeing the paragraph swap.
566
+ */
567
+ async function streamFakeExchange(
568
+ page: Page,
569
+ args: {
570
+ eventName: "__demo-chat" | "__demo-embed-chat";
571
+ prompt: string;
572
+ reply: string;
573
+ open?: boolean;
574
+ replace?: { from: string; to: string };
575
+ chunkMs?: [number, number];
576
+ chunkSize?: [number, number];
577
+ },
578
+ ) {
579
+ const userMessage = {
580
+ id: `demo-u-${Date.now()}`,
581
+ role: "user",
582
+ parts: [{ type: "text", text: args.prompt }],
583
+ };
584
+ const assistantId = `demo-a-${Date.now() + 1}`;
585
+
586
+ // Step 1: post the user message and (optionally) open the panel.
587
+ await page.evaluate(
588
+ ({ name, msgs, open }) => {
589
+ window.dispatchEvent(
590
+ new CustomEvent(name, { detail: { messages: msgs, open } }),
591
+ );
592
+ },
593
+ {
594
+ name: args.eventName,
595
+ msgs: [userMessage],
596
+ open: args.open,
597
+ },
598
+ );
599
+
600
+ // Brief "thinking" beat before the first token streams in.
601
+ await pause(280, 480);
602
+
603
+ // Step 2: stream the assistant text in small chunks.
604
+ const tokens = args.reply.split(/(\s+)/).filter((t) => t.length > 0);
605
+ const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
606
+ const [delayMin, delayMax] = args.chunkMs ?? [22, 55];
607
+ let acc = "";
608
+ let i = 0;
609
+ while (i < tokens.length) {
610
+ const take =
611
+ chunkMin +
612
+ Math.floor(Math.random() * Math.max(1, chunkMax - chunkMin + 1));
613
+ const chunk = tokens.slice(i, i + take).join("");
614
+ acc += chunk;
615
+ i += take;
616
+ await page.evaluate(
617
+ ({ name, msgs }) => {
618
+ window.dispatchEvent(
619
+ new CustomEvent(name, { detail: { messages: msgs } }),
620
+ );
621
+ },
622
+ {
623
+ name: args.eventName,
624
+ msgs: [
625
+ userMessage,
626
+ {
627
+ id: assistantId,
628
+ role: "assistant",
629
+ parts: [{ type: "text", text: acc }],
630
+ },
631
+ ],
632
+ },
633
+ );
634
+ await pause(delayMin, delayMax);
635
+ }
636
+
637
+ // Step 3: optional editor body rewrite, AFTER the reply is fully
638
+ // visible so the viewer reads the suggestion first.
639
+ if (args.replace && args.eventName === "__demo-chat") {
640
+ await pause(280, 450);
641
+ await page.evaluate(
642
+ ({ replace }) => {
643
+ window.dispatchEvent(
644
+ new CustomEvent("__demo-chat", { detail: { replace } }),
645
+ );
646
+ },
647
+ { replace: args.replace },
648
+ );
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Open the floating chat panel, type the prompt for real (so the user
654
+ * sees the textarea filling) then stream a fake assistant reply and
655
+ * apply a paragraph rewrite on the editor body.
656
+ */
657
+ async function requestRephrase(
658
+ page: Page,
659
+ args: {
660
+ prompt: string;
661
+ reply: string;
662
+ fromText: string;
663
+ toText: string;
664
+ speed: number;
665
+ },
666
+ ) {
667
+ // 1) Open the panel via the floating FAB and let it animate in.
668
+ const fab = page.getByRole("button", { name: /AI Assistant/i }).first();
669
+ if (await fab.isVisible().catch(() => false)) {
670
+ await fab.click();
671
+ await pause(220, 380);
672
+ }
673
+
674
+ // 2) Type the user prompt into the chat textarea (real typing for the
675
+ // visible "user side" of the exchange).
676
+ const textarea = page
677
+ .locator(".chat-floating textarea, .chat-panel textarea")
678
+ .first();
679
+ try {
680
+ await textarea.waitFor({ state: "visible", timeout: 3_000 });
681
+ await textarea.click();
682
+ await humanType(page, args.prompt, {
683
+ speed: args.speed * 2.2,
684
+ typoRate: 0,
685
+ thinkRate: 0.01,
686
+ });
687
+ await pause(100, 180);
688
+ } catch {
689
+ console.warn("[chat] textarea not found, will inject anyway");
690
+ }
691
+
692
+ // 3) Clear the textarea (mimics the post-submit reset) and stream the
693
+ // fake exchange + paragraph rewrite.
694
+ try {
695
+ await textarea.fill("");
696
+ } catch {
697
+ /* non-fatal */
698
+ }
699
+ await streamFakeExchange(page, {
700
+ eventName: "__demo-chat",
701
+ prompt: args.prompt,
702
+ reply: args.reply,
703
+ open: true,
704
+ replace: { from: args.fromText, to: args.toText },
705
+ });
706
+ // The editor-side rewrite animates async (highlight ~520ms + chunked
707
+ // retype ~24ms per 1-2 chars). Wait long enough to cover the big
708
+ // reveal without padding the end of the recording too much.
709
+ const animBudgetMs = 500 + args.toText.length * 18;
710
+ await pause(animBudgetMs, animBudgetMs + 200);
711
+ }
712
+
713
+ /**
714
+ * Build the article scaffold in one persona pass: three Heading 2 nodes
715
+ * each followed by an empty paragraph the assigned persona can later fill.
716
+ *
717
+ * Layout produced:
718
+ * <h2>The recipe</h2>
719
+ * <p></p> <- Alice fills this
720
+ * <h2>Intuition</h2>
721
+ * <p></p> <- Bob fills this
722
+ * <h2>In Python</h2>
723
+ * <p></p> <- Carol fills this
724
+ */
725
+ async function writeScaffold(page: Page, headings: string[], speed: number) {
726
+ for (const h of headings) {
727
+ await page.keyboard.type("## ", { delay: 22 });
728
+ await pause(40, 80);
729
+ await humanType(page, h, {
730
+ speed: speed * 2.2,
731
+ typoRate: 0,
732
+ thinkRate: 0.01,
733
+ });
734
+ await page.keyboard.press("Enter");
735
+ await pause(40, 80);
736
+ // Extra Enter to leave a dedicated empty paragraph below the heading.
737
+ await page.keyboard.press("Enter");
738
+ await pause(50, 100);
739
+ }
740
+ }
741
+
742
+ async function runAlice(page: Page, speed: number) {
743
+ console.log("[alice] ready");
744
+ await pause(150, 250);
745
+
746
+ // --- Title + subtitle (fast, no typos) -------------------------------
747
+ // Speed-tuned for the 30s budget: humans type fast enough that the
748
+ // viewer reads the title forming, but we skip typo backspaces here.
749
+ const fast = speed * 2;
750
+ const title = page.getByPlaceholder("Article title");
751
+ await humanTypeInto(page, title, "Attention, by hand", {
752
+ speed: fast,
753
+ typoRate: 0,
754
+ thinkRate: 0.01,
755
+ });
756
+
757
+ await pause(80, 140);
758
+
759
+ const subtitle = page.getByPlaceholder("Subtitle (optional)");
760
+ await humanTypeInto(
761
+ page,
762
+ subtitle,
763
+ "The one formula behind every LLM",
764
+ { speed: fast, typoRate: 0, thinkRate: 0.01 },
765
+ );
766
+
767
+ await pause(120, 220);
768
+
769
+ // --- Banner: neural network illustration ------------------------------
770
+ await createNeuralNetworkBanner(page, speed);
771
+
772
+ // --- Article scaffold (3 sections, each with an empty paragraph) ------
773
+ // Alice writes the skeleton so Bob and Carol have stable heading anchors
774
+ // to claim their own paragraphs.
775
+ await focusEnd(page);
776
+ await pause(80, 160);
777
+ await writeScaffold(page, ["The recipe", "Intuition", "In Python"], speed);
778
+ console.log("[alice] scaffold ready, opening parallel phase");
779
+
780
+ // --- Section 1: The recipe (Alice's section, parallel with Bob/Carol) -
781
+ await focusParagraphAfterHeading(page, "The recipe");
782
+ await pause(80, 160);
783
+
784
+ // Inline math uses $$...$$ (see @tiptap/extension-mathematics).
785
+ const bodySpeed = speed * 1.8;
786
+ await humanType(
787
+ page,
788
+ "At the heart of a Transformer, attention mixes queries ",
789
+ { speed: bodySpeed, typoRate: 0, thinkRate: 0.02 },
790
+ );
791
+ await humanType(page, "$$Q$$", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
792
+ await humanType(page, ", keys ", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
793
+ await humanType(page, "$$K$$", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
794
+ await humanType(page, " and values ", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
795
+ await humanType(page, "$$V$$", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
796
+ await humanType(page, ".", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
797
+
798
+ await pause(160, 280);
799
+
800
+ // Block math line uses $$$...$$$
801
+ await page.keyboard.press("Enter");
802
+ await pause(60, 120);
803
+ await humanType(
804
+ page,
805
+ "$$$\\text{Attention}(Q, K, V) = \\text{softmax}\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right)V$$$",
806
+ { speed: bodySpeed, typoRate: 0, thinkRate: 0 },
807
+ );
808
+ await page.keyboard.press("Enter");
809
+ await pause(80, 160);
810
+
811
+ // Follow-up paragraph wrapping up the recipe. Adds substance so the
812
+ // final article reads like more than a bullet list of components.
813
+ // Typed fast (2.5x base) to stay inside the video budget.
814
+ await humanType(
815
+ page,
816
+ "Softmax turns the scores into weights, and the weighted sum over V gives a contextual vector per token.",
817
+ { speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
818
+ );
819
+
820
+ await pause(180, 320);
821
+
822
+ // --- Rephrase flow: Alice selects Bob's "Intuition" paragraph, opens
823
+ // the main chat and asks to reformulate. The selection is made
824
+ // visible BEFORE the chat opens so the viewer understands what
825
+ // will be rewritten. The exchange is faked via `__demo-chat`.
826
+ try {
827
+ await page
828
+ .locator(".ProseMirror p")
829
+ .filter({ hasText: "Each token asks" })
830
+ .first()
831
+ .waitFor({ state: "visible", timeout: 15_000 });
832
+ await pause(220, 380);
833
+
834
+ const selected = await selectParagraphByText(page, "Each token asks");
835
+ if (!selected) {
836
+ console.warn("[alice] could not select Bob's paragraph");
837
+ } else {
838
+ // Give the viewer a short beat to notice the highlighted paragraph
839
+ // before Alice moves to the chat panel.
840
+ await pause(380, 540);
841
+ await requestRephrase(page, {
842
+ prompt: "Rephrase this paragraph more concisely.",
843
+ reply:
844
+ "Sure - tightened to a three-beat rhythm that matches the headings:",
845
+ fromText:
846
+ "Each token asks: which other tokens should I look at? Q is the question, K is the address, V is the answer.",
847
+ toText:
848
+ "Q asks the question. K shows the address. V delivers the answer.",
849
+ speed,
850
+ });
851
+ }
852
+ } catch {
853
+ console.warn("[alice] Bob's paragraph not detected, skipping rephrase");
854
+ }
855
+
856
+ console.log("[alice] scenario complete");
857
+ }
858
+
859
+ async function runBob(page: Page, speed: number) {
860
+ console.log("[bob] joined, opening author modal early");
861
+ await pause(800, 1_200);
862
+
863
+ // 1) Author modal happens FIRST, in parallel with Alice typing the title
864
+ // and creating the banner. The modal sits above the editor so it
865
+ // doesn't disturb Alice's body work, and it's the fastest way to
866
+ // show "Bob is contributing too" while Alice is still solo on screen.
867
+ await addAuthor(page, "Alice Renoir", speed, {
868
+ newAffiliation: "Hugging Face",
869
+ });
870
+ await pause(120, 240);
871
+ await addAuthor(page, "Bob Mercier", speed, {
872
+ selectAffiliations: [1],
873
+ });
874
+ await pause(120, 240);
875
+ await addAuthor(page, "Carol Dubois", speed, {
876
+ selectAffiliations: [1],
877
+ });
878
+ console.log("[bob] authors added");
879
+
880
+ // 2) Wait for the "Intuition" heading from Alice's scaffold, then claim
881
+ // the paragraph after it. This is when the parallel typing window
882
+ // really kicks in: Alice writing "The recipe", Carol writing
883
+ // "In Python", Bob writing here, all at the same time.
884
+ try {
885
+ await waitForHeading2(page, "Intuition", 60_000);
886
+ } catch {
887
+ console.warn("[bob] 'Intuition' heading not found, skipping body");
888
+ return;
889
+ }
890
+ await pause(150, 280);
891
+
892
+ const focused = await focusParagraphAfterHeading(page, "Intuition");
893
+ if (!focused) {
894
+ console.warn("[bob] could not focus paragraph after 'Intuition'");
895
+ return;
896
+ }
897
+ await pause(60, 120);
898
+ const bobSpeed = speed * 1.8;
899
+ await humanType(
900
+ page,
901
+ "Each token asks: which other tokens should I look at? ",
902
+ { speed: bobSpeed, typoRate: 0, thinkRate: 0.02 },
903
+ );
904
+ await humanType(page, "$$Q$$", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
905
+ await humanType(page, " is the question, ", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
906
+ await humanType(page, "$$K$$", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
907
+ await humanType(page, " is the address, ", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
908
+ await humanType(page, "$$V$$", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
909
+ await humanType(page, " is the answer.", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
910
+
911
+ await pause(120, 220);
912
+
913
+ // Follow-up paragraph expanding the mental model. Bob doesn't touch
914
+ // the first paragraph again: Alice will target it for the rephrase.
915
+ await page.keyboard.press("Enter");
916
+ await pause(60, 120);
917
+ await humanType(
918
+ page,
919
+ "Multiply, normalize with softmax, and each token becomes a weighted blend of its neighbours.",
920
+ { speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
921
+ );
922
+
923
+ console.log("[bob] scenario complete");
924
+ }
925
+
926
+ async function runCarol(page: Page, speed: number) {
927
+ console.log("[carol] joined, will write the code section");
928
+ await pause(1_200, 1_800);
929
+
930
+ // Wait for Alice's scaffold to ship the "In Python" heading, then claim
931
+ // the paragraph below it and type the code block immediately. Carol
932
+ // types in parallel with Alice's section and Bob's section.
933
+ try {
934
+ await waitForHeading2(page, "In Python", 60_000);
935
+ } catch {
936
+ console.warn("[carol] 'In Python' heading not found, skipping section");
937
+ return;
938
+ }
939
+ await pause(150, 300);
940
+
941
+ const focused = await focusParagraphAfterHeading(page, "In Python");
942
+ if (!focused) {
943
+ console.warn("[carol] could not focus paragraph after 'In Python'");
944
+ return;
945
+ }
946
+ await pause(80, 160);
947
+
948
+ // Preamble line before the code block so the section reads as a
949
+ // narrated snippet rather than an isolated chunk of Python.
950
+ await humanType(
951
+ page,
952
+ "The whole recipe fits in eight lines of PyTorch:",
953
+ { speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
954
+ );
955
+ await page.keyboard.press("Enter");
956
+ await pause(80, 160);
957
+
958
+ const code =
959
+ "import torch\n" +
960
+ "import torch.nn.functional as F\n" +
961
+ "\n" +
962
+ "def attention(Q, K, V):\n" +
963
+ " d_k = K.size(-1)\n" +
964
+ " scores = Q @ K.transpose(-2, -1) / d_k ** 0.5\n" +
965
+ " weights = F.softmax(scores, dim=-1)\n" +
966
+ " return weights @ V";
967
+ await insertCodeBlock(page, "python", code, speed);
968
+
969
+ // Closing reference (lands right after the code block).
970
+ await humanType(
971
+ page,
972
+ "Introduced by Vaswani et al. (2017), this single block of math powers every modern LLM.",
973
+ { speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
974
+ );
975
+
976
+ console.log("[carol] scenario complete");
977
+ }
978
+
979
+ // ----------------------------------------------------------------------------
980
+ // Main
981
+ // ----------------------------------------------------------------------------
982
+
983
+ async function run() {
984
+ const args = parseArgs(process.argv.slice(2));
985
+ console.log(
986
+ `[trio] target=${args.url} speed=${args.speed}x allVisible=${args.allVisible}`
987
+ );
988
+
989
+ // Nuke any orphaned Chromium left over from a previous run (e.g. demo
990
+ // killed with Ctrl+C before it could close its browsers). Without this
991
+ // we get stale Yjs connections / extra windows cluttering the screen.
992
+ killLeftoverChromiums();
993
+
994
+ const alicePos = args.allVisible ? { x: 0, y: 0 } : undefined;
995
+ const bobPos = args.allVisible ? { x: 1300, y: 0 } : undefined;
996
+ const carolPos = args.allVisible ? { x: 650, y: 830 } : undefined;
997
+
998
+ const alice = await launchPersona(args.url, {
999
+ persona: PERSONAS.alice,
1000
+ headless: false,
1001
+ windowPosition: alicePos,
1002
+ fullscreen: !args.allVisible,
1003
+ });
1004
+ console.log("[trio] alice up");
1005
+
1006
+ // Wipe the shared Yjs doc once on Alice's session BEFORE Bob/Carol join,
1007
+ // so they connect to a clean slate (no leftover banner / body / title
1008
+ // from a previous demo run). Idempotent: a no-op on a fresh server.
1009
+ console.log("[trio] resetting shared document");
1010
+ await resetDoc(alice.page);
1011
+
1012
+ const bob = await launchPersona(args.url, {
1013
+ persona: PERSONAS.bob,
1014
+ headless: !args.allVisible,
1015
+ windowPosition: bobPos,
1016
+ });
1017
+ console.log("[trio] bob up");
1018
+
1019
+ const carol = await launchPersona(args.url, {
1020
+ persona: PERSONAS.carol,
1021
+ headless: !args.allVisible,
1022
+ windowPosition: carolPos,
1023
+ });
1024
+ console.log("[trio] carol up");
1025
+
1026
+ // T0 marks the start of the actual demo content (after the browsers
1027
+ // and Yjs reset). Useful to measure the perceived "video length"
1028
+ // independently of Playwright cold-start time.
1029
+ const T0 = Date.now();
1030
+ const stamp = (tag: string) =>
1031
+ console.log(`[trio] +${((Date.now() - T0) / 1000).toFixed(2)}s ${tag}`);
1032
+ stamp("demo phase start");
1033
+
1034
+ await Promise.all([
1035
+ runAlice(alice.page, args.speed)
1036
+ .then(() => stamp("alice done"))
1037
+ .catch((err) => console.error("[alice] error:", err)),
1038
+ runBob(bob.page, args.speed)
1039
+ .then(() => stamp("bob done"))
1040
+ .catch((err) => console.error("[bob] error:", err)),
1041
+ runCarol(carol.page, args.speed)
1042
+ .then(() => stamp("carol done"))
1043
+ .catch((err) => console.error("[carol] error:", err)),
1044
+ ]);
1045
+ stamp("all personas finished");
1046
+
1047
+ console.log("[trio] all personas finished. Browsers stay open. Ctrl+C to quit.");
1048
+ await new Promise(() => {});
1049
+ }
1050
+
1051
+ run().catch((err) => {
1052
+ console.error("[trio] fatal:", err);
1053
+ process.exit(1);
1054
+ });
frontend/src/editor/load-demo.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Editor } from "@tiptap/core";
2
+ import type * as Y from "yjs";
3
+ import type { FrontmatterStore } from "./frontmatter/frontmatter-store";
4
+ import type { EmbedStore } from "./embeds/embed-store";
5
+ import { seedFrontmatter } from "./frontmatter/seed-frontmatter";
6
+ import { DEFAULT_CONTENT, SEED_CITATIONS } from "./default-content";
7
+ import {
8
+ SEED_BANNER_HTML,
9
+ SEED_SCALING_CHART_HTML,
10
+ SEED_MEMORY_BAR_HTML,
11
+ SEED_CONFUSION_MATRIX_HTML,
12
+ } from "./seed-embeds";
13
+
14
+ /**
15
+ * Replace the current document with the demo content.
16
+ * Called explicitly by the user via /demo slash command or a UI button.
17
+ */
18
+ export function loadDemoContent(
19
+ editor: Editor,
20
+ frontmatterStore: FrontmatterStore,
21
+ citationsMap: Y.Map<any>,
22
+ settingsMap: Y.Map<any>,
23
+ embedStore?: EmbedStore | null,
24
+ ) {
25
+ editor.commands.setContent(DEFAULT_CONTENT);
26
+ seedFrontmatter(frontmatterStore, true);
27
+
28
+ for (const [key, entry] of Object.entries(SEED_CITATIONS)) {
29
+ citationsMap.set(key, entry);
30
+ }
31
+ settingsMap.set("citationStyle", "apa");
32
+
33
+ if (embedStore) {
34
+ embedStore.set("banner.html", SEED_BANNER_HTML);
35
+ embedStore.set("d3-scaling-chart.html", SEED_SCALING_CHART_HTML);
36
+ embedStore.set("d3-memory-bar.html", SEED_MEMORY_BAR_HTML);
37
+ embedStore.set("d3-confusion-matrix.html", SEED_CONFUSION_MATRIX_HTML);
38
+ }
39
+ }
frontend/src/editor/seed-embed-confusion-matrix.ts ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Side-by-side confusion matrices: baseline vs improved.
3
+ * Adapted from research-article-template/app/src/content/embeds/d3-confusion-matrix.html
4
+ * Self-contained with inline data.
5
+ */
6
+ export const SEED_CONFUSION_MATRIX_HTML = `<div class="d3-confusion-matrix"></div>
7
+ <style>
8
+ .d3-confusion-matrix { position: relative; }
9
+ .d3-confusion-matrix .panels { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 4px; }
10
+ .d3-confusion-matrix .panel { flex: 1 1 320px; min-width: 280px; }
11
+ .d3-confusion-matrix .panel__title { color: var(--text-color); font-size: 12px; line-height: 1.35; margin: 0 0 6px 0; font-weight: 600; }
12
+ .d3-confusion-matrix .axis-label { fill: var(--text-color); font-size: 11px; font-weight: 700; }
13
+ .d3-confusion-matrix .cell-text { fill: var(--muted-color); font-size: 11px; pointer-events: none; }
14
+ .d3-confusion-matrix .chart-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; }
15
+ </style>
16
+ <script>
17
+ (() => {
18
+ const ensureD3 = (cb) => {
19
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
20
+ let s = document.getElementById('d3-cdn-script');
21
+ if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
22
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
23
+ s.addEventListener('load', onReady, { once: true });
24
+ if (window.d3) onReady();
25
+ };
26
+
27
+ const bootstrap = () => {
28
+ const scriptEl = document.currentScript;
29
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
30
+ if (!(container && container.classList && container.classList.contains('d3-confusion-matrix'))) {
31
+ const cs = Array.from(document.querySelectorAll('.d3-confusion-matrix')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
32
+ container = cs[cs.length - 1] || null;
33
+ }
34
+ if (!container) return;
35
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
36
+
37
+ container.style.position = container.style.position || 'relative';
38
+ let tip = container.querySelector('.d3-tooltip'); let tipInner;
39
+ if (!tip) {
40
+ tip = document.createElement('div'); tip.className = 'd3-tooltip';
41
+ Object.assign(tip.style, { position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none', padding: '8px 10px', borderRadius: '8px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)', background: 'var(--surface-bg)', color: 'var(--text-color)', boxShadow: '0 4px 24px rgba(0,0,0,.18)', opacity: '0', transition: 'opacity .12s ease' });
42
+ tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign = 'left'; tip.appendChild(tipInner); container.appendChild(tip);
43
+ } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
44
+
45
+ const panels = document.createElement('div'); panels.className = 'panels';
46
+ const panelA = document.createElement('div'); panelA.className = 'panel';
47
+ const titleA = document.createElement('div'); titleA.className = 'panel__title'; titleA.textContent = 'Baseline (row-normalized %)';
48
+ panelA.appendChild(titleA);
49
+ const mountA = document.createElement('div'); panelA.appendChild(mountA);
50
+ const panelB = document.createElement('div'); panelB.className = 'panel';
51
+ const titleB = document.createElement('div'); titleB.className = 'panel__title'; titleB.textContent = 'Delta (Improved - Baseline, pp)';
52
+ panelB.appendChild(titleB);
53
+ const mountB = document.createElement('div'); panelB.appendChild(mountB);
54
+ panels.appendChild(panelA); panels.appendChild(panelB);
55
+ container.appendChild(panels);
56
+
57
+ const cardA = document.createElement('div'); cardA.className = 'chart-card'; mountA.appendChild(cardA);
58
+ const svgA = d3.select(cardA).append('svg').attr('width', '100%').style('display', 'block');
59
+ const gRootA = svgA.append('g'); const gCellsA = gRootA.append('g'); const gAxesA = gRootA.append('g');
60
+ const cardB = document.createElement('div'); cardB.className = 'chart-card'; mountB.appendChild(cardB);
61
+ const svgB = d3.select(cardB).append('svg').attr('width', '100%').style('display', 'block');
62
+ const gRootB = svgB.append('g'); const gCellsB = gRootB.append('g'); const gAxesB = gRootB.append('g');
63
+
64
+ const classes = ['0','1','2','3','4','5','6','7','8','9'];
65
+ const matrixA = [[90,2,1,0,0,0,1,0,5,1],[3,85,5,1,0,1,2,1,1,1],[1,6,70,10,4,4,1,1,1,2],[0,1,8,65,10,10,2,1,1,2],[0,0,2,6,83,3,1,1,3,1],[0,1,2,12,4,70,5,2,2,2],[1,2,1,0,1,2,88,1,3,1],[0,1,1,1,1,1,2,90,1,2],[6,2,2,4,6,3,3,2,70,2],[1,1,1,1,2,1,1,2,1,89]];
66
+ const matrixB = [[94,1,0,0,0,0,1,0,3,1],[2,90,3,1,0,0,1,1,1,1],[1,4,78,7,3,3,1,1,1,1],[0,1,5,74,7,8,1,1,1,2],[0,0,1,4,88,2,1,1,2,1],[0,1,1,9,3,78,3,1,2,2],[1,1,1,0,1,1,91,1,2,1],[0,1,1,1,1,1,1,92,1,1],[4,1,1,3,4,2,2,2,79,2],[1,1,1,1,2,1,1,1,1,90]];
67
+
68
+ const getSequentialColors = (count) => {
69
+ try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') { return window.ColorPalettes.getColors('sequential', count); } } catch (_) {}
70
+ const arr = []; for (let i = 0; i < count; i++) arr.push('var(--primary-color)'); return arr;
71
+ };
72
+ const palette = getSequentialColors(13);
73
+ const getDivergingColors = (count) => {
74
+ try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') { return window.ColorPalettes.getColors('diverging', count); } } catch (_) {}
75
+ const arr = []; for (let i = 0; i < count; i++) { const t = i / (count - 1); arr.push('color-mix(in srgb, #D64545 ' + Math.round((1-t)*100) + '%, #3A7BD5 ' + Math.round(t*100) + '%)'); } return arr;
76
+ };
77
+
78
+ const margin = { top: 36, right: 24, bottom: 26, left: 56 };
79
+ let width = 800, height = 480;
80
+
81
+ function chooseTextOnBg(bgCss) {
82
+ try {
83
+ var m = String(bgCss||'').match(/rgba?\\(([^)]+)\\)/);
84
+ if (!m) return '#0e1116';
85
+ var parts = m[1].split(',').map(function(s){ return parseFloat(s.trim()); });
86
+ var srgb = [parts[0],parts[1],parts[2]].map(function(v){ return Math.max(0,Math.min(255,v))/255; });
87
+ var linear = srgb.map(function(c){ return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4); });
88
+ var L = 0.2126*linear[0]+0.7152*linear[1]+0.0722*linear[2];
89
+ return L < 0.5 ? '#ffffff' : '#0e1116';
90
+ } catch(_){ return '#0e1116'; }
91
+ }
92
+
93
+ function updateSize() {
94
+ width = container.clientWidth || 800;
95
+ var gap = 16; var minPanel = 320;
96
+ var nCols = (width >= (minPanel * 2 + gap)) ? 2 : 1;
97
+ var panelWidth = nCols === 2 ? Math.max(minPanel, Math.floor((width - gap) / 2)) : Math.max(minPanel, width);
98
+ var base = Math.max(minPanel, Math.round(panelWidth * 0.92));
99
+ height = base;
100
+ svgA.attr('viewBox', '0 0 ' + panelWidth + ' ' + height).attr('preserveAspectRatio', 'xMidYMid meet').style('width', '100%').style('height', 'auto');
101
+ svgB.attr('viewBox', '0 0 ' + panelWidth + ' ' + height).attr('preserveAspectRatio', 'xMidYMid meet').style('width', '100%').style('height', 'auto');
102
+ gRootA.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
103
+ gRootB.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
104
+ var innerWidth = panelWidth - margin.left - margin.right;
105
+ var innerHeight = height - margin.top - margin.bottom;
106
+ return { innerWidth: innerWidth, innerHeight: innerHeight };
107
+ }
108
+
109
+ function computeValues(matrix) {
110
+ var n = classes.length;
111
+ var totalsByRow = matrix.map(function(row){ return row.reduce(function(a,b){return a+b;},0); });
112
+ var flat = []; var minV = Infinity; var maxV = -Infinity;
113
+ for (var r = 0; r < n; r++) { for (var c = 0; c < n; c++) { var count = matrix[r][c]; var value = totalsByRow[r] ? count / totalsByRow[r] : 0; if (value < minV) minV = value; if (value > maxV) maxV = value; flat.push({ r: r, c: c, count: count, value: value }); } }
114
+ return { data: flat, minV: minV, maxV: maxV };
115
+ }
116
+
117
+ function getColorScale(values, minV, maxV) {
118
+ var hasPalette = palette.length > 0;
119
+ if (hasPalette && window.ColorPalettes) { var scale = d3.scaleQuantile().domain(values).range(palette); return function(v){ return scale(v); }; }
120
+ var norm = d3.scaleLinear().domain([minV, maxV]).range([0.08, 0.9]).clamp(true);
121
+ return function(v){ return 'color-mix(in oklab, var(--primary-color) ' + Math.round(norm(v)*100) + '%, var(--surface-bg))'; };
122
+ }
123
+
124
+ function render() {
125
+ var sizes = updateSize();
126
+ var n = classes.length;
127
+ var gridSize = Math.min(sizes.innerWidth, sizes.innerHeight);
128
+ var cellSize = gridSize / n;
129
+ var x = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0.06);
130
+ var yScale = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0.06);
131
+
132
+ var dataA = computeValues(matrixA);
133
+ var colorA = getColorScale(dataA.data.map(function(d){return d.value;}), dataA.minV, dataA.maxV);
134
+
135
+ gCellsA.selectAll('*').remove();
136
+ dataA.data.forEach(function(d) {
137
+ gCellsA.append('rect').attr('x', x(d.c)).attr('y', yScale(d.r)).attr('width', x.bandwidth()).attr('height', yScale.bandwidth()).attr('rx', 2).attr('fill', colorA(d.value))
138
+ .on('mousemove', function(event) { var p = d3.pointer(event, container); tipInner.innerHTML = '<strong>' + classes[d.r] + '</strong> &rarr; <strong>' + classes[d.c] + '</strong><br/>' + (d.value*100).toFixed(1) + '% (' + d.count + ')'; tip.style.transform = 'translate(' + (p[0]+10) + 'px, ' + (p[1]+10) + 'px)'; tip.style.opacity = '1'; })
139
+ .on('mouseleave', function() { tip.style.opacity = '0'; });
140
+ gCellsA.append('text').attr('class', 'cell-text').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle').attr('x', x(d.c) + x.bandwidth()/2).attr('y', yScale(d.r) + yScale.bandwidth()/2).text(Math.round(d.value*100));
141
+ });
142
+
143
+ gAxesA.selectAll('*').remove();
144
+ classes.forEach(function(c, i) {
145
+ gAxesA.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('x', x(i) + x.bandwidth()/2).attr('y', -8).text(c);
146
+ gAxesA.append('text').attr('class', 'axis-label').attr('text-anchor', 'end').attr('x', -8).attr('y', yScale(i) + yScale.bandwidth()/2).attr('dominant-baseline', 'middle').text(c);
147
+ });
148
+ gAxesA.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('x', gridSize/2).attr('y', sizes.innerHeight + 20).text('Predicted');
149
+ gAxesA.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('transform', 'translate(' + (-40) + ',' + (gridSize/2) + ') rotate(-90)').text('Actual');
150
+
151
+ var dataB = computeValues(matrixB);
152
+ var diverging = getDivergingColors(13);
153
+ var mapA = new Map(dataA.data.map(function(d){ return [d.r + '-' + d.c, d.value]; }));
154
+ var delta = dataB.data.map(function(d){ return { r: d.r, c: d.c, count: d.count, value: d.value - (mapA.get(d.r + '-' + d.c) || 0) }; });
155
+ var maxAbsDelta = Math.max(0.01, d3.max(delta, function(d){ return Math.abs(d.value); }) || 0.01);
156
+ var colorB = d3.scaleQuantize().domain([-maxAbsDelta, maxAbsDelta]).range(diverging);
157
+
158
+ gCellsB.selectAll('*').remove();
159
+ delta.forEach(function(d, i) {
160
+ gCellsB.append('rect').attr('x', x(d.c)).attr('y', yScale(d.r)).attr('width', x.bandwidth()).attr('height', yScale.bandwidth()).attr('rx', 2).attr('fill', colorB(d.value))
161
+ .on('mousemove', function(event) { var p = d3.pointer(event, container); var a = dataA.data.find(function(x){ return x.r===d.r && x.c===d.c; }); var b = dataB.data.find(function(x){ return x.r===d.r && x.c===d.c; }); var dv = ((b?b.value:0)-(a?a.value:0))*100; tipInner.innerHTML = '<strong>' + classes[d.r] + '</strong> &rarr; <strong>' + classes[d.c] + '</strong><br/>baseline ' + (a?a.value*100:0).toFixed(1) + '%<br/>improved ' + (b?b.value*100:0).toFixed(1) + '%<br/>delta ' + dv.toFixed(1) + ' pp'; tip.style.transform = 'translate(' + (p[0]+10) + 'px, ' + (p[1]+10) + 'px)'; tip.style.opacity = '1'; })
162
+ .on('mouseleave', function() { tip.style.opacity = '0'; });
163
+ gCellsB.append('text').attr('class', 'cell-text').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle').attr('x', x(d.c) + x.bandwidth()/2).attr('y', yScale(d.r) + yScale.bandwidth()/2).text(Math.round(d.value*100));
164
+ });
165
+
166
+ gAxesB.selectAll('*').remove();
167
+ classes.forEach(function(c, i) {
168
+ gAxesB.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('x', x(i) + x.bandwidth()/2).attr('y', -8).text(c);
169
+ gAxesB.append('text').attr('class', 'axis-label').attr('text-anchor', 'end').attr('x', -8).attr('y', yScale(i) + yScale.bandwidth()/2).attr('dominant-baseline', 'middle').text(c);
170
+ });
171
+ gAxesB.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('x', gridSize/2).attr('y', sizes.innerHeight + 20).text('Predicted');
172
+ gAxesB.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('transform', 'translate(' + (-40) + ',' + (gridSize/2) + ') rotate(-90)').text('Actual');
173
+ }
174
+
175
+ render();
176
+ if (window.ResizeObserver) { new ResizeObserver(function(){ render(); }).observe(container); }
177
+ };
178
+
179
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function(){ ensureD3(bootstrap); }, { once: true }); } else { ensureD3(bootstrap); }
180
+ })();
181
+ <\/script>`;
frontend/src/editor/seed-embed-memory-bar.ts ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Stacked bar chart: GPU memory breakdown by component.
3
+ * Adapted from research-article-template/app/src/content/embeds/d3-bar.html
4
+ * Self-contained with inline data (no CSV dependency).
5
+ */
6
+ export const SEED_MEMORY_BAR_HTML = `<div class="d3-bar"></div>
7
+ <style>
8
+ .d3-bar .controls {
9
+ margin-top: 0;
10
+ display: flex;
11
+ gap: 16px;
12
+ align-items: center;
13
+ justify-content: flex-end;
14
+ flex-wrap: wrap;
15
+ }
16
+ .d3-bar .controls .control-group {
17
+ display: flex;
18
+ flex-direction: column;
19
+ align-items: flex-start;
20
+ gap: 6px;
21
+ }
22
+ .d3-bar .controls label {
23
+ font-size: 12px;
24
+ color: var(--text-color);
25
+ font-weight: 700;
26
+ }
27
+ .d3-bar .controls select {
28
+ font-size: 12px;
29
+ padding: 8px 28px 8px 10px;
30
+ border: 1px solid var(--border-color);
31
+ border-radius: 8px;
32
+ background-color: var(--surface-bg);
33
+ color: var(--text-color);
34
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
35
+ background-repeat: no-repeat;
36
+ background-position: right 8px center;
37
+ background-size: 12px;
38
+ -webkit-appearance: none;
39
+ -moz-appearance: none;
40
+ appearance: none;
41
+ cursor: pointer;
42
+ transition: border-color .15s ease, box-shadow .15s ease;
43
+ }
44
+ [data-theme="dark"] .d3-bar .controls select {
45
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
46
+ }
47
+ .d3-bar .controls select:hover { border-color: var(--primary-color); }
48
+ .d3-bar .controls select:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(232, 137, 171, .25); outline: none; }
49
+ .d3-bar .chart-header {
50
+ display: flex;
51
+ align-items: flex-start;
52
+ justify-content: flex-start;
53
+ gap: 20px;
54
+ margin: 8px 0 0 0;
55
+ flex-wrap: wrap;
56
+ padding-left: 8px;
57
+ padding-right: 8px;
58
+ }
59
+ .d3-bar .legend-bottom {
60
+ display: flex;
61
+ flex-direction: column;
62
+ align-items: flex-start;
63
+ gap: 6px;
64
+ font-size: 12px;
65
+ color: var(--text-color);
66
+ }
67
+ .d3-bar .legend-bottom .legend-title { font-size: 12px; font-weight: 700; color: var(--text-color); }
68
+ .d3-bar .legend-bottom .items { display: flex; flex-wrap: wrap; gap: 8px 14px; }
69
+ .d3-bar .legend-bottom .item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
70
+ .d3-bar .legend-bottom .swatch { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-color); display: inline-block; }
71
+ .d3-bar.hovering .legend-bottom .item.ghost { opacity: .35; }
72
+ .d3-bar.hovering .bars path.ghost { opacity: .35; }
73
+ .d3-bar .axis-label { fill: var(--text-color); font-size: 12px; font-weight: 700; }
74
+ .d3-bar .axes path, .d3-bar .axes line { stroke: var(--axis-color); }
75
+ .d3-bar .axes text { fill: var(--tick-color); }
76
+ .d3-bar .grid line { stroke: var(--grid-color); }
77
+ .d3-bar .d3-tooltip { z-index: var(--z-tooltip); backdrop-filter: saturate(1.12) blur(8px); }
78
+ .d3-bar .bars path.bar { transition: opacity .12s ease, stroke .12s ease, stroke-width .12s ease; }
79
+ .d3-bar .bars path.bar.highlight { stroke: none; stroke-width: 0; }
80
+ .d3-bar.hovering .bars path.ghost { opacity: .25; }
81
+ .d3-bar .d3-tooltip .swatch { width: 12px; height: 12px; border-radius: 3px; border: 1px solid var(--border-color); display: inline-block; margin-right: 6px; vertical-align: -2px; }
82
+ .d3-bar .chart-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; }
83
+ .d3-bar .controls { justify-content: flex-start; min-width: 320px; }
84
+ .d3-bar .controls .control-group { min-width: 150px; }
85
+ .d3-bar .controls select { font-size: 13px; min-width: 160px; }
86
+ </style>
87
+ <script>
88
+ (() => {
89
+ const ensureD3 = (cb) => {
90
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
91
+ let s = document.getElementById('d3-cdn-script');
92
+ if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
93
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
94
+ s.addEventListener('load', onReady, { once: true });
95
+ if (window.d3) onReady();
96
+ };
97
+
98
+ const bootstrap = () => {
99
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
100
+ const container = (mount && mount.querySelector && mount.querySelector('.d3-bar')) || document.querySelector('.d3-bar');
101
+ if (!container) return;
102
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
103
+
104
+ const seqLabels = ["1024", "2048", "4096", "8192"];
105
+ const seqScale = [1, 2, 4, 8];
106
+ const componentKeys = ['parameters', 'gradients', 'optimizer', 'activations'];
107
+ const modelSizes = ["1B", "3B", "8B", "70B", "405B"];
108
+ const paramsMem = { "1B": 4.0, "3B": 13.3, "8B": 26.0, "70B": 244.0, "405B": 1520.0 };
109
+ const actCoeff = { "1B": 3.6, "3B": 9.3, "8B": 46.2, "70B": 145.7, "405B": 1519.9 };
110
+ const recomputeModes = ["none", "selective", "full"];
111
+
112
+ const activationsCurve = (sizeKey, mode) => {
113
+ const coeff = actCoeff[sizeKey];
114
+ let arr = seqScale.map((v) => coeff * (v * v));
115
+ if (mode === 'selective') arr = arr.map((v) => v * 0.25);
116
+ else if (mode === 'full') arr = arr.map((v) => v * (1 / 16));
117
+ return arr;
118
+ };
119
+ const stackFor = (sizeKey, mode) => {
120
+ const p = seqScale.map(() => paramsMem[sizeKey]);
121
+ const g = seqScale.map(() => paramsMem[sizeKey]);
122
+ const o = seqScale.map(() => 2 * paramsMem[sizeKey]);
123
+ const a = activationsCurve(sizeKey, mode);
124
+ return { parameters: p, gradients: g, optimizer: o, activations: a };
125
+ };
126
+
127
+ const Y = {};
128
+ recomputeModes.forEach((m) => { Y[m] = {}; modelSizes.forEach((s) => { Y[m][s] = stackFor(s, m); }); });
129
+
130
+ const controls = document.createElement('div'); controls.className = 'controls';
131
+ const groupSize = document.createElement('div'); groupSize.className = 'control-group';
132
+ const labelSize = document.createElement('label'); labelSize.textContent = 'Model Size';
133
+ const selSize = document.createElement('select'); modelSizes.forEach((s) => { const o = document.createElement('option'); o.value = s; o.textContent = s; selSize.appendChild(o); });
134
+ groupSize.appendChild(labelSize); groupSize.appendChild(selSize);
135
+ const groupRecomp = document.createElement('div'); groupRecomp.className = 'control-group';
136
+ const labelRecomp = document.createElement('label'); labelRecomp.textContent = 'Recomputation';
137
+ const selRecomp = document.createElement('select'); recomputeModes.forEach((m) => { const o = document.createElement('option'); o.value = m; o.textContent = m; selRecomp.appendChild(o); });
138
+ groupRecomp.appendChild(labelRecomp); groupRecomp.appendChild(selRecomp);
139
+
140
+ const header = document.createElement('div'); header.className = 'chart-header';
141
+ const legendBottom = document.createElement('div'); legendBottom.className = 'legend-bottom';
142
+ const legendTitle = document.createElement('div'); legendTitle.className = 'legend-title'; legendTitle.textContent = 'Legend';
143
+ const legendItems = document.createElement('div'); legendItems.className = 'items';
144
+ legendBottom.appendChild(legendTitle); legendBottom.appendChild(legendItems);
145
+ header.appendChild(legendBottom); header.appendChild(controls);
146
+
147
+ const card = document.createElement('div'); card.className = 'chart-card'; container.appendChild(card);
148
+ container.appendChild(header);
149
+ const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block');
150
+ const gRoot = svg.append('g');
151
+ const gGrid = gRoot.append('g').attr('class', 'grid');
152
+ const gAxes = gRoot.append('g').attr('class', 'axes');
153
+ const gBars = gRoot.append('g').attr('class', 'bars');
154
+
155
+ container.style.position = container.style.position || 'relative';
156
+ let tip = container.querySelector('.d3-tooltip'); let tipInner;
157
+ if (!tip) { tip = document.createElement('div'); tip.className = 'd3-tooltip'; Object.assign(tip.style, { position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none', padding: '8px 10px', borderRadius: '8px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)', background: 'var(--surface-bg)', color: 'var(--text-color)', boxShadow: '0 4px 24px rgba(0,0,0,.18)', opacity: '0', transition: 'opacity .12s ease' }); tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign = 'left'; tip.appendChild(tipInner); container.appendChild(tip); } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
158
+
159
+ let currentSize = modelSizes[0];
160
+ let currentMode = 'selective';
161
+ selRecomp.value = currentMode;
162
+
163
+ let width = 800, height = 360;
164
+ const margin = { top: 16, right: 28, bottom: 56, left: 64 };
165
+ const x0 = d3.scaleBand().paddingInner(0.25).paddingOuter(0.1);
166
+ const y = d3.scaleLinear();
167
+ function getCategoricalColors(count) {
168
+ try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') { return window.ColorPalettes.getColors('categorical', count); } } catch (_) {}
169
+ const tableau = (window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948'];
170
+ return tableau.slice(0, count);
171
+ }
172
+ const palette = getCategoricalColors(componentKeys.length);
173
+ const colorMap = new Map(componentKeys.map((k, i) => [k, palette[i]]));
174
+ const colorOf = (key) => colorMap.get(key) || 'var(--primary-color)';
175
+
176
+ function yMax(sizeKey, mode) {
177
+ const s = Y[mode][sizeKey]; let max = 0;
178
+ for (let i = 0; i < seqLabels.length; i++) { const sum = s.parameters[i] + s.gradients[i] + s.optimizer[i] + s.activations[i]; if (sum > max) max = sum; }
179
+ return max * 1.05;
180
+ }
181
+
182
+ function renderLegend() {
183
+ legendItems.innerHTML = componentKeys.map((key, i) => '<span class="item" data-key="' + key + '"><span class="swatch" style="background:' + palette[i] + '"></span><span>' + key + '</span></span>').join('');
184
+ legendItems.querySelectorAll('.item').forEach((el) => {
185
+ el.addEventListener('mouseenter', () => { const k = el.getAttribute('data-key'); if (!k) return; container.classList.add('hovering'); gBars.selectAll('path.bar').classed('ghost', d => d && d.key !== k); legendItems.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-key') !== k)); });
186
+ el.addEventListener('mouseleave', () => { container.classList.remove('hovering'); gBars.selectAll('path.bar').classed('ghost', false); legendItems.querySelectorAll('.item').forEach(it => it.classList.remove('ghost')); });
187
+ });
188
+ }
189
+
190
+ function updateScales() {
191
+ width = container.clientWidth || 800; height = Math.max(260, Math.round(width / 3)); svg.attr('width', width).attr('height', height);
192
+ const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
193
+ x0.domain(seqLabels).range([0, innerWidth]);
194
+ y.domain([0, yMax(currentSize, currentMode)]).range([innerHeight, 0]).nice();
195
+ gGrid.selectAll('*').remove();
196
+ gGrid.selectAll('line').data(y.ticks(6)).join('line').attr('x1', 0).attr('x2', innerWidth).attr('y1', (d) => y(d)).attr('y2', (d) => y(d)).attr('stroke', 'var(--grid-color)').attr('stroke-width', 1).attr('shape-rendering', 'crispEdges');
197
+ gAxes.selectAll('*').remove();
198
+ gAxes.append('g').attr('transform', 'translate(0,' + innerHeight + ')').call(d3.axisBottom(x0)).call((g) => { g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size', '12px'); });
199
+ gAxes.append('g').call(d3.axisLeft(y).ticks(6).tickFormat(d3.format('~f'))).call((g) => { g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size', '12px'); });
200
+ gAxes.append('text').attr('class', 'axis-label').attr('x', innerWidth / 2).attr('y', innerHeight + 44).attr('text-anchor', 'middle').text('Sequence Length');
201
+ gAxes.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('transform', 'translate(' + (-52) + ',' + (innerHeight / 2) + ') rotate(-90)').text('Memory (GB)');
202
+ renderLegend();
203
+ return { innerWidth: innerWidth, innerHeight: innerHeight };
204
+ }
205
+
206
+ function drawBars() {
207
+ const stacks = Y[currentMode][currentSize];
208
+ const series = componentKeys.map((key, i) => ({ key: key, color: palette[i], values: stacks[key] }));
209
+ const stacked = seqLabels.map((label, i) => {
210
+ let acc = 0; const items = [];
211
+ series.forEach((s, idx) => { const y0 = acc; const y1 = acc + s.values[i]; items.push({ key: s.key, color: s.color, i: i, y0: y0, y1: y1, xLabel: label, value: s.values[i], isBottom: idx === 0, isTop: idx === series.length - 1 }); acc = y1; });
212
+ const total = acc; items.forEach(it => { it.total = total; });
213
+ return { label: label, items: items };
214
+ });
215
+ const sizes = updateScales();
216
+ const bandWidth = x0.bandwidth();
217
+ const rCorner = 4;
218
+ const roundedPath = (x, yTop, w, h, isTop, isBottom) => {
219
+ const r = Math.min(rCorner, Math.max(0, Math.min(w, h) / 2));
220
+ const rTL = isTop ? r : 0, rTR = isTop ? r : 0, rBR = isBottom ? r : 0, rBL = isBottom ? r : 0;
221
+ const x0 = x, y0 = yTop, x1 = x + w, y1 = yTop + h;
222
+ return 'M' + (x0 + rTL) + ',' + y0 + 'H' + (x1 - rTR) + (rTR ? 'Q' + x1 + ',' + y0 + ' ' + x1 + ',' + (y0 + rTR) : 'V' + y0) + 'V' + (y1 - rBR) + (rBR ? 'Q' + x1 + ',' + y1 + ' ' + (x1 - rBR) + ',' + y1 : 'H' + x1) + 'H' + (x0 + rBL) + (rBL ? 'Q' + x0 + ',' + y1 + ' ' + x0 + ',' + (y1 - rBL) : 'V' + y1) + 'V' + (y0 + rTL) + (rTL ? 'Q' + x0 + ',' + y0 + ' ' + (x0 + rTL) + ',' + y0 : 'H' + x0) + 'Z';
223
+ };
224
+ const groups = gBars.selectAll('g.bar-group').data(stacked, d => d.label);
225
+ const groupsEnter = groups.enter().append('g').attr('class', 'bar-group');
226
+ groupsEnter.merge(groups).attr('transform', (d) => 'translate(' + x0(d.label) + ',0)');
227
+ groups.exit().remove();
228
+ const bars = groupsEnter.merge(groups).selectAll('path.bar').data(d => d.items, d => d.key);
229
+ bars.enter().append('path').attr('class', 'bar')
230
+ .attr('d', (d) => roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
231
+ .attr('fill', (d) => d.color)
232
+ .on('mouseenter', function (ev, d) { container.classList.add('hovering'); gBars.selectAll('path.bar').classed('ghost', (dd) => !(dd && dd.key === d.key)); var pct = d.total > 0 ? (d.value / d.total * 100) : 0; tipInner.innerHTML = '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;"><span class="swatch" style="background:' + d.color + '"></span><strong>' + d.key + '</strong></div><div><strong>Seq</strong> ' + d.xLabel + '</div><div><strong>Mem</strong> ' + d.value.toFixed(1) + ' GB <span style="opacity:.7">(' + pct.toFixed(0) + '%)</span></div><div style="opacity:.7"><strong>Total</strong> ' + d.total.toFixed(1) + ' GB</div>'; tip.style.opacity = '1'; legendItems.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-key') !== d.key)); })
233
+ .on('mousemove', function (ev) { var m = d3.pointer(ev, container); var maxX = (container.clientWidth || 0) - (tip.offsetWidth + 6); var maxY = (container.clientHeight || 0) - (tip.offsetHeight + 6); tip.style.transform = 'translate(' + Math.round(Math.max(0, Math.min(m[0] + 12, maxX))) + 'px, ' + Math.round(Math.max(0, Math.min(m[1] + 12, maxY))) + 'px)'; })
234
+ .on('mouseleave', function () { tip.style.opacity = '0'; tip.style.transform = 'translate(-9999px, -9999px)'; container.classList.remove('hovering'); gBars.selectAll('path.bar').classed('ghost', false); legendItems.querySelectorAll('.item').forEach(it => { it.classList.remove('hovered'); it.classList.remove('ghost'); }); })
235
+ .merge(bars).transition().duration(200)
236
+ .attr('d', (d) => roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
237
+ .attr('fill', (d) => d.color);
238
+ bars.exit().remove();
239
+ }
240
+
241
+ drawBars();
242
+ controls.appendChild(groupSize); controls.appendChild(groupRecomp);
243
+ selSize.addEventListener('change', (e) => { currentSize = e.target.value; drawBars(); });
244
+ selRecomp.addEventListener('change', (e) => { currentMode = e.target.value; drawBars(); });
245
+ if (window.ResizeObserver) { new ResizeObserver(() => drawBars()).observe(container); }
246
+ };
247
+
248
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
249
+ })();
250
+ <\/script>`;
frontend/src/editor/seed-embeds.ts ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Seed embed HTML for the demo content.
3
+ *
4
+ * banner.html: self-contained scatter plot with generated data (no CSV).
5
+ * d3-scaling-chart.html: simple line chart for the body embed.
6
+ * d3-memory-bar.html: stacked bar chart for memory analysis.
7
+ * d3-confusion-matrix.html: side-by-side confusion matrices.
8
+ */
9
+
10
+ export const SEED_BANNER_HTML = `<div class="d3-scatter"></div>
11
+ <style>
12
+ .d3-scatter svg { display: block; }
13
+ .d3-scatter .centroid text { pointer-events: none; }
14
+ </style>
15
+ <script>
16
+ (() => {
17
+ const ensureD3 = (cb) => {
18
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
19
+ let s = document.getElementById('d3-cdn-script');
20
+ if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
21
+ s.addEventListener('load', () => { if (window.d3) cb(); }, { once: true });
22
+ if (window.d3) cb();
23
+ };
24
+
25
+ const bootstrap = () => {
26
+ const scriptEl = document.currentScript;
27
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
28
+ if (!(container && container.classList.contains('d3-scatter'))) {
29
+ const cs = [...document.querySelectorAll('.d3-scatter')].filter(el => el.dataset.mounted !== 'true');
30
+ container = cs[cs.length - 1] || null;
31
+ }
32
+ if (!container || container.dataset.mounted === 'true') return;
33
+ container.dataset.mounted = 'true';
34
+ container.style.position = 'relative';
35
+
36
+ const categories = ['Language', 'Vision', 'Multimodal', 'RL', 'Diffusion', 'Retrieval'];
37
+ const rng = (seed) => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; };
38
+ const r = rng(42);
39
+ const gauss = () => {
40
+ const u = Math.max(1e-9, r());
41
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * r());
42
+ };
43
+ const centers = categories.map((cat, ci) => {
44
+ const tx = (ci + 0.5) / categories.length;
45
+ const cx = 0.08 + tx * 0.84 + (r() - 0.5) * 0.06;
46
+ const cy = 0.25 + r() * 0.5;
47
+ const sx = 0.05 + r() * 0.05;
48
+ const sy = 0.05 + r() * 0.05;
49
+ const theta = r() * Math.PI;
50
+ return { cat, cx, cy, sx, sy, cos: Math.cos(theta), sin: Math.sin(theta) };
51
+ });
52
+ const data = [];
53
+ centers.forEach(({ cat, cx, cy, sx, sy, cos, sin }) => {
54
+ const n = 225 + Math.floor(r() * 270);
55
+ for (let i = 0; i < n; i++) {
56
+ const gx = gauss() * sx, gy = gauss() * sy;
57
+ data.push({
58
+ x: cx + gx * cos - gy * sin,
59
+ y: cy + gx * sin + gy * cos,
60
+ group: cat,
61
+ label: cat + ' ' + (i + 1),
62
+ });
63
+ }
64
+ });
65
+ for (let k = 0; k < 120; k++) {
66
+ const a = centers[Math.floor(r() * centers.length)];
67
+ let b = centers[Math.floor(r() * centers.length)];
68
+ if (b === a) b = centers[(centers.indexOf(a) + 1) % centers.length];
69
+ const t = r();
70
+ data.push({
71
+ x: a.cx + (b.cx - a.cx) * t + gauss() * 0.015,
72
+ y: a.cy + (b.cy - a.cy) * t + gauss() * 0.015,
73
+ group: r() < 0.5 ? a.cat : b.cat,
74
+ label: 'bridge',
75
+ });
76
+ }
77
+
78
+ const svg = d3.select(container).append('svg').attr('width', '100%').style('display', 'block');
79
+ const gDots = svg.append('g');
80
+ const gLabels = svg.append('g');
81
+
82
+ const x = d3.scaleLinear();
83
+ const y = d3.scaleLinear();
84
+ const color = d3.scaleOrdinal().domain(categories);
85
+ const margin = { top: 8, right: 12, bottom: 8, left: 12 };
86
+
87
+ function refreshPalette() {
88
+ try {
89
+ if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
90
+ const arr = window.ColorPalettes.getColors('categorical', categories.length);
91
+ if (arr && arr.length) { color.range(arr); return; }
92
+ }
93
+ } catch {}
94
+ color.range(['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948']);
95
+ }
96
+
97
+ function draw() {
98
+ const w = container.clientWidth || 800;
99
+ const h = Math.max(200, Math.round(w / 2.8));
100
+ svg.attr('width', w).attr('height', h);
101
+
102
+ const iw = w - margin.left - margin.right;
103
+ const ih = h - margin.top - margin.bottom;
104
+ x.domain(d3.extent(data, d => d.x)).range([margin.left, margin.left + iw]);
105
+ y.domain(d3.extent(data, d => d.y)).range([margin.top + ih, margin.top]);
106
+
107
+ const dots = gDots.selectAll('circle').data(data);
108
+ dots.enter().append('circle')
109
+ .attr('r', 3.5).attr('fill-opacity', 0.8)
110
+ .attr('stroke-width', 0.5)
111
+ .merge(dots)
112
+ .attr('cx', d => x(d.x)).attr('cy', d => y(d.y))
113
+ .attr('fill', d => color(d.group))
114
+ .attr('stroke', d => d3.color(color(d.group)).darker(0.3));
115
+ dots.exit().remove();
116
+
117
+ const centroids = d3.rollups(data, v => ({ x: d3.mean(v, d => d.x), y: d3.mean(v, d => d.y) }), d => d.group);
118
+ const labels = gLabels.selectAll('g.centroid').data(centroids, d => d[0]);
119
+ const enter = labels.enter().append('g').attr('class', 'centroid');
120
+ enter.append('text').attr('class', 'bg').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
121
+ .style('font-weight', '800').style('font-size', '14px').style('stroke', 'var(--page-bg, #fff)')
122
+ .style('stroke-width', '6px').style('paint-order', 'stroke fill');
123
+ enter.append('text').attr('class', 'fg').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
124
+ .style('font-weight', '800').style('font-size', '14px');
125
+ const merged = enter.merge(labels);
126
+ merged.attr('transform', d => 'translate(' + x(d[1].x) + ',' + y(d[1].y) + ')');
127
+ merged.select('.bg').text(d => d[0]).style('fill', 'var(--page-bg, #fff)');
128
+ merged.select('.fg').text(d => d[0]).style('fill', d => color(d[0]));
129
+ labels.exit().remove();
130
+ }
131
+
132
+ refreshPalette();
133
+ draw();
134
+ document.addEventListener('palettes:updated', () => { refreshPalette(); draw(); });
135
+ if (window.ResizeObserver) new ResizeObserver(draw).observe(container);
136
+ };
137
+
138
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
139
+ else ensureD3(bootstrap);
140
+ })();
141
+ <\/script>`;
142
+
143
+ export const SEED_SCALING_CHART_HTML = `<div class="d3-line-chart"></div>
144
+ <style>
145
+ .d3-line-chart {
146
+ font-family: sans-serif;
147
+ max-width: 600px;
148
+ margin: 0 auto;
149
+ padding: 20px;
150
+ border-radius: 8px;
151
+ color: var(--text-color);
152
+ }
153
+ .d3-line-chart .line { fill: none; stroke: var(--primary-color); stroke-width: 3px; }
154
+ .d3-line-chart .area { fill: var(--primary-color); opacity: 0.3; }
155
+ .d3-line-chart .axis path, .d3-line-chart .axis line { stroke: var(--axis-color); }
156
+ .d3-line-chart .axis text { fill: var(--text-color); }
157
+ .d3-line-chart .grid line { stroke: var(--grid-color); stroke-opacity: 0.7; shape-rendering: crispEdges; }
158
+ .d3-line-chart .tooltip { position: absolute; pointer-events: none; background-color: var(--surface-bg); border: 1px solid var(--border-color); padding: 8px; border-radius: 4px; font-size: 12px; color: var(--text-color); }
159
+ .d3-line-chart .dot { fill: var(--primary-color); stroke: var(--surface-bg); stroke-width: 2px; }
160
+ </style>
161
+ <script>
162
+ (() => {
163
+ const ensureD3 = (cb) => {
164
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
165
+ let s = document.getElementById('d3-cdn-script');
166
+ if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
167
+ s.addEventListener('load', () => { if (window.d3) cb(); }, { once: true });
168
+ if (window.d3) cb();
169
+ };
170
+
171
+ const bootstrap = () => {
172
+ const scriptEl = document.currentScript;
173
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
174
+ if (!(container && container.classList.contains('d3-line-chart'))) {
175
+ const cs = [...document.querySelectorAll('.d3-line-chart')].filter(el => el.dataset.mounted !== 'true');
176
+ container = cs[cs.length - 1] || null;
177
+ }
178
+ if (!container || container.dataset.mounted === 'true') return;
179
+ container.dataset.mounted = 'true';
180
+
181
+ const data = [
182
+ { date: new Date("2023-01-01"), value: 30 },
183
+ { date: new Date("2023-01-02"), value: 80 },
184
+ { date: new Date("2023-01-03"), value: 45 },
185
+ { date: new Date("2023-01-04"), value: 60 },
186
+ { date: new Date("2023-01-05"), value: 20 },
187
+ { date: new Date("2023-01-06"), value: 90 },
188
+ { date: new Date("2023-01-07"), value: 55 }
189
+ ];
190
+
191
+ const margin = { top: 20, right: 30, bottom: 40, left: 40 };
192
+
193
+ const render = () => {
194
+ const width = container.clientWidth;
195
+ const height = Math.min(width / 1.6, 400);
196
+ d3.select(container).select('svg').remove();
197
+ d3.select(container).select('.tooltip').remove();
198
+
199
+ const svg = d3.select(container).append('svg').attr('width', width).attr('height', height);
200
+ const g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
201
+ const iw = width - margin.left - margin.right;
202
+ const ih = height - margin.top - margin.bottom;
203
+
204
+ const x = d3.scaleTime().domain(d3.extent(data, d => d.date)).range([0, iw]);
205
+ const y = d3.scaleLinear().domain([0, d3.max(data, d => d.value)]).nice().range([ih, 0]);
206
+
207
+ g.append('g').attr('class', 'axis').attr('transform', 'translate(0,' + ih + ')').call(d3.axisBottom(x));
208
+ g.append('g').attr('class', 'axis').call(d3.axisLeft(y).ticks(5));
209
+ g.append('g').attr('class', 'grid').call(d3.axisLeft(y).tickSize(-iw).tickFormat(''));
210
+
211
+ const line = d3.line().x(d => x(d.date)).y(d => y(d.value)).curve(d3.curveMonotoneX);
212
+ const area = d3.area().x(d => x(d.date)).y0(ih).y1(d => y(d.value)).curve(d3.curveMonotoneX);
213
+
214
+ g.append('path').datum(data).attr('class', 'area').attr('d', area);
215
+ g.append('path').datum(data).attr('class', 'line').attr('d', line);
216
+ g.selectAll('.dot').data(data).enter().append('circle').attr('class', 'dot')
217
+ .attr('cx', d => x(d.date)).attr('cy', d => y(d.value)).attr('r', 5);
218
+ };
219
+
220
+ render();
221
+ if (window.ResizeObserver) new ResizeObserver(render).observe(container);
222
+ };
223
+
224
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
225
+ else ensureD3(bootstrap);
226
+ })();
227
+ <\/script>`;
228
+
229
+ export { SEED_MEMORY_BAR_HTML } from "./seed-embed-memory-bar";
230
+ export { SEED_CONFUSION_MATRIX_HTML } from "./seed-embed-confusion-matrix";