@phren/cli 0.0.15 → 0.0.16
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/mcp/dist/capabilities/cli.js +1 -1
- package/mcp/dist/capabilities/mcp.js +1 -1
- package/mcp/dist/capabilities/vscode.js +1 -1
- package/mcp/dist/capabilities/web-ui.js +1 -1
- package/mcp/dist/cli-graph.js +4 -1
- package/mcp/dist/cli-hooks-context.js +1 -1
- package/mcp/dist/cli-namespaces.js +17 -7
- package/mcp/dist/hooks.js +126 -26
- package/mcp/dist/mcp-data.js +38 -39
- package/mcp/dist/mcp-graph.js +2 -2
- package/mcp/dist/mcp-hooks.js +5 -64
- package/mcp/dist/mcp-skills.js +13 -5
- package/mcp/dist/memory-ui-assets.js +2 -1
- package/mcp/dist/memory-ui-graph.js +26 -12
- package/mcp/dist/memory-ui-page.js +2 -2
- package/mcp/dist/memory-ui-scripts.js +34 -18
- package/mcp/dist/memory-ui-server.js +12 -5
- package/mcp/dist/phren-paths.js +17 -0
- package/mcp/dist/shared.js +1 -1
- package/mcp/dist/skill-registry.js +25 -2
- package/package.json +1 -1
package/mcp/dist/mcp-skills.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mcpResponse } from "./mcp-types.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
|
-
import { isValidProjectName } from "./utils.js";
|
|
5
|
+
import { isValidProjectName, safeProjectPath } from "./utils.js";
|
|
6
6
|
import { parseSkillFrontmatter, validateSkillFrontmatter } from "./link-skills.js";
|
|
7
7
|
import { removeSkillPath, setSkillEnabledAndSync } from "./skill-files.js";
|
|
8
8
|
import { buildSkillManifest, findLocalSkill, findSkill, getAllSkills } from "./skill-registry.js";
|
|
@@ -120,10 +120,18 @@ export function register(server, ctx) {
|
|
|
120
120
|
}
|
|
121
121
|
fs.mkdirSync(destDir, { recursive: true });
|
|
122
122
|
const existing = findLocalSkill(phrenPath, scope, safeName);
|
|
123
|
-
const dest =
|
|
124
|
-
? existing.path
|
|
125
|
-
:
|
|
126
|
-
if (!dest
|
|
123
|
+
const dest = existing
|
|
124
|
+
? safeProjectPath(destDir, path.relative(destDir, existing.path))
|
|
125
|
+
: safeProjectPath(destDir, `${safeName}.md`);
|
|
126
|
+
if (!dest) {
|
|
127
|
+
return mcpResponse({ ok: false, error: "Skill path escapes phren store." });
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
if (fs.existsSync(dest) && fs.lstatSync(dest).isSymbolicLink()) {
|
|
131
|
+
return mcpResponse({ ok: false, error: "Refusing to write through a symlinked skill path." });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
127
135
|
return mcpResponse({ ok: false, error: "Skill path escapes phren store." });
|
|
128
136
|
}
|
|
129
137
|
const existed = Boolean(existing) || fs.existsSync(dest);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export const WEB_UI_STYLES = " /*\n * Typography scale — modular 1.25 ratio, base = 14px\n * --text-xs (caption): ~11px (base / 1.25^2, rounded)\n * --text-sm (small): 12px (base / 1.25, rounded)\n * --text-base (body): 14px\n * --text-md (h3): 18px (base * 1.25, rounded)\n * --text-lg (h2): 22px (base * 1.25^2, rounded)\n * --text-xl (h1): 28px (base * 1.25^3, rounded)\n */\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n :root {\n --text-xs: 11px;\n --text-sm: 12px;\n --text-base: 14px;\n --text-md: 18px;\n --text-lg: 22px;\n --text-xl: 28px;\n --bg: #f9f8f6;\n --surface: #ffffff;\n --surface-raised: #fafbfc;\n --surface-sunken: #f1f3f6;\n --ink: #1a1a18;\n --ink-secondary: #3d3d3a;\n --muted: #7a7872;\n --accent: #b8860b;\n --accent-hover: #9a7209;\n --accent-dim: rgba(184,134,11,.08);\n --accent-glow: rgba(184,134,11,.15);\n --cyan: #2b5f8e;\n --cyan-dim: rgba(43,95,142,.08);\n --border: #e5e7eb;\n --border-light: #f3f4f6;\n --danger: #c45a4a;\n --danger-dim: rgba(239,68,68,.08);\n --warning: #d4892e;\n --success: #10b981;\n --success-dim: rgba(16,185,129,.08);\n --purple: #b8860b;\n --purple-dim: rgba(184,134,11,.08);\n --blue: #3b82f6;\n --red: #c45a4a;\n --green: #10b981;\n --radius: 10px;\n --radius-sm: 6px;\n --shadow-sm: 0 1px 2px rgba(0,0,0,.04), 0 1px 1px rgba(0,0,0,.02);\n --shadow: 0 2px 8px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);\n --shadow-lg: 0 8px 24px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.04);\n --font: \"Inter\", system-ui, -apple-system, sans-serif;\n --mono: \"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", monospace;\n }\n\n [data-theme=\"dark\"] {\n --bg: #0d0e0c;\n --surface: #15160f;\n --surface-raised: #1c1d16;\n --surface-sunken: #0a0b08;\n --ink: #e8e4d9;\n --ink-secondary: #b5b0a3;\n --muted: #6b6860;\n --accent: #d4892e;\n --accent-hover: #e09a3a;\n --accent-dim: rgba(212,137,46,.10);\n --accent-glow: rgba(212,137,46,.18);\n --cyan: #3a7bae;\n --cyan-dim: rgba(58,123,174,.12);\n --border: #2a2820;\n --border-light: #1e1d17;\n --danger-dim: rgba(239,68,68,.1);\n --success-dim: rgba(16,185,129,.1);\n --purple-dim: rgba(212,137,46,.12);\n --shadow-sm: 0 1px 3px rgba(0,0,0,.4);\n --shadow: 0 2px 8px rgba(0,0,0,.5), 0 0 1px rgba(0,0,0,.3);\n --shadow-lg: 0 8px 32px rgba(0,0,0,.6), 0 0 1px rgba(0,0,0,.4);\n }\n [data-theme=\"dark\"] .split-item.selected { background: rgba(212,137,46,.08); border-left-color: var(--accent); }\n [data-theme=\"dark\"] .split-item:hover { background: var(--surface-raised); }\n [data-theme=\"dark\"] .hook-item.selected { background: rgba(212,137,46,.08); border-left-color: var(--accent); }\n [data-theme=\"dark\"] .hook-item:hover { background: var(--surface-raised); }\n [data-theme=\"dark\"] .projects-search { background: var(--surface); color: var(--ink); }\n [data-theme=\"dark\"] .review-filters select { background: var(--surface); color: var(--ink); }\n [data-theme=\"dark\"] .review-edit-textarea { background: var(--surface-sunken); color: var(--ink); border-color: var(--border); }\n [data-theme=\"dark\"] .reader-content textarea { background: var(--surface-sunken); color: var(--ink); }\n [data-theme=\"dark\"] .graph-container { background: #0a0b08; }\n [data-theme=\"dark\"] .reader-toolbar { background: var(--surface-sunken); }\n [data-theme=\"dark\"] .card { border-color: var(--border); }\n [data-theme=\"dark\"] .badge-project { background: rgba(212,137,46,.15); color: #d4892e; }\n [data-theme=\"dark\"] .badge { background: var(--surface-sunken); }\n [data-theme=\"dark\"] .review-card { border-color: var(--border); background: var(--surface-raised); }\n [data-theme=\"dark\"] .review-card:hover { border-color: var(--accent); }\n\n body {\n font-family: var(--font);\n background: var(--bg);\n color: var(--ink);\n line-height: 1.5;\n min-height: 100vh;\n }\n\n /* ── Header ─────────────────────────────────────────────── */\n .header {\n background: rgba(255,255,255,.82);\n backdrop-filter: blur(16px) saturate(1.8);\n -webkit-backdrop-filter: blur(16px) saturate(1.8);\n border-bottom: 1px solid var(--border);\n padding: 0 24px;\n display: flex;\n align-items: center;\n gap: 32px;\n height: 56px;\n position: sticky;\n top: 0;\n z-index: 100;\n }\n [data-theme=\"dark\"] .header {\n background: rgba(13,14,12,.82);\n border-bottom-color: var(--border);\n box-shadow: 0 1px 0 rgba(212,137,46,.08);\n }\n .header-brand {\n font-size: var(--text-md);\n font-weight: 700;\n color: var(--accent);\n display: flex;\n align-items: center;\n gap: 8px;\n letter-spacing: -0.03em;\n }\n .header-brand svg {\n width: 20px;\n height: 20px;\n opacity: .8;\n }\n .nav { display: flex; gap: 0; height: 100%; }\n .nav-item {\n padding: 0 16px;\n font-size: var(--text-base);\n font-weight: 500;\n color: var(--muted);\n cursor: pointer;\n border: none;\n background: none;\n height: 100%;\n display: flex;\n align-items: center;\n border-bottom: 2px solid transparent;\n transition: color .15s, border-color .15s;\n font-family: var(--font);\n letter-spacing: -0.01em;\n }\n .nav-item:hover { color: var(--ink); }\n .nav-item.active {\n color: var(--accent);\n border-bottom-color: var(--accent);\n font-weight: 600;\n }\n .nav-item .count {\n background: var(--accent-dim);\n color: var(--accent);\n font-size: var(--text-xs);\n padding: 0 8px;\n border-radius: 10px;\n margin-left: 8px;\n font-weight: 700;\n letter-spacing: .02em;\n }\n\n /* ── Main ────────────────────────────────────────────────── */\n .main { padding: 24px; max-width: 1400px; margin: 0 auto; }\n .tab-content { display: none; }\n .tab-content.active { display: block; }\n\n /* ── Cards ───────────────────────────────────────────────── */\n .card {\n background: var(--surface-raised);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow-sm);\n overflow: hidden;\n }\n .card-header {\n padding: 12px 20px;\n border-bottom: 1px solid var(--border-light);\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--surface-raised);\n }\n .card-header h2 {\n font-size: var(--text-sm);\n font-weight: 650;\n text-transform: uppercase;\n letter-spacing: .05em;\n color: var(--muted);\n }\n .card-body { padding: 20px; }\n\n /* ── Projects Tab ────────────────────────────────────────── */\n .projects-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));\n gap: 16px;\n }\n .project-card {\n background: var(--surface-raised);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 20px;\n cursor: pointer;\n transition: box-shadow .2s, border-color .2s, transform .15s;\n position: relative;\n }\n .project-card:hover {\n box-shadow: var(--shadow-lg);\n border-color: color-mix(in srgb, var(--accent) 40%, var(--border));\n transform: translateY(-1px);\n }\n .project-card.selected {\n border-color: var(--accent);\n box-shadow: 0 0 0 1px var(--accent), var(--shadow);\n }\n .project-card-name {\n font-size: var(--text-md);\n font-weight: 600;\n margin-bottom: 8px;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .project-card-summary {\n font-size: var(--text-base);\n color: var(--muted);\n line-height: 1.5;\n margin-bottom: 12px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n }\n .project-card-stats {\n display: flex;\n gap: 16px;\n font-size: var(--text-sm);\n color: var(--muted);\n }\n .project-card-stat {\n display: flex;\n align-items: center;\n gap: 4px;\n }\n .project-card-stat strong { color: var(--ink); font-weight: 600; }\n\n /* Project detail panel */\n .project-detail {\n margin-top: 20px;\n }\n .project-detail-header {\n display: flex;\n align-items: center;\n gap: 16px;\n margin-bottom: 16px;\n }\n .project-detail-header h2 { font-size: var(--text-lg); font-weight: 700; }\n .project-detail-header .btn { font-size: var(--text-sm); }\n .project-detail-tabs {\n display: flex;\n gap: 0;\n border-bottom: 1px solid var(--border);\n margin-bottom: 0;\n }\n .project-detail-tab {\n padding: 12px 20px;\n font-size: var(--text-base);\n font-weight: 500;\n color: var(--muted);\n cursor: pointer;\n border: none;\n background: none;\n border-bottom: 2px solid transparent;\n font-family: var(--font);\n transition: color .15s;\n }\n .project-detail-tab:hover { color: var(--ink); }\n .project-detail-tab.active { color: var(--accent); border-bottom-color: var(--accent); }\n .project-detail-content {\n background: var(--surface);\n border: 1px solid var(--border);\n border-top: none;\n border-radius: 0 0 var(--radius) var(--radius);\n min-height: 400px;\n }\n .project-detail-content pre {\n margin: 0;\n padding: 20px;\n font-family: var(--mono);\n font-size: var(--text-sm);\n line-height: 1.7;\n white-space: pre-wrap;\n word-break: break-word;\n overflow: auto;\n max-height: 600px;\n }\n .project-detail-empty {\n padding: 60px 20px;\n text-align: center;\n color: var(--muted);\n font-size: var(--text-base);\n }\n\n /* ── Finding detail cards ─────────────────────────────────── */\n .finding-cards { display: flex; flex-direction: column; gap: 10px; padding: 14px; }\n .finding-detail-card {\n border: 1px solid var(--border);\n border-radius: var(--radius-sm);\n background: var(--surface-raised);\n overflow: hidden;\n }\n .finding-detail-card summary {\n list-style: none;\n cursor: pointer;\n padding: 12px 14px;\n color: var(--ink-secondary);\n font-size: var(--text-sm);\n line-height: 1.5;\n font-weight: 500;\n }\n .finding-detail-card summary::-webkit-details-marker { display: none; }\n .finding-detail-card[open] summary {\n border-bottom: 1px solid var(--border-light);\n background: color-mix(in srgb, var(--accent) 4%, var(--surface-raised));\n }\n .finding-detail-meta {\n padding: 10px 14px 12px;\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n align-items: center;\n font-size: var(--text-xs);\n color: var(--muted);\n }\n .finding-score-indicator {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n display: inline-block;\n margin-right: 6px;\n vertical-align: middle;\n }\n .finding-score-indicator.healthy { background: #10b981; }\n .finding-score-indicator.decaying { background: #f59e0b; }\n .finding-score-indicator.stale { background: #ef4444; }\n\n\n /* ── Review Tab ──────────────────────────────────────────── */\n .review-filters {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 16px;\n flex-wrap: wrap;\n }\n .review-filters select {\n padding: 8px 28px 8px 12px;\n border: 1px solid var(--border);\n border-radius: var(--radius-sm);\n font-size: var(--text-sm);\n font-family: var(--font);\n font-weight: 500;\n background: var(--surface);\n color: var(--ink-secondary);\n outline: none;\n cursor: pointer;\n transition: border-color .15s;\n appearance: none;\n -webkit-appearance: none;\n background-image: url(\"data:image/svg+xml,%3Csvg width='10' height='6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%236b7280' fill='none' stroke-width='1.5'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 8px center;\n }\n .review-filters select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }\n .review-cards { display: flex; flex-direction: column; gap: 12px; }\n .review-card {\n background: var(--surface-raised);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 0;\n box-shadow: var(--shadow-sm);\n transition: border-color .2s, box-shadow .2s;\n overflow: hidden;\n }\n .review-card:hover {\n border-color: color-mix(in srgb, var(--accent) 30%, var(--border));\n box-shadow: var(--shadow), 0 0 0 1px var(--accent-dim);\n }\n .review-card-inner {\n padding: 16px 20px;\n }\n .review-card-header {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 12px;\n flex-wrap: wrap;\n }\n .review-card-text {\n font-size: var(--text-base);\n line-height: 1.65;\n margin-bottom: 12px;\n color: var(--ink-secondary);\n }\n .review-card-text code {\n background: var(--surface-sunken);\n border: 1px solid var(--border);\n padding: 0 4px;\n border-radius: 4px;\n font-size: var(--text-sm);\n font-family: var(--mono);\n }\n .review-card-text p { margin: 0 0 8px; }\n .review-card-text p:last-child { margin-bottom: 0; }\n .review-card-actions {\n display: flex;\n gap: 8px;\n align-items: center;\n padding-top: 12px;\n border-top: 1px solid var(--border-light);\n }\n .review-card-edit {\n margin-top: 12px;\n padding-top: 12px;\n border-top: 1px solid var(--border-light);\n }\n .review-edit-textarea {\n width: 100%;\n min-height: 80px;\n padding: 12px 16px;\n border: 1px solid var(--border);\n border-radius: var(--radius-sm);\n font-size: var(--text-base);\n font-family: var(--font);\n line-height: 1.6;\n resize: vertical;\n background: var(--surface-sunken);\n color: var(--ink);\n transition: border-color .15s;\n }\n .review-edit-textarea:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-dim);\n }\n .review-help {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--surface);\n overflow: hidden;\n }\n .review-help summary {\n cursor: pointer;\n font-size: var(--text-sm);\n font-weight: 600;\n color: var(--muted);\n padding: 12px 16px;\n letter-spacing: .02em;\n text-transform: uppercase;\n }\n .review-help dl { margin: 0; padding: 0 16px 16px; font-size: var(--text-base); }\n .review-help dt { font-weight: 600; margin-top: 12px; color: var(--ink-secondary); font-size: var(--text-base); }\n .review-help dd { margin: 4px 0 0 0; color: var(--muted); line-height: 1.55; font-size: var(--text-base); }\n .review-help dd code {\n background: var(--surface-sunken);\n border: 1px solid var(--border);\n padding: 0 4px;\n border-radius: 4px;\n font-size: var(--text-sm);\n font-family: var(--mono);\n }\n .panes { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 16px; }\n .card ul { margin: 0; padding-left: 0; list-style: none; max-height: 220px; overflow: auto; font-size: var(--text-sm); }\n .card li { padding: 8px 0; color: var(--muted); border-bottom: 1px solid var(--border-light); line-height: 1.5; font-size: var(--text-sm); }\n .card li:last-child { border-bottom: none; }\n\n /* ── Star button ─────────────────────────────────────────── */\n .star-btn {\n position: absolute;\n top: 12px;\n right: 12px;\n background: none;\n border: none;\n font-size: var(--text-md);\n cursor: pointer;\n color: var(--border);\n transition: color .15s;\n padding: 4px;\n line-height: 1;\n }\n .star-btn:hover { color: var(--warning); }\n .star-btn.starred { color: var(--warning); }\n\n /* ── Project search ──────────────────────────────────────── */\n .projects-search {\n width: 100%;\n padding: 12px 16px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n font-size: var(--text-base);\n font-family: var(--font);\n margin-bottom: 16px;\n background: var(--surface);\n outline: none;\n transition: border-color .15s;\n }\n .projects-search:focus { border-color: var(--accent); }\n\n /* ── GitHub link ──────────────────────────────────────────── */\n .github-link {\n font-size: var(--text-sm);\n color: var(--muted);\n text-decoration: none;\n display: inline-flex;\n align-items: center;\n gap: 4px;\n }\n .github-link:hover { color: var(--ink); }\n\n /* ── Graph Tab ───────────────────────────────────────────── */\n .graph-container {\n background: #0d0e0c;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n overflow: hidden;\n position: relative;\n }\n #graph-canvas {\n width: 100%;\n height: calc(100vh - 160px);\n min-height: 800px;\n display: block;\n cursor: grab;\n }\n #graph-canvas:active { cursor: grabbing; }\n .graph-controls {\n position: absolute;\n top: 12px;\n right: 12px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n z-index: 2;\n }\n .graph-controls button {\n width: 36px;\n height: 36px;\n background: rgba(255,255,255,.1);\n border: 1px solid rgba(255,255,255,.15);\n border-radius: 6px;\n color: #e2e8f0;\n font-size: var(--text-md);\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n backdrop-filter: blur(8px);\n transition: background .15s;\n }\n .graph-controls button:hover { background: rgba(255,255,255,.2); }\n .graph-legend {\n display: flex;\n gap: 24px;\n padding: 14px 20px;\n background: rgba(10,11,8,.85);\n border-top: 1px solid rgba(255,255,255,.1);\n backdrop-filter: blur(8px);\n }\n .graph-legend-item {\n display: flex;\n align-items: center;\n gap: 8px;\n color: #94a3b8;\n font-size: var(--text-sm);\n font-weight: 500;\n }\n .graph-legend-dot {\n width: 12px;\n height: 12px;\n border-radius: 50%;\n display: inline-block;\n }\n .graph-tooltip {\n position: absolute;\n background: rgba(10,11,8,.95);\n color: #e2e8f0;\n padding: 8px 12px;\n border-radius: 6px;\n font-size: var(--text-sm);\n max-width: 300px;\n pointer-events: none;\n opacity: 0;\n transition: opacity .15s;\n border: 1px solid rgba(255,255,255,.1);\n line-height: 1.4;\n z-index: 10;\n }\n .graph-tooltip.visible { opacity: 1; }\n .graph-filters {\n position: absolute;\n top: 12px;\n left: 12px;\n right: 52px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 10px 14px;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 8px;\n backdrop-filter: blur(8px);\n }\n .graph-filter {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n align-items: center;\n }\n .graph-filter-btn {\n padding: 4px 12px;\n background: rgba(255,255,255,.08);\n border: 1px solid rgba(255,255,255,.12);\n border-radius: 20px;\n color: #94a3b8;\n font-size: var(--text-xs);\n font-weight: 500;\n cursor: pointer;\n transition: all .15s;\n font-family: var(--font);\n }\n .graph-filter-btn:hover, .graph-filter-btn.active {\n background: rgba(255,255,255,.15);\n color: #e2e8f0;\n }\n\n /* ── Two-pane (Skills/Hooks) ─────────────────────────────── */\n .split-view {\n display: grid;\n grid-template-columns: 280px 1fr;\n gap: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--surface);\n height: calc(100vh - 140px);\n min-height: 520px;\n overflow: hidden;\n }\n .split-sidebar {\n border-right: 1px solid var(--border);\n overflow-y: auto;\n background: var(--surface-sunken);\n }\n .split-group-label {\n padding: 8px 16px;\n font-size: var(--text-xs);\n text-transform: uppercase;\n letter-spacing: .06em;\n color: var(--muted);\n font-weight: 600;\n background: var(--surface-sunken);\n border-bottom: 1px solid var(--border);\n position: sticky;\n top: 0;\n }\n .split-item {\n padding: 12px 16px;\n cursor: pointer;\n border-bottom: 1px solid var(--border-light);\n font-size: var(--text-base);\n transition: background .1s;\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n .split-item:hover { background: var(--surface-raised); }\n .split-item.selected { background: var(--accent-dim); border-left: 3px solid var(--accent); padding-left: 12px; }\n .split-reader {\n display: flex;\n flex-direction: column;\n }\n .reader-toolbar {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 12px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface-sunken);\n }\n .reader-title { font-weight: 650; font-size: var(--text-base); flex-shrink: 0; }\n .reader-path {\n font-size: var(--text-xs);\n color: var(--muted);\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-family: var(--mono);\n }\n .reader-content {\n flex: 1;\n overflow: auto;\n display: flex;\n flex-direction: column;\n min-height: 0;\n }\n .reader-content pre {\n margin: 0;\n padding: 20px;\n font-size: var(--text-sm);\n line-height: 1.7;\n font-family: var(--mono);\n white-space: pre-wrap;\n word-break: break-word;\n }\n .reader-content textarea {\n flex: 1;\n width: 100%;\n min-height: 300px;\n padding: 20px;\n font-size: var(--text-sm);\n line-height: 1.7;\n font-family: var(--mono);\n border: none;\n outline: none;\n resize: none;\n }\n .reader-empty {\n padding: 60px 20px;\n text-align: center;\n color: var(--muted);\n font-size: var(--text-base);\n }\n\n /* Hook items */\n .hook-item {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 12px 16px;\n border-bottom: 1px solid var(--border-light);\n cursor: pointer;\n transition: background .1s;\n }\n .hook-item:hover { background: var(--surface-raised); }\n .hook-item.selected { background: var(--accent-dim); border-left: 3px solid var(--accent); padding-left: 12px; }\n .hook-name { flex: 1; font-size: var(--text-base); font-weight: 500; }\n .hook-custom-event { font-size: var(--text-sm); font-weight: 600; color: var(--ink); }\n .hook-custom-cmd { font-size: var(--text-xs); color: var(--muted); word-break: break-all; margin-top: 4px; }\n\n /* ── Badges & Buttons ────────────────────────────────────── */\n .badge {\n display: inline-flex;\n align-items: center;\n padding: 2px 8px;\n border-radius: 6px;\n font-size: var(--text-xs);\n font-weight: 600;\n background: var(--surface-sunken);\n color: var(--muted);\n letter-spacing: .02em;\n text-transform: uppercase;\n }\n .badge-project { background: var(--purple-dim); color: var(--purple); }\n .badge-on { background: var(--success-dim); color: var(--success); }\n .badge-off { background: var(--danger-dim); color: var(--danger); }\n .badge-count { background: var(--accent); color: white; min-width: 20px; text-align: center; border-radius: 10px; }\n .badge-machine { background: var(--accent-dim); color: var(--accent); }\n .badge-model { background: rgba(245,158,11,.1); color: #d97706; }\n\n .btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n padding: 8px 16px;\n border-radius: var(--radius-sm);\n font-size: var(--text-sm);\n font-weight: 550;\n font-family: var(--font);\n cursor: pointer;\n transition: all .15s ease;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--ink-secondary);\n letter-spacing: -0.01em;\n }\n .btn:hover { background: var(--surface-sunken); color: var(--ink); }\n .btn:active { transform: scale(.97); }\n .btn-primary { background: var(--accent); color: white; border-color: transparent; }\n .btn-primary:hover { background: var(--accent-hover); }\n .btn-approve {\n background: var(--success-dim);\n color: var(--success);\n border-color: transparent;\n font-weight: 600;\n }\n .btn-approve:hover { background: var(--success); color: white; }\n .btn-reject {\n background: transparent;\n color: var(--muted);\n border-color: var(--border);\n }\n .btn-reject:hover { background: var(--danger-dim); color: var(--danger); border-color: var(--danger); }\n .btn-sm { padding: 4px 12px; font-size: var(--text-xs); }\n\n kbd {\n background: var(--surface-sunken);\n border: 1px solid var(--border);\n border-radius: 4px;\n padding: 0 8px;\n font-size: var(--text-xs);\n font-family: var(--mono);\n font-weight: 550;\n color: var(--muted);\n line-height: 1;\n }\n\n .text-muted { color: var(--muted); }\n .status-msg { font-size: var(--text-sm); padding: 4px 8px; border-radius: var(--radius-sm); }\n .status-msg.ok { background: var(--success-dim); color: var(--green); }\n .status-msg.err { background: var(--danger-dim); color: var(--red); }\n\n /* ── Status LED ──────────────────────────────────────────────── */\n .status-led {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n display: inline-block;\n vertical-align: middle;\n margin-right: 8px;\n flex-shrink: 0;\n }\n .status-led-ok {\n background: #b8860b;\n color: #22c55e;\n animation: ledPulse 2.5s infinite;\n }\n .status-led-warn {\n background: #f59e0b;\n color: #f59e0b;\n animation: ledPulse 1.2s infinite;\n }\n .status-led-err {\n background: #ef4444;\n color: #ef4444;\n animation: ledPulse 0.6s infinite;\n }\n @keyframes ledPulse {\n 0% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }\n 70% { opacity: 0.8; box-shadow: 0 0 0 5px transparent; }\n 100% { opacity: 1; }\n }\n\n @media (max-width: 900px) {\n .projects-grid { grid-template-columns: 1fr; }\n .split-view { grid-template-columns: 1fr; }\n .panes { grid-template-columns: 1fr; }\n .header { padding: 0 12px; gap: 12px; }\n .main { padding: 16px; }\n }\n\n ::view-transition-old(root), ::view-transition-new(root) {\n animation-duration: 0.18s;\n }\n\n @keyframes cardIn {\n from { opacity: 0; transform: translateY(8px); }\n to { opacity: 1; transform: translateY(0); }\n }\n .review-card {\n animation: cardIn 0.3s cubic-bezier(.21,1.02,.73,1) backwards;\n }\n .review-card.removing {\n animation: cardOut 0.25s ease forwards;\n pointer-events: none;\n }\n @keyframes cardOut {\n to { opacity: 0; transform: translateY(-6px) scale(.98); margin-top: -4px; padding: 0; }\n }\n\n /* ── Batch actions ───────────────────────────────────────────── */\n .review-card-check {\n position: absolute;\n top: 16px;\n right: 16px;\n width: 18px;\n height: 18px;\n border: 2px solid var(--border);\n border-radius: 4px;\n cursor: pointer;\n background: var(--surface);\n transition: all .15s;\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 2;\n }\n .review-card-check:hover { border-color: var(--accent); }\n .review-card-check.checked {\n background: var(--accent);\n border-color: var(--accent);\n }\n .review-card-check.checked::after {\n content: '';\n width: 6px;\n height: 10px;\n border: solid white;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n margin-top: -2px;\n }\n .review-card { position: relative; }\n .batch-bar {\n position: fixed;\n bottom: 24px;\n left: 50%;\n transform: translateX(-50%) translateY(80px);\n background: var(--surface-raised, #1a2233);\n border: 1px solid var(--border);\n border-radius: 12px;\n padding: 12px 16px;\n display: flex;\n align-items: center;\n gap: 12px;\n box-shadow: 0 8px 32px rgba(0,0,0,.2);\n z-index: 500;\n transition: transform .3s cubic-bezier(.21,1.02,.73,1);\n pointer-events: all;\n }\n .batch-bar.visible {\n transform: translateX(-50%) translateY(0);\n }\n .batch-bar-count {\n font-size: var(--text-base);\n font-weight: 600;\n color: var(--ink);\n min-width: 80px;\n }\n\n .review-banner-pill {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 2px 10px;\n border-radius: 99px;\n font-size: var(--text-xs);\n font-weight: 600;\n background: var(--surface-raised);\n border: 1px solid var(--border);\n color: var(--ink);\n }\n .review-banner-pill.rp-danger {\n background: rgba(239,68,68,.12);\n border-color: rgba(239,68,68,.3);\n color: #f87171;\n }\n .review-banner-pill.rp-warn {\n background: rgba(251,191,36,.10);\n border-color: rgba(251,191,36,.3);\n color: #fbbf24;\n }\n\n /* ── Diff view ───────────────────────────────────────────────── */\n .review-diff {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 1px;\n background: var(--border);\n border: 1px solid var(--border);\n border-radius: var(--radius-sm);\n overflow: hidden;\n margin-bottom: 8px;\n font-size: var(--text-sm);\n font-family: var(--mono);\n line-height: 1.6;\n }\n .review-diff-pane {\n padding: 12px;\n background: var(--surface-sunken);\n white-space: pre-wrap;\n word-break: break-word;\n min-height: 60px;\n }\n .review-diff-pane-label {\n font-size: var(--text-xs);\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: .05em;\n color: var(--muted);\n margin-bottom: 8px;\n font-family: var(--font);\n }\n .diff-del { background: var(--danger-dim); color: var(--danger); text-decoration: line-through; }\n .diff-ins { background: var(--success-dim); color: var(--success); }\n\n /* ── Drag reorder ────────────────────────────────────────────── */\n .review-card.dragging {\n opacity: .5;\n transform: scale(.98);\n z-index: 10;\n }\n .review-card.drag-over {\n border-top: 2px solid var(--accent);\n margin-top: -1px;\n }\n .review-card-drag-handle {\n width: 16px;\n height: 16px;\n cursor: grab;\n color: var(--border);\n transition: color .15s;\n flex-shrink: 0;\n margin-right: 4px;\n }\n .review-card-drag-handle:hover { color: var(--muted); }\n .review-card-drag-handle:active { cursor: grabbing; }\n\n\n /* ── Similarity groups ────────────────────────────────────────── */\n .review-group {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 8px;\n margin-bottom: 12px;\n background: color-mix(in srgb, var(--surface) 95%, var(--accent) 5%);\n }\n .review-group .review-card { margin-bottom: 8px; }\n .review-group .review-card:last-child { margin-bottom: 0; }\n .review-group-header {\n font-size: var(--text-xs);\n color: var(--muted);\n margin-bottom: 8px;\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 0 4px;\n }\n .review-group-toggle {\n background: none;\n border: none;\n color: var(--muted);\n cursor: pointer;\n font-size: var(--text-xs);\n padding: 0 4px;\n }\n .review-group-toggle:hover { color: var(--ink-secondary); }\n .review-group.collapsed .review-card:not(:first-child) { display: none; }\n\n /* ── Select all ──────────────────────────────────────────────── */\n .review-select-all {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: var(--text-sm);\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n margin-bottom: 8px;\n }\n .review-select-all input[type=\"checkbox\"] {\n width: 14px;\n height: 14px;\n cursor: pointer;\n accent-color: var(--accent);\n }\n /* ── Toast ───────────────────────────────────────────────────── */\n .toast-container {\n position: fixed;\n bottom: 24px;\n left: 50%;\n transform: translateX(-50%);\n z-index: 999;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 8px;\n pointer-events: none;\n }\n .toast {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 12px 16px;\n background: var(--surface-raised, #1a2233);\n color: var(--ink, #e8ecf4);\n border: 1px solid var(--border, #1e2a3e);\n border-radius: var(--radius);\n font-size: var(--text-base);\n font-weight: 500;\n box-shadow: 0 8px 32px rgba(0,0,0,.2);\n pointer-events: all;\n animation: toastIn 0.25s cubic-bezier(.21,1.02,.73,1);\n max-width: 420px;\n backdrop-filter: blur(12px);\n }\n .toast.ok { border-color: var(--success); background: var(--success-dim); color: var(--success); }\n .toast.err { border-color: var(--danger); background: var(--danger-dim); color: var(--danger); }\n @keyframes toastIn {\n from { opacity: 0; transform: translateY(12px) scale(.96); }\n to { opacity: 1; transform: translateY(0) scale(1); }\n }\n .toast-undo {\n background: none;\n border: 1px solid currentColor;\n color: inherit;\n border-radius: var(--radius-sm);\n padding: 4px 12px;\n font-size: var(--text-xs);\n font-weight: 600;\n cursor: pointer;\n font-family: var(--font);\n opacity: .8;\n transition: opacity .15s;\n }\n .toast-undo:hover { opacity: 1; }\n\n /* ── Command Palette ─────────────────────────────────────────── */\n .cmdpal-overlay {\n position: fixed;\n inset: 0;\n background: rgba(0,0,0,.4);\n backdrop-filter: blur(4px);\n z-index: 900;\n display: none;\n align-items: flex-start;\n justify-content: center;\n padding-top: 14vh;\n }\n .cmdpal-overlay.open { display: flex; }\n .cmdpal-box {\n background: var(--surface-raised);\n border: 1px solid var(--border);\n border-radius: 12px;\n width: 100%;\n max-width: 520px;\n box-shadow: 0 24px 80px rgba(0,0,0,.25);\n overflow: hidden;\n }\n .cmdpal-input {\n width: 100%;\n padding: 16px 20px;\n font-size: var(--text-md);\n font-family: var(--font);\n border: none;\n outline: none;\n background: transparent;\n color: var(--ink);\n border-bottom: 1px solid var(--border);\n font-weight: 450;\n }\n .cmdpal-input::placeholder { color: var(--muted); }\n .cmdpal-results { max-height: 340px; overflow-y: auto; }\n .cmdpal-item {\n padding: 12px 20px;\n cursor: pointer;\n font-size: var(--text-base);\n display: flex;\n align-items: center;\n gap: 8px;\n transition: background .1s;\n }\n .cmdpal-item:hover, .cmdpal-item.selected { background: var(--accent-dim); }\n .cmdpal-item-name { font-weight: 550; color: var(--ink); }\n .cmdpal-item-meta { font-size: var(--text-xs); color: var(--muted); margin-left: auto; font-weight: 500; }\n .cmdpal-empty { padding: 32px 20px; text-align: center; color: var(--muted); font-size: var(--text-base); }\n @keyframes countPop {\n 0% { transform: scale(1); }\n 50% { transform: scale(1.3); }\n 100% { transform: scale(1); }\n }\n @keyframes countFlip {\n 0% { transform: scale(1) rotateX(0); }\n 40% { transform: scale(1.3) rotateX(90deg); }\n 60% { transform: scale(1.3) rotateX(-10deg); }\n 100% { transform: scale(1) rotateX(0); }\n }\n .count-animating {\n animation: countPop 0.3s ease-out;\n }\n .count-flipping {\n animation: countFlip 0.3s ease-out;\n }";
|
|
2
2
|
const WEB_UI_SCRIPT_TEMPLATE = "(function() {\n // ── State ────────────────────────────────────────────────────\n var _authToken = '__PHREN_AUTH_TOKEN_1d7b7c99__';\n var _skillsLoaded = false, _hooksLoaded = false, _graphLoaded = false;\n var _currentSkillPath = null, _currentHookPath = null;\n var _editingSkill = false, _editingHook = false;\n var _selectedProject = null;\n var _scoresPromise = null;\n\n var _activeFetches = 0;\n function setLed(state) {\n var led = document.getElementById('sync-led');\n if (!led) return;\n led.className = 'status-led status-led-' + state;\n led.title = state === 'ok' ? 'Synced' : state === 'warn' ? 'Working...' : 'Error';\n }\n function ledFetch(url, opts) {\n _activeFetches++;\n setLed('warn');\n return fetch(url, opts).then(function(r) {\n _activeFetches--;\n if (_activeFetches <= 0) { _activeFetches = 0; setLed('ok'); }\n return r;\n }).catch(function(err) {\n _activeFetches--;\n if (_activeFetches <= 0) { _activeFetches = 0; setLed('err'); }\n setTimeout(function() { if (_activeFetches <= 0) setLed('ok'); }, 3000);\n throw err;\n });\n }\n\n // ── Tab switching ────────────────────────────────────────────\n window.switchTab = function(tab) {\n function doSwitch() {\n document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });\n document.querySelectorAll('.nav-item').forEach(function(el) { el.classList.remove('active'); });\n var tabEl = document.getElementById('tab-' + tab);\n if (tabEl) tabEl.classList.add('active');\n var navBtn = document.querySelector('.nav-item[data-tab=\"' + tab + '\"]');\n if (navBtn) navBtn.classList.add('active');\n if (tab === 'projects' && !document.querySelector('.project-card')) loadProjects();\n if (tab === 'review') { loadReviewCards(); loadReviewActivity(); }\n if (tab === 'skills' && !_skillsLoaded) loadSkills();\n if (tab === 'hooks' && !_hooksLoaded) loadHooks();\n if (tab === 'graph' && !_graphLoaded) loadGraph();\n }\n if (document.startViewTransition) {\n document.startViewTransition(doSwitch);\n } else {\n doSwitch();\n }\n };\n\n // ── Projects ─────────────────────────────────────────────────\n function getStarredProjects() {\n try { return JSON.parse(localStorage.getItem('phren-starred-projects') || '[]'); } catch { return []; }\n }\n function setStarredProjects(arr) {\n localStorage.setItem('phren-starred-projects', JSON.stringify(arr));\n }\n\n function renderProjectCards(data) {\n var grid = document.getElementById('projects-grid');\n if (!data.length) {\n grid.innerHTML = '<div style=\"padding:60px;color:var(--muted);grid-column:1/-1;text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 12px;display:block\"><path d=\"M18 30h44v26a4 4 0 01-4 4H22a4 4 0 01-4-4V30z\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><path d=\"M18 30l6-10h16l6 10\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"none\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;margin-bottom:4px\">No projects yet</div><div style=\"font-size:var(--text-sm)\">Run <code>phren init</code> to create one.</div></div>';\n return;\n }\n var starred = getStarredProjects();\n // Sort: starred first, then by activity\n var sorted = data.slice().sort(function(a, b) {\n var aStarred = starred.indexOf(a.name) !== -1 ? 1 : 0;\n var bStarred = starred.indexOf(b.name) !== -1 ? 1 : 0;\n if (aStarred !== bStarred) return bStarred - aStarred;\n return 0; // preserve server sort order\n });\n grid.innerHTML = sorted.map(function(p) {\n var isStarred = starred.indexOf(p.name) !== -1;\n var githubHtml = p.githubUrl ? '<a class=\"github-link\" href=\"'+esc(p.githubUrl)+'\" target=\"_blank\" rel=\"noopener\" data-ui-action=\"stopProp\">GitHub</a>' : '';\n var sparkHtml = '';\n if (p.sparkline && p.sparkline.some(function(v) { return v > 0; })) {\n var sp = p.sparkline;\n var max = Math.max.apply(null, sp) || 1;\n var w = 80, h = 20;\n var pts = sp.map(function(v, i) { return (i * w / (sp.length - 1)).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1); }).join(' ');\n sparkHtml = '<svg class=\"project-sparkline\" width=\"' + w + '\" height=\"' + h + '\" viewBox=\"0 0 ' + w + ' ' + h + '\" style=\"margin-top:8px;display:block\"><polyline points=\"' + pts + '\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" opacity=\".7\" /><polyline points=\"0,' + h + ' ' + pts + ' ' + w + ',' + h + '\" fill=\"var(--accent-dim)\" stroke=\"none\" /></svg>';\n }\n return '<div class=\"project-card\" data-ui-action=\"selectProject\" data-project=\"'+esc(p.name)+'\" data-summary=\"'+esc(p.summaryText || '')+'\">' +\n '<button class=\"star-btn'+(isStarred ? ' starred' : '')+'\" data-ui-action=\"toggleStar\" title=\"Star project\">★</button>' +\n '<div class=\"project-card-name\">' + esc(p.name) + '</div>' +\n (p.summaryText ? '<div class=\"project-card-summary\">' + esc(p.summaryText) + '</div>' : '<div class=\"project-card-summary\" style=\"font-style:italic\">No summary</div>') +\n '<div class=\"project-card-stats\">' +\n '<span class=\"project-card-stat\"><strong>' + p.findingCount + '</strong> findings</span>' +\n '<span class=\"project-card-stat\"><strong>' + p.taskCount + '</strong> task</span>' +\n (p.hasClaudeMd ? '<span class=\"project-card-stat\">CLAUDE.md</span>' : '') +\n (p.hasReference ? '<span class=\"project-card-stat\">reference/</span>' : '') +\n githubHtml +\n '</div>' +\n sparkHtml +\n '</div>';\n }).join('');\n }\n\n var _projectData = [];\n\n function loadProjects() {\n fetch(authUrl('/api/projects')).then(function(r) { return r.json(); }).then(function(data) {\n _projectData = data;\n renderProjectCards(data);\n });\n }\n\n // ── Review queue (live) ───────────────────────────────────────\n var _reviewData = [];\n var _reviewCardKeys = new Set(); // tracks project+line keys currently in DOM\n\n function cardKey(item) { return item.project + '\\\\x00' + item.line; }\n\n function renderReviewCard(item, delayMs) {\n var key = cardKey(item);\n var projectBadge = '<span class=\"badge badge-project\">' + esc(item.project) + '</span>';\n var sectionBadge = '<span class=\"badge\">' + esc(item.section) + '</span>';\n var machineBadge = item.machine ? '<span class=\"badge badge-machine\" title=\"Machine: ' + esc(item.machine) + '\">' + esc(item.machine) + '</span>' : '';\n var modelBadge = (item.model && item.model !== 'unknown') ? '<span class=\"badge badge-model\" title=\"Model: ' + esc(item.model) + '\">' + esc(item.model) + '</span>' : '';\n var dateSpan = '<span class=\"text-muted\" style=\"font-size:var(--text-sm);margin-left:auto\">' + esc(item.date) + '</span>';\n\n var cardText = esc(item.text);\n\n var div = document.createElement('div');\n div.className = 'review-card';\n div.setAttribute('data-key', key);\n div.setAttribute('data-project', item.project);\n div.setAttribute('data-machine', item.machine || '');\n div.setAttribute('data-model', item.model || '');\n div.style.animationDelay = delayMs + 'ms';\n div.innerHTML =\n '<div class=\"review-card-inner\">' +\n '<div class=\"review-card-header\">' +\n '<svg class=\"review-card-drag-handle\" viewBox=\"0 0 16 16\" fill=\"currentColor\"><circle cx=\"5\" cy=\"4\" r=\"1.2\"/><circle cx=\"11\" cy=\"4\" r=\"1.2\"/><circle cx=\"5\" cy=\"8\" r=\"1.2\"/><circle cx=\"11\" cy=\"8\" r=\"1.2\"/><circle cx=\"5\" cy=\"12\" r=\"1.2\"/><circle cx=\"11\" cy=\"12\" r=\"1.2\"/></svg>' +\n projectBadge + sectionBadge + machineBadge + modelBadge + dateSpan +\n '</div>' +\n '<div class=\"review-card-text\">' + cardText + '</div>' +\n '<div class=\"review-card-actions\">' +\n '<button type=\"button\" class=\"btn btn-sm btn-approve\" data-ui-action=\"reviewAction\" data-review-type=\"approve\">Approve</button>' +\n '<button type=\"button\" class=\"btn btn-sm btn-reject\" data-ui-action=\"reviewAction\" data-review-type=\"reject\">Reject</button>' +\n '<button type=\"button\" class=\"btn btn-sm\" data-ui-action=\"toggleReviewEdit\">Edit</button>' +\n '</div>' +\n '<div class=\"review-card-edit\" style=\"display:none\">' +\n '<form data-ui-action=\"reviewEditSubmit\">' +\n '<textarea name=\"new_text\" class=\"review-edit-textarea\">' + cardText + '</textarea>' +\n '<div style=\"display:flex;gap:8px;margin-top:8px\">' +\n '<button type=\"submit\" class=\"btn btn-sm btn-primary\">Save</button>' +\n '<button type=\"button\" class=\"btn btn-sm\" data-ui-action=\"toggleReviewEdit\">Cancel</button>' +\n '</div>' +\n '</form>' +\n '</div>' +\n '</div>';\n\n var approveBtn = div.querySelector('.btn-approve');\n if (approveBtn) {\n approveBtn.setAttribute('data-project', item.project);\n approveBtn.setAttribute('data-line', item.line);\n }\n var rejectBtn = div.querySelector('.btn-reject');\n if (rejectBtn) {\n rejectBtn.setAttribute('data-project', item.project);\n rejectBtn.setAttribute('data-line', item.line);\n }\n var editForm = div.querySelector('.review-card-edit form');\n if (editForm) {\n editForm.setAttribute('data-project', item.project);\n editForm.setAttribute('data-line', item.line);\n }\n\n // Render queue text as escaped plain text; review items are not trusted markdown/HTML.\n var textEl = div.querySelector('.review-card-text');\n if (textEl) {\n textEl.innerHTML = esc(item.text).replace(/\\n/g, '<br>');\n }\n\n // Add batch checkbox\n var checkbox = document.createElement('div');\n checkbox.className = 'review-card-check';\n checkbox.onclick = function(e) {\n e.stopPropagation();\n this.classList.toggle('checked');\n updateBatchBar();\n };\n div.querySelector('.review-card-inner').appendChild(checkbox);\n\n // Drag reorder\n div.setAttribute('draggable', 'true');\n var handle = div.querySelector('.review-card-drag-handle');\n if (handle) {\n handle.addEventListener('mousedown', function() { div.setAttribute('draggable', 'true'); });\n }\n div.addEventListener('dragstart', function(e) {\n div.classList.add('dragging');\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', key);\n });\n div.addEventListener('dragend', function() {\n div.classList.remove('dragging');\n document.querySelectorAll('.review-card.drag-over').forEach(function(c) { c.classList.remove('drag-over'); });\n });\n div.addEventListener('dragover', function(e) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n var dragging = document.querySelector('.review-card.dragging');\n if (dragging && dragging !== div) {\n div.classList.add('drag-over');\n }\n });\n div.addEventListener('dragleave', function() {\n div.classList.remove('drag-over');\n });\n div.addEventListener('drop', function(e) {\n e.preventDefault();\n div.classList.remove('drag-over');\n var dragging = document.querySelector('.review-card.dragging');\n if (dragging && dragging !== div) {\n var list = div.parentNode;\n var cards = Array.from(list.querySelectorAll('.review-card'));\n var dragIdx = cards.indexOf(dragging);\n var dropIdx = cards.indexOf(div);\n if (dragIdx < dropIdx) {\n list.insertBefore(dragging, div.nextSibling);\n } else {\n list.insertBefore(dragging, div);\n }\n }\n });\n\n return div;\n }\n\n function loadReviewCards() {\n var url = '/api/review-queue' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(data) {\n _reviewData = data;\n var list = document.getElementById('review-cards-list');\n if (!list) return;\n\n // Build set of incoming keys\n var incomingKeys = new Set(data.map(cardKey));\n\n // Remove cards no longer in queue (animate out)\n var existing = list.querySelectorAll('.review-card[data-key]');\n existing.forEach(function(card) {\n var key = card.getAttribute('data-key');\n if (!incomingKeys.has(key)) {\n card.classList.add('removing');\n setTimeout(function() { if (card.parentNode) card.parentNode.removeChild(card); }, 300);\n _reviewCardKeys.delete(key);\n }\n });\n\n // Add new cards with similarity grouping (animate in with stagger)\n var newItems = data.filter(function(item) { return !_reviewCardKeys.has(cardKey(item)); });\n if (newItems.length > 0) {\n // Remove existing groups if full re-render\n list.querySelectorAll('.review-group').forEach(function(g) { g.remove(); });\n\n var groups = clusterCards(newItems);\n var delayBase = 0;\n groups.forEach(function(groupItems) {\n if (groupItems.length > 1) {\n // Multi-item group: wrap in a group container\n var groupDiv = document.createElement('div');\n groupDiv.className = 'review-group';\n var header = document.createElement('div');\n header.className = 'review-group-header';\n header.innerHTML = '<span>Similar (' + groupItems.length + ' items)</span><button class=\"review-group-toggle\" data-ui-action=\"toggleGroup\">\\u25BC</button>';\n groupDiv.appendChild(header);\n groupItems.forEach(function(item) {\n var key = cardKey(item);\n var card = renderReviewCard(item, delayBase);\n groupDiv.appendChild(card);\n _reviewCardKeys.add(key);\n delayBase += 40;\n });\n list.appendChild(groupDiv);\n } else {\n // Singleton: render normally\n var item = groupItems[0];\n var key = cardKey(item);\n var card = renderReviewCard(item, delayBase);\n list.appendChild(card);\n _reviewCardKeys.add(key);\n delayBase += 40;\n }\n });\n }\n\n // Show/hide select-all checkbox\n var selectAllEl = document.getElementById('review-select-all');\n if (selectAllEl) selectAllEl.style.display = data.length > 0 ? 'flex' : 'none';\n\n // Empty state\n var totalVisible = list.querySelectorAll('.review-card:not(.removing)').length;\n var emptyEl = list.querySelector('.review-cards-empty');\n if (totalVisible === 0 && newItems.length === 0) {\n if (!emptyEl) {\n var empty = document.createElement('div');\n empty.className = 'review-cards-empty';\n empty.style.cssText = 'text-align:center;padding:40px;color:var(--muted)';\n empty.innerHTML = '<svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 12px;display:block\"><rect x=\"15\" y=\"20\" width=\"50\" height=\"40\" rx=\"4\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><polyline points=\"28,38 36,46 52,32\" stroke=\"var(--accent)\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;margin-bottom:4px\">All caught up</div><div style=\"font-size:var(--text-sm);color:var(--muted)\">No memories waiting for review.</div>';\n list.appendChild(empty);\n }\n } else if (emptyEl) {\n emptyEl.remove();\n }\n\n // Remove loading spinner\n var loading = list.querySelector('.review-cards-loading');\n if (loading) loading.remove();\n\n // Update nav badge\n var navBtn = document.querySelector('.nav-item[data-tab=\"review\"] .count');\n if (data.length > 0) {\n if (!navBtn) {\n var btn = document.querySelector('.nav-item[data-tab=\"review\"]');\n if (btn) {\n var countSpan = document.createElement('span');\n countSpan.className = 'count count-animating';\n countSpan.textContent = String(data.length);\n btn.appendChild(countSpan);\n }\n } else {\n var oldCount = parseInt(navBtn.textContent, 10) || 0;\n if (oldCount !== data.length) {\n animateCount(navBtn, oldCount, data.length);\n navBtn.classList.remove('count-animating');\n void navBtn.offsetWidth;\n navBtn.classList.add('count-animating');\n }\n }\n } else {\n if (navBtn) navBtn.remove();\n }\n\n // Update filters\n var machines = new Set(), models = new Set(), projects = new Set();\n data.forEach(function(item) {\n projects.add(item.project);\n if (item.machine) machines.add(item.machine);\n if (item.model && item.model !== 'unknown') models.add(item.model);\n });\n\n var filterContainer = document.getElementById('review-filters');\n if (filterContainer) {\n filterContainer.style.display = data.length > 0 ? 'flex' : 'none';\n }\n\n function updateSelect(id, values, allLabel) {\n var sel = document.getElementById(id);\n if (!sel) return;\n var current = sel.value;\n sel.innerHTML = '<option value=\"\">' + allLabel + '</option>';\n Array.from(values).sort().forEach(function(v) {\n var opt = document.createElement('option');\n opt.value = v;\n opt.textContent = v;\n if (v === current) opt.selected = true;\n sel.appendChild(opt);\n });\n }\n updateSelect('review-filter-project', projects, 'All projects');\n updateSelect('review-filter-machine', machines, 'All machines');\n updateSelect('review-filter-model', models, 'All models');\n\n // Show/hide keyboard hints\n var hints = document.getElementById('review-kbd-hints');\n if (hints) hints.style.display = data.length > 0 ? 'flex' : 'none';\n\n // Re-apply current filter\n filterReviewCards();\n }).catch(function() {\n var list = document.getElementById('review-cards-list');\n if (list) {\n var loading = list.querySelector('.review-cards-loading');\n if (loading) loading.textContent = 'Failed to load review queue.';\n }\n });\n }\n\n function loadReviewActivity() {\n var url = '/api/review-activity' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(data) {\n var acceptedEl = document.getElementById('accepted-list');\n var usageEl = document.getElementById('usage-list');\n if (acceptedEl) acceptedEl.innerHTML = data.accepted && data.accepted.length ? data.accepted.map(function(l) { return '<li>' + esc(l) + '</li>'; }).join('') : '<li style=\"color:var(--muted)\">None yet.</li>';\n if (usageEl) usageEl.innerHTML = data.usage && data.usage.length ? data.usage.map(function(l) { return '<li>' + esc(l) + '</li>'; }).join('') : '<li style=\"color:var(--muted)\">No usage events yet.</li>';\n }).catch(function() {});\n }\n\n function refreshLiveState() {\n loadProjects();\n fetch(authUrl('/api/runtime-health')).then(function(r) { return r.json(); }).then(function(data) {\n var summary = document.getElementById('sync-state-summary');\n if (!summary) return;\n summary.innerHTML =\n '<div><strong>Auto-save</strong><div class=\"text-muted\">' + esc(data.autoSaveStatus || 'n/a') + '</div></div>' +\n '<div><strong>Last pull</strong><div class=\"text-muted\">' + esc((data.lastPullStatus || 'n/a') + ' ' + (data.lastPullAt || '')) + '</div></div>' +\n '<div><strong>Last push</strong><div class=\"text-muted\">' + esc((data.lastPushStatus || 'n/a') + ' ' + (data.lastPushAt || '')) + '</div></div>' +\n '<div><strong>Unsynced commits</strong><div class=\"text-muted\">' + esc(String(data.unsyncedCommits || 0)) + '</div></div>';\n // Update sync LED\n var led = document.getElementById('sync-led');\n if (led) {\n var pushOk = !data.lastPushStatus || data.lastPushStatus === 'ok' || data.lastPushStatus === 'n/a';\n var pullOk = !data.lastPullStatus || data.lastPullStatus === 'ok' || data.lastPullStatus === 'n/a';\n var hasUnsynced = (data.unsyncedCommits || 0) > 0;\n if (!pushOk || !pullOk) {\n led.className = 'status-led status-led-err';\n led.title = 'Sync error';\n } else if (hasUnsynced) {\n led.className = 'status-led status-led-warn';\n led.title = hasUnsynced + ' unsynced commit' + (hasUnsynced > 1 ? 's' : '');\n } else {\n led.className = 'status-led status-led-ok';\n led.title = 'phren is synced';\n }\n }\n });\n if (_selectedProject) {\n var activeTab = document.querySelector('.project-detail-tab.active');\n var activeFile = activeTab ? activeTab.textContent : 'Findings';\n var fileMap = { 'Findings': 'FINDINGS.md', 'Tasks': 'tasks.md', 'CLAUDE.md': 'CLAUDE.md', 'Summary': 'summary.md', 'Reference': 'reference:browser' };\n loadProjectFile(fileMap[activeFile] || 'FINDINGS.md', activeTab);\n }\n if (_skillsLoaded) loadSkills();\n if (_hooksLoaded) loadHooks();\n if (_graphLoaded && !_graphRunning) loadGraph();\n loadReviewCards();\n loadReviewActivity();\n }\n\n function pollLiveUpdates() {\n fetch(authUrl('/api/change-token'))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data || !data.token) return;\n if (!_lastChangeToken) {\n _lastChangeToken = data.token;\n return;\n }\n if (data.token === _lastChangeToken) return;\n _lastChangeToken = data.token;\n refreshLiveState();\n })\n .catch(function() {});\n }\n\n window.toggleStar = function(name) {\n var starred = getStarredProjects();\n var idx = starred.indexOf(name);\n if (idx !== -1) starred.splice(idx, 1); else starred.push(name);\n setStarredProjects(starred);\n renderProjectCards(_projectData);\n };\n\n window.filterProjects = function(query) {\n var cards = document.querySelectorAll('.project-card');\n var q = query.toLowerCase();\n cards.forEach(function(card) {\n var name = (card.getAttribute('data-project') || '').toLowerCase();\n var summary = (card.getAttribute('data-summary') || '').toLowerCase();\n card.style.display = (!q || name.indexOf(q) !== -1 || summary.indexOf(q) !== -1) ? '' : 'none';\n });\n };\n\n window.toggleReviewEdit = function(btn) {\n var card = btn.closest('.review-card');\n if (!card) return;\n var editSection = card.querySelector('.review-card-edit');\n if (!editSection) return;\n var isVisible = editSection.style.display !== 'none';\n editSection.style.display = isVisible ? 'none' : 'block';\n\n if (!isVisible) {\n // Create diff view if not present\n var existing = editSection.querySelector('.review-diff');\n if (existing) existing.remove();\n\n var ta = editSection.querySelector('textarea');\n var originalText = card.querySelector('.review-card-text') ? card.querySelector('.review-card-text').textContent.trim() : '';\n\n var diffContainer = document.createElement('div');\n diffContainer.className = 'review-diff';\n diffContainer.innerHTML =\n '<div class=\"review-diff-pane\"><div class=\"review-diff-pane-label\">Original</div><div class=\"diff-original\"></div></div>' +\n '<div class=\"review-diff-pane\"><div class=\"review-diff-pane-label\">Edited</div><div class=\"diff-edited\"></div></div>';\n\n // Insert before the textarea\n ta.parentNode.insertBefore(diffContainer, ta);\n\n function updateDiff() {\n var original = originalText;\n var edited = ta.value;\n var origEl = diffContainer.querySelector('.diff-original');\n var editEl = diffContainer.querySelector('.diff-edited');\n\n if (original === edited) {\n origEl.textContent = original;\n editEl.innerHTML = '<span style=\"color:var(--muted);font-style:italic\">No changes</span>';\n return;\n }\n\n // Simple word-level diff\n var origWords = original.split(/(\\\\s+)/);\n var editWords = edited.split(/(\\\\s+)/);\n var origHtml = '', editHtml = '';\n\n // Simple sequential comparison\n var i = 0, j = 0;\n while (i < origWords.length || j < editWords.length) {\n if (i < origWords.length && j < editWords.length && origWords[i] === editWords[j]) {\n origHtml += esc(origWords[i]);\n editHtml += esc(editWords[j]);\n i++; j++;\n } else if (i < origWords.length && editWords.indexOf(origWords[i], j) === -1) {\n origHtml += '<span class=\"diff-del\">' + esc(origWords[i]) + '</span>';\n i++;\n } else if (j < editWords.length && origWords.indexOf(editWords[j], i) === -1) {\n editHtml += '<span class=\"diff-ins\">' + esc(editWords[j]) + '</span>';\n j++;\n } else {\n // advance both\n if (i < origWords.length) { origHtml += '<span class=\"diff-del\">' + esc(origWords[i]) + '</span>'; i++; }\n if (j < editWords.length) { editHtml += '<span class=\"diff-ins\">' + esc(editWords[j]) + '</span>'; j++; }\n }\n }\n origEl.innerHTML = origHtml;\n editEl.innerHTML = editHtml;\n }\n\n updateDiff();\n ta.addEventListener('input', updateDiff);\n }\n };\n\n window.filterReviewCards = function() {\n var fp = document.getElementById('review-filter-project');\n var fm = document.getElementById('review-filter-machine');\n var fmod = document.getElementById('review-filter-model');\n if (!fp) return;\n var project = fp.value;\n var machine = fm ? fm.value : '';\n var model = fmod ? fmod.value : '';\n var cards = document.querySelectorAll('.review-card');\n var shown = 0;\n var total = cards.length;\n cards.forEach(function(card) {\n var cp = card.getAttribute('data-project') || '';\n var cm = card.getAttribute('data-machine') || '';\n var cmod = card.getAttribute('data-model') || '';\n var show = (!project || cp === project) && (!machine || cm === machine) && (!model || cmod === model);\n card.style.display = show ? '' : 'none';\n if (show) shown++;\n });\n var countEl = document.getElementById('review-filter-count');\n if (countEl) countEl.textContent = shown < total ? (shown + ' of ' + total) : '';\n };\n\n window.selectProject = function(name, el) {\n _selectedProject = name;\n document.querySelectorAll('.project-card').forEach(function(c) { c.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var area = document.getElementById('project-detail-area');\n area.innerHTML =\n '<div class=\"project-detail\">' +\n '<div class=\"project-detail-header\"><h2>' + esc(name) + '</h2></div>' +\n '<div class=\"project-detail-tabs\">' +\n '<button class=\"project-detail-tab active\" data-ui-action=\"loadProjectFile\" data-file=\"FINDINGS.md\">Findings</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"tasks.md\">Tasks</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"CLAUDE.md\">CLAUDE.md</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"summary.md\">Summary</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"reference:browser\">Reference</button>' +\n '</div>' +\n '<div class=\"project-detail-content\" id=\"project-content\"><div class=\"project-detail-empty\">Loading...</div></div>' +\n '</div>';\n loadProjectFile('FINDINGS.md', area.querySelector('.project-detail-tab'));\n area.scrollIntoView({ behavior: 'smooth', block: 'start' });\n };\n\n\n\n function normalizeScoresPayload(payload) {\n if (!payload || typeof payload !== 'object') return {};\n if (payload.entries && typeof payload.entries === 'object') return payload.entries;\n return payload;\n }\n\n function loadScores() {\n if (_scoresPromise) return _scoresPromise;\n _scoresPromise = fetch(authUrl('/api/scores'))\n .then(function(r) { return r.json(); })\n .then(function(payload) { return normalizeScoresPayload(payload); })\n .catch(function() { return {}; });\n return _scoresPromise;\n }\n\n function scoreDigest(text) {\n if (!window.crypto || !window.crypto.subtle || typeof TextEncoder === 'undefined') return Promise.resolve('');\n var data = new TextEncoder().encode(text);\n return window.crypto.subtle.digest('SHA-1', data).then(function(buf) {\n var bytes = Array.from(new Uint8Array(buf));\n return bytes.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').slice(0, 12);\n }).catch(function() { return ''; });\n }\n\n function computeQualityFromEntry(entry) {\n if (!entry) return null;\n var now = Date.now();\n var lastUsed = entry.lastUsedAt ? new Date(entry.lastUsedAt).getTime() : 0;\n var daysSince = lastUsed ? (now - lastUsed) / 86400000 : 999;\n\n var recencyBoost = 0;\n if (daysSince <= 7) recencyBoost = 0.15;\n else if (daysSince <= 30) recencyBoost = 0;\n else recencyBoost = -0.1 * Math.min(3, (daysSince - 30) / 30);\n\n var impressions = entry.impressions || 0;\n var frequencyBoost = impressions > 0 ? Math.min(0.2, Math.log2(impressions + 1) * 0.05) : 0;\n var helpful = entry.helpful || 0;\n var reprompt = entry.repromptPenalty || 0;\n var regression = entry.regressionPenalty || 0;\n var penalties = reprompt + regression * 2;\n var feedbackScore = helpful * 0.15 - penalties * 0.2;\n var raw = 1 + feedbackScore + recencyBoost + frequencyBoost;\n return Math.max(0.2, Math.min(1.5, raw));\n }\n\n function decayStateFromQuality(multiplier) {\n if (typeof multiplier !== 'number' || Number.isNaN(multiplier)) return 'unknown';\n if (multiplier > 0.7) return 'healthy';\n if (multiplier >= 0.3) return 'decaying';\n return 'stale';\n }\n\n function parseFindings(content) {\n var lines = String(content || '').split(String.fromCharCode(10));\n var findings = [];\n var currentDate = '';\n var inDetails = false;\n lines.forEach(function(line) {\n var trimmed = line.trim();\n if (trimmed.indexOf('<details') === 0) { inDetails = true; return; }\n if (inDetails) {\n if (trimmed.indexOf('</details>') === 0) inDetails = false;\n return;\n }\n if (trimmed.indexOf('## ') === 0) {\n currentDate = trimmed.slice(3).trim();\n return;\n }\n if (trimmed.indexOf('- ') !== 0) return;\n var text = trimmed.slice(2).trim();\n if (!text) return;\n findings.push({ text: text, date: currentDate });\n });\n return findings;\n }\n\n function renderFindingsContent(container, project, content) {\n var findings = parseFindings(content);\n if (!findings.length) {\n container.innerHTML = '<pre>' + esc(content) + '</pre>';\n return;\n }\n loadScores().then(function(scores) {\n return Promise.all(findings.map(function(finding) {\n var short = finding.text.trim().replace(/ +/g, ' ').slice(0, 200);\n return scoreDigest(project + ':FINDINGS.md:' + short).then(function(digest) {\n var key = digest ? (project + '/FINDINGS.md:' + digest) : '';\n var entry = key ? scores[key] : null;\n var multiplier = computeQualityFromEntry(entry);\n var state = decayStateFromQuality(multiplier);\n return {\n text: finding.text,\n date: finding.date,\n key: key,\n multiplier: multiplier,\n state: state,\n };\n });\n }));\n }).then(function(items) {\n var html = '<div style=\"display:flex;gap:8px;padding:14px 14px 0 14px\"><input id=\"finding-add-input\" type=\"text\" placeholder=\"Add a finding\u2026\" style=\"flex:1;border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm);font-family:var(--font)\" onkeydown=\"if(event.key===\\'Enter\\')window.phrenAddFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\')\"><button onclick=\"window.phrenAddFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\')\" style=\"border:1px solid var(--accent);border-radius:var(--radius-sm);padding:4px 12px;background:var(--accent);color:#fff;font-size:var(--text-sm);cursor:pointer;font-family:var(--font)\">Add</button></div>';\n html += '<div class=\"finding-cards\">';\n items.forEach(function(item) {\n var scoreText = typeof item.multiplier === 'number' ? item.multiplier.toFixed(2) : 'n/a';\n var stateText = item.state === 'unknown' ? 'unknown' : item.state;\n var stateClass = item.state === 'unknown' ? 'decaying' : item.state;\n html +=\n '<details class=\"finding-detail-card\">' +\n '<summary>' + esc(item.text) + '</summary>' +\n '<div class=\"finding-detail-meta\">' +\n (item.date ? '<span>Date: ' + esc(item.date) + '</span>' : '') +\n '<span>Quality multiplier: ' + esc(scoreText) + '</span>' +\n '<span><span class=\"finding-score-indicator ' + esc(stateClass) + '\"></span>Decay state: ' + esc(stateText) + '</span>' +\n '<button onclick=\"window.phrenRemoveFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\', this)\" data-finding-text=\"' + esc(item.text) + '\" style=\"margin-left:auto;background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;cursor:pointer;color:var(--muted);font-size:11px\">Remove</button>' +\n '</div>' +\n '</details>';\n });\n html += '</div>';\n container.innerHTML = html;\n }).catch(function() {\n container.innerHTML = '<pre>' + esc(content) + '</pre>';\n });\n }\n\n window.loadProjectFile = function(file, btn) {\n if (!_selectedProject) return;\n document.querySelectorAll('.project-detail-tab').forEach(function(b) { b.classList.remove('active'); });\n if (btn) btn.classList.add('active');\n var container = document.getElementById('project-content');\n container.innerHTML = '<div class=\"project-detail-empty\">Loading...</div>';\n if (file === 'reference:browser' && typeof window.phrenLoadProjectReference === 'function') {\n window.phrenLoadProjectReference();\n return;\n }\n fetch(authUrl('/api/project-content?project=' + encodeURIComponent(_selectedProject) + '&file=' + encodeURIComponent(file)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data.ok) {\n container.innerHTML = '<div class=\"project-detail-empty\">' + esc(data.error || 'File not found') + '</div>';\n return;\n }\n if (file === 'FINDINGS.md') {\n renderFindingsContent(container, _selectedProject, data.content);\n return;\n }\n container.innerHTML = '<pre>' + esc(data.content) + '</pre>';\n });\n };\n\n loadProjects();\n loadReviewCards();\n loadReviewActivity();\n var _lastChangeToken = '';\n pollLiveUpdates();\n window.setInterval(pollLiveUpdates, 2000);\n\n // ── Auth helpers ─────────────────────────────────────────────\n function authUrl(base) {\n return base + (base.indexOf('?') === -1 ? '?' : '&') + '_auth=' + encodeURIComponent(_authToken);\n }\n function authBody(body) {\n return body + (_authToken ? '&_auth=' + encodeURIComponent(_authToken) : '');\n }\n\n // ── Finding management ───────────────────────────────────────\n window.phrenAddFinding = function(project) {\n var input = document.getElementById('finding-add-input');\n if (!input || !input.value.trim()) return;\n var text = input.value.trim();\n fetch(authUrl('/api/csrf-token')).then(function(r) { return r.json(); }).then(function(csrfData) {\n var body = new URLSearchParams({ text: text });\n if (csrfData.token) body.set('_csrf', csrfData.token);\n return fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'POST', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { input.value = ''; window.selectProjectFile('FINDINGS.md'); } else { alert(data.error || 'Failed to add finding'); }\n }).catch(function(err) { alert('Error: ' + String(err)); });\n };\n window.phrenRemoveFinding = function(project, btn) {\n var text = btn.getAttribute('data-finding-text');\n if (!text || !confirm('Remove this finding?')) return;\n fetch(authUrl('/api/csrf-token')).then(function(r) { return r.json(); }).then(function(csrfData) {\n var body = new URLSearchParams({ text: text });\n if (csrfData.token) body.set('_csrf', csrfData.token);\n return fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'DELETE', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { window.selectProjectFile('FINDINGS.md'); } else { alert(data.error || 'Failed to remove finding'); }\n }).catch(function(err) { alert('Error: ' + String(err)); });\n };\n\n // ── Skills ───────────────────────────────────────────────────\n function loadSkills() {\n fetch(authUrl('/api/skills')).then(function(r) { return r.json(); }).then(function(data) {\n _skillsLoaded = true;\n var list = document.getElementById('skills-list');\n if (!data.length) { list.innerHTML = '<div style=\"padding:40px 20px;color:var(--muted);text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 8px;display:block\"><path d=\"M40 16l4 12h-8l4-12z\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\" stroke-linejoin=\"round\"/><path d=\"M40 28v20\" stroke=\"var(--accent)\" stroke-width=\"2.5\" stroke-linecap=\"round\"/><path d=\"M34 38h12\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/><path d=\"M36 48l4 12 4-12\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"none\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;font-size:var(--text-base)\">No skills installed</div></div>'; return; }\n var bySource = {};\n data.forEach(function(s) { (bySource[s.source] = bySource[s.source] || []).push(s); });\n var html = '';\n Object.keys(bySource).sort().forEach(function(src) {\n html += '<div class=\"split-group-label\">' + esc(src) + '</div>';\n bySource[src].forEach(function(s) {\n html += '<div class=\"split-item\" data-path=\"' + esc(s.path) + '\" data-name=\"' + esc(s.name) + '\" data-ui-action=\"selectSkillFromEl\">' +\n '<span>' + esc(s.name) + '</span>' +\n '<span class=\"text-muted\" style=\"font-size:var(--text-xs)\">' + esc(s.source) + '</span>' +\n '</div>';\n });\n });\n list.innerHTML = html;\n });\n }\n\n window.selectSkillFromEl = function(el) {\n if (!el) return;\n selectSkill(el.getAttribute('data-path') || '', el, el.getAttribute('data-name') || '');\n };\n\n window.selectSkill = function(filePath, el, name) {\n if (_editingSkill && !confirm('Discard unsaved changes?')) return;\n _editingSkill = false;\n _currentSkillPath = filePath;\n document.querySelectorAll('#skills-list .split-item').forEach(function(i) { i.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var reader = document.getElementById('skills-reader');\n reader.innerHTML = '<div class=\"reader-empty\">Loading...</div>';\n fetch(authUrl('/api/skill-content?path=' + encodeURIComponent(filePath)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data.ok) { reader.innerHTML = '<div class=\"reader-empty\">' + esc(data.error || 'Error loading file') + '</div>'; return; }\n reader.innerHTML =\n '<div class=\"reader-toolbar\">' +\n '<span class=\"reader-title\">' + esc(name) + '</span>' +\n '<span class=\"reader-path\">' + esc(filePath) + '</span>' +\n '<span id=\"skill-status\"></span>' +\n '<button class=\"btn btn-sm\" data-ui-action=\"editSkill\">Edit</button>' +\n '</div>' +\n '<div class=\"reader-content\"><pre id=\"skill-pre\">' + esc(data.content) + '</pre></div>';\n });\n };\n\n window.editSkill = function() {\n var pre = document.getElementById('skill-pre');\n if (!pre) return;\n _editingSkill = true;\n var content = pre.textContent;\n var toolbar = document.querySelector('#skills-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-sm btn-primary';\n saveBtn.textContent = 'Save';\n saveBtn.onclick = saveSkill;\n var cancelBtn = document.createElement('button');\n cancelBtn.className = 'btn btn-sm';\n cancelBtn.textContent = 'Cancel';\n cancelBtn.onclick = cancelSkillEdit;\n toolbar.appendChild(saveBtn);\n toolbar.appendChild(cancelBtn);\n var ta = document.createElement('textarea');\n ta.id = 'skill-textarea';\n ta.value = content;\n pre.replaceWith(ta);\n ta.focus();\n };\n\n window.cancelSkillEdit = function() {\n _editingSkill = false;\n if (_currentSkillPath) {\n var items = document.querySelectorAll('#skills-list .split-item.selected');\n if (items.length) items[0].click();\n }\n };\n\n window.saveSkill = function() {\n var ta = document.getElementById('skill-textarea');\n if (!ta || !_currentSkillPath) return;\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/skill-save', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('path=' + encodeURIComponent(_currentSkillPath) + '&content=' + encodeURIComponent(ta.value)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) {\n _editingSkill = false;\n setStatus('skill-status', 'Saved', 'ok');\n var pre = document.createElement('pre');\n pre.id = 'skill-pre';\n pre.textContent = ta.value;\n ta.replaceWith(pre);\n var toolbar = document.querySelector('#skills-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var editBtn = document.createElement('button');\n editBtn.className = 'btn btn-sm';\n editBtn.textContent = 'Edit';\n editBtn.onclick = window.editSkill;\n toolbar.appendChild(editBtn);\n } else {\n setStatus('skill-status', data.error || 'Save failed', 'err');\n }\n });\n });\n };\n\n // ── Hooks ────────────────────────────────────────────────────\n function loadHooks() {\n fetch(authUrl('/api/hooks')).then(function(r) { return r.json(); }).then(function(data) {\n _hooksLoaded = true;\n var list = document.getElementById('hooks-list');\n var html = '<div class=\"split-group-label\">Lifecycle Hooks</div>';\n data.tools.forEach(function(t) {\n html += '<div class=\"hook-item\" data-config-path=\"' + esc(t.configPath) + '\" data-tool=\"' + esc(t.tool) + '\" data-exists=\"' + (t.exists ? 'true' : 'false') + '\" data-ui-action=\"selectHookFromEl\">' +\n '<span class=\"hook-name\">' + esc(t.tool) + '</span>' +\n '<span class=\"badge ' + (t.enabled ? 'badge-on' : 'badge-off') + '\">' + (t.enabled ? 'on' : 'off') + '</span>' +\n '</div>';\n });\n if (data.customHooks && data.customHooks.length) {\n html += '<div class=\"split-group-label\">Custom Hooks</div>';\n data.customHooks.forEach(function(ch) {\n html += '<div class=\"split-item\" style=\"cursor:default;flex-direction:column;align-items:flex-start\">' +\n '<div class=\"hook-custom-event\">' + esc(ch.event) + '</div>' +\n '<div class=\"hook-custom-cmd\">' + esc(ch.command) + '</div>' +\n '</div>';\n });\n }\n // Show illustrated empty state if no hook configs exist on disk and no custom hooks\n var anyExists = data.tools.some(function(t) { return t.exists; });\n if (!anyExists && (!data.customHooks || !data.customHooks.length)) {\n html += '<div style=\"padding:40px 20px;color:var(--muted);text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 8px;display:block\"><rect x=\"20\" y=\"28\" width=\"16\" height=\"24\" rx=\"3\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><rect x=\"44\" y=\"28\" width=\"16\" height=\"24\" rx=\"3\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><path d=\"M36 36c4-6 4-6 8 0\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/><path d=\"M36 44c4-6 4-6 8 0\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg><div style=\"font-weight:500;font-size:var(--text-base)\">No hooks configured</div></div>';\n }\n if (data.projectOverrides && data.projectOverrides.length) {\n html += '<div class=\"split-group-label\">Per-Project Overrides</div>';\n data.projectOverrides.forEach(function(po) {\n var baseLabel = po.baseEnabled === true ? 'enabled' : po.baseEnabled === false ? 'disabled' : 'default';\n html += '<div class=\"split-item\" style=\"cursor:default;flex-direction:column;align-items:flex-start;gap:4px\">';\n html += '<div style=\"display:flex;align-items:center;gap:8px;width:100%\"><span class=\"hook-name\">' + esc(po.project) + '</span><span class=\"badge ' + (po.baseEnabled === false ? 'badge-off' : 'badge-on') + '\">' + esc(baseLabel) + '</span></div>';\n po.events.forEach(function(ev) {\n if (ev.configured === null) return;\n html += '<div style=\"display:flex;align-items:center;gap:8px;padding-left:12px;font-size:var(--text-xs)\"><span style=\"color:var(--muted)\">' + esc(ev.event) + '</span><span class=\"badge ' + (ev.enabled ? 'badge-on' : 'badge-off') + '\" style=\"font-size:10px\">' + (ev.enabled ? 'on' : 'off') + '</span></div>';\n });\n html += '</div>';\n });\n }\n list.innerHTML = html;\n });\n }\n\n window.selectHookFromEl = function(el) {\n if (!el) return;\n selectHook(\n el.getAttribute('data-config-path') || '',\n el,\n el.getAttribute('data-tool') || '',\n el.getAttribute('data-exists') === 'true'\n );\n };\n\n window.selectHook = function(filePath, el, toolName, exists) {\n if (_editingHook && !confirm('Discard unsaved changes?')) return;\n _editingHook = false;\n _currentHookPath = filePath;\n document.querySelectorAll('#hooks-list .hook-item').forEach(function(i) { i.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var reader = document.getElementById('hooks-reader');\n if (!exists) {\n reader.innerHTML =\n '<div class=\"reader-toolbar\"><span class=\"reader-title\">' + esc(toolName) + '</span><span class=\"reader-path\">' + esc(filePath) + '</span></div>' +\n '<div class=\"reader-empty\">Config file not found. This tool may not be installed or configured.</div>';\n return;\n }\n reader.innerHTML =\n '<div class=\"reader-toolbar\">' +\n '<span class=\"reader-title\">' + esc(toolName) + '</span>' +\n '<span class=\"reader-path\">' + esc(filePath) + '</span>' +\n '<span id=\"hook-status\"></span>' +\n '<button class=\"btn btn-sm\" data-ui-action=\"editHook\">Edit</button>' +\n '<button class=\"btn btn-sm btn-primary\" data-tool=\"' + esc(toolName) + '\" data-ui-action=\"toggleHookToolFromEl\">Toggle</button>' +\n '</div>' +\n '<div class=\"reader-content\"><div class=\"reader-empty\">Loading...</div></div>';\n fetch(authUrl('/api/skill-content?path=' + encodeURIComponent(filePath)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n var content = reader.querySelector('.reader-content');\n if (!content) return;\n if (!data.ok) { content.innerHTML = '<div class=\"reader-empty\">' + esc(data.error || 'Error loading file') + '</div>'; return; }\n content.innerHTML = '<pre id=\"hook-pre\">' + esc(data.content) + '</pre>';\n });\n };\n\n window.toggleHookToolFromEl = function(el) {\n if (!el) return;\n toggleHookTool(el.getAttribute('data-tool') || '');\n };\n\n window.editHook = function() {\n var pre = document.getElementById('hook-pre');\n if (!pre) return;\n _editingHook = true;\n var toolbar = document.querySelector('#hooks-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-sm btn-primary';\n saveBtn.textContent = 'Save';\n saveBtn.onclick = window.saveHook;\n var cancelBtn = document.createElement('button');\n cancelBtn.className = 'btn btn-sm';\n cancelBtn.textContent = 'Cancel';\n cancelBtn.onclick = window.cancelHookEdit;\n toolbar.appendChild(saveBtn);\n toolbar.appendChild(cancelBtn);\n var ta = document.createElement('textarea');\n ta.id = 'hook-textarea';\n ta.value = pre.textContent;\n pre.replaceWith(ta);\n ta.focus();\n };\n\n window.cancelHookEdit = function() {\n _editingHook = false;\n _hooksLoaded = false;\n loadHooks();\n document.getElementById('hooks-reader').innerHTML = '<div class=\"reader-empty\">Select a hook config to view its contents.</div>';\n };\n\n window.saveHook = function() {\n var ta = document.getElementById('hook-textarea');\n if (!ta || !_currentHookPath) return;\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/skill-save', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('path=' + encodeURIComponent(_currentHookPath) + '&content=' + encodeURIComponent(ta.value)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) {\n _editingHook = false;\n setStatus('hook-status', 'Saved', 'ok');\n window.cancelHookEdit();\n } else {\n setStatus('hook-status', data.error || 'Save failed', 'err');\n }\n });\n });\n };\n\n window.toggleHookTool = function(toolName) {\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/hook-toggle', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('tool=' + encodeURIComponent(toolName)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { _hooksLoaded = false; loadHooks(); }\n });\n });\n };\n\n // ── Graph (Force-directed) ───────────────────────────────────\n var _graphData = null;\n var _graphNodes = [];\n var _graphZoom = 1;\n var _graphPanX = 0, _graphPanY = 0;\n var _graphDrag = null;\n var _graphRunning = false;\n var _graphAlpha = 1;\n var _graphFilter = 'all';\n var _graphProjectFilter = 'all';\n var _graphSourceFilter = 'all';\n var _graphNodeLimit = 300;\n var _graphTotalNodes = 0;\n var _graphListenersAttached = false;\n var _graphSelectedNodeId = '';\n\n var COLORS = { project: '#d4892e', decision: '#3b82f6', pitfall: '#ef4444', pattern: '#10b981', tradeoff: '#f59e0b', architecture: '#8b4a12', bug: '#dc2626', 'task-active': '#10b981', 'task-queue': '#b8860b', entity: '#3a7bae', reference: '#6b8e7a', other: '#f4a261' };\n var RADII = { project: 18, decision: 8, pitfall: 8, pattern: 8, tradeoff: 8, architecture: 8, bug: 8, 'task-active': 7, 'task-queue': 7, entity: 10, reference: 6, other: 8 };\n\n \n\n \n\n \n\n \n\n \n\n function loadGraph() {\n var url = '/api/graph' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) {\n if (!r.ok) throw new Error('HTTP ' + r.status);\n return r.json();\n }).then(function(data) {\n _graphLoaded = true;\n _graphTotalNodes = data.total || data.nodes.length;\n if (data.nodes.length > _graphNodeLimit) {\n var projectNodes = data.nodes.filter(function(n) { return n.group === 'project'; });\n var findingNodes = data.nodes.filter(function(n) { return n.group !== 'project'; });\n var allowed = Math.max(0, _graphNodeLimit - projectNodes.length);\n var kept = findingNodes.slice(findingNodes.length - allowed);\n var keptSet = {};\n projectNodes.concat(kept).forEach(function(n) { keptSet[n.id] = true; });\n data = { nodes: projectNodes.concat(kept), links: data.links.filter(function(l) { return keptSet[l.source] && keptSet[l.target]; }), total: data.total };\n }\n _graphData = data;\n \n if (window.phrenGraph && window.phrenGraph.mount) { window.phrenGraph.mount(data); } else { console.error('[phren] phrenGraph.mount not available'); }\n }).catch(function(err) {\n _graphLoaded = false;\n var canvas = document.getElementById('graph-canvas');\n if (canvas) {\n var ctx = canvas.getContext('2d');\n var w = canvas.clientWidth, h = canvas.clientHeight;\n ctx.fillStyle = '#ef4444';\n ctx.font = '14px system-ui';\n ctx.textAlign = 'center';\n ctx.fillText('Graph failed to load: ' + err.message, w/2, h/2);\n }\n });\n }\n\n /* initGraph removed — handled by phrenGraph.mount in memory-ui-graph.ts */\n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n // ── Utils ────────────────────────────────────────────────────\n function setStatus(id, msg, type) {\n var el = document.getElementById(id);\n if (!el) return;\n el.className = 'status-msg' + (type ? ' ' + type : '');\n el.textContent = msg;\n if (msg) setTimeout(function() { el.textContent = ''; el.className = ''; }, 3000);\n }\n\n function esc(s) {\n return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"');\n }\n\n // ── Dark mode ────────────────────────────────────────────────\n (function initTheme() {\n var saved = localStorage.getItem('phren-theme');\n var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;\n var theme = saved || (prefersDark ? 'dark' : 'light');\n document.documentElement.setAttribute('data-theme', theme);\n var btn = document.getElementById('theme-toggle');\n if (btn) btn.textContent = theme === 'dark' ? '🌙' : '☀️';\n })();\n\n window.toggleTheme = function() {\n var current = document.documentElement.getAttribute('data-theme') || 'light';\n var next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('phren-theme', next);\n var btn = document.getElementById('theme-toggle');\n if (btn) btn.textContent = next === 'dark' ? '🌙' : '☀️';\n };\n\n // ── Toast system ─────────────────────────────────────────────\n function showToast(msg, type) {\n var container = document.getElementById('toast-container');\n if (!container) return;\n var toast = document.createElement('div');\n toast.className = 'toast' + (type ? ' ' + type : '');\n toast.textContent = msg;\n container.appendChild(toast);\n setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 3000);\n }\n\n var _pendingUndo = null;\n\n function showUndoToast(action, onUndo, onCommit) {\n var container = document.getElementById('toast-container');\n if (!container) return;\n var toast = document.createElement('div');\n toast.className = 'toast';\n var label = document.createElement('span');\n label.textContent = action.charAt(0).toUpperCase() + action.slice(1) + 'd memory';\n var undoBtn = document.createElement('button');\n undoBtn.className = 'toast-undo';\n undoBtn.textContent = 'Undo';\n toast.appendChild(label);\n toast.appendChild(undoBtn);\n container.appendChild(toast);\n\n var committed = false;\n var timer = setTimeout(function() {\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onCommit();\n }, 5000);\n\n undoBtn.onclick = function() {\n if (committed) return;\n clearTimeout(timer);\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onUndo();\n };\n\n _pendingUndo = {\n execute: function() {\n if (committed) return;\n clearTimeout(timer);\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onCommit();\n }\n };\n }\n\n // ── Review fetch actions ──────────────────────────────────────\n function fetchCsrfToken(cb) {\n var url = '/api/csrf-token' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(d) { cb(d.token || null); }).catch(function() { cb(null); });\n }\n\n // ── Batch actions ─────────────────────────────────────────────\n function getCheckedCards() {\n return Array.from(document.querySelectorAll('.review-card-check.checked')).map(function(cb) {\n return cb.closest('.review-card');\n }).filter(Boolean);\n }\n\n function updateBatchBar() {\n var checked = getCheckedCards();\n var bar = document.getElementById('batch-bar');\n var count = document.getElementById('batch-count');\n if (!bar || !count) return;\n count.textContent = checked.length + ' selected';\n bar.classList.toggle('visible', checked.length > 0);\n }\n\n window.toggleSelectAll = function(checked) {\n document.querySelectorAll('.review-card').forEach(function(card) {\n if (card.style.display === 'none') return;\n var cb = card.querySelector('.review-card-check');\n if (!cb) return;\n if (checked) cb.classList.add('checked');\n else cb.classList.remove('checked');\n });\n updateBatchBar();\n };\n\n window.clearBatchSelection = function() {\n document.querySelectorAll('.review-card-check.checked').forEach(function(cb) {\n cb.classList.remove('checked');\n });\n var selectAllCb = document.querySelector('#review-select-all input[type=\"checkbox\"]');\n if (selectAllCb) selectAllCb.checked = false;\n updateBatchBar();\n };\n\n\n // -- Similarity grouping --\n function wordSet(text) {\n return new Set(text.toLowerCase().replace(/[^a-z0-9\\s]/g, '').split(/\\s+/).filter(function(w) { return w.length > 2; }));\n }\n\n function jaccardSimilarity(a, b) {\n var intersection = 0;\n a.forEach(function(w) { if (b.has(w)) intersection++; });\n var union = new Set(Array.from(a).concat(Array.from(b))).size;\n return union === 0 ? 0 : intersection / union;\n }\n\n function clusterCards(items) {\n if (items.length <= 1) return [items];\n var THRESHOLD = 0.25;\n var wordSets = items.map(function(item) { return wordSet(item.text || ''); });\n var assigned = new Array(items.length).fill(-1);\n var groups = [];\n\n for (var i = 0; i < items.length; i++) {\n if (assigned[i] !== -1) continue;\n var group = [i];\n assigned[i] = groups.length;\n for (var j = i + 1; j < items.length; j++) {\n if (assigned[j] !== -1) continue;\n if (jaccardSimilarity(wordSets[i], wordSets[j]) > THRESHOLD) {\n group.push(j);\n assigned[j] = groups.length;\n }\n }\n groups.push(group);\n }\n return groups.map(function(indices) {\n return indices.map(function(idx) { return items[idx]; });\n });\n }\n\n window.toggleGroup = function(btn) {\n var group = btn.closest('.review-group');\n if (group) group.classList.toggle('collapsed');\n btn.textContent = group && group.classList.contains('collapsed') ? '\\u25B6' : '\\u25BC';\n };\n\n window.batchAction = function(action) {\n var cards = getCheckedCards();\n if (!cards.length) return;\n var remaining = cards.length;\n cards.forEach(function(card) {\n var project = card.getAttribute('data-project');\n var key = card.getAttribute('data-key');\n var line = key ? key.split('\\\\x00')[1] : '';\n if (!project || !line) return;\n card.classList.add('removing');\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/' + action, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n remaining--;\n if (d.ok) {\n setTimeout(function() { if (card.parentNode) card.remove(); }, 300);\n } else {\n card.classList.remove('removing');\n }\n if (remaining <= 0) {\n updateBatchBar();\n setTimeout(loadReviewCards, 500);\n }\n }).catch(function() {\n remaining--;\n card.classList.remove('removing');\n if (remaining <= 0) updateBatchBar();\n });\n });\n });\n showToast(action.charAt(0).toUpperCase() + action.slice(1) + 'd ' + cards.length + ' memories', 'ok');\n };\n\n function removeCard(card, action, project, line, text) {\n card.classList.add('removing');\n // Clear any pending undo for a different card\n if (_pendingUndo) {\n _pendingUndo.execute();\n _pendingUndo = null;\n }\n showUndoToast(action, function undo() {\n // Cancel: re-show the card\n card.classList.remove('removing');\n _pendingUndo = null;\n }, function commit() {\n // Execute: actually call the server\n _pendingUndo = null;\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/' + action, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n if (d.ok) {\n card.remove();\n updateReviewCount(-1);\n setTimeout(loadReviewCards, 500);\n } else {\n card.classList.remove('removing');\n showToast('Error: ' + (d.error || 'Unknown error'), 'err');\n }\n }).catch(function() {\n card.classList.remove('removing');\n showToast('Network error', 'err');\n });\n });\n });\n }\n\n window.reviewActionFromEl = function(btn, action) {\n if (!btn) return;\n window.reviewAction(btn, action, btn.getAttribute('data-project') || '', btn.getAttribute('data-line') || '');\n };\n\n window.reviewAction = function(btn, action, project, line) {\n var card = btn.closest('.review-card');\n if (!card) return;\n var text = card.querySelector('.review-card-text') ? card.querySelector('.review-card-text').textContent : '';\n removeCard(card, action, project, line, text);\n };\n\n window.reviewEditSubmitFromEl = function(e, form) {\n if (!form) return;\n window.reviewEditSubmit(e, form.getAttribute('data-project') || '', form.getAttribute('data-line') || '');\n };\n\n window.reviewEditSubmit = function(e, project, line) {\n e.preventDefault();\n var form = e.target;\n var ta = form.querySelector('textarea[name=\"new_text\"]');\n if (!ta) return;\n var newText = ta.value;\n var card = form.closest('.review-card');\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line) + '&new_text=' + encodeURIComponent(newText);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/edit', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n if (d.ok) {\n var textEl = card ? card.querySelector('.review-card-text') : null;\n if (textEl) textEl.innerHTML = esc(newText).replace(/\\n/g, '<br>');\n var editSection = card ? card.querySelector('.review-card-edit') : null;\n if (editSection) editSection.style.display = 'none';\n showToast('Saved', 'ok');\n } else {\n showToast('Error: ' + (d.error || 'Unknown error'), 'err');\n }\n }).catch(function() { showToast('Network error', 'err'); });\n });\n };\n\n function animateCount(el, from, to) {\n if (from === to) return;\n var diff = Math.abs(from - to);\n if (diff <= 3) {\n // Cycle through intermediate values at 80ms intervals\n var step = from < to ? 1 : -1;\n var current = from;\n var interval = setInterval(function() {\n current += step;\n el.textContent = String(current);\n if (current === to) clearInterval(interval);\n }, 80);\n } else {\n // Quick scale-and-flip CSS animation\n el.classList.remove('count-flipping');\n void el.offsetWidth;\n el.classList.add('count-flipping');\n setTimeout(function() { el.textContent = String(to); }, 120);\n setTimeout(function() { el.classList.remove('count-flipping'); }, 350);\n }\n }\n\n function updateReviewCount(delta) {\n var navBtn = document.querySelector('.nav-item[data-tab=\"review\"] .count');\n if (!navBtn) return;\n var current = parseInt(navBtn.textContent, 10) || 0;\n var next = current + delta;\n if (next <= 0) {\n navBtn.classList.add('count-flipping');\n setTimeout(function() { navBtn.remove(); }, 300);\n } else {\n animateCount(navBtn, current, next);\n }\n }\n\n // ── Keyboard shortcuts ────────────────────────────────────────\n var _focusedCard = null;\n\n function getVisibleCards() {\n return Array.from(document.querySelectorAll('.review-card')).filter(function(c) { return c.style.display !== 'none'; });\n }\n\n function focusCard(card) {\n if (_focusedCard) { _focusedCard.style.outline = ''; _focusedCard.style.outlineOffset = ''; }\n _focusedCard = card;\n if (card) {\n card.style.outline = '2px solid var(--accent)';\n card.style.outlineOffset = '2px';\n card.style.borderRadius = 'var(--radius)';\n card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n }\n }\n\n document.addEventListener('keydown', function(e) {\n // Ignore if typing in an input/textarea\n var tag = document.activeElement && document.activeElement.tagName;\n if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;\n\n var activeTab = document.querySelector('.tab-content.active');\n var isReviewTab = activeTab && activeTab.id === 'tab-review';\n var isProjectsTab = activeTab && activeTab.id === 'tab-projects';\n\n // / → focus project search\n if (e.key === '/' && isProjectsTab) {\n e.preventDefault();\n var search = document.getElementById('projects-search');\n if (search) search.focus();\n return;\n }\n\n if (!isReviewTab) return;\n\n var cards = getVisibleCards();\n if (!cards.length) return;\n\n if (e.key === 'j' || e.key === 'ArrowDown') {\n e.preventDefault();\n var idx = _focusedCard ? cards.indexOf(_focusedCard) : -1;\n focusCard(cards[Math.min(idx + 1, cards.length - 1)]);\n } else if (e.key === 'k' || e.key === 'ArrowUp') {\n e.preventDefault();\n var idx2 = _focusedCard ? cards.indexOf(_focusedCard) : cards.length;\n focusCard(cards[Math.max(idx2 - 1, 0)]);\n } else if (e.key === 'a' && _focusedCard) {\n var approveBtn = _focusedCard.querySelector('.btn-approve');\n if (approveBtn) approveBtn.click();\n } else if (e.key === 'r' && _focusedCard) {\n var rejectBtn = _focusedCard.querySelector('.btn-reject');\n if (rejectBtn) rejectBtn.click();\n } else if (e.key === 'e' && _focusedCard) {\n var editBtn = _focusedCard.querySelector('.review-card-actions .btn:not(.btn-approve):not(.btn-reject)');\n if (editBtn) editBtn.click();\n } else if (e.key === 'Escape') {\n if (_focusedCard) {\n var editSection = _focusedCard.querySelector('.review-card-edit');\n if (editSection && editSection.style.display !== 'none') {\n toggleReviewEdit(_focusedCard.querySelector('.review-card-actions .btn:not(.btn-approve):not(.btn-reject)'));\n }\n }\n }\n });\n\n // ── Command palette ───────────────────────────────────────────\n var _cmdpalSelected = -1;\n\n window.openCmdPal = function() {\n var overlay = document.getElementById('cmdpal');\n if (!overlay) return;\n overlay.classList.add('open');\n var input = document.getElementById('cmdpal-input');\n if (input) { input.value = ''; input.focus(); }\n cmdpalSearch('');\n };\n\n window.closeCmdPal = function(e) {\n if (e && e.target !== document.getElementById('cmdpal')) return;\n var overlay = document.getElementById('cmdpal');\n if (overlay) overlay.classList.remove('open');\n };\n\n window.cmdpalSearch = function(query) {\n _cmdpalSelected = -1;\n var results = document.getElementById('cmdpal-results');\n if (!results) return;\n var q = query.toLowerCase();\n var matches = _projectData.filter(function(p) {\n return !q || p.name.toLowerCase().indexOf(q) !== -1 || (p.summaryText || '').toLowerCase().indexOf(q) !== -1;\n }).slice(0, 8);\n if (!matches.length) {\n results.innerHTML = '<div class=\"cmdpal-empty\">No projects found</div>';\n return;\n }\n results.innerHTML = matches.map(function(p, i) {\n return '<div class=\"cmdpal-item\" data-name=\"' + esc(p.name) + '\" data-ui-action=\"cmdpalSelect\">' +\n '<span class=\"cmdpal-item-name\">' + esc(p.name) + '</span>' +\n '<span class=\"cmdpal-item-meta\">' + p.findingCount + ' findings</span>' +\n '</div>';\n }).join('');\n };\n\n window.cmdpalKey = function(e) {\n var items = document.querySelectorAll('.cmdpal-item');\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n _cmdpalSelected = Math.min(_cmdpalSelected + 1, items.length - 1);\n items.forEach(function(el, i) { el.classList.toggle('selected', i === _cmdpalSelected); });\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n _cmdpalSelected = Math.max(_cmdpalSelected - 1, 0);\n items.forEach(function(el, i) { el.classList.toggle('selected', i === _cmdpalSelected); });\n } else if (e.key === 'Enter') {\n if (_cmdpalSelected >= 0 && items[_cmdpalSelected]) {\n var name = items[_cmdpalSelected].getAttribute('data-name');\n if (name) cmdpalSelect(name);\n }\n } else if (e.key === 'Escape') {\n document.getElementById('cmdpal').classList.remove('open');\n }\n };\n\n window.cmdpalSelect = function(name) {\n document.getElementById('cmdpal').classList.remove('open');\n switchTab('projects');\n setTimeout(function() {\n var card = document.querySelector('.project-card[data-project=\"' + name + '\"]');\n if (card) card.click();\n else {\n var search = document.getElementById('projects-search');\n if (search) { search.value = name; filterProjects(name); }\n }\n }, 200);\n };\n\n // Cmd+K / Ctrl+K to open\n document.addEventListener('keydown', function(e) {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n var overlay = document.getElementById('cmdpal');\n if (overlay && overlay.classList.contains('open')) {\n overlay.classList.remove('open');\n } else {\n openCmdPal();\n }\n }\n });\n// Event delegation for dynamically generated UI elements\n document.addEventListener('click', function(e) {\n var target = e.target;\n if (!target || typeof target.closest !== 'function') return;\n var actionEl = target.closest('[data-ui-action]');\n if (!actionEl) return;\n var action = actionEl.getAttribute('data-ui-action');\n if (action === 'selectProject') {\n selectProject(actionEl.closest('.project-card').getAttribute('data-project'), actionEl.closest('.project-card'));\n } else if (action === 'toggleStar') {\n e.stopPropagation();\n var card = actionEl.closest('.project-card');\n toggleStar(card ? card.getAttribute('data-project') : '');\n } else if (action === 'stopProp') {\n e.stopPropagation();\n } else if (action === 'reviewAction') {\n reviewActionFromEl(actionEl, actionEl.getAttribute('data-review-type'));\n } else if (action === 'toggleReviewEdit') {\n toggleReviewEdit(actionEl);\n } else if (action === 'toggleGroup') {\n toggleGroup(actionEl);\n } else if (action === 'loadProjectFile') {\n loadProjectFile(actionEl.getAttribute('data-file'), actionEl);\n } else if (action === 'selectSkillFromEl') {\n selectSkillFromEl(actionEl.closest('.split-item'));\n } else if (action === 'editSkill') {\n editSkill();\n } else if (action === 'selectHookFromEl') {\n selectHookFromEl(actionEl.closest('.split-item'));\n } else if (action === 'editHook') {\n editHook();\n } else if (action === 'toggleHookToolFromEl') {\n toggleHookToolFromEl(actionEl);\n } else if (action === 'cmdpalSelect') {\n cmdpalSelect(actionEl.closest('.cmdpal-item').getAttribute('data-name'));\n }\n });\n document.addEventListener('submit', function(e) {\n var form = e.target;\n if (!form || typeof form.getAttribute !== 'function') return;\n if (form.getAttribute('data-ui-action') === 'reviewEditSubmit') {\n e.preventDefault();\n reviewEditSubmitFromEl(e, form);\n }\n });\n})();\n";
|
|
3
3
|
export function renderWebUiScript(authToken) {
|
|
4
|
-
const
|
|
4
|
+
const safeToken = JSON.stringify(authToken).slice(1, -1); // escape for JS string literal
|
|
5
|
+
const script = WEB_UI_SCRIPT_TEMPLATE.replace("__PHREN_AUTH_TOKEN_1d7b7c99__", safeToken);
|
|
5
6
|
return script;
|
|
6
7
|
}
|
|
7
8
|
// Static review UI payloads live here so the page wrapper only owns HTML scaffolding and sync-state interpolation.
|
|
@@ -338,10 +338,10 @@ export function renderGraphScript() {
|
|
|
338
338
|
/* sprite rendering */
|
|
339
339
|
if (phrenImgReady) {
|
|
340
340
|
ctx.save();
|
|
341
|
-
/* fixed
|
|
342
|
-
var spriteScreenSize =
|
|
341
|
+
/* fixed 48px size in screen pixels, scale up to 56px on arrival flash */
|
|
342
|
+
var spriteScreenSize = 48;
|
|
343
343
|
if (phren.arriving && phren.arriveTimer < 0.4) {
|
|
344
|
-
spriteScreenSize =
|
|
344
|
+
spriteScreenSize = 48 + 8 * (1 - phren.arriveTimer / 0.4);
|
|
345
345
|
}
|
|
346
346
|
var spriteSize = spriteScreenSize * s; /* convert to graph coords */
|
|
347
347
|
/* bob up/down when walking — sine wave synced to walk progress */
|
|
@@ -1454,8 +1454,8 @@ export function renderGraphScript() {
|
|
|
1454
1454
|
if (healthText) html += '<div style="margin-top:4px">' + healthText + '</div>';
|
|
1455
1455
|
if (node.project) {
|
|
1456
1456
|
html += '<div style="display:flex;gap:8px;margin-top:12px">';
|
|
1457
|
-
html += '<button class="btn btn-sm"
|
|
1458
|
-
html += '<button class="btn btn-sm"
|
|
1457
|
+
html += '<button class="btn btn-sm" data-action="graphNodeEdit" data-project="' + esc(node.project) + '" data-finding="' + esc(findingText) + '" style="padding:5px 14px;font-size:12px">Edit</button>';
|
|
1458
|
+
html += '<button class="btn btn-sm" data-action="graphNodeDelete" data-project="' + esc(node.project) + '" data-finding="' + esc(findingText) + '" style="padding:5px 14px;font-size:12px;color:#ef4444;border-color:#ef4444">Delete</button>';
|
|
1459
1459
|
html += '</div>';
|
|
1460
1460
|
}
|
|
1461
1461
|
|
|
@@ -1507,8 +1507,8 @@ export function renderGraphScript() {
|
|
|
1507
1507
|
html += '<div style="margin-top:8px;padding:8px 10px;border-radius:6px;border:1px solid var(--border);background:var(--surface-alt,var(--surface))">';
|
|
1508
1508
|
html += '<div style="font-size:12px;line-height:1.5;color:var(--ink)">' + esc(lfText) + '</div>';
|
|
1509
1509
|
html += '<div style="display:flex;gap:6px;margin-top:6px">';
|
|
1510
|
-
html += '<button class="btn btn-sm"
|
|
1511
|
-
html += '<button class="btn btn-sm"
|
|
1510
|
+
html += '<button class="btn btn-sm" data-action="graphNodeEdit" data-project="' + esc(lf.project) + '" data-finding="' + esc(lfText) + '" style="padding:3px 10px;font-size:11px">Edit</button>';
|
|
1511
|
+
html += '<button class="btn btn-sm" data-action="graphNodeDelete" data-project="' + esc(lf.project) + '" data-finding="' + esc(lfText) + '" style="padding:3px 10px;font-size:11px;color:#ef4444;border-color:#ef4444">Delete</button>';
|
|
1512
1512
|
html += '</div></div>';
|
|
1513
1513
|
}
|
|
1514
1514
|
}
|
|
@@ -2056,11 +2056,25 @@ export function renderGraphScript() {
|
|
|
2056
2056
|
renderGraphDetails(null);
|
|
2057
2057
|
};
|
|
2058
2058
|
|
|
2059
|
+
/* ── delegated click handler for graph detail panel buttons ─────────── */
|
|
2060
|
+
document.addEventListener('click', function(e) {
|
|
2061
|
+
var target = e.target;
|
|
2062
|
+
if (!target || typeof target.closest !== 'function') return;
|
|
2063
|
+
var actionEl = target.closest('[data-action]');
|
|
2064
|
+
if (!actionEl) return;
|
|
2065
|
+
var action = actionEl.getAttribute('data-action');
|
|
2066
|
+
var project = actionEl.getAttribute('data-project');
|
|
2067
|
+
var finding = actionEl.getAttribute('data-finding');
|
|
2068
|
+
if (action === 'graphNodeEdit' && project && finding) { window.graphNodeEdit(project, finding); }
|
|
2069
|
+
else if (action === 'graphNodeDelete' && project && finding) { window.graphNodeDelete(project, finding); }
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2059
2072
|
/* ── node edit/delete actions ────────────────────────────────────────── */
|
|
2060
2073
|
|
|
2061
|
-
function
|
|
2062
|
-
|
|
2063
|
-
|
|
2074
|
+
var authUrl = window._phrenAuthUrl || function(u) { return u; };
|
|
2075
|
+
var fetchCsrfToken = window._phrenFetchCsrfToken || function(cb) {
|
|
2076
|
+
fetch(authUrl('/api/csrf-token')).then(function(r) { return r.json(); }).then(function(d) { cb(d.token || ''); }).catch(function() { cb(''); });
|
|
2077
|
+
};
|
|
2064
2078
|
|
|
2065
2079
|
window.graphNodeEdit = function(project, findingText) {
|
|
2066
2080
|
var panel = document.getElementById('graph-detail-panel');
|
|
@@ -2096,7 +2110,7 @@ export function renderGraphScript() {
|
|
|
2096
2110
|
body.set('old_text', findingText);
|
|
2097
2111
|
body.set('new_text', newText);
|
|
2098
2112
|
if (csrf) body.set('_csrf', csrf);
|
|
2099
|
-
fetch('/api/findings/' + encodeURIComponent(project), { method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
|
|
2113
|
+
fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
|
|
2100
2114
|
.then(function(r) { return r.json(); })
|
|
2101
2115
|
.then(function(d) {
|
|
2102
2116
|
if (d.ok) {
|
|
@@ -2122,7 +2136,7 @@ export function renderGraphScript() {
|
|
|
2122
2136
|
var body = new URLSearchParams();
|
|
2123
2137
|
body.set('text', findingText);
|
|
2124
2138
|
if (csrf) body.set('_csrf', csrf);
|
|
2125
|
-
fetch('/api/findings/' + encodeURIComponent(project), { method: 'DELETE', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
|
|
2139
|
+
fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'DELETE', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
|
|
2126
2140
|
.then(function(r) { return r.json(); })
|
|
2127
2141
|
.then(function(d) {
|
|
2128
2142
|
if (d.ok) {
|
|
@@ -321,7 +321,7 @@ ${TASK_UI_STYLES}
|
|
|
321
321
|
</div>
|
|
322
322
|
|
|
323
323
|
<script${nonceAttr}>
|
|
324
|
-
${renderWebUiScript(
|
|
324
|
+
${renderWebUiScript(authToken || "")}
|
|
325
325
|
</script>
|
|
326
326
|
<script${nonceAttr}>
|
|
327
327
|
${renderGraphScript()}
|
|
@@ -330,7 +330,7 @@ ${renderGraphScript()}
|
|
|
330
330
|
${renderReviewQueueEditSyncScript()}
|
|
331
331
|
</script>
|
|
332
332
|
<script${nonceAttr}>
|
|
333
|
-
${renderSharedWebUiHelpers(
|
|
333
|
+
${renderSharedWebUiHelpers(authToken || "")}
|
|
334
334
|
</script>
|
|
335
335
|
<script${nonceAttr}>
|
|
336
336
|
${renderSkillUiEnhancementScript(h(authToken || ""))}
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* window._phrenFetchCsrfToken(cb) — fetch the CSRF token and call cb(token)
|
|
8
8
|
*/
|
|
9
9
|
export function renderSharedWebUiHelpers(authToken) {
|
|
10
|
+
const safeToken = JSON.stringify(authToken).slice(1, -1); // escape for JS string literal
|
|
10
11
|
return `(function() {
|
|
11
|
-
window._phrenAuthToken = '${
|
|
12
|
+
window._phrenAuthToken = '${safeToken}';
|
|
12
13
|
window._phrenEsc = function(s) {
|
|
13
14
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
14
15
|
};
|
|
@@ -683,6 +684,7 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
683
684
|
};
|
|
684
685
|
|
|
685
686
|
window.toggleDoneSection = function(btn) {
|
|
687
|
+
if (!btn) return;
|
|
686
688
|
var list = btn.nextElementSibling;
|
|
687
689
|
var arrow = btn.querySelector('.task-toggle-arrow');
|
|
688
690
|
if (!list) return;
|
|
@@ -712,10 +714,10 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
712
714
|
});
|
|
713
715
|
var doneTasks = showDone ? [] : _allTasks.filter(function(t) {
|
|
714
716
|
if (projectFilter && t.project !== projectFilter) return false;
|
|
715
|
-
return t.section === 'Done';
|
|
717
|
+
return t.section === 'Done' || t.checked;
|
|
716
718
|
});
|
|
717
719
|
|
|
718
|
-
var activeCount = tasks.filter(function(t) { return t.section !== 'Done'; }).length;
|
|
720
|
+
var activeCount = tasks.filter(function(t) { return t.section !== 'Done' && !t.checked; }).length;
|
|
719
721
|
var countEl = document.getElementById('tasks-count');
|
|
720
722
|
if (countEl) countEl.textContent = activeCount + ' active' + (doneTasks.length ? ', ' + doneTasks.length + ' done' : '');
|
|
721
723
|
|
|
@@ -736,12 +738,13 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
736
738
|
return pa - pb;
|
|
737
739
|
}
|
|
738
740
|
|
|
739
|
-
// Separate into priority groups
|
|
740
|
-
|
|
741
|
-
var
|
|
742
|
-
var
|
|
743
|
-
var
|
|
744
|
-
var
|
|
741
|
+
// Separate into priority groups (exclude checked tasks even if not in Done section)
|
|
742
|
+
function isActive(t) { return t.section !== 'Done' && !t.checked; }
|
|
743
|
+
var high = tasks.filter(function(t) { return t.priority === 'high' && isActive(t); }).sort(sortByPriority);
|
|
744
|
+
var medium = tasks.filter(function(t) { return t.priority === 'medium' && isActive(t); }).sort(sortByPriority);
|
|
745
|
+
var low = tasks.filter(function(t) { return t.priority === 'low' && isActive(t); }).sort(sortByPriority);
|
|
746
|
+
var noPriority = tasks.filter(function(t) { return !t.priority && isActive(t); }).sort(sortByPriority);
|
|
747
|
+
var doneVisible = tasks.filter(function(t) { return t.section === 'Done' || t.checked; });
|
|
745
748
|
|
|
746
749
|
function renderTaskCard(t) {
|
|
747
750
|
var borderClass = t.priority === 'high' ? ' task-card-high' : t.priority === 'medium' ? ' task-card-medium' : t.priority === 'low' ? ' task-card-low' : '';
|
|
@@ -759,10 +762,10 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
759
762
|
if (t.context) html += '<span class="task-card-context">' + esc(t.context) + '</span>';
|
|
760
763
|
html += '</div>';
|
|
761
764
|
html += '<div class="task-card-actions">';
|
|
762
|
-
if (t.section !== 'Done') {
|
|
763
|
-
html += '<button class="task-done-btn"
|
|
765
|
+
if (t.section !== 'Done' && !t.checked) {
|
|
766
|
+
html += '<button class="task-done-btn" data-ts-action="completeTask" data-project="' + esc(t.project) + '" data-item="' + esc(t.line) + '" title="Mark done"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M3 8.5l3.5 3.5 6.5-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Done</button>';
|
|
764
767
|
}
|
|
765
|
-
html += '<button class="task-remove-btn"
|
|
768
|
+
html += '<button class="task-remove-btn" data-ts-action="removeTask" data-project="' + esc(t.project) + '" data-item="' + esc(t.line) + '" title="Delete task" style="background:none;border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px 8px;cursor:pointer;color:var(--muted);font-size:var(--text-xs)"><svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>';
|
|
766
769
|
html += '</div>';
|
|
767
770
|
html += '</div>';
|
|
768
771
|
return html;
|
|
@@ -770,12 +773,12 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
770
773
|
|
|
771
774
|
var html = '';
|
|
772
775
|
|
|
773
|
-
// Add task input at top
|
|
774
|
-
var projects = projectFilter ? [projectFilter] :
|
|
776
|
+
// Add task input at top (only when a specific project is selected)
|
|
777
|
+
var projects = projectFilter ? [projectFilter] : [];
|
|
775
778
|
projects.forEach(function(proj) {
|
|
776
779
|
html += '<div class="task-add-bar">';
|
|
777
|
-
html += '<input id="task-add-input-' + esc(proj) + '" type="text" class="task-add-input" placeholder="Add a task to ' + esc(proj) + '\u2026"
|
|
778
|
-
html += '<button class="task-add-btn"
|
|
780
|
+
html += '<input id="task-add-input-' + esc(proj) + '" type="text" class="task-add-input" placeholder="Add a task to ' + esc(proj) + '\u2026" data-ts-action="addTaskKeydown" data-project="' + esc(proj) + '">';
|
|
781
|
+
html += '<button class="task-add-btn" data-ts-action="addTask" data-project="' + esc(proj) + '"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Add</button>';
|
|
779
782
|
html += '</div>';
|
|
780
783
|
});
|
|
781
784
|
|
|
@@ -801,7 +804,7 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
801
804
|
var allDone = showDone ? doneVisible : doneTasks;
|
|
802
805
|
if (allDone.length) {
|
|
803
806
|
html += '<div class="task-done-section">';
|
|
804
|
-
html += '<button class="task-done-toggle"
|
|
807
|
+
html += '<button class="task-done-toggle" data-ts-action="toggleDoneSection">';
|
|
805
808
|
html += '<span class="task-toggle-arrow">\u25B6</span> Completed <span class="task-section-count">' + allDone.length + '</span></button>';
|
|
806
809
|
html += '<div class="task-done-list" style="display:none">';
|
|
807
810
|
html += '<div class="task-card-grid">';
|
|
@@ -1221,7 +1224,11 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
1221
1224
|
var actionEl = target.closest('[data-ts-action]');
|
|
1222
1225
|
if (!actionEl) return;
|
|
1223
1226
|
var action = actionEl.getAttribute('data-ts-action');
|
|
1224
|
-
if (action === '
|
|
1227
|
+
if (action === 'toggleDoneSection') { toggleDoneSection(actionEl); }
|
|
1228
|
+
else if (action === 'completeTask') { completeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
|
|
1229
|
+
else if (action === 'removeTask') { removeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
|
|
1230
|
+
else if (action === 'addTask') { addTaskFromUi(actionEl.getAttribute('data-project')); }
|
|
1231
|
+
else if (action === 'setFindingSensitivity') { setFindingSensitivity(actionEl.getAttribute('data-level')); }
|
|
1225
1232
|
else if (action === 'toggleAutoCapture') { setAutoCapture(actionEl.getAttribute('data-enabled') !== 'true'); }
|
|
1226
1233
|
else if (action === 'setTaskMode') { setTaskMode(actionEl.getAttribute('data-mode')); }
|
|
1227
1234
|
else if (action === 'setProactivity') { setProactivity(actionEl.getAttribute('data-level')); }
|
|
@@ -1258,6 +1265,15 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
1258
1265
|
}
|
|
1259
1266
|
});
|
|
1260
1267
|
|
|
1268
|
+
// Keydown delegation for add-task inputs (Enter key)
|
|
1269
|
+
document.addEventListener('keydown', function(e) {
|
|
1270
|
+
var target = e.target;
|
|
1271
|
+
if (!target || !target.getAttribute) return;
|
|
1272
|
+
if (target.getAttribute('data-ts-action') === 'addTaskKeydown' && e.key === 'Enter') {
|
|
1273
|
+
addTaskFromUi(target.getAttribute('data-project'));
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1261
1277
|
window.setFindingSensitivity = function(level) {
|
|
1262
1278
|
var descriptions = {
|
|
1263
1279
|
high: 'Capture findings proactively, including minor observations.',
|