@phren/cli 0.0.13 → 0.0.15
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/memory-ui-assets.js +1 -1
- package/mcp/dist/memory-ui-data.js +24 -2
- package/mcp/dist/memory-ui-graph.js +66 -20
- package/mcp/dist/memory-ui-scripts.js +9 -5
- package/mcp/dist/memory-ui-server.js +106 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
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
|
-
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 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";
|
|
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
4
|
const script = WEB_UI_SCRIPT_TEMPLATE.replace("__PHREN_AUTH_TOKEN_1d7b7c99__", authToken);
|
|
5
5
|
return script;
|
|
@@ -6,6 +6,7 @@ import { errorMessage } from "./utils.js";
|
|
|
6
6
|
import { readInstallPreferences } from "./init-preferences.js";
|
|
7
7
|
import { readCustomHooks } from "./hooks.js";
|
|
8
8
|
import { hookConfigPaths, hookConfigRoots } from "./provider-adapters.js";
|
|
9
|
+
import { readProjectConfig, isProjectHookEnabled, PROJECT_HOOK_EVENTS } from "./project-config.js";
|
|
9
10
|
import { getAllSkills } from "./skill-registry.js";
|
|
10
11
|
import { resolveTaskFilePath, readTasks, TASKS_FILENAME } from "./data-tasks.js";
|
|
11
12
|
import { buildIndex, queryDocBySourceKey, queryRows } from "./shared-index.js";
|
|
@@ -101,7 +102,7 @@ export function collectSkillsForUI(phrenPath, profile = "") {
|
|
|
101
102
|
enabled: skill.enabled,
|
|
102
103
|
}));
|
|
103
104
|
}
|
|
104
|
-
export function getHooksData(phrenPath) {
|
|
105
|
+
export function getHooksData(phrenPath, profile) {
|
|
105
106
|
const prefs = readInstallPreferences(phrenPath);
|
|
106
107
|
const globalEnabled = prefs.hooksEnabled !== false;
|
|
107
108
|
const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
|
|
@@ -112,7 +113,28 @@ export function getHooksData(phrenPath) {
|
|
|
112
113
|
configPath: paths[tool],
|
|
113
114
|
exists: fs.existsSync(paths[tool]),
|
|
114
115
|
}));
|
|
115
|
-
|
|
116
|
+
// Collect per-project hook overrides
|
|
117
|
+
const projectOverrides = [];
|
|
118
|
+
const projects = getProjectDirs(phrenPath, profile)
|
|
119
|
+
.map((dir) => path.basename(dir))
|
|
120
|
+
.filter((p) => p !== "global");
|
|
121
|
+
for (const project of projects) {
|
|
122
|
+
const config = readProjectConfig(phrenPath, project);
|
|
123
|
+
const hasOverrides = typeof config.hooks?.enabled === "boolean" ||
|
|
124
|
+
PROJECT_HOOK_EVENTS.some((ev) => typeof config.hooks?.[ev] === "boolean");
|
|
125
|
+
if (!hasOverrides)
|
|
126
|
+
continue;
|
|
127
|
+
projectOverrides.push({
|
|
128
|
+
project,
|
|
129
|
+
baseEnabled: typeof config.hooks?.enabled === "boolean" ? config.hooks.enabled : null,
|
|
130
|
+
events: PROJECT_HOOK_EVENTS.map((event) => ({
|
|
131
|
+
event,
|
|
132
|
+
configured: typeof config.hooks?.[event] === "boolean" ? config.hooks[event] : null,
|
|
133
|
+
enabled: isProjectHookEnabled(phrenPath, project, event, config),
|
|
134
|
+
})),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { globalEnabled, tools, customHooks: readCustomHooks(phrenPath), projectOverrides };
|
|
116
138
|
}
|
|
117
139
|
export async function buildGraph(phrenPath, profile, focusProject) {
|
|
118
140
|
const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global");
|
|
@@ -164,7 +164,30 @@ export function renderGraphScript() {
|
|
|
164
164
|
return path;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/* pin nodes so the force simulation skips them (prevents jitter while phren walks) */
|
|
168
|
+
var phrenPinnedNodes = [];
|
|
169
|
+
|
|
170
|
+
function phrenPinNode(node) {
|
|
171
|
+
if (node && !node._phrenPinned) {
|
|
172
|
+
node._phrenPinned = true;
|
|
173
|
+
node.vx = 0;
|
|
174
|
+
node.vy = 0;
|
|
175
|
+
phrenPinnedNodes.push(node);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function phrenUnpinAll() {
|
|
180
|
+
for (var i = 0; i < phrenPinnedNodes.length; i++) {
|
|
181
|
+
phrenPinnedNodes[i]._phrenPinned = false;
|
|
182
|
+
phrenPinnedNodes[i].vx = 0;
|
|
183
|
+
phrenPinnedNodes[i].vy = 0;
|
|
184
|
+
}
|
|
185
|
+
phrenPinnedNodes = [];
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
function phrenMoveTo(x, y, targetNode) {
|
|
189
|
+
/* unpin all previously pinned nodes before starting a new move */
|
|
190
|
+
phrenUnpinAll();
|
|
168
191
|
phren.targetX = x;
|
|
169
192
|
phren.targetY = y;
|
|
170
193
|
phren.moving = true;
|
|
@@ -179,6 +202,12 @@ export function renderGraphScript() {
|
|
|
179
202
|
phren.waypointIdx = 0;
|
|
180
203
|
phren.targetNodeId = targetNode ? targetNode.id : null;
|
|
181
204
|
phren.targetNodeRef = targetNode || null; /* live reference for position tracking */
|
|
205
|
+
/* pin target node and all waypoint nodes so physics doesn't move them during walk */
|
|
206
|
+
phrenPinNode(targetNode);
|
|
207
|
+
for (var i = 0; i < phren.waypoints.length; i++) {
|
|
208
|
+
var wpn = phrenNodeById(phren.waypoints[i]);
|
|
209
|
+
if (wpn) phrenPinNode(wpn);
|
|
210
|
+
}
|
|
182
211
|
/* ensure animation loop is running so phren movement renders */
|
|
183
212
|
if (!animFrame) animFrame = requestAnimationFrame(tick);
|
|
184
213
|
}
|
|
@@ -218,16 +247,45 @@ export function renderGraphScript() {
|
|
|
218
247
|
var dx = wx - phren.x;
|
|
219
248
|
var dy = wy - phren.y;
|
|
220
249
|
var dist = Math.sqrt(dx * dx + dy * dy);
|
|
221
|
-
|
|
250
|
+
/* snap-to-target helper — used both by arrival check and overshoot clamp */
|
|
251
|
+
var snapped = false;
|
|
252
|
+
if (dist < 4) {
|
|
222
253
|
phren.x = wx;
|
|
223
254
|
phren.y = wy;
|
|
224
|
-
|
|
255
|
+
snapped = true;
|
|
256
|
+
} else {
|
|
257
|
+
/* ease-in-out via sine curve over the full trip distance */
|
|
258
|
+
var t = phren.tripDist > 0 ? Math.min(1, phren.tripProgress / phren.tripDist) : 1;
|
|
259
|
+
var easeInOut = 0.5 - 0.5 * Math.cos(Math.PI * t);
|
|
260
|
+
var baseSpeed = Math.max(3, phren.tripDist * 0.12);
|
|
261
|
+
var speed = Math.max(1.5, baseSpeed * (0.15 + 0.85 * easeInOut));
|
|
262
|
+
/* clamp speed to remaining distance — prevents overshoot oscillation */
|
|
263
|
+
if (speed >= dist) {
|
|
264
|
+
phren.x = wx;
|
|
265
|
+
phren.y = wy;
|
|
266
|
+
speed = dist;
|
|
267
|
+
snapped = true;
|
|
268
|
+
} else {
|
|
269
|
+
phren.x += (dx / dist) * speed;
|
|
270
|
+
phren.y += (dy / dist) * speed;
|
|
271
|
+
}
|
|
272
|
+
phren.tripProgress += speed;
|
|
273
|
+
/* record trail — longer buffer for gradual fade */
|
|
274
|
+
phren.trailPoints.push({ x: phren.x, y: phren.y, age: 0 });
|
|
275
|
+
if (phren.trailPoints.length > 50) phren.trailPoints.shift();
|
|
276
|
+
}
|
|
277
|
+
/* advance waypoint or finish when snapped to current target */
|
|
278
|
+
if (snapped) {
|
|
225
279
|
if (phren.waypoints.length > 0 && phren.waypointIdx < phren.waypoints.length) {
|
|
226
280
|
phren.waypointIdx++;
|
|
227
281
|
} else {
|
|
228
282
|
phren.moving = false;
|
|
229
283
|
phren.arriving = true;
|
|
230
284
|
phren.arriveTimer = 0;
|
|
285
|
+
/* clear trail on arrival so purple line doesn't linger */
|
|
286
|
+
phren.trailPoints = [];
|
|
287
|
+
/* unpin all nodes now that phren has arrived */
|
|
288
|
+
phrenUnpinAll();
|
|
231
289
|
/* update keyboard-nav current node */
|
|
232
290
|
if (phren.targetNodeId) {
|
|
233
291
|
phrenCurrentNodeId = phren.targetNodeId;
|
|
@@ -235,18 +293,6 @@ export function renderGraphScript() {
|
|
|
235
293
|
phrenRefreshAdjacentLinks();
|
|
236
294
|
}
|
|
237
295
|
}
|
|
238
|
-
} else {
|
|
239
|
-
/* ease-in-out via sine curve over the full trip distance */
|
|
240
|
-
var t = phren.tripDist > 0 ? Math.min(1, phren.tripProgress / phren.tripDist) : 1;
|
|
241
|
-
var easeInOut = 0.5 - 0.5 * Math.cos(Math.PI * t);
|
|
242
|
-
var baseSpeed = Math.max(3, phren.tripDist * 0.12);
|
|
243
|
-
var speed = Math.max(1.5, baseSpeed * (0.15 + 0.85 * easeInOut));
|
|
244
|
-
phren.x += (dx / dist) * speed;
|
|
245
|
-
phren.y += (dy / dist) * speed;
|
|
246
|
-
phren.tripProgress += speed;
|
|
247
|
-
/* record trail — longer buffer for gradual fade */
|
|
248
|
-
phren.trailPoints.push({ x: phren.x, y: phren.y, age: 0 });
|
|
249
|
-
if (phren.trailPoints.length > 50) phren.trailPoints.shift();
|
|
250
296
|
}
|
|
251
297
|
}
|
|
252
298
|
if (phren.arriving) {
|
|
@@ -752,7 +798,7 @@ export function renderGraphScript() {
|
|
|
752
798
|
/* direct N^2 repulsion for small graphs (cheaper than quadtree overhead) */
|
|
753
799
|
for (var i = 0; i < n; i++) {
|
|
754
800
|
var nd = nodes[i];
|
|
755
|
-
if (nd === dragging) continue;
|
|
801
|
+
if (nd === dragging || nd._phrenPinned) continue;
|
|
756
802
|
var fx = 0, fy = 0;
|
|
757
803
|
for (var j = 0; j < n; j++) {
|
|
758
804
|
if (i === j) continue;
|
|
@@ -782,7 +828,7 @@ export function renderGraphScript() {
|
|
|
782
828
|
/* repulsion via quadtree */
|
|
783
829
|
for (var i = 0; i < n; i++) {
|
|
784
830
|
var nd = nodes[i];
|
|
785
|
-
if (nd === dragging) continue;
|
|
831
|
+
if (nd === dragging || nd._phrenPinned) continue;
|
|
786
832
|
var r = qt.computeForce(nd, THETA, REPULSION, 0, 0);
|
|
787
833
|
nd.vx = (nd.vx || 0) + r.fx * alpha;
|
|
788
834
|
nd.vy = (nd.vy || 0) + r.fy * alpha;
|
|
@@ -798,8 +844,8 @@ export function renderGraphScript() {
|
|
|
798
844
|
var dist = Math.sqrt(dx * dx + dy * dy) + 0.1;
|
|
799
845
|
var force = SPRING_K * (dist - REST_LEN) * alpha;
|
|
800
846
|
var fx = force * dx / dist, fy = force * dy / dist;
|
|
801
|
-
if (s !== dragging) { s.vx += fx; s.vy += fy; }
|
|
802
|
-
if (t !== dragging) { t.vx -= fx; t.vy -= fy; }
|
|
847
|
+
if (s !== dragging && !s._phrenPinned) { s.vx += fx; s.vy += fy; }
|
|
848
|
+
if (t !== dragging && !t._phrenPinned) { t.vx -= fx; t.vy -= fy; }
|
|
803
849
|
}
|
|
804
850
|
|
|
805
851
|
/* while a node is held, keep pulling its local cluster every frame */
|
|
@@ -814,7 +860,7 @@ export function renderGraphScript() {
|
|
|
814
860
|
var gravScale = n > 50 ? 80 / n : 1;
|
|
815
861
|
for (var i = 0; i < n; i++) {
|
|
816
862
|
var nd = nodes[i];
|
|
817
|
-
if (nd === dragging) continue;
|
|
863
|
+
if (nd === dragging || nd._phrenPinned) continue;
|
|
818
864
|
var grav = GRAVITY * gravScale;
|
|
819
865
|
nd.vx += (cx - nd.x) * grav * alpha;
|
|
820
866
|
nd.vy += (cy - nd.y) * grav * alpha;
|
|
@@ -823,7 +869,7 @@ export function renderGraphScript() {
|
|
|
823
869
|
/* integrate */
|
|
824
870
|
for (var i = 0; i < n; i++) {
|
|
825
871
|
var nd = nodes[i];
|
|
826
|
-
if (nd === dragging) continue;
|
|
872
|
+
if (nd === dragging || nd._phrenPinned) continue;
|
|
827
873
|
nd.vx *= DAMPING;
|
|
828
874
|
nd.vy *= DAMPING;
|
|
829
875
|
nd.x += nd.vx;
|
|
@@ -600,6 +600,10 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
600
600
|
return base + (base.indexOf('?') === -1 ? '?' : '&') + '_auth=' + encodeURIComponent(_tsAuthToken);
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
function loadJson(url) {
|
|
604
|
+
return fetch(url).then(function(r) { return r.json(); });
|
|
605
|
+
}
|
|
606
|
+
|
|
603
607
|
function priorityBadge(p) {
|
|
604
608
|
if (!p) return '';
|
|
605
609
|
var colors = { high: '#ef4444', medium: '#f59e0b', low: '#6b7280' };
|
|
@@ -756,9 +760,9 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
756
760
|
html += '</div>';
|
|
757
761
|
html += '<div class="task-card-actions">';
|
|
758
762
|
if (t.section !== 'Done') {
|
|
759
|
-
html += '<button class="task-done-btn" onclick="completeTaskFromUi(
|
|
763
|
+
html += '<button class="task-done-btn" onclick="completeTaskFromUi(\\'' + esc(t.project).replace(/'/g, "\\\\'") + '\\', \\'' + esc(t.line).replace(/'/g, "\\\\'") + '\\')" 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>';
|
|
760
764
|
}
|
|
761
|
-
html += '<button class="task-remove-btn" onclick="removeTaskFromUi(
|
|
765
|
+
html += '<button class="task-remove-btn" onclick="removeTaskFromUi(\\'' + esc(t.project).replace(/'/g, "\\\\'") + '\\', \\'' + esc(t.line).replace(/'/g, "\\\\'") + '\\')" 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>';
|
|
762
766
|
html += '</div>';
|
|
763
767
|
html += '</div>';
|
|
764
768
|
return html;
|
|
@@ -770,8 +774,8 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
770
774
|
var projects = projectFilter ? [projectFilter] : Array.from(new Set(_allTasks.map(function(t) { return t.project; }))).sort();
|
|
771
775
|
projects.forEach(function(proj) {
|
|
772
776
|
html += '<div class="task-add-bar">';
|
|
773
|
-
html += '<input id="task-add-input-' + esc(proj) + '" type="text" class="task-add-input" placeholder="Add a task to ' + esc(proj) + '\u2026" onkeydown="if(event.key
|
|
774
|
-
html += '<button class="task-add-btn" onclick="addTaskFromUi(
|
|
777
|
+
html += '<input id="task-add-input-' + esc(proj) + '" type="text" class="task-add-input" placeholder="Add a task to ' + esc(proj) + '\u2026" onkeydown="if(event.key===\\\'Enter\\\')addTaskFromUi(\\'' + esc(proj).replace(/'/g, "\\\\'") + '\\')">';
|
|
778
|
+
html += '<button class="task-add-btn" onclick="addTaskFromUi(\\'' + esc(proj).replace(/'/g, "\\\\'") + '\\')"><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>';
|
|
775
779
|
html += '</div>';
|
|
776
780
|
});
|
|
777
781
|
|
|
@@ -897,7 +901,7 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
897
901
|
var scopeNote = document.getElementById('settings-scope-note');
|
|
898
902
|
if (scopeNote) {
|
|
899
903
|
scopeNote.textContent = selectedProject
|
|
900
|
-
? 'Showing effective config for "' + selectedProject + '". Overrides are saved to that project
|
|
904
|
+
? 'Showing effective config for "' + selectedProject + '". Overrides are saved to that project\\\'s phren.project.yaml.'
|
|
901
905
|
: 'Showing global settings. Select a project to view and edit per-project overrides.';
|
|
902
906
|
}
|
|
903
907
|
|
|
@@ -7,7 +7,7 @@ import * as querystring from "querystring";
|
|
|
7
7
|
import { spawn, execFileSync } from "child_process";
|
|
8
8
|
import { computePhrenLiveStateToken, getProjectDirs, } from "./shared.js";
|
|
9
9
|
import { editFinding, readReviewQueue, removeFinding, readFindings, addFinding as addFindingStore, readTasksAcrossProjects, addTask as addTaskStore, completeTask as completeTaskStore, removeTask as removeTaskStore, TASKS_FILENAME, } from "./data-access.js";
|
|
10
|
-
import { isValidProjectName, errorMessage } from "./utils.js";
|
|
10
|
+
import { isValidProjectName, errorMessage, queueFilePath } from "./utils.js";
|
|
11
11
|
import { readInstallPreferences, writeInstallPreferences, writeGovernanceInstallPreferences } from "./init-preferences.js";
|
|
12
12
|
import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isAllowedFilePath, readSyncSnapshot, recentAccepted, recentUsage, } from "./memory-ui-data.js";
|
|
13
13
|
import { CONSOLIDATION_ENTRY_THRESHOLD } from "./content-validate.js";
|
|
@@ -388,6 +388,111 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
|
|
|
388
388
|
}));
|
|
389
389
|
return;
|
|
390
390
|
}
|
|
391
|
+
// POST /api/approve — remove item from review queue (keep finding)
|
|
392
|
+
if (req.method === "POST" && pathname === "/api/approve") {
|
|
393
|
+
void readFormBody(req, res).then((parsed) => {
|
|
394
|
+
if (!parsed)
|
|
395
|
+
return;
|
|
396
|
+
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
397
|
+
return;
|
|
398
|
+
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
399
|
+
return;
|
|
400
|
+
const project = String(parsed.project || "");
|
|
401
|
+
const line = String(parsed.line || "");
|
|
402
|
+
if (!project || !isValidProjectName(project) || !line) {
|
|
403
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
404
|
+
res.end(JSON.stringify({ ok: false, error: "Missing project or line" }));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
const qPath = queueFilePath(phrenPath, project);
|
|
409
|
+
if (fs.existsSync(qPath)) {
|
|
410
|
+
const content = fs.readFileSync(qPath, "utf8");
|
|
411
|
+
const lines = content.split("\n");
|
|
412
|
+
const filtered = lines.filter((l) => l.trim() !== line.trim());
|
|
413
|
+
fs.writeFileSync(qPath, filtered.join("\n"));
|
|
414
|
+
}
|
|
415
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
416
|
+
res.end(JSON.stringify({ ok: true }));
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
420
|
+
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// POST /api/reject — remove item from review queue AND remove finding
|
|
426
|
+
if (req.method === "POST" && pathname === "/api/reject") {
|
|
427
|
+
void readFormBody(req, res).then((parsed) => {
|
|
428
|
+
if (!parsed)
|
|
429
|
+
return;
|
|
430
|
+
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
431
|
+
return;
|
|
432
|
+
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
433
|
+
return;
|
|
434
|
+
const project = String(parsed.project || "");
|
|
435
|
+
const line = String(parsed.line || "");
|
|
436
|
+
if (!project || !isValidProjectName(project) || !line) {
|
|
437
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
438
|
+
res.end(JSON.stringify({ ok: false, error: "Missing project or line" }));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
// Remove from review queue
|
|
443
|
+
const qPath = queueFilePath(phrenPath, project);
|
|
444
|
+
if (fs.existsSync(qPath)) {
|
|
445
|
+
const content = fs.readFileSync(qPath, "utf8");
|
|
446
|
+
const lines = content.split("\n");
|
|
447
|
+
const filtered = lines.filter((l) => l.trim() !== line.trim());
|
|
448
|
+
fs.writeFileSync(qPath, filtered.join("\n"));
|
|
449
|
+
}
|
|
450
|
+
// Also remove the finding from FINDINGS.md
|
|
451
|
+
// Extract text from the line (strip "- " prefix and inline metadata)
|
|
452
|
+
const findingText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
|
|
453
|
+
if (findingText) {
|
|
454
|
+
removeFinding(phrenPath, project, findingText);
|
|
455
|
+
}
|
|
456
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
457
|
+
res.end(JSON.stringify({ ok: true }));
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
461
|
+
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// POST /api/edit — edit a finding's text
|
|
467
|
+
if (req.method === "POST" && pathname === "/api/edit") {
|
|
468
|
+
void readFormBody(req, res).then((parsed) => {
|
|
469
|
+
if (!parsed)
|
|
470
|
+
return;
|
|
471
|
+
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
472
|
+
return;
|
|
473
|
+
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
474
|
+
return;
|
|
475
|
+
const project = String(parsed.project || "");
|
|
476
|
+
const line = String(parsed.line || "");
|
|
477
|
+
const newText = String(parsed.new_text || "");
|
|
478
|
+
if (!project || !isValidProjectName(project) || !line || !newText) {
|
|
479
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
480
|
+
res.end(JSON.stringify({ ok: false, error: "Missing project, line, or new_text" }));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const oldText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
|
|
485
|
+
const result = editFinding(phrenPath, project, oldText, newText);
|
|
486
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
487
|
+
res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
491
|
+
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
391
496
|
if (req.method === "GET" && pathname.startsWith("/api/project-content")) {
|
|
392
497
|
const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
|
|
393
498
|
const project = String(qs.project || "");
|