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.
@@ -0,0 +1,1168 @@
1
+ import { WEB_TEXT_JSON } from "./client-text";
2
+
3
+ export function webClientScript(textJson = WEB_TEXT_JSON): string {
4
+ return ` const qs = new URLSearchParams(location.search);
5
+ const token = qs.get("token") || sessionStorage.getItem("codetrap-token") || "";
6
+ if (token) sessionStorage.setItem("codetrap-token", token);
7
+ const savedLocale = localStorage.getItem("codetrap-locale");
8
+ const initialLocale = savedLocale === "zh" ? "zh" : "en";
9
+
10
+ const TEXT = ${textJson};
11
+
12
+ const state = {
13
+ locale: initialLocale,
14
+ mainView: "review",
15
+ projects: [],
16
+ sessions: [],
17
+ candidates: [],
18
+ traps: [],
19
+ trapKey: null,
20
+ trapDetails: {},
21
+ trapLoadingKey: null,
22
+ trapSearch: "",
23
+ trapFilters: { scope: "", status: "", category: "", module: "", owner: "" },
24
+ trapSort: "updated",
25
+ insightTraps: [],
26
+ insightFilters: { scope: "", status: "all" },
27
+ projectRoot: null,
28
+ sessionId: null,
29
+ candidateId: null,
30
+ candidateView: "inbox",
31
+ options: { categories: [], severities: [], scopes: [] },
32
+ conflicts: []
33
+ };
34
+
35
+ const el = (id) => document.getElementById(id);
36
+
37
+ function t(key, params = {}) {
38
+ const text = TEXT[state.locale]?.[key] ?? TEXT.en[key] ?? key;
39
+ return Object.entries(params).reduce((value, [name, replacement]) =>
40
+ value.replaceAll("{" + name + "}", String(replacement)), text);
41
+ }
42
+
43
+ function valueLabel(value) {
44
+ const key = "value." + value;
45
+ const label = t(key);
46
+ return label === key ? String(value ?? "") : label;
47
+ }
48
+
49
+ function optionPairs(values) {
50
+ return values.map((value) => [value, valueLabel(value)]);
51
+ }
52
+
53
+ function renderShellText() {
54
+ document.documentElement.lang = state.locale === "zh" ? "zh-CN" : "en";
55
+ document.title = "codetrap " + t("app.subtitle");
56
+ el("app-subtitle").textContent = t("app.subtitle");
57
+ el("refresh").textContent = t("action.refresh");
58
+ el("refresh").title = t("action.refresh");
59
+ el("project-add").textContent = t("action.add");
60
+ el("project-path").placeholder = t("placeholder.projectPath");
61
+ el("sessions-title").textContent = t("section.sessions");
62
+ document.querySelector("[data-main-view='review']").textContent = t("nav.review");
63
+ document.querySelector("[data-main-view='library']").textContent = t("nav.library");
64
+ document.querySelector("[data-main-view='insights']").textContent = t("nav.insights");
65
+ document.querySelectorAll("[data-locale]").forEach((button) => {
66
+ button.classList.toggle("active", button.dataset.locale === state.locale);
67
+ });
68
+ }
69
+
70
+ function setLocale(locale) {
71
+ if (locale !== "en" && locale !== "zh") return;
72
+ state.locale = locale;
73
+ localStorage.setItem("codetrap-locale", locale);
74
+ renderShellText();
75
+ renderProjects();
76
+ renderSessions();
77
+ renderActiveView();
78
+ }
79
+
80
+ async function api(path, options = {}) {
81
+ const headers = { "X-Codetrap-Token": token, ...(options.headers || {}) };
82
+ if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
83
+ const res = await fetch(path, { ...options, headers });
84
+ const text = await res.text();
85
+ const data = text ? JSON.parse(text) : null;
86
+ if (!res.ok) {
87
+ const err = new Error(data?.error || res.statusText);
88
+ err.payload = data;
89
+ throw err;
90
+ }
91
+ return data;
92
+ }
93
+
94
+ function showStatus(message, isError = false) {
95
+ const box = el("status");
96
+ box.textContent = message;
97
+ box.className = "status show" + (isError ? " error" : "");
98
+ clearTimeout(showStatus.timer);
99
+ showStatus.timer = setTimeout(() => box.className = "status", 3200);
100
+ }
101
+
102
+ async function bootstrap() {
103
+ const data = await api("/api/bootstrap");
104
+ state.projects = data.projects;
105
+ state.projectRoot = data.current_project_root || data.projects[0]?.root || null;
106
+ state.options = data.options;
107
+ renderShellText();
108
+ renderProjects();
109
+ await loadSessions();
110
+ renderActiveView();
111
+ }
112
+
113
+ async function loadSessions() {
114
+ if (!state.projectRoot) {
115
+ state.sessions = [];
116
+ state.candidates = [];
117
+ state.traps = [];
118
+ state.insightTraps = [];
119
+ renderSessions();
120
+ renderActiveView();
121
+ return;
122
+ }
123
+ const data = await api("/api/sessions?project=" + encodeURIComponent(state.projectRoot));
124
+ state.sessions = data.sessions;
125
+ if (!state.sessionId || !state.sessions.some((s) => s.id === state.sessionId)) {
126
+ state.sessionId = state.sessions[0]?.id || null;
127
+ }
128
+ renderSessions();
129
+ if (state.mainView === "library") {
130
+ await loadTraps();
131
+ } else if (state.mainView === "insights") {
132
+ await loadInsightTraps();
133
+ } else {
134
+ await loadCandidates();
135
+ }
136
+ }
137
+
138
+ async function loadCandidates() {
139
+ if (!state.projectRoot || !state.sessionId) {
140
+ state.candidates = [];
141
+ if (state.mainView === "review") {
142
+ renderCandidates();
143
+ renderDetail();
144
+ }
145
+ return;
146
+ }
147
+ const data = await api("/api/candidates?project=" + encodeURIComponent(state.projectRoot) + "&session=" + encodeURIComponent(state.sessionId));
148
+ state.candidates = data.candidates;
149
+ selectVisibleCandidate();
150
+ if (state.mainView === "review") {
151
+ renderCandidates();
152
+ renderDetail();
153
+ }
154
+ }
155
+
156
+ async function loadTraps() {
157
+ if (!state.projectRoot) {
158
+ state.traps = [];
159
+ state.trapKey = null;
160
+ if (state.mainView === "library") {
161
+ renderLibrary();
162
+ renderTrapDetail();
163
+ }
164
+ return;
165
+ }
166
+ const params = new URLSearchParams({ project: state.projectRoot });
167
+ Object.entries(state.trapFilters).forEach(([key, value]) => {
168
+ if (value) params.set(key, value);
169
+ });
170
+ const data = await api("/api/traps?" + params.toString());
171
+ state.traps = data.traps;
172
+ state.trapDetails = {};
173
+ selectVisibleTrap();
174
+ if (state.mainView === "library") {
175
+ renderLibrary();
176
+ renderTrapDetail();
177
+ }
178
+ }
179
+
180
+ async function loadInsightTraps() {
181
+ if (!state.projectRoot) {
182
+ state.insightTraps = [];
183
+ if (state.mainView === "insights") {
184
+ renderInsightsView();
185
+ renderInsightDetail();
186
+ }
187
+ return;
188
+ }
189
+ const params = new URLSearchParams({ project: state.projectRoot });
190
+ Object.entries(state.insightFilters).forEach(([key, value]) => {
191
+ if (value) params.set(key, value);
192
+ });
193
+ const data = await api("/api/traps?" + params.toString());
194
+ state.insightTraps = data.traps;
195
+ if (state.mainView === "insights") {
196
+ renderInsightsView();
197
+ renderInsightDetail();
198
+ }
199
+ }
200
+
201
+ function renderMainViewButtons() {
202
+ document.querySelectorAll("[data-main-view]").forEach((button) => {
203
+ button.classList.toggle("active", button.dataset.mainView === state.mainView);
204
+ });
205
+ }
206
+
207
+ function renderActiveView() {
208
+ renderMainViewButtons();
209
+ if (state.mainView === "library") {
210
+ el("queue-title").textContent = t("title.trapLibrary");
211
+ el("detail-title").textContent = t("title.trapDetail");
212
+ el("candidate-tabs").classList.add("hidden");
213
+ renderLibrary();
214
+ renderTrapDetail();
215
+ } else if (state.mainView === "insights") {
216
+ el("queue-title").textContent = t("title.growthInsights");
217
+ el("detail-title").textContent = t("title.insightDetail");
218
+ el("candidate-tabs").classList.add("hidden");
219
+ renderInsightsView();
220
+ renderInsightDetail();
221
+ } else {
222
+ el("queue-title").textContent = t("title.candidateInbox");
223
+ el("detail-title").textContent = t("title.candidateDetail");
224
+ el("candidate-tabs").classList.remove("hidden");
225
+ renderCandidates();
226
+ renderDetail();
227
+ }
228
+ }
229
+
230
+ function renderProjects() {
231
+ el("projects").innerHTML = state.projects.length ? state.projects.map((project) => \`
232
+ <button class="row \${project.root === state.projectRoot ? "active" : ""}" data-project="\${escapeAttr(project.root)}">
233
+ <span class="row-title">\${escapeHtml(project.name)}</span>
234
+ <span class="subtle">\${escapeHtml(project.root)}</span>
235
+ </button>
236
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noProjects")) + '</div>';
237
+ document.querySelectorAll("[data-project]").forEach((button) => {
238
+ button.addEventListener("click", async () => {
239
+ state.projectRoot = button.dataset.project;
240
+ state.sessionId = null;
241
+ state.candidateId = null;
242
+ state.trapKey = null;
243
+ state.trapDetails = {};
244
+ state.insightTraps = [];
245
+ renderProjects();
246
+ await loadSessions();
247
+ });
248
+ });
249
+ }
250
+
251
+ function renderSessions() {
252
+ el("sessions").innerHTML = state.sessions.length ? state.sessions.map((session) => \`
253
+ <div class="row \${session.id === state.sessionId ? "active" : ""}">
254
+ <button type="button" class="row-main" data-session="\${escapeAttr(session.id)}">
255
+ <span class="row-title">\${escapeHtml(session.goal)}</span>
256
+ <span class="meta">
257
+ <span class="pill">\${escapeHtml(valueLabel(session.status))}</span>
258
+ <span class="pill">\${escapeHtml(t("pill.candidates", { count: session.candidate_count || 0 }))}</span>
259
+ <span class="pill accepted">\${escapeHtml(t("pill.accepted", { count: session.accepted_count || 0 }))}</span>
260
+ </span>
261
+ </button>
262
+ <button type="button" class="row-action danger" data-delete-session="\${escapeAttr(session.id)}">\${escapeHtml(t("action.deleteSession"))}</button>
263
+ </div>
264
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noSessions")) + '</div>';
265
+ document.querySelectorAll("[data-session]").forEach((button) => {
266
+ button.addEventListener("click", async () => {
267
+ state.sessionId = button.dataset.session;
268
+ state.candidateId = null;
269
+ renderSessions();
270
+ await loadCandidates();
271
+ });
272
+ });
273
+ document.querySelectorAll("[data-delete-session]").forEach((button) => {
274
+ button.addEventListener("click", async () => {
275
+ await deleteSession(button.dataset.deleteSession);
276
+ });
277
+ });
278
+ }
279
+
280
+ async function deleteSession(sessionId) {
281
+ if (!sessionId || !confirm(t("prompt.deleteSession", { id: sessionId }))) return;
282
+ try {
283
+ await api("/api/session/delete", {
284
+ method: "POST",
285
+ body: JSON.stringify({ projectRoot: state.projectRoot, sessionId })
286
+ });
287
+ if (state.sessionId === sessionId) {
288
+ state.sessionId = null;
289
+ state.candidateId = null;
290
+ state.candidates = [];
291
+ }
292
+ await loadSessions();
293
+ showStatus(t("status.sessionDeleted"));
294
+ } catch (error) {
295
+ showStatus(error.message, true);
296
+ }
297
+ }
298
+
299
+ async function cleanupDeletedCandidates() {
300
+ if (!state.sessionId) return;
301
+ try {
302
+ const data = await api("/api/session/cleanup", {
303
+ method: "POST",
304
+ body: JSON.stringify({ projectRoot: state.projectRoot, sessionId: state.sessionId })
305
+ });
306
+ if (data.removed_candidate_ids?.includes(state.candidateId)) {
307
+ state.candidateId = null;
308
+ }
309
+ await loadSessions();
310
+ showStatus(t("status.deletedCandidatesCleaned"));
311
+ } catch (error) {
312
+ showStatus(error.message, true);
313
+ }
314
+ }
315
+
316
+ function renderCandidates() {
317
+ if (state.mainView !== "review") return;
318
+ const pendingCount = state.candidates.filter((candidate) => candidate.status === "proposed").length;
319
+ const reviewedCount = state.candidates.length - pendingCount;
320
+ const sorted = sortedVisibleCandidates();
321
+ selectVisibleCandidate(sorted);
322
+ const session = state.sessions.find((item) => item.id === state.sessionId);
323
+ el("queue-meta").textContent = session
324
+ ? t("meta.sessionCounts", { goal: session.goal, pending: pendingCount, reviewed: reviewedCount })
325
+ : t("meta.noSession");
326
+ renderCandidateViewTabs(pendingCount, reviewedCount);
327
+ el("candidates").innerHTML = sorted.length ? sorted.map((candidate) => \`
328
+ <div class="row \${candidate.id === state.candidateId ? "active" : ""} \${candidate.status} \${reviewCssClass(candidate)}">
329
+ <button type="button" class="row-main" data-candidate="\${escapeAttr(candidate.id)}">
330
+ <span class="row-title">\${escapeHtml(candidate.trap.title)}</span>
331
+ <span class="meta">
332
+ <span class="pill \${candidate.status} \${reviewCssClass(candidate)}">\${escapeHtml(reviewLabel(candidate))}</span>
333
+ <span class="pill">\${escapeHtml(t("pill.quality", { score: Number(candidate.quality_score).toFixed(2) }))}</span>
334
+ \${candidate.quality.warnings.length ? '<span class="pill warn">' + escapeHtml(t("pill.warnings", { count: candidate.quality.warnings.length })) + '</span>' : ''}
335
+ </span>
336
+ </button>
337
+ \${renderCandidateRowAction(candidate)}
338
+ </div>
339
+ \`).join("") : '<div class="empty">' + escapeHtml(t(state.candidateView === "inbox" ? "empty.noPending" : "empty.noReviewed")) + '</div>';
340
+ document.querySelectorAll("[data-candidate]").forEach((button) => {
341
+ button.addEventListener("click", () => {
342
+ state.candidateId = button.dataset.candidate;
343
+ state.conflicts = [];
344
+ renderCandidates();
345
+ renderDetail();
346
+ });
347
+ });
348
+ bindTrapJumpButtons();
349
+ }
350
+
351
+ function renderCandidateRowAction(candidate) {
352
+ const review = candidate.review;
353
+ if (!review || review.status !== "accepted") return "";
354
+ return \`<button type="button" class="row-action" data-view-trap-scope="\${escapeAttr(review.scope)}" data-view-trap-id="\${escapeAttr(review.trap_id)}">\${escapeHtml(t("action.viewTrap"))}</button>\`;
355
+ }
356
+
357
+ function renderLibrary() {
358
+ if (state.mainView !== "library") return;
359
+ el("queue-title").textContent = t("title.trapLibrary");
360
+ el("candidate-tabs").classList.add("hidden");
361
+ el("candidates").innerHTML = \`
362
+ <div class="library-tools">
363
+ <input id="trap-search" placeholder="\${escapeAttr(t("placeholder.searchTraps"))}" value="\${escapeAttr(state.trapSearch)}">
364
+ <div class="filter-grid">
365
+ \${filterSelect("trap-filter-scope", t("label.scope"), state.trapFilters.scope, [["", t("option.projectGlobal")], ...optionPairs(state.options.scopes)])}
366
+ \${filterSelect("trap-filter-status", t("label.status"), state.trapFilters.status, [["", valueLabel("active")], ["all", valueLabel("all")], ["archived", valueLabel("archived")], ["superseded", valueLabel("superseded")]])}
367
+ \${filterSelect("trap-filter-category", t("label.category"), state.trapFilters.category, [["", t("option.allCategories")], ...optionPairs(state.options.categories)])}
368
+ \${filterSelect("trap-sort", t("label.sort"), state.trapSort, [["updated", t("sort.updated")], ["severity", t("sort.severity")], ["hits", t("sort.hits")], ["category", t("sort.category")], ["title", t("sort.title")]])}
369
+ <div class="field"><label for="trap-filter-module">\${escapeHtml(t("label.module"))}</label><input id="trap-filter-module" value="\${escapeAttr(state.trapFilters.module)}" placeholder="\${escapeAttr(t("placeholder.anyModule"))}"></div>
370
+ <div class="field"><label for="trap-filter-owner">\${escapeHtml(t("label.owner"))}</label><input id="trap-filter-owner" value="\${escapeAttr(state.trapFilters.owner)}" placeholder="\${escapeAttr(t("placeholder.anyOwner"))}"></div>
371
+ <button type="button" id="trap-filter-clear" class="ghost">\${escapeHtml(t("action.clearFilters"))}</button>
372
+ </div>
373
+ </div>
374
+ <div id="library-insights"></div>
375
+ <div id="trap-rows" class="trap-rows"></div>
376
+ \`;
377
+ bindLibraryControls();
378
+ renderTrapResults();
379
+ }
380
+
381
+ function filterSelect(id, label, value, options) {
382
+ return \`<div class="field"><label for="\${id}">\${label}</label><select id="\${id}">\${options.map(([optionValue, optionLabel]) => \`<option value="\${escapeAttr(optionValue)}" \${optionValue === value ? "selected" : ""}>\${escapeHtml(optionLabel)}</option>\`).join("")}</select></div>\`;
383
+ }
384
+
385
+ function bindLibraryControls() {
386
+ const search = el("trap-search");
387
+ if (search) {
388
+ search.addEventListener("input", () => {
389
+ state.trapSearch = search.value;
390
+ state.trapKey = null;
391
+ renderTrapResults();
392
+ renderTrapDetail();
393
+ });
394
+ }
395
+ bindTrapFilter("trap-filter-scope", "scope");
396
+ bindTrapFilter("trap-filter-status", "status");
397
+ bindTrapFilter("trap-filter-category", "category");
398
+ bindTrapFilter("trap-filter-module", "module");
399
+ bindTrapFilter("trap-filter-owner", "owner");
400
+ const sort = el("trap-sort");
401
+ if (sort) {
402
+ sort.addEventListener("change", () => {
403
+ state.trapSort = sort.value;
404
+ state.trapKey = null;
405
+ renderTrapResults();
406
+ renderTrapDetail();
407
+ });
408
+ }
409
+ const clear = el("trap-filter-clear");
410
+ if (clear) {
411
+ clear.addEventListener("click", async () => {
412
+ state.trapFilters = { scope: "", status: "", category: "", module: "", owner: "" };
413
+ state.trapSearch = "";
414
+ state.trapKey = null;
415
+ await loadTraps();
416
+ });
417
+ }
418
+ }
419
+
420
+ function bindTrapFilter(id, key) {
421
+ const control = el(id);
422
+ if (!control) return;
423
+ const apply = async () => {
424
+ state.trapFilters[key] = control.value.trim();
425
+ state.trapKey = null;
426
+ await loadTraps();
427
+ };
428
+ control.addEventListener("change", apply);
429
+ control.addEventListener("keydown", (event) => {
430
+ if (event.key === "Enter") {
431
+ event.preventDefault();
432
+ apply();
433
+ }
434
+ });
435
+ }
436
+
437
+ function renderTrapResults() {
438
+ const rows = el("trap-rows");
439
+ const insights = el("library-insights");
440
+ if (!rows || !insights) return;
441
+ const visible = visibleTraps();
442
+ selectVisibleTrap(visible);
443
+ el("queue-meta").textContent = state.projectRoot
444
+ ? t("meta.libraryCounts", { shown: visible.length, loaded: state.traps.length, sort: sortLabel(state.trapSort) })
445
+ : t("meta.noProject");
446
+ insights.innerHTML = renderInsights(visible);
447
+ rows.innerHTML = visible.length ? visible.map((trap) => \`
448
+ <button class="row \${trapKey(trap) === state.trapKey ? "active" : ""}" data-trap-key="\${escapeAttr(trapKey(trap))}">
449
+ <span class="row-title">\${escapeHtml(trap.title)}</span>
450
+ <span class="meta">
451
+ <span class="pill \${escapeAttr(trap.severity)}">\${escapeHtml(valueLabel(trap.severity))}</span>
452
+ <span class="pill">\${escapeHtml(valueLabel(trap.category))}</span>
453
+ <span class="pill scope">\${escapeHtml(valueLabel(trap.scope))}</span>
454
+ <span class="pill \${escapeAttr(trap.status)}">\${escapeHtml(valueLabel(trap.status))}</span>
455
+ <span class="pill">\${escapeHtml(t("pill.hits", { count: Number(trap.hit_count || 0) }))}</span>
456
+ </span>
457
+ <span class="subtle">\${escapeHtml(trap.updated_at || trap.created_at || "")}</span>
458
+ </button>
459
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noTrapMatches")) + '</div>';
460
+ document.querySelectorAll("[data-trap-key]").forEach((button) => {
461
+ button.addEventListener("click", () => {
462
+ state.trapKey = button.dataset.trapKey;
463
+ renderTrapResults();
464
+ renderTrapDetail();
465
+ });
466
+ });
467
+ }
468
+
469
+ function renderInsights(traps) {
470
+ const serious = traps.filter((trap) => trap.severity === "error" || trap.severity === "critical").length;
471
+ const topCategory = topValue(traps.map((trap) => trap.category));
472
+ const topModule = topValue(traps.map((trap) => trap.module).filter(Boolean));
473
+ const topTag = topValue(traps.flatMap((trap) => trap.tags || []));
474
+ const mostViewed = [...traps].sort((a, b) => Number(b.hit_count || 0) - Number(a.hit_count || 0))[0];
475
+ return \`<div class="summary-grid">
476
+ \${metric(t("metric.loadedTraps"), traps.length || "0", t("metric.currentFilters"))}
477
+ \${metric(t("metric.highSeverity"), serious || "0", t("metric.errorCritical"))}
478
+ \${metric(t("metric.topCategory"), topCategory ? valueLabel(topCategory) : "-", t("metric.repeatedPattern"))}
479
+ \${metric(t("metric.focusArea"), topModule || topTag || "-", topModule ? t("metric.module") : t("metric.tag"))}
480
+ \${metric(t("metric.mostViewed"), mostViewed ? "#" + mostViewed.id : "-", mostViewed ? mostViewed.title : t("metric.noHits"))}
481
+ </div>\`;
482
+ }
483
+
484
+ function renderInsightsView() {
485
+ if (state.mainView !== "insights") return;
486
+ const traps = state.insightTraps;
487
+ const serious = traps.filter((trap) => trap.severity === "error" || trap.severity === "critical").length;
488
+ const topCategory = topValue(traps.map((trap) => trap.category));
489
+ const topModule = topValue(traps.map((trap) => trap.module).filter(Boolean));
490
+ const topTag = topValue(traps.flatMap((trap) => trap.tags || []));
491
+ const mostViewed = sortTraps(traps, "hits")[0];
492
+ el("queue-title").textContent = t("title.growthInsights");
493
+ el("candidate-tabs").classList.add("hidden");
494
+ el("queue-meta").textContent = state.projectRoot
495
+ ? t("meta.insightCounts", { count: traps.length, status: valueLabel(state.insightFilters.status || "all") })
496
+ : t("meta.noProject");
497
+ el("candidates").innerHTML = \`
498
+ <div class="library-tools">
499
+ <div class="filter-grid">
500
+ \${filterSelect("insight-filter-scope", t("label.scope"), state.insightFilters.scope, [["", t("option.projectGlobal")], ...optionPairs(state.options.scopes)])}
501
+ \${filterSelect("insight-filter-status", t("label.status"), state.insightFilters.status, [["all", valueLabel("all")], ["active", valueLabel("active")], ["archived", valueLabel("archived")], ["superseded", valueLabel("superseded")]])}
502
+ </div>
503
+ </div>
504
+ <div class="summary-grid">
505
+ \${metric(t("metric.confirmedTraps"), traps.length || "0", t("metric.selectedScope"))}
506
+ \${metric(t("metric.highSeverity"), serious || "0", t("metric.errorCritical"))}
507
+ \${metric(t("metric.topCategory"), topCategory ? valueLabel(topCategory) : "-", t("metric.largestPattern"))}
508
+ \${metric(t("metric.focusArea"), topModule || topTag || "-", topModule ? t("metric.module") : t("metric.tag"))}
509
+ \${metric(t("metric.mostViewed"), mostViewed ? "#" + mostViewed.id : "-", mostViewed ? mostViewed.title : t("metric.noHits"))}
510
+ </div>
511
+ <div class="insight-grid">
512
+ \${renderInsightRankBlock(t("insight.categories"), topValues(traps.map((trap) => trap.category), 6, true), traps.length)}
513
+ \${renderInsightRankBlock(t("insight.modules"), topValues(traps.map((trap) => trap.module).filter(Boolean), 6), traps.length)}
514
+ \${renderInsightRankBlock(t("insight.tags"), topValues(traps.flatMap((trap) => trap.tags || []), 8), traps.length)}
515
+ \${renderInsightRankBlock(t("insight.severityMix"), topValues(traps.map((trap) => trap.severity), 5, true), traps.length)}
516
+ </div>
517
+ \`;
518
+ bindInsightControls();
519
+ }
520
+
521
+ function renderInsightDetail() {
522
+ if (state.mainView !== "insights") return;
523
+ const traps = state.insightTraps;
524
+ const recent = sortTraps(traps, "updated").slice(0, 8);
525
+ const mostViewed = sortTraps(traps, "hits").filter((trap) => Number(trap.hit_count || 0) > 0).slice(0, 8);
526
+ const seriousRecent = sortTraps(traps.filter((trap) => trap.severity === "error" || trap.severity === "critical"), "updated").slice(0, 8);
527
+ el("detail-title").textContent = t("title.insightDetail");
528
+ el("detail-meta").textContent = state.projectRoot ? (state.insightFilters.scope ? valueLabel(state.insightFilters.scope) : t("option.projectGlobal")) : t("meta.selectProject");
529
+ el("detail").innerHTML = \`
530
+ <div class="scroll">
531
+ <div class="section">
532
+ <div class="title">\${escapeHtml(t("title.recentTraps"))}</div>
533
+ \${renderInsightTrapRows(recent)}
534
+ </div>
535
+ <div class="section">
536
+ <div class="title">\${escapeHtml(t("title.mostViewed"))}</div>
537
+ \${renderInsightTrapRows(mostViewed)}
538
+ </div>
539
+ <div class="section">
540
+ <div class="title">\${escapeHtml(t("title.recentHighSeverity"))}</div>
541
+ \${renderInsightTrapRows(seriousRecent)}
542
+ </div>
543
+ </div>
544
+ \`;
545
+ bindTrapJumpButtons();
546
+ }
547
+
548
+ function bindInsightControls() {
549
+ const scope = el("insight-filter-scope");
550
+ if (scope) {
551
+ scope.addEventListener("change", async () => {
552
+ state.insightFilters.scope = scope.value;
553
+ await loadInsightTraps();
554
+ });
555
+ }
556
+ const status = el("insight-filter-status");
557
+ if (status) {
558
+ status.addEventListener("change", async () => {
559
+ state.insightFilters.status = status.value;
560
+ await loadInsightTraps();
561
+ });
562
+ }
563
+ }
564
+
565
+ function renderInsightRankBlock(label, items, total) {
566
+ return \`<div class="insight-block">
567
+ <div class="title">\${escapeHtml(label)}</div>
568
+ <div class="rank-list">
569
+ \${items.length ? items.map((item) => renderRankRow(item, total)).join("") : '<div class="empty">' + escapeHtml(t("empty.noData")) + '</div>'}
570
+ </div>
571
+ </div>\`;
572
+ }
573
+
574
+ function renderRankRow(item, total) {
575
+ const width = total > 0 ? Math.max(6, Math.round((item.count / total) * 100)) : 0;
576
+ return \`<div class="rank-row">
577
+ <div class="rank-label">\${escapeHtml(item.label)}</div>
578
+ <div class="rank-count">\${item.count}</div>
579
+ <div class="bar-track"><div class="bar-fill" style="width:\${width}%"></div></div>
580
+ </div>\`;
581
+ }
582
+
583
+ function renderInsightTrapRows(traps) {
584
+ return traps.length ? traps.map((trap) => \`
585
+ <button type="button" class="row" data-view-trap-scope="\${escapeAttr(trap.scope)}" data-view-trap-id="\${escapeAttr(trap.id)}">
586
+ <span class="row-title">\${escapeHtml(trap.title)}</span>
587
+ <span class="meta">
588
+ <span class="pill \${escapeAttr(trap.severity)}">\${escapeHtml(valueLabel(trap.severity))}</span>
589
+ <span class="pill">\${escapeHtml(valueLabel(trap.category))}</span>
590
+ <span class="pill scope">\${escapeHtml(valueLabel(trap.scope))}</span>
591
+ <span class="pill \${escapeAttr(trap.status)}">\${escapeHtml(valueLabel(trap.status))}</span>
592
+ <span class="pill">\${escapeHtml(t("pill.hits", { count: Number(trap.hit_count || 0) }))}</span>
593
+ </span>
594
+ <span class="subtle">\${escapeHtml(trap.updated_at || trap.created_at || "")}</span>
595
+ </button>
596
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noTraps")) + '</div>';
597
+ }
598
+
599
+ function metric(label, value, detail) {
600
+ return \`<div class="metric"><div class="metric-label">\${escapeHtml(label)}</div><div class="metric-value">\${escapeHtml(value)}</div><div class="subtle">\${escapeHtml(detail)}</div></div>\`;
601
+ }
602
+
603
+ function topValue(values) {
604
+ const counts = new Map();
605
+ values.forEach((value) => {
606
+ if (!value) return;
607
+ counts.set(value, (counts.get(value) || 0) + 1);
608
+ });
609
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))[0]?.[0] || "";
610
+ }
611
+
612
+ function topValues(values, limit, translateValues = false) {
613
+ const counts = new Map();
614
+ values.forEach((value) => {
615
+ if (!value) return;
616
+ counts.set(value, (counts.get(value) || 0) + 1);
617
+ });
618
+ return [...counts.entries()]
619
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
620
+ .slice(0, limit)
621
+ .map(([label, count]) => ({ label: translateValues ? valueLabel(label) : label, count }));
622
+ }
623
+
624
+ function visibleTraps() {
625
+ const query = state.trapSearch.trim().toLowerCase();
626
+ const traps = query ? state.traps.filter((trap) => trapSearchText(trap).includes(query)) : state.traps;
627
+ return sortTraps(traps, state.trapSort);
628
+ }
629
+
630
+ function sortTraps(traps, sortKey) {
631
+ const sorted = [...traps];
632
+ sorted.sort((a, b) => {
633
+ if (sortKey === "severity") return severityRank(b.severity) - severityRank(a.severity) || byUpdatedDesc(a, b) || byTitle(a, b);
634
+ if (sortKey === "hits") return Number(b.hit_count || 0) - Number(a.hit_count || 0) || byUpdatedDesc(a, b) || byTitle(a, b);
635
+ if (sortKey === "category") return byText(a.category, b.category) || byTitle(a, b);
636
+ if (sortKey === "title") return byTitle(a, b);
637
+ return byUpdatedDesc(a, b) || byTitle(a, b);
638
+ });
639
+ return sorted;
640
+ }
641
+
642
+ function sortLabel(sortKey) {
643
+ return sortKey === "severity" ? t("sortLabel.severity")
644
+ : sortKey === "hits" ? t("sortLabel.hits")
645
+ : sortKey === "category" ? t("sortLabel.category")
646
+ : sortKey === "title" ? t("sortLabel.title")
647
+ : t("sortLabel.updated");
648
+ }
649
+
650
+ function byUpdatedDesc(a, b) {
651
+ return byText(b.updated_at || b.created_at || "", a.updated_at || a.created_at || "");
652
+ }
653
+
654
+ function byTitle(a, b) {
655
+ return byText(a.title, b.title);
656
+ }
657
+
658
+ function byText(a, b) {
659
+ return String(a || "").localeCompare(String(b || ""));
660
+ }
661
+
662
+ function severityRank(severity) {
663
+ return severity === "critical" ? 4 : severity === "error" ? 3 : severity === "warning" ? 2 : severity === "info" ? 1 : 0;
664
+ }
665
+
666
+ function trapSearchText(trap) {
667
+ return [
668
+ trap.title,
669
+ trap.category,
670
+ trap.severity,
671
+ trap.status,
672
+ trap.scope,
673
+ trap.context,
674
+ trap.mistake,
675
+ trap.fix,
676
+ trap.module,
677
+ trap.owner,
678
+ ...(trap.tags || []),
679
+ ...(trap.path_globs || []),
680
+ ].filter(Boolean).join(" ").toLowerCase();
681
+ }
682
+
683
+ function selectVisibleTrap(traps = visibleTraps()) {
684
+ if (!traps.some((trap) => trapKey(trap) === state.trapKey)) {
685
+ state.trapKey = traps[0] ? trapKey(traps[0]) : null;
686
+ }
687
+ }
688
+
689
+ function currentTrap() {
690
+ return state.traps.find((trap) => trapKey(trap) === state.trapKey) || null;
691
+ }
692
+
693
+ function trapKey(trap) {
694
+ return trap.scope + ":" + trap.id;
695
+ }
696
+
697
+ function bindTrapJumpButtons() {
698
+ document.querySelectorAll("[data-view-trap-scope][data-view-trap-id]").forEach((button) => {
699
+ if (button.dataset.jumpBound === "true") return;
700
+ button.dataset.jumpBound = "true";
701
+ button.addEventListener("click", async (event) => {
702
+ event.stopPropagation();
703
+ const id = Number.parseInt(button.dataset.viewTrapId, 10);
704
+ if (!button.dataset.viewTrapScope || !Number.isInteger(id)) return;
705
+ await jumpToTrap(button.dataset.viewTrapScope, id);
706
+ });
707
+ });
708
+ }
709
+
710
+ async function jumpToTrap(scope, id) {
711
+ const key = scope + ":" + id;
712
+ state.mainView = "library";
713
+ state.candidateId = null;
714
+ state.trapSearch = "";
715
+ state.trapFilters = { scope, status: "all", category: "", module: "", owner: "" };
716
+ state.trapKey = key;
717
+ renderMainViewButtons();
718
+ await loadTraps();
719
+ if (state.traps.some((trap) => trapKey(trap) === key)) {
720
+ state.trapKey = key;
721
+ renderTrapResults();
722
+ renderTrapDetail();
723
+ showStatus(t("status.openedTrap", { id }));
724
+ } else {
725
+ showStatus(t("status.trapNotInLibrary", { id }), true);
726
+ }
727
+ }
728
+
729
+ function renderCandidateViewTabs(pendingCount, reviewedCount) {
730
+ document.querySelectorAll("[data-candidate-view]").forEach((button) => {
731
+ const view = button.dataset.candidateView;
732
+ const count = view === "inbox" ? pendingCount : reviewedCount;
733
+ button.classList.toggle("active", view === state.candidateView);
734
+ button.textContent = t(view === "inbox" ? "tab.inbox" : "tab.reviewed", { count });
735
+ });
736
+ }
737
+
738
+ function sortedVisibleCandidates() {
739
+ return state.candidates
740
+ .filter(candidateVisible)
741
+ .sort((a, b) => statusRank(a.status) - statusRank(b.status) || b.quality_score - a.quality_score);
742
+ }
743
+
744
+ function candidateVisible(candidate) {
745
+ return state.candidateView === "inbox" ? candidate.status === "proposed" : candidate.status !== "proposed";
746
+ }
747
+
748
+ function selectVisibleCandidate(candidates = sortedVisibleCandidates()) {
749
+ if (!candidates.some((candidate) => candidate.id === state.candidateId)) {
750
+ state.candidateId = candidates[0]?.id || null;
751
+ }
752
+ }
753
+
754
+ function renderTrapDetail() {
755
+ if (state.mainView !== "library") return;
756
+ const trap = currentTrap();
757
+ el("detail-title").textContent = t("title.trapDetail");
758
+ el("detail-meta").textContent = trap ? "#" + trap.id + " / " + valueLabel(trap.scope) : t("meta.selectTrap");
759
+ if (!trap) {
760
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.noTrapSelected")) + '</div>';
761
+ return;
762
+ }
763
+
764
+ const key = trapKey(trap);
765
+ const details = state.trapDetails[key];
766
+ if (!details) {
767
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.loadingTrapDetails")) + '</div>';
768
+ ensureTrapDetail(trap);
769
+ return;
770
+ }
771
+
772
+ const detailTrap = details.trap;
773
+ el("detail").innerHTML = \`
774
+ <div class="scroll">
775
+ <div class="section">
776
+ <div class="meta">
777
+ <span class="pill scope">\${escapeHtml(valueLabel(details.scope))}</span>
778
+ <span class="pill \${escapeAttr(detailTrap.severity)}">\${escapeHtml(valueLabel(detailTrap.severity))}</span>
779
+ <span class="pill">\${escapeHtml(valueLabel(detailTrap.category))}</span>
780
+ <span class="pill \${escapeAttr(detailTrap.status)}">\${escapeHtml(valueLabel(detailTrap.status))}</span>
781
+ <span class="pill">\${escapeHtml(t("pill.hits", { count: Number(detailTrap.hit_count || 0) }))}</span>
782
+ </div>
783
+ <div class="title" style="font-size:16px">\${escapeHtml(detailTrap.title)}</div>
784
+ </div>
785
+ <div class="section">
786
+ \${textBlock(t("label.context"), detailTrap.context)}
787
+ \${textBlock(t("label.mistake"), detailTrap.mistake)}
788
+ \${textBlock(t("label.fix"), detailTrap.fix)}
789
+ </div>
790
+ <div class="section">
791
+ <div class="detail-kv">
792
+ \${kv(t("label.tags"), (detailTrap.tags || []).join(", ") || "-")}
793
+ \${kv(t("label.pathGlobs"), (detailTrap.path_globs || []).join(", ") || "-")}
794
+ \${kv(t("label.module"), detailTrap.module || "-")}
795
+ \${kv(t("label.owner"), detailTrap.owner || "-")}
796
+ \${kv(t("label.created"), detailTrap.created_at || "-")}
797
+ \${kv(t("label.updated"), detailTrap.updated_at || "-")}
798
+ \${kv(t("label.stateKey"), detailTrap.state_key || "-")}
799
+ \${kv(t("label.supersedes"), detailTrap.supersedes_id ?? "-")}
800
+ \${kv(t("label.validFrom"), detailTrap.valid_from || "-")}
801
+ \${kv(t("label.validUntil"), detailTrap.valid_until || "-")}
802
+ </div>
803
+ </div>
804
+ \${renderTrapCode(t("title.before"), detailTrap.before_code)}
805
+ \${renderTrapCode(t("title.after"), detailTrap.after_code)}
806
+ <div class="section">
807
+ <div class="title">\${escapeHtml(t("title.evidence"))}</div>
808
+ \${details.evidence.length ? details.evidence.map(renderEvidence).join("") : '<div class="empty">' + escapeHtml(t("empty.noEvidence")) + '</div>'}
809
+ </div>
810
+ </div>
811
+ \`;
812
+ }
813
+
814
+ async function ensureTrapDetail(trap) {
815
+ const key = trapKey(trap);
816
+ if (state.trapDetails[key] || state.trapLoadingKey === key) return;
817
+ state.trapLoadingKey = key;
818
+ try {
819
+ const params = new URLSearchParams({
820
+ project: state.projectRoot,
821
+ id: String(trap.id),
822
+ scope: trap.scope,
823
+ });
824
+ state.trapDetails[key] = await api("/api/trap?" + params.toString());
825
+ if (state.mainView === "library" && state.trapKey === key) renderTrapDetail();
826
+ } catch (error) {
827
+ showStatus(error.message, true);
828
+ } finally {
829
+ if (state.trapLoadingKey === key) state.trapLoadingKey = null;
830
+ }
831
+ }
832
+
833
+ function textBlock(label, value) {
834
+ return \`<div class="text-block"><label>\${escapeHtml(label)}</label><div class="content">\${escapeHtml(value || "-")}</div></div>\`;
835
+ }
836
+
837
+ function kv(label, value) {
838
+ return \`<div class="kv"><div class="kv-label">\${escapeHtml(label)}</div><div class="kv-value">\${escapeHtml(value)}</div></div>\`;
839
+ }
840
+
841
+ function renderTrapCode(label, value) {
842
+ if (!value) return "";
843
+ return \`<div class="section"><div class="title">\${escapeHtml(label)}</div><pre class="code-block"><code>\${escapeHtml(value)}</code></pre></div>\`;
844
+ }
845
+
846
+ function renderDetail() {
847
+ if (state.mainView !== "review") return;
848
+ const candidate = state.candidates.find((item) => item.id === state.candidateId);
849
+ el("detail-meta").textContent = candidate ? candidate.id + " / " + valueLabel(candidate.status) : t("meta.selectCandidate");
850
+ if (!candidate) {
851
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.noCandidateSelected")) + '</div>';
852
+ return;
853
+ }
854
+ const disabled = candidate.status !== "proposed" ? "disabled" : "";
855
+ el("detail").innerHTML = \`
856
+ <div class="scroll">
857
+ \${renderReviewNotice(candidate)}
858
+ <form class="section" id="candidate-form">
859
+ <div class="form-grid">
860
+ \${field("title", t("label.title"), candidate.trap.title, disabled)}
861
+ \${selectField("category", t("label.category"), candidate.trap.category, state.options.categories, disabled)}
862
+ \${selectField("scope", t("label.scope"), candidate.trap.scope, state.options.scopes, disabled)}
863
+ \${selectField("severity", t("label.severity"), candidate.trap.severity || "warning", state.options.severities, disabled)}
864
+ \${field("tags", t("label.tags"), (candidate.trap.tags || []).join(", "), disabled)}
865
+ \${field("path_globs", t("label.pathGlobs"), (candidate.trap.path_globs || []).join(", "), disabled)}
866
+ \${field("module", t("label.module"), candidate.trap.module || "", disabled)}
867
+ \${field("owner", t("label.owner"), candidate.trap.owner || "", disabled)}
868
+ \${textarea("context", t("label.context"), candidate.trap.context, disabled)}
869
+ \${textarea("mistake", t("label.mistake"), candidate.trap.mistake, disabled)}
870
+ \${textarea("fix", t("label.fix"), candidate.trap.fix, disabled)}
871
+ </div>
872
+ </form>
873
+ <div class="section">
874
+ <div class="meta">
875
+ <span class="pill">\${escapeHtml(t("pill.quality", { score: Number(candidate.quality_score).toFixed(2) }))}</span>
876
+ <span class="pill">\${escapeHtml(t("pill.conflict", { status: valueLabel(candidate.quality.conflict_status) }))}</span>
877
+ <span class="pill">\${escapeHtml(t("pill.action", { action: valueLabel(candidate.quality.suggested_action) }))}</span>
878
+ </div>
879
+ \${candidate.quality.warnings.map((warning) => '<div class="warning">' + escapeHtml(warning) + '</div>').join("")}
880
+ </div>
881
+ <div class="section">
882
+ <div class="title">\${escapeHtml(t("title.evidence"))}</div>
883
+ \${candidate.evidence.length ? candidate.evidence.map(renderEvidence).join("") : '<div class="empty">' + escapeHtml(t("empty.noEvidence")) + '</div>'}
884
+ </div>
885
+ \${renderConflicts()}
886
+ </div>
887
+ \${renderDetailActions(candidate, disabled)}
888
+ \`;
889
+ bindDetailActions(candidate);
890
+ bindTrapJumpButtons();
891
+ }
892
+
893
+ function renderReviewNotice(candidate) {
894
+ const review = candidate.review;
895
+ if (!review || review.status === "pending") return "";
896
+ if (review.status === "accepted_missing") {
897
+ return \`<div class="section"><div class="warning">
898
+ <div class="meta">
899
+ <span class="pill accepted-missing">\${escapeHtml(reviewLabel(candidate))}</span>
900
+ <button type="button" class="ghost" data-clean-deleted-candidates>\${escapeHtml(t("action.cleanDeletedCandidates"))}</button>
901
+ </div>
902
+ </div></div>\`;
903
+ }
904
+ if (review.status === "accepted") {
905
+ return \`<div class="section"><div class="evidence review-note">
906
+ <div class="meta">
907
+ <span class="pill accepted">\${escapeHtml(reviewLabel(candidate))}</span>
908
+ <span class="pill">\${escapeHtml(valueLabel(review.trap_status))}</span>
909
+ <button type="button" class="ghost" data-view-trap-scope="\${escapeAttr(review.scope)}" data-view-trap-id="\${escapeAttr(review.trap_id)}">\${escapeHtml(t("action.viewTrap"))}</button>
910
+ </div>
911
+ <div class="subtle">\${escapeHtml(review.trap_title)}</div>
912
+ </div></div>\`;
913
+ }
914
+ if (review.status === "rejected") {
915
+ return \`<div class="section"><div class="evidence">
916
+ <div class="meta"><span class="pill rejected">\${escapeHtml(reviewLabel(candidate))}</span></div>
917
+ \${review.rejection_reason ? '<div class="subtle">' + escapeHtml(review.rejection_reason) + '</div>' : ''}
918
+ </div></div>\`;
919
+ }
920
+ return "";
921
+ }
922
+
923
+ function renderDetailActions(candidate, disabled) {
924
+ if (candidate.status !== "proposed") {
925
+ const review = candidate.review;
926
+ const viewTrap = review?.status === "accepted"
927
+ ? \`<button type="button" data-view-trap-scope="\${escapeAttr(review.scope)}" data-view-trap-id="\${escapeAttr(review.trap_id)}">\${escapeHtml(t("action.viewTrap"))}</button>\`
928
+ : "";
929
+ const cleanDeleted = review?.status === "accepted_missing"
930
+ ? \`<button type="button" data-clean-deleted-candidates>\${escapeHtml(t("action.cleanDeletedCandidates"))}</button>\`
931
+ : "";
932
+ return \`<div class="actions"><span class="pill \${reviewCssClass(candidate)}">\${escapeHtml(reviewLabel(candidate))}</span>\${viewTrap}\${cleanDeleted}</div>\`;
933
+ }
934
+ return \`<div class="actions">
935
+ <button id="save" class="primary" \${disabled}>\${escapeHtml(t("action.save"))}</button>
936
+ <button id="accept" \${disabled}>\${escapeHtml(t("action.accept"))}</button>
937
+ <button id="reject" class="danger" \${disabled}>\${escapeHtml(t("action.reject"))}</button>
938
+ <button id="accept-anyway" \${disabled}>\${escapeHtml(t("action.acceptAnyway"))}</button>
939
+ <input id="supersedes" placeholder="\${escapeAttr(t("placeholder.supersedesId"))}" style="width:150px" \${disabled}>
940
+ <button id="supersede" \${disabled}>\${escapeHtml(t("action.supersede"))}</button>
941
+ </div>\`;
942
+ }
943
+
944
+ function bindDetailActions(candidate) {
945
+ document.querySelectorAll("[data-clean-deleted-candidates]").forEach((button) => {
946
+ button.addEventListener("click", cleanupDeletedCandidates);
947
+ });
948
+ const save = el("save");
949
+ if (!save) return;
950
+ save.addEventListener("click", async () => {
951
+ try {
952
+ const data = await api("/api/candidate/save", {
953
+ method: "POST",
954
+ body: JSON.stringify(candidatePayload(candidate.id))
955
+ });
956
+ await syncAfterMutation(data.candidate.id);
957
+ showStatus(t("status.candidateSaved"));
958
+ } catch (error) {
959
+ showStatus(error.message, true);
960
+ }
961
+ });
962
+ el("accept").addEventListener("click", () => acceptCandidate({}));
963
+ el("accept-anyway").addEventListener("click", () => acceptCandidate({ acceptAnyway: true }));
964
+ el("supersede").addEventListener("click", () => {
965
+ const value = Number.parseInt(el("supersedes").value, 10);
966
+ if (Number.isNaN(value)) return showStatus(t("status.supersedesRequired"), true);
967
+ acceptCandidate({ supersedesId: value });
968
+ });
969
+ el("reject").addEventListener("click", async () => {
970
+ const reason = prompt(t("prompt.rejectReason")) || "";
971
+ try {
972
+ const data = await api("/api/candidate/reject", {
973
+ method: "POST",
974
+ body: JSON.stringify({ projectRoot: state.projectRoot, sessionId: state.sessionId, candidateId: candidate.id, reason })
975
+ });
976
+ await syncAfterMutation(data.candidate.id);
977
+ showStatus(t("status.candidateRejected"));
978
+ } catch (error) {
979
+ showStatus(error.message, true);
980
+ }
981
+ });
982
+ }
983
+
984
+ async function acceptCandidate(extra) {
985
+ try {
986
+ const data = await api("/api/candidate/accept", {
987
+ method: "POST",
988
+ body: JSON.stringify({ projectRoot: state.projectRoot, sessionId: state.sessionId, candidateId: state.candidateId, ...extra })
989
+ });
990
+ await syncAfterMutation(data.candidate.id);
991
+ state.conflicts = [];
992
+ showStatus(t("status.candidateAccepted"));
993
+ } catch (error) {
994
+ if (error.payload?.possible_conflicts) {
995
+ state.conflicts = error.payload.possible_conflicts;
996
+ showStatus(t("status.possibleConflict"), true);
997
+ await loadCandidates();
998
+ state.conflicts = error.payload.possible_conflicts;
999
+ renderDetail();
1000
+ } else {
1001
+ showStatus(error.message, true);
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ function candidatePayload(candidateId) {
1007
+ const form = new FormData(el("candidate-form"));
1008
+ return {
1009
+ projectRoot: state.projectRoot,
1010
+ sessionId: state.sessionId,
1011
+ candidateId,
1012
+ trap: {
1013
+ title: String(form.get("title") || ""),
1014
+ category: String(form.get("category") || ""),
1015
+ scope: String(form.get("scope") || ""),
1016
+ severity: String(form.get("severity") || ""),
1017
+ tags: splitList(form.get("tags")),
1018
+ path_globs: splitList(form.get("path_globs")),
1019
+ module: blankToNull(form.get("module")),
1020
+ owner: blankToNull(form.get("owner")),
1021
+ context: String(form.get("context") || ""),
1022
+ mistake: String(form.get("mistake") || ""),
1023
+ fix: String(form.get("fix") || "")
1024
+ }
1025
+ };
1026
+ }
1027
+
1028
+ function replaceCandidate(candidate) {
1029
+ state.candidates = state.candidates.map((item) => item.id === candidate.id ? candidate : item);
1030
+ renderCandidates();
1031
+ renderDetail();
1032
+ }
1033
+
1034
+ async function syncAfterMutation(candidateId) {
1035
+ state.candidateId = candidateId;
1036
+ await loadSessions();
1037
+ }
1038
+
1039
+ async function refreshAll() {
1040
+ try {
1041
+ await bootstrap();
1042
+ showStatus(t("status.refreshed"));
1043
+ } catch (error) {
1044
+ showStatus(error.message, true);
1045
+ }
1046
+ }
1047
+
1048
+ el("refresh").addEventListener("click", refreshAll);
1049
+ document.querySelectorAll("[data-locale]").forEach((button) => {
1050
+ button.addEventListener("click", () => setLocale(button.dataset.locale));
1051
+ });
1052
+ document.querySelectorAll("[data-main-view]").forEach((button) => {
1053
+ button.addEventListener("click", async () => {
1054
+ state.mainView = button.dataset.mainView;
1055
+ state.candidateId = null;
1056
+ state.trapKey = null;
1057
+ renderActiveView();
1058
+ if (state.mainView === "library") {
1059
+ await loadTraps();
1060
+ } else if (state.mainView === "insights") {
1061
+ await loadInsightTraps();
1062
+ } else {
1063
+ await loadCandidates();
1064
+ }
1065
+ });
1066
+ });
1067
+ document.querySelectorAll("[data-candidate-view]").forEach((button) => {
1068
+ button.addEventListener("click", () => {
1069
+ state.candidateView = button.dataset.candidateView;
1070
+ state.candidateId = null;
1071
+ state.conflicts = [];
1072
+ renderCandidates();
1073
+ renderDetail();
1074
+ });
1075
+ });
1076
+ el("project-form").addEventListener("submit", async (event) => {
1077
+ event.preventDefault();
1078
+ try {
1079
+ const path = el("project-path").value.trim();
1080
+ if (!path) return;
1081
+ const data = await api("/api/projects", { method: "POST", body: JSON.stringify({ path }) });
1082
+ state.projects = data.projects;
1083
+ state.projectRoot = data.project.root;
1084
+ state.sessionId = null;
1085
+ state.candidateId = null;
1086
+ state.trapKey = null;
1087
+ state.trapDetails = {};
1088
+ state.insightTraps = [];
1089
+ el("project-path").value = "";
1090
+ renderProjects();
1091
+ await loadSessions();
1092
+ } catch (error) {
1093
+ showStatus(error.message, true);
1094
+ }
1095
+ });
1096
+
1097
+ function field(name, label, value, disabled) {
1098
+ return \`<div class="field"><label for="\${name}">\${label}</label><input id="\${name}" name="\${name}" value="\${escapeAttr(value || "")}" \${disabled}></div>\`;
1099
+ }
1100
+
1101
+ function textarea(name, label, value, disabled) {
1102
+ return \`<div class="field full"><label for="\${name}">\${label}</label><textarea id="\${name}" name="\${name}" \${disabled}>\${escapeHtml(value || "")}</textarea></div>\`;
1103
+ }
1104
+
1105
+ function selectField(name, label, value, options, disabled) {
1106
+ 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(valueLabel(option))}</option>\`).join("")}</select></div>\`;
1107
+ }
1108
+
1109
+ function renderEvidence(evidence) {
1110
+ return \`<div class="evidence">
1111
+ <div class="meta">
1112
+ <span class="pill">\${escapeHtml(valueLabel(evidence.source_type))}</span>
1113
+ \${evidence.source_ref ? '<span class="pill">' + escapeHtml(evidence.source_ref) + '</span>' : ''}
1114
+ </div>
1115
+ <div class="subtle">\${escapeHtml((evidence.related_files || []).join(", "))}</div>
1116
+ <div>\${escapeHtml(evidence.note || "")}</div>
1117
+ </div>\`;
1118
+ }
1119
+
1120
+ function renderConflicts() {
1121
+ if (!state.conflicts.length) return "";
1122
+ return \`<div class="section"><div class="title">\${escapeHtml(t("title.possibleConflicts"))}</div>\${state.conflicts.map((conflict) => \`
1123
+ <div class="conflict">
1124
+ <div class="meta"><span class="pill danger">#\${conflict.trap_id}</span><span class="pill">\${escapeHtml(valueLabel(conflict.scope))}</span><span class="pill warn">\${escapeHtml(conflict.reason)}</span></div>
1125
+ <strong>\${escapeHtml(conflict.title)}</strong>
1126
+ <div class="subtle">\${escapeHtml(conflict.context)}</div>
1127
+ <div>\${escapeHtml(conflict.fix)}</div>
1128
+ </div>\`).join("")}</div>\`;
1129
+ }
1130
+
1131
+ function statusRank(status) {
1132
+ return status === "proposed" ? 0 : status === "accepted" ? 1 : 2;
1133
+ }
1134
+
1135
+ function reviewLabel(candidate) {
1136
+ const review = candidate.review;
1137
+ if (!review || review.status === "pending") return t("review.pending");
1138
+ if (review.status === "accepted") return t("review.accepted", { id: review.trap_id });
1139
+ if (review.status === "accepted_missing") {
1140
+ return review.trap_id === undefined ? t("review.acceptedLinkMissing") : t("review.acceptedDeleted", { id: review.trap_id });
1141
+ }
1142
+ if (review.status === "rejected") return t("review.rejected");
1143
+ return valueLabel(candidate.status);
1144
+ }
1145
+
1146
+ function reviewCssClass(candidate) {
1147
+ return String(candidate.review?.status || candidate.status).replace(/_/g, "-");
1148
+ }
1149
+
1150
+ function splitList(value) {
1151
+ return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
1152
+ }
1153
+
1154
+ function blankToNull(value) {
1155
+ const text = String(value || "").trim();
1156
+ return text ? text : null;
1157
+ }
1158
+
1159
+ function escapeHtml(value) {
1160
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
1161
+ }
1162
+
1163
+ function escapeAttr(value) {
1164
+ return escapeHtml(value);
1165
+ }
1166
+
1167
+ refreshAll();`;
1168
+ }