@demon-utils/playwright 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js.map CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/commentary.ts"],
3
+ "sources": ["../src/commentary.ts", "../src/review.ts", "../src/html-generator.ts"],
4
4
  "sourcesContent": [
5
- "import type { Page } from \"@playwright/test\";\n\nexport interface ShowCommentaryOptions {\n selector: string;\n text: string;\n}\n\nconst TOOLTIP_ID = \"demon-commentary-tooltip\";\n\nexport async function showCommentary(\n page: Page,\n options: ShowCommentaryOptions,\n): Promise<void> {\n await page.evaluate(\n ({ selector, text, tooltipId }) => {\n const target = document.querySelector(selector);\n if (!target) {\n throw new Error(\n `demon commentary: element not found for selector \"${selector}\"`,\n );\n }\n\n // Remove any existing tooltip\n document.getElementById(tooltipId)?.remove();\n\n const rect = target.getBoundingClientRect();\n\n const tooltip = document.createElement(\"div\");\n tooltip.id = tooltipId;\n tooltip.textContent = text;\n\n const style = document.createElement(\"style\");\n style.setAttribute(\"data-demon-commentary\", \"\");\n style.textContent = `\n @keyframes demon-commentary-in {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n @keyframes demon-commentary-out {\n from {\n opacity: 1;\n transform: translateY(0);\n }\n to {\n opacity: 0;\n transform: translateY(8px);\n }\n }\n #${tooltipId} {\n position: fixed;\n z-index: 2147483647;\n background: #1a1a2e;\n color: #eee;\n padding: 8px 14px;\n border-radius: 6px;\n font: 14px/1.4 system-ui, sans-serif;\n max-width: 320px;\n box-shadow: 0 4px 12px rgba(0,0,0,0.3);\n pointer-events: none;\n animation: demon-commentary-in 0.3s ease-out forwards;\n }\n #${tooltipId}.demon-commentary-hiding {\n animation: demon-commentary-out 0.25s ease-in forwards;\n }\n `;\n\n // Remove previous style if any\n document.querySelector(\"style[data-demon-commentary]\")?.remove();\n document.head.appendChild(style);\n\n // Position below the target element, horizontally centered\n const top = rect.bottom + 10;\n const left = rect.left + rect.width / 2;\n tooltip.style.top = `${top}px`;\n tooltip.style.left = `${left}px`;\n tooltip.style.transform = `translateX(-50%)`;\n\n document.body.appendChild(tooltip);\n },\n { selector: options.selector, text: options.text, tooltipId: TOOLTIP_ID },\n );\n}\n\nexport async function hideCommentary(page: Page): Promise<void> {\n await page.evaluate((tooltipId) => {\n const tooltip = document.getElementById(tooltipId);\n if (!tooltip) return;\n\n tooltip.classList.add(\"demon-commentary-hiding\");\n\n tooltip.addEventListener(\n \"animationend\",\n () => {\n tooltip.remove();\n document.querySelector(\"style[data-demon-commentary]\")?.remove();\n },\n { once: true },\n );\n }, TOOLTIP_ID);\n\n // Wait for the animate-out to complete\n await page.waitForTimeout(300);\n}\n"
5
+ "import type { Page } from \"@playwright/test\";\n\nexport interface ShowCommentaryOptions {\n selector: string;\n text: string;\n}\n\nconst TOOLTIP_ID = \"demon-commentary-tooltip\";\n\nexport async function showCommentary(\n page: Page,\n options: ShowCommentaryOptions,\n): Promise<void> {\n await page.evaluate(\n ({ selector, text, tooltipId }) => {\n const target = document.querySelector(selector);\n if (!target) {\n throw new Error(\n `demon commentary: element not found for selector \"${selector}\"`,\n );\n }\n\n // Remove any existing tooltip\n document.getElementById(tooltipId)?.remove();\n\n const rect = target.getBoundingClientRect();\n\n const tooltip = document.createElement(\"div\");\n tooltip.id = tooltipId;\n tooltip.textContent = text;\n\n const style = document.createElement(\"style\");\n style.setAttribute(\"data-demon-commentary\", \"\");\n style.textContent = `\n @keyframes demon-commentary-in {\n from {\n opacity: 0;\n transform: translateY(var(--demon-slide-y, 8px));\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n @keyframes demon-commentary-out {\n from {\n opacity: 1;\n transform: translateY(0);\n }\n to {\n opacity: 0;\n transform: translateY(var(--demon-slide-y, 8px));\n }\n }\n #${tooltipId} {\n --demon-slide-y: 8px;\n position: fixed;\n z-index: 2147483647;\n background: #1a1a2e;\n color: #eee;\n padding: 8px 14px;\n border-radius: 6px;\n font: 14px/1.4 system-ui, sans-serif;\n max-width: 320px;\n box-shadow: 0 4px 12px rgba(0,0,0,0.3);\n pointer-events: none;\n animation: demon-commentary-in 0.3s ease-out forwards;\n }\n #${tooltipId}.demon-commentary-hiding {\n animation: demon-commentary-out 0.25s ease-in forwards;\n }\n `;\n\n // Remove previous style if any\n document.querySelector(\"style[data-demon-commentary]\")?.remove();\n document.head.appendChild(style);\n\n // Append hidden to measure real dimensions\n tooltip.style.visibility = \"hidden\";\n document.body.appendChild(tooltip);\n const tooltipRect = tooltip.getBoundingClientRect();\n const tooltipWidth = tooltipRect.width;\n const tooltipHeight = tooltipRect.height;\n const viewportWidth = window.innerWidth;\n\n // Vertical: default below target, flip above if overflowing bottom\n let top = rect.bottom + 10;\n if (\n top + tooltipHeight > window.innerHeight &&\n rect.top - 10 - tooltipHeight >= 0\n ) {\n top = rect.top - 10 - tooltipHeight;\n tooltip.style.setProperty(\"--demon-slide-y\", \"-8px\");\n }\n\n // Horizontal: centered, clamped to viewport\n const left = Math.max(\n 4,\n Math.min(\n rect.left + rect.width / 2 - tooltipWidth / 2,\n viewportWidth - 4 - tooltipWidth,\n ),\n );\n\n tooltip.style.top = `${top}px`;\n tooltip.style.left = `${left}px`;\n tooltip.style.visibility = \"\";\n },\n { selector: options.selector, text: options.text, tooltipId: TOOLTIP_ID },\n );\n}\n\nexport async function hideCommentary(page: Page): Promise<void> {\n await page.evaluate((tooltipId) => {\n const tooltip = document.getElementById(tooltipId);\n if (!tooltip) return;\n\n tooltip.classList.add(\"demon-commentary-hiding\");\n\n tooltip.addEventListener(\n \"animationend\",\n () => {\n tooltip.remove();\n document.querySelector(\"style[data-demon-commentary]\")?.remove();\n },\n { once: true },\n );\n }, TOOLTIP_ID);\n\n // Wait for the animate-out to complete\n await page.waitForTimeout(300);\n}\n",
6
+ "import type { ReviewMetadata } from \"./review-types.ts\";\n\nexport type SpawnFn = (\n cmd: string[],\n) => { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> };\n\nexport interface InvokeClaudeOptions {\n agent?: string;\n spawn?: SpawnFn;\n}\n\nexport function buildReviewPrompt(filenames: string[]): string {\n const fileList = filenames.map((f) => `- ${f}`).join(\"\\n\");\n\n return `You are given the following .webm demo video filenames:\n\n${fileList}\n\nBased on the filenames, generate a JSON object matching this exact schema:\n\n{\n \"demos\": [\n {\n \"file\": \"<filename>\",\n \"summary\": \"<a short sentence describing what the demo likely shows>\",\n \"annotations\": [\n { \"timestampSeconds\": <number>, \"text\": \"<annotation text>\" }\n ]\n }\n ]\n}\n\nRules:\n- Return ONLY the JSON object, no markdown fences or extra text.\n- Include one entry in \"demos\" for each filename, in the same order.\n- Infer the summary and annotations from the filename.\n- Each demo should have at least one annotation starting at timestampSeconds 0.\n- \"file\" must exactly match the provided filename.`;\n}\n\nexport async function invokeClaude(\n prompt: string,\n options?: InvokeClaudeOptions,\n): Promise<string> {\n const spawnFn = options?.spawn ?? defaultSpawn;\n const agent = options?.agent ?? \"claude\";\n const proc = spawnFn([agent, \"-p\", prompt]);\n\n const reader = proc.stdout.getReader();\n const chunks: Uint8Array[] = [];\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n }\n\n const exitCode = await proc.exitCode;\n const output = new TextDecoder().decode(\n concatUint8Arrays(chunks),\n );\n\n if (exitCode !== 0) {\n throw new Error(\n `claude process exited with code ${exitCode}: ${output.trim()}`,\n );\n }\n\n return output.trim();\n}\n\nexport function parseReviewMetadata(raw: string): ReviewMetadata {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n throw new Error(`Invalid JSON from LLM: ${raw.slice(0, 200)}`);\n }\n\n if (typeof parsed !== \"object\" || parsed === null || !(\"demos\" in parsed)) {\n throw new Error(\"Missing 'demos' array in review metadata\");\n }\n\n const obj = parsed as Record<string, unknown>;\n if (!Array.isArray(obj[\"demos\"])) {\n throw new Error(\"'demos' must be an array\");\n }\n\n for (const demo of obj[\"demos\"] as unknown[]) {\n if (typeof demo !== \"object\" || demo === null) {\n throw new Error(\"Each demo must be an object\");\n }\n const d = demo as Record<string, unknown>;\n\n if (typeof d[\"file\"] !== \"string\") {\n throw new Error(\"Each demo must have a 'file' string\");\n }\n if (typeof d[\"summary\"] !== \"string\") {\n throw new Error(\"Each demo must have a 'summary' string\");\n }\n if (!Array.isArray(d[\"annotations\"])) {\n throw new Error(\"Each demo must have an 'annotations' array\");\n }\n\n for (const ann of d[\"annotations\"] as unknown[]) {\n if (typeof ann !== \"object\" || ann === null) {\n throw new Error(\"Each annotation must be an object\");\n }\n const a = ann as Record<string, unknown>;\n if (typeof a[\"timestampSeconds\"] !== \"number\") {\n throw new Error(\"Each annotation must have a 'timestampSeconds' number\");\n }\n if (typeof a[\"text\"] !== \"string\") {\n throw new Error(\"Each annotation must have a 'text' string\");\n }\n }\n }\n\n return parsed as ReviewMetadata;\n}\n\nfunction defaultSpawn(\n cmd: string[],\n): { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> } {\n const [command, ...args] = cmd;\n const proc = Bun.spawn([command!, ...args], {\n stdout: \"pipe\",\n stderr: \"pipe\",\n });\n return {\n exitCode: proc.exited,\n stdout: proc.stdout as unknown as ReadableStream<Uint8Array>,\n };\n}\n\nfunction concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {\n const totalLength = arrays.reduce((sum, a) => sum + a.length, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const a of arrays) {\n result.set(a, offset);\n offset += a.length;\n }\n return result;\n}\n",
7
+ "import type { ReviewMetadata } from \"./review-types.ts\";\n\nexport interface GenerateReviewHtmlOptions {\n metadata: ReviewMetadata;\n title?: string;\n}\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport function generateReviewHtml(options: GenerateReviewHtmlOptions): string {\n const { metadata, title = \"Demo Review\" } = options;\n\n if (metadata.demos.length === 0) {\n throw new Error(\"metadata.demos must not be empty\");\n }\n\n const firstDemo = metadata.demos[0];\n\n const demoButtons = metadata.demos\n .map((demo, i) => {\n const activeClass = i === 0 ? ' class=\"active\"' : \"\";\n return `<li><button data-index=\"${i}\"${activeClass}>${escapeHtml(demo.file)}</button></li>`;\n })\n .join(\"\\n \");\n\n const metadataJson = JSON.stringify(metadata).replace(/<\\//g, \"<\\\\/\");\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }\n header { padding: 1rem 2rem; background: #16213e; border-bottom: 1px solid #0f3460; }\n header h1 { font-size: 1.4rem; color: #e94560; }\n .review-layout { display: flex; height: calc(100vh - 60px); }\n .video-panel { flex: 4; padding: 1rem; display: flex; align-items: center; justify-content: center; background: #0f0f23; }\n .video-panel video { width: 100%; max-height: 100%; border-radius: 4px; }\n .side-panel { flex: 1; min-width: 260px; max-width: 360px; padding: 1rem; overflow-y: auto; background: #16213e; border-left: 1px solid #0f3460; }\n .side-panel h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #e94560; }\n .side-panel section { margin-bottom: 1.5rem; }\n #demo-list { list-style: none; }\n #demo-list li { margin-bottom: 0.25rem; }\n #demo-list button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #0f3460; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }\n #demo-list button:hover { background: #0f3460; }\n #demo-list button.active { background: #e94560; color: #fff; border-color: #e94560; }\n #summary-text { font-size: 0.9rem; line-height: 1.5; color: #ccc; }\n #annotations-list { list-style: none; }\n #annotations-list li { margin-bottom: 0.3rem; }\n #annotations-list button { width: 100%; text-align: left; padding: 0.3rem 0.5rem; background: transparent; color: #53a8b6; border: none; cursor: pointer; font-size: 0.85rem; }\n #annotations-list button:hover { color: #e94560; text-decoration: underline; }\n .timestamp { font-weight: bold; margin-right: 0.4rem; color: #e94560; }\n </style>\n</head>\n<body>\n <header>\n <h1>${escapeHtml(title)}</h1>\n </header>\n <main class=\"review-layout\">\n <div class=\"video-panel\">\n <video id=\"review-video\" controls src=\"${escapeAttr(firstDemo.file)}\"></video>\n </div>\n <div class=\"side-panel\">\n <section>\n <h2>Demos</h2>\n <ul id=\"demo-list\">\n ${demoButtons}\n </ul>\n </section>\n <section>\n <h2>Summary</h2>\n <p id=\"summary-text\"></p>\n </section>\n <section>\n <h2>Annotations</h2>\n <ul id=\"annotations-list\"></ul>\n </section>\n </div>\n </main>\n <script>\n (function() {\n var metadata = ${metadataJson};\n var video = document.getElementById(\"review-video\");\n var summaryText = document.getElementById(\"summary-text\");\n var annotationsList = document.getElementById(\"annotations-list\");\n var demoButtons = document.querySelectorAll(\"#demo-list button\");\n\n function esc(s) {\n var d = document.createElement(\"div\");\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n }\n\n function formatTime(seconds) {\n var m = Math.floor(seconds / 60);\n var s = Math.floor(seconds % 60);\n return m + \":\" + (s < 10 ? \"0\" : \"\") + s;\n }\n\n function selectDemo(index) {\n var demo = metadata.demos[index];\n video.src = demo.file;\n video.load();\n summaryText.textContent = demo.summary;\n\n demoButtons.forEach(function(btn, i) {\n btn.classList.toggle(\"active\", i === index);\n });\n\n var html = \"\";\n demo.annotations.forEach(function(ann) {\n html += '<li><button data-time=\"' + ann.timestampSeconds + '\">' +\n '<span class=\"timestamp\">' + esc(formatTime(ann.timestampSeconds)) + '</span>' +\n esc(ann.text) + '</button></li>';\n });\n annotationsList.innerHTML = html;\n }\n\n demoButtons.forEach(function(btn) {\n btn.addEventListener(\"click\", function() {\n selectDemo(parseInt(btn.getAttribute(\"data-index\"), 10));\n });\n });\n\n annotationsList.addEventListener(\"click\", function(e) {\n var btn = e.target.closest(\"button[data-time]\");\n if (btn) {\n video.currentTime = parseFloat(btn.getAttribute(\"data-time\"));\n video.play();\n }\n });\n\n selectDemo(0);\n })();\n </script>\n</body>\n</html>`;\n}\n"
6
8
  ],
7
- "mappings": ";AAOA,IAAM,aAAa;AAEnB,eAAsB,cAAc,CAClC,MACA,SACe;AAAA,EACf,MAAM,KAAK,SACT,GAAG,UAAU,MAAM,gBAAgB;AAAA,IACjC,MAAM,SAAS,SAAS,cAAc,QAAQ;AAAA,IAC9C,IAAI,CAAC,QAAQ;AAAA,MACX,MAAM,IAAI,MACR,qDAAqD,WACvD;AAAA,IACF;AAAA,IAGA,SAAS,eAAe,SAAS,GAAG,OAAO;AAAA,IAE3C,MAAM,OAAO,OAAO,sBAAsB;AAAA,IAE1C,MAAM,UAAU,SAAS,cAAc,KAAK;AAAA,IAC5C,QAAQ,KAAK;AAAA,IACb,QAAQ,cAAc;AAAA,IAEtB,MAAM,QAAQ,SAAS,cAAc,OAAO;AAAA,IAC5C,MAAM,aAAa,yBAAyB,EAAE;AAAA,IAC9C,MAAM,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,WAqBf;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,WAaA;AAAA;AAAA;AAAA;AAAA,IAML,SAAS,cAAc,8BAA8B,GAAG,OAAO;AAAA,IAC/D,SAAS,KAAK,YAAY,KAAK;AAAA,IAG/B,MAAM,MAAM,KAAK,SAAS;AAAA,IAC1B,MAAM,OAAO,KAAK,OAAO,KAAK,QAAQ;AAAA,IACtC,QAAQ,MAAM,MAAM,GAAG;AAAA,IACvB,QAAQ,MAAM,OAAO,GAAG;AAAA,IACxB,QAAQ,MAAM,YAAY;AAAA,IAE1B,SAAS,KAAK,YAAY,OAAO;AAAA,KAEnC,EAAE,UAAU,QAAQ,UAAU,MAAM,QAAQ,MAAM,WAAW,WAAW,CAC1E;AAAA;AAGF,eAAsB,cAAc,CAAC,MAA2B;AAAA,EAC9D,MAAM,KAAK,SAAS,CAAC,cAAc;AAAA,IACjC,MAAM,UAAU,SAAS,eAAe,SAAS;AAAA,IACjD,IAAI,CAAC;AAAA,MAAS;AAAA,IAEd,QAAQ,UAAU,IAAI,yBAAyB;AAAA,IAE/C,QAAQ,iBACN,gBACA,MAAM;AAAA,MACJ,QAAQ,OAAO;AAAA,MACf,SAAS,cAAc,8BAA8B,GAAG,OAAO;AAAA,OAEjE,EAAE,MAAM,KAAK,CACf;AAAA,KACC,UAAU;AAAA,EAGb,MAAM,KAAK,eAAe,GAAG;AAAA;",
8
- "debugId": "308A425302523F0964756E2164756E21",
9
+ "mappings": "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",
10
+ "debugId": "C244AE0F9893F9BF64756E2164756E21",
9
11
  "names": []
10
12
  }
package/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "@demon-utils/playwright",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "module": "dist/index.js",
6
6
  "types": "src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./src/index.ts"
11
+ }
12
+ },
7
13
  "files": [
8
14
  "dist",
9
15
  "src"
10
16
  ],
17
+ "bin": {
18
+ "demon-demo-review": "dist/bin/demon-demo-review.js"
19
+ },
11
20
  "type": "module",
12
21
  "peerDependencies": {
13
22
  "@playwright/test": ">=1.40.0"
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, statSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { resolve, join, basename } from "node:path";
4
+
5
+ import {
6
+ buildReviewPrompt,
7
+ invokeClaude,
8
+ parseReviewMetadata,
9
+ } from "../review.ts";
10
+ import { generateReviewHtml } from "../html-generator.ts";
11
+
12
+ let dir: string | undefined;
13
+ let agent: string | undefined;
14
+
15
+ const args = process.argv.slice(2);
16
+ for (let i = 0; i < args.length; i++) {
17
+ if (args[i] === "--agent") {
18
+ agent = args[++i];
19
+ } else if (!dir) {
20
+ dir = args[i];
21
+ }
22
+ }
23
+
24
+ if (!dir) {
25
+ console.error("Usage: demon-demo-review [--agent <path>] <directory>");
26
+ console.error(" Discovers .webm video files in the given directory.");
27
+ process.exit(1);
28
+ }
29
+
30
+ const resolved = resolve(dir);
31
+
32
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
33
+ console.error(`Error: "${resolved}" is not a valid directory.`);
34
+ process.exit(1);
35
+ }
36
+
37
+ const webmFiles = readdirSync(resolved)
38
+ .filter((f) => f.endsWith(".webm"))
39
+ .map((f) => join(resolved, f))
40
+ .sort();
41
+
42
+ if (webmFiles.length === 0) {
43
+ console.error(`Error: No .webm files found in "${resolved}".`);
44
+ process.exit(1);
45
+ }
46
+
47
+ for (const file of webmFiles) {
48
+ console.log(file);
49
+ }
50
+
51
+ try {
52
+ const basenames = webmFiles.map((f) => basename(f));
53
+ const prompt = buildReviewPrompt(basenames);
54
+
55
+ console.log("Invoking claude to generate review metadata...");
56
+ const rawOutput = await invokeClaude(prompt, { agent });
57
+
58
+ const metadata = parseReviewMetadata(rawOutput);
59
+ const outputPath = join(resolved, "review-metadata.json");
60
+ writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + "\n");
61
+ console.log(`Review metadata written to ${outputPath}`);
62
+
63
+ const html = generateReviewHtml({ metadata });
64
+ const htmlPath = join(resolved, "review.html");
65
+ writeFileSync(htmlPath, html);
66
+ console.log(resolve(htmlPath));
67
+ } catch (err) {
68
+ console.error(
69
+ "Error generating review metadata:",
70
+ err instanceof Error ? err.message : err,
71
+ );
72
+ process.exit(1);
73
+ }
@@ -69,3 +69,99 @@ test("showCommentary throws for missing selector", async ({ page }) => {
69
69
  }),
70
70
  ).rejects.toThrow();
71
71
  });
72
+
73
+ test("tooltip flips above when target is near bottom edge", async ({
74
+ page,
75
+ }) => {
76
+ await page.goto(`data:text/html,
77
+ <html><body>
78
+ <button id="btn" style="position:fixed;bottom:20px;left:50%;transform:translateX(-50%);">Bottom</button>
79
+ </body></html>`);
80
+
81
+ await showCommentary(page, { selector: "#btn", text: "Flipped above" });
82
+
83
+ const tooltip = page.locator(`#${TOOLTIP_ID}`);
84
+ const btn = page.locator("#btn");
85
+ const tooltipBox = await tooltip.boundingBox();
86
+ const btnBox = await btn.boundingBox();
87
+
88
+ expect(tooltipBox).toBeTruthy();
89
+ expect(btnBox).toBeTruthy();
90
+ // Tooltip bottom should be above or at the target top
91
+ expect(tooltipBox!.y + tooltipBox!.height).toBeLessThanOrEqual(btnBox!.y);
92
+ });
93
+
94
+ test("tooltip doesn't overflow right edge", async ({ page }) => {
95
+ await page.goto(`data:text/html,
96
+ <html><body>
97
+ <button id="btn" style="position:fixed;top:100px;right:10px;">Right</button>
98
+ </body></html>`);
99
+
100
+ await showCommentary(page, { selector: "#btn", text: "Clamped right" });
101
+
102
+ const tooltipBox = await page.locator(`#${TOOLTIP_ID}`).boundingBox();
103
+ expect(tooltipBox).toBeTruthy();
104
+ expect(tooltipBox!.x + tooltipBox!.width).toBeLessThanOrEqual(1280);
105
+ });
106
+
107
+ test("tooltip doesn't overflow left edge", async ({ page }) => {
108
+ await page.goto(`data:text/html,
109
+ <html><body>
110
+ <button id="btn" style="position:fixed;top:100px;left:10px;">Left</button>
111
+ </body></html>`);
112
+
113
+ await showCommentary(page, { selector: "#btn", text: "Clamped left" });
114
+
115
+ const tooltipBox = await page.locator(`#${TOOLTIP_ID}`).boundingBox();
116
+ expect(tooltipBox).toBeTruthy();
117
+ expect(tooltipBox!.x).toBeGreaterThanOrEqual(0);
118
+ });
119
+
120
+ test("tooltip handles bottom-right corner target", async ({ page }) => {
121
+ await page.goto(`data:text/html,
122
+ <html><body>
123
+ <button id="btn" style="position:fixed;bottom:20px;right:10px;">Corner</button>
124
+ </body></html>`);
125
+
126
+ await showCommentary(page, {
127
+ selector: "#btn",
128
+ text: "Bottom-right corner",
129
+ });
130
+
131
+ const tooltip = page.locator(`#${TOOLTIP_ID}`);
132
+ const btn = page.locator("#btn");
133
+ const tooltipBox = await tooltip.boundingBox();
134
+ const btnBox = await btn.boundingBox();
135
+
136
+ expect(tooltipBox).toBeTruthy();
137
+ expect(btnBox).toBeTruthy();
138
+ // Flipped above
139
+ expect(tooltipBox!.y + tooltipBox!.height).toBeLessThanOrEqual(btnBox!.y);
140
+ // Right-clamped
141
+ expect(tooltipBox!.x + tooltipBox!.width).toBeLessThanOrEqual(1280);
142
+ });
143
+
144
+ test("default position: tooltip below target, horizontally centered", async ({
145
+ page,
146
+ }) => {
147
+ await page.goto(INLINE_HTML);
148
+
149
+ await showCommentary(page, {
150
+ selector: "#submit-btn",
151
+ text: "Default position",
152
+ });
153
+
154
+ const tooltip = page.locator(`#${TOOLTIP_ID}`);
155
+ const btn = page.locator("#submit-btn");
156
+ const tooltipBox = await tooltip.boundingBox();
157
+ const btnBox = await btn.boundingBox();
158
+
159
+ expect(tooltipBox).toBeTruthy();
160
+ expect(btnBox).toBeTruthy();
161
+ // Below target
162
+ expect(tooltipBox!.y).toBeGreaterThanOrEqual(btnBox!.y + btnBox!.height);
163
+ // Horizontally: tooltip center ≈ button center (within 2px tolerance)
164
+ const tooltipCenter = tooltipBox!.x + tooltipBox!.width / 2;
165
+ const btnCenter = btnBox!.x + btnBox!.width / 2;
166
+ expect(Math.abs(tooltipCenter - btnCenter)).toBeLessThanOrEqual(2);
167
+ });
package/src/commentary.ts CHANGED
@@ -35,7 +35,7 @@ export async function showCommentary(
35
35
  @keyframes demon-commentary-in {
36
36
  from {
37
37
  opacity: 0;
38
- transform: translateY(8px);
38
+ transform: translateY(var(--demon-slide-y, 8px));
39
39
  }
40
40
  to {
41
41
  opacity: 1;
@@ -49,10 +49,11 @@ export async function showCommentary(
49
49
  }
50
50
  to {
51
51
  opacity: 0;
52
- transform: translateY(8px);
52
+ transform: translateY(var(--demon-slide-y, 8px));
53
53
  }
54
54
  }
55
55
  #${tooltipId} {
56
+ --demon-slide-y: 8px;
56
57
  position: fixed;
57
58
  z-index: 2147483647;
58
59
  background: #1a1a2e;
@@ -74,14 +75,36 @@ export async function showCommentary(
74
75
  document.querySelector("style[data-demon-commentary]")?.remove();
75
76
  document.head.appendChild(style);
76
77
 
77
- // Position below the target element, horizontally centered
78
- const top = rect.bottom + 10;
79
- const left = rect.left + rect.width / 2;
78
+ // Append hidden to measure real dimensions
79
+ tooltip.style.visibility = "hidden";
80
+ document.body.appendChild(tooltip);
81
+ const tooltipRect = tooltip.getBoundingClientRect();
82
+ const tooltipWidth = tooltipRect.width;
83
+ const tooltipHeight = tooltipRect.height;
84
+ const viewportWidth = window.innerWidth;
85
+
86
+ // Vertical: default below target, flip above if overflowing bottom
87
+ let top = rect.bottom + 10;
88
+ if (
89
+ top + tooltipHeight > window.innerHeight &&
90
+ rect.top - 10 - tooltipHeight >= 0
91
+ ) {
92
+ top = rect.top - 10 - tooltipHeight;
93
+ tooltip.style.setProperty("--demon-slide-y", "-8px");
94
+ }
95
+
96
+ // Horizontal: centered, clamped to viewport
97
+ const left = Math.max(
98
+ 4,
99
+ Math.min(
100
+ rect.left + rect.width / 2 - tooltipWidth / 2,
101
+ viewportWidth - 4 - tooltipWidth,
102
+ ),
103
+ );
104
+
80
105
  tooltip.style.top = `${top}px`;
81
106
  tooltip.style.left = `${left}px`;
82
- tooltip.style.transform = `translateX(-50%)`;
83
-
84
- document.body.appendChild(tooltip);
107
+ tooltip.style.visibility = "";
85
108
  },
86
109
  { selector: options.selector, text: options.text, tooltipId: TOOLTIP_ID },
87
110
  );
@@ -0,0 +1,195 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import type { ReviewMetadata } from "./review-types.ts";
4
+ import { generateReviewHtml } from "./html-generator.ts";
5
+
6
+ function makeMetadata(overrides?: Partial<ReviewMetadata>): ReviewMetadata {
7
+ return {
8
+ demos: [
9
+ {
10
+ file: "login-flow.webm",
11
+ summary: "Shows the login flow end to end",
12
+ annotations: [
13
+ { timestampSeconds: 0, text: "Page loads" },
14
+ { timestampSeconds: 5, text: "User types credentials" },
15
+ { timestampSeconds: 12, text: "Login succeeds" },
16
+ ],
17
+ },
18
+ {
19
+ file: "signup.webm",
20
+ summary: "Demonstrates the signup process",
21
+ annotations: [
22
+ { timestampSeconds: 0, text: "Signup form appears" },
23
+ { timestampSeconds: 8, text: "Form submitted" },
24
+ ],
25
+ },
26
+ ],
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ describe("generateReviewHtml", () => {
32
+ describe("structure", () => {
33
+ test("starts with <!DOCTYPE html>", () => {
34
+ const html = generateReviewHtml({ metadata: makeMetadata() });
35
+ expect(html).toStartWith("<!DOCTYPE html>");
36
+ });
37
+
38
+ test("contains required elements", () => {
39
+ const html = generateReviewHtml({ metadata: makeMetadata() });
40
+ expect(html).toContain("<style>");
41
+ expect(html).toContain("</style>");
42
+ expect(html).toContain("<script>");
43
+ expect(html).toContain("</script>");
44
+ expect(html).toContain("<video");
45
+ expect(html).toContain("<header>");
46
+ expect(html).toContain('class="review-layout"');
47
+ });
48
+ });
49
+
50
+ describe("video element", () => {
51
+ test("has correct id and initial src", () => {
52
+ const html = generateReviewHtml({ metadata: makeMetadata() });
53
+ expect(html).toContain('id="review-video"');
54
+ expect(html).toContain('src="login-flow.webm"');
55
+ });
56
+
57
+ test("has controls attribute", () => {
58
+ const html = generateReviewHtml({ metadata: makeMetadata() });
59
+ expect(html).toContain("<video id=\"review-video\" controls");
60
+ });
61
+ });
62
+
63
+ describe("title", () => {
64
+ test("defaults to Demo Review", () => {
65
+ const html = generateReviewHtml({ metadata: makeMetadata() });
66
+ expect(html).toContain("<title>Demo Review</title>");
67
+ expect(html).toContain("<h1>Demo Review</h1>");
68
+ });
69
+
70
+ test("uses custom title when provided", () => {
71
+ const html = generateReviewHtml({
72
+ metadata: makeMetadata(),
73
+ title: "My Custom Review",
74
+ });
75
+ expect(html).toContain("<title>My Custom Review</title>");
76
+ expect(html).toContain("<h1>My Custom Review</h1>");
77
+ });
78
+ });
79
+
80
+ describe("demo navigation", () => {
81
+ test("renders one button per demo", () => {
82
+ const html = generateReviewHtml({ metadata: makeMetadata() });
83
+ expect(html).toContain('data-index="0"');
84
+ expect(html).toContain('data-index="1"');
85
+ expect(html).not.toContain('data-index="2"');
86
+ });
87
+
88
+ test("first button has active class", () => {
89
+ const html = generateReviewHtml({ metadata: makeMetadata() });
90
+ expect(html).toContain('data-index="0" class="active"');
91
+ });
92
+
93
+ test("second button does not have active class", () => {
94
+ const html = generateReviewHtml({ metadata: makeMetadata() });
95
+ const idx1Match = html.match(/data-index="1"([^>]*)/);
96
+ expect(idx1Match).toBeTruthy();
97
+ expect(idx1Match![1]).not.toContain("active");
98
+ });
99
+
100
+ test("displays demo filenames", () => {
101
+ const html = generateReviewHtml({ metadata: makeMetadata() });
102
+ expect(html).toContain("login-flow.webm");
103
+ expect(html).toContain("signup.webm");
104
+ });
105
+ });
106
+
107
+ describe("metadata embedding", () => {
108
+ test("embeds metadata JSON in script", () => {
109
+ const metadata = makeMetadata();
110
+ const html = generateReviewHtml({ metadata });
111
+ expect(html).toContain("login-flow.webm");
112
+ expect(html).toContain("Shows the login flow end to end");
113
+ expect(html).toContain("var metadata =");
114
+ });
115
+
116
+ test("escapes </ in JSON to prevent script breakout", () => {
117
+ const metadata = makeMetadata({
118
+ demos: [
119
+ {
120
+ file: "test.webm",
121
+ summary: "contains </script> tag",
122
+ annotations: [],
123
+ },
124
+ ],
125
+ });
126
+ const html = generateReviewHtml({ metadata });
127
+ expect(html).not.toContain("</script> tag");
128
+ expect(html).toContain("<\\/script> tag");
129
+ });
130
+ });
131
+
132
+ describe("HTML escaping", () => {
133
+ test("escapes special chars in filenames", () => {
134
+ const metadata = makeMetadata({
135
+ demos: [
136
+ {
137
+ file: '<img src="x">.webm',
138
+ summary: "normal summary",
139
+ annotations: [],
140
+ },
141
+ ],
142
+ });
143
+ const html = generateReviewHtml({ metadata });
144
+ expect(html).not.toContain('<img src="x">');
145
+ expect(html).toContain("&lt;img src=&quot;x&quot;&gt;.webm");
146
+ });
147
+
148
+ test("escapes special chars in title", () => {
149
+ const html = generateReviewHtml({
150
+ metadata: makeMetadata(),
151
+ title: 'Test & <Review> "Page"',
152
+ });
153
+ expect(html).toContain("Test &amp; &lt;Review&gt; &quot;Page&quot;");
154
+ });
155
+ });
156
+
157
+ describe("edge cases", () => {
158
+ test("throws on empty demos array", () => {
159
+ expect(() =>
160
+ generateReviewHtml({ metadata: { demos: [] } }),
161
+ ).toThrow("metadata.demos must not be empty");
162
+ });
163
+
164
+ test("handles demo with many annotations", () => {
165
+ const annotations = Array.from({ length: 100 }, (_, i) => ({
166
+ timestampSeconds: i * 10,
167
+ text: `Annotation ${i}`,
168
+ }));
169
+ const metadata: ReviewMetadata = {
170
+ demos: [
171
+ { file: "long.webm", summary: "Long demo", annotations },
172
+ ],
173
+ };
174
+ const html = generateReviewHtml({ metadata });
175
+ expect(html).toContain("Annotation 0");
176
+ expect(html).toContain("Annotation 99");
177
+ });
178
+
179
+ test("handles single demo", () => {
180
+ const metadata: ReviewMetadata = {
181
+ demos: [
182
+ {
183
+ file: "only.webm",
184
+ summary: "The only demo",
185
+ annotations: [{ timestampSeconds: 3, text: "Something happens" }],
186
+ },
187
+ ],
188
+ };
189
+ const html = generateReviewHtml({ metadata });
190
+ expect(html).toContain('data-index="0"');
191
+ expect(html).not.toContain('data-index="1"');
192
+ expect(html).toContain('src="only.webm"');
193
+ });
194
+ });
195
+ });