@demon-utils/playwright 0.1.5 → 0.1.6

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.
@@ -2,37 +2,91 @@
2
2
  // @bun
3
3
 
4
4
  // src/bin/demon-demo-review.ts
5
- import { existsSync, statSync, readdirSync, writeFileSync } from "fs";
6
- import { resolve, join, basename } from "path";
5
+ import { existsSync, statSync, readdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
6
+ import { resolve, join, basename, dirname } from "path";
7
7
 
8
8
  // src/review.ts
9
- function buildReviewPrompt(filenames) {
10
- const fileList = filenames.map((f) => `- ${f}`).join(`
9
+ var GIT_DIFF_MAX_CHARS = 50000;
10
+ function buildReviewPrompt(options) {
11
+ const { filenames, stepsMap, gitDiff, guidelines } = options;
12
+ const demoEntries = filenames.map((f) => {
13
+ const steps = stepsMap[f] ?? [];
14
+ const stepLines = steps.map((s) => `- [${s.timestampSeconds}s] ${s.text}`).join(`
11
15
  `);
12
- return `You are given the following .webm demo video filenames:
16
+ return `Video: ${f}
17
+ Recorded steps:
18
+ ${stepLines || "(no steps recorded)"}`;
19
+ });
20
+ const sections = [];
21
+ if (guidelines && guidelines.length > 0) {
22
+ sections.push(`## Coding Guidelines
23
+
24
+ ${guidelines.join(`
25
+
26
+ `)}`);
27
+ }
28
+ if (gitDiff) {
29
+ let diff = gitDiff;
30
+ if (diff.length > GIT_DIFF_MAX_CHARS) {
31
+ diff = diff.slice(0, GIT_DIFF_MAX_CHARS) + `
13
32
 
14
- ${fileList}
33
+ ... (diff truncated at 50k characters)`;
34
+ }
35
+ sections.push(`## Git Diff
36
+
37
+ \`\`\`diff
38
+ ${diff}
39
+ \`\`\``);
40
+ }
41
+ sections.push(`## Demo Recordings
15
42
 
16
- Based on the filenames, generate a JSON object matching this exact schema:
43
+ ${demoEntries.join(`
44
+
45
+ `)}`);
46
+ return `You are a code reviewer. You are given a git diff, coding guidelines, and demo recordings that show the feature in action.
47
+
48
+ ${sections.join(`
49
+
50
+ `)}
51
+
52
+ ## Task
53
+
54
+ Review the code changes and demo recordings. Generate a JSON object matching this exact schema:
17
55
 
18
56
  {
19
57
  "demos": [
20
58
  {
21
59
  "file": "<filename>",
22
- "summary": "<a short sentence describing what the demo likely shows>",
23
- "annotations": [
24
- { "timestampSeconds": <number>, "text": "<annotation text>" }
25
- ]
60
+ "summary": "<a meaningful sentence describing what this demo showcases based on the steps>"
26
61
  }
27
- ]
62
+ ],
63
+ "review": {
64
+ "summary": "<2-3 sentence overview of the changes>",
65
+ "highlights": ["<positive aspect 1>", "<positive aspect 2>"],
66
+ "verdict": "approve" | "request_changes",
67
+ "verdictReason": "<one sentence justifying the verdict>",
68
+ "issues": [
69
+ {
70
+ "severity": "major" | "minor" | "nit",
71
+ "description": "<what the issue is and how to fix it>"
72
+ }
73
+ ]
74
+ }
28
75
  }
29
76
 
30
77
  Rules:
31
78
  - Return ONLY the JSON object, no markdown fences or extra text.
32
79
  - Include one entry in "demos" for each filename, in the same order.
33
- - Infer the summary and annotations from the filename.
34
- - Each demo should have at least one annotation starting at timestampSeconds 0.
35
- - "file" must exactly match the provided filename.`;
80
+ - "file" must exactly match the provided filename.
81
+ - "verdict" must be exactly "approve" or "request_changes".
82
+ - Use "request_changes" if there are any "major" issues.
83
+ - "severity" must be exactly "major", "minor", or "nit".
84
+ - "major": bugs, security issues, broken functionality, guideline violations.
85
+ - "minor": code quality, readability, missing edge cases.
86
+ - "nit": style, naming, trivial improvements.
87
+ - "highlights" must have at least one entry.
88
+ - "issues" can be an empty array if there are no issues.
89
+ - Verify that demo steps demonstrate the acceptance criteria being met.`;
36
90
  }
37
91
  async function invokeClaude(prompt, options) {
38
92
  const spawnFn = options?.spawn ?? defaultSpawn;
@@ -53,10 +107,25 @@ async function invokeClaude(prompt, options) {
53
107
  }
54
108
  return output.trim();
55
109
  }
56
- function parseReviewMetadata(raw) {
110
+ var VALID_VERDICTS = new Set(["approve", "request_changes"]);
111
+ var VALID_SEVERITIES = new Set(["major", "minor", "nit"]);
112
+ function extractJson(raw) {
113
+ try {
114
+ JSON.parse(raw);
115
+ return raw;
116
+ } catch {}
117
+ const start = raw.indexOf("{");
118
+ const end = raw.lastIndexOf("}");
119
+ if (start === -1 || end === -1 || end <= start) {
120
+ throw new Error(`No JSON object found in LLM response: ${raw.slice(0, 200)}`);
121
+ }
122
+ return raw.slice(start, end + 1);
123
+ }
124
+ function parseLlmResponse(raw) {
125
+ const jsonStr = extractJson(raw);
57
126
  let parsed;
58
127
  try {
59
- parsed = JSON.parse(raw);
128
+ parsed = JSON.parse(jsonStr);
60
129
  } catch {
61
130
  throw new Error(`Invalid JSON from LLM: ${raw.slice(0, 200)}`);
62
131
  }
@@ -78,20 +147,44 @@ function parseReviewMetadata(raw) {
78
147
  if (typeof d["summary"] !== "string") {
79
148
  throw new Error("Each demo must have a 'summary' string");
80
149
  }
81
- if (!Array.isArray(d["annotations"])) {
82
- throw new Error("Each demo must have an 'annotations' array");
150
+ }
151
+ if (typeof obj["review"] !== "object" || obj["review"] === null) {
152
+ throw new Error("Missing 'review' object in response");
153
+ }
154
+ const review = obj["review"];
155
+ if (typeof review["summary"] !== "string") {
156
+ throw new Error("review.summary must be a string");
157
+ }
158
+ if (!Array.isArray(review["highlights"])) {
159
+ throw new Error("review.highlights must be an array");
160
+ }
161
+ if (review["highlights"].length === 0) {
162
+ throw new Error("review.highlights must not be empty");
163
+ }
164
+ for (const h of review["highlights"]) {
165
+ if (typeof h !== "string") {
166
+ throw new Error("Each highlight must be a string");
83
167
  }
84
- for (const ann of d["annotations"]) {
85
- if (typeof ann !== "object" || ann === null) {
86
- throw new Error("Each annotation must be an object");
87
- }
88
- const a = ann;
89
- if (typeof a["timestampSeconds"] !== "number") {
90
- throw new Error("Each annotation must have a 'timestampSeconds' number");
91
- }
92
- if (typeof a["text"] !== "string") {
93
- throw new Error("Each annotation must have a 'text' string");
94
- }
168
+ }
169
+ if (typeof review["verdict"] !== "string" || !VALID_VERDICTS.has(review["verdict"])) {
170
+ throw new Error("review.verdict must be 'approve' or 'request_changes'");
171
+ }
172
+ if (typeof review["verdictReason"] !== "string") {
173
+ throw new Error("review.verdictReason must be a string");
174
+ }
175
+ if (!Array.isArray(review["issues"])) {
176
+ throw new Error("review.issues must be an array");
177
+ }
178
+ for (const issue of review["issues"]) {
179
+ if (typeof issue !== "object" || issue === null) {
180
+ throw new Error("Each issue must be an object");
181
+ }
182
+ const i = issue;
183
+ if (typeof i["severity"] !== "string" || !VALID_SEVERITIES.has(i["severity"])) {
184
+ throw new Error("Each issue severity must be 'major', 'minor', or 'nit'");
185
+ }
186
+ if (typeof i["description"] !== "string") {
187
+ throw new Error("Each issue must have a 'description' string");
95
188
  }
96
189
  }
97
190
  return parsed;
@@ -125,6 +218,32 @@ function escapeHtml(s) {
125
218
  function escapeAttr(s) {
126
219
  return escapeHtml(s);
127
220
  }
221
+ function renderReviewSection(review) {
222
+ const bannerClass = review.verdict === "approve" ? "approve" : "request-changes";
223
+ const verdictLabel = review.verdict === "approve" ? "Approved" : "Changes Requested";
224
+ const highlightsHtml = review.highlights.map((h) => `<li>${escapeHtml(h)}</li>`).join(`
225
+ `);
226
+ const issuesHtml = review.issues.length > 0 ? review.issues.map((issue) => {
227
+ const badgeLabel = issue.severity.toUpperCase();
228
+ return `<div class="issue ${issue.severity}"><span class="severity-badge">${badgeLabel}</span> <span class="issue-text">${escapeHtml(issue.description)}</span><button class="feedback-add-issue" data-issue="${escapeAttr(issue.description)}">+</button></div>`;
229
+ }).join(`
230
+ `) : '<p class="no-issues">No issues found.</p>';
231
+ return `<section class="review-section">
232
+ <div class="verdict-banner ${bannerClass}">
233
+ <strong>${verdictLabel}</strong>: ${escapeHtml(review.verdictReason)}
234
+ </div>
235
+ <div class="review-body">
236
+ <h2>Summary</h2>
237
+ <p>${escapeHtml(review.summary)}</p>
238
+ <h2>Highlights</h2>
239
+ <ul class="highlights-list">
240
+ ${highlightsHtml}
241
+ </ul>
242
+ <h2>Issues</h2>
243
+ ${issuesHtml}
244
+ </div>
245
+ </section>`;
246
+ }
128
247
  function generateReviewHtml(options) {
129
248
  const { metadata, title = "Demo Review" } = options;
130
249
  if (metadata.demos.length === 0) {
@@ -137,6 +256,9 @@ function generateReviewHtml(options) {
137
256
  }).join(`
138
257
  `);
139
258
  const metadataJson = JSON.stringify(metadata).replace(/<\//g, "<\\/");
259
+ const reviewHtml = metadata.review ? renderReviewSection(metadata.review) : "";
260
+ const hasReview = !!metadata.review;
261
+ const defaultTab = hasReview ? "summary" : "demos";
140
262
  return `<!DOCTYPE html>
141
263
  <html lang="en">
142
264
  <head>
@@ -148,9 +270,40 @@ function generateReviewHtml(options) {
148
270
  body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
149
271
  header { padding: 1rem 2rem; background: #16213e; border-bottom: 1px solid #0f3460; }
150
272
  header h1 { font-size: 1.4rem; color: #e94560; }
151
- .review-layout { display: flex; height: calc(100vh - 60px); }
273
+ .tab-bar { display: flex; gap: 0; background: #16213e; border-bottom: 2px solid #0f3460; padding: 0 2rem; }
274
+ .tab-btn { padding: 0.7rem 1.5rem; background: none; border: none; border-bottom: 3px solid transparent; color: #999; font-size: 0.95rem; cursor: pointer; font-family: inherit; transition: all 0.15s; margin-bottom: -2px; }
275
+ .tab-btn:hover { color: #e0e0e0; }
276
+ .tab-btn.active { color: #e94560; border-bottom-color: #e94560; }
277
+ .tab-panel { display: none; }
278
+ .tab-panel.active { display: block; }
279
+ .review-section { padding: 1.5rem 2rem; }
280
+ .verdict-banner { padding: 1rem 1.5rem; border-radius: 6px; font-size: 1rem; margin-bottom: 1.5rem; }
281
+ .verdict-banner.approve { background: #1b4332; border: 1px solid #2d6a4f; color: #95d5b2; }
282
+ .verdict-banner.request-changes { background: #4a1520; border: 1px solid #842029; color: #f5c6cb; }
283
+ .review-body { max-width: 900px; }
284
+ .review-body h2 { font-size: 1.1rem; color: #e94560; margin: 1.2rem 0 0.5rem; }
285
+ .review-body p { font-size: 0.95rem; line-height: 1.6; color: #ccc; }
286
+ .highlights-list { list-style: disc; padding-left: 1.5rem; margin-bottom: 0.5rem; }
287
+ .highlights-list li { font-size: 0.95rem; line-height: 1.5; color: #95d5b2; margin-bottom: 0.3rem; }
288
+ .issue { padding: 0.6rem 0.8rem; margin-bottom: 0.5rem; border-radius: 4px; font-size: 0.9rem; line-height: 1.4; }
289
+ .issue.major { background: rgba(132, 32, 41, 0.3); border-left: 4px solid #dc3545; }
290
+ .issue.minor { background: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; }
291
+ .issue.nit { background: rgba(108, 117, 125, 0.2); border-left: 4px solid #6c757d; }
292
+ .severity-badge { display: inline-block; font-size: 0.7rem; font-weight: bold; padding: 0.15rem 0.4rem; border-radius: 3px; margin-right: 0.5rem; vertical-align: middle; }
293
+ .issue.major .severity-badge { background: #dc3545; color: #fff; }
294
+ .issue.minor .severity-badge { background: #ffc107; color: #000; }
295
+ .issue.nit .severity-badge { background: #6c757d; color: #fff; }
296
+ .no-issues { color: #95d5b2; font-style: italic; }
297
+ .demos-section { padding: 1rem 0; }
298
+ .review-layout { display: flex; height: 600px; }
152
299
  .video-panel { flex: 4; padding: 1rem; display: flex; align-items: center; justify-content: center; background: #0f0f23; }
153
- .video-panel video { width: 100%; max-height: 100%; border-radius: 4px; }
300
+ .video-wrapper { position: relative; width: 100%; max-height: 100%; display: flex; flex-direction: column; }
301
+ .video-wrapper video { width: 100%; max-height: calc(100% - 36px); border-radius: 4px 4px 0 0; display: block; cursor: pointer; }
302
+ .video-controls { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #16213e; border-radius: 0 0 4px 4px; }
303
+ .video-controls button { background: none; border: none; color: #e0e0e0; cursor: pointer; font-size: 1rem; padding: 0; width: 20px; display: flex; align-items: center; justify-content: center; }
304
+ .video-controls button:hover { color: #e94560; }
305
+ .video-controls input[type="range"] { flex: 1; height: 4px; accent-color: #e94560; cursor: pointer; }
306
+ .video-controls .vc-time { font-size: 0.75rem; color: #999; white-space: nowrap; font-variant-numeric: tabular-nums; }
154
307
  .side-panel { flex: 1; min-width: 260px; max-width: 360px; padding: 1rem; overflow-y: auto; background: #16213e; border-left: 1px solid #0f3460; }
155
308
  .side-panel h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #e94560; }
156
309
  .side-panel section { margin-bottom: 1.5rem; }
@@ -160,44 +313,109 @@ function generateReviewHtml(options) {
160
313
  #demo-list button:hover { background: #0f3460; }
161
314
  #demo-list button.active { background: #e94560; color: #fff; border-color: #e94560; }
162
315
  #summary-text { font-size: 0.9rem; line-height: 1.5; color: #ccc; }
163
- #annotations-list { list-style: none; }
164
- #annotations-list li { margin-bottom: 0.3rem; }
165
- #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; }
166
- #annotations-list button:hover { color: #e94560; text-decoration: underline; }
316
+ #steps-list { list-style: none; }
317
+ #steps-list li { margin-bottom: 0.3rem; }
318
+ #steps-list button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; background: transparent; color: #53a8b6; border: none; border-left: 3px solid transparent; cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
319
+ #steps-list button:hover { color: #e94560; }
320
+ #steps-list button.step-active { background: rgba(233, 69, 96, 0.15); color: #e94560; border-left-color: #e94560; }
167
321
  .timestamp { font-weight: bold; margin-right: 0.4rem; color: #e94560; }
322
+ .issue { position: relative; }
323
+ .feedback-add-issue { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); background: none; border: 1px solid #53a8b6; color: #53a8b6; border-radius: 4px; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.45rem; line-height: 1; }
324
+ .feedback-add-issue:hover { background: #53a8b6; color: #1a1a2e; }
325
+ #feedback-selection-btn { display: none; position: absolute; z-index: 1000; padding: 0.35rem 0.7rem; background: #e94560; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 0.8rem; white-space: nowrap; }
326
+ .feedback-layout { display: flex; gap: 1.5rem; padding: 1.5rem 2rem; }
327
+ .feedback-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
328
+ .feedback-right { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.5rem; }
329
+ #feedback-list { list-style: none; padding: 0; }
330
+ #feedback-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; background: #16213e; border: 1px solid #0f3460; border-radius: 4px; margin-bottom: 0.4rem; font-size: 0.9rem; }
331
+ #feedback-list li span { flex: 1; }
332
+ .feedback-remove { background: none; border: none; color: #dc3545; cursor: pointer; font-size: 0.9rem; padding: 0 0.3rem; }
333
+ .feedback-remove:hover { color: #ff6b7a; }
334
+ #feedback-general { width: 100%; min-height: 100px; background: #16213e; color: #e0e0e0; border: 1px solid #0f3460; border-radius: 4px; padding: 0.6rem; font-family: inherit; font-size: 0.9rem; resize: vertical; }
335
+ #feedback-preview { background: #0f0f23; color: #ccc; border: 1px solid #0f3460; border-radius: 4px; padding: 1rem; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; flex: 1; min-height: 200px; overflow-y: auto; }
336
+ #feedback-copy { align-self: flex-end; padding: 0.5rem 1rem; background: none; border: 1px solid #53a8b6; color: #53a8b6; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
337
+ #feedback-copy:hover { background: #53a8b6; color: #1a1a2e; }
168
338
  </style>
169
339
  </head>
170
340
  <body>
171
341
  <header>
172
342
  <h1>${escapeHtml(title)}</h1>
173
343
  </header>
174
- <main class="review-layout">
175
- <div class="video-panel">
176
- <video id="review-video" controls src="${escapeAttr(firstDemo.file)}"></video>
177
- </div>
178
- <div class="side-panel">
179
- <section>
180
- <h2>Demos</h2>
181
- <ul id="demo-list">
344
+ <nav class="tab-bar">
345
+ ${hasReview ? `<button class="tab-btn${defaultTab === "summary" ? " active" : ""}" data-tab="summary">Summary</button>` : ""}
346
+ <button class="tab-btn${defaultTab === "demos" ? " active" : ""}" data-tab="demos">Demos</button>
347
+ ${hasReview ? `<button class="tab-btn" data-tab="feedback">Feedback</button>` : ""}
348
+ </nav>
349
+ <main>
350
+ ${hasReview ? `<div id="tab-summary" class="tab-panel${defaultTab === "summary" ? " active" : ""}">
351
+ ${reviewHtml}
352
+ </div>` : ""}
353
+ <div id="tab-demos" class="tab-panel${defaultTab === "demos" ? " active" : ""}">
354
+ <section class="demos-section">
355
+ <div class="review-layout">
356
+ <div class="video-panel">
357
+ <div class="video-wrapper">
358
+ <video id="review-video" src="${escapeAttr(firstDemo.file)}"></video>
359
+ <div class="video-controls">
360
+ <button id="vc-play" aria-label="Play">&#9654;</button>
361
+ <input id="vc-seek" type="range" min="0" max="100" value="0" step="0.1">
362
+ <span class="vc-time" id="vc-time">0:00 / 0:00</span>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ <div class="side-panel">
367
+ <section>
368
+ <h2>Demos</h2>
369
+ <ul id="demo-list">
182
370
  ${demoButtons}
183
- </ul>
184
- </section>
185
- <section>
186
- <h2>Summary</h2>
187
- <p id="summary-text"></p>
188
- </section>
189
- <section>
190
- <h2>Annotations</h2>
191
- <ul id="annotations-list"></ul>
192
- </section>
371
+ </ul>
372
+ </section>
373
+ <section>
374
+ <h2>Summary</h2>
375
+ <p id="summary-text"></p>
376
+ </section>
377
+ <section id="steps-section">
378
+ <h2>Steps</h2>
379
+ <ul id="steps-list"></ul>
380
+ </section>
381
+ </div>
382
+ </div>
383
+ </section>
193
384
  </div>
385
+ ${hasReview ? `<div id="tab-feedback" class="tab-panel">
386
+ <div class="feedback-layout">
387
+ <div class="feedback-left">
388
+ <h2>Feedback Items</h2>
389
+ <ul id="feedback-list"></ul>
390
+ <h2>General Feedback</h2>
391
+ <textarea id="feedback-general" placeholder="Add general feedback here..."></textarea>
392
+ </div>
393
+ <div class="feedback-right">
394
+ <h2>Preview</h2>
395
+ <pre id="feedback-preview"></pre>
396
+ <button id="feedback-copy">Copy to clipboard</button>
397
+ </div>
398
+ </div>
399
+ </div>` : ""}
194
400
  </main>
401
+ ${hasReview ? `<button id="feedback-selection-btn">Add to feedback</button>` : ""}
195
402
  <script>
196
403
  (function() {
404
+ // Tab switching
405
+ var tabBtns = document.querySelectorAll(".tab-btn");
406
+ var tabPanels = document.querySelectorAll(".tab-panel");
407
+ tabBtns.forEach(function(btn) {
408
+ btn.addEventListener("click", function() {
409
+ var target = btn.getAttribute("data-tab");
410
+ tabBtns.forEach(function(b) { b.classList.toggle("active", b === btn); });
411
+ tabPanels.forEach(function(p) { p.classList.toggle("active", p.id === "tab-" + target); });
412
+ });
413
+ });
414
+
197
415
  var metadata = ${metadataJson};
198
416
  var video = document.getElementById("review-video");
199
417
  var summaryText = document.getElementById("summary-text");
200
- var annotationsList = document.getElementById("annotations-list");
418
+ var stepsList = document.getElementById("steps-list");
201
419
  var demoButtons = document.querySelectorAll("#demo-list button");
202
420
 
203
421
  function esc(s) {
@@ -222,13 +440,13 @@ function generateReviewHtml(options) {
222
440
  btn.classList.toggle("active", i === index);
223
441
  });
224
442
 
225
- var html = "";
226
- demo.annotations.forEach(function(ann) {
227
- html += '<li><button data-time="' + ann.timestampSeconds + '">' +
228
- '<span class="timestamp">' + esc(formatTime(ann.timestampSeconds)) + '</span>' +
229
- esc(ann.text) + '</button></li>';
443
+ var stepsHtml = "";
444
+ demo.steps.forEach(function(step) {
445
+ stepsHtml += '<li><button data-time="' + step.timestampSeconds + '">' +
446
+ '<span class="timestamp">' + esc(formatTime(step.timestampSeconds)) + '</span>' +
447
+ esc(step.text) + '</button></li>';
230
448
  });
231
- annotationsList.innerHTML = html;
449
+ stepsList.innerHTML = stepsHtml;
232
450
  }
233
451
 
234
452
  demoButtons.forEach(function(btn) {
@@ -237,7 +455,7 @@ function generateReviewHtml(options) {
237
455
  });
238
456
  });
239
457
 
240
- annotationsList.addEventListener("click", function(e) {
458
+ stepsList.addEventListener("click", function(e) {
241
459
  var btn = e.target.closest("button[data-time]");
242
460
  if (btn) {
243
461
  video.currentTime = parseFloat(btn.getAttribute("data-time"));
@@ -245,13 +463,229 @@ function generateReviewHtml(options) {
245
463
  }
246
464
  });
247
465
 
466
+ video.addEventListener("timeupdate", function() {
467
+ var buttons = document.querySelectorAll("#steps-list button[data-time]");
468
+ var ct = video.currentTime;
469
+ var activeIdx = -1;
470
+ buttons.forEach(function(btn, i) {
471
+ if (parseFloat(btn.getAttribute("data-time")) <= ct) activeIdx = i;
472
+ btn.classList.remove("step-active");
473
+ });
474
+ if (activeIdx >= 0) buttons[activeIdx].classList.add("step-active");
475
+ });
476
+
248
477
  selectDemo(0);
478
+
479
+ // Custom video controls
480
+ var playBtn = document.getElementById("vc-play");
481
+ var seekBar = document.getElementById("vc-seek");
482
+ var timeDisplay = document.getElementById("vc-time");
483
+ var seeking = false;
484
+
485
+ function fmtTime(sec) {
486
+ var m = Math.floor(sec / 60);
487
+ var s = Math.floor(sec % 60);
488
+ return m + ":" + (s < 10 ? "0" : "") + s;
489
+ }
490
+
491
+ function updateTime() {
492
+ var cur = video.currentTime || 0;
493
+ var dur = video.duration || 0;
494
+ timeDisplay.textContent = fmtTime(cur) + " / " + fmtTime(dur);
495
+ if (!seeking && dur) seekBar.value = (cur / dur) * 100;
496
+ }
497
+
498
+ function updatePlayBtn() {
499
+ playBtn.innerHTML = video.paused ? "&#9654;" : "&#9646;&#9646;";
500
+ }
501
+
502
+ playBtn.addEventListener("click", function() {
503
+ video.paused ? video.play() : video.pause();
504
+ });
505
+ video.addEventListener("click", function() {
506
+ video.paused ? video.play() : video.pause();
507
+ });
508
+ video.addEventListener("play", updatePlayBtn);
509
+ video.addEventListener("pause", updatePlayBtn);
510
+ video.addEventListener("ended", updatePlayBtn);
511
+ video.addEventListener("timeupdate", updateTime);
512
+ video.addEventListener("loadedmetadata", updateTime);
513
+
514
+ seekBar.addEventListener("input", function() {
515
+ seeking = true;
516
+ if (video.duration) {
517
+ video.currentTime = (seekBar.value / 100) * video.duration;
518
+ }
519
+ });
520
+ seekBar.addEventListener("change", function() { seeking = false; });
521
+
522
+ // Feedback tab logic
523
+ if (document.getElementById("tab-feedback")) {
524
+ var feedbackItems = [];
525
+ var feedbackList = document.getElementById("feedback-list");
526
+ var feedbackGeneral = document.getElementById("feedback-general");
527
+ var feedbackPreview = document.getElementById("feedback-preview");
528
+ var feedbackCopy = document.getElementById("feedback-copy");
529
+ var selectionBtn = document.getElementById("feedback-selection-btn");
530
+
531
+ function addFeedbackItem(text) {
532
+ var trimmed = text.trim();
533
+ if (!trimmed) return;
534
+ for (var i = 0; i < feedbackItems.length; i++) {
535
+ if (feedbackItems[i] === trimmed) return;
536
+ }
537
+ feedbackItems.push(trimmed);
538
+ renderFeedback();
539
+ }
540
+
541
+ function removeFeedbackItem(index) {
542
+ feedbackItems.splice(index, 1);
543
+ renderFeedback();
544
+ }
545
+
546
+ function renderFeedback() {
547
+ var html = "";
548
+ feedbackItems.forEach(function(item, i) {
549
+ html += '<li><span>' + esc(item) + '</span><button class="feedback-remove" data-index="' + i + '">X</button></li>';
550
+ });
551
+ feedbackList.innerHTML = html;
552
+ updatePreview();
553
+ }
554
+
555
+ function updatePreview() {
556
+ var lines = "";
557
+ feedbackItems.forEach(function(item, i) {
558
+ lines += (i + 1) + ". Address: " + item + "\\n";
559
+ });
560
+ var general = feedbackGeneral.value.trim();
561
+ if (general) {
562
+ lines += "\\nGeneral feedback:\\n" + general;
563
+ }
564
+ feedbackPreview.textContent = lines;
565
+ }
566
+
567
+ // Issue "+" buttons
568
+ var summaryTab = document.getElementById("tab-summary");
569
+ if (summaryTab) {
570
+ summaryTab.addEventListener("click", function(e) {
571
+ var btn = e.target.closest(".feedback-add-issue");
572
+ if (btn) {
573
+ addFeedbackItem(btn.getAttribute("data-issue"));
574
+ }
575
+ });
576
+ }
577
+
578
+ // Text selection floating button
579
+ var selectionTimeout;
580
+ document.addEventListener("mouseup", function(e) {
581
+ clearTimeout(selectionTimeout);
582
+ selectionTimeout = setTimeout(function() {
583
+ var sel = window.getSelection();
584
+ var text = sel ? sel.toString().trim() : "";
585
+ if (!text) return;
586
+ var anchor = sel.anchorNode;
587
+ var inSummary = false;
588
+ var node = anchor;
589
+ while (node) {
590
+ if (node.id === "tab-summary") { inSummary = true; break; }
591
+ node = node.parentNode;
592
+ }
593
+ if (!inSummary) return;
594
+ selectionBtn.style.display = "block";
595
+ selectionBtn.style.left = e.pageX + "px";
596
+ selectionBtn.style.top = (e.pageY - 35) + "px";
597
+ selectionBtn._selectedText = text;
598
+ }, 100);
599
+ });
600
+
601
+ selectionBtn.addEventListener("click", function() {
602
+ if (selectionBtn._selectedText) {
603
+ addFeedbackItem(selectionBtn._selectedText);
604
+ }
605
+ selectionBtn.style.display = "none";
606
+ window.getSelection().removeAllRanges();
607
+ });
608
+
609
+ document.addEventListener("mousedown", function(e) {
610
+ if (e.target !== selectionBtn) {
611
+ selectionBtn.style.display = "none";
612
+ }
613
+ });
614
+
615
+ // Remove buttons
616
+ feedbackList.addEventListener("click", function(e) {
617
+ var btn = e.target.closest(".feedback-remove");
618
+ if (btn) {
619
+ removeFeedbackItem(parseInt(btn.getAttribute("data-index"), 10));
620
+ }
621
+ });
622
+
623
+ // Textarea input
624
+ feedbackGeneral.addEventListener("input", updatePreview);
625
+
626
+ // Copy button
627
+ feedbackCopy.addEventListener("click", function() {
628
+ var text = feedbackPreview.textContent;
629
+ function onCopied() {
630
+ feedbackCopy.textContent = "Copied!";
631
+ setTimeout(function() { feedbackCopy.textContent = "Copy to clipboard"; }, 1500);
632
+ }
633
+ if (navigator.clipboard && navigator.clipboard.writeText) {
634
+ navigator.clipboard.writeText(text).then(onCopied, onCopied);
635
+ } else {
636
+ onCopied();
637
+ }
638
+ });
639
+
640
+ renderFeedback();
641
+ }
249
642
  })();
250
643
  </script>
251
644
  </body>
252
645
  </html>`;
253
646
  }
254
647
 
648
+ // src/git-context.ts
649
+ var {readFileSync} = (() => ({}));
650
+ var defaultExec = async (cmd, cwd) => {
651
+ const proc = Bun.spawnSync(cmd, { cwd });
652
+ if (proc.exitCode !== 0) {
653
+ const stderr = proc.stderr.toString().trim();
654
+ throw new Error(`Command failed (exit ${proc.exitCode}): ${cmd.join(" ")}${stderr ? `: ${stderr}` : ""}`);
655
+ }
656
+ return proc.stdout.toString();
657
+ };
658
+ var defaultReadFile = (path) => {
659
+ return readFileSync(path, "utf-8");
660
+ };
661
+ async function getRepoContext(demosDir, options) {
662
+ const exec = options?.exec ?? defaultExec;
663
+ const readFile = options?.readFile ?? defaultReadFile;
664
+ const gitRoot = (await exec(["git", "rev-parse", "--show-toplevel"], demosDir)).trim();
665
+ let gitDiff;
666
+ const workingDiff = (await exec(["git", "diff", "HEAD"], gitRoot)).trim();
667
+ if (workingDiff.length > 0) {
668
+ gitDiff = workingDiff;
669
+ } else {
670
+ gitDiff = (await exec(["git", "diff", "HEAD~1..HEAD"], gitRoot)).trim();
671
+ }
672
+ const lsOutput = (await exec(["git", "ls-files"], gitRoot)).trim();
673
+ const files = lsOutput.split(`
674
+ `).filter((f) => f.length > 0);
675
+ const guidelinePatterns = ["CLAUDE.md", "SKILL.md"];
676
+ const guidelines = [];
677
+ for (const file of files) {
678
+ const basename = file.split("/").pop() ?? "";
679
+ if (guidelinePatterns.includes(basename)) {
680
+ const fullPath = `${gitRoot}/${file}`;
681
+ const content = readFile(fullPath);
682
+ guidelines.push(`# ${file}
683
+ ${content}`);
684
+ }
685
+ }
686
+ return { gitDiff, guidelines };
687
+ }
688
+
255
689
  // src/bin/demon-demo-review.ts
256
690
  var dir;
257
691
  var agent;
@@ -273,20 +707,68 @@ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
273
707
  console.error(`Error: "${resolved}" is not a valid directory.`);
274
708
  process.exit(1);
275
709
  }
276
- var webmFiles = readdirSync(resolved).filter((f) => f.endsWith(".webm")).map((f) => join(resolved, f)).sort();
710
+ var webmFiles = readdirSync(resolved).filter((f) => f.endsWith(".webm")).map((f) => join(resolved, f));
277
711
  if (webmFiles.length === 0) {
278
- console.error(`Error: No .webm files found in "${resolved}".`);
712
+ for (const entry of readdirSync(resolved, { withFileTypes: true })) {
713
+ if (!entry.isDirectory())
714
+ continue;
715
+ const subdir = join(resolved, entry.name);
716
+ for (const f of readdirSync(subdir)) {
717
+ if (f.endsWith(".webm")) {
718
+ webmFiles.push(join(subdir, f));
719
+ }
720
+ }
721
+ }
722
+ }
723
+ webmFiles.sort();
724
+ if (webmFiles.length === 0) {
725
+ console.error(`Error: No .webm files found in "${resolved}" or its subdirectories.`);
279
726
  process.exit(1);
280
727
  }
281
728
  for (const file of webmFiles) {
282
729
  console.log(file);
283
730
  }
731
+ var stepsMap = {};
732
+ for (const webmFile of webmFiles) {
733
+ const stepsPath = join(dirname(webmFile), "demo-steps.json");
734
+ if (!existsSync(stepsPath))
735
+ continue;
736
+ try {
737
+ const raw = readFileSync2(stepsPath, "utf-8");
738
+ const parsed = JSON.parse(raw);
739
+ if (Array.isArray(parsed)) {
740
+ stepsMap[basename(webmFile)] = parsed;
741
+ }
742
+ } catch {}
743
+ }
744
+ if (Object.keys(stepsMap).length === 0) {
745
+ console.error("Error: No demo-steps.json found alongside any .webm files.");
746
+ console.error("Use DemoRecorder in your demo tests to generate step data.");
747
+ process.exit(1);
748
+ }
749
+ var gitDiff;
750
+ var guidelines;
751
+ try {
752
+ const repoContext = await getRepoContext(resolved);
753
+ gitDiff = repoContext.gitDiff;
754
+ guidelines = repoContext.guidelines;
755
+ } catch (err) {
756
+ console.warn("Warning: Could not gather repo context:", err instanceof Error ? err.message : err);
757
+ }
284
758
  try {
285
759
  const basenames = webmFiles.map((f) => basename(f));
286
- const prompt = buildReviewPrompt(basenames);
760
+ const prompt = buildReviewPrompt({ filenames: basenames, stepsMap, gitDiff, guidelines });
287
761
  console.log("Invoking claude to generate review metadata...");
288
762
  const rawOutput = await invokeClaude(prompt, { agent });
289
- const metadata = parseReviewMetadata(rawOutput);
763
+ const llmResponse = parseLlmResponse(rawOutput);
764
+ const metadata = {
765
+ demos: llmResponse.demos.map((demo) => ({
766
+ file: demo.file,
767
+ summary: demo.summary,
768
+ steps: stepsMap[demo.file] ?? []
769
+ })),
770
+ review: llmResponse.review
771
+ };
290
772
  const outputPath = join(resolved, "review-metadata.json");
291
773
  writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + `
292
774
  `);
@@ -300,4 +782,4 @@ try {
300
782
  process.exit(1);
301
783
  }
302
784
 
303
- //# debugId=8D4EBA82376B7AB564756E2164756E21
785
+ //# debugId=B95119C5D796705964756E2164756E21