@phren/cli 0.0.16 → 0.0.17
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-page.js +16 -18
- package/mcp/dist/memory-ui-scripts.js +44 -3
- package/mcp/dist/memory-ui-server.js +1 -0
- package/mcp/dist/memory-ui-styles.js +102 -0
- 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 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";
|
|
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 _currentSkillEnabled = true, _currentSkillSource = '', _currentSkillName = '';\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-enabled=\"' + (s.enabled ? 'true' : 'false') + '\" data-source=\"' + esc(s.source) + '\" data-ui-action=\"selectSkillFromEl\">' +\n '<span>' + esc(s.name) + '</span>' +\n '<span class=\"badge ' + (s.enabled ? 'badge-on' : 'badge-off') + '\">' + (s.enabled ? 'active' : 'inactive') + '</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') || '', el.getAttribute('data-enabled') === 'true', el.getAttribute('data-source') || '');\n };\n\n window.selectSkill = function(filePath, el, name, enabled, source) {\n if (_editingSkill && !confirm('Discard unsaved changes?')) return;\n _editingSkill = false;\n _currentSkillPath = filePath;\n _currentSkillEnabled = typeof enabled === 'boolean' ? enabled : true;\n _currentSkillSource = source || '';\n _currentSkillName = name || '';\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 var statusBadge = '<span class=\"badge ' + (_currentSkillEnabled ? 'badge-on' : 'badge-off') + '\" id=\"skill-enabled-badge\">' + (_currentSkillEnabled ? 'active' : 'inactive') + '</span>';\n var toggleLabel = _currentSkillEnabled ? 'Disable' : 'Enable';\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 statusBadge +\n '<span id=\"skill-status\"></span>' +\n '<button class=\"btn btn-sm\" data-ui-action=\"toggleSkill\">' + toggleLabel + '</button>' +\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 toggleBtn = document.createElement('button');\n toggleBtn.className = 'btn btn-sm';\n toggleBtn.textContent = _currentSkillEnabled ? 'Disable' : 'Enable';\n toggleBtn.setAttribute('data-ui-action', 'toggleSkill');\n toolbar.appendChild(toggleBtn);\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 window.toggleSkill = function() {\n if (!_currentSkillPath || !_currentSkillName) return;\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n var nextEnabled = !_currentSkillEnabled;\n fetch('/api/skill-toggle', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('project=' + encodeURIComponent(_currentSkillSource) + '&name=' + encodeURIComponent(_currentSkillName) + '&enabled=' + encodeURIComponent(String(nextEnabled))) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (!data.ok) return;\n _currentSkillEnabled = nextEnabled;\n loadSkills();\n window.selectSkill(_currentSkillPath, null, _currentSkillName, _currentSkillEnabled, _currentSkillSource);\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 html += '<div class=\"split-group-label\">Per-Project Overrides</div>';\n if (data.projectOverrides && data.projectOverrides.length) {\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 } else {\n html += '<div class=\"split-item\" style=\"cursor:default;color:var(--muted);font-size:var(--text-sm)\">No per-project overrides configured. Use <code>phren hooks enable/disable</code> with <code>--project</code> or configure in Settings.</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 === 'toggleSkill') {\n toggleSkill();\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 safeToken = JSON.stringify(authToken).slice(1, -1); // escape for JS string literal
|
|
5
5
|
const script = WEB_UI_SCRIPT_TEMPLATE.replace("__PHREN_AUTH_TOKEN_1d7b7c99__", safeToken);
|
|
@@ -98,24 +98,11 @@ ${TASK_UI_STYLES}
|
|
|
98
98
|
</div>
|
|
99
99
|
</div>
|
|
100
100
|
<details class="review-help" style="margin-bottom:16px">
|
|
101
|
-
<summary>
|
|
102
|
-
<
|
|
103
|
-
<dt>What is the Review Queue?</dt>
|
|
104
|
-
<dd>Fragments flagged by governance for human review. Items accumulate here when <code>phren maintain govern</code> is run.</dd>
|
|
105
|
-
<dt>Can I approve, reject, or edit items here?</dt>
|
|
106
|
-
<dd>Yes. Each review card has <strong>Approve</strong>, <strong>Reject</strong>, and <strong>Edit</strong> buttons. Approve accepts the fragment, Reject removes it, and Edit lets you revise the text before accepting. You can also use batch actions to approve or reject multiple items at once.</dd>
|
|
107
|
-
<dt>How do I clear items?</dt>
|
|
108
|
-
<dd>Approve or reject items directly in the UI, or use maintenance flows such as <code>phren maintain prune</code>.</dd>
|
|
109
|
-
<dt>Is this automatic?</dt>
|
|
110
|
-
<dd>No. Agents do not auto-accept review-queue items.</dd>
|
|
111
|
-
<dt>How do items get here?</dt>
|
|
112
|
-
<dd><code>phren maintain govern</code> flags stale or low-confidence fragments for review.</dd>
|
|
113
|
-
<dt>How to reduce noise?</dt>
|
|
114
|
-
<dd>Run <code>phren maintain prune</code> to auto-remove expired items without manual review.</dd>
|
|
115
|
-
</dl>
|
|
101
|
+
<summary>How review works</summary>
|
|
102
|
+
<p style="margin-top:8px;font-size:var(--text-sm);color:var(--muted)">Items waiting for your review. Approve to keep, reject to remove. You can also edit the text before approving, or use the checkboxes to bulk approve or reject multiple items at once.</p>
|
|
116
103
|
</details>
|
|
117
104
|
|
|
118
|
-
<p style="font-size:var(--text-sm);color:var(--muted);margin-bottom:12px;letter-spacing:-0.01em">
|
|
105
|
+
<p style="font-size:var(--text-sm);color:var(--muted);margin-bottom:12px;letter-spacing:-0.01em">Items waiting for your review. Approve to keep, reject to remove.</p>
|
|
119
106
|
|
|
120
107
|
<div id="review-summary-banner" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center"></div>
|
|
121
108
|
|
|
@@ -130,7 +117,18 @@ ${TASK_UI_STYLES}
|
|
|
130
117
|
<option value="">All models</option>
|
|
131
118
|
</select>
|
|
132
119
|
<span id="review-filter-count" class="text-muted" style="font-size:var(--text-sm);margin-left:8px"></span>
|
|
133
|
-
<
|
|
120
|
+
<label id="review-select-all" style="display:none;margin-left:auto;align-items:center;gap:6px;font-size:var(--text-sm);color:var(--muted);cursor:pointer;user-select:none">
|
|
121
|
+
<input type="checkbox" onchange="toggleSelectAll(this.checked)" style="width:14px;height:14px;cursor:pointer;accent-color:var(--accent)" />
|
|
122
|
+
Select all
|
|
123
|
+
</label>
|
|
124
|
+
<button class="btn btn-sm" id="highlight-only-btn">Flagged only</button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div id="batch-bar" class="batch-bar">
|
|
128
|
+
<span id="batch-count" class="batch-bar-count"></span>
|
|
129
|
+
<button class="btn btn-sm btn-approve" onclick="batchAction('approve')">Approve selected</button>
|
|
130
|
+
<button class="btn btn-sm btn-reject" onclick="batchAction('reject')">Reject selected</button>
|
|
131
|
+
<button class="btn btn-sm" onclick="clearBatchSelection()">Clear</button>
|
|
134
132
|
</div>
|
|
135
133
|
|
|
136
134
|
<div id="review-kbd-hints" style="font-size:var(--text-xs);color:var(--muted);margin-bottom:12px;display:none;gap:16px;flex-wrap:wrap">
|
|
@@ -224,7 +222,7 @@ ${TASK_UI_STYLES}
|
|
|
224
222
|
<div style="padding:20px;color:var(--muted)">Loading...</div>
|
|
225
223
|
</div>
|
|
226
224
|
<div class="split-reader" id="hooks-reader">
|
|
227
|
-
<div class="reader-empty">Select a hook config to view its contents.</div>
|
|
225
|
+
<div class="reader-empty">Select a hook config to view its contents.<br/><span style="font-size:var(--text-sm);color:var(--muted);margin-top:8px;display:inline-block">Per-project hooks can also be configured in Settings > [project name].</span></div>
|
|
228
226
|
</div>
|
|
229
227
|
</div>
|
|
230
228
|
</div>
|
|
@@ -605,6 +605,8 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
605
605
|
return fetch(url).then(function(r) { return r.json(); });
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
+
var _taskViewMode = 'list';
|
|
609
|
+
|
|
608
610
|
function priorityBadge(p) {
|
|
609
611
|
if (!p) return '';
|
|
610
612
|
var colors = { high: '#ef4444', medium: '#f59e0b', low: '#6b7280' };
|
|
@@ -612,6 +614,12 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
612
614
|
return '<span class="task-priority-badge task-priority-' + esc(p) + '">' + esc(p) + '</span>';
|
|
613
615
|
}
|
|
614
616
|
|
|
617
|
+
function sessionBadge(sessionId) {
|
|
618
|
+
if (!sessionId) return '';
|
|
619
|
+
var short = sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
|
|
620
|
+
return '<span class="task-session-badge" title="Session ' + esc(sessionId) + '">' + esc(short) + '</span>';
|
|
621
|
+
}
|
|
622
|
+
|
|
615
623
|
function statusChip(section, checked) {
|
|
616
624
|
if (checked || section === 'Done') return '<span class="task-status-chip task-status-done" title="Done"><svg width="12" height="12" 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</span>';
|
|
617
625
|
if (section === 'Active') return '<span class="task-status-chip task-status-active" title="In Progress">In Progress</span>';
|
|
@@ -731,10 +739,16 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
731
739
|
// Group by priority within sections
|
|
732
740
|
var priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
733
741
|
function sortByPriority(a, b) {
|
|
734
|
-
|
|
735
|
-
var
|
|
742
|
+
// Unchecked before checked
|
|
743
|
+
var aChecked = a.checked || a.section === 'Done' ? 1 : 0;
|
|
744
|
+
var bChecked = b.checked || b.section === 'Done' ? 1 : 0;
|
|
745
|
+
if (aChecked !== bChecked) return aChecked - bChecked;
|
|
746
|
+
// Pinned first
|
|
736
747
|
if (a.pinned && !b.pinned) return -1;
|
|
737
748
|
if (!a.pinned && b.pinned) return 1;
|
|
749
|
+
// Then by priority
|
|
750
|
+
var pa = priorityOrder[a.priority] !== undefined ? priorityOrder[a.priority] : 1;
|
|
751
|
+
var pb = priorityOrder[b.priority] !== undefined ? priorityOrder[b.priority] : 1;
|
|
738
752
|
return pa - pb;
|
|
739
753
|
}
|
|
740
754
|
|
|
@@ -756,6 +770,7 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
756
770
|
html += pinIndicator(t.pinned);
|
|
757
771
|
html += githubBadge(t.githubIssue, t.githubUrl);
|
|
758
772
|
html += priorityBadge(t.priority);
|
|
773
|
+
html += sessionBadge(t.sessionId);
|
|
759
774
|
html += '</div>';
|
|
760
775
|
html += '<div class="task-card-body">';
|
|
761
776
|
html += '<span class="task-card-text">' + esc(t.line) + '</span>';
|
|
@@ -773,6 +788,30 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
773
788
|
|
|
774
789
|
var html = '';
|
|
775
790
|
|
|
791
|
+
// Summary bar
|
|
792
|
+
var allActive = _allTasks.filter(function(t) { return t.section !== 'Done' && !t.checked; });
|
|
793
|
+
var highCount = allActive.filter(function(t) { return t.priority === 'high'; }).length;
|
|
794
|
+
var medCount = allActive.filter(function(t) { return t.priority === 'medium'; }).length;
|
|
795
|
+
var lowCount = allActive.filter(function(t) { return t.priority === 'low'; }).length;
|
|
796
|
+
var projectCounts = {};
|
|
797
|
+
allActive.forEach(function(t) { projectCounts[t.project] = (projectCounts[t.project] || 0) + 1; });
|
|
798
|
+
var topProjects = Object.keys(projectCounts).sort(function(a, b) { return projectCounts[b] - projectCounts[a]; }).slice(0, 3);
|
|
799
|
+
html += '<div class="task-summary-bar">';
|
|
800
|
+
html += '<span class="task-summary-total">' + allActive.length + ' active</span>';
|
|
801
|
+
if (highCount) html += '<span class="task-summary-pill task-summary-high">' + highCount + ' high</span>';
|
|
802
|
+
if (medCount) html += '<span class="task-summary-pill task-summary-medium">' + medCount + ' medium</span>';
|
|
803
|
+
if (lowCount) html += '<span class="task-summary-pill task-summary-low">' + lowCount + ' low</span>';
|
|
804
|
+
if (topProjects.length) {
|
|
805
|
+
html += '<span class="task-summary-projects">';
|
|
806
|
+
topProjects.forEach(function(p) { html += '<span class="task-summary-project">' + esc(p) + ' (' + projectCounts[p] + ')</span>'; });
|
|
807
|
+
html += '</span>';
|
|
808
|
+
}
|
|
809
|
+
html += '<span class="task-view-toggle" style="margin-left:auto">';
|
|
810
|
+
html += '<button class="task-view-btn' + (_taskViewMode === 'list' ? ' active' : '') + '" data-ts-action="setTaskView" data-mode="list" title="List view"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>';
|
|
811
|
+
html += '<button class="task-view-btn' + (_taskViewMode === 'compact' ? ' active' : '') + '" data-ts-action="setTaskView" data-mode="compact" title="Compact view"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="9" y="2" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="1" y="9" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="9" y="9" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/></svg></button>';
|
|
812
|
+
html += '</span>';
|
|
813
|
+
html += '</div>';
|
|
814
|
+
|
|
776
815
|
// Add task input at top (only when a specific project is selected)
|
|
777
816
|
var projects = projectFilter ? [projectFilter] : [];
|
|
778
817
|
projects.forEach(function(proj) {
|
|
@@ -785,9 +824,10 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
785
824
|
// Priority sections
|
|
786
825
|
function renderSection(title, items, icon) {
|
|
787
826
|
if (!items.length) return '';
|
|
827
|
+
var gridClass = _taskViewMode === 'compact' ? 'task-card-grid task-card-grid-compact' : 'task-card-grid';
|
|
788
828
|
var shtml = '<div class="task-priority-section">';
|
|
789
829
|
shtml += '<div class="task-section-header"><span class="task-section-icon">' + icon + '</span> ' + title + ' <span class="task-section-count">' + items.length + '</span></div>';
|
|
790
|
-
shtml += '<div class="
|
|
830
|
+
shtml += '<div class="' + gridClass + '">';
|
|
791
831
|
items.forEach(function(t) { shtml += renderTaskCard(t); });
|
|
792
832
|
shtml += '</div></div>';
|
|
793
833
|
return shtml;
|
|
@@ -1228,6 +1268,7 @@ export function renderTasksAndSettingsScript(authToken) {
|
|
|
1228
1268
|
else if (action === 'completeTask') { completeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
|
|
1229
1269
|
else if (action === 'removeTask') { removeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
|
|
1230
1270
|
else if (action === 'addTask') { addTaskFromUi(actionEl.getAttribute('data-project')); }
|
|
1271
|
+
else if (action === 'setTaskView') { _taskViewMode = actionEl.getAttribute('data-mode') || 'list'; filterTasks(); }
|
|
1231
1272
|
else if (action === 'setFindingSensitivity') { setFindingSensitivity(actionEl.getAttribute('data-level')); }
|
|
1232
1273
|
else if (action === 'toggleAutoCapture') { setAutoCapture(actionEl.getAttribute('data-enabled') !== 'true'); }
|
|
1233
1274
|
else if (action === 'setTaskMode') { setTaskMode(actionEl.getAttribute('data-mode')); }
|
|
@@ -552,4 +552,106 @@ export const TASK_UI_STYLES = `
|
|
|
552
552
|
transition: transform 0.2s;
|
|
553
553
|
}
|
|
554
554
|
.task-done-list { padding-top: 8px; }
|
|
555
|
+
|
|
556
|
+
/* ── Task Summary Bar ──────────────────────────── */
|
|
557
|
+
.task-summary-bar {
|
|
558
|
+
display: flex;
|
|
559
|
+
align-items: center;
|
|
560
|
+
gap: 10px;
|
|
561
|
+
padding: 10px 14px;
|
|
562
|
+
background: var(--surface-sunken, var(--surface));
|
|
563
|
+
border: 1px solid var(--border);
|
|
564
|
+
border-radius: var(--radius);
|
|
565
|
+
margin-bottom: 16px;
|
|
566
|
+
flex-wrap: wrap;
|
|
567
|
+
font-size: var(--text-sm);
|
|
568
|
+
}
|
|
569
|
+
.task-summary-total {
|
|
570
|
+
font-weight: 600;
|
|
571
|
+
color: var(--ink);
|
|
572
|
+
font-size: var(--text-base);
|
|
573
|
+
}
|
|
574
|
+
.task-summary-pill {
|
|
575
|
+
display: inline-flex;
|
|
576
|
+
align-items: center;
|
|
577
|
+
padding: 2px 8px;
|
|
578
|
+
border-radius: 999px;
|
|
579
|
+
font-size: 11px;
|
|
580
|
+
font-weight: 600;
|
|
581
|
+
}
|
|
582
|
+
.task-summary-high { background: #ef444422; color: #ef4444; }
|
|
583
|
+
.task-summary-medium { background: #f59e0b22; color: #f59e0b; }
|
|
584
|
+
.task-summary-low { background: #6b728022; color: #6b7280; }
|
|
585
|
+
.task-summary-projects {
|
|
586
|
+
display: flex;
|
|
587
|
+
gap: 6px;
|
|
588
|
+
align-items: center;
|
|
589
|
+
}
|
|
590
|
+
.task-summary-project {
|
|
591
|
+
font-size: 11px;
|
|
592
|
+
color: var(--muted);
|
|
593
|
+
padding: 1px 6px;
|
|
594
|
+
border: 1px solid var(--border);
|
|
595
|
+
border-radius: var(--radius-sm);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/* ── Task Session Badge ──────────────────────────── */
|
|
599
|
+
.task-session-badge {
|
|
600
|
+
display: inline-block;
|
|
601
|
+
padding: 1px 6px;
|
|
602
|
+
border-radius: var(--radius-sm);
|
|
603
|
+
font-size: 10px;
|
|
604
|
+
font-family: var(--mono, monospace);
|
|
605
|
+
background: var(--surface-sunken, var(--surface));
|
|
606
|
+
color: var(--muted);
|
|
607
|
+
border: 1px solid var(--border);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/* ── Task View Toggle ──────────────────────────── */
|
|
611
|
+
.task-view-toggle {
|
|
612
|
+
display: flex;
|
|
613
|
+
gap: 2px;
|
|
614
|
+
border: 1px solid var(--border);
|
|
615
|
+
border-radius: var(--radius-sm);
|
|
616
|
+
overflow: hidden;
|
|
617
|
+
}
|
|
618
|
+
.task-view-btn {
|
|
619
|
+
display: flex;
|
|
620
|
+
align-items: center;
|
|
621
|
+
justify-content: center;
|
|
622
|
+
width: 30px;
|
|
623
|
+
height: 28px;
|
|
624
|
+
background: var(--surface);
|
|
625
|
+
border: none;
|
|
626
|
+
color: var(--muted);
|
|
627
|
+
cursor: pointer;
|
|
628
|
+
transition: background 0.15s, color 0.15s;
|
|
629
|
+
}
|
|
630
|
+
.task-view-btn:hover { color: var(--ink); }
|
|
631
|
+
.task-view-btn.active {
|
|
632
|
+
background: var(--accent-dim);
|
|
633
|
+
color: var(--accent);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/* ── Task Compact Grid ──────────────────────────── */
|
|
637
|
+
.task-card-grid-compact {
|
|
638
|
+
display: grid !important;
|
|
639
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
640
|
+
gap: 8px;
|
|
641
|
+
}
|
|
642
|
+
.task-card-grid-compact .task-card {
|
|
643
|
+
padding: 10px 12px;
|
|
644
|
+
}
|
|
645
|
+
.task-card-grid-compact .task-card-body {
|
|
646
|
+
gap: 2px;
|
|
647
|
+
}
|
|
648
|
+
.task-card-grid-compact .task-card-text {
|
|
649
|
+
font-size: var(--text-sm);
|
|
650
|
+
display: -webkit-box;
|
|
651
|
+
-webkit-line-clamp: 2;
|
|
652
|
+
-webkit-box-orient: vertical;
|
|
653
|
+
overflow: hidden;
|
|
654
|
+
}
|
|
655
|
+
.task-card-grid-compact .task-card-context { display: none; }
|
|
656
|
+
.task-card-grid-compact .task-card-actions { margin-top: 6px; }
|
|
555
657
|
`;
|