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 +179 -0
- backend/demo/human-typing.ts +132 -0
- backend/demo/personas.ts +21 -0
- backend/demo/solo.ts +130 -0
- backend/demo/trio.ts +1054 -0
- frontend/src/editor/load-demo.ts +39 -0
- frontend/src/editor/seed-embed-confusion-matrix.ts +181 -0
- frontend/src/editor/seed-embed-memory-bar.ts +250 -0
- frontend/src/editor/seed-embeds.ts +230 -0
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> → <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> → <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";
|