codetrap 0.1.4 → 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.
@@ -0,0 +1,776 @@
1
+ export const WEB_INDEX_HTML = `<!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>codetrap review console</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --bg: #f7f3ea;
11
+ --panel: #fbfaf6;
12
+ --panel-2: #fffdf8;
13
+ --surface: #ffffff;
14
+ --surface-hover: #f1eee6;
15
+ --line: #ded8cc;
16
+ --line-soft: #e9e4da;
17
+ --text: #20201d;
18
+ --muted: #716b62;
19
+ --faint: #9c9488;
20
+ --accent: #0f766e;
21
+ --accent-soft: #d9f1eb;
22
+ --accent-strong: #064e46;
23
+ --danger: #b42318;
24
+ --warn: #9a6700;
25
+ --ok: #18794e;
26
+ --shadow: rgba(36, 31, 24, 0.08);
27
+ }
28
+
29
+ * { box-sizing: border-box; }
30
+ html, body { height: 100%; }
31
+ body {
32
+ margin: 0;
33
+ 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%);
36
+ color: var(--text);
37
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
38
+ letter-spacing: 0;
39
+ -webkit-font-smoothing: antialiased;
40
+ }
41
+
42
+ button, input, select, textarea {
43
+ font: inherit;
44
+ letter-spacing: 0;
45
+ }
46
+
47
+ button {
48
+ border: 1px solid var(--line);
49
+ background: var(--surface);
50
+ color: var(--text);
51
+ min-height: 32px;
52
+ padding: 0 12px;
53
+ border-radius: 8px;
54
+ cursor: pointer;
55
+ box-shadow: 0 1px 2px var(--shadow);
56
+ }
57
+
58
+ button:hover { background: var(--surface-hover); border-color: #c9c1b4; }
59
+ button.primary { background: #20201d; color: #fffdf8; border-color: #20201d; }
60
+ button.danger { border-color: color-mix(in srgb, var(--danger), var(--line) 35%); color: var(--danger); }
61
+ button.ghost { background: transparent; }
62
+ button:disabled { color: var(--faint); border-color: var(--line); cursor: not-allowed; opacity: 0.62; }
63
+
64
+ .segmented {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ gap: 2px;
68
+ padding: 3px;
69
+ border: 1px solid var(--line);
70
+ border-radius: 9px;
71
+ background: rgba(255, 255, 255, 0.58);
72
+ box-shadow: 0 1px 2px var(--shadow);
73
+ }
74
+
75
+ .segmented button {
76
+ min-height: 26px;
77
+ padding: 0 9px;
78
+ border: 0;
79
+ border-radius: 6px;
80
+ background: transparent;
81
+ color: var(--muted);
82
+ box-shadow: none;
83
+ font-size: 12px;
84
+ }
85
+
86
+ .segmented button.active {
87
+ background: var(--text);
88
+ color: #fffdf8;
89
+ }
90
+
91
+ input, select, textarea {
92
+ width: 100%;
93
+ border: 1px solid var(--line);
94
+ background: #fffdf8;
95
+ color: var(--text);
96
+ border-radius: 8px;
97
+ padding: 8px 9px;
98
+ outline: none;
99
+ }
100
+
101
+ textarea {
102
+ min-height: 104px;
103
+ resize: vertical;
104
+ line-height: 1.45;
105
+ }
106
+
107
+ input:focus, select:focus, textarea:focus {
108
+ border-color: var(--accent);
109
+ box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
110
+ }
111
+
112
+ .shell {
113
+ height: 100%;
114
+ display: grid;
115
+ grid-template-columns: minmax(250px, 0.82fr) minmax(320px, 1fr) minmax(460px, 1.48fr);
116
+ gap: 0;
117
+ overflow: hidden;
118
+ }
119
+
120
+ .rail, .queue, .detail {
121
+ min-height: 0;
122
+ border-right: 1px solid var(--line-soft);
123
+ background: color-mix(in srgb, var(--panel), transparent 8%);
124
+ display: flex;
125
+ flex-direction: column;
126
+ backdrop-filter: blur(12px);
127
+ }
128
+
129
+ .detail { border-right: 0; background: var(--panel-2); }
130
+
131
+ .bar {
132
+ min-height: 56px;
133
+ padding: 12px 14px;
134
+ border-bottom: 1px solid var(--line-soft);
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: space-between;
138
+ gap: 10px;
139
+ }
140
+
141
+ .title {
142
+ font-weight: 650;
143
+ text-transform: none;
144
+ font-size: 13px;
145
+ color: var(--text);
146
+ }
147
+
148
+ .subtle { color: var(--muted); font-size: 12px; min-width: 0; overflow-wrap: anywhere; }
149
+ .scroll { overflow: auto; min-height: 0; }
150
+ .stack { display: grid; gap: 10px; padding: 12px; }
151
+
152
+ .project-form {
153
+ display: grid;
154
+ grid-template-columns: 1fr auto;
155
+ gap: 8px;
156
+ padding: 12px;
157
+ border-bottom: 1px solid var(--line-soft);
158
+ }
159
+
160
+ .row {
161
+ width: 100%;
162
+ text-align: left;
163
+ display: grid;
164
+ gap: 5px;
165
+ padding: 10px;
166
+ border: 1px solid var(--line);
167
+ border-radius: 8px;
168
+ background: rgba(255, 255, 255, 0.72);
169
+ overflow: hidden;
170
+ box-shadow: 0 1px 2px var(--shadow);
171
+ }
172
+
173
+ .row:hover { background: #fffdf8; border-color: #cfc7ba; }
174
+ .row.active { border-color: color-mix(in srgb, var(--accent), var(--line) 28%); background: #ffffff; box-shadow: inset 3px 0 0 var(--accent), 0 8px 28px var(--shadow); }
175
+ .row.accepted { border-color: color-mix(in srgb, var(--ok), var(--line) 55%); }
176
+ .row.accepted-missing { border-color: color-mix(in srgb, var(--warn), var(--line) 40%); }
177
+ .row.rejected { border-color: color-mix(in srgb, var(--danger), var(--line) 55%); opacity: 0.72; }
178
+ .row-title { overflow-wrap: anywhere; }
179
+ .meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
180
+
181
+ .pill {
182
+ display: inline-flex;
183
+ align-items: center;
184
+ min-height: 22px;
185
+ border: 1px solid var(--line);
186
+ border-radius: 999px;
187
+ padding: 2px 8px;
188
+ color: var(--muted);
189
+ font-size: 11px;
190
+ white-space: nowrap;
191
+ }
192
+
193
+ .pill.proposed { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent), var(--line) 55%); }
194
+ .pill.accepted { color: var(--ok); border-color: color-mix(in srgb, var(--ok), var(--line) 55%); }
195
+ .pill.accepted-missing { color: var(--warn); border-color: color-mix(in srgb, var(--warn), var(--line) 55%); }
196
+ .pill.rejected { color: var(--danger); border-color: color-mix(in srgb, var(--danger), var(--line) 55%); }
197
+ .pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn), var(--line) 55%); }
198
+
199
+ .detail-body {
200
+ display: grid;
201
+ grid-template-rows: auto 1fr auto;
202
+ min-height: 0;
203
+ height: 100%;
204
+ }
205
+
206
+ .form-grid {
207
+ display: grid;
208
+ grid-template-columns: repeat(2, minmax(0, 1fr));
209
+ gap: 10px;
210
+ }
211
+
212
+ .field { display: grid; gap: 5px; }
213
+ .field.full { grid-column: 1 / -1; }
214
+ label { color: var(--muted); font-size: 11px; text-transform: uppercase; }
215
+
216
+ .section {
217
+ border-top: 1px solid var(--line-soft);
218
+ padding: 12px;
219
+ display: grid;
220
+ gap: 10px;
221
+ }
222
+
223
+ .evidence, .warning, .conflict {
224
+ border: 1px solid var(--line);
225
+ border-radius: 8px;
226
+ padding: 10px;
227
+ background: rgba(255, 255, 255, 0.68);
228
+ overflow-wrap: anywhere;
229
+ }
230
+
231
+ .warning { border-color: color-mix(in srgb, var(--warn), var(--line) 50%); color: var(--warn); }
232
+ .conflict { border-color: color-mix(in srgb, var(--danger), var(--line) 45%); }
233
+ .review-note { border-color: color-mix(in srgb, var(--accent), var(--line) 55%); }
234
+ .actions {
235
+ padding: 12px;
236
+ border-top: 1px solid var(--line-soft);
237
+ display: flex;
238
+ gap: 8px;
239
+ flex-wrap: wrap;
240
+ background: rgba(255, 255, 255, 0.018);
241
+ }
242
+
243
+ .empty {
244
+ padding: 28px 18px;
245
+ color: var(--muted);
246
+ text-align: center;
247
+ }
248
+
249
+ .status {
250
+ position: fixed;
251
+ right: 14px;
252
+ bottom: 14px;
253
+ max-width: 520px;
254
+ border: 1px solid var(--line);
255
+ background: #fffdf8;
256
+ color: var(--text);
257
+ border-radius: 8px;
258
+ padding: 10px 12px;
259
+ box-shadow: 0 12px 40px var(--shadow);
260
+ display: none;
261
+ z-index: 20;
262
+ }
263
+
264
+ .status.show { display: block; }
265
+ .status.error { border-color: var(--danger); color: var(--danger); }
266
+
267
+ @media (max-width: 1060px) {
268
+ .shell { grid-template-columns: 1fr; overflow: auto; }
269
+ .rail, .queue, .detail { min-height: 520px; border-right: 0; border-bottom: 1px solid var(--line); }
270
+ }
271
+ </style>
272
+ </head>
273
+ <body>
274
+ <main class="shell">
275
+ <aside class="rail">
276
+ <div class="bar">
277
+ <div>
278
+ <div class="title">codetrap</div>
279
+ <div class="subtle">review console</div>
280
+ </div>
281
+ <button class="ghost" id="refresh" title="Refresh">Refresh</button>
282
+ </div>
283
+ <form class="project-form" id="project-form">
284
+ <input id="project-path" placeholder="/path/to/project">
285
+ <button type="submit">Add</button>
286
+ </form>
287
+ <div class="scroll">
288
+ <div class="stack" id="projects"></div>
289
+ <div class="section">
290
+ <div class="title">sessions</div>
291
+ <div id="sessions" class="stack" style="padding:0"></div>
292
+ </div>
293
+ </div>
294
+ </aside>
295
+
296
+ <section class="queue">
297
+ <div class="bar">
298
+ <div>
299
+ <div class="title">candidate inbox</div>
300
+ <div class="subtle" id="queue-meta">no project selected</div>
301
+ </div>
302
+ <div class="segmented" aria-label="Candidate view">
303
+ <button type="button" class="active" data-candidate-view="inbox">Inbox</button>
304
+ <button type="button" data-candidate-view="reviewed">Reviewed</button>
305
+ </div>
306
+ </div>
307
+ <div class="scroll">
308
+ <div class="stack" id="candidates"></div>
309
+ </div>
310
+ </section>
311
+
312
+ <section class="detail">
313
+ <div class="bar">
314
+ <div>
315
+ <div class="title">candidate detail</div>
316
+ <div class="subtle" id="detail-meta">select a candidate</div>
317
+ </div>
318
+ </div>
319
+ <div class="detail-body" id="detail"></div>
320
+ </section>
321
+ </main>
322
+ <div class="status" id="status"></div>
323
+
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>
775
+ </body>
776
+ </html>`;