codetrap 0.1.6 → 0.1.7

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/src/web/static.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { webClientScript } from "./client-script";
2
+
1
3
  export const WEB_INDEX_HTML = `<!doctype html>
2
4
  <html lang="en">
3
5
  <head>
@@ -7,23 +9,26 @@ export const WEB_INDEX_HTML = `<!doctype html>
7
9
  <style>
8
10
  :root {
9
11
  color-scheme: light;
10
- --bg: #f7f3ea;
11
- --panel: #fbfaf6;
12
- --panel-2: #fffdf8;
12
+ --bg: #f3f6f2;
13
+ --panel: #f8faf7;
14
+ --panel-2: #fcfdfb;
13
15
  --surface: #ffffff;
14
- --surface-hover: #f1eee6;
15
- --line: #ded8cc;
16
- --line-soft: #e9e4da;
17
- --text: #20201d;
18
- --muted: #716b62;
19
- --faint: #9c9488;
16
+ --surface-hover: #edf3ef;
17
+ --line: #d6dfd9;
18
+ --line-soft: #e5ebe6;
19
+ --text: #20231f;
20
+ --muted: #657069;
21
+ --faint: #8b968e;
20
22
  --accent: #0f766e;
21
23
  --accent-soft: #d9f1eb;
22
24
  --accent-strong: #064e46;
25
+ --ink: #1f2937;
26
+ --violet: #4f46e5;
27
+ --violet-soft: #e6e8ff;
23
28
  --danger: #b42318;
24
29
  --warn: #9a6700;
25
30
  --ok: #18794e;
26
- --shadow: rgba(36, 31, 24, 0.08);
31
+ --shadow: rgba(28, 39, 32, 0.08);
27
32
  }
28
33
 
29
34
  * { box-sizing: border-box; }
@@ -31,8 +36,8 @@ export const WEB_INDEX_HTML = `<!doctype html>
31
36
  body {
32
37
  margin: 0;
33
38
  background:
34
- radial-gradient(circle at 18% 0%, rgba(13, 148, 136, 0.08), transparent 32%),
35
- linear-gradient(180deg, #fbf8f1 0%, var(--bg) 44%, #f3eee4 100%);
39
+ linear-gradient(120deg, rgba(15, 118, 110, 0.08), transparent 34%),
40
+ linear-gradient(180deg, #fbfcf8 0%, var(--bg) 48%, #eef3ef 100%);
36
41
  color: var(--text);
37
42
  font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
38
43
  letter-spacing: 0;
@@ -56,7 +61,7 @@ export const WEB_INDEX_HTML = `<!doctype html>
56
61
  }
57
62
 
58
63
  button:hover { background: var(--surface-hover); border-color: #c9c1b4; }
59
- button.primary { background: #20201d; color: #fffdf8; border-color: #20201d; }
64
+ button.primary { background: var(--ink); color: #fffdf8; border-color: var(--ink); }
60
65
  button.danger { border-color: color-mix(in srgb, var(--danger), var(--line) 35%); color: var(--danger); }
61
66
  button.ghost { background: transparent; }
62
67
  button:disabled { color: var(--faint); border-color: var(--line); cursor: not-allowed; opacity: 0.62; }
@@ -138,6 +143,14 @@ export const WEB_INDEX_HTML = `<!doctype html>
138
143
  gap: 10px;
139
144
  }
140
145
 
146
+ .rail-actions {
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: flex-end;
150
+ gap: 8px;
151
+ flex-wrap: wrap;
152
+ }
153
+
141
154
  .title {
142
155
  font-weight: 650;
143
156
  text-transform: none;
@@ -175,6 +188,26 @@ export const WEB_INDEX_HTML = `<!doctype html>
175
188
  .row.accepted { border-color: color-mix(in srgb, var(--ok), var(--line) 55%); }
176
189
  .row.accepted-missing { border-color: color-mix(in srgb, var(--warn), var(--line) 40%); }
177
190
  .row.rejected { border-color: color-mix(in srgb, var(--danger), var(--line) 55%); opacity: 0.72; }
191
+ .row-main {
192
+ width: 100%;
193
+ min-height: 0;
194
+ padding: 0;
195
+ border: 0;
196
+ border-radius: 0;
197
+ background: transparent;
198
+ box-shadow: none;
199
+ text-align: left;
200
+ display: grid;
201
+ gap: 5px;
202
+ color: inherit;
203
+ }
204
+ .row-main:hover { background: transparent; border-color: transparent; }
205
+ .row-action {
206
+ justify-self: start;
207
+ min-height: 28px;
208
+ font-size: 12px;
209
+ box-shadow: none;
210
+ }
178
211
  .row-title { overflow-wrap: anywhere; }
179
212
  .meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
180
213
 
@@ -195,6 +228,9 @@ export const WEB_INDEX_HTML = `<!doctype html>
195
228
  .pill.accepted-missing { color: var(--warn); border-color: color-mix(in srgb, var(--warn), var(--line) 55%); }
196
229
  .pill.rejected { color: var(--danger); border-color: color-mix(in srgb, var(--danger), var(--line) 55%); }
197
230
  .pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn), var(--line) 55%); }
231
+ .pill.scope { color: var(--violet); background: var(--violet-soft); border-color: color-mix(in srgb, var(--violet), var(--line) 55%); }
232
+ .pill.critical { color: var(--danger); border-color: color-mix(in srgb, var(--danger), var(--line) 42%); }
233
+ .pill.error { color: var(--warn); border-color: color-mix(in srgb, var(--warn), var(--line) 42%); }
198
234
 
199
235
  .detail-body {
200
236
  display: grid;
@@ -213,6 +249,153 @@ export const WEB_INDEX_HTML = `<!doctype html>
213
249
  .field.full { grid-column: 1 / -1; }
214
250
  label { color: var(--muted); font-size: 11px; text-transform: uppercase; }
215
251
 
252
+ .library-tools {
253
+ display: grid;
254
+ gap: 10px;
255
+ padding: 12px;
256
+ border-bottom: 1px solid var(--line-soft);
257
+ background: rgba(255, 255, 255, 0.54);
258
+ }
259
+
260
+ .filter-grid {
261
+ display: grid;
262
+ grid-template-columns: repeat(2, minmax(0, 1fr));
263
+ gap: 8px;
264
+ }
265
+
266
+ .filter-grid .wide { grid-column: 1 / -1; }
267
+
268
+ .summary-grid {
269
+ display: grid;
270
+ grid-template-columns: repeat(2, minmax(0, 1fr));
271
+ gap: 8px;
272
+ padding: 12px;
273
+ border-bottom: 1px solid var(--line-soft);
274
+ }
275
+
276
+ .metric {
277
+ border: 1px solid var(--line);
278
+ border-radius: 8px;
279
+ padding: 10px;
280
+ background: rgba(255, 255, 255, 0.72);
281
+ min-height: 74px;
282
+ display: grid;
283
+ align-content: space-between;
284
+ gap: 6px;
285
+ }
286
+
287
+ .metric-value {
288
+ font-size: 21px;
289
+ line-height: 1;
290
+ font-weight: 720;
291
+ color: var(--text);
292
+ overflow-wrap: anywhere;
293
+ }
294
+
295
+ .metric-label {
296
+ color: var(--muted);
297
+ font-size: 11px;
298
+ text-transform: uppercase;
299
+ }
300
+
301
+ .insight-grid {
302
+ display: grid;
303
+ gap: 10px;
304
+ padding: 12px;
305
+ }
306
+
307
+ .insight-block {
308
+ border-top: 1px solid var(--line-soft);
309
+ padding-top: 10px;
310
+ display: grid;
311
+ gap: 8px;
312
+ }
313
+
314
+ .rank-list {
315
+ display: grid;
316
+ gap: 7px;
317
+ }
318
+
319
+ .rank-row {
320
+ display: grid;
321
+ grid-template-columns: minmax(0, 1fr) auto;
322
+ gap: 8px;
323
+ align-items: center;
324
+ font-size: 13px;
325
+ }
326
+
327
+ .rank-label { overflow-wrap: anywhere; }
328
+ .rank-count { color: var(--muted); font-size: 12px; }
329
+
330
+ .bar-track {
331
+ grid-column: 1 / -1;
332
+ height: 5px;
333
+ border-radius: 999px;
334
+ background: var(--line-soft);
335
+ overflow: hidden;
336
+ }
337
+
338
+ .bar-fill {
339
+ height: 100%;
340
+ border-radius: inherit;
341
+ background: var(--accent);
342
+ }
343
+
344
+ .trap-rows {
345
+ display: grid;
346
+ gap: 10px;
347
+ padding: 12px;
348
+ }
349
+
350
+ .text-block {
351
+ display: grid;
352
+ gap: 6px;
353
+ }
354
+
355
+ .text-block .content {
356
+ white-space: pre-wrap;
357
+ line-height: 1.48;
358
+ overflow-wrap: anywhere;
359
+ }
360
+
361
+ .code-block {
362
+ margin: 0;
363
+ border: 1px solid var(--line);
364
+ border-radius: 8px;
365
+ padding: 10px;
366
+ background: #17201d;
367
+ color: #eef6f0;
368
+ overflow: auto;
369
+ line-height: 1.45;
370
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
371
+ font-size: 12px;
372
+ }
373
+
374
+ .detail-kv {
375
+ display: grid;
376
+ grid-template-columns: repeat(2, minmax(0, 1fr));
377
+ gap: 8px;
378
+ }
379
+
380
+ .kv {
381
+ border: 1px solid var(--line);
382
+ border-radius: 8px;
383
+ padding: 9px;
384
+ background: rgba(255, 255, 255, 0.62);
385
+ overflow-wrap: anywhere;
386
+ }
387
+
388
+ .kv-label {
389
+ color: var(--muted);
390
+ font-size: 11px;
391
+ text-transform: uppercase;
392
+ margin-bottom: 4px;
393
+ }
394
+
395
+ .kv-value { font-size: 13px; }
396
+
397
+ .hidden { display: none !important; }
398
+
216
399
  .section {
217
400
  border-top: 1px solid var(--line-soft);
218
401
  padding: 12px;
@@ -266,7 +449,15 @@ export const WEB_INDEX_HTML = `<!doctype html>
266
449
 
267
450
  @media (max-width: 1060px) {
268
451
  .shell { grid-template-columns: 1fr; overflow: auto; }
269
- .rail, .queue, .detail { min-height: 520px; border-right: 0; border-bottom: 1px solid var(--line); }
452
+ .rail { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
453
+ .queue, .detail { min-height: 520px; border-right: 0; border-bottom: 1px solid var(--line); }
454
+ }
455
+
456
+ @media (max-width: 520px) {
457
+ .bar { align-items: flex-start; flex-direction: column; }
458
+ .rail-actions { justify-content: flex-start; }
459
+ .filter-grid, .summary-grid, .detail-kv { grid-template-columns: 1fr; }
460
+ .project-form { grid-template-columns: 1fr auto; }
270
461
  }
271
462
  </style>
272
463
  </head>
@@ -276,18 +467,29 @@ export const WEB_INDEX_HTML = `<!doctype html>
276
467
  <div class="bar">
277
468
  <div>
278
469
  <div class="title">codetrap</div>
279
- <div class="subtle">review console</div>
470
+ <div class="subtle" id="app-subtitle">review console</div>
471
+ </div>
472
+ <div class="rail-actions">
473
+ <div class="segmented" aria-label="Main view">
474
+ <button type="button" class="active" data-main-view="review">Review</button>
475
+ <button type="button" data-main-view="library">Library</button>
476
+ <button type="button" data-main-view="insights">Insights</button>
477
+ </div>
478
+ <div class="segmented" aria-label="Language">
479
+ <button type="button" data-locale="en">EN</button>
480
+ <button type="button" data-locale="zh">中文</button>
481
+ </div>
482
+ <button class="ghost" id="refresh" title="Refresh">Refresh</button>
280
483
  </div>
281
- <button class="ghost" id="refresh" title="Refresh">Refresh</button>
282
484
  </div>
283
485
  <form class="project-form" id="project-form">
284
486
  <input id="project-path" placeholder="/path/to/project">
285
- <button type="submit">Add</button>
487
+ <button type="submit" id="project-add">Add</button>
286
488
  </form>
287
489
  <div class="scroll">
288
490
  <div class="stack" id="projects"></div>
289
491
  <div class="section">
290
- <div class="title">sessions</div>
492
+ <div class="title" id="sessions-title">sessions</div>
291
493
  <div id="sessions" class="stack" style="padding:0"></div>
292
494
  </div>
293
495
  </div>
@@ -296,10 +498,10 @@ export const WEB_INDEX_HTML = `<!doctype html>
296
498
  <section class="queue">
297
499
  <div class="bar">
298
500
  <div>
299
- <div class="title">candidate inbox</div>
501
+ <div class="title" id="queue-title">candidate inbox</div>
300
502
  <div class="subtle" id="queue-meta">no project selected</div>
301
503
  </div>
302
- <div class="segmented" aria-label="Candidate view">
504
+ <div class="segmented" id="candidate-tabs" aria-label="Candidate view">
303
505
  <button type="button" class="active" data-candidate-view="inbox">Inbox</button>
304
506
  <button type="button" data-candidate-view="reviewed">Reviewed</button>
305
507
  </div>
@@ -312,7 +514,7 @@ export const WEB_INDEX_HTML = `<!doctype html>
312
514
  <section class="detail">
313
515
  <div class="bar">
314
516
  <div>
315
- <div class="title">candidate detail</div>
517
+ <div class="title" id="detail-title">candidate detail</div>
316
518
  <div class="subtle" id="detail-meta">select a candidate</div>
317
519
  </div>
318
520
  </div>
@@ -321,456 +523,6 @@ export const WEB_INDEX_HTML = `<!doctype html>
321
523
  </main>
322
524
  <div class="status" id="status"></div>
323
525
 
324
- <script>
325
- const qs = new URLSearchParams(location.search);
326
- const token = qs.get("token") || sessionStorage.getItem("codetrap-token") || "";
327
- if (token) sessionStorage.setItem("codetrap-token", token);
328
-
329
- const state = {
330
- projects: [],
331
- sessions: [],
332
- candidates: [],
333
- projectRoot: null,
334
- sessionId: null,
335
- candidateId: null,
336
- candidateView: "inbox",
337
- options: { categories: [], severities: [], scopes: [] },
338
- conflicts: []
339
- };
340
-
341
- const el = (id) => document.getElementById(id);
342
-
343
- async function api(path, options = {}) {
344
- const headers = { "X-Codetrap-Token": token, ...(options.headers || {}) };
345
- if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
346
- const res = await fetch(path, { ...options, headers });
347
- const text = await res.text();
348
- const data = text ? JSON.parse(text) : null;
349
- if (!res.ok) {
350
- const err = new Error(data?.error || res.statusText);
351
- err.payload = data;
352
- throw err;
353
- }
354
- return data;
355
- }
356
-
357
- function showStatus(message, isError = false) {
358
- const box = el("status");
359
- box.textContent = message;
360
- box.className = "status show" + (isError ? " error" : "");
361
- clearTimeout(showStatus.timer);
362
- showStatus.timer = setTimeout(() => box.className = "status", 3200);
363
- }
364
-
365
- async function bootstrap() {
366
- const data = await api("/api/bootstrap");
367
- state.projects = data.projects;
368
- state.projectRoot = data.current_project_root || data.projects[0]?.root || null;
369
- state.options = data.options;
370
- renderProjects();
371
- await loadSessions();
372
- }
373
-
374
- async function loadSessions() {
375
- if (!state.projectRoot) {
376
- state.sessions = [];
377
- state.candidates = [];
378
- renderSessions();
379
- renderCandidates();
380
- renderDetail();
381
- return;
382
- }
383
- const data = await api("/api/sessions?project=" + encodeURIComponent(state.projectRoot));
384
- state.sessions = data.sessions;
385
- if (!state.sessionId || !state.sessions.some((s) => s.id === state.sessionId)) {
386
- state.sessionId = state.sessions[0]?.id || null;
387
- }
388
- renderSessions();
389
- await loadCandidates();
390
- }
391
-
392
- async function loadCandidates() {
393
- if (!state.projectRoot || !state.sessionId) {
394
- state.candidates = [];
395
- renderCandidates();
396
- renderDetail();
397
- return;
398
- }
399
- const data = await api("/api/candidates?project=" + encodeURIComponent(state.projectRoot) + "&session=" + encodeURIComponent(state.sessionId));
400
- state.candidates = data.candidates;
401
- selectVisibleCandidate();
402
- renderCandidates();
403
- renderDetail();
404
- }
405
-
406
- function renderProjects() {
407
- el("projects").innerHTML = state.projects.length ? state.projects.map((project) => \`
408
- <button class="row \${project.root === state.projectRoot ? "active" : ""}" data-project="\${escapeAttr(project.root)}">
409
- <span class="row-title">\${escapeHtml(project.name)}</span>
410
- <span class="subtle">\${escapeHtml(project.root)}</span>
411
- </button>
412
- \`).join("") : '<div class="empty">No projects</div>';
413
- document.querySelectorAll("[data-project]").forEach((button) => {
414
- button.addEventListener("click", async () => {
415
- state.projectRoot = button.dataset.project;
416
- state.sessionId = null;
417
- state.candidateId = null;
418
- renderProjects();
419
- await loadSessions();
420
- });
421
- });
422
- }
423
-
424
- function renderSessions() {
425
- el("sessions").innerHTML = state.sessions.length ? state.sessions.map((session) => \`
426
- <button class="row \${session.id === state.sessionId ? "active" : ""}" data-session="\${escapeAttr(session.id)}">
427
- <span class="row-title">\${escapeHtml(session.goal)}</span>
428
- <span class="meta">
429
- <span class="pill">\${escapeHtml(session.status)}</span>
430
- <span class="pill">\${session.candidate_count || 0} candidates</span>
431
- <span class="pill accepted">\${session.accepted_count || 0} accepted</span>
432
- </span>
433
- </button>
434
- \`).join("") : '<div class="empty">No sessions</div>';
435
- document.querySelectorAll("[data-session]").forEach((button) => {
436
- button.addEventListener("click", async () => {
437
- state.sessionId = button.dataset.session;
438
- state.candidateId = null;
439
- renderSessions();
440
- await loadCandidates();
441
- });
442
- });
443
- }
444
-
445
- function renderCandidates() {
446
- const pendingCount = state.candidates.filter((candidate) => candidate.status === "proposed").length;
447
- const reviewedCount = state.candidates.length - pendingCount;
448
- const sorted = sortedVisibleCandidates();
449
- selectVisibleCandidate(sorted);
450
- const session = state.sessions.find((item) => item.id === state.sessionId);
451
- el("queue-meta").textContent = session ? session.goal + " / " + pendingCount + " pending, " + reviewedCount + " reviewed" : "no session selected";
452
- renderCandidateViewTabs(pendingCount, reviewedCount);
453
- el("candidates").innerHTML = sorted.length ? sorted.map((candidate) => \`
454
- <button class="row \${candidate.id === state.candidateId ? "active" : ""} \${candidate.status} \${reviewCssClass(candidate)}" data-candidate="\${escapeAttr(candidate.id)}">
455
- <span class="row-title">\${escapeHtml(candidate.trap.title)}</span>
456
- <span class="meta">
457
- <span class="pill \${candidate.status} \${reviewCssClass(candidate)}">\${escapeHtml(reviewLabel(candidate))}</span>
458
- <span class="pill">q \${Number(candidate.quality_score).toFixed(2)}</span>
459
- \${candidate.quality.warnings.length ? '<span class="pill warn">' + candidate.quality.warnings.length + ' warnings</span>' : ''}
460
- </span>
461
- </button>
462
- \`).join("") : '<div class="empty">' + (state.candidateView === "inbox" ? "No pending candidates" : "No reviewed candidates") + '</div>';
463
- document.querySelectorAll("[data-candidate]").forEach((button) => {
464
- button.addEventListener("click", () => {
465
- state.candidateId = button.dataset.candidate;
466
- state.conflicts = [];
467
- renderCandidates();
468
- renderDetail();
469
- });
470
- });
471
- }
472
-
473
- function renderCandidateViewTabs(pendingCount, reviewedCount) {
474
- document.querySelectorAll("[data-candidate-view]").forEach((button) => {
475
- const view = button.dataset.candidateView;
476
- const count = view === "inbox" ? pendingCount : reviewedCount;
477
- button.classList.toggle("active", view === state.candidateView);
478
- button.textContent = (view === "inbox" ? "Inbox" : "Reviewed") + " " + count;
479
- });
480
- }
481
-
482
- function sortedVisibleCandidates() {
483
- return state.candidates
484
- .filter(candidateVisible)
485
- .sort((a, b) => statusRank(a.status) - statusRank(b.status) || b.quality_score - a.quality_score);
486
- }
487
-
488
- function candidateVisible(candidate) {
489
- return state.candidateView === "inbox" ? candidate.status === "proposed" : candidate.status !== "proposed";
490
- }
491
-
492
- function selectVisibleCandidate(candidates = sortedVisibleCandidates()) {
493
- if (!candidates.some((candidate) => candidate.id === state.candidateId)) {
494
- state.candidateId = candidates[0]?.id || null;
495
- }
496
- }
497
-
498
- function renderDetail() {
499
- const candidate = state.candidates.find((item) => item.id === state.candidateId);
500
- el("detail-meta").textContent = candidate ? candidate.id + " / " + candidate.status : "select a candidate";
501
- if (!candidate) {
502
- el("detail").innerHTML = '<div class="empty">No candidate selected</div>';
503
- return;
504
- }
505
- const disabled = candidate.status !== "proposed" ? "disabled" : "";
506
- el("detail").innerHTML = \`
507
- <div class="scroll">
508
- \${renderReviewNotice(candidate)}
509
- <form class="section" id="candidate-form">
510
- <div class="form-grid">
511
- \${field("title", "Title", candidate.trap.title, disabled)}
512
- \${selectField("category", "Category", candidate.trap.category, state.options.categories, disabled)}
513
- \${selectField("scope", "Scope", candidate.trap.scope, state.options.scopes, disabled)}
514
- \${selectField("severity", "Severity", candidate.trap.severity || "warning", state.options.severities, disabled)}
515
- \${field("tags", "Tags", (candidate.trap.tags || []).join(", "), disabled)}
516
- \${field("path_globs", "Path globs", (candidate.trap.path_globs || []).join(", "), disabled)}
517
- \${field("module", "Module", candidate.trap.module || "", disabled)}
518
- \${field("owner", "Owner", candidate.trap.owner || "", disabled)}
519
- \${textarea("context", "Context", candidate.trap.context, disabled)}
520
- \${textarea("mistake", "Mistake", candidate.trap.mistake, disabled)}
521
- \${textarea("fix", "Fix", candidate.trap.fix, disabled)}
522
- </div>
523
- </form>
524
- <div class="section">
525
- <div class="meta">
526
- <span class="pill">quality \${Number(candidate.quality_score).toFixed(2)}</span>
527
- <span class="pill">conflict \${escapeHtml(candidate.quality.conflict_status)}</span>
528
- <span class="pill">action \${escapeHtml(candidate.quality.suggested_action)}</span>
529
- </div>
530
- \${candidate.quality.warnings.map((warning) => '<div class="warning">' + escapeHtml(warning) + '</div>').join("")}
531
- </div>
532
- <div class="section">
533
- <div class="title">evidence</div>
534
- \${candidate.evidence.length ? candidate.evidence.map(renderEvidence).join("") : '<div class="empty">No evidence</div>'}
535
- </div>
536
- \${renderConflicts()}
537
- </div>
538
- \${renderDetailActions(candidate, disabled)}
539
- \`;
540
- bindDetailActions(candidate);
541
- }
542
-
543
- function renderReviewNotice(candidate) {
544
- const review = candidate.review;
545
- if (!review || review.status === "pending") return "";
546
- if (review.status === "accepted_missing") {
547
- return \`<div class="section"><div class="warning">\${escapeHtml(review.label)}</div></div>\`;
548
- }
549
- if (review.status === "accepted") {
550
- return \`<div class="section"><div class="evidence review-note">
551
- <div class="meta">
552
- <span class="pill accepted">\${escapeHtml(review.label)}</span>
553
- <span class="pill">\${escapeHtml(review.trap_status)}</span>
554
- </div>
555
- <div class="subtle">\${escapeHtml(review.trap_title)}</div>
556
- </div></div>\`;
557
- }
558
- if (review.status === "rejected") {
559
- return \`<div class="section"><div class="evidence">
560
- <div class="meta"><span class="pill rejected">\${escapeHtml(review.label)}</span></div>
561
- \${review.rejection_reason ? '<div class="subtle">' + escapeHtml(review.rejection_reason) + '</div>' : ''}
562
- </div></div>\`;
563
- }
564
- return "";
565
- }
566
-
567
- function renderDetailActions(candidate, disabled) {
568
- if (candidate.status !== "proposed") {
569
- return \`<div class="actions"><span class="pill \${reviewCssClass(candidate)}">\${escapeHtml(reviewLabel(candidate))}</span></div>\`;
570
- }
571
- return \`<div class="actions">
572
- <button id="save" class="primary" \${disabled}>Save</button>
573
- <button id="accept" \${disabled}>Accept</button>
574
- <button id="reject" class="danger" \${disabled}>Reject</button>
575
- <button id="accept-anyway" \${disabled}>Accept anyway</button>
576
- <input id="supersedes" placeholder="supersedes id" style="width:150px" \${disabled}>
577
- <button id="supersede" \${disabled}>Supersede</button>
578
- </div>\`;
579
- }
580
-
581
- function bindDetailActions(candidate) {
582
- const save = el("save");
583
- if (!save) return;
584
- save.addEventListener("click", async () => {
585
- try {
586
- const data = await api("/api/candidate/save", {
587
- method: "POST",
588
- body: JSON.stringify(candidatePayload(candidate.id))
589
- });
590
- await syncAfterMutation(data.candidate.id);
591
- showStatus("Candidate saved");
592
- } catch (error) {
593
- showStatus(error.message, true);
594
- }
595
- });
596
- el("accept").addEventListener("click", () => acceptCandidate({}));
597
- el("accept-anyway").addEventListener("click", () => acceptCandidate({ acceptAnyway: true }));
598
- el("supersede").addEventListener("click", () => {
599
- const value = Number.parseInt(el("supersedes").value, 10);
600
- if (Number.isNaN(value)) return showStatus("Supersedes id is required", true);
601
- acceptCandidate({ supersedesId: value });
602
- });
603
- el("reject").addEventListener("click", async () => {
604
- const reason = prompt("Reject reason") || "";
605
- try {
606
- const data = await api("/api/candidate/reject", {
607
- method: "POST",
608
- body: JSON.stringify({ projectRoot: state.projectRoot, sessionId: state.sessionId, candidateId: candidate.id, reason })
609
- });
610
- await syncAfterMutation(data.candidate.id);
611
- showStatus("Candidate rejected");
612
- } catch (error) {
613
- showStatus(error.message, true);
614
- }
615
- });
616
- }
617
-
618
- async function acceptCandidate(extra) {
619
- try {
620
- const data = await api("/api/candidate/accept", {
621
- method: "POST",
622
- body: JSON.stringify({ projectRoot: state.projectRoot, sessionId: state.sessionId, candidateId: state.candidateId, ...extra })
623
- });
624
- await syncAfterMutation(data.candidate.id);
625
- state.conflicts = [];
626
- showStatus("Candidate accepted");
627
- } catch (error) {
628
- if (error.payload?.possible_conflicts) {
629
- state.conflicts = error.payload.possible_conflicts;
630
- showStatus("Possible conflict found", true);
631
- await loadCandidates();
632
- state.conflicts = error.payload.possible_conflicts;
633
- renderDetail();
634
- } else {
635
- showStatus(error.message, true);
636
- }
637
- }
638
- }
639
-
640
- function candidatePayload(candidateId) {
641
- const form = new FormData(el("candidate-form"));
642
- return {
643
- projectRoot: state.projectRoot,
644
- sessionId: state.sessionId,
645
- candidateId,
646
- trap: {
647
- title: String(form.get("title") || ""),
648
- category: String(form.get("category") || ""),
649
- scope: String(form.get("scope") || ""),
650
- severity: String(form.get("severity") || ""),
651
- tags: splitList(form.get("tags")),
652
- path_globs: splitList(form.get("path_globs")),
653
- module: blankToNull(form.get("module")),
654
- owner: blankToNull(form.get("owner")),
655
- context: String(form.get("context") || ""),
656
- mistake: String(form.get("mistake") || ""),
657
- fix: String(form.get("fix") || "")
658
- }
659
- };
660
- }
661
-
662
- function replaceCandidate(candidate) {
663
- state.candidates = state.candidates.map((item) => item.id === candidate.id ? candidate : item);
664
- renderCandidates();
665
- renderDetail();
666
- }
667
-
668
- async function syncAfterMutation(candidateId) {
669
- state.candidateId = candidateId;
670
- await loadSessions();
671
- }
672
-
673
- async function refreshAll() {
674
- try {
675
- await bootstrap();
676
- showStatus("Refreshed");
677
- } catch (error) {
678
- showStatus(error.message, true);
679
- }
680
- }
681
-
682
- el("refresh").addEventListener("click", refreshAll);
683
- document.querySelectorAll("[data-candidate-view]").forEach((button) => {
684
- button.addEventListener("click", () => {
685
- state.candidateView = button.dataset.candidateView;
686
- state.candidateId = null;
687
- state.conflicts = [];
688
- renderCandidates();
689
- renderDetail();
690
- });
691
- });
692
- el("project-form").addEventListener("submit", async (event) => {
693
- event.preventDefault();
694
- try {
695
- const path = el("project-path").value.trim();
696
- if (!path) return;
697
- const data = await api("/api/projects", { method: "POST", body: JSON.stringify({ path }) });
698
- state.projects = data.projects;
699
- state.projectRoot = data.project.root;
700
- state.sessionId = null;
701
- state.candidateId = null;
702
- el("project-path").value = "";
703
- renderProjects();
704
- await loadSessions();
705
- } catch (error) {
706
- showStatus(error.message, true);
707
- }
708
- });
709
-
710
- function field(name, label, value, disabled) {
711
- return \`<div class="field"><label for="\${name}">\${label}</label><input id="\${name}" name="\${name}" value="\${escapeAttr(value || "")}" \${disabled}></div>\`;
712
- }
713
-
714
- function textarea(name, label, value, disabled) {
715
- return \`<div class="field full"><label for="\${name}">\${label}</label><textarea id="\${name}" name="\${name}" \${disabled}>\${escapeHtml(value || "")}</textarea></div>\`;
716
- }
717
-
718
- function selectField(name, label, value, options, disabled) {
719
- return \`<div class="field"><label for="\${name}">\${label}</label><select id="\${name}" name="\${name}" \${disabled}>\${options.map((option) => \`<option value="\${escapeAttr(option)}" \${option === value ? "selected" : ""}>\${escapeHtml(option)}</option>\`).join("")}</select></div>\`;
720
- }
721
-
722
- function renderEvidence(evidence) {
723
- return \`<div class="evidence">
724
- <div class="meta">
725
- <span class="pill">\${escapeHtml(evidence.source_type)}</span>
726
- \${evidence.source_ref ? '<span class="pill">' + escapeHtml(evidence.source_ref) + '</span>' : ''}
727
- </div>
728
- <div class="subtle">\${escapeHtml((evidence.related_files || []).join(", "))}</div>
729
- <div>\${escapeHtml(evidence.note || "")}</div>
730
- </div>\`;
731
- }
732
-
733
- function renderConflicts() {
734
- if (!state.conflicts.length) return "";
735
- return \`<div class="section"><div class="title">possible conflicts</div>\${state.conflicts.map((conflict) => \`
736
- <div class="conflict">
737
- <div class="meta"><span class="pill danger">#\${conflict.trap_id}</span><span class="pill">\${escapeHtml(conflict.scope)}</span><span class="pill warn">\${escapeHtml(conflict.reason)}</span></div>
738
- <strong>\${escapeHtml(conflict.title)}</strong>
739
- <div class="subtle">\${escapeHtml(conflict.context)}</div>
740
- <div>\${escapeHtml(conflict.fix)}</div>
741
- </div>\`).join("")}</div>\`;
742
- }
743
-
744
- function statusRank(status) {
745
- return status === "proposed" ? 0 : status === "accepted" ? 1 : 2;
746
- }
747
-
748
- function reviewLabel(candidate) {
749
- return candidate.review?.label || candidate.status;
750
- }
751
-
752
- function reviewCssClass(candidate) {
753
- return String(candidate.review?.status || candidate.status).replace(/_/g, "-");
754
- }
755
-
756
- function splitList(value) {
757
- return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
758
- }
759
-
760
- function blankToNull(value) {
761
- const text = String(value || "").trim();
762
- return text ? text : null;
763
- }
764
-
765
- function escapeHtml(value) {
766
- return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
767
- }
768
-
769
- function escapeAttr(value) {
770
- return escapeHtml(value);
771
- }
772
-
773
- refreshAll();
774
- </script>
526
+ <script>${webClientScript()}</script>
775
527
  </body>
776
528
  </html>`;