@phren/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +590 -0
- package/mcp/dist/capabilities/cli.js +61 -0
- package/mcp/dist/capabilities/index.js +15 -0
- package/mcp/dist/capabilities/mcp.js +61 -0
- package/mcp/dist/capabilities/types.js +57 -0
- package/mcp/dist/capabilities/vscode.js +61 -0
- package/mcp/dist/capabilities/web-ui.js +61 -0
- package/mcp/dist/cli-actions.js +302 -0
- package/mcp/dist/cli-config.js +580 -0
- package/mcp/dist/cli-extract.js +305 -0
- package/mcp/dist/cli-govern.js +371 -0
- package/mcp/dist/cli-graph.js +169 -0
- package/mcp/dist/cli-hooks-citations.js +44 -0
- package/mcp/dist/cli-hooks-context.js +56 -0
- package/mcp/dist/cli-hooks-globs.js +83 -0
- package/mcp/dist/cli-hooks-output.js +130 -0
- package/mcp/dist/cli-hooks-retrieval.js +2 -0
- package/mcp/dist/cli-hooks-session.js +1402 -0
- package/mcp/dist/cli-hooks.js +350 -0
- package/mcp/dist/cli-namespaces.js +989 -0
- package/mcp/dist/cli-ops.js +253 -0
- package/mcp/dist/cli-search.js +407 -0
- package/mcp/dist/cli.js +108 -0
- package/mcp/dist/content-archive.js +278 -0
- package/mcp/dist/content-citation.js +391 -0
- package/mcp/dist/content-dedup.js +622 -0
- package/mcp/dist/content-learning.js +472 -0
- package/mcp/dist/content-metadata.js +186 -0
- package/mcp/dist/content-validate.js +462 -0
- package/mcp/dist/core-finding.js +54 -0
- package/mcp/dist/core-project.js +36 -0
- package/mcp/dist/core-search.js +50 -0
- package/mcp/dist/data-access.js +400 -0
- package/mcp/dist/data-tasks.js +821 -0
- package/mcp/dist/embedding.js +344 -0
- package/mcp/dist/entrypoint.js +387 -0
- package/mcp/dist/finding-context.js +172 -0
- package/mcp/dist/finding-impact.js +181 -0
- package/mcp/dist/finding-journal.js +122 -0
- package/mcp/dist/finding-lifecycle.js +259 -0
- package/mcp/dist/governance-audit.js +22 -0
- package/mcp/dist/governance-locks.js +96 -0
- package/mcp/dist/governance-policy.js +648 -0
- package/mcp/dist/governance-scores.js +355 -0
- package/mcp/dist/hooks.js +449 -0
- package/mcp/dist/impact-scoring.js +22 -0
- package/mcp/dist/index-query.js +168 -0
- package/mcp/dist/index.js +205 -0
- package/mcp/dist/init-config.js +336 -0
- package/mcp/dist/init-preferences.js +62 -0
- package/mcp/dist/init-setup.js +1305 -0
- package/mcp/dist/init-shared.js +29 -0
- package/mcp/dist/init.js +1730 -0
- package/mcp/dist/link-checksums.js +62 -0
- package/mcp/dist/link-context.js +257 -0
- package/mcp/dist/link-doctor.js +591 -0
- package/mcp/dist/link-skills.js +212 -0
- package/mcp/dist/link.js +596 -0
- package/mcp/dist/logger.js +15 -0
- package/mcp/dist/machine-identity.js +38 -0
- package/mcp/dist/mcp-config.js +254 -0
- package/mcp/dist/mcp-data.js +315 -0
- package/mcp/dist/mcp-extract-facts.js +78 -0
- package/mcp/dist/mcp-extract.js +133 -0
- package/mcp/dist/mcp-finding.js +557 -0
- package/mcp/dist/mcp-graph.js +339 -0
- package/mcp/dist/mcp-hooks.js +256 -0
- package/mcp/dist/mcp-memory.js +58 -0
- package/mcp/dist/mcp-ops.js +328 -0
- package/mcp/dist/mcp-search.js +628 -0
- package/mcp/dist/mcp-session.js +651 -0
- package/mcp/dist/mcp-skills.js +189 -0
- package/mcp/dist/mcp-tasks.js +551 -0
- package/mcp/dist/mcp-types.js +7 -0
- package/mcp/dist/memory-ui-assets.js +6 -0
- package/mcp/dist/memory-ui-data.js +513 -0
- package/mcp/dist/memory-ui-graph.js +1910 -0
- package/mcp/dist/memory-ui-page.js +353 -0
- package/mcp/dist/memory-ui-scripts.js +1387 -0
- package/mcp/dist/memory-ui-server.js +1218 -0
- package/mcp/dist/memory-ui-styles.js +555 -0
- package/mcp/dist/memory-ui.js +9 -0
- package/mcp/dist/package-metadata.js +13 -0
- package/mcp/dist/phren-art.js +52 -0
- package/mcp/dist/phren-core.js +108 -0
- package/mcp/dist/phren-dotenv.js +67 -0
- package/mcp/dist/phren-paths.js +476 -0
- package/mcp/dist/proactivity.js +172 -0
- package/mcp/dist/profile-store.js +228 -0
- package/mcp/dist/project-config.js +85 -0
- package/mcp/dist/project-locator.js +25 -0
- package/mcp/dist/project-topics.js +1134 -0
- package/mcp/dist/provider-adapters.js +176 -0
- package/mcp/dist/runtime-profile.js +18 -0
- package/mcp/dist/session-checkpoints.js +131 -0
- package/mcp/dist/session-utils.js +68 -0
- package/mcp/dist/shared-content.js +8 -0
- package/mcp/dist/shared-embedding-cache.js +143 -0
- package/mcp/dist/shared-fragment-graph.js +456 -0
- package/mcp/dist/shared-governance.js +4 -0
- package/mcp/dist/shared-index.js +1334 -0
- package/mcp/dist/shared-ollama.js +192 -0
- package/mcp/dist/shared-paths.js +1 -0
- package/mcp/dist/shared-retrieval.js +796 -0
- package/mcp/dist/shared-search-fallback.js +375 -0
- package/mcp/dist/shared-sqljs.js +42 -0
- package/mcp/dist/shared-stemmer.js +171 -0
- package/mcp/dist/shared-vector-index.js +199 -0
- package/mcp/dist/shared.js +114 -0
- package/mcp/dist/shell-entry.js +209 -0
- package/mcp/dist/shell-input.js +943 -0
- package/mcp/dist/shell-palette.js +119 -0
- package/mcp/dist/shell-render.js +252 -0
- package/mcp/dist/shell-state-store.js +81 -0
- package/mcp/dist/shell-types.js +13 -0
- package/mcp/dist/shell-view-list.js +14 -0
- package/mcp/dist/shell-view.js +707 -0
- package/mcp/dist/shell.js +352 -0
- package/mcp/dist/skill-files.js +117 -0
- package/mcp/dist/skill-registry.js +279 -0
- package/mcp/dist/skill-state.js +28 -0
- package/mcp/dist/startup-embedding.js +57 -0
- package/mcp/dist/status.js +323 -0
- package/mcp/dist/synonyms.json +670 -0
- package/mcp/dist/task-hygiene.js +251 -0
- package/mcp/dist/task-lifecycle.js +347 -0
- package/mcp/dist/tasks-github.js +76 -0
- package/mcp/dist/telemetry.js +165 -0
- package/mcp/dist/test-global-setup.js +37 -0
- package/mcp/dist/tool-registry.js +104 -0
- package/mcp/dist/update.js +97 -0
- package/mcp/dist/utils.js +543 -0
- package/package.json +67 -0
- package/skills/README.md +7 -0
- package/skills/consolidate/SKILL.md +152 -0
- package/skills/discover/SKILL.md +175 -0
- package/skills/init/SKILL.md +216 -0
- package/skills/profiles/SKILL.md +121 -0
- package/skills/sync/SKILL.md +261 -0
- package/starter/README.md +74 -0
- package/starter/global/CLAUDE.md +89 -0
- package/starter/global/skills/humanize.md +30 -0
- package/starter/global/skills/pipeline.md +35 -0
- package/starter/global/skills/release.md +35 -0
- package/starter/machines.yaml +8 -0
- package/starter/my-api/.claude/skills/README.md +7 -0
- package/starter/my-api/CLAUDE.md +33 -0
- package/starter/my-api/FINDINGS.md +9 -0
- package/starter/my-api/summary.md +7 -0
- package/starter/my-api/tasks.md +7 -0
- package/starter/my-first-project/.claude/skills/README.md +7 -0
- package/starter/my-first-project/CLAUDE.md +49 -0
- package/starter/my-first-project/FINDINGS.md +24 -0
- package/starter/my-first-project/summary.md +11 -0
- package/starter/my-first-project/tasks.md +25 -0
- package/starter/my-frontend/.claude/skills/README.md +7 -0
- package/starter/my-frontend/CLAUDE.md +33 -0
- package/starter/my-frontend/FINDINGS.md +9 -0
- package/starter/my-frontend/summary.md +7 -0
- package/starter/my-frontend/tasks.md +7 -0
- package/starter/profiles/default.yaml +4 -0
- package/starter/profiles/personal.yaml +4 -0
- package/starter/profiles/work.yaml +4 -0
- package/starter/templates/README.md +7 -0
- package/starter/templates/frontend/CLAUDE.md +23 -0
- package/starter/templates/frontend/FINDINGS.md +7 -0
- package/starter/templates/frontend/reference/README.md +4 -0
- package/starter/templates/frontend/summary.md +7 -0
- package/starter/templates/frontend/tasks.md +11 -0
- package/starter/templates/library/CLAUDE.md +22 -0
- package/starter/templates/library/FINDINGS.md +7 -0
- package/starter/templates/library/reference/README.md +4 -0
- package/starter/templates/library/summary.md +7 -0
- package/starter/templates/library/tasks.md +11 -0
- package/starter/templates/monorepo/CLAUDE.md +21 -0
- package/starter/templates/monorepo/FINDINGS.md +7 -0
- package/starter/templates/monorepo/reference/README.md +4 -0
- package/starter/templates/monorepo/summary.md +7 -0
- package/starter/templates/monorepo/tasks.md +11 -0
- package/starter/templates/python-project/CLAUDE.md +21 -0
- package/starter/templates/python-project/FINDINGS.md +7 -0
- package/starter/templates/python-project/reference/README.md +4 -0
- package/starter/templates/python-project/summary.md +7 -0
- package/starter/templates/python-project/tasks.md +10 -0
|
@@ -0,0 +1,6 @@
|
|
|
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: #374151;\n --muted: #6b7280;\n --accent: #7B68AE;\n --accent-hover: #6A58A0;\n --accent-dim: rgba(123,104,174,.08);\n --accent-glow: rgba(123,104,174,.15);\n --cyan: #00E5FF;\n --cyan-dim: rgba(0,229,255,.08);\n --border: #e5e7eb;\n --border-light: #f3f4f6;\n --danger: #ef4444;\n --danger-dim: rgba(239,68,68,.08);\n --warning: #f59e0b;\n --success: #10b981;\n --success-dim: rgba(16,185,129,.08);\n --purple: #8b5cf6;\n --purple-dim: rgba(139,92,246,.08);\n --blue: #3b82f6;\n --red: #ef4444;\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: #b8c4d8;\n --muted: #7889a4;\n --accent: #9B8BC4;\n --accent-hover: #AE9ED4;\n --accent-dim: rgba(155,139,196,.08);\n --accent-glow: rgba(155,139,196,.15);\n --cyan: #00E5FF;\n --cyan-dim: rgba(0,229,255,.10);\n --border: #1e2a3e;\n --border-light: #151d2c;\n --danger-dim: rgba(239,68,68,.1);\n --success-dim: rgba(16,185,129,.1);\n --purple-dim: rgba(139,92,246,.1);\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(34,211,238,.06); 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(34,211,238,.06); 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: #060a12; }\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(139,92,246,.15); color: #a78bfa; }\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(11,15,26,.82);\n border-bottom-color: var(--border);\n box-shadow: 0 1px 0 rgba(34,211,238,.06);\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: #0f172a;\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(15,23,42,.8);\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(15,23,42,.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: #7B68AE;\n color: #22c55e;\n animation: ledPulse 2.5s infinite;\n }\n .status-led-warn {\n background: #f59e0b;\n color: #f59e0b;\n animation: ledPulse 1.2s infinite;\n }\n .status-led-err {\n background: #ef4444;\n color: #ef4444;\n animation: ledPulse 0.6s infinite;\n }\n @keyframes ledPulse {\n 0% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }\n 70% { opacity: 0.8; box-shadow: 0 0 0 5px transparent; }\n 100% { opacity: 1; }\n }\n\n @media (max-width: 900px) {\n .projects-grid { grid-template-columns: 1fr; }\n .split-view { grid-template-columns: 1fr; }\n .panes { grid-template-columns: 1fr; }\n .header { padding: 0 12px; gap: 12px; }\n .main { padding: 16px; }\n }\n\n ::view-transition-old(root), ::view-transition-new(root) {\n animation-duration: 0.18s;\n }\n\n @keyframes cardIn {\n from { opacity: 0; transform: translateY(8px); }\n to { opacity: 1; transform: translateY(0); }\n }\n .review-card {\n animation: cardIn 0.3s cubic-bezier(.21,1.02,.73,1) backwards;\n }\n .review-card.removing {\n animation: cardOut 0.25s ease forwards;\n pointer-events: none;\n }\n @keyframes cardOut {\n to { opacity: 0; transform: translateY(-6px) scale(.98); margin-top: -4px; padding: 0; }\n }\n\n /* ── Batch actions ───────────────────────────────────────────── */\n .review-card-check {\n position: absolute;\n top: 16px;\n right: 16px;\n width: 18px;\n height: 18px;\n border: 2px solid var(--border);\n border-radius: 4px;\n cursor: pointer;\n background: var(--surface);\n transition: all .15s;\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 2;\n }\n .review-card-check:hover { border-color: var(--accent); }\n .review-card-check.checked {\n background: var(--accent);\n border-color: var(--accent);\n }\n .review-card-check.checked::after {\n content: '';\n width: 6px;\n height: 10px;\n border: solid white;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n margin-top: -2px;\n }\n .review-card { position: relative; }\n .batch-bar {\n position: fixed;\n bottom: 24px;\n left: 50%;\n transform: translateX(-50%) translateY(80px);\n background: var(--surface-raised, #1a2233);\n border: 1px solid var(--border);\n border-radius: 12px;\n padding: 12px 16px;\n display: flex;\n align-items: center;\n gap: 12px;\n box-shadow: 0 8px 32px rgba(0,0,0,.2);\n z-index: 500;\n transition: transform .3s cubic-bezier(.21,1.02,.73,1);\n pointer-events: all;\n }\n .batch-bar.visible {\n transform: translateX(-50%) translateY(0);\n }\n .batch-bar-count {\n font-size: var(--text-base);\n font-weight: 600;\n color: var(--ink);\n min-width: 80px;\n }\n\n .review-banner-pill {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 2px 10px;\n border-radius: 99px;\n font-size: var(--text-xs);\n font-weight: 600;\n background: var(--surface-raised);\n border: 1px solid var(--border);\n color: var(--ink);\n }\n .review-banner-pill.rp-danger {\n background: rgba(239,68,68,.12);\n border-color: rgba(239,68,68,.3);\n color: #f87171;\n }\n .review-banner-pill.rp-warn {\n background: rgba(251,191,36,.10);\n border-color: rgba(251,191,36,.3);\n color: #fbbf24;\n }\n\n /* ── Diff view ───────────────────────────────────────────────── */\n .review-diff {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 1px;\n background: var(--border);\n border: 1px solid var(--border);\n border-radius: var(--radius-sm);\n overflow: hidden;\n margin-bottom: 8px;\n font-size: var(--text-sm);\n font-family: var(--mono);\n line-height: 1.6;\n }\n .review-diff-pane {\n padding: 12px;\n background: var(--surface-sunken);\n white-space: pre-wrap;\n word-break: break-word;\n min-height: 60px;\n }\n .review-diff-pane-label {\n font-size: var(--text-xs);\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: .05em;\n color: var(--muted);\n margin-bottom: 8px;\n font-family: var(--font);\n }\n .diff-del { background: var(--danger-dim); color: var(--danger); text-decoration: line-through; }\n .diff-ins { background: var(--success-dim); color: var(--success); }\n\n /* ── Drag reorder ────────────────────────────────────────────── */\n .review-card.dragging {\n opacity: .5;\n transform: scale(.98);\n z-index: 10;\n }\n .review-card.drag-over {\n border-top: 2px solid var(--accent);\n margin-top: -1px;\n }\n .review-card-drag-handle {\n width: 16px;\n height: 16px;\n cursor: grab;\n color: var(--border);\n transition: color .15s;\n flex-shrink: 0;\n margin-right: 4px;\n }\n .review-card-drag-handle:hover { color: var(--muted); }\n .review-card-drag-handle:active { cursor: grabbing; }\n\n\n /* ── Similarity groups ────────────────────────────────────────── */\n .review-group {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 8px;\n margin-bottom: 12px;\n background: color-mix(in srgb, var(--surface) 95%, var(--accent) 5%);\n }\n .review-group .review-card { margin-bottom: 8px; }\n .review-group .review-card:last-child { margin-bottom: 0; }\n .review-group-header {\n font-size: var(--text-xs);\n color: var(--muted);\n margin-bottom: 8px;\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 0 4px;\n }\n .review-group-toggle {\n background: none;\n border: none;\n color: var(--muted);\n cursor: pointer;\n font-size: var(--text-xs);\n padding: 0 4px;\n }\n .review-group-toggle:hover { color: var(--ink-secondary); }\n .review-group.collapsed .review-card:not(:first-child) { display: none; }\n\n /* ── Select all ──────────────────────────────────────────────── */\n .review-select-all {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: var(--text-sm);\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n margin-bottom: 8px;\n }\n .review-select-all input[type=\"checkbox\"] {\n width: 14px;\n height: 14px;\n cursor: pointer;\n accent-color: var(--accent);\n }\n /* ── Toast ───────────────────────────────────────────────────── */\n .toast-container {\n position: fixed;\n bottom: 24px;\n left: 50%;\n transform: translateX(-50%);\n z-index: 999;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 8px;\n pointer-events: none;\n }\n .toast {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 12px 16px;\n background: var(--surface-raised, #1a2233);\n color: var(--ink, #e8ecf4);\n border: 1px solid var(--border, #1e2a3e);\n border-radius: var(--radius);\n font-size: var(--text-base);\n font-weight: 500;\n box-shadow: 0 8px 32px rgba(0,0,0,.2);\n pointer-events: all;\n animation: toastIn 0.25s cubic-bezier(.21,1.02,.73,1);\n max-width: 420px;\n backdrop-filter: blur(12px);\n }\n .toast.ok { border-color: var(--success); background: var(--success-dim); color: var(--success); }\n .toast.err { border-color: var(--danger); background: var(--danger-dim); color: var(--danger); }\n @keyframes toastIn {\n from { opacity: 0; transform: translateY(12px) scale(.96); }\n to { opacity: 1; transform: translateY(0) scale(1); }\n }\n .toast-undo {\n background: none;\n border: 1px solid currentColor;\n color: inherit;\n border-radius: var(--radius-sm);\n padding: 4px 12px;\n font-size: var(--text-xs);\n font-weight: 600;\n cursor: pointer;\n font-family: var(--font);\n opacity: .8;\n transition: opacity .15s;\n }\n .toast-undo:hover { opacity: 1; }\n\n /* ── Command Palette ─────────────────────────────────────────── */\n .cmdpal-overlay {\n position: fixed;\n inset: 0;\n background: rgba(0,0,0,.4);\n backdrop-filter: blur(4px);\n z-index: 900;\n display: none;\n align-items: flex-start;\n justify-content: center;\n padding-top: 14vh;\n }\n .cmdpal-overlay.open { display: flex; }\n .cmdpal-box {\n background: var(--surface-raised);\n border: 1px solid var(--border);\n border-radius: 12px;\n width: 100%;\n max-width: 520px;\n box-shadow: 0 24px 80px rgba(0,0,0,.25);\n overflow: hidden;\n }\n .cmdpal-input {\n width: 100%;\n padding: 16px 20px;\n font-size: var(--text-md);\n font-family: var(--font);\n border: none;\n outline: none;\n background: transparent;\n color: var(--ink);\n border-bottom: 1px solid var(--border);\n font-weight: 450;\n }\n .cmdpal-input::placeholder { color: var(--muted); }\n .cmdpal-results { max-height: 340px; overflow-y: auto; }\n .cmdpal-item {\n padding: 12px 20px;\n cursor: pointer;\n font-size: var(--text-base);\n display: flex;\n align-items: center;\n gap: 8px;\n transition: background .1s;\n }\n .cmdpal-item:hover, .cmdpal-item.selected { background: var(--accent-dim); }\n .cmdpal-item-name { font-weight: 550; color: var(--ink); }\n .cmdpal-item-meta { font-size: var(--text-xs); color: var(--muted); margin-left: auto; font-weight: 500; }\n .cmdpal-empty { padding: 32px 20px; text-align: center; color: var(--muted); font-size: var(--text-base); }\n @keyframes countPop {\n 0% { transform: scale(1); }\n 50% { transform: scale(1.3); }\n 100% { transform: scale(1); }\n }\n @keyframes countFlip {\n 0% { transform: scale(1) rotateX(0); }\n 40% { transform: scale(1.3) rotateX(90deg); }\n 60% { transform: scale(1.3) rotateX(-10deg); }\n 100% { transform: scale(1) rotateX(0); }\n }\n .count-animating {\n animation: countPop 0.3s ease-out;\n }\n .count-flipping {\n animation: countFlip 0.3s ease-out;\n }";
|
|
2
|
+
const WEB_UI_SCRIPT_TEMPLATE = "(function() {\n // ── State ────────────────────────────────────────────────────\n var _authToken = '__PHREN_AUTH_TOKEN_1d7b7c99__';\n var _skillsLoaded = false, _hooksLoaded = false, _graphLoaded = false;\n var _currentSkillPath = null, _currentHookPath = null;\n var _editingSkill = false, _editingHook = false;\n var _selectedProject = null;\n var _scoresPromise = null;\n\n var _activeFetches = 0;\n function setLed(state) {\n var led = document.getElementById('sync-led');\n if (!led) return;\n led.className = 'status-led status-led-' + state;\n led.title = state === 'ok' ? 'Synced' : state === 'warn' ? 'Working...' : 'Error';\n }\n function ledFetch(url, opts) {\n _activeFetches++;\n setLed('warn');\n return fetch(url, opts).then(function(r) {\n _activeFetches--;\n if (_activeFetches <= 0) { _activeFetches = 0; setLed('ok'); }\n return r;\n }).catch(function(err) {\n _activeFetches--;\n if (_activeFetches <= 0) { _activeFetches = 0; setLed('err'); }\n setTimeout(function() { if (_activeFetches <= 0) setLed('ok'); }, 3000);\n throw err;\n });\n }\n\n // ── Tab switching ────────────────────────────────────────────\n window.switchTab = function(tab) {\n function doSwitch() {\n document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });\n document.querySelectorAll('.nav-item').forEach(function(el) { el.classList.remove('active'); });\n var tabEl = document.getElementById('tab-' + tab);\n if (tabEl) tabEl.classList.add('active');\n var navBtn = document.querySelector('.nav-item[data-tab=\"' + tab + '\"]');\n if (navBtn) navBtn.classList.add('active');\n if (tab === 'projects' && !document.querySelector('.project-card')) loadProjects();\n if (tab === 'review') { loadReviewCards(); loadReviewActivity(); }\n if (tab === 'skills' && !_skillsLoaded) loadSkills();\n if (tab === 'hooks' && !_hooksLoaded) loadHooks();\n if (tab === 'graph' && !_graphLoaded) loadGraph();\n }\n if (document.startViewTransition) {\n document.startViewTransition(doSwitch);\n } else {\n doSwitch();\n }\n };\n\n // ── Projects ─────────────────────────────────────────────────\n function getStarredProjects() {\n try { return JSON.parse(localStorage.getItem('phren-starred-projects') || '[]'); } catch { return []; }\n }\n function setStarredProjects(arr) {\n localStorage.setItem('phren-starred-projects', JSON.stringify(arr));\n }\n\n function renderProjectCards(data) {\n var grid = document.getElementById('projects-grid');\n if (!data.length) {\n grid.innerHTML = '<div style=\"padding:60px;color:var(--muted);grid-column:1/-1;text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 12px;display:block\"><path d=\"M18 30h44v26a4 4 0 01-4 4H22a4 4 0 01-4-4V30z\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><path d=\"M18 30l6-10h16l6 10\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"none\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;margin-bottom:4px\">No projects yet</div><div style=\"font-size:var(--text-sm)\">Run <code>phren init</code> to create one.</div></div>';\n return;\n }\n var starred = getStarredProjects();\n // Sort: starred first, then by activity\n var sorted = data.slice().sort(function(a, b) {\n var aStarred = starred.indexOf(a.name) !== -1 ? 1 : 0;\n var bStarred = starred.indexOf(b.name) !== -1 ? 1 : 0;\n if (aStarred !== bStarred) return bStarred - aStarred;\n return 0; // preserve server sort order\n });\n grid.innerHTML = sorted.map(function(p) {\n var isStarred = starred.indexOf(p.name) !== -1;\n var githubHtml = p.githubUrl ? '<a class=\"github-link\" href=\"'+esc(p.githubUrl)+'\" target=\"_blank\" rel=\"noopener\" data-ui-action=\"stopProp\">GitHub</a>' : '';\n var sparkHtml = '';\n if (p.sparkline && p.sparkline.some(function(v) { return v > 0; })) {\n var sp = p.sparkline;\n var max = Math.max.apply(null, sp) || 1;\n var w = 80, h = 20;\n var pts = sp.map(function(v, i) { return (i * w / (sp.length - 1)).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1); }).join(' ');\n sparkHtml = '<svg class=\"project-sparkline\" width=\"' + w + '\" height=\"' + h + '\" viewBox=\"0 0 ' + w + ' ' + h + '\" style=\"margin-top:8px;display:block\"><polyline points=\"' + pts + '\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" opacity=\".7\" /><polyline points=\"0,' + h + ' ' + pts + ' ' + w + ',' + h + '\" fill=\"var(--accent-dim)\" stroke=\"none\" /></svg>';\n }\n return '<div class=\"project-card\" data-ui-action=\"selectProject\" data-project=\"'+esc(p.name)+'\" data-summary=\"'+esc(p.summaryText || '')+'\">' +\n '<button class=\"star-btn'+(isStarred ? ' starred' : '')+'\" data-ui-action=\"toggleStar\" title=\"Star project\">★</button>' +\n '<div class=\"project-card-name\">' + esc(p.name) + '</div>' +\n (p.summaryText ? '<div class=\"project-card-summary\">' + esc(p.summaryText) + '</div>' : '<div class=\"project-card-summary\" style=\"font-style:italic\">No summary</div>') +\n '<div class=\"project-card-stats\">' +\n '<span class=\"project-card-stat\"><strong>' + p.findingCount + '</strong> findings</span>' +\n '<span class=\"project-card-stat\"><strong>' + p.taskCount + '</strong> task</span>' +\n (p.hasClaudeMd ? '<span class=\"project-card-stat\">CLAUDE.md</span>' : '') +\n (p.hasReference ? '<span class=\"project-card-stat\">reference/</span>' : '') +\n githubHtml +\n '</div>' +\n sparkHtml +\n '</div>';\n }).join('');\n }\n\n var _projectData = [];\n\n function loadProjects() {\n fetch(authUrl('/api/projects')).then(function(r) { return r.json(); }).then(function(data) {\n _projectData = data;\n renderProjectCards(data);\n });\n }\n\n // ── Review queue (live) ───────────────────────────────────────\n var _reviewData = [];\n var _reviewCardKeys = new Set(); // tracks project+line keys currently in DOM\n\n function cardKey(item) { return item.project + '\\\\x00' + item.line; }\n\n function renderReviewCard(item, delayMs) {\n var key = cardKey(item);\n var projectBadge = '<span class=\"badge badge-project\">' + esc(item.project) + '</span>';\n var sectionBadge = '<span class=\"badge\">' + esc(item.section) + '</span>';\n var machineBadge = item.machine ? '<span class=\"badge badge-machine\" title=\"Machine: ' + esc(item.machine) + '\">' + esc(item.machine) + '</span>' : '';\n var modelBadge = (item.model && item.model !== 'unknown') ? '<span class=\"badge badge-model\" title=\"Model: ' + esc(item.model) + '\">' + esc(item.model) + '</span>' : '';\n var dateSpan = '<span class=\"text-muted\" style=\"font-size:var(--text-sm);margin-left:auto\">' + esc(item.date) + '</span>';\n\n var cardText = esc(item.text);\n\n var div = document.createElement('div');\n div.className = 'review-card';\n div.setAttribute('data-key', key);\n div.setAttribute('data-project', item.project);\n div.setAttribute('data-machine', item.machine || '');\n div.setAttribute('data-model', item.model || '');\n div.style.animationDelay = delayMs + 'ms';\n div.innerHTML =\n '<div class=\"review-card-inner\">' +\n '<div class=\"review-card-header\">' +\n '<svg class=\"review-card-drag-handle\" viewBox=\"0 0 16 16\" fill=\"currentColor\"><circle cx=\"5\" cy=\"4\" r=\"1.2\"/><circle cx=\"11\" cy=\"4\" r=\"1.2\"/><circle cx=\"5\" cy=\"8\" r=\"1.2\"/><circle cx=\"11\" cy=\"8\" r=\"1.2\"/><circle cx=\"5\" cy=\"12\" r=\"1.2\"/><circle cx=\"11\" cy=\"12\" r=\"1.2\"/></svg>' +\n projectBadge + sectionBadge + machineBadge + modelBadge + dateSpan +\n '</div>' +\n '<div class=\"review-card-text\">' + cardText + '</div>' +\n '<div class=\"review-card-actions\">' +\n '<button type=\"button\" class=\"btn btn-sm btn-approve\" data-ui-action=\"reviewAction\" data-review-type=\"approve\">Approve</button>' +\n '<button type=\"button\" class=\"btn btn-sm btn-reject\" data-ui-action=\"reviewAction\" data-review-type=\"reject\">Reject</button>' +\n '<button type=\"button\" class=\"btn btn-sm\" data-ui-action=\"toggleReviewEdit\">Edit</button>' +\n '</div>' +\n '<div class=\"review-card-edit\" style=\"display:none\">' +\n '<form data-ui-action=\"reviewEditSubmit\">' +\n '<textarea name=\"new_text\" class=\"review-edit-textarea\">' + cardText + '</textarea>' +\n '<div style=\"display:flex;gap:8px;margin-top:8px\">' +\n '<button type=\"submit\" class=\"btn btn-sm btn-primary\">Save</button>' +\n '<button type=\"button\" class=\"btn btn-sm\" data-ui-action=\"toggleReviewEdit\">Cancel</button>' +\n '</div>' +\n '</form>' +\n '</div>' +\n '</div>';\n\n var approveBtn = div.querySelector('.btn-approve');\n if (approveBtn) {\n approveBtn.setAttribute('data-project', item.project);\n approveBtn.setAttribute('data-line', item.line);\n }\n var rejectBtn = div.querySelector('.btn-reject');\n if (rejectBtn) {\n rejectBtn.setAttribute('data-project', item.project);\n rejectBtn.setAttribute('data-line', item.line);\n }\n var editForm = div.querySelector('.review-card-edit form');\n if (editForm) {\n editForm.setAttribute('data-project', item.project);\n editForm.setAttribute('data-line', item.line);\n }\n\n // Render queue text as escaped plain text; review items are not trusted markdown/HTML.\n var textEl = div.querySelector('.review-card-text');\n if (textEl) {\n textEl.innerHTML = esc(item.text).replace(/\\n/g, '<br>');\n }\n\n // Add batch checkbox\n var checkbox = document.createElement('div');\n checkbox.className = 'review-card-check';\n checkbox.onclick = function(e) {\n e.stopPropagation();\n this.classList.toggle('checked');\n updateBatchBar();\n };\n div.querySelector('.review-card-inner').appendChild(checkbox);\n\n // Drag reorder\n div.setAttribute('draggable', 'true');\n var handle = div.querySelector('.review-card-drag-handle');\n if (handle) {\n handle.addEventListener('mousedown', function() { div.setAttribute('draggable', 'true'); });\n }\n div.addEventListener('dragstart', function(e) {\n div.classList.add('dragging');\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', key);\n });\n div.addEventListener('dragend', function() {\n div.classList.remove('dragging');\n document.querySelectorAll('.review-card.drag-over').forEach(function(c) { c.classList.remove('drag-over'); });\n });\n div.addEventListener('dragover', function(e) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n var dragging = document.querySelector('.review-card.dragging');\n if (dragging && dragging !== div) {\n div.classList.add('drag-over');\n }\n });\n div.addEventListener('dragleave', function() {\n div.classList.remove('drag-over');\n });\n div.addEventListener('drop', function(e) {\n e.preventDefault();\n div.classList.remove('drag-over');\n var dragging = document.querySelector('.review-card.dragging');\n if (dragging && dragging !== div) {\n var list = div.parentNode;\n var cards = Array.from(list.querySelectorAll('.review-card'));\n var dragIdx = cards.indexOf(dragging);\n var dropIdx = cards.indexOf(div);\n if (dragIdx < dropIdx) {\n list.insertBefore(dragging, div.nextSibling);\n } else {\n list.insertBefore(dragging, div);\n }\n }\n });\n\n return div;\n }\n\n function loadReviewCards() {\n var url = '/api/review-queue' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(data) {\n _reviewData = data;\n var list = document.getElementById('review-cards-list');\n if (!list) return;\n\n // Build set of incoming keys\n var incomingKeys = new Set(data.map(cardKey));\n\n // Remove cards no longer in queue (animate out)\n var existing = list.querySelectorAll('.review-card[data-key]');\n existing.forEach(function(card) {\n var key = card.getAttribute('data-key');\n if (!incomingKeys.has(key)) {\n card.classList.add('removing');\n setTimeout(function() { if (card.parentNode) card.parentNode.removeChild(card); }, 300);\n _reviewCardKeys.delete(key);\n }\n });\n\n // Add new cards with similarity grouping (animate in with stagger)\n var newItems = data.filter(function(item) { return !_reviewCardKeys.has(cardKey(item)); });\n if (newItems.length > 0) {\n // Remove existing groups if full re-render\n list.querySelectorAll('.review-group').forEach(function(g) { g.remove(); });\n\n var groups = clusterCards(newItems);\n var delayBase = 0;\n groups.forEach(function(groupItems) {\n if (groupItems.length > 1) {\n // Multi-item group: wrap in a group container\n var groupDiv = document.createElement('div');\n groupDiv.className = 'review-group';\n var header = document.createElement('div');\n header.className = 'review-group-header';\n header.innerHTML = '<span>Similar (' + groupItems.length + ' items)</span><button class=\"review-group-toggle\" data-ui-action=\"toggleGroup\">\\u25BC</button>';\n groupDiv.appendChild(header);\n groupItems.forEach(function(item) {\n var key = cardKey(item);\n var card = renderReviewCard(item, delayBase);\n groupDiv.appendChild(card);\n _reviewCardKeys.add(key);\n delayBase += 40;\n });\n list.appendChild(groupDiv);\n } else {\n // Singleton: render normally\n var item = groupItems[0];\n var key = cardKey(item);\n var card = renderReviewCard(item, delayBase);\n list.appendChild(card);\n _reviewCardKeys.add(key);\n delayBase += 40;\n }\n });\n }\n\n // Show/hide select-all checkbox\n var selectAllEl = document.getElementById('review-select-all');\n if (selectAllEl) selectAllEl.style.display = data.length > 0 ? 'flex' : 'none';\n\n // Empty state\n var totalVisible = list.querySelectorAll('.review-card:not(.removing)').length;\n var emptyEl = list.querySelector('.review-cards-empty');\n if (totalVisible === 0 && newItems.length === 0) {\n if (!emptyEl) {\n var empty = document.createElement('div');\n empty.className = 'review-cards-empty';\n empty.style.cssText = 'text-align:center;padding:40px;color:var(--muted)';\n empty.innerHTML = '<svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 12px;display:block\"><rect x=\"15\" y=\"20\" width=\"50\" height=\"40\" rx=\"4\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><polyline points=\"28,38 36,46 52,32\" stroke=\"var(--accent)\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;margin-bottom:4px\">All caught up</div><div style=\"font-size:var(--text-sm);color:var(--muted)\">No memories waiting for review.</div>';\n list.appendChild(empty);\n }\n } else if (emptyEl) {\n emptyEl.remove();\n }\n\n // Remove loading spinner\n var loading = list.querySelector('.review-cards-loading');\n if (loading) loading.remove();\n\n // Update nav badge\n var navBtn = document.querySelector('.nav-item[data-tab=\"review\"] .count');\n if (data.length > 0) {\n if (!navBtn) {\n var btn = document.querySelector('.nav-item[data-tab=\"review\"]');\n if (btn) {\n var countSpan = document.createElement('span');\n countSpan.className = 'count count-animating';\n countSpan.textContent = String(data.length);\n btn.appendChild(countSpan);\n }\n } else {\n var oldCount = parseInt(navBtn.textContent, 10) || 0;\n if (oldCount !== data.length) {\n animateCount(navBtn, oldCount, data.length);\n navBtn.classList.remove('count-animating');\n void navBtn.offsetWidth;\n navBtn.classList.add('count-animating');\n }\n }\n } else {\n if (navBtn) navBtn.remove();\n }\n\n // Update filters\n var machines = new Set(), models = new Set(), projects = new Set();\n data.forEach(function(item) {\n projects.add(item.project);\n if (item.machine) machines.add(item.machine);\n if (item.model && item.model !== 'unknown') models.add(item.model);\n });\n\n var filterContainer = document.getElementById('review-filters');\n if (filterContainer) {\n filterContainer.style.display = data.length > 0 ? 'flex' : 'none';\n }\n\n function updateSelect(id, values, allLabel) {\n var sel = document.getElementById(id);\n if (!sel) return;\n var current = sel.value;\n sel.innerHTML = '<option value=\"\">' + allLabel + '</option>';\n Array.from(values).sort().forEach(function(v) {\n var opt = document.createElement('option');\n opt.value = v;\n opt.textContent = v;\n if (v === current) opt.selected = true;\n sel.appendChild(opt);\n });\n }\n updateSelect('review-filter-project', projects, 'All projects');\n updateSelect('review-filter-machine', machines, 'All machines');\n updateSelect('review-filter-model', models, 'All models');\n\n // Show/hide keyboard hints\n var hints = document.getElementById('review-kbd-hints');\n if (hints) hints.style.display = data.length > 0 ? 'flex' : 'none';\n\n // Re-apply current filter\n filterReviewCards();\n }).catch(function() {\n var list = document.getElementById('review-cards-list');\n if (list) {\n var loading = list.querySelector('.review-cards-loading');\n if (loading) loading.textContent = 'Failed to load review queue.';\n }\n });\n }\n\n function loadReviewActivity() {\n var url = '/api/review-activity' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(data) {\n var acceptedEl = document.getElementById('accepted-list');\n var usageEl = document.getElementById('usage-list');\n if (acceptedEl) acceptedEl.innerHTML = data.accepted && data.accepted.length ? data.accepted.map(function(l) { return '<li>' + esc(l) + '</li>'; }).join('') : '<li style=\"color:var(--muted)\">None yet.</li>';\n if (usageEl) usageEl.innerHTML = data.usage && data.usage.length ? data.usage.map(function(l) { return '<li>' + esc(l) + '</li>'; }).join('') : '<li style=\"color:var(--muted)\">No usage events yet.</li>';\n }).catch(function() {});\n }\n\n function refreshLiveState() {\n loadProjects();\n fetch(authUrl('/api/runtime-health')).then(function(r) { return r.json(); }).then(function(data) {\n var summary = document.getElementById('sync-state-summary');\n if (!summary) return;\n summary.innerHTML =\n '<div><strong>Auto-save</strong><div class=\"text-muted\">' + esc(data.autoSaveStatus || 'n/a') + '</div></div>' +\n '<div><strong>Last pull</strong><div class=\"text-muted\">' + esc((data.lastPullStatus || 'n/a') + ' ' + (data.lastPullAt || '')) + '</div></div>' +\n '<div><strong>Last push</strong><div class=\"text-muted\">' + esc((data.lastPushStatus || 'n/a') + ' ' + (data.lastPushAt || '')) + '</div></div>' +\n '<div><strong>Unsynced commits</strong><div class=\"text-muted\">' + esc(String(data.unsyncedCommits || 0)) + '</div></div>';\n // Update sync LED\n var led = document.getElementById('sync-led');\n if (led) {\n var pushOk = !data.lastPushStatus || data.lastPushStatus === 'ok' || data.lastPushStatus === 'n/a';\n var pullOk = !data.lastPullStatus || data.lastPullStatus === 'ok' || data.lastPullStatus === 'n/a';\n var hasUnsynced = (data.unsyncedCommits || 0) > 0;\n if (!pushOk || !pullOk) {\n led.className = 'status-led status-led-err';\n led.title = 'Sync error';\n } else if (hasUnsynced) {\n led.className = 'status-led status-led-warn';\n led.title = hasUnsynced + ' unsynced commit' + (hasUnsynced > 1 ? 's' : '');\n } else {\n led.className = 'status-led status-led-ok';\n led.title = 'phren is synced';\n }\n }\n });\n if (_selectedProject) {\n var activeTab = document.querySelector('.project-detail-tab.active');\n var activeFile = activeTab ? activeTab.textContent : 'Findings';\n var fileMap = { 'Findings': 'FINDINGS.md', 'Tasks': 'tasks.md', 'CLAUDE.md': 'CLAUDE.md', 'Summary': 'summary.md', 'Reference': 'reference:browser' };\n loadProjectFile(fileMap[activeFile] || 'FINDINGS.md', activeTab);\n }\n if (_skillsLoaded) loadSkills();\n if (_hooksLoaded) loadHooks();\n if (_graphLoaded && !_graphRunning) loadGraph();\n loadReviewCards();\n loadReviewActivity();\n }\n\n function pollLiveUpdates() {\n fetch(authUrl('/api/change-token'))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data || !data.token) return;\n if (!_lastChangeToken) {\n _lastChangeToken = data.token;\n return;\n }\n if (data.token === _lastChangeToken) return;\n _lastChangeToken = data.token;\n refreshLiveState();\n })\n .catch(function() {});\n }\n\n window.toggleStar = function(name) {\n var starred = getStarredProjects();\n var idx = starred.indexOf(name);\n if (idx !== -1) starred.splice(idx, 1); else starred.push(name);\n setStarredProjects(starred);\n renderProjectCards(_projectData);\n };\n\n window.filterProjects = function(query) {\n var cards = document.querySelectorAll('.project-card');\n var q = query.toLowerCase();\n cards.forEach(function(card) {\n var name = (card.getAttribute('data-project') || '').toLowerCase();\n var summary = (card.getAttribute('data-summary') || '').toLowerCase();\n card.style.display = (!q || name.indexOf(q) !== -1 || summary.indexOf(q) !== -1) ? '' : 'none';\n });\n };\n\n window.toggleReviewEdit = function(btn) {\n var card = btn.closest('.review-card');\n if (!card) return;\n var editSection = card.querySelector('.review-card-edit');\n if (!editSection) return;\n var isVisible = editSection.style.display !== 'none';\n editSection.style.display = isVisible ? 'none' : 'block';\n\n if (!isVisible) {\n // Create diff view if not present\n var existing = editSection.querySelector('.review-diff');\n if (existing) existing.remove();\n\n var ta = editSection.querySelector('textarea');\n var originalText = card.querySelector('.review-card-text') ? card.querySelector('.review-card-text').textContent.trim() : '';\n\n var diffContainer = document.createElement('div');\n diffContainer.className = 'review-diff';\n diffContainer.innerHTML =\n '<div class=\"review-diff-pane\"><div class=\"review-diff-pane-label\">Original</div><div class=\"diff-original\"></div></div>' +\n '<div class=\"review-diff-pane\"><div class=\"review-diff-pane-label\">Edited</div><div class=\"diff-edited\"></div></div>';\n\n // Insert before the textarea\n ta.parentNode.insertBefore(diffContainer, ta);\n\n function updateDiff() {\n var original = originalText;\n var edited = ta.value;\n var origEl = diffContainer.querySelector('.diff-original');\n var editEl = diffContainer.querySelector('.diff-edited');\n\n if (original === edited) {\n origEl.textContent = original;\n editEl.innerHTML = '<span style=\"color:var(--muted);font-style:italic\">No changes</span>';\n return;\n }\n\n // Simple word-level diff\n var origWords = original.split(/(\\\\s+)/);\n var editWords = edited.split(/(\\\\s+)/);\n var origHtml = '', editHtml = '';\n\n // Simple sequential comparison\n var i = 0, j = 0;\n while (i < origWords.length || j < editWords.length) {\n if (i < origWords.length && j < editWords.length && origWords[i] === editWords[j]) {\n origHtml += esc(origWords[i]);\n editHtml += esc(editWords[j]);\n i++; j++;\n } else if (i < origWords.length && editWords.indexOf(origWords[i], j) === -1) {\n origHtml += '<span class=\"diff-del\">' + esc(origWords[i]) + '</span>';\n i++;\n } else if (j < editWords.length && origWords.indexOf(editWords[j], i) === -1) {\n editHtml += '<span class=\"diff-ins\">' + esc(editWords[j]) + '</span>';\n j++;\n } else {\n // advance both\n if (i < origWords.length) { origHtml += '<span class=\"diff-del\">' + esc(origWords[i]) + '</span>'; i++; }\n if (j < editWords.length) { editHtml += '<span class=\"diff-ins\">' + esc(editWords[j]) + '</span>'; j++; }\n }\n }\n origEl.innerHTML = origHtml;\n editEl.innerHTML = editHtml;\n }\n\n updateDiff();\n ta.addEventListener('input', updateDiff);\n }\n };\n\n window.filterReviewCards = function() {\n var fp = document.getElementById('review-filter-project');\n var fm = document.getElementById('review-filter-machine');\n var fmod = document.getElementById('review-filter-model');\n if (!fp) return;\n var project = fp.value;\n var machine = fm ? fm.value : '';\n var model = fmod ? fmod.value : '';\n var cards = document.querySelectorAll('.review-card');\n var shown = 0;\n var total = cards.length;\n cards.forEach(function(card) {\n var cp = card.getAttribute('data-project') || '';\n var cm = card.getAttribute('data-machine') || '';\n var cmod = card.getAttribute('data-model') || '';\n var show = (!project || cp === project) && (!machine || cm === machine) && (!model || cmod === model);\n card.style.display = show ? '' : 'none';\n if (show) shown++;\n });\n var countEl = document.getElementById('review-filter-count');\n if (countEl) countEl.textContent = shown < total ? (shown + ' of ' + total) : '';\n };\n\n window.selectProject = function(name, el) {\n _selectedProject = name;\n document.querySelectorAll('.project-card').forEach(function(c) { c.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var area = document.getElementById('project-detail-area');\n area.innerHTML =\n '<div class=\"project-detail\">' +\n '<div class=\"project-detail-header\"><h2>' + esc(name) + '</h2></div>' +\n '<div class=\"project-detail-tabs\">' +\n '<button class=\"project-detail-tab active\" data-ui-action=\"loadProjectFile\" data-file=\"FINDINGS.md\">Findings</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"tasks.md\">Tasks</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"CLAUDE.md\">CLAUDE.md</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"summary.md\">Summary</button>' +\n '<button class=\"project-detail-tab\" data-ui-action=\"loadProjectFile\" data-file=\"reference:browser\">Reference</button>' +\n '</div>' +\n '<div class=\"project-detail-content\" id=\"project-content\"><div class=\"project-detail-empty\">Loading...</div></div>' +\n '</div>';\n loadProjectFile('FINDINGS.md', area.querySelector('.project-detail-tab'));\n area.scrollIntoView({ behavior: 'smooth', block: 'start' });\n };\n\n\n\n function normalizeScoresPayload(payload) {\n if (!payload || typeof payload !== 'object') return {};\n if (payload.entries && typeof payload.entries === 'object') return payload.entries;\n return payload;\n }\n\n function loadScores() {\n if (_scoresPromise) return _scoresPromise;\n _scoresPromise = fetch(authUrl('/api/scores'))\n .then(function(r) { return r.json(); })\n .then(function(payload) { return normalizeScoresPayload(payload); })\n .catch(function() { return {}; });\n return _scoresPromise;\n }\n\n function scoreDigest(text) {\n if (!window.crypto || !window.crypto.subtle || typeof TextEncoder === 'undefined') return Promise.resolve('');\n var data = new TextEncoder().encode(text);\n return window.crypto.subtle.digest('SHA-1', data).then(function(buf) {\n var bytes = Array.from(new Uint8Array(buf));\n return bytes.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').slice(0, 12);\n }).catch(function() { return ''; });\n }\n\n function computeQualityFromEntry(entry) {\n if (!entry) return null;\n var now = Date.now();\n var lastUsed = entry.lastUsedAt ? new Date(entry.lastUsedAt).getTime() : 0;\n var daysSince = lastUsed ? (now - lastUsed) / 86400000 : 999;\n\n var recencyBoost = 0;\n if (daysSince <= 7) recencyBoost = 0.15;\n else if (daysSince <= 30) recencyBoost = 0;\n else recencyBoost = -0.1 * Math.min(3, (daysSince - 30) / 30);\n\n var impressions = entry.impressions || 0;\n var frequencyBoost = impressions > 0 ? Math.min(0.2, Math.log2(impressions + 1) * 0.05) : 0;\n var helpful = entry.helpful || 0;\n var reprompt = entry.repromptPenalty || 0;\n var regression = entry.regressionPenalty || 0;\n var penalties = reprompt + regression * 2;\n var feedbackScore = helpful * 0.15 - penalties * 0.2;\n var raw = 1 + feedbackScore + recencyBoost + frequencyBoost;\n return Math.max(0.2, Math.min(1.5, raw));\n }\n\n function decayStateFromQuality(multiplier) {\n if (typeof multiplier !== 'number' || Number.isNaN(multiplier)) return 'unknown';\n if (multiplier > 0.7) return 'healthy';\n if (multiplier >= 0.3) return 'decaying';\n return 'stale';\n }\n\n function parseFindings(content) {\n var lines = String(content || '').split(String.fromCharCode(10));\n var findings = [];\n var currentDate = '';\n var inDetails = false;\n lines.forEach(function(line) {\n var trimmed = line.trim();\n if (trimmed.indexOf('<details') === 0) { inDetails = true; return; }\n if (inDetails) {\n if (trimmed.indexOf('</details>') === 0) inDetails = false;\n return;\n }\n if (trimmed.indexOf('## ') === 0) {\n currentDate = trimmed.slice(3).trim();\n return;\n }\n if (trimmed.indexOf('- ') !== 0) return;\n var text = trimmed.slice(2).trim();\n if (!text) return;\n findings.push({ text: text, date: currentDate });\n });\n return findings;\n }\n\n function renderFindingsContent(container, project, content) {\n var findings = parseFindings(content);\n if (!findings.length) {\n container.innerHTML = '<pre>' + esc(content) + '</pre>';\n return;\n }\n loadScores().then(function(scores) {\n return Promise.all(findings.map(function(finding) {\n var short = finding.text.trim().replace(/ +/g, ' ').slice(0, 200);\n return scoreDigest(project + ':FINDINGS.md:' + short).then(function(digest) {\n var key = digest ? (project + '/FINDINGS.md:' + digest) : '';\n var entry = key ? scores[key] : null;\n var multiplier = computeQualityFromEntry(entry);\n var state = decayStateFromQuality(multiplier);\n return {\n text: finding.text,\n date: finding.date,\n key: key,\n multiplier: multiplier,\n state: state,\n };\n });\n }));\n }).then(function(items) {\n var html = '<div style=\"display:flex;gap:8px;padding:14px 14px 0 14px\"><input id=\"finding-add-input\" type=\"text\" placeholder=\"Add a finding\u2026\" style=\"flex:1;border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm);font-family:var(--font)\" onkeydown=\"if(event.key===\\'Enter\\')window.phrenAddFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\')\"><button onclick=\"window.phrenAddFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\')\" style=\"border:1px solid var(--accent);border-radius:var(--radius-sm);padding:4px 12px;background:var(--accent);color:#fff;font-size:var(--text-sm);cursor:pointer;font-family:var(--font)\">Add</button></div>';\n html += '<div class=\"finding-cards\">';\n items.forEach(function(item) {\n var scoreText = typeof item.multiplier === 'number' ? item.multiplier.toFixed(2) : 'n/a';\n var stateText = item.state === 'unknown' ? 'unknown' : item.state;\n var stateClass = item.state === 'unknown' ? 'decaying' : item.state;\n html +=\n '<details class=\"finding-detail-card\">' +\n '<summary>' + esc(item.text) + '</summary>' +\n '<div class=\"finding-detail-meta\">' +\n (item.date ? '<span>Date: ' + esc(item.date) + '</span>' : '') +\n '<span>Quality multiplier: ' + esc(scoreText) + '</span>' +\n '<span><span class=\"finding-score-indicator ' + esc(stateClass) + '\"></span>Decay state: ' + esc(stateText) + '</span>' +\n '<button onclick=\"window.phrenRemoveFinding(\\'' + esc(project).replace(/'/g, \"\\\\'\") + '\\', this)\" data-finding-text=\"' + esc(item.text) + '\" style=\"margin-left:auto;background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;cursor:pointer;color:var(--muted);font-size:11px\">Remove</button>' +\n '</div>' +\n '</details>';\n });\n html += '</div>';\n container.innerHTML = html;\n }).catch(function() {\n container.innerHTML = '<pre>' + esc(content) + '</pre>';\n });\n }\n\n window.loadProjectFile = function(file, btn) {\n if (!_selectedProject) return;\n document.querySelectorAll('.project-detail-tab').forEach(function(b) { b.classList.remove('active'); });\n if (btn) btn.classList.add('active');\n var container = document.getElementById('project-content');\n container.innerHTML = '<div class=\"project-detail-empty\">Loading...</div>';\n if (file === 'reference:browser' && typeof window.phrenLoadProjectReference === 'function') {\n window.phrenLoadProjectReference();\n return;\n }\n fetch(authUrl('/api/project-content?project=' + encodeURIComponent(_selectedProject) + '&file=' + encodeURIComponent(file)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data.ok) {\n container.innerHTML = '<div class=\"project-detail-empty\">' + esc(data.error || 'File not found') + '</div>';\n return;\n }\n if (file === 'FINDINGS.md') {\n renderFindingsContent(container, _selectedProject, data.content);\n return;\n }\n container.innerHTML = '<pre>' + esc(data.content) + '</pre>';\n });\n };\n\n loadProjects();\n loadReviewCards();\n loadReviewActivity();\n var _lastChangeToken = '';\n pollLiveUpdates();\n window.setInterval(pollLiveUpdates, 2000);\n\n // ── Auth helpers ─────────────────────────────────────────────\n function authUrl(base) {\n return base + (base.indexOf('?') === -1 ? '?' : '&') + '_auth=' + encodeURIComponent(_authToken);\n }\n function authBody(body) {\n return body + (_authToken ? '&_auth=' + encodeURIComponent(_authToken) : '');\n }\n\n // ── Finding management ───────────────────────────────────────\n window.phrenAddFinding = function(project) {\n var input = document.getElementById('finding-add-input');\n if (!input || !input.value.trim()) return;\n var text = input.value.trim();\n fetch(authUrl('/api/csrf-token')).then(function(r) { return r.json(); }).then(function(csrfData) {\n var body = new URLSearchParams({ text: text });\n if (csrfData.token) body.set('_csrf', csrfData.token);\n return fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'POST', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { input.value = ''; window.selectProjectFile('FINDINGS.md'); } else { alert(data.error || 'Failed to add finding'); }\n }).catch(function(err) { alert('Error: ' + String(err)); });\n };\n window.phrenRemoveFinding = function(project, btn) {\n var text = btn.getAttribute('data-finding-text');\n if (!text || !confirm('Remove this finding?')) return;\n fetch(authUrl('/api/csrf-token')).then(function(r) { return r.json(); }).then(function(csrfData) {\n var body = new URLSearchParams({ text: text });\n if (csrfData.token) body.set('_csrf', csrfData.token);\n return fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'DELETE', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { window.selectProjectFile('FINDINGS.md'); } else { alert(data.error || 'Failed to remove finding'); }\n }).catch(function(err) { alert('Error: ' + String(err)); });\n };\n\n // ── Skills ───────────────────────────────────────────────────\n function loadSkills() {\n fetch(authUrl('/api/skills')).then(function(r) { return r.json(); }).then(function(data) {\n _skillsLoaded = true;\n var list = document.getElementById('skills-list');\n if (!data.length) { list.innerHTML = '<div style=\"padding:40px 20px;color:var(--muted);text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 8px;display:block\"><path d=\"M40 16l4 12h-8l4-12z\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\" stroke-linejoin=\"round\"/><path d=\"M40 28v20\" stroke=\"var(--accent)\" stroke-width=\"2.5\" stroke-linecap=\"round\"/><path d=\"M34 38h12\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/><path d=\"M36 48l4 12 4-12\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"none\" stroke-linejoin=\"round\"/></svg><div style=\"font-weight:500;font-size:var(--text-base)\">No skills installed</div></div>'; return; }\n var bySource = {};\n data.forEach(function(s) { (bySource[s.source] = bySource[s.source] || []).push(s); });\n var html = '';\n Object.keys(bySource).sort().forEach(function(src) {\n html += '<div class=\"split-group-label\">' + esc(src) + '</div>';\n bySource[src].forEach(function(s) {\n html += '<div class=\"split-item\" data-path=\"' + esc(s.path) + '\" data-name=\"' + esc(s.name) + '\" data-ui-action=\"selectSkillFromEl\">' +\n '<span>' + esc(s.name) + '</span>' +\n '<span class=\"text-muted\" style=\"font-size:var(--text-xs)\">' + esc(s.source) + '</span>' +\n '</div>';\n });\n });\n list.innerHTML = html;\n });\n }\n\n window.selectSkillFromEl = function(el) {\n if (!el) return;\n selectSkill(el.getAttribute('data-path') || '', el, el.getAttribute('data-name') || '');\n };\n\n window.selectSkill = function(filePath, el, name) {\n if (_editingSkill && !confirm('Discard unsaved changes?')) return;\n _editingSkill = false;\n _currentSkillPath = filePath;\n document.querySelectorAll('#skills-list .split-item').forEach(function(i) { i.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var reader = document.getElementById('skills-reader');\n reader.innerHTML = '<div class=\"reader-empty\">Loading...</div>';\n fetch(authUrl('/api/skill-content?path=' + encodeURIComponent(filePath)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n if (!data.ok) { reader.innerHTML = '<div class=\"reader-empty\">' + esc(data.error || 'Error loading file') + '</div>'; return; }\n reader.innerHTML =\n '<div class=\"reader-toolbar\">' +\n '<span class=\"reader-title\">' + esc(name) + '</span>' +\n '<span class=\"reader-path\">' + esc(filePath) + '</span>' +\n '<span id=\"skill-status\"></span>' +\n '<button class=\"btn btn-sm\" data-ui-action=\"editSkill\">Edit</button>' +\n '</div>' +\n '<div class=\"reader-content\"><pre id=\"skill-pre\">' + esc(data.content) + '</pre></div>';\n });\n };\n\n window.editSkill = function() {\n var pre = document.getElementById('skill-pre');\n if (!pre) return;\n _editingSkill = true;\n var content = pre.textContent;\n var toolbar = document.querySelector('#skills-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-sm btn-primary';\n saveBtn.textContent = 'Save';\n saveBtn.onclick = saveSkill;\n var cancelBtn = document.createElement('button');\n cancelBtn.className = 'btn btn-sm';\n cancelBtn.textContent = 'Cancel';\n cancelBtn.onclick = cancelSkillEdit;\n toolbar.appendChild(saveBtn);\n toolbar.appendChild(cancelBtn);\n var ta = document.createElement('textarea');\n ta.id = 'skill-textarea';\n ta.value = content;\n pre.replaceWith(ta);\n ta.focus();\n };\n\n window.cancelSkillEdit = function() {\n _editingSkill = false;\n if (_currentSkillPath) {\n var items = document.querySelectorAll('#skills-list .split-item.selected');\n if (items.length) items[0].click();\n }\n };\n\n window.saveSkill = function() {\n var ta = document.getElementById('skill-textarea');\n if (!ta || !_currentSkillPath) return;\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/skill-save', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('path=' + encodeURIComponent(_currentSkillPath) + '&content=' + encodeURIComponent(ta.value)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) {\n _editingSkill = false;\n setStatus('skill-status', 'Saved', 'ok');\n var pre = document.createElement('pre');\n pre.id = 'skill-pre';\n pre.textContent = ta.value;\n ta.replaceWith(pre);\n var toolbar = document.querySelector('#skills-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var editBtn = document.createElement('button');\n editBtn.className = 'btn btn-sm';\n editBtn.textContent = 'Edit';\n editBtn.onclick = window.editSkill;\n toolbar.appendChild(editBtn);\n } else {\n setStatus('skill-status', data.error || 'Save failed', 'err');\n }\n });\n });\n };\n\n // ── Hooks ────────────────────────────────────────────────────\n function loadHooks() {\n fetch(authUrl('/api/hooks')).then(function(r) { return r.json(); }).then(function(data) {\n _hooksLoaded = true;\n var list = document.getElementById('hooks-list');\n var html = '<div class=\"split-group-label\">Lifecycle Hooks</div>';\n data.tools.forEach(function(t) {\n html += '<div class=\"hook-item\" data-config-path=\"' + esc(t.configPath) + '\" data-tool=\"' + esc(t.tool) + '\" data-exists=\"' + (t.exists ? 'true' : 'false') + '\" data-ui-action=\"selectHookFromEl\">' +\n '<span class=\"hook-name\">' + esc(t.tool) + '</span>' +\n '<span class=\"badge ' + (t.enabled ? 'badge-on' : 'badge-off') + '\">' + (t.enabled ? 'on' : 'off') + '</span>' +\n '</div>';\n });\n if (data.customHooks && data.customHooks.length) {\n html += '<div class=\"split-group-label\">Custom Hooks</div>';\n data.customHooks.forEach(function(ch) {\n html += '<div class=\"split-item\" style=\"cursor:default;flex-direction:column;align-items:flex-start\">' +\n '<div class=\"hook-custom-event\">' + esc(ch.event) + '</div>' +\n '<div class=\"hook-custom-cmd\">' + esc(ch.command) + '</div>' +\n '</div>';\n });\n }\n // Show illustrated empty state if no hook configs exist on disk and no custom hooks\n var anyExists = data.tools.some(function(t) { return t.exists; });\n if (!anyExists && (!data.customHooks || !data.customHooks.length)) {\n html += '<div style=\"padding:40px 20px;color:var(--muted);text-align:center\"><svg viewBox=\"0 0 80 80\" width=\"80\" height=\"80\" fill=\"none\" style=\"margin:0 auto 8px;display:block\"><rect x=\"20\" y=\"28\" width=\"16\" height=\"24\" rx=\"3\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><rect x=\"44\" y=\"28\" width=\"16\" height=\"24\" rx=\"3\" stroke=\"var(--accent)\" stroke-width=\"2\" fill=\"var(--accent-dim)\"/><path d=\"M36 36c4-6 4-6 8 0\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/><path d=\"M36 44c4-6 4-6 8 0\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg><div style=\"font-weight:500;font-size:var(--text-base)\">No hooks configured</div></div>';\n }\n list.innerHTML = html;\n });\n }\n\n window.selectHookFromEl = function(el) {\n if (!el) return;\n selectHook(\n el.getAttribute('data-config-path') || '',\n el,\n el.getAttribute('data-tool') || '',\n el.getAttribute('data-exists') === 'true'\n );\n };\n\n window.selectHook = function(filePath, el, toolName, exists) {\n if (_editingHook && !confirm('Discard unsaved changes?')) return;\n _editingHook = false;\n _currentHookPath = filePath;\n document.querySelectorAll('#hooks-list .hook-item').forEach(function(i) { i.classList.remove('selected'); });\n if (el) el.classList.add('selected');\n var reader = document.getElementById('hooks-reader');\n if (!exists) {\n reader.innerHTML =\n '<div class=\"reader-toolbar\"><span class=\"reader-title\">' + esc(toolName) + '</span><span class=\"reader-path\">' + esc(filePath) + '</span></div>' +\n '<div class=\"reader-empty\">Config file not found. This tool may not be installed or configured.</div>';\n return;\n }\n reader.innerHTML =\n '<div class=\"reader-toolbar\">' +\n '<span class=\"reader-title\">' + esc(toolName) + '</span>' +\n '<span class=\"reader-path\">' + esc(filePath) + '</span>' +\n '<span id=\"hook-status\"></span>' +\n '<button class=\"btn btn-sm\" data-ui-action=\"editHook\">Edit</button>' +\n '<button class=\"btn btn-sm btn-primary\" data-tool=\"' + esc(toolName) + '\" data-ui-action=\"toggleHookToolFromEl\">Toggle</button>' +\n '</div>' +\n '<div class=\"reader-content\"><div class=\"reader-empty\">Loading...</div></div>';\n fetch(authUrl('/api/skill-content?path=' + encodeURIComponent(filePath)))\n .then(function(r) { return r.json(); })\n .then(function(data) {\n var content = reader.querySelector('.reader-content');\n if (!content) return;\n if (!data.ok) { content.innerHTML = '<div class=\"reader-empty\">' + esc(data.error || 'Error loading file') + '</div>'; return; }\n content.innerHTML = '<pre id=\"hook-pre\">' + esc(data.content) + '</pre>';\n });\n };\n\n window.toggleHookToolFromEl = function(el) {\n if (!el) return;\n toggleHookTool(el.getAttribute('data-tool') || '');\n };\n\n window.editHook = function() {\n var pre = document.getElementById('hook-pre');\n if (!pre) return;\n _editingHook = true;\n var toolbar = document.querySelector('#hooks-reader .reader-toolbar');\n var btns = toolbar.querySelectorAll('.btn');\n btns.forEach(function(b) { b.remove(); });\n var saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-sm btn-primary';\n saveBtn.textContent = 'Save';\n saveBtn.onclick = window.saveHook;\n var cancelBtn = document.createElement('button');\n cancelBtn.className = 'btn btn-sm';\n cancelBtn.textContent = 'Cancel';\n cancelBtn.onclick = window.cancelHookEdit;\n toolbar.appendChild(saveBtn);\n toolbar.appendChild(cancelBtn);\n var ta = document.createElement('textarea');\n ta.id = 'hook-textarea';\n ta.value = pre.textContent;\n pre.replaceWith(ta);\n ta.focus();\n };\n\n window.cancelHookEdit = function() {\n _editingHook = false;\n _hooksLoaded = false;\n loadHooks();\n document.getElementById('hooks-reader').innerHTML = '<div class=\"reader-empty\">Select a hook config to view its contents.</div>';\n };\n\n window.saveHook = function() {\n var ta = document.getElementById('hook-textarea');\n if (!ta || !_currentHookPath) return;\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/skill-save', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('path=' + encodeURIComponent(_currentHookPath) + '&content=' + encodeURIComponent(ta.value)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) {\n _editingHook = false;\n setStatus('hook-status', 'Saved', 'ok');\n window.cancelHookEdit();\n } else {\n setStatus('hook-status', data.error || 'Save failed', 'err');\n }\n });\n });\n };\n\n window.toggleHookTool = function(toolName) {\n fetchCsrfToken(function(csrfToken) {\n var csrfPart = csrfToken ? '&_csrf=' + encodeURIComponent(csrfToken) : '';\n fetch('/api/hook-toggle', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: authBody('tool=' + encodeURIComponent(toolName)) + csrfPart\n }).then(function(r) { return r.json(); }).then(function(data) {\n if (data.ok) { _hooksLoaded = false; loadHooks(); }\n });\n });\n };\n\n // ── Graph (Force-directed) ───────────────────────────────────\n var _graphData = null;\n var _graphNodes = [];\n var _graphZoom = 1;\n var _graphPanX = 0, _graphPanY = 0;\n var _graphDrag = null;\n var _graphRunning = false;\n var _graphAlpha = 1;\n var _graphFilter = 'all';\n var _graphProjectFilter = 'all';\n var _graphSourceFilter = 'all';\n var _graphNodeLimit = 300;\n var _graphTotalNodes = 0;\n var _graphListenersAttached = false;\n var _graphSelectedNodeId = '';\n\n var COLORS = { project: '#7B68AE', decision: '#3b82f6', pitfall: '#ef4444', pattern: '#10b981', tradeoff: '#f59e0b', architecture: '#8b5cf6', bug: '#dc2626', 'task-active': '#10b981', 'task-queue': '#5B4B8A', entity: '#3a7bae', reference: '#6b8e7a', other: '#f4a261' };\n var RADII = { project: 18, decision: 8, pitfall: 8, pattern: 8, tradeoff: 8, architecture: 8, bug: 8, 'task-active': 7, 'task-queue': 7, entity: 10, reference: 6, other: 8 };\n\n \n\n \n\n \n\n \n\n \n\n function loadGraph() {\n var url = '/api/graph' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) {\n if (!r.ok) throw new Error('HTTP ' + r.status);\n return r.json();\n }).then(function(data) {\n _graphLoaded = true;\n _graphTotalNodes = data.total || data.nodes.length;\n if (data.nodes.length > _graphNodeLimit) {\n var projectNodes = data.nodes.filter(function(n) { return n.group === 'project'; });\n var findingNodes = data.nodes.filter(function(n) { return n.group !== 'project'; });\n var allowed = Math.max(0, _graphNodeLimit - projectNodes.length);\n var kept = findingNodes.slice(findingNodes.length - allowed);\n var keptSet = {};\n projectNodes.concat(kept).forEach(function(n) { keptSet[n.id] = true; });\n data = { nodes: projectNodes.concat(kept), links: data.links.filter(function(l) { return keptSet[l.source] && keptSet[l.target]; }), total: data.total };\n }\n _graphData = data;\n \n if (window.phrenGraph && window.phrenGraph.mount) { window.phrenGraph.mount(data); } else { console.error('[phren] phrenGraph.mount not available'); }\n }).catch(function(err) {\n _graphLoaded = false;\n var canvas = document.getElementById('graph-canvas');\n if (canvas) {\n var ctx = canvas.getContext('2d');\n var w = canvas.clientWidth, h = canvas.clientHeight;\n ctx.fillStyle = '#ef4444';\n ctx.font = '14px system-ui';\n ctx.textAlign = 'center';\n ctx.fillText('Graph failed to load: ' + err.message, w/2, h/2);\n }\n });\n }\n\n /* initGraph removed — handled by phrenGraph.mount in memory-ui-graph.ts */\n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n // ── Utils ────────────────────────────────────────────────────\n function setStatus(id, msg, type) {\n var el = document.getElementById(id);\n if (!el) return;\n el.className = 'status-msg' + (type ? ' ' + type : '');\n el.textContent = msg;\n if (msg) setTimeout(function() { el.textContent = ''; el.className = ''; }, 3000);\n }\n\n function esc(s) {\n return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"');\n }\n\n // ── Dark mode ────────────────────────────────────────────────\n (function initTheme() {\n var saved = localStorage.getItem('phren-theme');\n var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;\n var theme = saved || (prefersDark ? 'dark' : 'light');\n document.documentElement.setAttribute('data-theme', theme);\n var btn = document.getElementById('theme-toggle');\n if (btn) btn.textContent = theme === 'dark' ? '🌙' : '☀️';\n })();\n\n window.toggleTheme = function() {\n var current = document.documentElement.getAttribute('data-theme') || 'light';\n var next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('phren-theme', next);\n var btn = document.getElementById('theme-toggle');\n if (btn) btn.textContent = next === 'dark' ? '🌙' : '☀️';\n };\n\n // ── Toast system ─────────────────────────────────────────────\n function showToast(msg, type) {\n var container = document.getElementById('toast-container');\n if (!container) return;\n var toast = document.createElement('div');\n toast.className = 'toast' + (type ? ' ' + type : '');\n toast.textContent = msg;\n container.appendChild(toast);\n setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 3000);\n }\n\n var _pendingUndo = null;\n\n function showUndoToast(action, onUndo, onCommit) {\n var container = document.getElementById('toast-container');\n if (!container) return;\n var toast = document.createElement('div');\n toast.className = 'toast';\n var label = document.createElement('span');\n label.textContent = action.charAt(0).toUpperCase() + action.slice(1) + 'd memory';\n var undoBtn = document.createElement('button');\n undoBtn.className = 'toast-undo';\n undoBtn.textContent = 'Undo';\n toast.appendChild(label);\n toast.appendChild(undoBtn);\n container.appendChild(toast);\n\n var committed = false;\n var timer = setTimeout(function() {\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onCommit();\n }, 5000);\n\n undoBtn.onclick = function() {\n if (committed) return;\n clearTimeout(timer);\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onUndo();\n };\n\n _pendingUndo = {\n execute: function() {\n if (committed) return;\n clearTimeout(timer);\n committed = true;\n if (toast.parentNode) toast.parentNode.removeChild(toast);\n onCommit();\n }\n };\n }\n\n // ── Review fetch actions ──────────────────────────────────────\n function fetchCsrfToken(cb) {\n var url = '/api/csrf-token' + (_authToken ? '?_auth=' + encodeURIComponent(_authToken) : '');\n fetch(url).then(function(r) { return r.json(); }).then(function(d) { cb(d.token || null); }).catch(function() { cb(null); });\n }\n\n // ── Batch actions ─────────────────────────────────────────────\n function getCheckedCards() {\n return Array.from(document.querySelectorAll('.review-card-check.checked')).map(function(cb) {\n return cb.closest('.review-card');\n }).filter(Boolean);\n }\n\n function updateBatchBar() {\n var checked = getCheckedCards();\n var bar = document.getElementById('batch-bar');\n var count = document.getElementById('batch-count');\n if (!bar || !count) return;\n count.textContent = checked.length + ' selected';\n bar.classList.toggle('visible', checked.length > 0);\n }\n\n window.toggleSelectAll = function(checked) {\n document.querySelectorAll('.review-card').forEach(function(card) {\n if (card.style.display === 'none') return;\n var cb = card.querySelector('.review-card-check');\n if (!cb) return;\n if (checked) cb.classList.add('checked');\n else cb.classList.remove('checked');\n });\n updateBatchBar();\n };\n\n window.clearBatchSelection = function() {\n document.querySelectorAll('.review-card-check.checked').forEach(function(cb) {\n cb.classList.remove('checked');\n });\n var selectAllCb = document.querySelector('#review-select-all input[type=\"checkbox\"]');\n if (selectAllCb) selectAllCb.checked = false;\n updateBatchBar();\n };\n\n\n // -- Similarity grouping --\n function wordSet(text) {\n return new Set(text.toLowerCase().replace(/[^a-z0-9\\s]/g, '').split(/\\s+/).filter(function(w) { return w.length > 2; }));\n }\n\n function jaccardSimilarity(a, b) {\n var intersection = 0;\n a.forEach(function(w) { if (b.has(w)) intersection++; });\n var union = new Set(Array.from(a).concat(Array.from(b))).size;\n return union === 0 ? 0 : intersection / union;\n }\n\n function clusterCards(items) {\n if (items.length <= 1) return [items];\n var THRESHOLD = 0.25;\n var wordSets = items.map(function(item) { return wordSet(item.text || ''); });\n var assigned = new Array(items.length).fill(-1);\n var groups = [];\n\n for (var i = 0; i < items.length; i++) {\n if (assigned[i] !== -1) continue;\n var group = [i];\n assigned[i] = groups.length;\n for (var j = i + 1; j < items.length; j++) {\n if (assigned[j] !== -1) continue;\n if (jaccardSimilarity(wordSets[i], wordSets[j]) > THRESHOLD) {\n group.push(j);\n assigned[j] = groups.length;\n }\n }\n groups.push(group);\n }\n return groups.map(function(indices) {\n return indices.map(function(idx) { return items[idx]; });\n });\n }\n\n window.toggleGroup = function(btn) {\n var group = btn.closest('.review-group');\n if (group) group.classList.toggle('collapsed');\n btn.textContent = group && group.classList.contains('collapsed') ? '\\u25B6' : '\\u25BC';\n };\n\n window.batchAction = function(action) {\n var cards = getCheckedCards();\n if (!cards.length) return;\n var remaining = cards.length;\n cards.forEach(function(card) {\n var project = card.getAttribute('data-project');\n var key = card.getAttribute('data-key');\n var line = key ? key.split('\\\\x00')[1] : '';\n if (!project || !line) return;\n card.classList.add('removing');\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/' + action, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n remaining--;\n if (d.ok) {\n setTimeout(function() { if (card.parentNode) card.remove(); }, 300);\n } else {\n card.classList.remove('removing');\n }\n if (remaining <= 0) {\n updateBatchBar();\n setTimeout(loadReviewCards, 500);\n }\n }).catch(function() {\n remaining--;\n card.classList.remove('removing');\n if (remaining <= 0) updateBatchBar();\n });\n });\n });\n showToast(action.charAt(0).toUpperCase() + action.slice(1) + 'd ' + cards.length + ' memories', 'ok');\n };\n\n function removeCard(card, action, project, line, text) {\n card.classList.add('removing');\n // Clear any pending undo for a different card\n if (_pendingUndo) {\n _pendingUndo.execute();\n _pendingUndo = null;\n }\n showUndoToast(action, function undo() {\n // Cancel: re-show the card\n card.classList.remove('removing');\n _pendingUndo = null;\n }, function commit() {\n // Execute: actually call the server\n _pendingUndo = null;\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/' + action, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n if (d.ok) {\n card.remove();\n updateReviewCount(-1);\n setTimeout(loadReviewCards, 500);\n } else {\n card.classList.remove('removing');\n showToast('Error: ' + (d.error || 'Unknown error'), 'err');\n }\n }).catch(function() {\n card.classList.remove('removing');\n showToast('Network error', 'err');\n });\n });\n });\n }\n\n window.reviewActionFromEl = function(btn, action) {\n if (!btn) return;\n window.reviewAction(btn, action, btn.getAttribute('data-project') || '', btn.getAttribute('data-line') || '');\n };\n\n window.reviewAction = function(btn, action, project, line) {\n var card = btn.closest('.review-card');\n if (!card) return;\n var text = card.querySelector('.review-card-text') ? card.querySelector('.review-card-text').textContent : '';\n removeCard(card, action, project, line, text);\n };\n\n window.reviewEditSubmitFromEl = function(e, form) {\n if (!form) return;\n window.reviewEditSubmit(e, form.getAttribute('data-project') || '', form.getAttribute('data-line') || '');\n };\n\n window.reviewEditSubmit = function(e, project, line) {\n e.preventDefault();\n var form = e.target;\n var ta = form.querySelector('textarea[name=\"new_text\"]');\n if (!ta) return;\n var newText = ta.value;\n var card = form.closest('.review-card');\n fetchCsrfToken(function(csrfToken) {\n var body = 'project=' + encodeURIComponent(project) + '&line=' + encodeURIComponent(line) + '&new_text=' + encodeURIComponent(newText);\n if (_authToken) body += '&_auth=' + encodeURIComponent(_authToken);\n if (csrfToken) body += '&_csrf=' + encodeURIComponent(csrfToken);\n ledFetch('/api/edit', {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body\n }).then(function(r) { return r.json(); }).then(function(d) {\n if (d.ok) {\n var textEl = card ? card.querySelector('.review-card-text') : null;\n if (textEl) textEl.innerHTML = esc(newText).replace(/\\n/g, '<br>');\n var editSection = card ? card.querySelector('.review-card-edit') : null;\n if (editSection) editSection.style.display = 'none';\n showToast('Saved', 'ok');\n } else {\n showToast('Error: ' + (d.error || 'Unknown error'), 'err');\n }\n }).catch(function() { showToast('Network error', 'err'); });\n });\n };\n\n function animateCount(el, from, to) {\n if (from === to) return;\n var diff = Math.abs(from - to);\n if (diff <= 3) {\n // Cycle through intermediate values at 80ms intervals\n var step = from < to ? 1 : -1;\n var current = from;\n var interval = setInterval(function() {\n current += step;\n el.textContent = String(current);\n if (current === to) clearInterval(interval);\n }, 80);\n } else {\n // Quick scale-and-flip CSS animation\n el.classList.remove('count-flipping');\n void el.offsetWidth;\n el.classList.add('count-flipping');\n setTimeout(function() { el.textContent = String(to); }, 120);\n setTimeout(function() { el.classList.remove('count-flipping'); }, 350);\n }\n }\n\n function updateReviewCount(delta) {\n var navBtn = document.querySelector('.nav-item[data-tab=\"review\"] .count');\n if (!navBtn) return;\n var current = parseInt(navBtn.textContent, 10) || 0;\n var next = current + delta;\n if (next <= 0) {\n navBtn.classList.add('count-flipping');\n setTimeout(function() { navBtn.remove(); }, 300);\n } else {\n animateCount(navBtn, current, next);\n }\n }\n\n // ── Keyboard shortcuts ────────────────────────────────────────\n var _focusedCard = null;\n\n function getVisibleCards() {\n return Array.from(document.querySelectorAll('.review-card')).filter(function(c) { return c.style.display !== 'none'; });\n }\n\n function focusCard(card) {\n if (_focusedCard) { _focusedCard.style.outline = ''; _focusedCard.style.outlineOffset = ''; }\n _focusedCard = card;\n if (card) {\n card.style.outline = '2px solid var(--accent)';\n card.style.outlineOffset = '2px';\n card.style.borderRadius = 'var(--radius)';\n card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n }\n }\n\n document.addEventListener('keydown', function(e) {\n // Ignore if typing in an input/textarea\n var tag = document.activeElement && document.activeElement.tagName;\n if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;\n\n var activeTab = document.querySelector('.tab-content.active');\n var isReviewTab = activeTab && activeTab.id === 'tab-review';\n var isProjectsTab = activeTab && activeTab.id === 'tab-projects';\n\n // / → focus project search\n if (e.key === '/' && isProjectsTab) {\n e.preventDefault();\n var search = document.getElementById('projects-search');\n if (search) search.focus();\n return;\n }\n\n if (!isReviewTab) return;\n\n var cards = getVisibleCards();\n if (!cards.length) return;\n\n if (e.key === 'j' || e.key === 'ArrowDown') {\n e.preventDefault();\n var idx = _focusedCard ? cards.indexOf(_focusedCard) : -1;\n focusCard(cards[Math.min(idx + 1, cards.length - 1)]);\n } else if (e.key === 'k' || e.key === 'ArrowUp') {\n e.preventDefault();\n var idx2 = _focusedCard ? cards.indexOf(_focusedCard) : cards.length;\n focusCard(cards[Math.max(idx2 - 1, 0)]);\n } else if (e.key === 'a' && _focusedCard) {\n var approveBtn = _focusedCard.querySelector('.btn-approve');\n if (approveBtn) approveBtn.click();\n } else if (e.key === 'r' && _focusedCard) {\n var rejectBtn = _focusedCard.querySelector('.btn-reject');\n if (rejectBtn) rejectBtn.click();\n } else if (e.key === 'e' && _focusedCard) {\n var editBtn = _focusedCard.querySelector('.review-card-actions .btn:not(.btn-approve):not(.btn-reject)');\n if (editBtn) editBtn.click();\n } else if (e.key === 'Escape') {\n if (_focusedCard) {\n var editSection = _focusedCard.querySelector('.review-card-edit');\n if (editSection && editSection.style.display !== 'none') {\n toggleReviewEdit(_focusedCard.querySelector('.review-card-actions .btn:not(.btn-approve):not(.btn-reject)'));\n }\n }\n }\n });\n\n // ── Command palette ───────────────────────────────────────────\n var _cmdpalSelected = -1;\n\n window.openCmdPal = function() {\n var overlay = document.getElementById('cmdpal');\n if (!overlay) return;\n overlay.classList.add('open');\n var input = document.getElementById('cmdpal-input');\n if (input) { input.value = ''; input.focus(); }\n cmdpalSearch('');\n };\n\n window.closeCmdPal = function(e) {\n if (e && e.target !== document.getElementById('cmdpal')) return;\n var overlay = document.getElementById('cmdpal');\n if (overlay) overlay.classList.remove('open');\n };\n\n window.cmdpalSearch = function(query) {\n _cmdpalSelected = -1;\n var results = document.getElementById('cmdpal-results');\n if (!results) return;\n var q = query.toLowerCase();\n var matches = _projectData.filter(function(p) {\n return !q || p.name.toLowerCase().indexOf(q) !== -1 || (p.summaryText || '').toLowerCase().indexOf(q) !== -1;\n }).slice(0, 8);\n if (!matches.length) {\n results.innerHTML = '<div class=\"cmdpal-empty\">No projects found</div>';\n return;\n }\n results.innerHTML = matches.map(function(p, i) {\n return '<div class=\"cmdpal-item\" data-name=\"' + esc(p.name) + '\" data-ui-action=\"cmdpalSelect\">' +\n '<span class=\"cmdpal-item-name\">' + esc(p.name) + '</span>' +\n '<span class=\"cmdpal-item-meta\">' + p.findingCount + ' findings</span>' +\n '</div>';\n }).join('');\n };\n\n window.cmdpalKey = function(e) {\n var items = document.querySelectorAll('.cmdpal-item');\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n _cmdpalSelected = Math.min(_cmdpalSelected + 1, items.length - 1);\n items.forEach(function(el, i) { el.classList.toggle('selected', i === _cmdpalSelected); });\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n _cmdpalSelected = Math.max(_cmdpalSelected - 1, 0);\n items.forEach(function(el, i) { el.classList.toggle('selected', i === _cmdpalSelected); });\n } else if (e.key === 'Enter') {\n if (_cmdpalSelected >= 0 && items[_cmdpalSelected]) {\n var name = items[_cmdpalSelected].getAttribute('data-name');\n if (name) cmdpalSelect(name);\n }\n } else if (e.key === 'Escape') {\n document.getElementById('cmdpal').classList.remove('open');\n }\n };\n\n window.cmdpalSelect = function(name) {\n document.getElementById('cmdpal').classList.remove('open');\n switchTab('projects');\n setTimeout(function() {\n var card = document.querySelector('.project-card[data-project=\"' + name + '\"]');\n if (card) card.click();\n else {\n var search = document.getElementById('projects-search');\n if (search) { search.value = name; filterProjects(name); }\n }\n }, 200);\n };\n\n // Cmd+K / Ctrl+K to open\n document.addEventListener('keydown', function(e) {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n var overlay = document.getElementById('cmdpal');\n if (overlay && overlay.classList.contains('open')) {\n overlay.classList.remove('open');\n } else {\n openCmdPal();\n }\n }\n });\n// Event delegation for dynamically generated UI elements\n document.addEventListener('click', function(e) {\n var target = e.target;\n if (!target || typeof target.closest !== 'function') return;\n var actionEl = target.closest('[data-ui-action]');\n if (!actionEl) return;\n var action = actionEl.getAttribute('data-ui-action');\n if (action === 'selectProject') {\n selectProject(actionEl.closest('.project-card').getAttribute('data-project'), actionEl.closest('.project-card'));\n } else if (action === 'toggleStar') {\n e.stopPropagation();\n var card = actionEl.closest('.project-card');\n toggleStar(card ? card.getAttribute('data-project') : '');\n } else if (action === 'stopProp') {\n e.stopPropagation();\n } else if (action === 'reviewAction') {\n reviewActionFromEl(actionEl, actionEl.getAttribute('data-review-type'));\n } else if (action === 'toggleReviewEdit') {\n toggleReviewEdit(actionEl);\n } else if (action === 'toggleGroup') {\n toggleGroup(actionEl);\n } else if (action === 'loadProjectFile') {\n loadProjectFile(actionEl.getAttribute('data-file'), actionEl);\n } else if (action === 'selectSkillFromEl') {\n selectSkillFromEl(actionEl.closest('.split-item'));\n } else if (action === 'editSkill') {\n editSkill();\n } else if (action === 'selectHookFromEl') {\n selectHookFromEl(actionEl.closest('.split-item'));\n } else if (action === 'editHook') {\n editHook();\n } else if (action === 'toggleHookToolFromEl') {\n toggleHookToolFromEl(actionEl);\n } else if (action === 'cmdpalSelect') {\n cmdpalSelect(actionEl.closest('.cmdpal-item').getAttribute('data-name'));\n }\n });\n document.addEventListener('submit', function(e) {\n var form = e.target;\n if (!form || typeof form.getAttribute !== 'function') return;\n if (form.getAttribute('data-ui-action') === 'reviewEditSubmit') {\n e.preventDefault();\n reviewEditSubmitFromEl(e, form);\n }\n });\n})();\n";
|
|
3
|
+
export function renderWebUiScript(authToken) {
|
|
4
|
+
return WEB_UI_SCRIPT_TEMPLATE.replace("__PHREN_AUTH_TOKEN_1d7b7c99__", authToken);
|
|
5
|
+
}
|
|
6
|
+
// Static review UI payloads live here so the page wrapper only owns HTML scaffolding and sync-state interpolation.
|