@phren/cli 0.0.14 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  export const cliManifest = {
2
2
  surface: "cli",
3
- version: "0.0.14",
3
+ version: "0.0.15",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "cli-actions.ts:handleAddFinding" },
@@ -1,6 +1,6 @@
1
1
  export const mcpManifest = {
2
2
  surface: "mcp",
3
- version: "0.0.14",
3
+ version: "0.0.15",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "index.ts:add_finding" },
@@ -1,6 +1,6 @@
1
1
  export const vscodeManifest = {
2
2
  surface: "vscode",
3
- version: "0.0.14",
3
+ version: "0.0.15",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "extension.ts:phren.addFinding" },
@@ -1,6 +1,6 @@
1
1
  export const webUiManifest = {
2
2
  surface: "web-ui",
3
- version: "0.0.14",
3
+ version: "0.0.15",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: false, reason: "Web UI is read-only for findings (review queue only)" },
@@ -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\">&#9733;</button>' +\n '<div class=\"project-card-name\">' + esc(p.name) + '</div>' +\n (p.summaryText ? '<div class=\"project-card-summary\">' + esc(p.summaryText) + '</div>' : '<div class=\"project-card-summary\" style=\"font-style:italic\">No summary</div>') +\n '<div class=\"project-card-stats\">' +\n '<span class=\"project-card-stat\"><strong>' + p.findingCount + '</strong> findings</span>' +\n '<span class=\"project-card-stat\"><strong>' + p.taskCount + '</strong> task</span>' +\n (p.hasClaudeMd ? '<span class=\"project-card-stat\">CLAUDE.md</span>' : '') +\n (p.hasReference ? '<span class=\"project-card-stat\">reference/</span>' : '') +\n githubHtml +\n '</div>' +\n sparkHtml +\n '</div>';\n }).join('');\n }\n\n var _projectData = [];\n\n function loadProjects() {\n fetch(authUrl('/api/projects')).then(function(r) { return r.json(); }).then(function(data) {\n _projectData = data;\n renderProjectCards(data);\n });\n }\n\n // ── Review queue (live) ───────────────────────────────────────\n var _reviewData = [];\n var _reviewCardKeys = new Set(); // tracks project+line keys currently in DOM\n\n function cardKey(item) { return item.project + '\\\\x00' + item.line; }\n\n function renderReviewCard(item, delayMs) {\n var key = cardKey(item);\n var projectBadge = '<span class=\"badge badge-project\">' + esc(item.project) + '</span>';\n var sectionBadge = '<span class=\"badge\">' + esc(item.section) + '</span>';\n var machineBadge = item.machine ? '<span class=\"badge badge-machine\" title=\"Machine: ' + esc(item.machine) + '\">' + esc(item.machine) + '</span>' : '';\n var modelBadge = (item.model && item.model !== 'unknown') ? '<span class=\"badge badge-model\" title=\"Model: ' + esc(item.model) + '\">' + esc(item.model) + '</span>' : '';\n var dateSpan = '<span class=\"text-muted\" style=\"font-size:var(--text-sm);margin-left:auto\">' + esc(item.date) + '</span>';\n\n var cardText = esc(item.text);\n\n var div = document.createElement('div');\n div.className = 'review-card';\n div.setAttribute('data-key', key);\n div.setAttribute('data-project', item.project);\n div.setAttribute('data-machine', item.machine || '');\n div.setAttribute('data-model', item.model || '');\n div.style.animationDelay = delayMs + 'ms';\n div.innerHTML =\n '<div class=\"review-card-inner\">' +\n '<div class=\"review-card-header\">' +\n '<svg class=\"review-card-drag-handle\" viewBox=\"0 0 16 16\" fill=\"currentColor\"><circle cx=\"5\" cy=\"4\" r=\"1.2\"/><circle cx=\"11\" cy=\"4\" r=\"1.2\"/><circle cx=\"5\" cy=\"8\" r=\"1.2\"/><circle cx=\"11\" cy=\"8\" r=\"1.2\"/><circle cx=\"5\" cy=\"12\" r=\"1.2\"/><circle cx=\"11\" cy=\"12\" r=\"1.2\"/></svg>' +\n projectBadge + sectionBadge + machineBadge + modelBadge + dateSpan +\n '</div>' +\n '<div class=\"review-card-text\">' + cardText + '</div>' +\n '<div class=\"review-card-actions\">' +\n '<button type=\"button\" class=\"btn btn-sm btn-approve\" data-ui-action=\"reviewAction\" data-review-type=\"approve\">Approve</button>' +\n '<button type=\"button\" class=\"btn btn-sm btn-reject\" data-ui-action=\"reviewAction\" data-review-type=\"reject\">Reject</button>' +\n '<button type=\"button\" class=\"btn btn-sm\" data-ui-action=\"toggleReviewEdit\">Edit</button>' +\n '</div>' +\n '<div class=\"review-card-edit\" style=\"display:none\">' +\n '<form data-ui-action=\"reviewEditSubmit\">' +\n '<textarea name=\"new_text\" class=\"review-edit-textarea\">' + cardText + '</textarea>' +\n '<div style=\"display:flex;gap:8px;margin-top:8px\">' +\n '<button type=\"submit\" class=\"btn btn-sm btn-primary\">Save</button>' +\n '<button type=\"button\" class=\"btn btn-sm\" data-ui-action=\"toggleReviewEdit\">Cancel</button>' +\n '</div>' +\n '</form>' +\n '</div>' +\n '</div>';\n\n var approveBtn = div.querySelector('.btn-approve');\n if (approveBtn) {\n approveBtn.setAttribute('data-project', item.project);\n approveBtn.setAttribute('data-line', item.line);\n }\n var rejectBtn = div.querySelector('.btn-reject');\n if (rejectBtn) {\n rejectBtn.setAttribute('data-project', item.project);\n rejectBtn.setAttribute('data-line', item.line);\n }\n var editForm = div.querySelector('.review-card-edit form');\n if (editForm) {\n editForm.setAttribute('data-project', item.project);\n editForm.setAttribute('data-line', item.line);\n }\n\n // Render queue text as escaped plain text; review items are not trusted markdown/HTML.\n var textEl = div.querySelector('.review-card-text');\n if (textEl) {\n textEl.innerHTML = esc(item.text).replace(/\\n/g, '<br>');\n }\n\n // Add batch checkbox\n var checkbox = document.createElement('div');\n checkbox.className = 'review-card-check';\n checkbox.onclick = function(e) {\n e.stopPropagation();\n this.classList.toggle('checked');\n updateBatchBar();\n };\n div.querySelector('.review-card-inner').appendChild(checkbox);\n\n // Drag reorder\n div.setAttribute('draggable', 'true');\n var handle = div.querySelector('.review-card-drag-handle');\n if (handle) {\n handle.addEventListener('mousedown', function() { div.setAttribute('draggable', 'true'); });\n }\n div.addEventListener('dragstart', function(e) {\n div.classList.add('dragging');\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', key);\n });\n div.addEventListener('dragend', function() {\n div.classList.remove('dragging');\n document.querySelectorAll('.review-card.drag-over').forEach(function(c) { c.classList.remove('drag-over'); });\n });\n div.addEventListener('dragover', function(e) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n var dragging = document.querySelector('.review-card.dragging');\n if (dragging && dragging !== div) {\n div.classList.add('drag-over');\n }\n });\n div.addEventListener('dragleave', function() {\n div.classList.remove('drag-over');\n });\n div.addEventListener('drop', function(e) {\n e.preventDefault();\n div.classList.remove('drag-over');\n var dragging = document.querySelector('.review-card.dragging');\n if (dragging && dragging !== div) {\n var list = div.parentNode;\n var cards = Array.from(list.querySelectorAll('.review-card'));\n var dragIdx = cards.indexOf(dragging);\n var dropIdx = cards.indexOf(div);\n if (dragIdx < dropIdx) {\n list.insertBefore(dragging, div.nextSibling);\n } else {\n list.insertBefore(dragging, div);\n }\n }\n });\n\n return div;\n }\n\n function loadReviewCards() {\n var url = '/api/review-queue' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(data) {\n _reviewData = data;\n var list = document.getElementById('review-cards-list');\n if (!list) return;\n\n // Build set of incoming keys\n var incomingKeys = new Set(data.map(cardKey));\n\n // Remove cards no longer in queue (animate out)\n var existing = list.querySelectorAll('.review-card[data-key]');\n existing.forEach(function(card) {\n var key = card.getAttribute('data-key');\n if (!incomingKeys.has(key)) {\n card.classList.add('removing');\n setTimeout(function() { if (card.parentNode) card.parentNode.removeChild(card); }, 300);\n _reviewCardKeys.delete(key);\n }\n });\n\n // Add new cards with similarity grouping (animate in with stagger)\n var newItems = data.filter(function(item) { return !_reviewCardKeys.has(cardKey(item)); });\n if (newItems.length > 0) {\n // Remove existing groups if full re-render\n list.querySelectorAll('.review-group').forEach(function(g) { g.remove(); });\n\n var groups = clusterCards(newItems);\n var delayBase = 0;\n groups.forEach(function(groupItems) {\n if (groupItems.length > 1) {\n // Multi-item group: wrap in a group container\n var groupDiv = document.createElement('div');\n groupDiv.className = 'review-group';\n var header = document.createElement('div');\n header.className = 'review-group-header';\n header.innerHTML = '<span>Similar (' + groupItems.length + ' items)</span><button class=\"review-group-toggle\" data-ui-action=\"toggleGroup\">\\u25BC</button>';\n groupDiv.appendChild(header);\n groupItems.forEach(function(item) {\n var key = cardKey(item);\n var card = renderReviewCard(item, delayBase);\n groupDiv.appendChild(card);\n _reviewCardKeys.add(key);\n delayBase += 40;\n });\n list.appendChild(groupDiv);\n } else {\n // Singleton: render normally\n var item = groupItems[0];\n var key = cardKey(item);\n var card = renderReviewCard(item, delayBase);\n list.appendChild(card);\n _reviewCardKeys.add(key);\n delayBase += 40;\n }\n });\n }\n\n // Show/hide select-all checkbox\n var selectAllEl = document.getElementById('review-select-all');\n if (selectAllEl) selectAllEl.style.display = data.length > 0 ? 'flex' : 'none';\n\n // Empty state\n var totalVisible = list.querySelectorAll('.review-card:not(.removing)').length;\n var emptyEl = list.querySelector('.review-cards-empty');\n if (totalVisible === 0 && newItems.length === 0) {\n if (!emptyEl) {\n var empty = document.createElement('div');\n empty.className = 'review-cards-empty';\n empty.style.cssText = 'text-align:center;padding:40px;color:var(--muted)';\n empty.innerHTML = '<svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 12px;display:block\"><rect x=\"15\" y=\"20\" width=\"50\" height=\"40\" rx=\"4\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><polyline points=\"28,38 36,46 52,32\" stroke=\"var(--accent)\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;margin-bottom:4px\">All caught up</div><div style=\"font-size:var(--text-sm);color:var(--muted)\">No memories waiting for review.</div>';\n list.appendChild(empty);\n }\n } else if (emptyEl) {\n emptyEl.remove();\n }\n\n // Remove loading spinner\n var loading = list.querySelector('.review-cards-loading');\n if (loading) loading.remove();\n\n // Update nav badge\n var navBtn = document.querySelector('.nav-item[data-tab=\"review\"] .count');\n if (data.length > 0) {\n if (!navBtn) {\n var btn = document.querySelector('.nav-item[data-tab=\"review\"]');\n if (btn) {\n var countSpan = document.createElement('span');\n countSpan.className = 'count count-animating';\n countSpan.textContent = String(data.length);\n btn.appendChild(countSpan);\n }\n } else {\n var oldCount = parseInt(navBtn.textContent, 10) || 0;\n if (oldCount !== data.length) {\n animateCount(navBtn, oldCount, data.length);\n navBtn.classList.remove('count-animating');\n void navBtn.offsetWidth;\n navBtn.classList.add('count-animating');\n }\n }\n } else {\n if (navBtn) navBtn.remove();\n }\n\n // Update filters\n var machines = new Set(), models = new Set(), projects = new Set();\n data.forEach(function(item) {\n projects.add(item.project);\n if (item.machine) machines.add(item.machine);\n if (item.model && item.model !== 'unknown') models.add(item.model);\n });\n\n var filterContainer = document.getElementById('review-filters');\n if (filterContainer) {\n filterContainer.style.display = data.length > 0 ? 'flex' : 'none';\n }\n\n function updateSelect(id, values, allLabel) {\n var sel = document.getElementById(id);\n if (!sel) return;\n var current = sel.value;\n sel.innerHTML = '<option value=\"\">' + allLabel + '</option>';\n Array.from(values).sort().forEach(function(v) {\n var opt = document.createElement('option');\n opt.value = v;\n opt.textContent = v;\n if (v === current) opt.selected = true;\n sel.appendChild(opt);\n });\n }\n updateSelect('review-filter-project', projects, 'All projects');\n updateSelect('review-filter-machine', machines, 'All machines');\n updateSelect('review-filter-model', models, 'All models');\n\n // Show/hide keyboard hints\n var hints = document.getElementById('review-kbd-hints');\n if (hints) hints.style.display = data.length > 0 ? 'flex' : 'none';\n\n // Re-apply current filter\n filterReviewCards();\n }).catch(function() {\n var list = document.getElementById('review-cards-list');\n if (list) {\n var loading = list.querySelector('.review-cards-loading');\n if (loading) loading.textContent = 'Failed to load review queue.';\n }\n });\n }\n\n function loadReviewActivity() {\n var url = '/api/review-activity' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(data) {\n var acceptedEl = document.getElementById('accepted-list');\n var usageEl = document.getElementById('usage-list');\n if (acceptedEl) acceptedEl.innerHTML = data.accepted && data.accepted.length ? data.accepted.map(function(l) { return '<li>' + esc(l) + '</li>'; }).join('') : '<li style=\"color:var(--muted)\">None yet.</li>';\n if (usageEl) usageEl.innerHTML = data.usage && data.usage.length ? data.usage.map(function(l) { return '<li>' + esc(l) + '</li>'; }).join('') : '<li style=\"color:var(--muted)\">No usage events yet.</li>';\n }).catch(function() {});\n }\n\n function refreshLiveState() {\n loadProjects();\n fetch(authUrl('/api/runtime-health')).then(function(r) { return r.json(); }).then(function(data) {\n var summary = document.getElementById('sync-state-summary');\n if (!summary) return;\n summary.innerHTML =\n '<div><strong>Auto-save</strong><div class=\"text-muted\">' + esc(data.autoSaveStatus || 'n/a') + '</div></div>' +\n '<div><strong>Last pull</strong><div class=\"text-muted\">' + esc((data.lastPullStatus || 'n/a') + ' ' + (data.lastPullAt || '')) + '</div></div>' +\n '<div><strong>Last push</strong><div class=\"text-muted\">' + esc((data.lastPushStatus || 'n/a') + ' ' + (data.lastPushAt || '')) + '</div></div>' +\n '<div><strong>Unsynced commits</strong><div class=\"text-muted\">' + esc(String(data.unsyncedCommits || 0)) + '</div></div>';\n // Update sync LED\n var led = document.getElementById('sync-led');\n if (led) {\n var pushOk = !data.lastPushStatus || data.lastPushStatus === 'ok' || data.lastPushStatus === 'n/a';\n var pullOk = !data.lastPullStatus || data.lastPullStatus === 'ok' || data.lastPullStatus === 'n/a';\n var hasUnsynced = (data.unsyncedCommits || 0) > 0;\n if (!pushOk || !pullOk) {\n led.className = 'status-led status-led-err';\n led.title = 'Sync error';\n } else if (hasUnsynced) {\n led.className = 'status-led status-led-warn';\n led.title = hasUnsynced + ' unsynced commit' + (hasUnsynced > 1 ? 's' : '');\n } else {\n led.className = 'status-led status-led-ok';\n led.title = 'phren is synced';\n }\n }\n });\n if (_selectedProject) {\n var activeTab = document.querySelector('.project-detail-tab.active');\n var activeFile = activeTab ? activeTab.textContent : 'Findings';\n var fileMap = { 'Findings': 'FINDINGS.md', 'Tasks': 'tasks.md', 'CLAUDE.md': 'CLAUDE.md', 'Summary': 'summary.md', 'Reference': 'reference:browser' };\n loadProjectFile(fileMap[activeFile] || 'FINDINGS.md', activeTab);\n }\n if (_skillsLoaded) loadSkills();\n if (_hooksLoaded) loadHooks();\n if (_graphLoaded && !_graphRunning) loadGraph();\n loadReviewCards();\n loadReviewActivity();\n }\n\n function pollLiveUpdates() {\n fetch(authUrl('/api/change-token'))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data || !data.token) return;\n if (!_lastChangeToken) {\n _lastChangeToken = data.token;\n return;\n }\n if (data.token === _lastChangeToken) return;\n _lastChangeToken = data.token;\n refreshLiveState();\n })\n .catch(function() {});\n }\n\n window.toggleStar = function(name) {\n var starred = getStarredProjects();\n var idx = starred.indexOf(name);\n if (idx !== -1) starred.splice(idx, 1); else starred.push(name);\n setStarredProjects(starred);\n renderProjectCards(_projectData);\n };\n\n window.filterProjects = function(query) {\n var cards = document.querySelectorAll('.project-card');\n var q = query.toLowerCase();\n cards.forEach(function(card) {\n var name = (card.getAttribute('data-project') || '').toLowerCase();\n var summary = (card.getAttribute('data-summary') || '').toLowerCase();\n card.style.display = (!q || name.indexOf(q) !== -1 || summary.indexOf(q) !== -1) ? '' : 'none';\n });\n };\n\n window.toggleReviewEdit = function(btn) {\n var card = btn.closest('.review-card');\n if (!card) return;\n var editSection = card.querySelector('.review-card-edit');\n if (!editSection) return;\n var isVisible = editSection.style.display !== 'none';\n editSection.style.display = isVisible ? 'none' : 'block';\n\n if (!isVisible) {\n // Create diff view if not present\n var existing = editSection.querySelector('.review-diff');\n if (existing) existing.remove();\n\n var ta = editSection.querySelector('textarea');\n var originalText = card.querySelector('.review-card-text') ? card.querySelector('.review-card-text').textContent.trim() : '';\n\n var diffContainer = document.createElement('div');\n diffContainer.className = 'review-diff';\n diffContainer.innerHTML =\n '<div class=\"review-diff-pane\"><div class=\"review-diff-pane-label\">Original</div><div class=\"diff-original\"></div></div>' +\n '<div class=\"review-diff-pane\"><div class=\"review-diff-pane-label\">Edited</div><div class=\"diff-edited\"></div></div>';\n\n // Insert before the textarea\n ta.parentNode.insertBefore(diffContainer, ta);\n\n function updateDiff() {\n var original = originalText;\n var edited = ta.value;\n var origEl = diffContainer.querySelector('.diff-original');\n var editEl = diffContainer.querySelector('.diff-edited');\n\n if (original === edited) {\n origEl.textContent = original;\n editEl.innerHTML = '<span style=\"color:var(--muted);font-style:italic\">No changes</span>';\n return;\n }\n\n // Simple word-level diff\n var origWords = original.split(/(\\\\s+)/);\n var editWords = edited.split(/(\\\\s+)/);\n var origHtml = '', editHtml = '';\n\n // Simple sequential comparison\n var i = 0, j = 0;\n while (i < origWords.length || j < editWords.length) {\n if (i < origWords.length && j < editWords.length && origWords[i] === editWords[j]) {\n origHtml += esc(origWords[i]);\n editHtml += esc(editWords[j]);\n i++; j++;\n } else if (i < origWords.length && editWords.indexOf(origWords[i], j) === -1) {\n origHtml += '<span class=\"diff-del\">' + esc(origWords[i]) + '</span>';\n i++;\n } else if (j < editWords.length && origWords.indexOf(editWords[j], i) === -1) {\n editHtml += '<span class=\"diff-ins\">' + esc(editWords[j]) + '</span>';\n j++;\n } else {\n // advance both\n if (i < origWords.length) { origHtml += '<span class=\"diff-del\">' + esc(origWords[i]) + '</span>'; i++; }\n if (j < editWords.length) { editHtml += '<span class=\"diff-ins\">' + esc(editWords[j]) + '</span>'; j++; }\n }\n }\n origEl.innerHTML = origHtml;\n editEl.innerHTML = editHtml;\n }\n\n updateDiff();\n ta.addEventListener('input', updateDiff);\n }\n };\n\n window.filterReviewCards = function() {\n var fp = document.getElementById('review-filter-project');\n var fm = document.getElementById('review-filter-machine');\n var fmod = document.getElementById('review-filter-model');\n if (!fp) return;\n var project = fp.value;\n var machine = fm ? fm.value : '';\n var model = fmod ? fmod.value : '';\n var cards = document.querySelectorAll('.review-card');\n var shown = 0;\n var total = cards.length;\n cards.forEach(function(card) {\n var cp = card.getAttribute('data-project') || '';\n var cm = card.getAttribute('data-machine') || '';\n var cmod = card.getAttribute('data-model') || '';\n var show = (!project || cp === project) && (!machine || cm === machine) && (!model || cmod === model);\n card.style.display = show ? '' : 'none';\n if (show) shown++;\n });\n var countEl = document.getElementById('review-filter-count');\n if (countEl) countEl.textContent = shown < total ? (shown + ' of ' + total) : '';\n };\n\n window.selectProject = function(name, el) {\n _selectedProject = name;\n document.querySelectorAll('.project-card').forEach(function(c) { c.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var area = document.getElementById('project-detail-area');\n area.innerHTML =\n '<div class=\"project-detail\">' +\n '<div class=\"project-detail-header\"><h2>' + esc(name) + '</h2></div>' +\n '<div class=\"project-detail-tabs\">' +\n '<button class=\"project-detail-tab active\" data-ui-action=\"loadProjectFile\" data-file=\"FINDINGS.md\">Findings</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"tasks.md\">Tasks</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"CLAUDE.md\">CLAUDE.md</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"summary.md\">Summary</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"reference:browser\">Reference</button>' +\n '</div>' +\n '<div class=\"project-detail-content\" id=\"project-content\"><div class=\"project-detail-empty\">Loading...</div></div>' +\n '</div>';\n loadProjectFile('FINDINGS.md', area.querySelector('.project-detail-tab'));\n area.scrollIntoView({ behavior: 'smooth', block: 'start' });\n };\n\n\n\n function normalizeScoresPayload(payload) {\n if (!payload || typeof payload !== 'object') return {};\n if (payload.entries && typeof payload.entries === 'object') return payload.entries;\n return payload;\n }\n\n function loadScores() {\n if (_scoresPromise) return _scoresPromise;\n _scoresPromise = fetch(authUrl('/api/scores'))\n .then(function(r) { return r.json(); })\n .then(function(payload) { return normalizeScoresPayload(payload); })\n .catch(function() { return {}; });\n return _scoresPromise;\n }\n\n function scoreDigest(text) {\n if (!window.crypto || !window.crypto.subtle || typeof TextEncoder === 'undefined') return Promise.resolve('');\n var data = new TextEncoder().encode(text);\n return window.crypto.subtle.digest('SHA-1', data).then(function(buf) {\n var bytes = Array.from(new Uint8Array(buf));\n return bytes.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').slice(0, 12);\n }).catch(function() { return ''; });\n }\n\n function computeQualityFromEntry(entry) {\n if (!entry) return null;\n var now = Date.now();\n var lastUsed = entry.lastUsedAt ? new Date(entry.lastUsedAt).getTime() : 0;\n var daysSince = lastUsed ? (now - lastUsed) / 86400000 : 999;\n\n var recencyBoost = 0;\n if (daysSince <= 7) recencyBoost = 0.15;\n else if (daysSince <= 30) recencyBoost = 0;\n else recencyBoost = -0.1 * Math.min(3, (daysSince - 30) / 30);\n\n var impressions = entry.impressions || 0;\n var frequencyBoost = impressions > 0 ? Math.min(0.2, Math.log2(impressions + 1) * 0.05) : 0;\n var helpful = entry.helpful || 0;\n var reprompt = entry.repromptPenalty || 0;\n var regression = entry.regressionPenalty || 0;\n var penalties = reprompt + regression * 2;\n var feedbackScore = helpful * 0.15 - penalties * 0.2;\n var raw = 1 + feedbackScore + recencyBoost + frequencyBoost;\n return Math.max(0.2, Math.min(1.5, raw));\n }\n\n function decayStateFromQuality(multiplier) {\n if (typeof multiplier !== 'number' || Number.isNaN(multiplier)) return 'unknown';\n if (multiplier > 0.7) return 'healthy';\n if (multiplier >= 0.3) return 'decaying';\n return 'stale';\n }\n\n function parseFindings(content) {\n var lines = String(content || '').split(String.fromCharCode(10));\n var findings = [];\n var currentDate = '';\n var inDetails = false;\n lines.forEach(function(line) {\n var trimmed = line.trim();\n if (trimmed.indexOf('<details') === 0) { inDetails = true; return; }\n if (inDetails) {\n if (trimmed.indexOf('</details>') === 0) inDetails = false;\n return;\n }\n if (trimmed.indexOf('## ') === 0) {\n currentDate = trimmed.slice(3).trim();\n return;\n }\n if (trimmed.indexOf('- ') !== 0) return;\n var text = trimmed.slice(2).trim();\n if (!text) return;\n findings.push({ text: text, date: currentDate });\n });\n return findings;\n }\n\n function renderFindingsContent(container, project, content) {\n var findings = parseFindings(content);\n if (!findings.length) {\n container.innerHTML = '<pre>' + esc(content) + '</pre>';\n return;\n }\n loadScores().then(function(scores) {\n return Promise.all(findings.map(function(finding) {\n var short = finding.text.trim().replace(/ +/g, ' ').slice(0, 200);\n return scoreDigest(project + ':FINDINGS.md:' + short).then(function(digest) {\n var key = digest ? (project + '/FINDINGS.md:' + digest) : '';\n var entry = key ? scores[key] : null;\n var multiplier = computeQualityFromEntry(entry);\n var state = decayStateFromQuality(multiplier);\n return {\n text: finding.text,\n date: finding.date,\n key: key,\n multiplier: multiplier,\n state: state,\n };\n });\n }));\n }).then(function(items) {\n var html = '<div style=\"display:flex;gap:8px;padding:14px 14px 0 14px\"><input id=\"finding-add-input\" type=\"text\" placeholder=\"Add a finding\u2026\" style=\"flex:1;border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm);font-family:var(--font)\" onkeydown=\"if(event.key===\\'Enter\\')window.phrenAddFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\')\"><button onclick=\"window.phrenAddFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\')\" style=\"border:1px solid var(--accent);border-radius:var(--radius-sm);padding:4px 12px;background:var(--accent);color:#fff;font-size:var(--text-sm);cursor:pointer;font-family:var(--font)\">Add</button></div>';\n html += '<div class=\"finding-cards\">';\n items.forEach(function(item) {\n var scoreText = typeof item.multiplier === 'number' ? item.multiplier.toFixed(2) : 'n/a';\n var stateText = item.state === 'unknown' ? 'unknown' : item.state;\n var stateClass = item.state === 'unknown' ? 'decaying' : item.state;\n html +=\n '<details class=\"finding-detail-card\">' +\n '<summary>' + esc(item.text) + '</summary>' +\n '<div class=\"finding-detail-meta\">' +\n (item.date ? '<span>Date: ' + esc(item.date) + '</span>' : '') +\n '<span>Quality multiplier: ' + esc(scoreText) + '</span>' +\n '<span><span class=\"finding-score-indicator ' + esc(stateClass) + '\"></span>Decay state: ' + esc(stateText) + '</span>' +\n '<button onclick=\"window.phrenRemoveFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\', this)\" data-finding-text=\"' + esc(item.text) + '\" style=\"margin-left:auto;background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;cursor:pointer;color:var(--muted);font-size:11px\">Remove</button>' +\n '</div>' +\n '</details>';\n });\n html += '</div>';\n container.innerHTML = html;\n }).catch(function() {\n container.innerHTML = '<pre>' + esc(content) + '</pre>';\n });\n }\n\n window.loadProjectFile = function(file, btn) {\n if (!_selectedProject) return;\n document.querySelectorAll('.project-detail-tab').forEach(function(b) { b.classList.remove('active'); });\n if (btn) btn.classList.add('active');\n var container = document.getElementById('project-content');\n container.innerHTML = '<div class=\"project-detail-empty\">Loading...</div>';\n if (file === 'reference:browser' && typeof window.phrenLoadProjectReference === 'function') {\n window.phrenLoadProjectReference();\n return;\n }\n fetch(authUrl('/api/project-content?project=' + encodeURIComponent(_selectedProject) + '&file=' + encodeURIComponent(file)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data.ok) {\n container.innerHTML = '<div class=\"project-detail-empty\">' + esc(data.error || 'File not found') + '</div>';\n return;\n }\n if (file === 'FINDINGS.md') {\n renderFindingsContent(container, _selectedProject, data.content);\n return;\n }\n container.innerHTML = '<pre>' + esc(data.content) + '</pre>';\n });\n };\n\n loadProjects();\n loadReviewCards();\n loadReviewActivity();\n var _lastChangeToken = '';\n pollLiveUpdates();\n window.setInterval(pollLiveUpdates, 2000);\n\n // ── Auth helpers ─────────────────────────────────────────────\n function authUrl(base) {\n return base + (base.indexOf('?') === -1 ? '?' : '&') + '_auth=' + encodeURIComponent(_authToken);\n }\n function authBody(body) {\n return body + (_authToken ? '&_auth=' + encodeURIComponent(_authToken) : '');\n }\n\n // ── Finding management ───────────────────────────────────────\n window.phrenAddFinding = function(project) {\n var input = document.getElementById('finding-add-input');\n if (!input || !input.value.trim()) return;\n var text = input.value.trim();\n fetch(authUrl('/api/csrf-token')).then(function(r) { return r.json(); }).then(function(csrfData) {\n var body = new URLSearchParams({ text: text });\n if (csrfData.token) body.set('_csrf', csrfData.token);\n return fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'POST', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { input.value = ''; window.selectProjectFile('FINDINGS.md'); } else { alert(data.error || 'Failed to add finding'); }\n }).catch(function(err) { alert('Error: ' + String(err)); });\n };\n window.phrenRemoveFinding = function(project, btn) {\n var text = btn.getAttribute('data-finding-text');\n if (!text || !confirm('Remove this finding?')) return;\n fetch(authUrl('/api/csrf-token')).then(function(r) { return r.json(); }).then(function(csrfData) {\n var body = new URLSearchParams({ text: text });\n if (csrfData.token) body.set('_csrf', csrfData.token);\n return fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'DELETE', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { window.selectProjectFile('FINDINGS.md'); } else { alert(data.error || 'Failed to remove finding'); }\n }).catch(function(err) { alert('Error: ' + String(err)); });\n };\n\n // ── Skills ───────────────────────────────────────────────────\n function loadSkills() {\n fetch(authUrl('/api/skills')).then(function(r) { return r.json(); }).then(function(data) {\n _skillsLoaded = true;\n var list = document.getElementById('skills-list');\n if (!data.length) { list.innerHTML = '<div style=\"padding:40px 20px;color:var(--muted);text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 8px;display:block\"><path d=\"M40 16l4 12h-8l4-12z\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\" stroke-linejoin=\"round\"/><path d=\"M40 28v20\" stroke=\"var(--accent)\" stroke-width=\"2.5\" stroke-linecap=\"round\"/><path d=\"M34 38h12\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/><path d=\"M36 48l4 12 4-12\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"none\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;font-size:var(--text-base)\">No skills installed</div></div>'; return; }\n var bySource = {};\n data.forEach(function(s) { (bySource[s.source] = bySource[s.source] || []).push(s); });\n var html = '';\n Object.keys(bySource).sort().forEach(function(src) {\n html += '<div class=\"split-group-label\">' + esc(src) + '</div>';\n bySource[src].forEach(function(s) {\n html += '<div class=\"split-item\" data-path=\"' + esc(s.path) + '\" data-name=\"' + esc(s.name) + '\" data-ui-action=\"selectSkillFromEl\">' +\n '<span>' + esc(s.name) + '</span>' +\n '<span class=\"text-muted\" style=\"font-size:var(--text-xs)\">' + esc(s.source) + '</span>' +\n '</div>';\n });\n });\n list.innerHTML = html;\n });\n }\n\n window.selectSkillFromEl = function(el) {\n if (!el) return;\n selectSkill(el.getAttribute('data-path') || '', el, el.getAttribute('data-name') || '');\n };\n\n window.selectSkill = function(filePath, el, name) {\n if (_editingSkill && !confirm('Discard unsaved changes?')) return;\n _editingSkill = false;\n _currentSkillPath = filePath;\n document.querySelectorAll('#skills-list .split-item').forEach(function(i) { i.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var reader = document.getElementById('skills-reader');\n reader.innerHTML = '<div class=\"reader-empty\">Loading...</div>';\n fetch(authUrl('/api/skill-content?path=' + encodeURIComponent(filePath)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data.ok) { reader.innerHTML = '<div class=\"reader-empty\">' + esc(data.error || 'Error loading file') + '</div>'; return; }\n reader.innerHTML =\n '<div class=\"reader-toolbar\">' +\n '<span class=\"reader-title\">' + esc(name) + '</span>' +\n '<span class=\"reader-path\">' + esc(filePath) + '</span>' +\n '<span id=\"skill-status\"></span>' +\n '<button class=\"btn btn-sm\" data-ui-action=\"editSkill\">Edit</button>' +\n '</div>' +\n '<div class=\"reader-content\"><pre id=\"skill-pre\">' + esc(data.content) + '</pre></div>';\n });\n };\n\n window.editSkill = function() {\n var pre = document.getElementById('skill-pre');\n if (!pre) return;\n _editingSkill = true;\n var content = pre.textContent;\n var toolbar = document.querySelector('#skills-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-sm btn-primary';\n saveBtn.textContent = 'Save';\n saveBtn.onclick = saveSkill;\n var cancelBtn = document.createElement('button');\n cancelBtn.className = 'btn btn-sm';\n cancelBtn.textContent = 'Cancel';\n cancelBtn.onclick = cancelSkillEdit;\n toolbar.appendChild(saveBtn);\n toolbar.appendChild(cancelBtn);\n var ta = document.createElement('textarea');\n ta.id = 'skill-textarea';\n ta.value = content;\n pre.replaceWith(ta);\n ta.focus();\n };\n\n window.cancelSkillEdit = function() {\n _editingSkill = false;\n if (_currentSkillPath) {\n var items = document.querySelectorAll('#skills-list .split-item.selected');\n if (items.length) items[0].click();\n }\n };\n\n window.saveSkill = function() {\n var ta = document.getElementById('skill-textarea');\n if (!ta || !_currentSkillPath) return;\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/skill-save', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('path=' + encodeURIComponent(_currentSkillPath) + '&content=' + encodeURIComponent(ta.value)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) {\n _editingSkill = false;\n setStatus('skill-status', 'Saved', 'ok');\n var pre = document.createElement('pre');\n pre.id = 'skill-pre';\n pre.textContent = ta.value;\n ta.replaceWith(pre);\n var toolbar = document.querySelector('#skills-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var editBtn = document.createElement('button');\n editBtn.className = 'btn btn-sm';\n editBtn.textContent = 'Edit';\n editBtn.onclick = window.editSkill;\n toolbar.appendChild(editBtn);\n } else {\n setStatus('skill-status', data.error || 'Save failed', 'err');\n }\n });\n });\n };\n\n // ── Hooks ────────────────────────────────────────────────────\n function loadHooks() {\n fetch(authUrl('/api/hooks')).then(function(r) { return r.json(); }).then(function(data) {\n _hooksLoaded = true;\n var list = document.getElementById('hooks-list');\n var html = '<div class=\"split-group-label\">Lifecycle Hooks</div>';\n data.tools.forEach(function(t) {\n html += '<div class=\"hook-item\" data-config-path=\"' + esc(t.configPath) + '\" data-tool=\"' + esc(t.tool) + '\" data-exists=\"' + (t.exists ? 'true' : 'false') + '\" data-ui-action=\"selectHookFromEl\">' +\n '<span class=\"hook-name\">' + esc(t.tool) + '</span>' +\n '<span class=\"badge ' + (t.enabled ? 'badge-on' : 'badge-off') + '\">' + (t.enabled ? 'on' : 'off') + '</span>' +\n '</div>';\n });\n if (data.customHooks && data.customHooks.length) {\n html += '<div class=\"split-group-label\">Custom Hooks</div>';\n data.customHooks.forEach(function(ch) {\n html += '<div class=\"split-item\" style=\"cursor:default;flex-direction:column;align-items:flex-start\">' +\n '<div class=\"hook-custom-event\">' + esc(ch.event) + '</div>' +\n '<div class=\"hook-custom-cmd\">' + esc(ch.command) + '</div>' +\n '</div>';\n });\n }\n // Show illustrated empty state if no hook configs exist on disk and no custom hooks\n var anyExists = data.tools.some(function(t) { return t.exists; });\n if (!anyExists && (!data.customHooks || !data.customHooks.length)) {\n html += '<div style=\"padding:40px 20px;color:var(--muted);text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 8px;display:block\"><rect x=\"20\" y=\"28\" width=\"16\" height=\"24\" rx=\"3\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><rect x=\"44\" y=\"28\" width=\"16\" height=\"24\" rx=\"3\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><path d=\"M36 36c4-6 4-6 8 0\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/><path d=\"M36 44c4-6 4-6 8 0\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg><div style=\"font-weight:500;font-size:var(--text-base)\">No hooks configured</div></div>';\n }\n list.innerHTML = html;\n });\n }\n\n window.selectHookFromEl = function(el) {\n if (!el) return;\n selectHook(\n el.getAttribute('data-config-path') || '',\n el,\n el.getAttribute('data-tool') || '',\n el.getAttribute('data-exists') === 'true'\n );\n };\n\n window.selectHook = function(filePath, el, toolName, exists) {\n if (_editingHook && !confirm('Discard unsaved changes?')) return;\n _editingHook = false;\n _currentHookPath = filePath;\n document.querySelectorAll('#hooks-list .hook-item').forEach(function(i) { i.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var reader = document.getElementById('hooks-reader');\n if (!exists) {\n reader.innerHTML =\n '<div class=\"reader-toolbar\"><span class=\"reader-title\">' + esc(toolName) + '</span><span class=\"reader-path\">' + esc(filePath) + '</span></div>' +\n '<div class=\"reader-empty\">Config file not found. This tool may not be installed or configured.</div>';\n return;\n }\n reader.innerHTML =\n '<div class=\"reader-toolbar\">' +\n '<span class=\"reader-title\">' + esc(toolName) + '</span>' +\n '<span class=\"reader-path\">' + esc(filePath) + '</span>' +\n '<span id=\"hook-status\"></span>' +\n '<button class=\"btn btn-sm\" data-ui-action=\"editHook\">Edit</button>' +\n '<button class=\"btn btn-sm btn-primary\" data-tool=\"' + esc(toolName) + '\" data-ui-action=\"toggleHookToolFromEl\">Toggle</button>' +\n '</div>' +\n '<div class=\"reader-content\"><div class=\"reader-empty\">Loading...</div></div>';\n fetch(authUrl('/api/skill-content?path=' + encodeURIComponent(filePath)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n var content = reader.querySelector('.reader-content');\n if (!content) return;\n if (!data.ok) { content.innerHTML = '<div class=\"reader-empty\">' + esc(data.error || 'Error loading file') + '</div>'; return; }\n content.innerHTML = '<pre id=\"hook-pre\">' + esc(data.content) + '</pre>';\n });\n };\n\n window.toggleHookToolFromEl = function(el) {\n if (!el) return;\n toggleHookTool(el.getAttribute('data-tool') || '');\n };\n\n window.editHook = function() {\n var pre = document.getElementById('hook-pre');\n if (!pre) return;\n _editingHook = true;\n var toolbar = document.querySelector('#hooks-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-sm btn-primary';\n saveBtn.textContent = 'Save';\n saveBtn.onclick = window.saveHook;\n var cancelBtn = document.createElement('button');\n cancelBtn.className = 'btn btn-sm';\n cancelBtn.textContent = 'Cancel';\n cancelBtn.onclick = window.cancelHookEdit;\n toolbar.appendChild(saveBtn);\n toolbar.appendChild(cancelBtn);\n var ta = document.createElement('textarea');\n ta.id = 'hook-textarea';\n ta.value = pre.textContent;\n pre.replaceWith(ta);\n ta.focus();\n };\n\n window.cancelHookEdit = function() {\n _editingHook = false;\n _hooksLoaded = false;\n loadHooks();\n document.getElementById('hooks-reader').innerHTML = '<div class=\"reader-empty\">Select a hook config to view its contents.</div>';\n };\n\n window.saveHook = function() {\n var ta = document.getElementById('hook-textarea');\n if (!ta || !_currentHookPath) return;\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/skill-save', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('path=' + encodeURIComponent(_currentHookPath) + '&content=' + encodeURIComponent(ta.value)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) {\n _editingHook = false;\n setStatus('hook-status', 'Saved', 'ok');\n window.cancelHookEdit();\n } else {\n setStatus('hook-status', data.error || 'Save failed', 'err');\n }\n });\n });\n };\n\n window.toggleHookTool = function(toolName) {\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/hook-toggle', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('tool=' + encodeURIComponent(toolName)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { _hooksLoaded = false; loadHooks(); }\n });\n });\n };\n\n // ── Graph (Force-directed) ───────────────────────────────────\n var _graphData = null;\n var _graphNodes = [];\n var _graphZoom = 1;\n var _graphPanX = 0, _graphPanY = 0;\n var _graphDrag = null;\n var _graphRunning = false;\n var _graphAlpha = 1;\n var _graphFilter = 'all';\n var _graphProjectFilter = 'all';\n var _graphSourceFilter = 'all';\n var _graphNodeLimit = 300;\n var _graphTotalNodes = 0;\n var _graphListenersAttached = false;\n var _graphSelectedNodeId = '';\n\n var COLORS = { project: '#d4892e', decision: '#3b82f6', pitfall: '#ef4444', pattern: '#10b981', tradeoff: '#f59e0b', architecture: '#8b4a12', bug: '#dc2626', 'task-active': '#10b981', 'task-queue': '#b8860b', entity: '#3a7bae', reference: '#6b8e7a', other: '#f4a261' };\n var RADII = { project: 18, decision: 8, pitfall: 8, pattern: 8, tradeoff: 8, architecture: 8, bug: 8, 'task-active': 7, 'task-queue': 7, entity: 10, reference: 6, other: 8 };\n\n \n\n \n\n \n\n \n\n \n\n function loadGraph() {\n var url = '/api/graph' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) {\n if (!r.ok) throw new Error('HTTP ' + r.status);\n return r.json();\n }).then(function(data) {\n _graphLoaded = true;\n _graphTotalNodes = data.total || data.nodes.length;\n if (data.nodes.length > _graphNodeLimit) {\n var projectNodes = data.nodes.filter(function(n) { return n.group === 'project'; });\n var findingNodes = data.nodes.filter(function(n) { return n.group !== 'project'; });\n var allowed = Math.max(0, _graphNodeLimit - projectNodes.length);\n var kept = findingNodes.slice(findingNodes.length - allowed);\n var keptSet = {};\n projectNodes.concat(kept).forEach(function(n) { keptSet[n.id] = true; });\n data = { nodes: projectNodes.concat(kept), links: data.links.filter(function(l) { return keptSet[l.source] && keptSet[l.target]; }), total: data.total };\n }\n _graphData = data;\n \n if (window.phrenGraph && window.phrenGraph.mount) { window.phrenGraph.mount(data); } else { console.error('[phren] phrenGraph.mount not available'); }\n }).catch(function(err) {\n _graphLoaded = false;\n var canvas = document.getElementById('graph-canvas');\n if (canvas) {\n var ctx = canvas.getContext('2d');\n var w = canvas.clientWidth, h = canvas.clientHeight;\n ctx.fillStyle = '#ef4444';\n ctx.font = '14px system-ui';\n ctx.textAlign = 'center';\n ctx.fillText('Graph failed to load: ' + err.message, w/2, h/2);\n }\n });\n }\n\n /* initGraph removed — handled by phrenGraph.mount in memory-ui-graph.ts */\n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n // ── Utils ────────────────────────────────────────────────────\n function setStatus(id, msg, type) {\n var el = document.getElementById(id);\n if (!el) return;\n el.className = 'status-msg' + (type ? ' ' + type : '');\n el.textContent = msg;\n if (msg) setTimeout(function() { el.textContent = ''; el.className = ''; }, 3000);\n }\n\n function esc(s) {\n return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');\n }\n\n // ── Dark mode ────────────────────────────────────────────────\n (function initTheme() {\n var saved = localStorage.getItem('phren-theme');\n var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;\n var theme = saved || (prefersDark ? 'dark' : 'light');\n document.documentElement.setAttribute('data-theme', theme);\n var btn = document.getElementById('theme-toggle');\n if (btn) btn.textContent = theme === 'dark' ? '🌙' : '☀️';\n })();\n\n window.toggleTheme = function() {\n var current = document.documentElement.getAttribute('data-theme') || 'light';\n var next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('phren-theme', next);\n var btn = document.getElementById('theme-toggle');\n if (btn) btn.textContent = next === 'dark' ? '🌙' : '☀️';\n };\n\n // ── Toast system ─────────────────────────────────────────────\n function showToast(msg, type) {\n var container = document.getElementById('toast-container');\n if (!container) return;\n var toast = document.createElement('div');\n toast.className = 'toast' + (type ? ' ' + type : '');\n toast.textContent = msg;\n container.appendChild(toast);\n setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 3000);\n }\n\n var _pendingUndo = null;\n\n function showUndoToast(action, onUndo, onCommit) {\n var container = document.getElementById('toast-container');\n if (!container) return;\n var toast = document.createElement('div');\n toast.className = 'toast';\n var label = document.createElement('span');\n label.textContent = action.charAt(0).toUpperCase() + action.slice(1) + 'd memory';\n var undoBtn = document.createElement('button');\n undoBtn.className = 'toast-undo';\n undoBtn.textContent = 'Undo';\n toast.appendChild(label);\n toast.appendChild(undoBtn);\n container.appendChild(toast);\n\n var committed = false;\n var timer = setTimeout(function() {\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onCommit();\n }, 5000);\n\n undoBtn.onclick = function() {\n if (committed) return;\n clearTimeout(timer);\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onUndo();\n };\n\n _pendingUndo = {\n execute: function() {\n if (committed) return;\n clearTimeout(timer);\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onCommit();\n }\n };\n }\n\n // ── Review fetch actions ──────────────────────────────────────\n function fetchCsrfToken(cb) {\n var url = '/api/csrf-token' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(d) { cb(d.token || null); }).catch(function() { cb(null); });\n }\n\n // ── Batch actions ─────────────────────────────────────────────\n function getCheckedCards() {\n return Array.from(document.querySelectorAll('.review-card-check.checked')).map(function(cb) {\n return cb.closest('.review-card');\n }).filter(Boolean);\n }\n\n function updateBatchBar() {\n var checked = getCheckedCards();\n var bar = document.getElementById('batch-bar');\n var count = document.getElementById('batch-count');\n if (!bar || !count) return;\n count.textContent = checked.length + ' selected';\n bar.classList.toggle('visible', checked.length > 0);\n }\n\n window.toggleSelectAll = function(checked) {\n document.querySelectorAll('.review-card').forEach(function(card) {\n if (card.style.display === 'none') return;\n var cb = card.querySelector('.review-card-check');\n if (!cb) return;\n if (checked) cb.classList.add('checked');\n else cb.classList.remove('checked');\n });\n updateBatchBar();\n };\n\n window.clearBatchSelection = function() {\n document.querySelectorAll('.review-card-check.checked').forEach(function(cb) {\n cb.classList.remove('checked');\n });\n var selectAllCb = document.querySelector('#review-select-all input[type=\"checkbox\"]');\n if (selectAllCb) selectAllCb.checked = false;\n updateBatchBar();\n };\n\n\n // -- Similarity grouping --\n function wordSet(text) {\n return new Set(text.toLowerCase().replace(/[^a-z0-9\\s]/g, '').split(/\\s+/).filter(function(w) { return w.length > 2; }));\n }\n\n function jaccardSimilarity(a, b) {\n var intersection = 0;\n a.forEach(function(w) { if (b.has(w)) intersection++; });\n var union = new Set(Array.from(a).concat(Array.from(b))).size;\n return union === 0 ? 0 : intersection / union;\n }\n\n function clusterCards(items) {\n if (items.length <= 1) return [items];\n var THRESHOLD = 0.25;\n var wordSets = items.map(function(item) { return wordSet(item.text || ''); });\n var assigned = new Array(items.length).fill(-1);\n var groups = [];\n\n for (var i = 0; i < items.length; i++) {\n if (assigned[i] !== -1) continue;\n var group = [i];\n assigned[i] = groups.length;\n for (var j = i + 1; j < items.length; j++) {\n if (assigned[j] !== -1) continue;\n if (jaccardSimilarity(wordSets[i], wordSets[j]) > THRESHOLD) {\n group.push(j);\n assigned[j] = groups.length;\n }\n }\n groups.push(group);\n }\n return groups.map(function(indices) {\n return indices.map(function(idx) { return items[idx]; });\n });\n }\n\n window.toggleGroup = function(btn) {\n var group = btn.closest('.review-group');\n if (group) group.classList.toggle('collapsed');\n btn.textContent = group && group.classList.contains('collapsed') ? '\\u25B6' : '\\u25BC';\n };\n\n window.batchAction = function(action) {\n var cards = getCheckedCards();\n if (!cards.length) return;\n var remaining = cards.length;\n cards.forEach(function(card) {\n var project = card.getAttribute('data-project');\n var key = card.getAttribute('data-key');\n var line = key ? key.split('\\\\x00')[1] : '';\n if (!project || !line) return;\n card.classList.add('removing');\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/' + action, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n remaining--;\n if (d.ok) {\n setTimeout(function() { if (card.parentNode) card.remove(); }, 300);\n } else {\n card.classList.remove('removing');\n }\n if (remaining <= 0) {\n updateBatchBar();\n setTimeout(loadReviewCards, 500);\n }\n }).catch(function() {\n remaining--;\n card.classList.remove('removing');\n if (remaining <= 0) updateBatchBar();\n });\n });\n });\n showToast(action.charAt(0).toUpperCase() + action.slice(1) + 'd ' + cards.length + ' memories', 'ok');\n };\n\n function removeCard(card, action, project, line, text) {\n card.classList.add('removing');\n // Clear any pending undo for a different card\n if (_pendingUndo) {\n _pendingUndo.execute();\n _pendingUndo = null;\n }\n showUndoToast(action, function undo() {\n // Cancel: re-show the card\n card.classList.remove('removing');\n _pendingUndo = null;\n }, function commit() {\n // Execute: actually call the server\n _pendingUndo = null;\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/' + action, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n if (d.ok) {\n card.remove();\n updateReviewCount(-1);\n setTimeout(loadReviewCards, 500);\n } else {\n card.classList.remove('removing');\n showToast('Error: ' + (d.error || 'Unknown error'), 'err');\n }\n }).catch(function() {\n card.classList.remove('removing');\n showToast('Network error', 'err');\n });\n });\n });\n }\n\n window.reviewActionFromEl = function(btn, action) {\n if (!btn) return;\n window.reviewAction(btn, action, btn.getAttribute('data-project') || '', btn.getAttribute('data-line') || '');\n };\n\n window.reviewAction = function(btn, action, project, line) {\n var card = btn.closest('.review-card');\n if (!card) return;\n var text = card.querySelector('.review-card-text') ? card.querySelector('.review-card-text').textContent : '';\n removeCard(card, action, project, line, text);\n };\n\n window.reviewEditSubmitFromEl = function(e, form) {\n if (!form) return;\n window.reviewEditSubmit(e, form.getAttribute('data-project') || '', form.getAttribute('data-line') || '');\n };\n\n window.reviewEditSubmit = function(e, project, line) {\n e.preventDefault();\n var form = e.target;\n var ta = form.querySelector('textarea[name=\"new_text\"]');\n if (!ta) return;\n var newText = ta.value;\n var card = form.closest('.review-card');\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line) + '&new_text=' + encodeURIComponent(newText);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/edit', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n if (d.ok) {\n var textEl = card ? card.querySelector('.review-card-text') : null;\n if (textEl) textEl.innerHTML = esc(newText).replace(/\\n/g, '<br>');\n var editSection = card ? card.querySelector('.review-card-edit') : null;\n if (editSection) editSection.style.display = 'none';\n showToast('Saved', 'ok');\n } else {\n showToast('Error: ' + (d.error || 'Unknown error'), 'err');\n }\n }).catch(function() { showToast('Network error', 'err'); });\n });\n };\n\n function animateCount(el, from, to) {\n if (from === to) return;\n var diff = Math.abs(from - to);\n if (diff <= 3) {\n // Cycle through intermediate values at 80ms intervals\n var step = from < to ? 1 : -1;\n var current = from;\n var interval = setInterval(function() {\n current += step;\n el.textContent = String(current);\n if (current === to) clearInterval(interval);\n }, 80);\n } else {\n // Quick scale-and-flip CSS animation\n el.classList.remove('count-flipping');\n void el.offsetWidth;\n el.classList.add('count-flipping');\n setTimeout(function() { el.textContent = String(to); }, 120);\n setTimeout(function() { el.classList.remove('count-flipping'); }, 350);\n }\n }\n\n function updateReviewCount(delta) {\n var navBtn = document.querySelector('.nav-item[data-tab=\"review\"] .count');\n if (!navBtn) return;\n var current = parseInt(navBtn.textContent, 10) || 0;\n var next = current + delta;\n if (next <= 0) {\n navBtn.classList.add('count-flipping');\n setTimeout(function() { navBtn.remove(); }, 300);\n } else {\n animateCount(navBtn, current, next);\n }\n }\n\n // ── Keyboard shortcuts ────────────────────────────────────────\n var _focusedCard = null;\n\n function getVisibleCards() {\n return Array.from(document.querySelectorAll('.review-card')).filter(function(c) { return c.style.display !== 'none'; });\n }\n\n function focusCard(card) {\n if (_focusedCard) { _focusedCard.style.outline = ''; _focusedCard.style.outlineOffset = ''; }\n _focusedCard = card;\n if (card) {\n card.style.outline = '2px solid var(--accent)';\n card.style.outlineOffset = '2px';\n card.style.borderRadius = 'var(--radius)';\n card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n }\n }\n\n document.addEventListener('keydown', function(e) {\n // Ignore if typing in an input/textarea\n var tag = document.activeElement && document.activeElement.tagName;\n if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;\n\n var activeTab = document.querySelector('.tab-content.active');\n var isReviewTab = activeTab && activeTab.id === 'tab-review';\n var isProjectsTab = activeTab && activeTab.id === 'tab-projects';\n\n // / → focus project search\n if (e.key === '/' && isProjectsTab) {\n e.preventDefault();\n var search = document.getElementById('projects-search');\n if (search) search.focus();\n return;\n }\n\n if (!isReviewTab) return;\n\n var cards = getVisibleCards();\n if (!cards.length) return;\n\n if (e.key === 'j' || e.key === 'ArrowDown') {\n e.preventDefault();\n var idx = _focusedCard ? cards.indexOf(_focusedCard) : -1;\n focusCard(cards[Math.min(idx + 1, cards.length - 1)]);\n } else if (e.key === 'k' || e.key === 'ArrowUp') {\n e.preventDefault();\n var idx2 = _focusedCard ? cards.indexOf(_focusedCard) : cards.length;\n focusCard(cards[Math.max(idx2 - 1, 0)]);\n } else if (e.key === 'a' && _focusedCard) {\n var approveBtn = _focusedCard.querySelector('.btn-approve');\n if (approveBtn) approveBtn.click();\n } else if (e.key === 'r' && _focusedCard) {\n var rejectBtn = _focusedCard.querySelector('.btn-reject');\n if (rejectBtn) rejectBtn.click();\n } else if (e.key === 'e' && _focusedCard) {\n var editBtn = _focusedCard.querySelector('.review-card-actions .btn:not(.btn-approve):not(.btn-reject)');\n if (editBtn) editBtn.click();\n } else if (e.key === 'Escape') {\n if (_focusedCard) {\n var editSection = _focusedCard.querySelector('.review-card-edit');\n if (editSection && editSection.style.display !== 'none') {\n toggleReviewEdit(_focusedCard.querySelector('.review-card-actions .btn:not(.btn-approve):not(.btn-reject)'));\n }\n }\n }\n });\n\n // ── Command palette ───────────────────────────────────────────\n var _cmdpalSelected = -1;\n\n window.openCmdPal = function() {\n var overlay = document.getElementById('cmdpal');\n if (!overlay) return;\n overlay.classList.add('open');\n var input = document.getElementById('cmdpal-input');\n if (input) { input.value = ''; input.focus(); }\n cmdpalSearch('');\n };\n\n window.closeCmdPal = function(e) {\n if (e && e.target !== document.getElementById('cmdpal')) return;\n var overlay = document.getElementById('cmdpal');\n if (overlay) overlay.classList.remove('open');\n };\n\n window.cmdpalSearch = function(query) {\n _cmdpalSelected = -1;\n var results = document.getElementById('cmdpal-results');\n if (!results) return;\n var q = query.toLowerCase();\n var matches = _projectData.filter(function(p) {\n return !q || p.name.toLowerCase().indexOf(q) !== -1 || (p.summaryText || '').toLowerCase().indexOf(q) !== -1;\n }).slice(0, 8);\n if (!matches.length) {\n results.innerHTML = '<div class=\"cmdpal-empty\">No projects found</div>';\n return;\n }\n results.innerHTML = matches.map(function(p, i) {\n return '<div class=\"cmdpal-item\" data-name=\"' + esc(p.name) + '\" data-ui-action=\"cmdpalSelect\">' +\n '<span class=\"cmdpal-item-name\">' + esc(p.name) + '</span>' +\n '<span class=\"cmdpal-item-meta\">' + p.findingCount + ' findings</span>' +\n '</div>';\n }).join('');\n };\n\n window.cmdpalKey = function(e) {\n var items = document.querySelectorAll('.cmdpal-item');\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n _cmdpalSelected = Math.min(_cmdpalSelected + 1, items.length - 1);\n items.forEach(function(el, i) { el.classList.toggle('selected', i === _cmdpalSelected); });\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n _cmdpalSelected = Math.max(_cmdpalSelected - 1, 0);\n items.forEach(function(el, i) { el.classList.toggle('selected', i === _cmdpalSelected); });\n } else if (e.key === 'Enter') {\n if (_cmdpalSelected >= 0 && items[_cmdpalSelected]) {\n var name = items[_cmdpalSelected].getAttribute('data-name');\n if (name) cmdpalSelect(name);\n }\n } else if (e.key === 'Escape') {\n document.getElementById('cmdpal').classList.remove('open');\n }\n };\n\n window.cmdpalSelect = function(name) {\n document.getElementById('cmdpal').classList.remove('open');\n switchTab('projects');\n setTimeout(function() {\n var card = document.querySelector('.project-card[data-project=\"' + name + '\"]');\n if (card) card.click();\n else {\n var search = document.getElementById('projects-search');\n if (search) { search.value = name; filterProjects(name); }\n }\n }, 200);\n };\n\n // Cmd+K / Ctrl+K to open\n document.addEventListener('keydown', function(e) {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n var overlay = document.getElementById('cmdpal');\n if (overlay && overlay.classList.contains('open')) {\n overlay.classList.remove('open');\n } else {\n openCmdPal();\n }\n }\n });\n// Event delegation for dynamically generated UI elements\n document.addEventListener('click', function(e) {\n var target = e.target;\n if (!target || typeof target.closest !== 'function') return;\n var actionEl = target.closest('[data-ui-action]');\n if (!actionEl) return;\n var action = actionEl.getAttribute('data-ui-action');\n if (action === 'selectProject') {\n selectProject(actionEl.closest('.project-card').getAttribute('data-project'), actionEl.closest('.project-card'));\n } else if (action === 'toggleStar') {\n e.stopPropagation();\n var card = actionEl.closest('.project-card');\n toggleStar(card ? card.getAttribute('data-project') : '');\n } else if (action === 'stopProp') {\n e.stopPropagation();\n } else if (action === 'reviewAction') {\n reviewActionFromEl(actionEl, actionEl.getAttribute('data-review-type'));\n } else if (action === 'toggleReviewEdit') {\n toggleReviewEdit(actionEl);\n } else if (action === 'toggleGroup') {\n toggleGroup(actionEl);\n } else if (action === 'loadProjectFile') {\n loadProjectFile(actionEl.getAttribute('data-file'), actionEl);\n } else if (action === 'selectSkillFromEl') {\n selectSkillFromEl(actionEl.closest('.split-item'));\n } else if (action === 'editSkill') {\n editSkill();\n } else if (action === 'selectHookFromEl') {\n selectHookFromEl(actionEl.closest('.split-item'));\n } else if (action === 'editHook') {\n editHook();\n } else if (action === 'toggleHookToolFromEl') {\n toggleHookToolFromEl(actionEl);\n } else if (action === 'cmdpalSelect') {\n cmdpalSelect(actionEl.closest('.cmdpal-item').getAttribute('data-name'));\n }\n });\n document.addEventListener('submit', function(e) {\n var form = e.target;\n if (!form || typeof form.getAttribute !== 'function') return;\n if (form.getAttribute('data-ui-action') === 'reviewEditSubmit') {\n e.preventDefault();\n reviewEditSubmitFromEl(e, form);\n }\n });\n})();\n";
2
+ const WEB_UI_SCRIPT_TEMPLATE = "(function() {\n // ── State ────────────────────────────────────────────────────\n var _authToken = '__PHREN_AUTH_TOKEN_1d7b7c99__';\n var _skillsLoaded = false, _hooksLoaded = false, _graphLoaded = false;\n var _currentSkillPath = null, _currentHookPath = null;\n var _editingSkill = false, _editingHook = false;\n var _selectedProject = null;\n var _scoresPromise = null;\n\n var _activeFetches = 0;\n function setLed(state) {\n var led = document.getElementById('sync-led');\n if (!led) return;\n led.className = 'status-led status-led-' + state;\n led.title = state === 'ok' ? 'Synced' : state === 'warn' ? 'Working...' : 'Error';\n }\n function ledFetch(url, opts) {\n _activeFetches++;\n setLed('warn');\n return fetch(url, opts).then(function(r) {\n _activeFetches--;\n if (_activeFetches <= 0) { _activeFetches = 0; setLed('ok'); }\n return r;\n }).catch(function(err) {\n _activeFetches--;\n if (_activeFetches <= 0) { _activeFetches = 0; setLed('err'); }\n setTimeout(function() { if (_activeFetches <= 0) setLed('ok'); }, 3000);\n throw err;\n });\n }\n\n // ── Tab switching ────────────────────────────────────────────\n window.switchTab = function(tab) {\n function doSwitch() {\n document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });\n document.querySelectorAll('.nav-item').forEach(function(el) { el.classList.remove('active'); });\n var tabEl = document.getElementById('tab-' + tab);\n if (tabEl) tabEl.classList.add('active');\n var navBtn = document.querySelector('.nav-item[data-tab=\"' + tab + '\"]');\n if (navBtn) navBtn.classList.add('active');\n if (tab === 'projects' && !document.querySelector('.project-card')) loadProjects();\n if (tab === 'review') { loadReviewCards(); loadReviewActivity(); }\n if (tab === 'skills' && !_skillsLoaded) loadSkills();\n if (tab === 'hooks' && !_hooksLoaded) loadHooks();\n if (tab === 'graph' && !_graphLoaded) loadGraph();\n }\n if (document.startViewTransition) {\n document.startViewTransition(doSwitch);\n } else {\n doSwitch();\n }\n };\n\n // ── Projects ─────────────────────────────────────────────────\n function getStarredProjects() {\n try { return JSON.parse(localStorage.getItem('phren-starred-projects') || '[]'); } catch { return []; }\n }\n function setStarredProjects(arr) {\n localStorage.setItem('phren-starred-projects', JSON.stringify(arr));\n }\n\n function renderProjectCards(data) {\n var grid = document.getElementById('projects-grid');\n if (!data.length) {\n grid.innerHTML = '<div style=\"padding:60px;color:var(--muted);grid-column:1/-1;text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 12px;display:block\"><path d=\"M18 30h44v26a4 4 0 01-4 4H22a4 4 0 01-4-4V30z\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><path d=\"M18 30l6-10h16l6 10\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"none\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;margin-bottom:4px\">No projects yet</div><div style=\"font-size:var(--text-sm)\">Run <code>phren init</code> to create one.</div></div>';\n return;\n }\n var starred = getStarredProjects();\n // Sort: starred first, then by activity\n var sorted = data.slice().sort(function(a, b) {\n var aStarred = starred.indexOf(a.name) !== -1 ? 1 : 0;\n var bStarred = starred.indexOf(b.name) !== -1 ? 1 : 0;\n if (aStarred !== bStarred) return bStarred - aStarred;\n return 0; // preserve server sort order\n });\n grid.innerHTML = sorted.map(function(p) {\n var isStarred = starred.indexOf(p.name) !== -1;\n var githubHtml = p.githubUrl ? '<a class=\"github-link\" href=\"'+esc(p.githubUrl)+'\" target=\"_blank\" rel=\"noopener\" data-ui-action=\"stopProp\">GitHub</a>' : '';\n var sparkHtml = '';\n if (p.sparkline && p.sparkline.some(function(v) { return v > 0; })) {\n var sp = p.sparkline;\n var max = Math.max.apply(null, sp) || 1;\n var w = 80, h = 20;\n var pts = sp.map(function(v, i) { return (i * w / (sp.length - 1)).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1); }).join(' ');\n sparkHtml = '<svg class=\"project-sparkline\" width=\"' + w + '\" height=\"' + h + '\" viewBox=\"0 0 ' + w + ' ' + h + '\" style=\"margin-top:8px;display:block\"><polyline points=\"' + pts + '\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" opacity=\".7\" /><polyline points=\"0,' + h + ' ' + pts + ' ' + w + ',' + h + '\" fill=\"var(--accent-dim)\" stroke=\"none\" /></svg>';\n }\n return '<div class=\"project-card\" data-ui-action=\"selectProject\" data-project=\"'+esc(p.name)+'\" data-summary=\"'+esc(p.summaryText || '')+'\">' +\n '<button class=\"star-btn'+(isStarred ? ' starred' : '')+'\" data-ui-action=\"toggleStar\" title=\"Star project\">&#9733;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');\n }\n\n // ── Dark mode ────────────────────────────────────────────────\n (function initTheme() {\n var saved = localStorage.getItem('phren-theme');\n var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;\n var theme = saved || (prefersDark ? 'dark' : 'light');\n document.documentElement.setAttribute('data-theme', theme);\n var btn = document.getElementById('theme-toggle');\n if (btn) btn.textContent = theme === 'dark' ? '🌙' : '☀️';\n })();\n\n window.toggleTheme = function() {\n var current = document.documentElement.getAttribute('data-theme') || 'light';\n var next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('phren-theme', next);\n var btn = document.getElementById('theme-toggle');\n if (btn) btn.textContent = next === 'dark' ? '🌙' : '☀️';\n };\n\n // ── Toast system ─────────────────────────────────────────────\n function showToast(msg, type) {\n var container = document.getElementById('toast-container');\n if (!container) return;\n var toast = document.createElement('div');\n toast.className = 'toast' + (type ? ' ' + type : '');\n toast.textContent = msg;\n container.appendChild(toast);\n setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 3000);\n }\n\n var _pendingUndo = null;\n\n function showUndoToast(action, onUndo, onCommit) {\n var container = document.getElementById('toast-container');\n if (!container) return;\n var toast = document.createElement('div');\n toast.className = 'toast';\n var label = document.createElement('span');\n label.textContent = action.charAt(0).toUpperCase() + action.slice(1) + 'd memory';\n var undoBtn = document.createElement('button');\n undoBtn.className = 'toast-undo';\n undoBtn.textContent = 'Undo';\n toast.appendChild(label);\n toast.appendChild(undoBtn);\n container.appendChild(toast);\n\n var committed = false;\n var timer = setTimeout(function() {\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onCommit();\n }, 5000);\n\n undoBtn.onclick = function() {\n if (committed) return;\n clearTimeout(timer);\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onUndo();\n };\n\n _pendingUndo = {\n execute: function() {\n if (committed) return;\n clearTimeout(timer);\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onCommit();\n }\n };\n }\n\n // ── Review fetch actions ──────────────────────────────────────\n function fetchCsrfToken(cb) {\n var url = '/api/csrf-token' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(d) { cb(d.token || null); }).catch(function() { cb(null); });\n }\n\n // ── Batch actions ─────────────────────────────────────────────\n function getCheckedCards() {\n return Array.from(document.querySelectorAll('.review-card-check.checked')).map(function(cb) {\n return cb.closest('.review-card');\n }).filter(Boolean);\n }\n\n function updateBatchBar() {\n var checked = getCheckedCards();\n var bar = document.getElementById('batch-bar');\n var count = document.getElementById('batch-count');\n if (!bar || !count) return;\n count.textContent = checked.length + ' selected';\n bar.classList.toggle('visible', checked.length > 0);\n }\n\n window.toggleSelectAll = function(checked) {\n document.querySelectorAll('.review-card').forEach(function(card) {\n if (card.style.display === 'none') return;\n var cb = card.querySelector('.review-card-check');\n if (!cb) return;\n if (checked) cb.classList.add('checked');\n else cb.classList.remove('checked');\n });\n updateBatchBar();\n };\n\n window.clearBatchSelection = function() {\n document.querySelectorAll('.review-card-check.checked').forEach(function(cb) {\n cb.classList.remove('checked');\n });\n var selectAllCb = document.querySelector('#review-select-all input[type=\"checkbox\"]');\n if (selectAllCb) selectAllCb.checked = false;\n updateBatchBar();\n };\n\n\n // -- Similarity grouping --\n function wordSet(text) {\n return new Set(text.toLowerCase().replace(/[^a-z0-9\\s]/g, '').split(/\\s+/).filter(function(w) { return w.length > 2; }));\n }\n\n function jaccardSimilarity(a, b) {\n var intersection = 0;\n a.forEach(function(w) { if (b.has(w)) intersection++; });\n var union = new Set(Array.from(a).concat(Array.from(b))).size;\n return union === 0 ? 0 : intersection / union;\n }\n\n function clusterCards(items) {\n if (items.length <= 1) return [items];\n var THRESHOLD = 0.25;\n var wordSets = items.map(function(item) { return wordSet(item.text || ''); });\n var assigned = new Array(items.length).fill(-1);\n var groups = [];\n\n for (var i = 0; i < items.length; i++) {\n if (assigned[i] !== -1) continue;\n var group = [i];\n assigned[i] = groups.length;\n for (var j = i + 1; j < items.length; j++) {\n if (assigned[j] !== -1) continue;\n if (jaccardSimilarity(wordSets[i], wordSets[j]) > THRESHOLD) {\n group.push(j);\n assigned[j] = groups.length;\n }\n }\n groups.push(group);\n }\n return groups.map(function(indices) {\n return indices.map(function(idx) { return items[idx]; });\n });\n }\n\n window.toggleGroup = function(btn) {\n var group = btn.closest('.review-group');\n if (group) group.classList.toggle('collapsed');\n btn.textContent = group && group.classList.contains('collapsed') ? '\\u25B6' : '\\u25BC';\n };\n\n window.batchAction = function(action) {\n var cards = getCheckedCards();\n if (!cards.length) return;\n var remaining = cards.length;\n cards.forEach(function(card) {\n var project = card.getAttribute('data-project');\n var key = card.getAttribute('data-key');\n var line = key ? key.split('\\\\x00')[1] : '';\n if (!project || !line) return;\n card.classList.add('removing');\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/' + action, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n remaining--;\n if (d.ok) {\n setTimeout(function() { if (card.parentNode) card.remove(); }, 300);\n } else {\n card.classList.remove('removing');\n }\n if (remaining <= 0) {\n updateBatchBar();\n setTimeout(loadReviewCards, 500);\n }\n }).catch(function() {\n remaining--;\n card.classList.remove('removing');\n if (remaining <= 0) updateBatchBar();\n });\n });\n });\n showToast(action.charAt(0).toUpperCase() + action.slice(1) + 'd ' + cards.length + ' memories', 'ok');\n };\n\n function removeCard(card, action, project, line, text) {\n card.classList.add('removing');\n // Clear any pending undo for a different card\n if (_pendingUndo) {\n _pendingUndo.execute();\n _pendingUndo = null;\n }\n showUndoToast(action, function undo() {\n // Cancel: re-show the card\n card.classList.remove('removing');\n _pendingUndo = null;\n }, function commit() {\n // Execute: actually call the server\n _pendingUndo = null;\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/' + action, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n if (d.ok) {\n card.remove();\n updateReviewCount(-1);\n setTimeout(loadReviewCards, 500);\n } else {\n card.classList.remove('removing');\n showToast('Error: ' + (d.error || 'Unknown error'), 'err');\n }\n }).catch(function() {\n card.classList.remove('removing');\n showToast('Network error', 'err');\n });\n });\n });\n }\n\n window.reviewActionFromEl = function(btn, action) {\n if (!btn) return;\n window.reviewAction(btn, action, btn.getAttribute('data-project') || '', btn.getAttribute('data-line') || '');\n };\n\n window.reviewAction = function(btn, action, project, line) {\n var card = btn.closest('.review-card');\n if (!card) return;\n var text = card.querySelector('.review-card-text') ? card.querySelector('.review-card-text').textContent : '';\n removeCard(card, action, project, line, text);\n };\n\n window.reviewEditSubmitFromEl = function(e, form) {\n if (!form) return;\n window.reviewEditSubmit(e, form.getAttribute('data-project') || '', form.getAttribute('data-line') || '');\n };\n\n window.reviewEditSubmit = function(e, project, line) {\n e.preventDefault();\n var form = e.target;\n var ta = form.querySelector('textarea[name=\"new_text\"]');\n if (!ta) return;\n var newText = ta.value;\n var card = form.closest('.review-card');\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line) + '&new_text=' + encodeURIComponent(newText);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/edit', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n if (d.ok) {\n var textEl = card ? card.querySelector('.review-card-text') : null;\n if (textEl) textEl.innerHTML = esc(newText).replace(/\\n/g, '<br>');\n var editSection = card ? card.querySelector('.review-card-edit') : null;\n if (editSection) editSection.style.display = 'none';\n showToast('Saved', 'ok');\n } else {\n showToast('Error: ' + (d.error || 'Unknown error'), 'err');\n }\n }).catch(function() { showToast('Network error', 'err'); });\n });\n };\n\n function animateCount(el, from, to) {\n if (from === to) return;\n var diff = Math.abs(from - to);\n if (diff <= 3) {\n // Cycle through intermediate values at 80ms intervals\n var step = from < to ? 1 : -1;\n var current = from;\n var interval = setInterval(function() {\n current += step;\n el.textContent = String(current);\n if (current === to) clearInterval(interval);\n }, 80);\n } else {\n // Quick scale-and-flip CSS animation\n el.classList.remove('count-flipping');\n void el.offsetWidth;\n el.classList.add('count-flipping');\n setTimeout(function() { el.textContent = String(to); }, 120);\n setTimeout(function() { el.classList.remove('count-flipping'); }, 350);\n }\n }\n\n function updateReviewCount(delta) {\n var navBtn = document.querySelector('.nav-item[data-tab=\"review\"] .count');\n if (!navBtn) return;\n var current = parseInt(navBtn.textContent, 10) || 0;\n var next = current + delta;\n if (next <= 0) {\n navBtn.classList.add('count-flipping');\n setTimeout(function() { navBtn.remove(); }, 300);\n } else {\n animateCount(navBtn, current, next);\n }\n }\n\n // ── Keyboard shortcuts ────────────────────────────────────────\n var _focusedCard = null;\n\n function getVisibleCards() {\n return Array.from(document.querySelectorAll('.review-card')).filter(function(c) { return c.style.display !== 'none'; });\n }\n\n function focusCard(card) {\n if (_focusedCard) { _focusedCard.style.outline = ''; _focusedCard.style.outlineOffset = ''; }\n _focusedCard = card;\n if (card) {\n card.style.outline = '2px solid var(--accent)';\n card.style.outlineOffset = '2px';\n card.style.borderRadius = 'var(--radius)';\n card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n }\n }\n\n document.addEventListener('keydown', function(e) {\n // Ignore if typing in an input/textarea\n var tag = document.activeElement && document.activeElement.tagName;\n if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;\n\n var activeTab = document.querySelector('.tab-content.active');\n var isReviewTab = activeTab && activeTab.id === 'tab-review';\n var isProjectsTab = activeTab && activeTab.id === 'tab-projects';\n\n // / → focus project search\n if (e.key === '/' && isProjectsTab) {\n e.preventDefault();\n var search = document.getElementById('projects-search');\n if (search) search.focus();\n return;\n }\n\n if (!isReviewTab) return;\n\n var cards = getVisibleCards();\n if (!cards.length) return;\n\n if (e.key === 'j' || e.key === 'ArrowDown') {\n e.preventDefault();\n var idx = _focusedCard ? cards.indexOf(_focusedCard) : -1;\n focusCard(cards[Math.min(idx + 1, cards.length - 1)]);\n } else if (e.key === 'k' || e.key === 'ArrowUp') {\n e.preventDefault();\n var idx2 = _focusedCard ? cards.indexOf(_focusedCard) : cards.length;\n focusCard(cards[Math.max(idx2 - 1, 0)]);\n } else if (e.key === 'a' && _focusedCard) {\n var approveBtn = _focusedCard.querySelector('.btn-approve');\n if (approveBtn) approveBtn.click();\n } else if (e.key === 'r' && _focusedCard) {\n var rejectBtn = _focusedCard.querySelector('.btn-reject');\n if (rejectBtn) rejectBtn.click();\n } else if (e.key === 'e' && _focusedCard) {\n var editBtn = _focusedCard.querySelector('.review-card-actions .btn:not(.btn-approve):not(.btn-reject)');\n if (editBtn) editBtn.click();\n } else if (e.key === 'Escape') {\n if (_focusedCard) {\n var editSection = _focusedCard.querySelector('.review-card-edit');\n if (editSection && editSection.style.display !== 'none') {\n toggleReviewEdit(_focusedCard.querySelector('.review-card-actions .btn:not(.btn-approve):not(.btn-reject)'));\n }\n }\n }\n });\n\n // ── Command palette ───────────────────────────────────────────\n var _cmdpalSelected = -1;\n\n window.openCmdPal = function() {\n var overlay = document.getElementById('cmdpal');\n if (!overlay) return;\n overlay.classList.add('open');\n var input = document.getElementById('cmdpal-input');\n if (input) { input.value = ''; input.focus(); }\n cmdpalSearch('');\n };\n\n window.closeCmdPal = function(e) {\n if (e && e.target !== document.getElementById('cmdpal')) return;\n var overlay = document.getElementById('cmdpal');\n if (overlay) overlay.classList.remove('open');\n };\n\n window.cmdpalSearch = function(query) {\n _cmdpalSelected = -1;\n var results = document.getElementById('cmdpal-results');\n if (!results) return;\n var q = query.toLowerCase();\n var matches = _projectData.filter(function(p) {\n return !q || p.name.toLowerCase().indexOf(q) !== -1 || (p.summaryText || '').toLowerCase().indexOf(q) !== -1;\n }).slice(0, 8);\n if (!matches.length) {\n results.innerHTML = '<div class=\"cmdpal-empty\">No projects found</div>';\n return;\n }\n results.innerHTML = matches.map(function(p, i) {\n return '<div class=\"cmdpal-item\" data-name=\"' + esc(p.name) + '\" data-ui-action=\"cmdpalSelect\">' +\n '<span class=\"cmdpal-item-name\">' + esc(p.name) + '</span>' +\n '<span class=\"cmdpal-item-meta\">' + p.findingCount + ' findings</span>' +\n '</div>';\n }).join('');\n };\n\n window.cmdpalKey = function(e) {\n var items = document.querySelectorAll('.cmdpal-item');\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n _cmdpalSelected = Math.min(_cmdpalSelected + 1, items.length - 1);\n items.forEach(function(el, i) { el.classList.toggle('selected', i === _cmdpalSelected); });\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n _cmdpalSelected = Math.max(_cmdpalSelected - 1, 0);\n items.forEach(function(el, i) { el.classList.toggle('selected', i === _cmdpalSelected); });\n } else if (e.key === 'Enter') {\n if (_cmdpalSelected >= 0 && items[_cmdpalSelected]) {\n var name = items[_cmdpalSelected].getAttribute('data-name');\n if (name) cmdpalSelect(name);\n }\n } else if (e.key === 'Escape') {\n document.getElementById('cmdpal').classList.remove('open');\n }\n };\n\n window.cmdpalSelect = function(name) {\n document.getElementById('cmdpal').classList.remove('open');\n switchTab('projects');\n setTimeout(function() {\n var card = document.querySelector('.project-card[data-project=\"' + name + '\"]');\n if (card) card.click();\n else {\n var search = document.getElementById('projects-search');\n if (search) { search.value = name; filterProjects(name); }\n }\n }, 200);\n };\n\n // Cmd+K / Ctrl+K to open\n document.addEventListener('keydown', function(e) {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n var overlay = document.getElementById('cmdpal');\n if (overlay && overlay.classList.contains('open')) {\n overlay.classList.remove('open');\n } else {\n openCmdPal();\n }\n }\n });\n// Event delegation for dynamically generated UI elements\n document.addEventListener('click', function(e) {\n var target = e.target;\n if (!target || typeof target.closest !== 'function') return;\n var actionEl = target.closest('[data-ui-action]');\n if (!actionEl) return;\n var action = actionEl.getAttribute('data-ui-action');\n if (action === 'selectProject') {\n selectProject(actionEl.closest('.project-card').getAttribute('data-project'), actionEl.closest('.project-card'));\n } else if (action === 'toggleStar') {\n e.stopPropagation();\n var card = actionEl.closest('.project-card');\n toggleStar(card ? card.getAttribute('data-project') : '');\n } else if (action === 'stopProp') {\n e.stopPropagation();\n } else if (action === 'reviewAction') {\n reviewActionFromEl(actionEl, actionEl.getAttribute('data-review-type'));\n } else if (action === 'toggleReviewEdit') {\n toggleReviewEdit(actionEl);\n } else if (action === 'toggleGroup') {\n toggleGroup(actionEl);\n } else if (action === 'loadProjectFile') {\n loadProjectFile(actionEl.getAttribute('data-file'), actionEl);\n } else if (action === 'selectSkillFromEl') {\n selectSkillFromEl(actionEl.closest('.split-item'));\n } else if (action === 'editSkill') {\n editSkill();\n } else if (action === 'selectHookFromEl') {\n selectHookFromEl(actionEl.closest('.split-item'));\n } else if (action === 'editHook') {\n editHook();\n } else if (action === 'toggleHookToolFromEl') {\n toggleHookToolFromEl(actionEl);\n } else if (action === 'cmdpalSelect') {\n cmdpalSelect(actionEl.closest('.cmdpal-item').getAttribute('data-name'));\n }\n });\n document.addEventListener('submit', function(e) {\n var form = e.target;\n if (!form || typeof form.getAttribute !== 'function') return;\n if (form.getAttribute('data-ui-action') === 'reviewEditSubmit') {\n e.preventDefault();\n reviewEditSubmitFromEl(e, form);\n }\n });\n})();\n";
3
3
  export function renderWebUiScript(authToken) {
4
4
  const script = WEB_UI_SCRIPT_TEMPLATE.replace("__PHREN_AUTH_TOKEN_1d7b7c99__", authToken);
5
5
  return script;
@@ -6,6 +6,7 @@ import { errorMessage } from "./utils.js";
6
6
  import { readInstallPreferences } from "./init-preferences.js";
7
7
  import { readCustomHooks } from "./hooks.js";
8
8
  import { hookConfigPaths, hookConfigRoots } from "./provider-adapters.js";
9
+ import { readProjectConfig, isProjectHookEnabled, PROJECT_HOOK_EVENTS } from "./project-config.js";
9
10
  import { getAllSkills } from "./skill-registry.js";
10
11
  import { resolveTaskFilePath, readTasks, TASKS_FILENAME } from "./data-tasks.js";
11
12
  import { buildIndex, queryDocBySourceKey, queryRows } from "./shared-index.js";
@@ -101,7 +102,7 @@ export function collectSkillsForUI(phrenPath, profile = "") {
101
102
  enabled: skill.enabled,
102
103
  }));
103
104
  }
104
- export function getHooksData(phrenPath) {
105
+ export function getHooksData(phrenPath, profile) {
105
106
  const prefs = readInstallPreferences(phrenPath);
106
107
  const globalEnabled = prefs.hooksEnabled !== false;
107
108
  const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
@@ -112,7 +113,28 @@ export function getHooksData(phrenPath) {
112
113
  configPath: paths[tool],
113
114
  exists: fs.existsSync(paths[tool]),
114
115
  }));
115
- return { globalEnabled, tools, customHooks: readCustomHooks(phrenPath) };
116
+ // Collect per-project hook overrides
117
+ const projectOverrides = [];
118
+ const projects = getProjectDirs(phrenPath, profile)
119
+ .map((dir) => path.basename(dir))
120
+ .filter((p) => p !== "global");
121
+ for (const project of projects) {
122
+ const config = readProjectConfig(phrenPath, project);
123
+ const hasOverrides = typeof config.hooks?.enabled === "boolean" ||
124
+ PROJECT_HOOK_EVENTS.some((ev) => typeof config.hooks?.[ev] === "boolean");
125
+ if (!hasOverrides)
126
+ continue;
127
+ projectOverrides.push({
128
+ project,
129
+ baseEnabled: typeof config.hooks?.enabled === "boolean" ? config.hooks.enabled : null,
130
+ events: PROJECT_HOOK_EVENTS.map((event) => ({
131
+ event,
132
+ configured: typeof config.hooks?.[event] === "boolean" ? config.hooks[event] : null,
133
+ enabled: isProjectHookEnabled(phrenPath, project, event, config),
134
+ })),
135
+ });
136
+ }
137
+ return { globalEnabled, tools, customHooks: readCustomHooks(phrenPath), projectOverrides };
116
138
  }
117
139
  export async function buildGraph(phrenPath, profile, focusProject) {
118
140
  const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global");
@@ -164,27 +164,30 @@ export function renderGraphScript() {
164
164
  return path;
165
165
  }
166
166
 
167
- /* pin a node so the force simulation skips it (prevents jitter while phren walks) */
168
- var phrenPinnedNode = null;
167
+ /* pin nodes so the force simulation skips them (prevents jitter while phren walks) */
168
+ var phrenPinnedNodes = [];
169
169
 
170
170
  function phrenPinNode(node) {
171
- if (phrenPinnedNode && phrenPinnedNode !== node) {
172
- /* unpin previous target — let physics resume on it */
173
- phrenPinnedNode._phrenPinned = false;
174
- phrenPinnedNode.vx = 0;
175
- phrenPinnedNode.vy = 0;
176
- }
177
- if (node) {
171
+ if (node && !node._phrenPinned) {
178
172
  node._phrenPinned = true;
179
173
  node.vx = 0;
180
174
  node.vy = 0;
175
+ phrenPinnedNodes.push(node);
176
+ }
177
+ }
178
+
179
+ function phrenUnpinAll() {
180
+ for (var i = 0; i < phrenPinnedNodes.length; i++) {
181
+ phrenPinnedNodes[i]._phrenPinned = false;
182
+ phrenPinnedNodes[i].vx = 0;
183
+ phrenPinnedNodes[i].vy = 0;
181
184
  }
182
- phrenPinnedNode = node;
185
+ phrenPinnedNodes = [];
183
186
  }
184
187
 
185
188
  function phrenMoveTo(x, y, targetNode) {
186
- /* pin the target node so physics doesn't move it while phren walks there */
187
- phrenPinNode(targetNode);
189
+ /* unpin all previously pinned nodes before starting a new move */
190
+ phrenUnpinAll();
188
191
  phren.targetX = x;
189
192
  phren.targetY = y;
190
193
  phren.moving = true;
@@ -199,6 +202,12 @@ export function renderGraphScript() {
199
202
  phren.waypointIdx = 0;
200
203
  phren.targetNodeId = targetNode ? targetNode.id : null;
201
204
  phren.targetNodeRef = targetNode || null; /* live reference for position tracking */
205
+ /* pin target node and all waypoint nodes so physics doesn't move them during walk */
206
+ phrenPinNode(targetNode);
207
+ for (var i = 0; i < phren.waypoints.length; i++) {
208
+ var wpn = phrenNodeById(phren.waypoints[i]);
209
+ if (wpn) phrenPinNode(wpn);
210
+ }
202
211
  /* ensure animation loop is running so phren movement renders */
203
212
  if (!animFrame) animFrame = requestAnimationFrame(tick);
204
213
  }
@@ -238,16 +247,45 @@ export function renderGraphScript() {
238
247
  var dx = wx - phren.x;
239
248
  var dy = wy - phren.y;
240
249
  var dist = Math.sqrt(dx * dx + dy * dy);
241
- if (dist < 2) {
250
+ /* snap-to-target helper used both by arrival check and overshoot clamp */
251
+ var snapped = false;
252
+ if (dist < 4) {
242
253
  phren.x = wx;
243
254
  phren.y = wy;
244
- /* advance to next waypoint or finish */
255
+ snapped = true;
256
+ } else {
257
+ /* ease-in-out via sine curve over the full trip distance */
258
+ var t = phren.tripDist > 0 ? Math.min(1, phren.tripProgress / phren.tripDist) : 1;
259
+ var easeInOut = 0.5 - 0.5 * Math.cos(Math.PI * t);
260
+ var baseSpeed = Math.max(3, phren.tripDist * 0.12);
261
+ var speed = Math.max(1.5, baseSpeed * (0.15 + 0.85 * easeInOut));
262
+ /* clamp speed to remaining distance — prevents overshoot oscillation */
263
+ if (speed >= dist) {
264
+ phren.x = wx;
265
+ phren.y = wy;
266
+ speed = dist;
267
+ snapped = true;
268
+ } else {
269
+ phren.x += (dx / dist) * speed;
270
+ phren.y += (dy / dist) * speed;
271
+ }
272
+ phren.tripProgress += speed;
273
+ /* record trail — longer buffer for gradual fade */
274
+ phren.trailPoints.push({ x: phren.x, y: phren.y, age: 0 });
275
+ if (phren.trailPoints.length > 50) phren.trailPoints.shift();
276
+ }
277
+ /* advance waypoint or finish when snapped to current target */
278
+ if (snapped) {
245
279
  if (phren.waypoints.length > 0 && phren.waypointIdx < phren.waypoints.length) {
246
280
  phren.waypointIdx++;
247
281
  } else {
248
282
  phren.moving = false;
249
283
  phren.arriving = true;
250
284
  phren.arriveTimer = 0;
285
+ /* clear trail on arrival so purple line doesn't linger */
286
+ phren.trailPoints = [];
287
+ /* unpin all nodes now that phren has arrived */
288
+ phrenUnpinAll();
251
289
  /* update keyboard-nav current node */
252
290
  if (phren.targetNodeId) {
253
291
  phrenCurrentNodeId = phren.targetNodeId;
@@ -255,18 +293,6 @@ export function renderGraphScript() {
255
293
  phrenRefreshAdjacentLinks();
256
294
  }
257
295
  }
258
- } else {
259
- /* ease-in-out via sine curve over the full trip distance */
260
- var t = phren.tripDist > 0 ? Math.min(1, phren.tripProgress / phren.tripDist) : 1;
261
- var easeInOut = 0.5 - 0.5 * Math.cos(Math.PI * t);
262
- var baseSpeed = Math.max(3, phren.tripDist * 0.12);
263
- var speed = Math.max(1.5, baseSpeed * (0.15 + 0.85 * easeInOut));
264
- phren.x += (dx / dist) * speed;
265
- phren.y += (dy / dist) * speed;
266
- phren.tripProgress += speed;
267
- /* record trail — longer buffer for gradual fade */
268
- phren.trailPoints.push({ x: phren.x, y: phren.y, age: 0 });
269
- if (phren.trailPoints.length > 50) phren.trailPoints.shift();
270
296
  }
271
297
  }
272
298
  if (phren.arriving) {
@@ -600,6 +600,10 @@ export function renderTasksAndSettingsScript(authToken) {
600
600
  return base + (base.indexOf('?') === -1 ? '?' : '&') + '_auth=' + encodeURIComponent(_tsAuthToken);
601
601
  }
602
602
 
603
+ function loadJson(url) {
604
+ return fetch(url).then(function(r) { return r.json(); });
605
+ }
606
+
603
607
  function priorityBadge(p) {
604
608
  if (!p) return '';
605
609
  var colors = { high: '#ef4444', medium: '#f59e0b', low: '#6b7280' };
@@ -756,9 +760,9 @@ export function renderTasksAndSettingsScript(authToken) {
756
760
  html += '</div>';
757
761
  html += '<div class="task-card-actions">';
758
762
  if (t.section !== 'Done') {
759
- html += '<button class="task-done-btn" onclick="completeTaskFromUi(\'' + esc(t.project).replace(/'/g, "\\'") + '\', \'' + esc(t.line).replace(/'/g, "\\'") + '\')" title="Mark done"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M3 8.5l3.5 3.5 6.5-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Done</button>';
763
+ html += '<button class="task-done-btn" onclick="completeTaskFromUi(\\'' + esc(t.project).replace(/'/g, "\\\\'") + '\\', \\'' + esc(t.line).replace(/'/g, "\\\\'") + '\\')" title="Mark done"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M3 8.5l3.5 3.5 6.5-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Done</button>';
760
764
  }
761
- html += '<button class="task-remove-btn" onclick="removeTaskFromUi(\'' + esc(t.project).replace(/'/g, "\\'") + '\', \'' + esc(t.line).replace(/'/g, "\\'") + '\')" title="Delete task" style="background:none;border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px 8px;cursor:pointer;color:var(--muted);font-size:var(--text-xs)"><svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>';
765
+ html += '<button class="task-remove-btn" onclick="removeTaskFromUi(\\'' + esc(t.project).replace(/'/g, "\\\\'") + '\\', \\'' + esc(t.line).replace(/'/g, "\\\\'") + '\\')" title="Delete task" style="background:none;border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px 8px;cursor:pointer;color:var(--muted);font-size:var(--text-xs)"><svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>';
762
766
  html += '</div>';
763
767
  html += '</div>';
764
768
  return html;
@@ -770,8 +774,8 @@ export function renderTasksAndSettingsScript(authToken) {
770
774
  var projects = projectFilter ? [projectFilter] : Array.from(new Set(_allTasks.map(function(t) { return t.project; }))).sort();
771
775
  projects.forEach(function(proj) {
772
776
  html += '<div class="task-add-bar">';
773
- html += '<input id="task-add-input-' + esc(proj) + '" type="text" class="task-add-input" placeholder="Add a task to ' + esc(proj) + '\u2026" onkeydown="if(event.key===\'Enter\')addTaskFromUi(\'' + esc(proj).replace(/'/g, "\\'") + '\')">';
774
- html += '<button class="task-add-btn" onclick="addTaskFromUi(\'' + esc(proj).replace(/'/g, "\\'") + '\')"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Add</button>';
777
+ html += '<input id="task-add-input-' + esc(proj) + '" type="text" class="task-add-input" placeholder="Add a task to ' + esc(proj) + '\u2026" onkeydown="if(event.key===\\\'Enter\\\')addTaskFromUi(\\'' + esc(proj).replace(/'/g, "\\\\'") + '\\')">';
778
+ html += '<button class="task-add-btn" onclick="addTaskFromUi(\\'' + esc(proj).replace(/'/g, "\\\\'") + '\\')"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Add</button>';
775
779
  html += '</div>';
776
780
  });
777
781
 
@@ -897,7 +901,7 @@ export function renderTasksAndSettingsScript(authToken) {
897
901
  var scopeNote = document.getElementById('settings-scope-note');
898
902
  if (scopeNote) {
899
903
  scopeNote.textContent = selectedProject
900
- ? 'Showing effective config for "' + selectedProject + '". Overrides are saved to that project\'s phren.project.yaml.'
904
+ ? 'Showing effective config for "' + selectedProject + '". Overrides are saved to that project\\\'s phren.project.yaml.'
901
905
  : 'Showing global settings. Select a project to view and edit per-project overrides.';
902
906
  }
903
907
 
@@ -7,7 +7,7 @@ import * as querystring from "querystring";
7
7
  import { spawn, execFileSync } from "child_process";
8
8
  import { computePhrenLiveStateToken, getProjectDirs, } from "./shared.js";
9
9
  import { editFinding, readReviewQueue, removeFinding, readFindings, addFinding as addFindingStore, readTasksAcrossProjects, addTask as addTaskStore, completeTask as completeTaskStore, removeTask as removeTaskStore, TASKS_FILENAME, } from "./data-access.js";
10
- import { isValidProjectName, errorMessage } from "./utils.js";
10
+ import { isValidProjectName, errorMessage, queueFilePath } from "./utils.js";
11
11
  import { readInstallPreferences, writeInstallPreferences, writeGovernanceInstallPreferences } from "./init-preferences.js";
12
12
  import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isAllowedFilePath, readSyncSnapshot, recentAccepted, recentUsage, } from "./memory-ui-data.js";
13
13
  import { CONSOLIDATION_ENTRY_THRESHOLD } from "./content-validate.js";
@@ -388,6 +388,111 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
388
388
  }));
389
389
  return;
390
390
  }
391
+ // POST /api/approve — remove item from review queue (keep finding)
392
+ if (req.method === "POST" && pathname === "/api/approve") {
393
+ void readFormBody(req, res).then((parsed) => {
394
+ if (!parsed)
395
+ return;
396
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
397
+ return;
398
+ if (!requireCsrf(res, parsed, csrfTokens, true))
399
+ return;
400
+ const project = String(parsed.project || "");
401
+ const line = String(parsed.line || "");
402
+ if (!project || !isValidProjectName(project) || !line) {
403
+ res.writeHead(200, { "content-type": "application/json" });
404
+ res.end(JSON.stringify({ ok: false, error: "Missing project or line" }));
405
+ return;
406
+ }
407
+ try {
408
+ const qPath = queueFilePath(phrenPath, project);
409
+ if (fs.existsSync(qPath)) {
410
+ const content = fs.readFileSync(qPath, "utf8");
411
+ const lines = content.split("\n");
412
+ const filtered = lines.filter((l) => l.trim() !== line.trim());
413
+ fs.writeFileSync(qPath, filtered.join("\n"));
414
+ }
415
+ res.writeHead(200, { "content-type": "application/json" });
416
+ res.end(JSON.stringify({ ok: true }));
417
+ }
418
+ catch (err) {
419
+ res.writeHead(200, { "content-type": "application/json" });
420
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
421
+ }
422
+ });
423
+ return;
424
+ }
425
+ // POST /api/reject — remove item from review queue AND remove finding
426
+ if (req.method === "POST" && pathname === "/api/reject") {
427
+ void readFormBody(req, res).then((parsed) => {
428
+ if (!parsed)
429
+ return;
430
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
431
+ return;
432
+ if (!requireCsrf(res, parsed, csrfTokens, true))
433
+ return;
434
+ const project = String(parsed.project || "");
435
+ const line = String(parsed.line || "");
436
+ if (!project || !isValidProjectName(project) || !line) {
437
+ res.writeHead(200, { "content-type": "application/json" });
438
+ res.end(JSON.stringify({ ok: false, error: "Missing project or line" }));
439
+ return;
440
+ }
441
+ try {
442
+ // Remove from review queue
443
+ const qPath = queueFilePath(phrenPath, project);
444
+ if (fs.existsSync(qPath)) {
445
+ const content = fs.readFileSync(qPath, "utf8");
446
+ const lines = content.split("\n");
447
+ const filtered = lines.filter((l) => l.trim() !== line.trim());
448
+ fs.writeFileSync(qPath, filtered.join("\n"));
449
+ }
450
+ // Also remove the finding from FINDINGS.md
451
+ // Extract text from the line (strip "- " prefix and inline metadata)
452
+ const findingText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
453
+ if (findingText) {
454
+ removeFinding(phrenPath, project, findingText);
455
+ }
456
+ res.writeHead(200, { "content-type": "application/json" });
457
+ res.end(JSON.stringify({ ok: true }));
458
+ }
459
+ catch (err) {
460
+ res.writeHead(200, { "content-type": "application/json" });
461
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
462
+ }
463
+ });
464
+ return;
465
+ }
466
+ // POST /api/edit — edit a finding's text
467
+ if (req.method === "POST" && pathname === "/api/edit") {
468
+ void readFormBody(req, res).then((parsed) => {
469
+ if (!parsed)
470
+ return;
471
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
472
+ return;
473
+ if (!requireCsrf(res, parsed, csrfTokens, true))
474
+ return;
475
+ const project = String(parsed.project || "");
476
+ const line = String(parsed.line || "");
477
+ const newText = String(parsed.new_text || "");
478
+ if (!project || !isValidProjectName(project) || !line || !newText) {
479
+ res.writeHead(200, { "content-type": "application/json" });
480
+ res.end(JSON.stringify({ ok: false, error: "Missing project, line, or new_text" }));
481
+ return;
482
+ }
483
+ try {
484
+ const oldText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
485
+ const result = editFinding(phrenPath, project, oldText, newText);
486
+ res.writeHead(200, { "content-type": "application/json" });
487
+ res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
488
+ }
489
+ catch (err) {
490
+ res.writeHead(200, { "content-type": "application/json" });
491
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
492
+ }
493
+ });
494
+ return;
495
+ }
391
496
  if (req.method === "GET" && pathname.startsWith("/api/project-content")) {
392
497
  const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
393
498
  const project = String(qs.project || "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "Knowledge layer for AI agents. Phren learns and recalls.",
5
5
  "type": "module",
6
6
  "bin": {