@jefuriiij/synthra 0.2.1 → 0.3.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/CHANGELOG.md +42 -0
- package/dist/cli/index.js +520 -267
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +151 -29
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +279 -93
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/dashboard/server.ts","../../package.json","../../src/shared/logger.ts","../../src/server/port.ts","../../src/dashboard/delta.ts","../../src/shared/paths.ts","../../src/shared/pricing.ts","../../src/shared/project-registry.ts","../../src/dashboard/public/index.html","../../src/dashboard/public/style.css"],"sourcesContent":["// Standalone dashboard server. Default port 8901 (override via\n// SYN_DASHBOARD_PORT); falls back through a small range 8901–8910 if the\n// preferred port is busy (so we can coexist with other co-installed\n// AI-context tools that also expose a dashboard).\n// Reads .synthra-graph/token_log.jsonl + .synthra-graph/gate_log.jsonl for the\n// given project and renders a live SPA backed by GET /data polled every 2s.\n\nimport { serve } from \"@hono/node-server\";\nimport { Hono } from \"hono\";\n\n// Tsup inlines this import at build time so `c.html` can echo whatever\n// version is running. Replaces the v__SYN_VERSION__ placeholder in the\n// dashboard footer on every GET /.\nimport pkgJson from \"../../package.json\" with { type: \"json\" };\n\nimport { log } from \"../shared/logger.js\";\nimport type { SynthraPaths } from \"../shared/paths.js\";\nimport { findFreePort } from \"../server/port.js\";\nimport { computeDashboardData } from \"./delta.js\";\n\nimport indexHtml from \"./public/index.html\";\nimport styleCss from \"./public/style.css\";\n\nconst FALLBACK_RANGE = 9; // try preferredPort + [0..9]\nconst VERSION = (pkgJson as { version: string }).version;\n// How many recent turns/gates the /data payload carries. The dashboard\n// paginates turns client-side (25/page); the donut uses the uncapped\n// per-project model aggregate, so it isn't bounded by this.\nconst RECENT_N = Number(process.env.SYN_DASHBOARD_RECENT_N) || 500;\n\nexport interface DashboardServerHandle {\n port: number;\n url: string;\n stop(): Promise<void>;\n}\n\nexport async function startDashboard(\n paths: SynthraPaths,\n preferredPort = 8901,\n): Promise<DashboardServerHandle> {\n const port = await findFreePort(preferredPort, preferredPort + FALLBACK_RANGE);\n if (port !== preferredPort) {\n log.info(\n `dashboard port ${preferredPort} was busy — bound to ${port} instead (likely another dashboard from a coexisting tool).`,\n );\n }\n const app = new Hono();\n\n app.get(\"/\", (c) => c.html(indexHtml.replaceAll(\"__SYN_VERSION__\", VERSION)));\n\n app.get(\"/style.css\", (c) => {\n c.header(\"Content-Type\", \"text/css; charset=utf-8\");\n c.header(\"Cache-Control\", \"no-cache\");\n return c.body(styleCss);\n });\n\n app.get(\"/health\", (c) => c.json({ ok: true }));\n\n app.get(\"/data\", async (c) => {\n const data = await computeDashboardData(paths, RECENT_N);\n return c.json(data);\n });\n\n const nodeServer = serve({ fetch: app.fetch, port, hostname: \"127.0.0.1\" });\n\n return {\n port,\n url: `http://127.0.0.1:${port}`,\n async stop() {\n await new Promise<void>((resolve, reject) => {\n nodeServer.close((err) => (err ? reject(err) : resolve()));\n });\n },\n };\n}\n","{\n \"name\": \"@jefuriiij/synthra\",\n \"version\": \"0.2.1\",\n \"publishConfig\": {\n \"access\": \"public\"\n },\n \"description\": \"Local context engine for AI coding assistants — graph-based context, branch-aware memory, real-time human-activity awareness, deterministic Grep/Glob gating, and a live token dashboard.\",\n \"type\": \"module\",\n \"bin\": {\n \"syn\": \"bin/syn\",\n \"synthra\": \"bin/syn\"\n },\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"test:coverage\": \"vitest run --coverage\",\n \"typecheck\": \"tsc --noEmit\",\n \"lint\": \"biome lint .\",\n \"format\": \"biome format --write .\",\n \"check\": \"biome check .\",\n \"check:fix\": \"biome check --write .\"\n },\n \"files\": [\n \"dist\",\n \"bin\",\n \"README.md\",\n \"CHANGELOG.md\",\n \"LICENSE\",\n \"ROADMAP.md\"\n ],\n \"keywords\": [\n \"claude-code\",\n \"mcp\",\n \"context-engine\",\n \"code-graph\",\n \"ai-coding\",\n \"token-savings\"\n ],\n \"author\": \"Jeff (@jefuriiij)\",\n \"license\": \"MIT\",\n \"homepage\": \"https://github.com/jefuriiij/synthra#readme\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/jefuriiij/synthra.git\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/jefuriiij/synthra/issues\"\n },\n \"engines\": {\n \"node\": \">=18\"\n },\n \"dependencies\": {\n \"@hono/node-server\": \"^1.18.0\",\n \"chokidar\": \"^5.0.0\",\n \"cross-spawn\": \"^7.0.6\",\n \"hono\": \"^4.12.23\",\n \"ignore\": \"^7.0.0\",\n \"sade\": \"^1.8.1\",\n \"tree-sitter-wasms\": \"^0.1.12\",\n \"web-tree-sitter\": \"^0.25.10\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.16\",\n \"@types/cross-spawn\": \"^6.0.6\",\n \"@types/node\": \"^25.9.1\",\n \"@vitest/coverage-v8\": \"^4.1.8\",\n \"tsup\": \"^8.5.1\",\n \"typescript\": \"^6.0.3\",\n \"vitest\": \"^4.1.7\"\n }\n}\n","// Minimal logger. Prefixes Synthra output with [syn].\n\ntype Level = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LEVEL_PRIORITY: Record<Level, number> = {\n debug: 10,\n info: 20,\n warn: 30,\n error: 40,\n};\n\nlet activeLevel: Level = (process.env.SYN_LOG_LEVEL as Level) ?? \"info\";\n\nexport function setLevel(level: Level): void {\n activeLevel = level;\n}\n\nfunction shouldLog(level: Level): boolean {\n return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[activeLevel];\n}\n\nfunction emit(level: Level, msg: string, ...args: unknown[]): void {\n if (!shouldLog(level)) return;\n const stream = level === \"error\" || level === \"warn\" ? process.stderr : process.stdout;\n stream.write(`[syn] ${msg}${args.length ? \" \" + args.map(String).join(\" \") : \"\"}\\n`);\n}\n\nexport const log = {\n debug: (m: string, ...a: unknown[]) => emit(\"debug\", m, ...a),\n info: (m: string, ...a: unknown[]) => emit(\"info\", m, ...a),\n warn: (m: string, ...a: unknown[]) => emit(\"warn\", m, ...a),\n error: (m: string, ...a: unknown[]) => emit(\"error\", m, ...a),\n};\n","// Finds a free port in the 8080–8099 range. Writes the chosen port to\n// .synthra-graph/mcp_port so PowerShell/Bash hook scripts can read it.\n// TODO: M2\n\nimport { createServer } from \"node:net\";\n\nexport const PORT_RANGE_START = 8080;\nexport const PORT_RANGE_END = 8099;\n\nexport async function findFreePort(\n start = PORT_RANGE_START,\n end = PORT_RANGE_END,\n): Promise<number> {\n for (let port = start; port <= end; port++) {\n if (await isFree(port)) return port;\n }\n throw new Error(`Synthra: no free port in ${start}-${end}`);\n}\n\nfunction isFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = createServer();\n s.once(\"error\", () => resolve(false));\n s.once(\"listening\", () => s.close(() => resolve(true)));\n s.listen(port, \"127.0.0.1\");\n });\n}\n","// Reads token_log.jsonl + gate_log.jsonl for the active project AND every\n// project registered in ~/.synthra/projects.json, then computes the\n// dashboard's rendered shape: per-project + global aggregate + recent calls\n// across all projects.\n\nimport { readFile } from \"node:fs/promises\";\n\nimport { resolvePaths, type SynthraPaths } from \"../shared/paths.js\";\nimport { estimateCostUsd } from \"../shared/pricing.js\";\nimport { listProjects } from \"../shared/project-registry.js\";\n\nconst AVG_TOKENS_PER_BLOCKED_GREP = 500;\n\nexport interface TokenLogEntry {\n /** Stop-hook-supplied timestamp (preferred). */\n ts?: string;\n /** Server-side fallback added by handleLog when ts isn't provided. */\n written_at?: string;\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n model: string;\n description?: string;\n project: string;\n}\n\nexport interface GateLogEntry {\n ts: string;\n tool: string;\n decision: \"allow\" | \"block\";\n query: string | null;\n reason?: string;\n}\n\nexport interface ToolLogEntry {\n ts?: string;\n /** Synthra MCP tool name, e.g. \"graph_continue\", \"graph_read\". */\n tool: string;\n}\n\n/** Count Synthra MCP tool calls by tool name. (#2) */\nexport function countToolCalls(entries: ToolLogEntry[]): Record<string, number> {\n const out: Record<string, number> = {};\n for (const e of entries) {\n if (!e.tool) continue;\n out[e.tool] = (out[e.tool] ?? 0) + 1;\n }\n return out;\n}\n\nexport interface ProjectStats {\n path: string;\n name: string;\n last_seen: string | null;\n total_turns: number;\n total_input_tokens: number;\n total_output_tokens: number;\n total_cache_read: number;\n total_cache_create: number;\n total_gate_calls: number;\n blocked_count: number;\n estimated_tokens_saved: number;\n estimated_cost_usd: number;\n total_tool_calls: number;\n tool_calls: Record<string, number>;\n models: Record<string, number>;\n}\n\nexport interface RecentTurn {\n ts: string;\n project_name: string;\n project_path: string;\n input: number;\n output: number;\n cache_read: number;\n cache_create: number;\n model: string;\n cost_usd: number;\n}\n\nexport interface RecentGate {\n ts: string;\n project_name: string;\n project_path: string;\n tool: string;\n decision: \"allow\" | \"block\";\n query: string | null;\n}\n\nexport interface DashboardData {\n active: {\n project_root: string;\n project_name: string;\n stats: ProjectStats;\n };\n global: {\n project_count: number;\n total_turns: number;\n total_input_tokens: number;\n total_output_tokens: number;\n total_cache_read: number;\n total_cache_create: number;\n total_gate_calls: number;\n blocked_count: number;\n estimated_tokens_saved: number;\n saved_percent: number;\n estimated_cost_usd: number;\n total_tool_calls: number;\n tool_calls: Record<string, number>;\n };\n projects: ProjectStats[];\n recent_turns: RecentTurn[];\n recent_gates: RecentGate[];\n}\n\nasync function readJsonl<T>(path: string): Promise<T[]> {\n try {\n const text = await readFile(path, \"utf8\");\n return text\n .split(/\\r?\\n/)\n .filter((l) => l.length > 0)\n .map((l) => {\n try {\n return JSON.parse(l) as T;\n } catch {\n return null;\n }\n })\n .filter((v): v is T => v !== null);\n } catch {\n return [];\n }\n}\n\nfunction basename(p: string): string {\n const parts = p.split(/[\\\\/]/);\n return parts[parts.length - 1] || p;\n}\n\ninterface ProjectFiles {\n path: string;\n name: string;\n last_seen: string | null;\n tokens: TokenLogEntry[];\n gates: GateLogEntry[];\n tools: ToolLogEntry[];\n}\n\nfunction summarize(p: ProjectFiles): ProjectStats {\n let totalIn = 0;\n let totalOut = 0;\n let totalCacheRead = 0;\n let totalCacheCreate = 0;\n let costUsd = 0;\n const models: Record<string, number> = {};\n\n for (const t of p.tokens) {\n totalIn += t.input_tokens ?? 0;\n totalOut += t.output_tokens ?? 0;\n totalCacheRead += t.cache_read_input_tokens ?? 0;\n totalCacheCreate += t.cache_creation_input_tokens ?? 0;\n costUsd += estimateCostUsd(t);\n if (t.model) models[t.model] = (models[t.model] ?? 0) + 1;\n }\n\n const blocked = p.gates.filter((g) => g.decision === \"block\").length;\n const saved = blocked * AVG_TOKENS_PER_BLOCKED_GREP;\n\n return {\n path: p.path,\n name: p.name,\n last_seen: p.last_seen,\n total_turns: p.tokens.length,\n total_input_tokens: totalIn,\n total_output_tokens: totalOut,\n total_cache_read: totalCacheRead,\n total_cache_create: totalCacheCreate,\n total_gate_calls: p.gates.length,\n blocked_count: blocked,\n estimated_tokens_saved: saved,\n estimated_cost_usd: Math.round(costUsd * 100) / 100,\n total_tool_calls: p.tools.length,\n tool_calls: countToolCalls(p.tools),\n models,\n };\n}\n\n// Dedupe is ON by default; set SYN_DASHBOARD_DEDUPE=0 (or \"off\"/\"false\") to see\n// every raw token-log entry — including duplicates a co-installed AI tool's Stop\n// hook may write to the same token_log.jsonl. (#7)\nfunction dedupeEnabled(): boolean {\n const v = process.env.SYN_DASHBOARD_DEDUPE;\n return v !== \"0\" && v !== \"off\" && v !== \"false\";\n}\n\nasync function loadProjectFiles(\n path: string,\n name: string,\n lastSeen: string | null,\n): Promise<ProjectFiles> {\n const paths = resolvePaths(path);\n const [rawTokens, gates, tools] = await Promise.all([\n readJsonl<TokenLogEntry>(paths.tokenLog),\n readJsonl<GateLogEntry>(paths.gateLog),\n readJsonl<ToolLogEntry>(paths.toolLog),\n ]);\n const tokens = dedupeEnabled() ? dedupeTokens(rawTokens) : rawTokens;\n return { path, name, last_seen: lastSeen, tokens, gates, tools };\n}\n\n/**\n * Collapse duplicate token-log entries from co-installed AI tools.\n *\n * Synthra is friendly with other tools that share the .synthra-graph/\n * token_log.jsonl shape — if a second tool's Stop hook also writes to\n * it, both fire on the same turn and emit nearly-identical entries\n * within ~10ms, double-counting every metric in the dashboard.\n *\n * Strategy: group by (project, usage counts, second-rounded timestamp);\n * inside a group, keep the entry with the most credible model field —\n * a real Claude model > \"<synthetic>\" > empty.\n */\nfunction dedupeTokens(entries: TokenLogEntry[]): TokenLogEntry[] {\n const score = (model: string | undefined): number => {\n if (!model) return 0;\n if (model === \"<synthetic>\") return 1;\n return 2; // real model name\n };\n\n const groups = new Map<string, TokenLogEntry[]>();\n for (const e of entries) {\n const ts = e.ts ?? e.written_at ?? \"\";\n const second = ts.slice(0, 19); // YYYY-MM-DDTHH:mm:ss\n const key = [\n e.project ?? \"\",\n e.input_tokens ?? 0,\n e.output_tokens ?? 0,\n e.cache_creation_input_tokens ?? 0,\n e.cache_read_input_tokens ?? 0,\n second,\n ].join(\"|\");\n const arr = groups.get(key) ?? [];\n arr.push(e);\n groups.set(key, arr);\n }\n\n const out: TokenLogEntry[] = [];\n for (const arr of groups.values()) {\n if (arr.length === 1) {\n out.push(arr[0]!);\n continue;\n }\n arr.sort((a, b) => score(b.model) - score(a.model));\n out.push(arr[0]!);\n }\n\n // Preserve chronological order in the per-project list.\n out.sort((a, b) => {\n const at = a.ts ?? a.written_at ?? \"\";\n const bt = b.ts ?? b.written_at ?? \"\";\n return at.localeCompare(bt);\n });\n return out;\n}\n\nexport async function computeDashboardData(\n activePaths: SynthraPaths,\n recentN = 500,\n): Promise<DashboardData> {\n const registered = await listProjects();\n\n // Always include the active project, even if not yet in the registry.\n const activePath = activePaths.projectRoot;\n const activeName = basename(activePath);\n const knownPaths = new Set(registered.map((p) => p.path));\n const allEntries: Array<{ path: string; name: string; last_seen: string | null }> = [\n ...registered.map((p) => ({ path: p.path, name: p.name, last_seen: p.last_seen })),\n ];\n if (!knownPaths.has(activePath)) {\n allEntries.unshift({ path: activePath, name: activeName, last_seen: null });\n }\n\n const loaded = await Promise.all(\n allEntries.map((e) => loadProjectFiles(e.path, e.name, e.last_seen)),\n );\n\n const projects = loaded\n .map(summarize)\n .sort(\n (a, b) =>\n b.total_input_tokens +\n b.total_output_tokens -\n (a.total_input_tokens + a.total_output_tokens),\n );\n\n const activeFiles = loaded.find((p) => p.path === activePath) ?? {\n path: activePath,\n name: activeName,\n last_seen: null,\n tokens: [],\n gates: [],\n tools: [],\n };\n const activeStats = summarize(activeFiles);\n\n // Global aggregates\n let g_in = 0,\n g_out = 0,\n g_cr = 0,\n g_cc = 0,\n g_gate = 0,\n g_block = 0,\n g_cost = 0,\n g_turns = 0,\n g_tools = 0;\n const g_tool_calls: Record<string, number> = {};\n for (const s of projects) {\n g_turns += s.total_turns;\n g_in += s.total_input_tokens;\n g_out += s.total_output_tokens;\n g_cr += s.total_cache_read;\n g_cc += s.total_cache_create;\n g_gate += s.total_gate_calls;\n g_block += s.blocked_count;\n g_cost += s.estimated_cost_usd;\n g_tools += s.total_tool_calls;\n for (const [k, v] of Object.entries(s.tool_calls)) g_tool_calls[k] = (g_tool_calls[k] ?? 0) + v;\n }\n const g_saved = g_block * AVG_TOKENS_PER_BLOCKED_GREP;\n const g_used = g_in + g_out + g_cc;\n const g_saved_pct = g_used + g_saved > 0 ? (g_saved / (g_used + g_saved)) * 100 : 0;\n\n // Recent turns + gates across all projects, sorted by ts descending\n const allTurns: RecentTurn[] = [];\n const allGates: RecentGate[] = [];\n for (const p of loaded) {\n for (const t of p.tokens) {\n allTurns.push({\n // Fall back to written_at — the Stop hook today posts entries without\n // a `ts` field, and the server tags them with written_at on receive.\n ts: t.ts ?? t.written_at ?? \"\",\n project_name: p.name,\n project_path: p.path,\n input: t.input_tokens ?? 0,\n output: t.output_tokens ?? 0,\n cache_read: t.cache_read_input_tokens ?? 0,\n cache_create: t.cache_creation_input_tokens ?? 0,\n model: t.model ?? \"\",\n cost_usd: Math.round(estimateCostUsd(t) * 1000) / 1000,\n });\n }\n for (const gate of p.gates) {\n allGates.push({\n ts: gate.ts,\n project_name: p.name,\n project_path: p.path,\n tool: gate.tool,\n decision: gate.decision,\n query: gate.query,\n });\n }\n }\n allTurns.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));\n allGates.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));\n\n return {\n active: {\n project_root: activePath,\n project_name: activeName,\n stats: activeStats,\n },\n global: {\n project_count: projects.length,\n total_turns: g_turns,\n total_input_tokens: g_in,\n total_output_tokens: g_out,\n total_cache_read: g_cr,\n total_cache_create: g_cc,\n total_gate_calls: g_gate,\n blocked_count: g_block,\n estimated_tokens_saved: g_saved,\n saved_percent: Math.round(g_saved_pct * 10) / 10,\n estimated_cost_usd: Math.round(g_cost * 100) / 100,\n total_tool_calls: g_tools,\n tool_calls: g_tool_calls,\n },\n projects,\n recent_turns: allTurns.slice(0, recentN),\n recent_gates: allGates.slice(0, recentN),\n };\n}\n\n// Legacy shapes from the M2 stub — kept for compat.\nexport interface TurnBreakdown {\n systemPromptTokens: number;\n conversationHistoryTokens: number;\n synthraPackTokens: number;\n userMessageTokens: number;\n responseTokens: number;\n totalTokens: number;\n costUsd: number;\n}\n\nexport interface SavingsDelta {\n withSynthra: TurnBreakdown;\n estimatedWithoutSynthra: TurnBreakdown;\n savedUsd: number;\n savedPercent: number;\n}\n\nexport function computeDelta(breakdown: TurnBreakdown, blockedGreps: number): SavingsDelta {\n const savedTokens = blockedGreps * AVG_TOKENS_PER_BLOCKED_GREP;\n const without: TurnBreakdown = {\n ...breakdown,\n conversationHistoryTokens: breakdown.conversationHistoryTokens + savedTokens,\n totalTokens: breakdown.totalTokens + savedTokens,\n costUsd: breakdown.costUsd + (savedTokens / 1_000_000) * 3,\n };\n const savedUsd = without.costUsd - breakdown.costUsd;\n const savedPercent = without.totalTokens > 0 ? (savedTokens / without.totalTokens) * 100 : 0;\n return {\n withSynthra: breakdown,\n estimatedWithoutSynthra: without,\n savedUsd,\n savedPercent: Math.round(savedPercent * 10) / 10,\n };\n}\n","// Resolves Synthra's storage locations inside a project root.\n\nimport { join } from \"node:path\";\n\nexport interface SynthraPaths {\n projectRoot: string;\n graphDir: string;\n contextDir: string;\n infoGraph: string;\n symbolIndex: string;\n sessionState: string;\n activityLog: string;\n tokenLog: string;\n gateLog: string;\n toolLog: string;\n accessLog: string;\n learnStore: string;\n mcpPort: string;\n mcpServerLog: string;\n mcpServerErrLog: string;\n contextStore: string;\n contextMd: string;\n branchesDir: string;\n claudeDir: string;\n claudeSettings: string;\n claudeHooksDir: string;\n claudeMd: string;\n gitignore: string;\n}\n\nexport function resolvePaths(projectRoot: string): SynthraPaths {\n const graphDir = join(projectRoot, \".synthra-graph\");\n const contextDir = join(projectRoot, \".synthra\");\n const claudeDir = join(projectRoot, \".claude\");\n\n return {\n projectRoot,\n graphDir,\n contextDir,\n infoGraph: join(graphDir, \"info_graph.json\"),\n symbolIndex: join(graphDir, \"symbol_index.json\"),\n sessionState: join(graphDir, \"session.json\"),\n activityLog: join(graphDir, \"activity.jsonl\"),\n tokenLog: join(graphDir, \"token_log.jsonl\"),\n gateLog: join(graphDir, \"gate_log.jsonl\"),\n toolLog: join(graphDir, \"tool_log.jsonl\"),\n accessLog: join(graphDir, \"access_log.jsonl\"),\n learnStore: join(graphDir, \"learn_store.json\"),\n mcpPort: join(graphDir, \"mcp_port\"),\n mcpServerLog: join(graphDir, \"mcp_server.log\"),\n mcpServerErrLog: join(graphDir, \"mcp_server.err.log\"),\n contextStore: join(contextDir, \"context-store.json\"),\n contextMd: join(contextDir, \"CONTEXT.md\"),\n branchesDir: join(contextDir, \"branches\"),\n claudeDir,\n claudeSettings: join(claudeDir, \"settings.local.json\"),\n claudeHooksDir: join(claudeDir, \"hooks\"),\n claudeMd: join(projectRoot, \"CLAUDE.md\"),\n gitignore: join(projectRoot, \".gitignore\"),\n };\n}\n","// Approximate per-million-token pricing for Claude models, in USD.\n// Sourced from Anthropic's published rates. Tilde everywhere — these can shift.\n//\n// Used only for the dashboard's \"~$X\" estimate; not for billing.\n\nexport interface ModelPricing {\n /** Cost per 1M raw-input tokens. */\n input: number;\n /** Cost per 1M output tokens. */\n output: number;\n /** Cost per 1M cache-read tokens (typically ~10% of input). */\n cacheRead: number;\n /** Cost per 1M cache-creation tokens (typically input × 1.25). */\n cacheCreate: number;\n}\n\nconst PRICING: Record<string, ModelPricing> = {\n // Opus-class models — premium tier\n \"claude-opus-4-7\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n \"claude-opus-4-6\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n \"claude-opus-4-5\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n // Sonnet-class — workhorse\n \"claude-sonnet-4-6\": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },\n \"claude-sonnet-4-5\": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },\n // Haiku-class — fast and cheap\n \"claude-haiku-4-5\": { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 },\n};\n\nconst FALLBACK: ModelPricing = { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 };\n\nexport function pricingFor(model: string | undefined | null): ModelPricing {\n if (!model) return FALLBACK;\n const direct = PRICING[model];\n if (direct) return direct;\n // Loose prefix match: \"claude-opus-…\" / \"claude-sonnet-…\" / \"claude-haiku-…\"\n if (model.includes(\"opus\")) return PRICING[\"claude-opus-4-7\"] ?? FALLBACK;\n if (model.includes(\"sonnet\")) return PRICING[\"claude-sonnet-4-6\"] ?? FALLBACK;\n if (model.includes(\"haiku\")) return PRICING[\"claude-haiku-4-5\"] ?? FALLBACK;\n return FALLBACK;\n}\n\nexport interface UsageRecord {\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n model?: string;\n}\n\n/** Approximate USD cost of a single usage record. */\nexport function estimateCostUsd(usage: UsageRecord): number {\n const p = pricingFor(usage.model);\n return (\n (usage.input_tokens / 1_000_000) * p.input +\n (usage.output_tokens / 1_000_000) * p.output +\n ((usage.cache_read_input_tokens ?? 0) / 1_000_000) * p.cacheRead +\n ((usage.cache_creation_input_tokens ?? 0) / 1_000_000) * p.cacheCreate\n );\n}\n","// Global registry of projects that have run `syn .` on this machine.\n// Stored at ~/.synthra/projects.json so the dashboard can enumerate them\n// without walking the filesystem.\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\n\nconst REGISTRY_DIR = join(homedir(), \".synthra\");\nconst REGISTRY_PATH = join(REGISTRY_DIR, \"projects.json\");\nconst SCHEMA_VERSION = 1;\n\nexport interface ProjectRegistryEntry {\n path: string; // absolute project root\n name: string; // basename for display\n first_seen: string; // ISO timestamp\n last_seen: string; // ISO timestamp\n}\n\ninterface Registry {\n schema_version: number;\n projects: ProjectRegistryEntry[];\n}\n\nasync function readRegistry(): Promise<Registry> {\n try {\n const raw = await readFile(REGISTRY_PATH, \"utf8\");\n const parsed = JSON.parse(raw) as Partial<Registry>;\n if (!Array.isArray(parsed.projects)) return { schema_version: SCHEMA_VERSION, projects: [] };\n return { schema_version: parsed.schema_version ?? SCHEMA_VERSION, projects: parsed.projects };\n } catch {\n return { schema_version: SCHEMA_VERSION, projects: [] };\n }\n}\n\nasync function writeRegistry(registry: Registry): Promise<void> {\n await mkdir(dirname(REGISTRY_PATH), { recursive: true });\n await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + \"\\n\", \"utf8\");\n}\n\n/** Upsert this project's entry. Updates `last_seen`; preserves `first_seen`. */\nexport async function recordProject(projectRoot: string): Promise<void> {\n const now = new Date().toISOString();\n const registry = await readRegistry();\n const existing = registry.projects.find((p) => p.path === projectRoot);\n if (existing) {\n existing.last_seen = now;\n existing.name = basename(projectRoot);\n } else {\n registry.projects.push({\n path: projectRoot,\n name: basename(projectRoot),\n first_seen: now,\n last_seen: now,\n });\n }\n try {\n await writeRegistry(registry);\n } catch {\n // Registry is best-effort — a write failure shouldn't block the session.\n }\n}\n\nexport async function listProjects(): Promise<ProjectRegistryEntry[]> {\n const registry = await readRegistry();\n // Sort by last_seen descending so the most active project surfaces first.\n return registry.projects\n .slice()\n .sort((a, b) => (a.last_seen > b.last_seen ? -1 : a.last_seen < b.last_seen ? 1 : 0));\n}\n\nexport { REGISTRY_PATH, REGISTRY_DIR };\n","<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Synthra · Dashboard</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"./style.css\" />\n</head>\n<body>\n\n <!-- ============ Top nav ============ -->\n <header class=\"topnav\">\n <div class=\"brand\">\n <div class=\"brand-mark\"></div>\n <div class=\"brand-name\">Synth<em>ra</em></div>\n <div class=\"brand-eyebrow\">Dashboard</div>\n <div class=\"nav-date\">\n <span class=\"nd-weekday\" id=\"hero-weekday\">—</span>\n <span class=\"nd-day\" id=\"hero-day\">—</span>\n <span class=\"nd-month\" id=\"hero-month\">—</span>\n </div>\n </div>\n <div class=\"top-right\">\n <span class=\"status-pill\">\n <span class=\"dot\" id=\"dot\"></span>\n <span id=\"status\">connecting…</span>\n </span>\n </div>\n <div class=\"topnav-right\">\n <div class=\"nav-active has-tooltip\" data-tooltip=\"The project directory Synthra is currently watching — the most recent `syn .` session on this machine.\">\n <span class=\"na-label\">Active</span>\n <span class=\"na-value\" id=\"active-project\" title=\"—\">—</span>\n </div>\n <span class=\"port-badge\">port <span class=\"mono\" id=\"port-num\">8901</span></span>\n <button class=\"faq-btn has-tooltip\" id=\"faq-btn\" data-tooltip=\"Open the FAQ — explains where every number on this dashboard comes from, how cost is calculated, and what the savings floor actually measures.\" aria-label=\"Open FAQ\">?</button>\n </div>\n </header>\n\n <!-- ============ Main 3-column grid ============ -->\n <main class=\"grid-main\">\n\n <!-- ===== Left ===== -->\n <aside class=\"col-left\">\n <div class=\"card donut-card has-tooltip\" data-tooltip=\"Which Claude models you've been calling, weighted by turn count. Opus = slow and expensive; Sonnet = workhorse; Haiku = cheap and fast. Helps you see where your budget is actually going.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Model usage</div>\n <div class=\"card-meta\">by turns</div>\n </div>\n <div class=\"donut-wrap\">\n <svg viewBox=\"0 0 140 140\" class=\"donut\" id=\"donut-svg\" aria-hidden=\"true\">\n <circle cx=\"70\" cy=\"70\" r=\"52\" class=\"donut-track\"/>\n </svg>\n <div class=\"donut-center\">\n <div class=\"donut-total\" id=\"donut-total\">0</div>\n <div class=\"donut-total-k\">turns</div>\n </div>\n </div>\n <div class=\"donut-legend\" id=\"donut-legend\"></div>\n </div>\n\n <!-- Projects — colored bar chart by turns -->\n <div class=\"card projects-card has-tooltip\" data-tooltip=\"Every project Synthra has tracked on this machine, ranked by how many turns ran there. Each project carries its own color. Click any row to open its full cost & token breakdown.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Projects</div>\n <div class=\"card-meta\">by turns</div>\n </div>\n <div class=\"proj-chart\" id=\"proj-chart\"></div>\n </div>\n </aside>\n\n <!-- ===== Center ===== -->\n <div class=\"col-center\">\n\n <!-- Metric strip — divider-separated, no individual card chrome -->\n <div class=\"metric-strip\">\n <div class=\"metric-item has-tooltip\" data-tooltip=\"Total back-and-forth exchanges with Claude across all projects. One turn = you send a message, Claude responds. Counted from the Stop hook against transcript JSONL files.\">\n <div class=\"m-label\">Turns</div>\n <div class=\"m-value\" id=\"m-turns\">0</div>\n </div>\n <div class=\"metric-item has-tooltip\" data-tooltip=\"↓ Input — new, uncached tokens you sent to Claude. Usually small (a few hundred per turn) because most of the conversation history comes from prompt cache.\">\n <div class=\"m-label\">↓ Input</div>\n <div class=\"m-value\" id=\"m-input\">0</div>\n </div>\n <div class=\"metric-item has-tooltip\" data-tooltip=\"↑ Output — tokens Claude generated in its responses. Most expensive line item per turn (~5× input rate on Opus). High output usually means long code edits.\">\n <div class=\"m-label\">↑ Output</div>\n <div class=\"m-value\" id=\"m-output\">0</div>\n </div>\n <div class=\"metric-item has-tooltip\" data-tooltip=\"⟲ Cache read — tokens reused from the prompt cache (system prompt, conversation history, Synthra's pre-packed context). Cheap, around 10% of the input rate. The bulk of every long session.\">\n <div class=\"m-label\">⟲ Cache R</div>\n <div class=\"m-value\" id=\"m-cache-r\">0</div>\n </div>\n <div class=\"metric-item has-tooltip\" data-tooltip=\"+ Cache write — tokens newly added to the prompt cache so future turns can read them cheaply. Premium-priced (~125% of input rate) but pays back across the session.\">\n <div class=\"m-label\">+ Cache W</div>\n <div class=\"m-value\" id=\"m-cache-w\">0</div>\n </div>\n </div>\n\n <!-- Savings hero -->\n <div class=\"card savings has-tooltip\" data-tooltip=\"What Synthra has saved you, as a deliberately conservative floor estimate. Each time the gate blocks an exploratory Grep/Glob, we credit 500 tokens × $3 per million-token input rate. Real savings are usually higher because the formula ignores cache thrash and follow-up Reads that the block also prevents. The audit line below shows the exact math live.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Synthra savings <span class=\"src-badge estimated\">floor</span></div>\n <div class=\"card-meta\" id=\"savings-pct\">— off</div>\n </div>\n <div class=\"savings-body\">\n <div class=\"savings-figure\">\n <div class=\"savings-money\" id=\"savings-money\">$0.00</div>\n <div class=\"savings-tokens\"><span id=\"savings-tokens\">0</span> tokens avoided</div>\n </div>\n <div class=\"savings-bar\">\n <div class=\"savings-actual\" id=\"savings-actual-bar\" style=\"width:100%\"></div>\n <div class=\"savings-saved\" id=\"savings-saved-bar\" style=\"width:0%\"></div>\n </div>\n <div class=\"savings-legend\">\n <div class=\"sl-row\"><span class=\"sl-dot actual\"></span>You paid <b id=\"savings-actual-amt\">$0.00</b></div>\n <div class=\"sl-row\"><span class=\"sl-dot saved\"></span>Baseline <b id=\"savings-baseline-amt\">$0.00</b></div>\n </div>\n </div>\n <div class=\"savings-audit\">\n <span class=\"audit-formula\">\n <b id=\"audit-blocks\">0</b> blocks × <b>500</b> tokens × <b>$3</b> / M input rate = <b id=\"audit-result\" class=\"audit-result\">$0.00</b>\n </span>\n </div>\n </div>\n\n <!-- Recent turns -->\n <div class=\"card turns-card has-tooltip\" data-tooltip=\"Every conversational turn Synthra has observed across all your projects, newest first. Each row shows when, which project, which model, and how the cost broke down between fresh input, generated output, and cache.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Recent turns</div>\n <div class=\"card-meta\" id=\"turns-count\">— shown</div>\n </div>\n <div class=\"turns-scroll\">\n <table class=\"turns-table\">\n <thead>\n <tr>\n <th class=\"has-tooltip\" data-tooltip=\"When this turn happened, in your local time. Turns from today show as a time; older turns show the date.\">Time</th>\n <th class=\"has-tooltip\" data-tooltip=\"Which project directory the turn ran in. Color-matched to the Projects chart on the left.\">Project</th>\n <th class=\"has-tooltip\" data-tooltip=\"The Claude model used for this turn — Opus, Sonnet, or Haiku — shown in the color-coded pill.\">Model</th>\n <th class=\"num has-tooltip\" data-tooltip=\"↓ Input — raw, uncached tokens sent to Claude this turn. Usually tiny (a few hundred) because conversation history is served from the prompt cache, not re-sent fresh.\">In</th>\n <th class=\"num has-tooltip\" data-tooltip=\"↑ Output — tokens Claude generated in its response. The most expensive line item per turn (~5× the input rate on Opus). Big numbers usually mean long code edits.\">Out</th>\n <th class=\"num has-tooltip\" data-tooltip=\"Cache Read / Cache Write. R = tokens reused from earlier turns (cheap, ~10% of the input rate). W = tokens newly written to the cache so future turns can read them (premium, ~125% of the input rate).\">Cache R/W</th>\n <th class=\"num has-tooltip\" data-tooltip=\"Estimated USD for this turn: input + output + cache read + cache write, each multiplied by the published Anthropic per-model rate.\">Cost</th>\n </tr>\n </thead>\n <tbody id=\"turns-body\"></tbody>\n </table>\n <p class=\"empty hidden\" id=\"turns-empty\">No turns logged yet. Run <code>syn .</code> in any project and chat with Claude.</p>\n </div>\n <div class=\"turns-pager hidden\" id=\"turns-pager\">\n <button type=\"button\" id=\"turns-prev\" aria-label=\"Previous page\">‹ Prev</button>\n <span class=\"mono\" id=\"turns-page-label\">page 1 of 1</span>\n <button type=\"button\" id=\"turns-next\" aria-label=\"Next page\">Next ›</button>\n </div>\n </div>\n\n </div>\n\n <!-- ===== Right ===== -->\n <aside class=\"col-right\">\n\n <!-- Cost hero -->\n <div class=\"card cost-hero has-tooltip\" data-tooltip=\"Your all-time Claude spend across every project Synthra has tracked on this machine. Token counts come from Claude's transcript JSONL files; dollar amounts are computed by multiplying those counts by Anthropic's published per-model rates. See the FAQ for full rate tables.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Total spend · <em>all time</em></div>\n </div>\n <div class=\"big-money\" id=\"big-cost\">$0.<em>00</em></div>\n <div class=\"cost-sub\">\n <div class=\"cs-row\">\n <span class=\"cs-k\">Tokens (in+out)</span>\n <span class=\"cs-v\" id=\"cs-tokens\">0</span>\n </div>\n <div class=\"cs-row\">\n <span class=\"cs-k\">Avg / turn</span>\n <span class=\"cs-v\" id=\"cs-avg\">$0.00</span>\n </div>\n </div>\n </div>\n\n <!-- Graph tool usage -->\n <div class=\"card tools-card has-tooltip\" data-tooltip=\"How often Claude actually used Synthra's graph tools (graph_continue / graph_read / …) across all projects. A positive usage signal — unlike the Moat's block count, it captures every time Claude reached for the graph instead of running a Grep.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Graph tools <em>used</em></div>\n <div class=\"card-meta\">all projects</div>\n </div>\n <div class=\"moat-value\"><span id=\"tool-total\">0</span> <em>calls</em></div>\n <div class=\"cost-sub\" id=\"tool-breakdown\"></div>\n </div>\n\n <!-- The Moat -->\n <div class=\"card moat has-tooltip\" data-tooltip=\"Synthra's PreToolUse hook intercepts. Each block = Synthra recognized the graph already had high-confidence context for the query, so it stopped Claude from running an exploratory Grep or Glob. The list below shows the latest decisions across all projects.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">The <em>Moat</em></div>\n <div class=\"card-meta\">PreToolUse</div>\n </div>\n <div class=\"moat-value\"><span id=\"blocks\">0</span> <em>blocks</em></div>\n <div class=\"gate-mini\" id=\"gate-mini\"></div>\n </div>\n\n </aside>\n </main>\n\n <!-- ============ Project dialog ============ -->\n <div class=\"dialog-backdrop hidden\" id=\"dialog-backdrop\" role=\"dialog\" aria-modal=\"true\">\n <div class=\"dialog\">\n <button class=\"dialog-close\" id=\"dialog-close\" aria-label=\"Close\">×</button>\n <div class=\"dialog-eyebrow\">Project · <em>details</em></div>\n <div class=\"dialog-name\" id=\"d-name\">—</div>\n <div class=\"dialog-path\" id=\"d-path\">—</div>\n <div class=\"dialog-grid\">\n <div class=\"dg-cell\">\n <div class=\"dg-k\">Total cost</div>\n <div class=\"dg-v money\" id=\"d-cost\">$0.00</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">Turns</div>\n <div class=\"dg-v\" id=\"d-turns\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">↓ Raw input</div>\n <div class=\"dg-v\" id=\"d-input\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">↑ Output</div>\n <div class=\"dg-v\" id=\"d-output\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">⟲ Cache read</div>\n <div class=\"dg-v\" id=\"d-cache-r\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">+ Cache write</div>\n <div class=\"dg-v\" id=\"d-cache-w\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">Moat blocks</div>\n <div class=\"dg-v\" id=\"d-blocks\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">Last active</div>\n <div class=\"dg-v dg-v-sm\" id=\"d-last\">—</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- ============ FAQ dialog ============ -->\n <div class=\"dialog-backdrop hidden\" id=\"faq-backdrop\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Dashboard FAQ\">\n <div class=\"dialog dialog-faq\">\n <button class=\"dialog-close\" id=\"faq-close\" aria-label=\"Close\">×</button>\n <div class=\"dialog-eyebrow\">FAQ · <em>where every number comes from</em></div>\n <div class=\"dialog-name\">Understanding your dashboard</div>\n <div class=\"dialog-path\">Synthra reports what Claude actually used — read from transcripts, not estimated.</div>\n\n <div class=\"faq-content\">\n\n <details open>\n <summary>Where does Synthra get these numbers from?</summary>\n <div class=\"faq-body\">\n <p>Synthra <strong>does not estimate</strong> token counts — it reads them directly from Claude's own log files. Every number on this dashboard is traceable:</p>\n <table>\n <tr><td>Turns, ↓ In, ↑ Out, Cache R/W</td><td>Parsed from Claude's transcript JSONLs at <code>~/.claude/projects/<encoded-cwd>/*.jsonl</code></td></tr>\n <tr><td>Model used per turn</td><td>From each turn's <code>model</code> field in the same transcripts</td></tr>\n <tr><td>Moat blocks + gate decisions</td><td>From <code>.synthra-graph/gate_log.jsonl</code> inside each project</td></tr>\n <tr><td>Active project, project list</td><td>From <code>~/.synthra/projects.json</code> (built up as you run <code>syn .</code>)</td></tr>\n <tr><td>Total spend (USD)</td><td>Above token counts × Anthropic's published per-model rates — see \"How is cost calculated?\" below</td></tr>\n <tr><td>Synthra savings (USD)</td><td><strong>Estimated</strong> — see \"About the Savings (floor) card\" below</td></tr>\n </table>\n <p>The cost-calculation logic lives in <code>src/shared/pricing.ts</code>; the aggregation logic in <code>src/dashboard/delta.ts</code>. Both are linked at the bottom.</p>\n </div>\n </details>\n\n <details>\n <summary>What do the columns in Recent Turns mean?</summary>\n <div class=\"faq-body\">\n <table>\n <tr><td><code>Time</code></td><td>When this turn happened (local time)</td></tr>\n <tr><td><code>Project</code></td><td>Which directory the turn ran in</td></tr>\n <tr><td><code>Model</code></td><td>Claude model used — Opus, Sonnet, or Haiku (color-coded in the model pill)</td></tr>\n <tr><td><code>In</code></td><td>Raw input tokens — brand-new content sent to Claude this turn, not cached</td></tr>\n <tr><td><code>Out</code></td><td>Tokens Claude generated in its response</td></tr>\n <tr><td><code>Cache R/W</code></td><td>Cache <strong>R</strong>ead (reused from prior turns) / Cache <strong>W</strong>rite (newly cached for future turns)</td></tr>\n <tr><td><code>Cost</code></td><td>Per-turn USD estimate using Anthropic's published rates</td></tr>\n </table>\n <p><strong>Why is Raw Input often tiny (e.g. 6 tokens)?</strong> Because Claude Code aggressively caches the system prompt, CLAUDE.md, tool definitions, and conversation history. On each turn, only your brand-new message is \"raw input\" — everything else is a cheap cache read. This is normal and saves significant money.</p>\n </div>\n </details>\n\n <details>\n <summary>How is cost calculated?</summary>\n <div class=\"faq-body\">\n <p>Each token type has a different per-million-token rate. Synthra uses these rates (defined in <code>src/shared/pricing.ts</code>):</p>\n <table>\n <thead><tr><td><strong>Token type</strong></td><td><strong>Haiku 4.5</strong></td><td><strong>Sonnet 4.x</strong></td><td><strong>Opus 4.x</strong></td></tr></thead>\n <tr><td>Raw Input</td><td>$1.00/M</td><td>$3.00/M</td><td>$15.00/M</td></tr>\n <tr><td>Cache Write</td><td>$1.25/M</td><td>$3.75/M</td><td>$18.75/M</td></tr>\n <tr><td>Cache Read</td><td>$0.10/M</td><td>$0.30/M</td><td>$1.50/M</td></tr>\n <tr><td>Output</td><td>$5.00/M</td><td>$15.00/M</td><td>$75.00/M</td></tr>\n </table>\n <p><strong>Cost</strong> = (Input × input rate) + (Output × output rate) + (Cache Read × read rate) + (Cache Write × write rate)</p>\n <p>Cache reads are <strong>10× cheaper</strong> than raw input. Cache writes are <strong>25% more expensive</strong> than raw input. So Claude Code's caching strategy pays for itself quickly across a session.</p>\n <div class=\"warning\"><span class=\"icon\">⚠</span>These are <strong>Anthropic API rates</strong>, not your plan billing. If you're on Claude Pro, Team, Max, or Enterprise, your actual billing is different — the costs shown are estimates of <em>API-equivalent</em> usage, useful for comparing sessions against each other. See <a href=\"https://www.anthropic.com/pricing\" target=\"_blank\" rel=\"noopener noreferrer\">anthropic.com/pricing</a> for the source of these rates.</div>\n </div>\n </details>\n\n <details>\n <summary>What is the total context size per turn?</summary>\n <div class=\"faq-body\">\n <p><code>Total context = Raw Input + Cache Read + Cache Write</code></p>\n <p>Example: if Raw Input is 6, Cache Read is 60K, and Cache Write is 13K, your turn used ~73K tokens of context — but 99.99% was efficiently cached, so you only paid the cache-read rate on most of it. The Recent Turns table lets you scan this row by row.</p>\n <p>Anthropic's prompt-caching mechanics are documented at <a href=\"https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching\" target=\"_blank\" rel=\"noopener noreferrer\">docs.anthropic.com/.../prompt-caching</a>.</p>\n </div>\n </details>\n\n <details>\n <summary>About the \"Savings (floor)\" card</summary>\n <div class=\"faq-body\">\n <p>This is the only number on the dashboard that's <strong>estimated</strong> rather than measured. Here's the math:</p>\n <p class=\"formula-box\">savings = blocks × 500 tokens × $3 per million input rate</p>\n <p>Each time Synthra's PreToolUse hook blocks a <code>Grep</code> or <code>Glob</code> call (because the graph already has high-confidence context), we credit a deliberately conservative 500 tokens at the Sonnet input rate.</p>\n <p>It under-counts on purpose because the formula ignores:</p>\n <ul>\n <li><strong>Cache thrash</strong> — the blocked tool result would have been written to the cache at ~125% of the input rate, which we don't count</li>\n <li><strong>Cascading reads</strong> — Claude usually follows a Grep with several <code>Read</code> calls, which the block also prevents but we don't credit</li>\n <li><strong>Bigger codebases</strong> — actual Grep results often exceed 500 tokens by 3–6× in a real repo</li>\n </ul>\n <p>Real savings are typically <strong>2–5× the floor</strong>. The audit row on the Savings card shows the formula live so you can verify the math.</p>\n </div>\n </details>\n\n <details>\n <summary>What is \"The Moat\"?</summary>\n <div class=\"faq-body\">\n <p>\"The Moat\" is Synthra's <strong>PreToolUse hook</strong>. Every time Claude Code is about to run a <code>Grep</code> or <code>Glob</code>, the hook calls Synthra's local server first and asks: \"do you already have high-confidence context for this query?\"</p>\n <p>If yes, Synthra returns <code>{\"decision\":\"block\"}</code> — Claude can't run the tool. Claude then has to use Synthra's graph tools (<code>graph_continue</code>, <code>graph_read</code>) instead, which return the answer without burning tokens on exploration. This is deterministic enforcement — not prose policy. Claude literally can't disobey.</p>\n <p>The Moat card shows total blocks; the inline list below shows the most recent gate decisions (block vs allow) with the originating query.</p>\n <p>Every decision is logged to <code>.synthra-graph/gate_log.jsonl</code> for the project.</p>\n </div>\n </details>\n\n <details>\n <summary>How does Synthra build the codebase graph?</summary>\n <div class=\"faq-body\">\n <p>When you run <code>syn .</code> in a project, Synthra walks the file tree (respecting <code>.gitignore</code> + <code>.synthraignore</code>) and parses each file with <strong>tree-sitter</strong> WebAssembly grammars. Currently 14 languages are supported:</p>\n <p>TypeScript, JavaScript, JSX/TSX, Python, Svelte, Vue, Go, Rust, Java, Kotlin, PHP, Ruby, C/C++, C#, and Dart.</p>\n <p>For each file we extract: function and class definitions, exports, imports, and test-to-source links. The output is a structured graph stored at <code>.synthra-graph/info_graph.json</code> with a symbol index at <code>.synthra-graph/symbol_index.json</code>.</p>\n <p>The graph is what makes pre-injection and The Moat work — both query it before Claude ever has to Grep.</p>\n </div>\n </details>\n\n <details>\n <summary>Where does Synthra store data on disk?</summary>\n <div class=\"faq-body\">\n <p>Synthra uses two folders per project, intentionally separated:</p>\n <table>\n <tr><td><code>.synthra-graph/</code></td><td><strong>Gitignored</strong>. Heavy generated state — the graph, symbol index, token + gate logs, session info. Rebuilt by <code>syn scan</code>.</td></tr>\n <tr><td><code>.synthra/</code></td><td><strong>Git-tracked.</strong> Decisions, context notes, branch-scoped memory — the part teammates inherit when they clone the repo.</td></tr>\n </table>\n <p>Plus one global file at <code>~/.synthra/projects.json</code> that tracks every project on this machine.</p>\n </div>\n </details>\n\n <details>\n <summary>How does branch-aware memory work?</summary>\n <div class=\"faq-body\">\n <p>Inside <code>.synthra/</code>, context is partitioned by git branch:</p>\n <ul>\n <li><code>.synthra/context-store.json</code> — decisions on the default branch</li>\n <li><code>.synthra/CONTEXT.md</code> — narrative notes on the default branch</li>\n <li><code>.synthra/branches/<sanitized-name>/</code> — overrides on feature branches</li>\n </ul>\n <p>When you switch branches, Synthra's git-watcher (using <code>fs.watch</code> on <code>.git/HEAD</code>) detects the change and reloads the right context. Decisions scoped to a feature branch don't leak back to <code>main</code> until merge.</p>\n </div>\n </details>\n\n <details>\n <summary>How does Synthra actually reduce my Claude bill?</summary>\n <div class=\"faq-body\">\n <p>Three mechanisms, in order of impact:</p>\n <ul>\n <li><strong>Pre-injection</strong> — at session start, Synthra packs ~4K tokens of graph context (function signatures, top inline bodies, file relationships) into Claude's prompt. Claude doesn't have to Grep / Read to discover what's in the codebase — it already knows.</li>\n <li><strong>The Moat</strong> — the PreToolUse hook deterministically blocks exploratory Grep/Glob when the graph already has high-confidence context. Counts on the Moat card.</li>\n <li><strong>Branch-aware memory</strong> — decisions and CONTEXT notes persist in <code>.synthra/</code>, so Claude doesn't have to be re-told what was decided last session.</li>\n </ul>\n <p>The dashboard shows real token counts from Claude's own logs so you can see the effect over time, not just take Synthra's word for it.</p>\n </div>\n </details>\n\n <details>\n <summary>Why do long conversations get expensive?</summary>\n <div class=\"faq-body\">\n <p>Each turn re-sends the entire conversation history as input. Even cached, the cumulative input grows roughly <strong>quadratically</strong>:</p>\n <table>\n <thead><tr><td><strong>Turns</strong></td><td><strong>Per-turn input</strong></td><td><strong>Cumulative input</strong></td></tr></thead>\n <tr><td>10</td><td>~2K (mostly cached)</td><td>~110K</td></tr>\n <tr><td>30</td><td>~2K</td><td>~930K</td></tr>\n <tr><td>50</td><td>~2K</td><td>~2.55M</td></tr>\n </table>\n <p>Prompt caching helps a lot (cache reads are 10× cheaper than fresh input), but context still grows. This is why Synthra's pre-injection matters: starting with the answer already in context means you reach a useful state in fewer turns.</p>\n <p><strong>Tip:</strong> Use <code>/compact</code> in Claude Code or start fresh sessions when a thread feels stale.</p>\n </div>\n </details>\n\n <details>\n <summary>Sources & references</summary>\n <div class=\"faq-body\">\n <p>Synthra is open source. Every number on this dashboard can be cross-checked:</p>\n <ul class=\"link-list\">\n <li><a href=\"https://github.com/jefuriiij/synthra\" target=\"_blank\" rel=\"noopener noreferrer\">github.com/jefuriiij/synthra</a> — source code, issues, roadmap</li>\n <li><a href=\"https://www.npmjs.com/package/@jefuriiij/synthra\" target=\"_blank\" rel=\"noopener noreferrer\">npm: @jefuriiij/synthra</a> — release history, install instructions</li>\n <li><a href=\"https://www.anthropic.com/pricing\" target=\"_blank\" rel=\"noopener noreferrer\">anthropic.com/pricing</a> — official rate table Synthra uses</li>\n <li><a href=\"https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching\" target=\"_blank\" rel=\"noopener noreferrer\">Anthropic docs · prompt caching</a> — explains cache read/write behavior</li>\n <li><code>src/shared/pricing.ts</code> — the file in this repo holding the rate table</li>\n <li><code>src/dashboard/delta.ts</code> — where dashboard aggregates are computed</li>\n <li><code>src/server/routes/gate.ts</code> — the Moat implementation</li>\n </ul>\n </div>\n </details>\n\n </div>\n </div>\n </div>\n\n <!-- ============ Footer ============ -->\n <footer class=\"foot\">\n <div>Synth<em>ra</em> · v__SYN_VERSION__</div>\n <div>Cost figures approximate · @jefuriiij</div>\n </footer>\n\n <script>\n const $ = (sel) => document.querySelector(sel);\n const SAVED_RATE_PER_M = 3.00; // USD per million tokens — conservative input rate\n\n // ----- model classification -----\n function modelFamily(model) {\n if (!model) return 'unknown';\n const m = model.toLowerCase();\n if (m === '<synthetic>') return 'unknown';\n if (m.includes('opus')) return 'opus';\n if (m.includes('sonnet')) return 'sonnet';\n if (m.includes('haiku')) return 'haiku';\n return 'unknown';\n }\n function modelLabel(model) {\n if (!model || model === '<synthetic>') return 'synthetic';\n return model.replace(/^claude-/, '');\n }\n\n // ----- formatting -----\n function fmt(n) {\n if (typeof n !== 'number' || !Number.isFinite(n)) return '0';\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + '<em>M</em>';\n if (n >= 1_000) return (n / 1_000).toFixed(1) + '<em>k</em>';\n return n.toLocaleString();\n }\n function fmtPlain(n) {\n if (typeof n !== 'number' || !Number.isFinite(n)) return '0';\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';\n if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';\n return n.toLocaleString();\n }\n function fmtCostBig(usd) {\n if (typeof usd !== 'number' || !Number.isFinite(usd)) usd = 0;\n let s;\n if (usd >= 1) s = usd.toFixed(2);\n else if (usd >= 0.01) s = usd.toFixed(3);\n else s = usd.toFixed(4);\n const dot = s.indexOf('.');\n if (dot === -1) return '$' + s;\n return '$' + s.slice(0, dot) + '.<em>' + s.slice(dot + 1) + '</em>';\n }\n function fmtCostFlat(usd) {\n if (typeof usd !== 'number' || !Number.isFinite(usd)) usd = 0;\n if (usd >= 1) return '$' + usd.toFixed(2);\n if (usd >= 0.01) return '$' + usd.toFixed(3);\n return '$' + usd.toFixed(4);\n }\n function fmtTs(iso) {\n try {\n const d = new Date(iso);\n const today = new Date();\n const isToday = d.toDateString() === today.toDateString();\n if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n return d.toLocaleDateString([], { month: 'short', day: 'numeric' });\n } catch { return iso; }\n }\n\n let lastData = null;\n let turnsPage = 1;\n const TURNS_PER_PAGE = 25;\n\n // ----- date in hero -----\n function setHeroDate() {\n const now = new Date();\n $('#hero-day').textContent = now.getDate();\n $('#hero-weekday').textContent = now.toLocaleDateString([], { weekday: 'short' });\n $('#hero-month').textContent = now.toLocaleDateString([], { month: 'long' });\n }\n\n // ----- renderers -----\n function renderSession(turns) {\n const agg = turns.reduce((a, t) => {\n a.turns += 1;\n a.input += t.input || 0;\n a.output += t.output || 0;\n a.cacheR += t.cache_read || 0;\n a.cacheW += t.cache_create || 0;\n return a;\n }, { turns: 0, input: 0, output: 0, cacheR: 0, cacheW: 0 });\n\n $('#m-turns').innerHTML = fmt(agg.turns);\n $('#m-input').innerHTML = fmt(agg.input);\n $('#m-output').innerHTML = fmt(agg.output);\n $('#m-cache-r').innerHTML = fmt(agg.cacheR);\n $('#m-cache-w').innerHTML = fmt(agg.cacheW);\n }\n\n function renderSavings(g) {\n const tokensSaved = g.estimated_tokens_saved || 0;\n const blocks = g.blocked_count || 0;\n const savedUsd = tokensSaved * SAVED_RATE_PER_M / 1_000_000;\n const actualUsd = g.estimated_cost_usd || 0;\n const baselineUsd = actualUsd + savedUsd;\n const savedPct = baselineUsd > 0 ? (savedUsd / baselineUsd) * 100 : 0;\n const actualPct = baselineUsd > 0 ? (actualUsd / baselineUsd) * 100 : 100;\n\n $('#savings-money').textContent = fmtCostFlat(savedUsd);\n $('#savings-pct').textContent = savedPct.toFixed(1) + '% off';\n $('#savings-tokens').textContent = fmtPlain(tokensSaved);\n $('#savings-actual-bar').style.width = actualPct.toFixed(2) + '%';\n $('#savings-saved-bar').style.width = savedPct.toFixed(2) + '%';\n $('#savings-actual-amt').textContent = fmtCostFlat(actualUsd);\n $('#savings-baseline-amt').textContent = fmtCostFlat(baselineUsd);\n\n // Audit row — live formula\n $('#audit-blocks').textContent = blocks.toLocaleString();\n $('#audit-result').textContent = fmtCostFlat(savedUsd);\n }\n\n function renderCostHero(g) {\n $('#big-cost').innerHTML = fmtCostBig(g.estimated_cost_usd);\n const totalTokens = (g.total_input_tokens || 0) + (g.total_output_tokens || 0);\n $('#cs-tokens').textContent = fmtPlain(totalTokens);\n const avg = g.total_turns > 0 ? g.estimated_cost_usd / g.total_turns : 0;\n $('#cs-avg').textContent = fmtCostFlat(avg);\n }\n\n function renderMoat(g) {\n $('#blocks').textContent = fmtPlain(g.blocked_count);\n }\n\n function renderToolUsage(g) {\n $('#tool-total').innerHTML = fmt(g.total_tool_calls || 0);\n const el = $('#tool-breakdown');\n el.innerHTML = '';\n const entries = Object.entries(g.tool_calls || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);\n if (!entries.length) {\n el.innerHTML = '<div class=\"empty\">No graph-tool calls yet.</div>';\n return;\n }\n const frag = document.createDocumentFragment();\n for (const [tool, n] of entries) {\n const row = document.createElement('div');\n row.className = 'cs-row';\n row.innerHTML = '<span class=\"cs-k\">' + tool + '</span><span class=\"cs-v\">' + fmtPlain(n) + '</span>';\n frag.appendChild(row);\n }\n el.appendChild(frag);\n }\n\n function renderTurns(turns) {\n const tbody = $('#turns-body');\n const empty = $('#turns-empty');\n const pager = $('#turns-pager');\n tbody.innerHTML = '';\n if (!turns.length) {\n empty.classList.remove('hidden');\n pager.classList.add('hidden');\n $('#turns-count').textContent = '0 shown';\n return;\n }\n empty.classList.add('hidden');\n\n const totalPages = Math.max(1, Math.ceil(turns.length / TURNS_PER_PAGE));\n // Clamp in case the list shrank (dedup / data churn) since last render.\n if (turnsPage > totalPages) turnsPage = totalPages;\n if (turnsPage < 1) turnsPage = 1;\n const start = (turnsPage - 1) * TURNS_PER_PAGE;\n const pageItems = turns.slice(start, start + TURNS_PER_PAGE);\n\n $('#turns-count').textContent =\n 'showing ' + (start + 1) + '–' + (start + pageItems.length) + ' of ' + turns.length;\n\n const frag = document.createDocumentFragment();\n for (const t of pageItems) {\n const family = modelFamily(t.model);\n const tr = document.createElement('tr');\n tr.innerHTML =\n '<td class=\"ts\">' + fmtTs(t.ts || t.written_at) + '</td>' +\n '<td class=\"proj\">' + (t.project_name || '—') + '</td>' +\n '<td><span class=\"model-pill ' + family + '\"><span class=\"sq\"></span>' + modelLabel(t.model) + '</span></td>' +\n '<td class=\"num\">' + fmtPlain(t.input || 0) + '</td>' +\n '<td class=\"num\">' + fmtPlain(t.output || 0) + '</td>' +\n '<td class=\"num\">' + fmtPlain(t.cache_read || 0) + ' / ' + fmtPlain(t.cache_create || 0) + '</td>' +\n '<td class=\"num cost\">' + fmtCostFlat(t.cost_usd || 0) + '</td>';\n frag.appendChild(tr);\n }\n tbody.appendChild(frag);\n\n if (totalPages <= 1) {\n pager.classList.add('hidden');\n } else {\n pager.classList.remove('hidden');\n $('#turns-page-label').textContent = 'page ' + turnsPage + ' of ' + totalPages;\n $('#turns-prev').disabled = turnsPage <= 1;\n $('#turns-next').disabled = turnsPage >= totalPages;\n }\n }\n\n function gotoTurnsPage(delta) {\n turnsPage += delta;\n renderTurns((lastData && lastData.recent_turns) || []);\n }\n\n function renderGateMini(gates) {\n const el = $('#gate-mini');\n el.innerHTML = '';\n if (!gates.length) {\n el.innerHTML = '<div class=\"empty\">No gate decisions yet.</div>';\n return;\n }\n const frag = document.createDocumentFragment();\n for (const g of gates.slice(0, 50)) {\n const row = document.createElement('div');\n row.className = 'gate-row';\n const cls = g.decision === 'block' ? 'block' : 'allow';\n row.innerHTML =\n '<span class=\"g-ts\">' + fmtTs(g.ts) + '</span>' +\n '<span class=\"g-decision ' + cls + '\">' + (g.decision || '—').toUpperCase() + '</span>' +\n '<span class=\"g-q\">' + (g.query || g.tool || '—') + '</span>';\n frag.appendChild(row);\n }\n el.appendChild(frag);\n }\n\n // ----- Project colors (stable per name) -----\n const PROJECT_COLORS = [\n 'oklch(78% 0.14 220)', // cyan\n 'oklch(75% 0.14 155)', // green\n 'oklch(78% 0.13 75)', // amber\n 'oklch(72% 0.14 285)', // violet\n 'oklch(72% 0.14 20)', // rose\n 'oklch(74% 0.13 195)', // teal\n 'oklch(80% 0.12 250)', // periwinkle\n 'oklch(76% 0.13 330)', // magenta\n ];\n function projColor(name) {\n let h = 0;\n for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;\n return PROJECT_COLORS[h % PROJECT_COLORS.length];\n }\n\n function renderProjects(projects) {\n const el = $('#proj-chart');\n el.innerHTML = '';\n if (!projects.length) {\n el.innerHTML = '<div class=\"empty\">No projects yet — run <code>syn .</code> in a project to start.</div>';\n return;\n }\n const ranked = [...projects].sort((a, b) => (b.total_turns || 0) - (a.total_turns || 0));\n const max = Math.max(1, ...ranked.map((p) => p.total_turns || 0));\n const frag = document.createDocumentFragment();\n for (const p of ranked) {\n const turns = p.total_turns || 0;\n const pct = Math.max(4, Math.round((turns / max) * 100));\n const row = document.createElement('button');\n row.className = 'proj-row';\n row.type = 'button';\n row.style.setProperty('--pc', projColor(p.name));\n row.innerHTML =\n '<span class=\"pr-top\">' +\n '<span class=\"pr-dot\"></span>' +\n '<span class=\"pr-name\" title=\"' + (p.path || p.name) + '\">' + p.name + '</span>' +\n '<span class=\"pr-turns\">' + fmtPlain(turns) + '</span>' +\n '<span class=\"pr-arrow\">›</span>' +\n '</span>' +\n '<span class=\"pr-bar\"><span class=\"pr-fill\" style=\"width:' + pct + '%\"></span></span>';\n row.addEventListener('click', () => openProjectDialog(p.name));\n frag.appendChild(row);\n }\n el.appendChild(frag);\n }\n\n // ----- Project dialog -----\n function lastActiveFor(name) {\n const turns = lastData?.recent_turns || [];\n for (const t of turns) {\n if (t.project_name === name) {\n const ts = t.ts || t.written_at;\n if (!ts) return '—';\n try {\n return new Date(ts).toLocaleString([], {\n year: 'numeric', month: 'short', day: 'numeric',\n hour: '2-digit', minute: '2-digit',\n });\n } catch { return ts; }\n }\n }\n return '—';\n }\n\n function openProjectDialog(name) {\n const p = (lastData?.projects || []).find((x) => x.name === name);\n if (!p) return;\n $('#d-name').textContent = p.name;\n $('#d-name').style.setProperty('--pc', projColor(p.name));\n $('#d-name').classList.add('has-accent');\n $('#d-path').textContent = p.path || '';\n $('#d-cost').textContent = fmtCostFlat(p.estimated_cost_usd);\n $('#d-turns').textContent = fmtPlain(p.total_turns);\n $('#d-input').textContent = fmtPlain(p.total_input_tokens);\n $('#d-output').textContent = fmtPlain(p.total_output_tokens);\n $('#d-cache-r').textContent = fmtPlain(p.total_cache_read);\n $('#d-cache-w').textContent = fmtPlain(p.total_cache_create);\n $('#d-blocks').textContent = fmtPlain(p.blocked_count);\n $('#d-last').textContent = lastActiveFor(p.name);\n $('#dialog-backdrop').classList.remove('hidden');\n }\n\n function closeProjectDialog() {\n $('#dialog-backdrop').classList.add('hidden');\n }\n\n $('#dialog-close').addEventListener('click', closeProjectDialog);\n $('#dialog-backdrop').addEventListener('click', (e) => {\n if (e.target.id === 'dialog-backdrop') closeProjectDialog();\n });\n\n // ----- FAQ dialog -----\n const faqBackdrop = $('#faq-backdrop');\n function openFaq() { faqBackdrop.classList.remove('hidden'); }\n function closeFaq() { faqBackdrop.classList.add('hidden'); }\n $('#faq-btn').addEventListener('click', openFaq);\n $('#faq-close').addEventListener('click', closeFaq);\n faqBackdrop.addEventListener('click', (e) => {\n if (e.target.id === 'faq-backdrop') closeFaq();\n });\n\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') { closeProjectDialog(); closeFaq(); }\n });\n\n $('#turns-prev').addEventListener('click', () => gotoTurnsPage(-1));\n $('#turns-next').addEventListener('click', () => gotoTurnsPage(1));\n\n // Reflect the actual port the dashboard is served on\n const portEl = $('#port-num');\n if (portEl && window.location.port) portEl.textContent = window.location.port;\n\n // ----- Donut chart (all-time model usage by turn count) -----\n // Takes a { model-string -> count } map summed across ALL projects, so it\n // reflects true all-time usage — independent of the recent-turns cap.\n function renderDonut(modelCounts) {\n const counts = { opus: 0, sonnet: 0, haiku: 0, unknown: 0 };\n for (const [model, n] of Object.entries(modelCounts || {})) counts[modelFamily(model)] += n;\n const total = counts.opus + counts.sonnet + counts.haiku + counts.unknown;\n\n const segs = [\n { fam: 'opus', label: 'Opus', n: counts.opus, color: 'var(--c-opus)' },\n { fam: 'sonnet', label: 'Sonnet', n: counts.sonnet, color: 'var(--c-sonnet)' },\n { fam: 'haiku', label: 'Haiku', n: counts.haiku, color: 'var(--c-haiku)' },\n { fam: 'unknown', label: 'Other', n: counts.unknown, color: 'var(--c-unknown)' },\n ].filter((s) => s.n > 0);\n\n const svg = $('#donut-svg');\n svg.querySelectorAll('.donut-seg').forEach((el) => el.remove());\n\n const C = 2 * Math.PI * 52; // ≈ 326.7\n let offset = 0;\n const ns = 'http://www.w3.org/2000/svg';\n if (total === 0) {\n // pleasant empty state — leave just the track\n } else {\n for (const s of segs) {\n const arc = (s.n / total) * C;\n const c = document.createElementNS(ns, 'circle');\n c.setAttribute('cx', '70');\n c.setAttribute('cy', '70');\n c.setAttribute('r', '52');\n c.setAttribute('fill', 'none');\n c.setAttribute('stroke', s.color);\n c.setAttribute('stroke-width', '14');\n c.setAttribute('stroke-dasharray', arc + ' ' + C);\n c.setAttribute('stroke-dashoffset', String(-offset));\n c.setAttribute('transform', 'rotate(-90 70 70)');\n c.setAttribute('stroke-linecap', segs.length === 1 ? 'round' : 'butt');\n c.classList.add('donut-seg');\n svg.appendChild(c);\n offset += arc;\n }\n }\n\n $('#donut-total').textContent = total;\n\n const legend = $('#donut-legend');\n legend.innerHTML = '';\n const lf = document.createDocumentFragment();\n const display = segs.length ? segs : [\n { fam: 'opus', label: 'Opus', n: 0, color: 'var(--c-opus)' },\n { fam: 'sonnet', label: 'Sonnet', n: 0, color: 'var(--c-sonnet)' },\n { fam: 'haiku', label: 'Haiku', n: 0, color: 'var(--c-haiku)' },\n ];\n for (const s of display) {\n const pct = total > 0 ? Math.round((s.n / total) * 100) : 0;\n const row = document.createElement('div');\n row.className = 'dl-row';\n row.innerHTML =\n '<span class=\"dl-dot\" style=\"background:' + s.color + '\"></span>' +\n '<span class=\"dl-name\">' + s.label + '</span>' +\n '<span class=\"dl-count\">' + s.n + '</span>' +\n '<span class=\"dl-pct\">' + pct + '%</span>';\n lf.appendChild(row);\n }\n legend.appendChild(lf);\n }\n\n // ----- master render -----\n function applyData(data) {\n const turns = data.recent_turns || [];\n const gates = data.recent_gates || [];\n\n renderSession(turns);\n renderSavings(data.global);\n renderCostHero(data.global);\n renderMoat(data.global);\n renderToolUsage(data.global);\n renderTurns(turns);\n renderGateMini(gates);\n\n // Donut: sum per-project model counts (uncapped, full history) so it\n // shows true all-time usage rather than just the recent-turns window.\n const modelCounts = {};\n for (const p of (data.projects || []))\n for (const [m, n] of Object.entries(p.models || {}))\n modelCounts[m] = (modelCounts[m] || 0) + n;\n renderDonut(modelCounts);\n }\n\n // ----- polling -----\n async function tick() {\n try {\n const res = await fetch('/data');\n if (!res.ok) throw new Error('HTTP ' + res.status);\n const data = await res.json();\n lastData = data;\n { const ap = data.active?.project_root || '—'; const el = $('#active-project'); el.textContent = ap; el.title = ap; }\n renderProjects(data.projects || []);\n applyData(data);\n $('#status').textContent = 'live · ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n $('#dot').classList.add('live'); $('#dot').classList.remove('dead');\n } catch (e) {\n $('#status').textContent = 'offline';\n $('#dot').classList.add('dead'); $('#dot').classList.remove('live');\n }\n }\n\n // ----- Viewport-clamped tooltip -----\n const tooltipEl = document.createElement('div');\n tooltipEl.className = 'global-tooltip';\n document.body.appendChild(tooltipEl);\n let activeTooltipTarget = null;\n\n function positionTooltip(target) {\n const rect = target.getBoundingClientRect();\n const ttRect = tooltipEl.getBoundingClientRect();\n const margin = 12;\n let top = rect.top - ttRect.height - 10;\n let left = rect.left + rect.width / 2 - ttRect.width / 2;\n if (left < margin) left = margin;\n if (left + ttRect.width > window.innerWidth - margin) {\n left = window.innerWidth - ttRect.width - margin;\n }\n if (top < margin) {\n top = rect.bottom + 10;\n }\n if (top + ttRect.height > window.innerHeight - margin) {\n top = window.innerHeight - ttRect.height - margin;\n }\n tooltipEl.style.top = top + 'px';\n tooltipEl.style.left = left + 'px';\n }\n\n function showTooltip(target) {\n const text = target.getAttribute('data-tooltip');\n if (!text) return;\n tooltipEl.textContent = text;\n tooltipEl.classList.add('on');\n positionTooltip(target);\n }\n\n function hideTooltip() {\n tooltipEl.classList.remove('on');\n }\n\n document.addEventListener('mouseover', (e) => {\n const t = (e.target instanceof Element) ? e.target.closest('.has-tooltip') : null;\n if (t !== activeTooltipTarget) {\n activeTooltipTarget = t;\n if (t) showTooltip(t);\n else hideTooltip();\n }\n });\n document.addEventListener('scroll', hideTooltip, true);\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTooltip(); });\n\n setHeroDate();\n tick();\n setInterval(tick, 10000);\n </script>\n</body>\n</html>\n","/* Synthra dashboard · v0.2 · Cool Marine\n Darkened surfaces; brand blue reserved for hero elements only.\n Layout: top nav + hero strip + 3-column main, fits 1280×720. */\n\n:root {\n /* Core palette */\n --ink: #04081A;\n --navy: #0A1530;\n --navy-2: #122549;\n --deep-blue: #1B3A78;\n --blue: #2C5DB8;\n --blue-bright: #5C8FE6;\n --sky: #9BC2EF;\n --mist: #D7E6F7;\n --bone: #F4F7FC;\n\n /* Text */\n --text: #ECF2FB;\n --text-dim: #A9BBD6;\n --text-mute: #6D80A0;\n\n /* Rules / dividers */\n --rule: rgba(155, 194, 239, .14);\n --rule-2: rgba(155, 194, 239, .06);\n --rule-hover: rgba(155, 194, 239, .28);\n\n /* Surfaces (darker than v0.1.2) */\n --surface-1: rgba(18, 37, 73, .14);\n --surface-2: rgba(18, 37, 73, .22);\n --surface-3: rgba(4, 8, 26, .55);\n\n /* Signal accents (OKLCH shared chroma) */\n --signal-cyan: oklch(78% 0.14 220);\n --signal-amber: oklch(78% 0.14 75);\n --signal-rose: oklch(70% 0.14 20);\n --signal-green: oklch(75% 0.14 155);\n --signal-violet: oklch(72% 0.14 285);\n\n /* Model family colors */\n --c-opus: #FF6338;\n --c-sonnet: #FFB938;\n --c-haiku: #7438FF;\n --c-unknown: #12CBF5;\n\n /* Money */\n --money: var(--signal-green);\n\n /* Type */\n --font-sans: \"Geist\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-serif: \"Instrument Serif\", \"Times New Roman\", serif;\n --font-mono: \"Geist Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n}\n\n/* ============================================================\n Reset + base\n ============================================================ */\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n}\n\nhtml,\nbody {\n height: 100vh;\n overflow: hidden;\n}\n\nbody {\n background: var(--ink);\n color: var(--text);\n font-family: var(--font-sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n display: grid;\n grid-template-rows: auto 1fr auto;\n position: relative;\n}\n\n/* Layered backdrop — quieter */\nbody::before,\nbody::after {\n content: \"\";\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 0;\n}\n\nbody::before {\n background-image: radial-gradient(circle, rgba(155, 194, 239, .06) 1px, transparent 1.2px);\n background-size: 22px 22px;\n}\n\nbody::after {\n background:\n radial-gradient(60% 40% at 50% 105%, rgba(44, 93, 184, .16) 0%, rgba(10, 21, 48, 0) 65%),\n radial-gradient(30% 25% at 50% 0%, rgba(92, 143, 230, .06) 0%, transparent 70%);\n}\n\nbody>* {\n position: relative;\n z-index: 1;\n}\n\nbutton {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: transparent;\n color: inherit;\n}\n\na {\n color: inherit;\n text-decoration: none;\n}\n\n/* ============================================================\n Top nav\n ============================================================ */\n.topnav {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: center;\n height: 52px;\n padding: 0 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(180deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.brand-mark {\n width: 22px;\n height: 22px;\n border-radius: 7px;\n background: radial-gradient(120% 120% at 30% 30%, #6FA6E8 0%, #2C5DB8 45%, #0A1530 100%);\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22), 0 4px 12px -6px #2C5DB8;\n}\n\n.brand-name {\n font-size: 15px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--mist);\n}\n\n.brand-name em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: 0;\n}\n\n.brand-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-left: 6px;\n padding-left: 10px;\n border-left: 1px solid var(--rule);\n}\n\n.top-right {\n display: flex;\n align-items: center;\n gap: 12px;\n grid-column: 2;\n justify-self: center;\n}\n\n.topnav-right {\n grid-column: 3;\n justify-self: end;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.port-badge {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding: 6px 10px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n}\n\n.port-badge .mono {\n color: var(--text-dim);\n letter-spacing: 0.04em;\n text-transform: none;\n}\n\n.faq-btn {\n width: 30px;\n height: 30px;\n border-radius: 50%;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .55);\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 13px;\n font-weight: 500;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, border-color 180ms, color 180ms, transform 180ms;\n}\n\n.faq-btn:hover {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n\n.status-pill {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 6px 12px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-dim);\n transition: border-color 240ms ease;\n}\n\n.status-pill:has(.dot.live) {\n border-color: rgba(155, 194, 239, .45);\n color: var(--mist);\n animation: pill-glow 2.4s ease-in-out infinite;\n}\n\n.status-pill:has(.dot.dead) {\n border-color: rgba(220, 90, 90, .40);\n color: oklch(80% 0.10 20);\n}\n\n@keyframes pill-glow {\n\n 0%,\n 100% {\n box-shadow: 0 0 14px -4px rgba(155, 194, 239, .30), inset 0 0 12px -8px rgba(155, 194, 239, .30);\n }\n\n 50% {\n box-shadow: 0 0 26px -2px rgba(155, 194, 239, .55), inset 0 0 18px -6px rgba(155, 194, 239, .45);\n }\n}\n\n.dot {\n width: 7px;\n height: 7px;\n border-radius: 2px;\n background: var(--text-mute);\n transition: background 200ms;\n}\n\n.dot.live {\n background: var(--signal-cyan);\n animation: dot-pulse 1.8s ease-in-out infinite;\n}\n\n.dot.dead {\n background: var(--signal-rose);\n box-shadow: 0 0 0 3px rgba(220, 90, 90, .10);\n}\n\n@keyframes dot-pulse {\n\n 0%,\n 100% {\n box-shadow:\n 0 0 0 3px rgba(155, 194, 239, .10),\n 0 0 6px rgba(155, 194, 239, .50);\n }\n\n 50% {\n box-shadow:\n 0 0 0 6px rgba(155, 194, 239, .05),\n 0 0 14px rgba(155, 194, 239, .90);\n }\n}\n\n/* ============================================================\n Hero strip\n ============================================================ */\n.hero-strip {\n display: flex;\n align-items: center;\n gap: 24px;\n padding: 14px 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(90deg, rgba(27, 58, 120, .10) 0%, rgba(4, 8, 26, 0) 100%);\n position: relative;\n overflow: hidden;\n}\n\n.hero-spacer {\n flex: 1;\n}\n\n.date-block {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.d-day {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 38px;\n line-height: 1;\n letter-spacing: -0.04em;\n color: var(--mist);\n}\n\n.d-rest {\n display: flex;\n flex-direction: column;\n gap: 2px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-dim);\n}\n\n.d-rest .d-mute {\n color: var(--text-mute);\n}\n\n.active-block {\n display: flex;\n flex-direction: column;\n gap: 2px;\n text-align: right;\n max-width: 360px;\n overflow: hidden;\n}\n\n.ab-label {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.ab-value {\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 360px;\n}\n\n/* ============================================================\n Main grid\n ============================================================ */\n.grid-main {\n display: grid;\n grid-template-columns: 260px 1fr 340px;\n gap: 16px;\n padding: 16px 24px;\n min-height: 0;\n z-index: 10;\n}\n\n.col-left,\n.col-center,\n.col-right {\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n}\n\n/* ============================================================\n Panels / cards — darker\n ============================================================ */\n.panel,\n.card {\n position: relative;\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n padding: 14px 16px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n transition: border-color 180ms ease, background 180ms ease;\n}\n\n.card.has-tooltip {\n cursor: help;\n}\n\n.card.has-tooltip:hover {\n border-color: var(--rule-hover);\n background: var(--surface-2);\n}\n\n.card-head {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 12px;\n}\n\n.card-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.card-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.card-meta {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n/* Legend panel */\n.panel {\n padding: 14px 14px 16px;\n gap: 14px;\n flex-shrink: 0;\n}\n\n.p-head {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.p-section {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.p-section+.p-section {\n padding-top: 12px;\n border-top: 1px solid var(--rule-2);\n}\n\n.ps-head {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 4px;\n}\n\n.check {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 3px 6px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n letter-spacing: 0.02em;\n}\n\nbutton.check {\n border: 0;\n background: transparent;\n width: 100%;\n text-align: left;\n}\n\n.check-clickable {\n cursor: pointer;\n border-radius: 6px;\n padding: 5px 6px;\n transition: background 140ms, color 140ms, transform 140ms;\n}\n\n.check-clickable .pf-arrow {\n margin-left: auto;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 12px;\n transition: color 140ms, transform 140ms;\n}\n\n.check-clickable:hover {\n background: rgba(155, 194, 239, .07);\n color: var(--mist);\n}\n\n.check-clickable:hover .pf-arrow {\n color: var(--sky);\n transform: translateX(2px);\n}\n\n.dot-sq {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n background: var(--text-mute);\n flex-shrink: 0;\n}\n\n.dot-sq.opus {\n background: var(--c-opus);\n}\n\n.dot-sq.sonnet {\n background: var(--c-sonnet);\n}\n\n.dot-sq.haiku {\n background: var(--c-haiku);\n}\n\n.dot-sq.unknown {\n background: var(--c-unknown);\n}\n\n.proj-filter {\n display: flex;\n flex-direction: column;\n gap: 1px;\n max-height: 90px;\n overflow-y: auto;\n}\n\n.pf-name {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 140px;\n}\n\n/* ============================================================\n Donut card (model usage)\n ============================================================ */\n.donut-card {\n flex: 1;\n gap: 10px;\n}\n\n.donut-wrap {\n position: relative;\n width: 140px;\n height: 140px;\n margin: 4px auto 0;\n}\n\n.donut {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.donut-track {\n fill: none;\n stroke: rgba(155, 194, 239, .07);\n stroke-width: 14;\n}\n\n.donut-seg {\n transition: stroke-dashoffset 400ms ease, stroke-dasharray 400ms ease;\n}\n\n.donut-center {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n pointer-events: none;\n}\n\n.donut-total {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.donut-total-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.donut-legend {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dl-row {\n display: grid;\n grid-template-columns: auto 1fr auto;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n}\n\n.dl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.dl-name {\n color: var(--text-dim);\n}\n\n.dl-pct {\n color: var(--mist);\n font-weight: 500;\n}\n\n/* ============================================================\n Center column — Metric strip (no card chrome, divider-separated)\n ============================================================ */\n.metric-strip {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n overflow: hidden;\n flex-shrink: 0;\n}\n\n.metric-item {\n padding: 14px 18px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n cursor: help;\n border-right: 1px solid var(--rule-2);\n transition: background 200ms ease;\n min-width: 0;\n}\n.metric-item:last-child { border-right: 0; }\n.metric-item:hover { background: var(--surface-2); }\n\n.m-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.m-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1;\n}\n\n.m-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: -0.005em;\n}\n\n/* ============================================================\n Savings card\n ============================================================ */\n.card.savings {\n flex-shrink: 0;\n gap: 12px;\n background:\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 50%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .18);\n}\n\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .32);\n}\n\n.savings-body {\n display: grid;\n grid-template-columns: auto 1fr;\n align-items: center;\n gap: 18px;\n}\n\n.savings-figure {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.savings-money {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n}\n\n.savings-tokens {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.savings-bar {\n position: relative;\n height: 8px;\n border-radius: 999px;\n overflow: hidden;\n background: var(--surface-3);\n display: flex;\n}\n\n.savings-actual {\n height: 100%;\n background: rgba(215, 230, 247, .55);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.savings-saved {\n height: 100%;\n background: var(--signal-green);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12);\n}\n\n.savings-legend {\n grid-column: 2;\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 24px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.08em;\n color: var(--text-mute);\n margin-top: 8px;\n}\n\n.sl-row {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.sl-row b {\n color: var(--mist);\n font-weight: 500;\n letter-spacing: 0.04em;\n}\n\n.sl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.sl-dot.actual {\n background: var(--mist);\n}\n\n.sl-dot.saved {\n background: var(--signal-green);\n}\n\n/* ============================================================\n Recent turns table\n ============================================================ */\n.turns-card {\n flex: 1;\n padding: 0;\n overflow: hidden;\n}\n\n.turns-card .card-head {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.turns-scroll {\n flex: 1;\n overflow-y: auto;\n min-height: 0;\n}\n\n.turns-table {\n width: 100%;\n border-collapse: collapse;\n}\n\n.turns-table thead th {\n position: sticky;\n top: 0;\n background: var(--ink);\n padding: 9px 16px;\n text-align: left;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n font-weight: 500;\n border-bottom: 1px solid var(--rule);\n z-index: 1;\n}\n\n.turns-table thead th.num {\n text-align: right;\n}\n\n.turns-table tbody td {\n padding: 8px 16px;\n border-bottom: 1px solid var(--rule-2);\n color: var(--text-dim);\n font-size: 12px;\n}\n\n.turns-table tbody td.num {\n text-align: right;\n font-family: var(--font-mono);\n}\n\n.turns-table tbody td.cost {\n color: var(--money);\n font-family: var(--font-mono);\n font-weight: 500;\n}\n\n.turns-table tbody td.ts {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n}\n\n.turns-table tbody td.proj {\n color: var(--mist);\n}\n\n.turns-table tbody tr:hover {\n background: rgba(155, 194, 239, .03);\n}\n\n.turns-table tbody tr:last-child td {\n border-bottom: 0;\n}\n\n/* Model pills */\n.model-pill {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 2px 8px;\n border-radius: 999px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.04em;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .5);\n color: var(--mist);\n}\n\n.model-pill .sq {\n width: 6px;\n height: 6px;\n border-radius: 2px;\n background: var(--text-mute);\n}\n\n.model-pill.opus {\n color: #FF8A66;\n border-color: rgba(255, 99, 56, .32);\n background: rgba(255, 99, 56, .07);\n}\n\n.model-pill.opus .sq {\n background: var(--c-opus);\n}\n\n.model-pill.sonnet {\n color: #FFC766;\n border-color: rgba(255, 185, 56, .32);\n background: rgba(255, 185, 56, .07);\n}\n\n.model-pill.sonnet .sq {\n background: var(--c-sonnet);\n}\n\n.model-pill.haiku {\n color: #A878FF;\n border-color: rgba(116, 56, 255, .42);\n background: rgba(116, 56, 255, .10);\n}\n\n.model-pill.haiku .sq {\n background: var(--c-haiku);\n}\n\n.model-pill.unknown {\n color: #5BDDF7;\n border-color: rgba(18, 203, 245, .32);\n background: rgba(18, 203, 245, .07);\n font-style: italic;\n}\n\n.model-pill.unknown .sq {\n background: var(--c-unknown);\n}\n\n/* ============================================================\n Right column — Cost hero\n ============================================================ */\n.cost-hero {\n position: relative;\n overflow: hidden;\n background:\n radial-gradient(120% 80% at 50% 110%, rgba(44, 93, 184, .18) 0%, rgba(4, 8, 26, .20) 60%),\n var(--surface-1);\n padding: 16px 18px 18px;\n gap: 10px;\n flex-shrink: 0;\n}\n\n.big-money {\n position: relative;\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 42px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n margin-top: 2px;\n}\n\n.big-money em {\n font-family: inherit;\n font-style: normal;\n font-weight: inherit;\n color: inherit;\n letter-spacing: inherit;\n opacity: 1;\n}\n\n.cost-sub {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin-top: 4px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.cs-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n font-family: var(--font-mono);\n font-size: 11px;\n}\n\n.cs-k {\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.cs-v {\n color: var(--mist);\n}\n\n/* ============================================================\n Moat card\n ============================================================ */\n.moat {\n flex: 1;\n gap: 8px;\n}\n\n.moat-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.03em;\n line-height: 1;\n color: var(--mist);\n margin-top: 2px;\n}\n\n.moat-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 18px;\n color: var(--sky);\n letter-spacing: 0;\n margin-left: 6px;\n}\n\n.gate-mini {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.gate-row {\n display: grid;\n grid-template-columns: auto auto 1fr;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-dim);\n padding: 3px 0;\n}\n\n.gate-row .g-ts {\n color: var(--text-mute);\n font-size: 9px;\n min-width: 38px;\n}\n\n.gate-row .g-decision {\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 999px;\n}\n\n.gate-row .g-decision.block {\n color: var(--signal-rose);\n background: rgba(220, 90, 90, .06);\n}\n\n.gate-row .g-decision.allow {\n color: var(--text-mute);\n background: rgba(155, 194, 239, .03);\n}\n\n.gate-row .g-q {\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ============================================================\n Tooltips\n ============================================================ */\n.has-tooltip {\n position: relative;\n}\n\n/* Global JS-positioned tooltip — viewport-clamped */\n.global-tooltip {\n position: fixed;\n top: 0;\n left: 0;\n background: linear-gradient(180deg, rgba(18, 37, 73, .98), rgba(10, 21, 48, .98));\n color: var(--mist);\n border: 1px solid var(--rule-hover);\n border-radius: 12px;\n padding: 14px 16px;\n font-family: var(--font-sans);\n font-size: 15px;\n font-weight: 400;\n text-transform: none;\n letter-spacing: 0;\n white-space: normal;\n width: 320px;\n max-width: calc(100vw - 24px);\n text-align: left;\n line-height: 1.55;\n box-shadow: 0 16px 36px rgba(0, 0, 0, .7);\n backdrop-filter: blur(10px);\n z-index: 99999;\n opacity: 0;\n pointer-events: none;\n transform: translateY(6px);\n transition: opacity 180ms ease, transform 180ms ease;\n}\n\n.global-tooltip.on {\n opacity: 1;\n transform: translateY(0);\n}\n\n/* ============================================================\n Footer\n ============================================================ */\n.foot {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 24px;\n border-top: 1px solid var(--rule);\n background: linear-gradient(0deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.foot em {\n font-family: var(--font-serif);\n font-style: italic;\n text-transform: none;\n letter-spacing: 0;\n color: var(--sky);\n font-size: 12px;\n}\n\n.foot .mono {\n color: var(--text-dim);\n text-transform: none;\n letter-spacing: 0.04em;\n}\n\n/* ============================================================\n Empty state\n ============================================================ */\n.empty {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.06em;\n color: var(--text-mute);\n text-align: center;\n padding: 16px 8px;\n font-style: italic;\n text-transform: none;\n}\n\n/* Scrollbar styling */\n.turns-scroll::-webkit-scrollbar,\n.proj-chart::-webkit-scrollbar,\n.gate-mini::-webkit-scrollbar {\n width: 6px;\n}\n\n.turns-scroll::-webkit-scrollbar-thumb,\n.proj-chart::-webkit-scrollbar-thumb,\n.gate-mini::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.turns-scroll::-webkit-scrollbar-track,\n.proj-chart::-webkit-scrollbar-track,\n.gate-mini::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ============================================================\n Staggered cascade on first paint (one-time, MOTION 6)\n ============================================================ */\n@keyframes cascade-in {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .col-left > *,\n .col-center > *,\n .col-right > * {\n opacity: 0;\n animation: cascade-in 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards;\n will-change: transform, opacity;\n }\n .col-left > *:nth-child(1) { animation-delay: 0ms; }\n .col-left > *:nth-child(2) { animation-delay: 120ms; }\n .col-center > *:nth-child(1) { animation-delay: 40ms; }\n .col-center > *:nth-child(2) { animation-delay: 140ms; }\n .col-center > *:nth-child(3) { animation-delay: 240ms; }\n .col-right > *:nth-child(1) { animation-delay: 80ms; }\n .col-right > *:nth-child(2) { animation-delay: 200ms; }\n .col-right > *:nth-child(3) { animation-delay: 320ms; }\n\n /* Clear will-change after animation completes */\n .col-left > *,\n .col-center > *,\n .col-right > * {\n animation-fill-mode: forwards;\n }\n}\n\n/* ============================================================\n Source / basis annotations\n ============================================================ */\n.card-source {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.08em;\n text-transform: lowercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-top: auto;\n padding-top: 8px;\n border-top: 1px solid var(--rule-2);\n width: 100%;\n}\n\n.src-badge {\n font-family: var(--font-mono);\n font-size: 8px;\n font-weight: 500;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 4px;\n flex-shrink: 0;\n}\n\n.src-badge.verified {\n color: var(--signal-green);\n background: rgba(123, 255, 199, .08);\n border: 1px solid rgba(123, 255, 199, .25);\n}\n\n.src-badge.estimated,\n.src-badge.estimated.floor {\n color: var(--signal-amber);\n background: rgba(255, 185, 56, .10);\n border: 1px solid rgba(255, 185, 56, .30);\n}\n\n.src-badge.priced {\n color: var(--signal-cyan);\n background: rgba(155, 194, 239, .08);\n border: 1px solid rgba(155, 194, 239, .25);\n}\n\n/* Eyebrow that contains a badge */\n.card-eyebrow .src-badge {\n margin-left: 4px;\n}\n\n/* Savings audit row — live formula reveal */\n.savings-audit {\n margin-top: 10px;\n padding: 10px 12px;\n border: 1px dashed rgba(255, 185, 56, .25);\n border-radius: 8px;\n background: rgba(255, 185, 56, .04);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: 0.04em;\n color: var(--text-mute);\n text-align: center;\n}\n\n.savings-audit b {\n color: var(--text-dim);\n font-weight: 500;\n}\n\n.savings-audit .audit-result {\n color: var(--money);\n}\n\n/* ============================================================\n FAQ dialog\n ============================================================ */\n.dialog.dialog-faq {\n max-width: min(80vw, 1100px);\n width: 100%;\n max-height: 86vh;\n display: flex;\n flex-direction: column;\n padding: 28px 32px 24px;\n gap: 6px;\n}\n\n.dialog.dialog-faq .dialog-path {\n margin-bottom: 4px;\n word-break: normal;\n overflow-wrap: anywhere;\n}\n\n.faq-content {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n margin-top: 18px;\n padding-right: 8px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content::-webkit-scrollbar {\n width: 6px;\n}\n\n.faq-content::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.faq-content::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.faq-content details {\n border: 1px solid var(--rule);\n border-radius: 12px;\n background: var(--surface-1);\n overflow: hidden;\n transition: background 180ms, border-color 180ms;\n flex-shrink: 0;\n}\n\n.faq-content details:hover {\n border-color: rgba(155, 194, 239, .22);\n}\n\n.faq-content details[open] {\n background: var(--surface-2);\n border-color: var(--rule-hover);\n}\n\n.faq-content summary {\n cursor: pointer;\n padding: 14px 20px;\n font-family: var(--font-sans);\n font-size: 14px;\n font-weight: 500;\n color: var(--mist);\n list-style: none;\n display: flex;\n align-items: center;\n gap: 12px;\n user-select: none;\n}\n\n.faq-content summary::-webkit-details-marker {\n display: none;\n}\n\n.faq-content summary::before {\n content: \"›\";\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 14px;\n transition: transform 220ms ease, color 220ms ease;\n}\n\n.faq-content details[open] summary::before {\n transform: rotate(90deg);\n color: var(--sky);\n}\n\n.faq-content .faq-body {\n padding: 0 22px 20px 46px;\n color: var(--text-dim);\n font-size: 13.5px;\n line-height: 1.7;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content .faq-body p {\n margin: 0;\n}\n\n.faq-content .faq-body ul {\n margin: 0;\n padding-left: 20px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.faq-content .faq-body li {\n margin: 0;\n}\n\n.faq-content .faq-body b,\n.faq-content .faq-body strong {\n color: var(--mist);\n font-weight: 500;\n}\n\n.faq-content .faq-body code {\n font-family: var(--font-mono);\n font-size: 12px;\n background: rgba(155, 194, 239, .08);\n padding: 2px 6px;\n border-radius: 4px;\n color: var(--mist);\n border: 1px solid rgba(155, 194, 239, .12);\n word-break: break-word;\n}\n\n.faq-content .faq-body a {\n color: var(--blue-bright);\n text-decoration: underline;\n text-decoration-color: rgba(92, 143, 230, .40);\n text-underline-offset: 3px;\n transition: color 140ms, text-decoration-color 140ms;\n}\n\n.faq-content .faq-body a:hover {\n color: var(--mist);\n text-decoration-color: var(--sky);\n}\n\n.faq-content .faq-body table {\n width: 100%;\n border-collapse: collapse;\n margin: 4px 0;\n font-size: 13px;\n table-layout: fixed;\n}\n\n.faq-content .faq-body thead td {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 6px;\n border-bottom: 1px solid var(--rule);\n font-weight: 500;\n}\n\n.faq-content .faq-body td {\n padding: 9px 10px;\n border-bottom: 1px solid var(--rule-2);\n vertical-align: top;\n word-break: break-word;\n}\n\n.faq-content .faq-body tr:last-child td {\n border-bottom: 0;\n}\n\n.faq-content .faq-body td:first-child {\n color: var(--text-dim);\n width: 38%;\n}\n\n.faq-content .faq-body td:first-child code {\n font-size: 11.5px;\n}\n\n.faq-content .faq-body .formula-box {\n font-family: var(--font-mono);\n font-size: 12.5px;\n background: rgba(255, 185, 56, .06);\n padding: 12px 14px;\n border-radius: 8px;\n border: 1px dashed rgba(255, 185, 56, .30);\n color: var(--mist);\n letter-spacing: 0.02em;\n}\n\n.faq-content .faq-body .link-list {\n list-style: none;\n padding-left: 0;\n}\n\n.faq-content .faq-body .link-list li {\n padding-left: 18px;\n position: relative;\n}\n\n.faq-content .faq-body .link-list li::before {\n content: \"›\";\n position: absolute;\n left: 0;\n color: var(--sky);\n font-family: var(--font-mono);\n}\n\n.faq-content .faq-body .warning {\n margin-top: 14px;\n padding: 12px 14px;\n background: rgba(255, 185, 56, .06);\n border: 1px solid rgba(255, 185, 56, .25);\n border-left: 3px solid var(--signal-amber);\n border-radius: 8px;\n font-size: 12.5px;\n color: var(--text-dim);\n}\n\n.faq-content .faq-body .warning .icon {\n color: var(--signal-amber);\n margin-right: 8px;\n font-weight: 500;\n}\n\n/* ============================================================\n Project dialog\n ============================================================ */\n.dialog-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(4, 8, 26, .78);\n backdrop-filter: blur(10px);\n z-index: 10000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n animation: dlg-fade 180ms ease;\n}\n\n@keyframes dlg-fade {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\n.dialog {\n position: relative;\n width: 100%;\n max-width: 520px;\n background:\n radial-gradient(120% 80% at 50% 0%, rgba(44, 93, 184, .22) 0%, rgba(4, 8, 26, .20) 60%),\n linear-gradient(180deg, rgba(18, 37, 73, .88) 0%, rgba(10, 21, 48, .96) 100%);\n border: 1px solid var(--rule-hover);\n border-radius: 18px;\n padding: 28px 32px 32px;\n box-shadow:\n 0 30px 80px -20px rgba(0, 0, 0, .7),\n inset 0 1px 0 rgba(255, 255, 255, .04);\n animation: dlg-rise 220ms cubic-bezier(.2, .7, .2, 1);\n}\n\n@keyframes dlg-rise {\n from {\n opacity: 0;\n transform: translateY(8px) scale(.98);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.dialog-close {\n position: absolute;\n top: 14px;\n right: 14px;\n width: 30px;\n height: 30px;\n border-radius: 50%;\n color: var(--text-mute);\n font-size: 22px;\n line-height: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, color 180ms;\n}\n\n.dialog-close:hover {\n background: rgba(155, 194, 239, .10);\n color: var(--mist);\n}\n\n.dialog-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 10px;\n}\n\n.dialog-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.dialog-name {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 28px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1.1;\n}\n\n.dialog-path {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n margin-top: 6px;\n word-break: break-all;\n}\n\n.dialog-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 18px 24px;\n margin-top: 22px;\n padding-top: 20px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dg-cell {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.dg-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.dg-v {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 22px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.dg-v.money {\n color: var(--money);\n}\n\n.dg-v-sm {\n font-size: 13px;\n font-family: var(--font-mono);\n font-weight: 400;\n color: var(--text-dim);\n letter-spacing: 0;\n}\n\n/* ============================================================\n v0.3 visual refresh\n - merged model-family legend into donut (count column)\n - Projects -> colored bar chart\n - elevated Savings card\n ============================================================ */\n\n/* Left column sizing: donut natural height, projects fills + scrolls */\n.donut-card { flex: 0 0 auto; }\n\n/* Donut legend now carries a count column */\n.dl-row {\n grid-template-columns: auto 1fr auto auto;\n gap: 8px;\n padding: 1px 0;\n}\n.dl-count {\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n}\n.dl-pct {\n min-width: 30px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n/* ---- Projects bar chart ---- */\n.projects-card {\n flex: 1 1 auto;\n min-height: 0;\n gap: 10px;\n}\n.proj-chart {\n display: flex;\n flex-direction: column;\n gap: 9px;\n overflow-y: auto;\n min-height: 0;\n flex: 1;\n padding-right: 2px;\n}\n.proj-row {\n display: flex;\n flex-direction: column;\n gap: 7px;\n width: 100%;\n text-align: left;\n padding: 8px;\n border-radius: 9px;\n background: transparent;\n border: 0;\n cursor: pointer;\n transition: background 150ms ease;\n}\n.proj-row:hover { background: rgba(155, 194, 239, .055); }\n.pr-top {\n display: grid;\n grid-template-columns: auto 1fr auto auto;\n align-items: center;\n gap: 9px;\n}\n.pr-dot {\n width: 9px;\n height: 9px;\n border-radius: 3px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 9px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n.pr-name {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text-dim);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n letter-spacing: 0.01em;\n transition: color 150ms ease;\n}\n.proj-row:hover .pr-name { color: var(--mist); }\n.pr-turns {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--mist);\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.02em;\n}\n.pr-arrow {\n font-family: var(--font-mono);\n font-size: 13px;\n color: var(--text-mute);\n transition: color 150ms ease, transform 150ms ease;\n}\n.proj-row:hover .pr-arrow {\n color: var(--pc, var(--sky));\n transform: translateX(2px);\n}\n.pr-bar {\n position: relative;\n height: 5px;\n border-radius: 999px;\n background: var(--surface-3);\n overflow: hidden;\n}\n.pr-fill {\n display: block;\n height: 100%;\n border-radius: 999px;\n background: linear-gradient(90deg,\n color-mix(in oklch, var(--pc, var(--sky)) 45%, transparent) 0%,\n var(--pc, var(--sky)) 100%);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18);\n transition: width 640ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n/* ---- Elevated Savings card (priority focus) ---- */\n.card.savings {\n background:\n radial-gradient(120% 140% at 10% -10%, rgba(123, 255, 199, .14) 0%, rgba(4, 8, 26, .08) 44%),\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 52%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .24);\n box-shadow:\n inset 0 1px 0 rgba(123, 255, 199, .08),\n 0 20px 46px -30px rgba(123, 255, 199, .55);\n}\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .38);\n}\n.savings-money {\n font-size: 40px;\n text-shadow: 0 0 26px rgba(123, 255, 199, .22);\n}\n.savings-bar { height: 9px; }\n.savings-saved {\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, .18),\n 0 0 12px -2px var(--signal-green);\n}\n\n/* Project dialog: name gets a project-colored accent dot */\n.dialog-name.has-accent {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n.dialog-name.has-accent::before {\n content: \"\";\n width: 12px;\n height: 12px;\n border-radius: 4px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 12px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n\n\n/* ============================================================\n v0.3.1 — date + active project folded into the top nav\n ============================================================ */\n\n/* Let the right cluster shrink so the active path can ellipsize */\n.topnav-right { min-width: 0; }\n\n/* Compact date beside the brand */\n.nav-date {\n display: inline-flex;\n align-items: baseline;\n gap: 6px;\n margin-left: 8px;\n padding-left: 12px;\n border-left: 1px solid var(--rule);\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.nav-date .nd-day { color: var(--mist); font-weight: 500; }\n.nav-date .nd-weekday { color: var(--text-dim); }\n.nav-date .nd-month { color: var(--text-mute); }\n\n/* Active project, compact, tail-truncated */\n.nav-active {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n max-width: 300px;\n padding-right: 12px;\n margin-right: 2px;\n border-right: 1px solid var(--rule);\n cursor: help;\n}\n.na-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n flex-shrink: 0;\n}\n.na-value {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--mist);\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* keep the project folder (tail) visible, ellipsize the drive prefix */\n direction: rtl;\n text-align: left;\n}\n\n/* Tighten nav on narrow widths */\n@media (max-width: 1100px) {\n .nav-active { max-width: 200px; }\n .nav-date .nd-month { display: none; }\n}\n\n\n/* Column headers signal they are hover-explainable */\n.turns-table thead th.has-tooltip { cursor: help; }\n.turns-table thead th.has-tooltip:hover { color: var(--text-dim); }\n\n/* ---- Recent-turns pager ---- */\n.turns-pager {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 10px;\n padding: 8px 2px 0;\n margin-top: 6px;\n border-top: 1px solid var(--rule-2);\n}\n.turns-pager.hidden { display: none; }\n.turns-pager button {\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n background: rgba(4, 8, 26, .55);\n border: 1px solid var(--rule);\n border-radius: 7px;\n padding: 4px 10px;\n cursor: pointer;\n transition: background 150ms, border-color 150ms, color 150ms, transform 150ms;\n}\n.turns-pager button:hover:not(:disabled) {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n.turns-pager button:disabled {\n opacity: .35;\n cursor: default;\n}\n#turns-page-label {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n min-width: 84px;\n text-align: center;\n}\n"],"mappings":"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A;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA;;;ATuBA,IAAM,iBAAiB;AACvB,IAAM,UAAW,gBAAgC;AAIjD,IAAM,WAAW,OAAO,QAAQ,IAAI,sBAAsB,KAAK;AAQ/D,eAAsB,eACpB,OACA,gBAAgB,MACgB;AAChC,QAAM,OAAO,MAAM,aAAa,eAAe,gBAAgB,cAAc;AAC7E,MAAI,SAAS,eAAe;AAC1B,QAAI;AAAA,MACF,kBAAkB,aAAa,6BAAwB,IAAI;AAAA,IAC7D;AAAA,EACF;AACA,QAAM,MAAM,IAAI,KAAK;AAErB,MAAI,IAAI,KAAK,CAAC,MAAM,EAAE,KAAK,eAAU,WAAW,mBAAmB,OAAO,CAAC,CAAC;AAE5E,MAAI,IAAI,cAAc,CAAC,MAAM;AAC3B,MAAE,OAAO,gBAAgB,yBAAyB;AAClD,MAAE,OAAO,iBAAiB,UAAU;AACpC,WAAO,EAAE,KAAK,aAAQ;AAAA,EACxB,CAAC;AAED,MAAI,IAAI,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,KAAK,CAAC,CAAC;AAE9C,MAAI,IAAI,SAAS,OAAO,MAAM;AAC5B,UAAM,OAAO,MAAM,qBAAqB,OAAO,QAAQ;AACvD,WAAO,EAAE,KAAK,IAAI;AAAA,EACpB,CAAC;AAED,QAAM,aAAa,MAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,YAAY,CAAC;AAE1E,SAAO;AAAA,IACL;AAAA,IACA,KAAK,oBAAoB,IAAI;AAAA,IAC7B,MAAM,OAAO;AACX,YAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,mBAAW,MAAM,CAAC,QAAS,MAAM,OAAO,GAAG,IAAI,QAAQ,CAAE;AAAA,MAC3D,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":["readFile","join","readFile","basename"]}
|
|
1
|
+
{"version":3,"sources":["../../src/dashboard/server.ts","../../package.json","../../src/shared/logger.ts","../../src/server/port.ts","../../src/dashboard/delta.ts","../../src/learn/store.ts","../../src/learn/usage.ts","../../src/shared/paths.ts","../../src/shared/pricing.ts","../../src/shared/project-registry.ts","../../src/dashboard/public/index.html","../../src/dashboard/public/style.css","../../src/dashboard/public/favicon.svg"],"sourcesContent":["// Standalone dashboard server. Default port 8901 (override via\n// SYN_DASHBOARD_PORT); falls back through a small range 8901–8910 if the\n// preferred port is busy (so we can coexist with other co-installed\n// AI-context tools that also expose a dashboard).\n// Reads .synthra-graph/token_log.jsonl + .synthra-graph/gate_log.jsonl for the\n// given project and renders a live SPA backed by GET /data polled every 2s.\n\nimport { serve } from \"@hono/node-server\";\nimport { Hono } from \"hono\";\n\n// Tsup inlines this import at build time so `c.html` can echo whatever\n// version is running. Replaces the v__SYN_VERSION__ placeholder in the\n// dashboard footer on every GET /.\nimport pkgJson from \"../../package.json\" with { type: \"json\" };\n\nimport { log } from \"../shared/logger.js\";\nimport type { SynthraPaths } from \"../shared/paths.js\";\nimport { findFreePort } from \"../server/port.js\";\nimport { computeDashboardData } from \"./delta.js\";\n\nimport indexHtml from \"./public/index.html\";\nimport styleCss from \"./public/style.css\";\nimport faviconSvg from \"./public/favicon.svg\";\n\nconst FALLBACK_RANGE = 9; // try preferredPort + [0..9]\nconst VERSION = (pkgJson as { version: string }).version;\n// How many recent turns/gates the /data payload carries. The dashboard\n// paginates turns client-side (25/page); the donut uses the uncapped\n// per-project model aggregate, so it isn't bounded by this.\nconst RECENT_N = Number(process.env.SYN_DASHBOARD_RECENT_N) || 500;\n\nexport interface DashboardServerHandle {\n port: number;\n url: string;\n stop(): Promise<void>;\n}\n\nexport async function startDashboard(\n paths: SynthraPaths,\n preferredPort = 8901,\n): Promise<DashboardServerHandle> {\n const port = await findFreePort(preferredPort, preferredPort + FALLBACK_RANGE);\n if (port !== preferredPort) {\n log.info(\n `dashboard port ${preferredPort} was busy — bound to ${port} instead (likely another dashboard from a coexisting tool).`,\n );\n }\n const app = new Hono();\n\n app.get(\"/\", (c) => c.html(indexHtml.replaceAll(\"__SYN_VERSION__\", VERSION)));\n\n app.get(\"/style.css\", (c) => {\n c.header(\"Content-Type\", \"text/css; charset=utf-8\");\n c.header(\"Cache-Control\", \"no-cache\");\n return c.body(styleCss);\n });\n\n app.get(\"/favicon.svg\", (c) => {\n c.header(\"Content-Type\", \"image/svg+xml; charset=utf-8\");\n c.header(\"Cache-Control\", \"public, max-age=86400\");\n return c.body(faviconSvg);\n });\n\n app.get(\"/health\", (c) => c.json({ ok: true }));\n\n app.get(\"/data\", async (c) => {\n const data = await computeDashboardData(paths, RECENT_N);\n return c.json(data);\n });\n\n const nodeServer = serve({ fetch: app.fetch, port, hostname: \"127.0.0.1\" });\n\n return {\n port,\n url: `http://127.0.0.1:${port}`,\n async stop() {\n await new Promise<void>((resolve, reject) => {\n nodeServer.close((err) => (err ? reject(err) : resolve()));\n });\n },\n };\n}\n","{\n \"name\": \"@jefuriiij/synthra\",\n \"version\": \"0.3.1\",\n \"publishConfig\": {\n \"access\": \"public\"\n },\n \"description\": \"Local context engine for AI coding assistants — graph-based context, branch-aware memory, real-time human-activity awareness, deterministic Grep/Glob gating, and a live token dashboard.\",\n \"type\": \"module\",\n \"bin\": {\n \"syn\": \"bin/syn\",\n \"synthra\": \"bin/syn\"\n },\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"test:coverage\": \"vitest run --coverage\",\n \"typecheck\": \"tsc --noEmit\",\n \"lint\": \"biome lint .\",\n \"format\": \"biome format --write .\",\n \"check\": \"biome check .\",\n \"check:fix\": \"biome check --write .\"\n },\n \"files\": [\n \"dist\",\n \"bin\",\n \"README.md\",\n \"CHANGELOG.md\",\n \"LICENSE\",\n \"ROADMAP.md\"\n ],\n \"keywords\": [\n \"claude-code\",\n \"mcp\",\n \"context-engine\",\n \"code-graph\",\n \"ai-coding\",\n \"token-savings\"\n ],\n \"author\": \"Jeff (@jefuriiij)\",\n \"license\": \"MIT\",\n \"homepage\": \"https://github.com/jefuriiij/synthra#readme\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/jefuriiij/synthra.git\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/jefuriiij/synthra/issues\"\n },\n \"engines\": {\n \"node\": \">=18\"\n },\n \"dependencies\": {\n \"@hono/node-server\": \"^1.18.0\",\n \"chokidar\": \"^5.0.0\",\n \"cross-spawn\": \"^7.0.6\",\n \"hono\": \"^4.12.23\",\n \"ignore\": \"^7.0.0\",\n \"sade\": \"^1.8.1\",\n \"tree-sitter-wasms\": \"^0.1.12\",\n \"web-tree-sitter\": \"^0.25.10\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.16\",\n \"@types/cross-spawn\": \"^6.0.6\",\n \"@types/node\": \"^25.9.1\",\n \"@vitest/coverage-v8\": \"^4.1.8\",\n \"tsup\": \"^8.5.1\",\n \"typescript\": \"^6.0.3\",\n \"vitest\": \"^4.1.7\"\n }\n}\n","// Minimal logger. Prefixes Synthra output with [syn].\n\ntype Level = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LEVEL_PRIORITY: Record<Level, number> = {\n debug: 10,\n info: 20,\n warn: 30,\n error: 40,\n};\n\nlet activeLevel: Level = (process.env.SYN_LOG_LEVEL as Level) ?? \"info\";\n\nexport function setLevel(level: Level): void {\n activeLevel = level;\n}\n\nfunction shouldLog(level: Level): boolean {\n return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[activeLevel];\n}\n\nfunction emit(level: Level, msg: string, ...args: unknown[]): void {\n if (!shouldLog(level)) return;\n const stream = level === \"error\" || level === \"warn\" ? process.stderr : process.stdout;\n stream.write(`[syn] ${msg}${args.length ? \" \" + args.map(String).join(\" \") : \"\"}\\n`);\n}\n\nexport const log = {\n debug: (m: string, ...a: unknown[]) => emit(\"debug\", m, ...a),\n info: (m: string, ...a: unknown[]) => emit(\"info\", m, ...a),\n warn: (m: string, ...a: unknown[]) => emit(\"warn\", m, ...a),\n error: (m: string, ...a: unknown[]) => emit(\"error\", m, ...a),\n};\n","// Finds a free port in the 8080–8099 range. Writes the chosen port to\n// .synthra-graph/mcp_port so PowerShell/Bash hook scripts can read it.\n// TODO: M2\n\nimport { createServer } from \"node:net\";\n\nexport const PORT_RANGE_START = 8080;\nexport const PORT_RANGE_END = 8099;\n\nexport async function findFreePort(\n start = PORT_RANGE_START,\n end = PORT_RANGE_END,\n): Promise<number> {\n for (let port = start; port <= end; port++) {\n if (await isFree(port)) return port;\n }\n throw new Error(`Synthra: no free port in ${start}-${end}`);\n}\n\nfunction isFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = createServer();\n s.once(\"error\", () => resolve(false));\n s.once(\"listening\", () => s.close(() => resolve(true)));\n s.listen(port, \"127.0.0.1\");\n });\n}\n","// Reads token_log.jsonl + gate_log.jsonl for the active project AND every\n// project registered in ~/.synthra/projects.json, then computes the\n// dashboard's rendered shape: per-project + global aggregate + recent calls\n// across all projects.\n\nimport { readFile } from \"node:fs/promises\";\n\nimport { readLearnStore } from \"../learn/store.js\";\nimport { effectiveScores, emptyStore, type LearnStore } from \"../learn/usage.js\";\nimport { resolvePaths, type SynthraPaths } from \"../shared/paths.js\";\nimport { estimateCostUsd } from \"../shared/pricing.js\";\nimport { listProjects } from \"../shared/project-registry.js\";\n\nconst AVG_TOKENS_PER_BLOCKED_GREP = 500;\n\nexport interface TokenLogEntry {\n /** Stop-hook-supplied timestamp (preferred). */\n ts?: string;\n /** Server-side fallback added by handleLog when ts isn't provided. */\n written_at?: string;\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n model: string;\n description?: string;\n project: string;\n}\n\nexport interface GateLogEntry {\n ts: string;\n tool: string;\n decision: \"allow\" | \"block\";\n query: string | null;\n reason?: string;\n}\n\nexport interface ToolLogEntry {\n ts?: string;\n /** Synthra MCP tool name, e.g. \"graph_continue\", \"graph_read\". */\n tool: string;\n}\n\n/** Count Synthra MCP tool calls by tool name. (#2) */\nexport function countToolCalls(entries: ToolLogEntry[]): Record<string, number> {\n const out: Record<string, number> = {};\n for (const e of entries) {\n if (!e.tool) continue;\n out[e.tool] = (out[e.tool] ?? 0) + 1;\n }\n return out;\n}\n\nexport interface HotFile {\n path: string;\n score: number;\n}\n\n/** Top files by current (decayed-to-now) usage weight — surfaces what the\n * usage-learning layer has learned this repo leans on. Ranked by effective\n * score so recency matters; score rounded for display. */\nexport function topHotFiles(store: LearnStore, nowMs: number, limit = 8): HotFile[] {\n return [...effectiveScores(store, nowMs).entries()]\n .map(([path, score]) => ({ path, score: Math.round(score * 10) / 10 }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n}\n\nexport interface ProjectStats {\n path: string;\n name: string;\n last_seen: string | null;\n total_turns: number;\n total_input_tokens: number;\n total_output_tokens: number;\n total_cache_read: number;\n total_cache_create: number;\n total_gate_calls: number;\n blocked_count: number;\n estimated_tokens_saved: number;\n estimated_cost_usd: number;\n total_tool_calls: number;\n tool_calls: Record<string, number>;\n hot_files: HotFile[];\n hot_files_total: number;\n models: Record<string, number>;\n}\n\nexport interface RecentTurn {\n ts: string;\n project_name: string;\n project_path: string;\n input: number;\n output: number;\n cache_read: number;\n cache_create: number;\n model: string;\n cost_usd: number;\n}\n\nexport interface RecentGate {\n ts: string;\n project_name: string;\n project_path: string;\n tool: string;\n decision: \"allow\" | \"block\";\n query: string | null;\n}\n\nexport interface DashboardData {\n active: {\n project_root: string;\n project_name: string;\n stats: ProjectStats;\n };\n global: {\n project_count: number;\n total_turns: number;\n total_input_tokens: number;\n total_output_tokens: number;\n total_cache_read: number;\n total_cache_create: number;\n total_gate_calls: number;\n blocked_count: number;\n estimated_tokens_saved: number;\n saved_percent: number;\n estimated_cost_usd: number;\n total_tool_calls: number;\n tool_calls: Record<string, number>;\n };\n projects: ProjectStats[];\n recent_turns: RecentTurn[];\n recent_gates: RecentGate[];\n}\n\nasync function readJsonl<T>(path: string): Promise<T[]> {\n try {\n const text = await readFile(path, \"utf8\");\n return text\n .split(/\\r?\\n/)\n .filter((l) => l.length > 0)\n .map((l) => {\n try {\n return JSON.parse(l) as T;\n } catch {\n return null;\n }\n })\n .filter((v): v is T => v !== null);\n } catch {\n return [];\n }\n}\n\nfunction basename(p: string): string {\n const parts = p.split(/[\\\\/]/);\n return parts[parts.length - 1] || p;\n}\n\ninterface ProjectFiles {\n path: string;\n name: string;\n last_seen: string | null;\n tokens: TokenLogEntry[];\n gates: GateLogEntry[];\n tools: ToolLogEntry[];\n learn: LearnStore;\n}\n\nfunction summarize(p: ProjectFiles): ProjectStats {\n let totalIn = 0;\n let totalOut = 0;\n let totalCacheRead = 0;\n let totalCacheCreate = 0;\n let costUsd = 0;\n const models: Record<string, number> = {};\n\n for (const t of p.tokens) {\n totalIn += t.input_tokens ?? 0;\n totalOut += t.output_tokens ?? 0;\n totalCacheRead += t.cache_read_input_tokens ?? 0;\n totalCacheCreate += t.cache_creation_input_tokens ?? 0;\n costUsd += estimateCostUsd(t);\n if (t.model) models[t.model] = (models[t.model] ?? 0) + 1;\n }\n\n const blocked = p.gates.filter((g) => g.decision === \"block\").length;\n const saved = blocked * AVG_TOKENS_PER_BLOCKED_GREP;\n const now = Date.now();\n\n return {\n path: p.path,\n name: p.name,\n last_seen: p.last_seen,\n total_turns: p.tokens.length,\n total_input_tokens: totalIn,\n total_output_tokens: totalOut,\n total_cache_read: totalCacheRead,\n total_cache_create: totalCacheCreate,\n total_gate_calls: p.gates.length,\n blocked_count: blocked,\n estimated_tokens_saved: saved,\n estimated_cost_usd: Math.round(costUsd * 100) / 100,\n total_tool_calls: p.tools.length,\n tool_calls: countToolCalls(p.tools),\n hot_files: topHotFiles(p.learn, now),\n hot_files_total: effectiveScores(p.learn, now).size,\n models,\n };\n}\n\n// Dedupe is ON by default; set SYN_DASHBOARD_DEDUPE=0 (or \"off\"/\"false\") to see\n// every raw token-log entry — including duplicates a co-installed AI tool's Stop\n// hook may write to the same token_log.jsonl. (#7)\nfunction dedupeEnabled(): boolean {\n const v = process.env.SYN_DASHBOARD_DEDUPE;\n return v !== \"0\" && v !== \"off\" && v !== \"false\";\n}\n\nasync function loadProjectFiles(\n path: string,\n name: string,\n lastSeen: string | null,\n): Promise<ProjectFiles> {\n const paths = resolvePaths(path);\n const [rawTokens, gates, tools, learn] = await Promise.all([\n readJsonl<TokenLogEntry>(paths.tokenLog),\n readJsonl<GateLogEntry>(paths.gateLog),\n readJsonl<ToolLogEntry>(paths.toolLog),\n readLearnStore(paths.learnStore),\n ]);\n const tokens = dedupeEnabled() ? dedupeTokens(rawTokens) : rawTokens;\n return { path, name, last_seen: lastSeen, tokens, gates, tools, learn };\n}\n\n/**\n * Collapse duplicate token-log entries from co-installed AI tools.\n *\n * Synthra is friendly with other tools that share the .synthra-graph/\n * token_log.jsonl shape — if a second tool's Stop hook also writes to\n * it, both fire on the same turn and emit nearly-identical entries\n * within ~10ms, double-counting every metric in the dashboard.\n *\n * Strategy: group by (project, usage counts, second-rounded timestamp);\n * inside a group, keep the entry with the most credible model field —\n * a real Claude model > \"<synthetic>\" > empty.\n */\nfunction dedupeTokens(entries: TokenLogEntry[]): TokenLogEntry[] {\n const score = (model: string | undefined): number => {\n if (!model) return 0;\n if (model === \"<synthetic>\") return 1;\n return 2; // real model name\n };\n\n const groups = new Map<string, TokenLogEntry[]>();\n for (const e of entries) {\n const ts = e.ts ?? e.written_at ?? \"\";\n const second = ts.slice(0, 19); // YYYY-MM-DDTHH:mm:ss\n const key = [\n e.project ?? \"\",\n e.input_tokens ?? 0,\n e.output_tokens ?? 0,\n e.cache_creation_input_tokens ?? 0,\n e.cache_read_input_tokens ?? 0,\n second,\n ].join(\"|\");\n const arr = groups.get(key) ?? [];\n arr.push(e);\n groups.set(key, arr);\n }\n\n const out: TokenLogEntry[] = [];\n for (const arr of groups.values()) {\n if (arr.length === 1) {\n out.push(arr[0]!);\n continue;\n }\n arr.sort((a, b) => score(b.model) - score(a.model));\n out.push(arr[0]!);\n }\n\n // Preserve chronological order in the per-project list.\n out.sort((a, b) => {\n const at = a.ts ?? a.written_at ?? \"\";\n const bt = b.ts ?? b.written_at ?? \"\";\n return at.localeCompare(bt);\n });\n return out;\n}\n\nexport async function computeDashboardData(\n activePaths: SynthraPaths,\n recentN = 500,\n): Promise<DashboardData> {\n const registered = await listProjects();\n\n // Always include the active project, even if not yet in the registry.\n const activePath = activePaths.projectRoot;\n const activeName = basename(activePath);\n const knownPaths = new Set(registered.map((p) => p.path));\n const allEntries: Array<{ path: string; name: string; last_seen: string | null }> = [\n ...registered.map((p) => ({ path: p.path, name: p.name, last_seen: p.last_seen })),\n ];\n if (!knownPaths.has(activePath)) {\n allEntries.unshift({ path: activePath, name: activeName, last_seen: null });\n }\n\n const loaded = await Promise.all(\n allEntries.map((e) => loadProjectFiles(e.path, e.name, e.last_seen)),\n );\n\n const projects = loaded\n .map(summarize)\n .sort(\n (a, b) =>\n b.total_input_tokens +\n b.total_output_tokens -\n (a.total_input_tokens + a.total_output_tokens),\n );\n\n const activeFiles = loaded.find((p) => p.path === activePath) ?? {\n path: activePath,\n name: activeName,\n last_seen: null,\n tokens: [],\n gates: [],\n tools: [],\n learn: emptyStore(),\n };\n const activeStats = summarize(activeFiles);\n\n // Global aggregates\n let g_in = 0,\n g_out = 0,\n g_cr = 0,\n g_cc = 0,\n g_gate = 0,\n g_block = 0,\n g_cost = 0,\n g_turns = 0,\n g_tools = 0;\n const g_tool_calls: Record<string, number> = {};\n for (const s of projects) {\n g_turns += s.total_turns;\n g_in += s.total_input_tokens;\n g_out += s.total_output_tokens;\n g_cr += s.total_cache_read;\n g_cc += s.total_cache_create;\n g_gate += s.total_gate_calls;\n g_block += s.blocked_count;\n g_cost += s.estimated_cost_usd;\n g_tools += s.total_tool_calls;\n for (const [k, v] of Object.entries(s.tool_calls)) g_tool_calls[k] = (g_tool_calls[k] ?? 0) + v;\n }\n const g_saved = g_block * AVG_TOKENS_PER_BLOCKED_GREP;\n const g_used = g_in + g_out + g_cc;\n const g_saved_pct = g_used + g_saved > 0 ? (g_saved / (g_used + g_saved)) * 100 : 0;\n\n // Recent turns + gates across all projects, sorted by ts descending\n const allTurns: RecentTurn[] = [];\n const allGates: RecentGate[] = [];\n for (const p of loaded) {\n for (const t of p.tokens) {\n allTurns.push({\n // Fall back to written_at — the Stop hook today posts entries without\n // a `ts` field, and the server tags them with written_at on receive.\n ts: t.ts ?? t.written_at ?? \"\",\n project_name: p.name,\n project_path: p.path,\n input: t.input_tokens ?? 0,\n output: t.output_tokens ?? 0,\n cache_read: t.cache_read_input_tokens ?? 0,\n cache_create: t.cache_creation_input_tokens ?? 0,\n model: t.model ?? \"\",\n cost_usd: Math.round(estimateCostUsd(t) * 1000) / 1000,\n });\n }\n for (const gate of p.gates) {\n allGates.push({\n ts: gate.ts,\n project_name: p.name,\n project_path: p.path,\n tool: gate.tool,\n decision: gate.decision,\n query: gate.query,\n });\n }\n }\n allTurns.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));\n allGates.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));\n\n return {\n active: {\n project_root: activePath,\n project_name: activeName,\n stats: activeStats,\n },\n global: {\n project_count: projects.length,\n total_turns: g_turns,\n total_input_tokens: g_in,\n total_output_tokens: g_out,\n total_cache_read: g_cr,\n total_cache_create: g_cc,\n total_gate_calls: g_gate,\n blocked_count: g_block,\n estimated_tokens_saved: g_saved,\n saved_percent: Math.round(g_saved_pct * 10) / 10,\n estimated_cost_usd: Math.round(g_cost * 100) / 100,\n total_tool_calls: g_tools,\n tool_calls: g_tool_calls,\n },\n projects,\n recent_turns: allTurns.slice(0, recentN),\n recent_gates: allGates.slice(0, recentN),\n };\n}\n\n// Legacy shapes from the M2 stub — kept for compat.\nexport interface TurnBreakdown {\n systemPromptTokens: number;\n conversationHistoryTokens: number;\n synthraPackTokens: number;\n userMessageTokens: number;\n responseTokens: number;\n totalTokens: number;\n costUsd: number;\n}\n\nexport interface SavingsDelta {\n withSynthra: TurnBreakdown;\n estimatedWithoutSynthra: TurnBreakdown;\n savedUsd: number;\n savedPercent: number;\n}\n\nexport function computeDelta(breakdown: TurnBreakdown, blockedGreps: number): SavingsDelta {\n const savedTokens = blockedGreps * AVG_TOKENS_PER_BLOCKED_GREP;\n const without: TurnBreakdown = {\n ...breakdown,\n conversationHistoryTokens: breakdown.conversationHistoryTokens + savedTokens,\n totalTokens: breakdown.totalTokens + savedTokens,\n costUsd: breakdown.costUsd + (savedTokens / 1_000_000) * 3,\n };\n const savedUsd = without.costUsd - breakdown.costUsd;\n const savedPercent = without.totalTokens > 0 ? (savedTokens / without.totalTokens) * 100 : 0;\n return {\n withSynthra: breakdown,\n estimatedWithoutSynthra: without,\n savedUsd,\n savedPercent: Math.round(savedPercent * 10) / 10,\n };\n}\n","// I/O for the usage-learning layer. Two files, both in .synthra-graph/\n// (gitignored, machine-local — usage is per-developer):\n// - access_log.jsonl : append-only raw events (source of truth, replayable)\n// - learn_store.json : derived decayed aggregate (a cache of the log)\n//\n// Every read is best-effort: a missing / corrupt / schema-mismatched file yields\n// an empty store, so the ranker degrades to its deterministic behavior.\n\nimport { appendFile, mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\n\nimport { emptyStore, LEARN_SCHEMA_VERSION, type AccessEvent, type LearnStore } from \"./usage.js\";\n\nexport async function readLearnStore(path: string): Promise<LearnStore> {\n try {\n const raw = await readFile(path, \"utf8\");\n const parsed = JSON.parse(raw) as Partial<LearnStore>;\n if (\n parsed.schema_version !== LEARN_SCHEMA_VERSION ||\n typeof parsed.files !== \"object\" ||\n parsed.files === null\n ) {\n return emptyStore();\n }\n return {\n schema_version: LEARN_SCHEMA_VERSION,\n asOf: typeof parsed.asOf === \"string\" ? parsed.asOf : emptyStore().asOf,\n files: parsed.files as LearnStore[\"files\"],\n };\n } catch {\n return emptyStore();\n }\n}\n\nexport async function writeLearnStore(path: string, store: LearnStore): Promise<void> {\n try {\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, JSON.stringify(store, null, 2) + \"\\n\", \"utf8\");\n } catch {\n // Persistence is best-effort; the log remains the source of truth.\n }\n}\n\nexport async function readAccessLog(path: string): Promise<AccessEvent[]> {\n try {\n const raw = await readFile(path, \"utf8\");\n const out: AccessEvent[] = [];\n for (const line of raw.split(\"\\n\")) {\n const t = line.trim();\n if (!t) continue;\n try {\n const ev = JSON.parse(t) as AccessEvent;\n if (\n ev &&\n typeof ev.ts === \"string\" &&\n typeof ev.path === \"string\" &&\n typeof ev.source === \"string\"\n ) {\n out.push(ev);\n }\n } catch {\n // Skip a malformed line rather than failing the whole replay.\n }\n }\n return out;\n } catch {\n return [];\n }\n}\n\nexport async function appendAccess(path: string, ev: AccessEvent): Promise<void> {\n try {\n await mkdir(dirname(path), { recursive: true });\n await appendFile(path, JSON.stringify(ev) + \"\\n\", \"utf8\");\n } catch {\n // Best-effort; never fail a tool call over telemetry.\n }\n}\n","// Pure decay/aggregate core for the usage-learning signal (\"the more it's used,\n// the smarter it gets\"). No I/O here — persistence + the raw access log live in\n// store.ts, and the stateful wrapper in runtime.ts.\n//\n// Files the AI actually pulls (graph_read) or edits (graph_register_edit) accrue\n// a time-decayed weight. retrieve() applies a bounded, capped boost so genuinely\n// \"hot\" files surface first — never dominating the deterministic ranker.\n\nexport type AccessSource = \"read\" | \"register_edit\" | \"continue\";\n\nexport interface AccessEvent {\n ts: string; // ISO timestamp\n path: string; // canonical FileNode.path (\"\" for source === \"continue\")\n source: AccessSource;\n query?: string; // only set for source === \"continue\" — reserved fuel for v2/v3\n}\n\nexport interface FileStat {\n /** Raw access count (never decayed) — diagnostic only. */\n count: number;\n /** Decayed weight as of `lastTs`. */\n decayed: number;\n /** ISO timestamp of the most recent folded event. */\n lastTs: string;\n}\n\nexport interface LearnStore {\n schema_version: number;\n asOf: string;\n files: Record<string, FileStat>;\n}\n\nexport const LEARN_SCHEMA_VERSION = 1;\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\n/** Usage-decay half-life. Env-overridable for dogfood tuning. */\nexport function halfLifeMs(): number {\n const env = Number(process.env.SYN_LEARN_HALFLIFE_DAYS);\n const days = Number.isFinite(env) && env > 0 ? env : 7;\n return days * DAY_MS;\n}\n\n/** Per-source accrual weight. `continue` never contributes to frequency — it's\n * logged only as query→outcome fuel for a future mechanism. */\nexport function weightFor(source: AccessSource): number {\n switch (source) {\n case \"register_edit\":\n return 2;\n case \"read\":\n return 1;\n default:\n return 0;\n }\n}\n\nexport function emptyStore(): LearnStore {\n return {\n schema_version: LEARN_SCHEMA_VERSION,\n asOf: new Date(0).toISOString(),\n files: {},\n };\n}\n\n/** exp(-λ·Δt) decay multiplier from `fromTs` forward to `toMs` epoch ms. */\nfunction decayFactor(fromTs: string, toMs: number, hl: number): number {\n const fromMs = Date.parse(fromTs);\n if (!Number.isFinite(fromMs)) return 1;\n const dt = toMs - fromMs;\n if (dt <= 0) return 1;\n return Math.exp(-(Math.LN2 / hl) * dt);\n}\n\n/** Fold one access event into the store: decay the file's prior weight up to the\n * event's timestamp, then add the event weight. Mutates and returns `store`.\n * Zero-weight (`continue`) and path-less events are ignored. Malformed\n * timestamps are dropped so the function stays deterministic. */\nexport function foldEvent(store: LearnStore, ev: AccessEvent): LearnStore {\n const w = weightFor(ev.source);\n if (w <= 0 || !ev.path) return store;\n const tMs = Date.parse(ev.ts);\n if (!Number.isFinite(tMs)) return store;\n\n const hl = halfLifeMs();\n const prev = store.files[ev.path];\n if (prev) {\n const decayed = prev.decayed * decayFactor(prev.lastTs, tMs, hl) + w;\n store.files[ev.path] = { count: prev.count + 1, decayed, lastTs: ev.ts };\n } else {\n store.files[ev.path] = { count: 1, decayed: w, lastTs: ev.ts };\n }\n return store;\n}\n\n/** Decayed weights as of `nowMs`, keyed by path. Effectively-zero entries are\n * omitted so an old/cold store contributes nothing. */\nexport function effectiveScores(store: LearnStore, nowMs: number): Map<string, number> {\n const hl = halfLifeMs();\n const out = new Map<string, number>();\n for (const [path, stat] of Object.entries(store.files)) {\n const eff = stat.decayed * decayFactor(stat.lastTs, nowMs, hl);\n if (eff > 0.01) out.set(path, eff);\n }\n return out;\n}\n\n/** Rebuild a store by replaying a raw access-log stream (the source of truth). */\nexport function recomputeFromLog(events: AccessEvent[]): LearnStore {\n const store = emptyStore();\n for (const ev of events) foldEvent(store, ev);\n return store;\n}\n","// Resolves Synthra's storage locations inside a project root.\n\nimport { join } from \"node:path\";\n\nexport interface SynthraPaths {\n projectRoot: string;\n graphDir: string;\n contextDir: string;\n infoGraph: string;\n symbolIndex: string;\n sessionState: string;\n activityLog: string;\n tokenLog: string;\n gateLog: string;\n toolLog: string;\n accessLog: string;\n learnStore: string;\n parseCache: string;\n mcpPort: string;\n mcpServerLog: string;\n mcpServerErrLog: string;\n contextStore: string;\n contextMd: string;\n branchesDir: string;\n claudeDir: string;\n claudeSettings: string;\n claudeHooksDir: string;\n claudeMd: string;\n gitignore: string;\n}\n\nexport function resolvePaths(projectRoot: string): SynthraPaths {\n const graphDir = join(projectRoot, \".synthra-graph\");\n const contextDir = join(projectRoot, \".synthra\");\n const claudeDir = join(projectRoot, \".claude\");\n\n return {\n projectRoot,\n graphDir,\n contextDir,\n infoGraph: join(graphDir, \"info_graph.json\"),\n symbolIndex: join(graphDir, \"symbol_index.json\"),\n sessionState: join(graphDir, \"session.json\"),\n activityLog: join(graphDir, \"activity.jsonl\"),\n tokenLog: join(graphDir, \"token_log.jsonl\"),\n gateLog: join(graphDir, \"gate_log.jsonl\"),\n toolLog: join(graphDir, \"tool_log.jsonl\"),\n accessLog: join(graphDir, \"access_log.jsonl\"),\n learnStore: join(graphDir, \"learn_store.json\"),\n parseCache: join(graphDir, \"parse_cache.json\"),\n mcpPort: join(graphDir, \"mcp_port\"),\n mcpServerLog: join(graphDir, \"mcp_server.log\"),\n mcpServerErrLog: join(graphDir, \"mcp_server.err.log\"),\n contextStore: join(contextDir, \"context-store.json\"),\n contextMd: join(contextDir, \"CONTEXT.md\"),\n branchesDir: join(contextDir, \"branches\"),\n claudeDir,\n claudeSettings: join(claudeDir, \"settings.local.json\"),\n claudeHooksDir: join(claudeDir, \"hooks\"),\n claudeMd: join(projectRoot, \"CLAUDE.md\"),\n gitignore: join(projectRoot, \".gitignore\"),\n };\n}\n","// Approximate per-million-token pricing for Claude models, in USD.\n// Sourced from Anthropic's published rates. Tilde everywhere — these can shift.\n//\n// Used only for the dashboard's \"~$X\" estimate; not for billing.\n\nexport interface ModelPricing {\n /** Cost per 1M raw-input tokens. */\n input: number;\n /** Cost per 1M output tokens. */\n output: number;\n /** Cost per 1M cache-read tokens (typically ~10% of input). */\n cacheRead: number;\n /** Cost per 1M cache-creation tokens (typically input × 1.25). */\n cacheCreate: number;\n}\n\nconst PRICING: Record<string, ModelPricing> = {\n // Opus-class models — premium tier\n \"claude-opus-4-7\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n \"claude-opus-4-6\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n \"claude-opus-4-5\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n // Sonnet-class — workhorse\n \"claude-sonnet-4-6\": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },\n \"claude-sonnet-4-5\": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },\n // Haiku-class — fast and cheap\n \"claude-haiku-4-5\": { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 },\n};\n\nconst FALLBACK: ModelPricing = { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 };\n\nexport function pricingFor(model: string | undefined | null): ModelPricing {\n if (!model) return FALLBACK;\n const direct = PRICING[model];\n if (direct) return direct;\n // Loose prefix match: \"claude-opus-…\" / \"claude-sonnet-…\" / \"claude-haiku-…\"\n if (model.includes(\"opus\")) return PRICING[\"claude-opus-4-7\"] ?? FALLBACK;\n if (model.includes(\"sonnet\")) return PRICING[\"claude-sonnet-4-6\"] ?? FALLBACK;\n if (model.includes(\"haiku\")) return PRICING[\"claude-haiku-4-5\"] ?? FALLBACK;\n return FALLBACK;\n}\n\nexport interface UsageRecord {\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n model?: string;\n}\n\n/** Approximate USD cost of a single usage record. */\nexport function estimateCostUsd(usage: UsageRecord): number {\n const p = pricingFor(usage.model);\n return (\n (usage.input_tokens / 1_000_000) * p.input +\n (usage.output_tokens / 1_000_000) * p.output +\n ((usage.cache_read_input_tokens ?? 0) / 1_000_000) * p.cacheRead +\n ((usage.cache_creation_input_tokens ?? 0) / 1_000_000) * p.cacheCreate\n );\n}\n","// Global registry of projects that have run `syn .` on this machine.\n// Stored at ~/.synthra/projects.json so the dashboard can enumerate them\n// without walking the filesystem.\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\n\nconst REGISTRY_DIR = join(homedir(), \".synthra\");\nconst REGISTRY_PATH = join(REGISTRY_DIR, \"projects.json\");\nconst SCHEMA_VERSION = 1;\n\nexport interface ProjectRegistryEntry {\n path: string; // absolute project root\n name: string; // basename for display\n first_seen: string; // ISO timestamp\n last_seen: string; // ISO timestamp\n}\n\ninterface Registry {\n schema_version: number;\n projects: ProjectRegistryEntry[];\n}\n\nasync function readRegistry(): Promise<Registry> {\n try {\n const raw = await readFile(REGISTRY_PATH, \"utf8\");\n const parsed = JSON.parse(raw) as Partial<Registry>;\n if (!Array.isArray(parsed.projects)) return { schema_version: SCHEMA_VERSION, projects: [] };\n return { schema_version: parsed.schema_version ?? SCHEMA_VERSION, projects: parsed.projects };\n } catch {\n return { schema_version: SCHEMA_VERSION, projects: [] };\n }\n}\n\nasync function writeRegistry(registry: Registry): Promise<void> {\n await mkdir(dirname(REGISTRY_PATH), { recursive: true });\n await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + \"\\n\", \"utf8\");\n}\n\n/** Upsert this project's entry. Updates `last_seen`; preserves `first_seen`. */\nexport async function recordProject(projectRoot: string): Promise<void> {\n const now = new Date().toISOString();\n const registry = await readRegistry();\n const existing = registry.projects.find((p) => p.path === projectRoot);\n if (existing) {\n existing.last_seen = now;\n existing.name = basename(projectRoot);\n } else {\n registry.projects.push({\n path: projectRoot,\n name: basename(projectRoot),\n first_seen: now,\n last_seen: now,\n });\n }\n try {\n await writeRegistry(registry);\n } catch {\n // Registry is best-effort — a write failure shouldn't block the session.\n }\n}\n\nexport async function listProjects(): Promise<ProjectRegistryEntry[]> {\n const registry = await readRegistry();\n // Sort by last_seen descending so the most active project surfaces first.\n return registry.projects\n .slice()\n .sort((a, b) => (a.last_seen > b.last_seen ? -1 : a.last_seen < b.last_seen ? 1 : 0));\n}\n\nexport { REGISTRY_PATH, REGISTRY_DIR };\n","<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Synthra · Dashboard</title>\n <link rel=\"icon\" href=\"./favicon.svg\" type=\"image/svg+xml\" />\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"./style.css\" />\n</head>\n<body>\n\n <!-- ============ Top nav ============ -->\n <header class=\"topnav\">\n <div class=\"brand\">\n <div class=\"brand-mark\"></div>\n <div class=\"brand-name\">Synth<em>ra</em></div>\n <div class=\"brand-eyebrow\">Dashboard</div>\n <div class=\"nav-date\">\n <span class=\"nd-weekday\" id=\"hero-weekday\">—</span>\n <span class=\"nd-day\" id=\"hero-day\">—</span>\n <span class=\"nd-month\" id=\"hero-month\">—</span>\n </div>\n </div>\n <div class=\"top-right\">\n <span class=\"status-pill\">\n <span class=\"dot\" id=\"dot\"></span>\n <span id=\"status\">connecting…</span>\n </span>\n </div>\n <div class=\"topnav-right\">\n <div class=\"nav-active has-tooltip\" data-tooltip=\"The project directory Synthra is currently watching — the most recent `syn .` session on this machine.\">\n <span class=\"na-label\">Active</span>\n <span class=\"na-value\" id=\"active-project\" title=\"—\">—</span>\n </div>\n <span class=\"port-badge\">port <span class=\"mono\" id=\"port-num\">8901</span></span>\n <button class=\"faq-btn has-tooltip\" id=\"faq-btn\" data-tooltip=\"Open the FAQ — explains where every number on this dashboard comes from, how cost is calculated, and what the savings floor actually measures.\" aria-label=\"Open FAQ\">?</button>\n </div>\n </header>\n\n <!-- ============ Main 3-column grid ============ -->\n <main class=\"grid-main\">\n\n <!-- ===== Left ===== -->\n <aside class=\"col-left\">\n <div class=\"card donut-card has-tooltip\" data-tooltip=\"Which Claude models you've been calling, weighted by turn count. Opus = slow and expensive; Sonnet = workhorse; Haiku = cheap and fast. Helps you see where your budget is actually going.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Model usage</div>\n <div class=\"card-meta\">by turns</div>\n </div>\n <div class=\"donut-wrap\">\n <svg viewBox=\"0 0 140 140\" class=\"donut\" id=\"donut-svg\" aria-hidden=\"true\">\n <circle cx=\"70\" cy=\"70\" r=\"52\" class=\"donut-track\"/>\n </svg>\n <div class=\"donut-center\">\n <div class=\"donut-total\" id=\"donut-total\">0</div>\n <div class=\"donut-total-k\">turns</div>\n </div>\n </div>\n <div class=\"donut-legend\" id=\"donut-legend\"></div>\n </div>\n\n <!-- Projects — colored bar chart by turns -->\n <div class=\"card projects-card has-tooltip\" data-tooltip=\"Every project Synthra has tracked on this machine, ranked by how many turns ran there. Each project carries its own color. Click any row to open its full cost & token breakdown.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Projects</div>\n <div class=\"card-meta\">by turns</div>\n </div>\n <div class=\"proj-chart\" id=\"proj-chart\"></div>\n </div>\n </aside>\n\n <!-- ===== Center ===== -->\n <div class=\"col-center\">\n\n <!-- Metric strip — divider-separated, no individual card chrome -->\n <div class=\"metric-strip\">\n <div class=\"metric-item has-tooltip\" data-tooltip=\"Total back-and-forth exchanges with Claude across all projects. One turn = you send a message, Claude responds. Counted from the Stop hook against transcript JSONL files.\">\n <div class=\"m-label\">Turns</div>\n <div class=\"m-value\" id=\"m-turns\">0</div>\n </div>\n <div class=\"metric-item has-tooltip\" data-tooltip=\"↓ Input — new, uncached tokens you sent to Claude. Usually small (a few hundred per turn) because most of the conversation history comes from prompt cache.\">\n <div class=\"m-label\">↓ Input</div>\n <div class=\"m-value\" id=\"m-input\">0</div>\n </div>\n <div class=\"metric-item has-tooltip\" data-tooltip=\"↑ Output — tokens Claude generated in its responses. Most expensive line item per turn (~5× input rate on Opus). High output usually means long code edits.\">\n <div class=\"m-label\">↑ Output</div>\n <div class=\"m-value\" id=\"m-output\">0</div>\n </div>\n <div class=\"metric-item has-tooltip\" data-tooltip=\"⟲ Cache read — tokens reused from the prompt cache (system prompt, conversation history, Synthra's pre-packed context). Cheap, around 10% of the input rate. The bulk of every long session.\">\n <div class=\"m-label\">⟲ Cache R</div>\n <div class=\"m-value\" id=\"m-cache-r\">0</div>\n </div>\n <div class=\"metric-item has-tooltip\" data-tooltip=\"+ Cache write — tokens newly added to the prompt cache so future turns can read them cheaply. Premium-priced (~125% of input rate) but pays back across the session.\">\n <div class=\"m-label\">+ Cache W</div>\n <div class=\"m-value\" id=\"m-cache-w\">0</div>\n </div>\n </div>\n\n <!-- Savings + Cost, side by side -->\n <div class=\"hero-row\">\n\n <!-- Savings hero -->\n <div class=\"card savings has-tooltip\" data-tooltip=\"What Synthra has saved you, as a deliberately conservative floor estimate. Each time the gate blocks an exploratory Grep/Glob, we credit 500 tokens × $3 per million-token input rate. Real savings are usually higher because the formula ignores cache thrash and follow-up Reads that the block also prevents. The audit line below shows the exact math live.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Synthra savings <span class=\"src-badge estimated\">floor</span></div>\n <div class=\"card-meta\" id=\"savings-pct\">— off</div>\n </div>\n <div class=\"savings-body\">\n <div class=\"savings-figure\">\n <div class=\"savings-money\" id=\"savings-money\">$0.00</div>\n <div class=\"savings-tokens\"><span id=\"savings-tokens\">0</span> tokens avoided</div>\n </div>\n <div class=\"savings-bar\">\n <div class=\"savings-actual\" id=\"savings-actual-bar\" style=\"width:100%\"></div>\n <div class=\"savings-saved\" id=\"savings-saved-bar\" style=\"width:0%\"></div>\n </div>\n <div class=\"savings-legend\">\n <div class=\"sl-row\"><span class=\"sl-dot actual\"></span>You paid <b id=\"savings-actual-amt\">$0.00</b></div>\n <div class=\"sl-row\"><span class=\"sl-dot saved\"></span>Baseline <b id=\"savings-baseline-amt\">$0.00</b></div>\n </div>\n </div>\n <div class=\"savings-audit\">\n <span class=\"audit-formula\">\n <b id=\"audit-blocks\">0</b> blocks × <b>500</b> tokens × <b>$3</b> / M input rate = <b id=\"audit-result\" class=\"audit-result\">$0.00</b>\n </span>\n </div>\n </div>\n\n <!-- Cost hero (moved beside Savings) -->\n <div class=\"card cost-hero has-tooltip\" data-tooltip=\"Your all-time Claude spend across every project Synthra has tracked on this machine. Token counts come from Claude's transcript JSONL files; dollar amounts are computed by multiplying those counts by Anthropic's published per-model rates. See the FAQ for full rate tables.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Total spend · <em>all time</em></div>\n </div>\n <div class=\"big-money\" id=\"big-cost\">$0.<em>00</em></div>\n <div class=\"cost-sub\">\n <div class=\"cs-row\">\n <span class=\"cs-k\">Tokens (in+out)</span>\n <span class=\"cs-v\" id=\"cs-tokens\">0</span>\n </div>\n <div class=\"cs-row\">\n <span class=\"cs-k\">Avg / turn</span>\n <span class=\"cs-v\" id=\"cs-avg\">$0.00</span>\n </div>\n </div>\n </div>\n\n </div><!-- /hero-row -->\n\n <!-- Recent turns -->\n <div class=\"card turns-card has-tooltip\" data-tooltip=\"Every conversational turn Synthra has observed across all your projects, newest first. Each row shows when, which project, which model, and how the cost broke down between fresh input, generated output, and cache.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Recent turns</div>\n <div class=\"card-meta\" id=\"turns-count\">— shown</div>\n </div>\n <div class=\"turns-scroll\">\n <table class=\"turns-table\">\n <thead>\n <tr>\n <th class=\"has-tooltip\" data-tooltip=\"When this turn happened, in your local time. Turns from today show as a time; older turns show the date.\">Time</th>\n <th class=\"has-tooltip\" data-tooltip=\"Which project directory the turn ran in. Color-matched to the Projects chart on the left.\">Project</th>\n <th class=\"has-tooltip\" data-tooltip=\"The Claude model used for this turn — Opus, Sonnet, or Haiku — shown in the color-coded pill.\">Model</th>\n <th class=\"num has-tooltip\" data-tooltip=\"↓ Input — raw, uncached tokens sent to Claude this turn. Usually tiny (a few hundred) because conversation history is served from the prompt cache, not re-sent fresh.\">In</th>\n <th class=\"num has-tooltip\" data-tooltip=\"↑ Output — tokens Claude generated in its response. The most expensive line item per turn (~5× the input rate on Opus). Big numbers usually mean long code edits.\">Out</th>\n <th class=\"num has-tooltip\" data-tooltip=\"Cache Read / Cache Write. R = tokens reused from earlier turns (cheap, ~10% of the input rate). W = tokens newly written to the cache so future turns can read them (premium, ~125% of the input rate).\">Cache R/W</th>\n <th class=\"num has-tooltip\" data-tooltip=\"Estimated USD for this turn: input + output + cache read + cache write, each multiplied by the published Anthropic per-model rate.\">Cost</th>\n </tr>\n </thead>\n <tbody id=\"turns-body\"></tbody>\n </table>\n <p class=\"empty hidden\" id=\"turns-empty\">No turns logged yet. Run <code>syn .</code> in any project and chat with Claude.</p>\n </div>\n <div class=\"turns-pager hidden\" id=\"turns-pager\">\n <button type=\"button\" id=\"turns-prev\" aria-label=\"Previous page\">‹ Prev</button>\n <span class=\"mono\" id=\"turns-page-label\">page 1 of 1</span>\n <button type=\"button\" id=\"turns-next\" aria-label=\"Next page\">Next ›</button>\n </div>\n </div>\n\n </div>\n\n <!-- ===== Right ===== -->\n <aside class=\"col-right\">\n\n <!-- Graph tool usage -->\n <div class=\"card tools-card has-tooltip\" data-tooltip=\"How often Claude actually used Synthra's graph tools (graph_continue / graph_read / …) across all projects. A positive usage signal — unlike the Moat's block count, it captures every time Claude reached for the graph instead of running a Grep.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Graph tools <em>used</em></div>\n <div class=\"card-meta\">all projects</div>\n </div>\n <div class=\"moat-value\"><span id=\"tool-total\">0</span> <em>calls</em></div>\n <div class=\"cost-sub\" id=\"tool-breakdown\"></div>\n </div>\n\n <!-- The Moat -->\n <div class=\"card moat has-tooltip\" data-tooltip=\"Synthra's PreToolUse hook intercepts. Each block = Synthra recognized the graph already had high-confidence context for the query, so it stopped Claude from running an exploratory Grep or Glob. The list below shows the latest decisions across all projects.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">The <em>Moat</em></div>\n <div class=\"card-meta\">PreToolUse</div>\n </div>\n <div class=\"moat-value\"><span id=\"blocks\">0</span> <em>blocks</em></div>\n <div class=\"gate-mini\" id=\"gate-mini\"></div>\n </div>\n\n <!-- Hot files (usage-learning) -->\n <div class=\"card tools-card has-tooltip\" data-tooltip=\"Files Synthra has learned you work in most, weighted by recent use — it ranks these higher in retrieval. Scoped to the active project; decays over ~7 days, so the list reflects what's hot now.\">\n <div class=\"card-head\">\n <div class=\"card-eyebrow\">Hot <em>files</em></div>\n <div class=\"card-meta\" id=\"hot-files-project\">active project</div>\n </div>\n <div class=\"moat-value\"><span id=\"hot-files-total\">0</span> <em>tracked</em></div>\n <div class=\"cost-sub\" id=\"hot-files-list\"></div>\n </div>\n\n </aside>\n </main>\n\n <!-- ============ Project dialog ============ -->\n <div class=\"dialog-backdrop hidden\" id=\"dialog-backdrop\" role=\"dialog\" aria-modal=\"true\">\n <div class=\"dialog\">\n <button class=\"dialog-close\" id=\"dialog-close\" aria-label=\"Close\">×</button>\n <div class=\"dialog-eyebrow\">Project · <em>details</em></div>\n <div class=\"dialog-name\" id=\"d-name\">—</div>\n <div class=\"dialog-path\" id=\"d-path\">—</div>\n <div class=\"dialog-grid\">\n <div class=\"dg-cell\">\n <div class=\"dg-k\">Total cost</div>\n <div class=\"dg-v money\" id=\"d-cost\">$0.00</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">Turns</div>\n <div class=\"dg-v\" id=\"d-turns\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">↓ Raw input</div>\n <div class=\"dg-v\" id=\"d-input\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">↑ Output</div>\n <div class=\"dg-v\" id=\"d-output\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">⟲ Cache read</div>\n <div class=\"dg-v\" id=\"d-cache-r\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">+ Cache write</div>\n <div class=\"dg-v\" id=\"d-cache-w\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">Moat blocks</div>\n <div class=\"dg-v\" id=\"d-blocks\">0</div>\n </div>\n <div class=\"dg-cell\">\n <div class=\"dg-k\">Last active</div>\n <div class=\"dg-v dg-v-sm\" id=\"d-last\">—</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- ============ FAQ dialog ============ -->\n <div class=\"dialog-backdrop hidden\" id=\"faq-backdrop\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Dashboard FAQ\">\n <div class=\"dialog dialog-faq\">\n <button class=\"dialog-close\" id=\"faq-close\" aria-label=\"Close\">×</button>\n <div class=\"dialog-eyebrow\">FAQ · <em>where every number comes from</em></div>\n <div class=\"dialog-name\">Understanding your dashboard</div>\n <div class=\"dialog-path\">Synthra reports what Claude actually used — read from transcripts, not estimated.</div>\n\n <div class=\"faq-content\">\n\n <details open>\n <summary>Where does Synthra get these numbers from?</summary>\n <div class=\"faq-body\">\n <p>Synthra <strong>does not estimate</strong> token counts — it reads them directly from Claude's own log files. Every number on this dashboard is traceable:</p>\n <table>\n <tr><td>Turns, ↓ In, ↑ Out, Cache R/W</td><td>Parsed from Claude's transcript JSONLs at <code>~/.claude/projects/<encoded-cwd>/*.jsonl</code></td></tr>\n <tr><td>Model used per turn</td><td>From each turn's <code>model</code> field in the same transcripts</td></tr>\n <tr><td>Moat blocks + gate decisions</td><td>From <code>.synthra-graph/gate_log.jsonl</code> inside each project</td></tr>\n <tr><td>Active project, project list</td><td>From <code>~/.synthra/projects.json</code> (built up as you run <code>syn .</code>)</td></tr>\n <tr><td>Total spend (USD)</td><td>Above token counts × Anthropic's published per-model rates — see \"How is cost calculated?\" below</td></tr>\n <tr><td>Synthra savings (USD)</td><td><strong>Estimated</strong> — see \"About the Savings (floor) card\" below</td></tr>\n </table>\n <p>The cost-calculation logic lives in <code>src/shared/pricing.ts</code>; the aggregation logic in <code>src/dashboard/delta.ts</code>. Both are linked at the bottom.</p>\n </div>\n </details>\n\n <details>\n <summary>What do the columns in Recent Turns mean?</summary>\n <div class=\"faq-body\">\n <table>\n <tr><td><code>Time</code></td><td>When this turn happened (local time)</td></tr>\n <tr><td><code>Project</code></td><td>Which directory the turn ran in</td></tr>\n <tr><td><code>Model</code></td><td>Claude model used — Opus, Sonnet, or Haiku (color-coded in the model pill)</td></tr>\n <tr><td><code>In</code></td><td>Raw input tokens — brand-new content sent to Claude this turn, not cached</td></tr>\n <tr><td><code>Out</code></td><td>Tokens Claude generated in its response</td></tr>\n <tr><td><code>Cache R/W</code></td><td>Cache <strong>R</strong>ead (reused from prior turns) / Cache <strong>W</strong>rite (newly cached for future turns)</td></tr>\n <tr><td><code>Cost</code></td><td>Per-turn USD estimate using Anthropic's published rates</td></tr>\n </table>\n <p><strong>Why is Raw Input often tiny (e.g. 6 tokens)?</strong> Because Claude Code aggressively caches the system prompt, CLAUDE.md, tool definitions, and conversation history. On each turn, only your brand-new message is \"raw input\" — everything else is a cheap cache read. This is normal and saves significant money.</p>\n </div>\n </details>\n\n <details>\n <summary>How is cost calculated?</summary>\n <div class=\"faq-body\">\n <p>Each token type has a different per-million-token rate. Synthra uses these rates (defined in <code>src/shared/pricing.ts</code>):</p>\n <table>\n <thead><tr><td><strong>Token type</strong></td><td><strong>Haiku 4.5</strong></td><td><strong>Sonnet 4.x</strong></td><td><strong>Opus 4.x</strong></td></tr></thead>\n <tr><td>Raw Input</td><td>$1.00/M</td><td>$3.00/M</td><td>$15.00/M</td></tr>\n <tr><td>Cache Write</td><td>$1.25/M</td><td>$3.75/M</td><td>$18.75/M</td></tr>\n <tr><td>Cache Read</td><td>$0.10/M</td><td>$0.30/M</td><td>$1.50/M</td></tr>\n <tr><td>Output</td><td>$5.00/M</td><td>$15.00/M</td><td>$75.00/M</td></tr>\n </table>\n <p><strong>Cost</strong> = (Input × input rate) + (Output × output rate) + (Cache Read × read rate) + (Cache Write × write rate)</p>\n <p>Cache reads are <strong>10× cheaper</strong> than raw input. Cache writes are <strong>25% more expensive</strong> than raw input. So Claude Code's caching strategy pays for itself quickly across a session.</p>\n <div class=\"warning\"><span class=\"icon\">⚠</span>These are <strong>Anthropic API rates</strong>, not your plan billing. If you're on Claude Pro, Team, Max, or Enterprise, your actual billing is different — the costs shown are estimates of <em>API-equivalent</em> usage, useful for comparing sessions against each other. See <a href=\"https://www.anthropic.com/pricing\" target=\"_blank\" rel=\"noopener noreferrer\">anthropic.com/pricing</a> for the source of these rates.</div>\n </div>\n </details>\n\n <details>\n <summary>What is the total context size per turn?</summary>\n <div class=\"faq-body\">\n <p><code>Total context = Raw Input + Cache Read + Cache Write</code></p>\n <p>Example: if Raw Input is 6, Cache Read is 60K, and Cache Write is 13K, your turn used ~73K tokens of context — but 99.99% was efficiently cached, so you only paid the cache-read rate on most of it. The Recent Turns table lets you scan this row by row.</p>\n <p>Anthropic's prompt-caching mechanics are documented at <a href=\"https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching\" target=\"_blank\" rel=\"noopener noreferrer\">docs.anthropic.com/.../prompt-caching</a>.</p>\n </div>\n </details>\n\n <details>\n <summary>About the \"Savings (floor)\" card</summary>\n <div class=\"faq-body\">\n <p>This is the only number on the dashboard that's <strong>estimated</strong> rather than measured. Here's the math:</p>\n <p class=\"formula-box\">savings = blocks × 500 tokens × $3 per million input rate</p>\n <p>Each time Synthra's PreToolUse hook blocks a <code>Grep</code> or <code>Glob</code> call (because the graph already has high-confidence context), we credit a deliberately conservative 500 tokens at the Sonnet input rate.</p>\n <p>It under-counts on purpose because the formula ignores:</p>\n <ul>\n <li><strong>Cache thrash</strong> — the blocked tool result would have been written to the cache at ~125% of the input rate, which we don't count</li>\n <li><strong>Cascading reads</strong> — Claude usually follows a Grep with several <code>Read</code> calls, which the block also prevents but we don't credit</li>\n <li><strong>Bigger codebases</strong> — actual Grep results often exceed 500 tokens by 3–6× in a real repo</li>\n </ul>\n <p>Real savings are typically <strong>2–5× the floor</strong>. The audit row on the Savings card shows the formula live so you can verify the math.</p>\n </div>\n </details>\n\n <details>\n <summary>What is \"The Moat\"?</summary>\n <div class=\"faq-body\">\n <p>\"The Moat\" is Synthra's <strong>PreToolUse hook</strong>. Every time Claude Code is about to run a <code>Grep</code> or <code>Glob</code>, the hook calls Synthra's local server first and asks: \"do you already have high-confidence context for this query?\"</p>\n <p>If yes, Synthra returns <code>{\"decision\":\"block\"}</code> — Claude can't run the tool. Claude then has to use Synthra's graph tools (<code>graph_continue</code>, <code>graph_read</code>) instead, which return the answer without burning tokens on exploration. This is deterministic enforcement — not prose policy. Claude literally can't disobey.</p>\n <p>The Moat card shows total blocks; the inline list below shows the most recent gate decisions (block vs allow) with the originating query.</p>\n <p>Every decision is logged to <code>.synthra-graph/gate_log.jsonl</code> for the project.</p>\n </div>\n </details>\n\n <details>\n <summary>How does Synthra build the codebase graph?</summary>\n <div class=\"faq-body\">\n <p>When you run <code>syn .</code> in a project, Synthra walks the file tree (respecting <code>.gitignore</code> + <code>.synthraignore</code>) and parses each file with <strong>tree-sitter</strong> WebAssembly grammars. Currently 14 languages are supported:</p>\n <p>TypeScript, JavaScript, JSX/TSX, Python, Svelte, Vue, Go, Rust, Java, Kotlin, PHP, Ruby, C/C++, C#, and Dart.</p>\n <p>For each file we extract: function and class definitions, exports, imports, and test-to-source links. The output is a structured graph stored at <code>.synthra-graph/info_graph.json</code> with a symbol index at <code>.synthra-graph/symbol_index.json</code>.</p>\n <p>The graph is what makes pre-injection and The Moat work — both query it before Claude ever has to Grep.</p>\n </div>\n </details>\n\n <details>\n <summary>Where does Synthra store data on disk?</summary>\n <div class=\"faq-body\">\n <p>Synthra uses two folders per project, intentionally separated:</p>\n <table>\n <tr><td><code>.synthra-graph/</code></td><td><strong>Gitignored</strong>. Heavy generated state — the graph, symbol index, token + gate logs, session info. Rebuilt by <code>syn scan</code>.</td></tr>\n <tr><td><code>.synthra/</code></td><td><strong>Git-tracked.</strong> Decisions, context notes, branch-scoped memory — the part teammates inherit when they clone the repo.</td></tr>\n </table>\n <p>Plus one global file at <code>~/.synthra/projects.json</code> that tracks every project on this machine.</p>\n </div>\n </details>\n\n <details>\n <summary>How does branch-aware memory work?</summary>\n <div class=\"faq-body\">\n <p>Inside <code>.synthra/</code>, context is partitioned by git branch:</p>\n <ul>\n <li><code>.synthra/context-store.json</code> — decisions on the default branch</li>\n <li><code>.synthra/CONTEXT.md</code> — narrative notes on the default branch</li>\n <li><code>.synthra/branches/<sanitized-name>/</code> — overrides on feature branches</li>\n </ul>\n <p>When you switch branches, Synthra's git-watcher (using <code>fs.watch</code> on <code>.git/HEAD</code>) detects the change and reloads the right context. Decisions scoped to a feature branch don't leak back to <code>main</code> until merge.</p>\n </div>\n </details>\n\n <details>\n <summary>How does Synthra actually reduce my Claude bill?</summary>\n <div class=\"faq-body\">\n <p>Three mechanisms, in order of impact:</p>\n <ul>\n <li><strong>Pre-injection</strong> — at session start, Synthra packs ~4K tokens of graph context (function signatures, top inline bodies, file relationships) into Claude's prompt. Claude doesn't have to Grep / Read to discover what's in the codebase — it already knows.</li>\n <li><strong>The Moat</strong> — the PreToolUse hook deterministically blocks exploratory Grep/Glob when the graph already has high-confidence context. Counts on the Moat card.</li>\n <li><strong>Branch-aware memory</strong> — decisions and CONTEXT notes persist in <code>.synthra/</code>, so Claude doesn't have to be re-told what was decided last session.</li>\n </ul>\n <p>The dashboard shows real token counts from Claude's own logs so you can see the effect over time, not just take Synthra's word for it.</p>\n </div>\n </details>\n\n <details>\n <summary>Why do long conversations get expensive?</summary>\n <div class=\"faq-body\">\n <p>Each turn re-sends the entire conversation history as input. Even cached, the cumulative input grows roughly <strong>quadratically</strong>:</p>\n <table>\n <thead><tr><td><strong>Turns</strong></td><td><strong>Per-turn input</strong></td><td><strong>Cumulative input</strong></td></tr></thead>\n <tr><td>10</td><td>~2K (mostly cached)</td><td>~110K</td></tr>\n <tr><td>30</td><td>~2K</td><td>~930K</td></tr>\n <tr><td>50</td><td>~2K</td><td>~2.55M</td></tr>\n </table>\n <p>Prompt caching helps a lot (cache reads are 10× cheaper than fresh input), but context still grows. This is why Synthra's pre-injection matters: starting with the answer already in context means you reach a useful state in fewer turns.</p>\n <p><strong>Tip:</strong> Use <code>/compact</code> in Claude Code or start fresh sessions when a thread feels stale.</p>\n </div>\n </details>\n\n <details>\n <summary>Sources & references</summary>\n <div class=\"faq-body\">\n <p>Synthra is open source. Every number on this dashboard can be cross-checked:</p>\n <ul class=\"link-list\">\n <li><a href=\"https://github.com/jefuriiij/synthra\" target=\"_blank\" rel=\"noopener noreferrer\">github.com/jefuriiij/synthra</a> — source code, issues, roadmap</li>\n <li><a href=\"https://www.npmjs.com/package/@jefuriiij/synthra\" target=\"_blank\" rel=\"noopener noreferrer\">npm: @jefuriiij/synthra</a> — release history, install instructions</li>\n <li><a href=\"https://www.anthropic.com/pricing\" target=\"_blank\" rel=\"noopener noreferrer\">anthropic.com/pricing</a> — official rate table Synthra uses</li>\n <li><a href=\"https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching\" target=\"_blank\" rel=\"noopener noreferrer\">Anthropic docs · prompt caching</a> — explains cache read/write behavior</li>\n <li><code>src/shared/pricing.ts</code> — the file in this repo holding the rate table</li>\n <li><code>src/dashboard/delta.ts</code> — where dashboard aggregates are computed</li>\n <li><code>src/server/routes/gate.ts</code> — the Moat implementation</li>\n </ul>\n </div>\n </details>\n\n </div>\n </div>\n </div>\n\n <!-- ============ Footer ============ -->\n <footer class=\"foot\">\n <div>Synth<em>ra</em> · v__SYN_VERSION__</div>\n <div>Cost figures approximate · @jefuriiij</div>\n </footer>\n\n <script>\n const $ = (sel) => document.querySelector(sel);\n const SAVED_RATE_PER_M = 3.00; // USD per million tokens — conservative input rate\n\n // ----- model classification -----\n function modelFamily(model) {\n if (!model) return 'unknown';\n const m = model.toLowerCase();\n if (m === '<synthetic>') return 'unknown';\n if (m.includes('opus')) return 'opus';\n if (m.includes('sonnet')) return 'sonnet';\n if (m.includes('haiku')) return 'haiku';\n return 'unknown';\n }\n function modelLabel(model) {\n if (!model || model === '<synthetic>') return 'synthetic';\n return model.replace(/^claude-/, '');\n }\n\n // ----- formatting -----\n function fmt(n) {\n if (typeof n !== 'number' || !Number.isFinite(n)) return '0';\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + '<em>M</em>';\n if (n >= 1_000) return (n / 1_000).toFixed(1) + '<em>k</em>';\n return n.toLocaleString();\n }\n function fmtPlain(n) {\n if (typeof n !== 'number' || !Number.isFinite(n)) return '0';\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';\n if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';\n return n.toLocaleString();\n }\n function fmtCostBig(usd) {\n if (typeof usd !== 'number' || !Number.isFinite(usd)) usd = 0;\n let s;\n if (usd >= 1) s = usd.toFixed(2);\n else if (usd >= 0.01) s = usd.toFixed(3);\n else s = usd.toFixed(4);\n const dot = s.indexOf('.');\n if (dot === -1) return '$' + s;\n return '$' + s.slice(0, dot) + '.<em>' + s.slice(dot + 1) + '</em>';\n }\n function fmtCostFlat(usd) {\n if (typeof usd !== 'number' || !Number.isFinite(usd)) usd = 0;\n if (usd >= 1) return '$' + usd.toFixed(2);\n if (usd >= 0.01) return '$' + usd.toFixed(3);\n return '$' + usd.toFixed(4);\n }\n function fmtTs(iso) {\n try {\n const d = new Date(iso);\n const today = new Date();\n const isToday = d.toDateString() === today.toDateString();\n if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n return d.toLocaleDateString([], { month: 'short', day: 'numeric' });\n } catch { return iso; }\n }\n\n let lastData = null;\n let turnsPage = 1;\n const TURNS_PER_PAGE = 25;\n\n // ----- date in hero -----\n function setHeroDate() {\n const now = new Date();\n $('#hero-day').textContent = now.getDate();\n $('#hero-weekday').textContent = now.toLocaleDateString([], { weekday: 'short' });\n $('#hero-month').textContent = now.toLocaleDateString([], { month: 'long' });\n }\n\n // ----- renderers -----\n function renderSession(turns) {\n const agg = turns.reduce((a, t) => {\n a.turns += 1;\n a.input += t.input || 0;\n a.output += t.output || 0;\n a.cacheR += t.cache_read || 0;\n a.cacheW += t.cache_create || 0;\n return a;\n }, { turns: 0, input: 0, output: 0, cacheR: 0, cacheW: 0 });\n\n $('#m-turns').innerHTML = fmt(agg.turns);\n $('#m-input').innerHTML = fmt(agg.input);\n $('#m-output').innerHTML = fmt(agg.output);\n $('#m-cache-r').innerHTML = fmt(agg.cacheR);\n $('#m-cache-w').innerHTML = fmt(agg.cacheW);\n }\n\n function renderSavings(g) {\n const tokensSaved = g.estimated_tokens_saved || 0;\n const blocks = g.blocked_count || 0;\n const savedUsd = tokensSaved * SAVED_RATE_PER_M / 1_000_000;\n const actualUsd = g.estimated_cost_usd || 0;\n const baselineUsd = actualUsd + savedUsd;\n const savedPct = baselineUsd > 0 ? (savedUsd / baselineUsd) * 100 : 0;\n const actualPct = baselineUsd > 0 ? (actualUsd / baselineUsd) * 100 : 100;\n\n $('#savings-money').textContent = fmtCostFlat(savedUsd);\n $('#savings-pct').textContent = savedPct.toFixed(1) + '% off';\n $('#savings-tokens').textContent = fmtPlain(tokensSaved);\n $('#savings-actual-bar').style.width = actualPct.toFixed(2) + '%';\n $('#savings-saved-bar').style.width = savedPct.toFixed(2) + '%';\n $('#savings-actual-amt').textContent = fmtCostFlat(actualUsd);\n $('#savings-baseline-amt').textContent = fmtCostFlat(baselineUsd);\n\n // Audit row — live formula\n $('#audit-blocks').textContent = blocks.toLocaleString();\n $('#audit-result').textContent = fmtCostFlat(savedUsd);\n }\n\n function renderCostHero(g) {\n $('#big-cost').innerHTML = fmtCostBig(g.estimated_cost_usd);\n const totalTokens = (g.total_input_tokens || 0) + (g.total_output_tokens || 0);\n $('#cs-tokens').textContent = fmtPlain(totalTokens);\n const avg = g.total_turns > 0 ? g.estimated_cost_usd / g.total_turns : 0;\n $('#cs-avg').textContent = fmtCostFlat(avg);\n }\n\n function renderMoat(g) {\n $('#blocks').textContent = fmtPlain(g.blocked_count);\n }\n\n function renderToolUsage(g) {\n $('#tool-total').innerHTML = fmt(g.total_tool_calls || 0);\n const el = $('#tool-breakdown');\n el.innerHTML = '';\n const entries = Object.entries(g.tool_calls || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);\n if (!entries.length) {\n el.innerHTML = '<div class=\"empty\">No graph-tool calls yet.</div>';\n return;\n }\n const frag = document.createDocumentFragment();\n for (const [tool, n] of entries) {\n const row = document.createElement('div');\n row.className = 'cs-row';\n row.innerHTML = '<span class=\"cs-k\">' + tool + '</span><span class=\"cs-v\">' + fmtPlain(n) + '</span>';\n frag.appendChild(row);\n }\n el.appendChild(frag);\n }\n\n function shortenPath(p) {\n const parts = String(p).split('/');\n return parts.length <= 2 ? p : '…/' + parts.slice(-2).join('/');\n }\n\n function renderHotFiles(active) {\n const stats = (active && active.stats) || {};\n const files = stats.hot_files || [];\n $('#hot-files-total').innerHTML = fmt(stats.hot_files_total || 0);\n const proj = $('#hot-files-project');\n if (proj) proj.textContent = (active && active.project_name) || 'active project';\n const el = $('#hot-files-list');\n el.innerHTML = '';\n if (!files.length) {\n el.innerHTML = '<div class=\"empty\">No usage learned yet — Synthra learns as you read/edit files.</div>';\n return;\n }\n const frag = document.createDocumentFragment();\n for (const f of files) {\n const row = document.createElement('div');\n row.className = 'cs-row';\n row.title = f.path;\n const k = document.createElement('span');\n k.className = 'cs-k path';\n k.textContent = shortenPath(f.path);\n const v = document.createElement('span');\n v.className = 'cs-v';\n v.textContent = String(f.score);\n row.append(k, v);\n frag.appendChild(row);\n }\n el.appendChild(frag);\n }\n\n function renderTurns(turns) {\n const tbody = $('#turns-body');\n const empty = $('#turns-empty');\n const pager = $('#turns-pager');\n tbody.innerHTML = '';\n if (!turns.length) {\n empty.classList.remove('hidden');\n pager.classList.add('hidden');\n $('#turns-count').textContent = '0 shown';\n return;\n }\n empty.classList.add('hidden');\n\n const totalPages = Math.max(1, Math.ceil(turns.length / TURNS_PER_PAGE));\n // Clamp in case the list shrank (dedup / data churn) since last render.\n if (turnsPage > totalPages) turnsPage = totalPages;\n if (turnsPage < 1) turnsPage = 1;\n const start = (turnsPage - 1) * TURNS_PER_PAGE;\n const pageItems = turns.slice(start, start + TURNS_PER_PAGE);\n\n $('#turns-count').textContent =\n 'showing ' + (start + 1) + '–' + (start + pageItems.length) + ' of ' + turns.length;\n\n const frag = document.createDocumentFragment();\n for (const t of pageItems) {\n const family = modelFamily(t.model);\n const tr = document.createElement('tr');\n tr.innerHTML =\n '<td class=\"ts\">' + fmtTs(t.ts || t.written_at) + '</td>' +\n '<td class=\"proj\">' + (t.project_name || '—') + '</td>' +\n '<td><span class=\"model-pill ' + family + '\"><span class=\"sq\"></span>' + modelLabel(t.model) + '</span></td>' +\n '<td class=\"num\">' + fmtPlain(t.input || 0) + '</td>' +\n '<td class=\"num\">' + fmtPlain(t.output || 0) + '</td>' +\n '<td class=\"num\">' + fmtPlain(t.cache_read || 0) + ' / ' + fmtPlain(t.cache_create || 0) + '</td>' +\n '<td class=\"num cost\">' + fmtCostFlat(t.cost_usd || 0) + '</td>';\n frag.appendChild(tr);\n }\n tbody.appendChild(frag);\n\n if (totalPages <= 1) {\n pager.classList.add('hidden');\n } else {\n pager.classList.remove('hidden');\n $('#turns-page-label').textContent = 'page ' + turnsPage + ' of ' + totalPages;\n $('#turns-prev').disabled = turnsPage <= 1;\n $('#turns-next').disabled = turnsPage >= totalPages;\n }\n }\n\n function gotoTurnsPage(delta) {\n turnsPage += delta;\n renderTurns((lastData && lastData.recent_turns) || []);\n }\n\n function renderGateMini(gates) {\n const el = $('#gate-mini');\n el.innerHTML = '';\n if (!gates.length) {\n el.innerHTML = '<div class=\"empty\">No gate decisions yet.</div>';\n return;\n }\n const frag = document.createDocumentFragment();\n for (const g of gates.slice(0, 50)) {\n const row = document.createElement('div');\n row.className = 'gate-row';\n const cls = g.decision === 'block' ? 'block' : 'allow';\n row.innerHTML =\n '<span class=\"g-ts\">' + fmtTs(g.ts) + '</span>' +\n '<span class=\"g-decision ' + cls + '\">' + (g.decision || '—').toUpperCase() + '</span>' +\n '<span class=\"g-q\">' + (g.query || g.tool || '—') + '</span>';\n frag.appendChild(row);\n }\n el.appendChild(frag);\n }\n\n // ----- Project colors (stable per name) -----\n const PROJECT_COLORS = [\n 'oklch(78% 0.14 220)', // cyan\n 'oklch(75% 0.14 155)', // green\n 'oklch(78% 0.13 75)', // amber\n 'oklch(72% 0.14 285)', // violet\n 'oklch(72% 0.14 20)', // rose\n 'oklch(74% 0.13 195)', // teal\n 'oklch(80% 0.12 250)', // periwinkle\n 'oklch(76% 0.13 330)', // magenta\n ];\n function projColor(name) {\n let h = 0;\n for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;\n return PROJECT_COLORS[h % PROJECT_COLORS.length];\n }\n\n function renderProjects(projects) {\n const el = $('#proj-chart');\n el.innerHTML = '';\n if (!projects.length) {\n el.innerHTML = '<div class=\"empty\">No projects yet — run <code>syn .</code> in a project to start.</div>';\n return;\n }\n const ranked = [...projects].sort((a, b) => (b.total_turns || 0) - (a.total_turns || 0));\n const max = Math.max(1, ...ranked.map((p) => p.total_turns || 0));\n const frag = document.createDocumentFragment();\n for (const p of ranked) {\n const turns = p.total_turns || 0;\n const pct = Math.max(4, Math.round((turns / max) * 100));\n const row = document.createElement('button');\n row.className = 'proj-row';\n row.type = 'button';\n row.style.setProperty('--pc', projColor(p.name));\n row.innerHTML =\n '<span class=\"pr-top\">' +\n '<span class=\"pr-dot\"></span>' +\n '<span class=\"pr-name\" title=\"' + (p.path || p.name) + '\">' + p.name + '</span>' +\n '<span class=\"pr-turns\">' + fmtPlain(turns) + '</span>' +\n '<span class=\"pr-arrow\">›</span>' +\n '</span>' +\n '<span class=\"pr-bar\"><span class=\"pr-fill\" style=\"width:' + pct + '%\"></span></span>';\n row.addEventListener('click', () => openProjectDialog(p.name));\n frag.appendChild(row);\n }\n el.appendChild(frag);\n }\n\n // ----- Project dialog -----\n function lastActiveFor(name) {\n const turns = lastData?.recent_turns || [];\n for (const t of turns) {\n if (t.project_name === name) {\n const ts = t.ts || t.written_at;\n if (!ts) return '—';\n try {\n return new Date(ts).toLocaleString([], {\n year: 'numeric', month: 'short', day: 'numeric',\n hour: '2-digit', minute: '2-digit',\n });\n } catch { return ts; }\n }\n }\n return '—';\n }\n\n function openProjectDialog(name) {\n const p = (lastData?.projects || []).find((x) => x.name === name);\n if (!p) return;\n $('#d-name').textContent = p.name;\n $('#d-name').style.setProperty('--pc', projColor(p.name));\n $('#d-name').classList.add('has-accent');\n $('#d-path').textContent = p.path || '';\n $('#d-cost').textContent = fmtCostFlat(p.estimated_cost_usd);\n $('#d-turns').textContent = fmtPlain(p.total_turns);\n $('#d-input').textContent = fmtPlain(p.total_input_tokens);\n $('#d-output').textContent = fmtPlain(p.total_output_tokens);\n $('#d-cache-r').textContent = fmtPlain(p.total_cache_read);\n $('#d-cache-w').textContent = fmtPlain(p.total_cache_create);\n $('#d-blocks').textContent = fmtPlain(p.blocked_count);\n $('#d-last').textContent = lastActiveFor(p.name);\n $('#dialog-backdrop').classList.remove('hidden');\n }\n\n function closeProjectDialog() {\n $('#dialog-backdrop').classList.add('hidden');\n }\n\n $('#dialog-close').addEventListener('click', closeProjectDialog);\n $('#dialog-backdrop').addEventListener('click', (e) => {\n if (e.target.id === 'dialog-backdrop') closeProjectDialog();\n });\n\n // ----- FAQ dialog -----\n const faqBackdrop = $('#faq-backdrop');\n function openFaq() { faqBackdrop.classList.remove('hidden'); }\n function closeFaq() { faqBackdrop.classList.add('hidden'); }\n $('#faq-btn').addEventListener('click', openFaq);\n $('#faq-close').addEventListener('click', closeFaq);\n faqBackdrop.addEventListener('click', (e) => {\n if (e.target.id === 'faq-backdrop') closeFaq();\n });\n\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') { closeProjectDialog(); closeFaq(); }\n });\n\n $('#turns-prev').addEventListener('click', () => gotoTurnsPage(-1));\n $('#turns-next').addEventListener('click', () => gotoTurnsPage(1));\n\n // Reflect the actual port the dashboard is served on\n const portEl = $('#port-num');\n if (portEl && window.location.port) portEl.textContent = window.location.port;\n\n // ----- Donut chart (all-time model usage by turn count) -----\n // Takes a { model-string -> count } map summed across ALL projects, so it\n // reflects true all-time usage — independent of the recent-turns cap.\n function renderDonut(modelCounts) {\n const counts = { opus: 0, sonnet: 0, haiku: 0, unknown: 0 };\n for (const [model, n] of Object.entries(modelCounts || {})) counts[modelFamily(model)] += n;\n const total = counts.opus + counts.sonnet + counts.haiku + counts.unknown;\n\n const segs = [\n { fam: 'opus', label: 'Opus', n: counts.opus, color: 'var(--c-opus)' },\n { fam: 'sonnet', label: 'Sonnet', n: counts.sonnet, color: 'var(--c-sonnet)' },\n { fam: 'haiku', label: 'Haiku', n: counts.haiku, color: 'var(--c-haiku)' },\n { fam: 'unknown', label: 'Other', n: counts.unknown, color: 'var(--c-unknown)' },\n ].filter((s) => s.n > 0);\n\n const svg = $('#donut-svg');\n svg.querySelectorAll('.donut-seg').forEach((el) => el.remove());\n\n const C = 2 * Math.PI * 52; // ≈ 326.7\n let offset = 0;\n const ns = 'http://www.w3.org/2000/svg';\n if (total === 0) {\n // pleasant empty state — leave just the track\n } else {\n for (const s of segs) {\n const arc = (s.n / total) * C;\n const c = document.createElementNS(ns, 'circle');\n c.setAttribute('cx', '70');\n c.setAttribute('cy', '70');\n c.setAttribute('r', '52');\n c.setAttribute('fill', 'none');\n c.setAttribute('stroke', s.color);\n c.setAttribute('stroke-width', '14');\n c.setAttribute('stroke-dasharray', arc + ' ' + C);\n c.setAttribute('stroke-dashoffset', String(-offset));\n c.setAttribute('transform', 'rotate(-90 70 70)');\n c.setAttribute('stroke-linecap', segs.length === 1 ? 'round' : 'butt');\n c.classList.add('donut-seg');\n svg.appendChild(c);\n offset += arc;\n }\n }\n\n $('#donut-total').textContent = total;\n\n const legend = $('#donut-legend');\n legend.innerHTML = '';\n const lf = document.createDocumentFragment();\n const display = segs.length ? segs : [\n { fam: 'opus', label: 'Opus', n: 0, color: 'var(--c-opus)' },\n { fam: 'sonnet', label: 'Sonnet', n: 0, color: 'var(--c-sonnet)' },\n { fam: 'haiku', label: 'Haiku', n: 0, color: 'var(--c-haiku)' },\n ];\n for (const s of display) {\n const pct = total > 0 ? Math.round((s.n / total) * 100) : 0;\n const row = document.createElement('div');\n row.className = 'dl-row';\n row.innerHTML =\n '<span class=\"dl-dot\" style=\"background:' + s.color + '\"></span>' +\n '<span class=\"dl-name\">' + s.label + '</span>' +\n '<span class=\"dl-count\">' + s.n + '</span>' +\n '<span class=\"dl-pct\">' + pct + '%</span>';\n lf.appendChild(row);\n }\n legend.appendChild(lf);\n }\n\n // ----- master render -----\n function applyData(data) {\n const turns = data.recent_turns || [];\n const gates = data.recent_gates || [];\n\n renderSession(turns);\n renderSavings(data.global);\n renderCostHero(data.global);\n renderMoat(data.global);\n renderToolUsage(data.global);\n renderHotFiles(data.active);\n renderTurns(turns);\n renderGateMini(gates);\n\n // Donut: sum per-project model counts (uncapped, full history) so it\n // shows true all-time usage rather than just the recent-turns window.\n const modelCounts = {};\n for (const p of (data.projects || []))\n for (const [m, n] of Object.entries(p.models || {}))\n modelCounts[m] = (modelCounts[m] || 0) + n;\n renderDonut(modelCounts);\n }\n\n // ----- polling -----\n async function tick() {\n try {\n const res = await fetch('/data');\n if (!res.ok) throw new Error('HTTP ' + res.status);\n const data = await res.json();\n lastData = data;\n { const ap = data.active?.project_root || '—'; const el = $('#active-project'); el.textContent = ap; el.title = ap; }\n renderProjects(data.projects || []);\n applyData(data);\n $('#status').textContent = 'live · ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n $('#dot').classList.add('live'); $('#dot').classList.remove('dead');\n } catch (e) {\n $('#status').textContent = 'offline';\n $('#dot').classList.add('dead'); $('#dot').classList.remove('live');\n }\n }\n\n // ----- Viewport-clamped tooltip -----\n const tooltipEl = document.createElement('div');\n tooltipEl.className = 'global-tooltip';\n document.body.appendChild(tooltipEl);\n let activeTooltipTarget = null;\n\n function positionTooltip(target) {\n const rect = target.getBoundingClientRect();\n const ttRect = tooltipEl.getBoundingClientRect();\n const margin = 12;\n let top = rect.top - ttRect.height - 10;\n let left = rect.left + rect.width / 2 - ttRect.width / 2;\n if (left < margin) left = margin;\n if (left + ttRect.width > window.innerWidth - margin) {\n left = window.innerWidth - ttRect.width - margin;\n }\n if (top < margin) {\n top = rect.bottom + 10;\n }\n if (top + ttRect.height > window.innerHeight - margin) {\n top = window.innerHeight - ttRect.height - margin;\n }\n tooltipEl.style.top = top + 'px';\n tooltipEl.style.left = left + 'px';\n }\n\n function showTooltip(target) {\n const text = target.getAttribute('data-tooltip');\n if (!text) return;\n tooltipEl.textContent = text;\n tooltipEl.classList.add('on');\n positionTooltip(target);\n }\n\n function hideTooltip() {\n tooltipEl.classList.remove('on');\n }\n\n document.addEventListener('mouseover', (e) => {\n const t = (e.target instanceof Element) ? e.target.closest('.has-tooltip') : null;\n if (t !== activeTooltipTarget) {\n activeTooltipTarget = t;\n if (t) showTooltip(t);\n else hideTooltip();\n }\n });\n document.addEventListener('scroll', hideTooltip, true);\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTooltip(); });\n\n setHeroDate();\n tick();\n setInterval(tick, 10000);\n </script>\n</body>\n</html>\n","/* Synthra dashboard · v0.2 · Cool Marine\n Darkened surfaces; brand blue reserved for hero elements only.\n Layout: top nav + hero strip + 3-column main, fits 1280×720. */\n\n:root {\n /* Core palette */\n --ink: #04081A;\n --navy: #0A1530;\n --navy-2: #122549;\n --deep-blue: #1B3A78;\n --blue: #2C5DB8;\n --blue-bright: #5C8FE6;\n --sky: #9BC2EF;\n --mist: #D7E6F7;\n --bone: #F4F7FC;\n\n /* Text */\n --text: #ECF2FB;\n --text-dim: #A9BBD6;\n --text-mute: #6D80A0;\n\n /* Rules / dividers */\n --rule: rgba(155, 194, 239, .14);\n --rule-2: rgba(155, 194, 239, .06);\n --rule-hover: rgba(155, 194, 239, .28);\n\n /* Surfaces (darker than v0.1.2) */\n --surface-1: rgba(18, 37, 73, .14);\n --surface-2: rgba(18, 37, 73, .22);\n --surface-3: rgba(4, 8, 26, .55);\n\n /* Signal accents (OKLCH shared chroma) */\n --signal-cyan: oklch(78% 0.14 220);\n --signal-amber: oklch(78% 0.14 75);\n --signal-rose: oklch(70% 0.14 20);\n --signal-green: oklch(75% 0.14 155);\n --signal-violet: oklch(72% 0.14 285);\n\n /* Model family colors */\n --c-opus: #FF6338;\n --c-sonnet: #FFB938;\n --c-haiku: #7438FF;\n --c-unknown: #12CBF5;\n\n /* Money */\n --money: var(--signal-green);\n\n /* Type */\n --font-sans: \"Geist\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-serif: \"Instrument Serif\", \"Times New Roman\", serif;\n --font-mono: \"Geist Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n}\n\n/* ============================================================\n Reset + base\n ============================================================ */\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n}\n\nhtml,\nbody {\n height: 100vh;\n overflow: hidden;\n}\n\nbody {\n background: var(--ink);\n color: var(--text);\n font-family: var(--font-sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n display: grid;\n grid-template-rows: auto 1fr auto;\n position: relative;\n}\n\n/* Layered backdrop — quieter */\nbody::before,\nbody::after {\n content: \"\";\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 0;\n}\n\nbody::before {\n background-image: radial-gradient(circle, rgba(155, 194, 239, .06) 1px, transparent 1.2px);\n background-size: 22px 22px;\n}\n\nbody::after {\n background:\n radial-gradient(60% 40% at 50% 105%, rgba(44, 93, 184, .16) 0%, rgba(10, 21, 48, 0) 65%),\n radial-gradient(30% 25% at 50% 0%, rgba(92, 143, 230, .06) 0%, transparent 70%);\n}\n\nbody>* {\n position: relative;\n z-index: 1;\n}\n\nbutton {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: transparent;\n color: inherit;\n}\n\na {\n color: inherit;\n text-decoration: none;\n}\n\n/* ============================================================\n Top nav\n ============================================================ */\n.topnav {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: center;\n height: 52px;\n padding: 0 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(180deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.brand-mark {\n width: 22px;\n height: 22px;\n border-radius: 7px;\n background: radial-gradient(120% 120% at 30% 30%, #6FA6E8 0%, #2C5DB8 45%, #0A1530 100%);\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22), 0 4px 12px -6px #2C5DB8;\n}\n\n.brand-name {\n font-size: 15px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--mist);\n}\n\n.brand-name em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: 0;\n}\n\n.brand-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-left: 6px;\n padding-left: 10px;\n border-left: 1px solid var(--rule);\n}\n\n.top-right {\n display: flex;\n align-items: center;\n gap: 12px;\n grid-column: 2;\n justify-self: center;\n}\n\n.topnav-right {\n grid-column: 3;\n justify-self: end;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.port-badge {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding: 6px 10px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n}\n\n.port-badge .mono {\n color: var(--text-dim);\n letter-spacing: 0.04em;\n text-transform: none;\n}\n\n.faq-btn {\n width: 30px;\n height: 30px;\n border-radius: 50%;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .55);\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 13px;\n font-weight: 500;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, border-color 180ms, color 180ms, transform 180ms;\n}\n\n.faq-btn:hover {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n\n.status-pill {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 6px 12px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-dim);\n transition: border-color 240ms ease;\n}\n\n.status-pill:has(.dot.live) {\n border-color: rgba(155, 194, 239, .45);\n color: var(--mist);\n animation: pill-glow 2.4s ease-in-out infinite;\n}\n\n.status-pill:has(.dot.dead) {\n border-color: rgba(220, 90, 90, .40);\n color: oklch(80% 0.10 20);\n}\n\n@keyframes pill-glow {\n\n 0%,\n 100% {\n box-shadow: 0 0 14px -4px rgba(155, 194, 239, .30), inset 0 0 12px -8px rgba(155, 194, 239, .30);\n }\n\n 50% {\n box-shadow: 0 0 26px -2px rgba(155, 194, 239, .55), inset 0 0 18px -6px rgba(155, 194, 239, .45);\n }\n}\n\n.dot {\n width: 7px;\n height: 7px;\n border-radius: 2px;\n background: var(--text-mute);\n transition: background 200ms;\n}\n\n.dot.live {\n background: var(--signal-cyan);\n animation: dot-pulse 1.8s ease-in-out infinite;\n}\n\n.dot.dead {\n background: var(--signal-rose);\n box-shadow: 0 0 0 3px rgba(220, 90, 90, .10);\n}\n\n@keyframes dot-pulse {\n\n 0%,\n 100% {\n box-shadow:\n 0 0 0 3px rgba(155, 194, 239, .10),\n 0 0 6px rgba(155, 194, 239, .50);\n }\n\n 50% {\n box-shadow:\n 0 0 0 6px rgba(155, 194, 239, .05),\n 0 0 14px rgba(155, 194, 239, .90);\n }\n}\n\n/* ============================================================\n Hero strip\n ============================================================ */\n.hero-strip {\n display: flex;\n align-items: center;\n gap: 24px;\n padding: 14px 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(90deg, rgba(27, 58, 120, .10) 0%, rgba(4, 8, 26, 0) 100%);\n position: relative;\n overflow: hidden;\n}\n\n.hero-spacer {\n flex: 1;\n}\n\n.date-block {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.d-day {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 38px;\n line-height: 1;\n letter-spacing: -0.04em;\n color: var(--mist);\n}\n\n.d-rest {\n display: flex;\n flex-direction: column;\n gap: 2px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-dim);\n}\n\n.d-rest .d-mute {\n color: var(--text-mute);\n}\n\n.active-block {\n display: flex;\n flex-direction: column;\n gap: 2px;\n text-align: right;\n max-width: 360px;\n overflow: hidden;\n}\n\n.ab-label {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.ab-value {\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 360px;\n}\n\n/* ============================================================\n Main grid\n ============================================================ */\n.grid-main {\n display: grid;\n grid-template-columns: 260px 1fr 340px;\n gap: 16px;\n padding: 16px 24px;\n min-height: 0;\n z-index: 10;\n}\n\n.col-left,\n.col-center,\n.col-right {\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n}\n\n/* Savings + Cost share one row at the top of the center column */\n.hero-row {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 12px;\n align-items: stretch;\n flex-shrink: 0;\n}\n\n.hero-row > .card { flex-shrink: 1; }\n\n@media (max-width: 1100px) {\n .hero-row { grid-template-columns: 1fr; }\n}\n\n/* ============================================================\n Panels / cards — darker\n ============================================================ */\n.panel,\n.card {\n position: relative;\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n padding: 14px 16px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n transition: border-color 180ms ease, background 180ms ease;\n}\n\n.card.has-tooltip {\n cursor: help;\n}\n\n.card.has-tooltip:hover {\n border-color: var(--rule-hover);\n background: var(--surface-2);\n}\n\n.card-head {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 12px;\n}\n\n.card-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.card-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.card-meta {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n/* Legend panel */\n.panel {\n padding: 14px 14px 16px;\n gap: 14px;\n flex-shrink: 0;\n}\n\n.p-head {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.p-section {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.p-section+.p-section {\n padding-top: 12px;\n border-top: 1px solid var(--rule-2);\n}\n\n.ps-head {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 4px;\n}\n\n.check {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 3px 6px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n letter-spacing: 0.02em;\n}\n\nbutton.check {\n border: 0;\n background: transparent;\n width: 100%;\n text-align: left;\n}\n\n.check-clickable {\n cursor: pointer;\n border-radius: 6px;\n padding: 5px 6px;\n transition: background 140ms, color 140ms, transform 140ms;\n}\n\n.check-clickable .pf-arrow {\n margin-left: auto;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 12px;\n transition: color 140ms, transform 140ms;\n}\n\n.check-clickable:hover {\n background: rgba(155, 194, 239, .07);\n color: var(--mist);\n}\n\n.check-clickable:hover .pf-arrow {\n color: var(--sky);\n transform: translateX(2px);\n}\n\n.dot-sq {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n background: var(--text-mute);\n flex-shrink: 0;\n}\n\n.dot-sq.opus {\n background: var(--c-opus);\n}\n\n.dot-sq.sonnet {\n background: var(--c-sonnet);\n}\n\n.dot-sq.haiku {\n background: var(--c-haiku);\n}\n\n.dot-sq.unknown {\n background: var(--c-unknown);\n}\n\n.proj-filter {\n display: flex;\n flex-direction: column;\n gap: 1px;\n max-height: 90px;\n overflow-y: auto;\n}\n\n.pf-name {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 140px;\n}\n\n/* ============================================================\n Donut card (model usage)\n ============================================================ */\n.donut-card {\n flex: 1;\n gap: 10px;\n}\n\n.donut-wrap {\n position: relative;\n width: 140px;\n height: 140px;\n margin: 4px auto 0;\n}\n\n.donut {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.donut-track {\n fill: none;\n stroke: rgba(155, 194, 239, .07);\n stroke-width: 14;\n}\n\n.donut-seg {\n transition: stroke-dashoffset 400ms ease, stroke-dasharray 400ms ease;\n}\n\n.donut-center {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n pointer-events: none;\n}\n\n.donut-total {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.donut-total-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.donut-legend {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dl-row {\n display: grid;\n grid-template-columns: auto 1fr auto;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n}\n\n.dl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.dl-name {\n color: var(--text-dim);\n}\n\n.dl-pct {\n color: var(--mist);\n font-weight: 500;\n}\n\n/* ============================================================\n Center column — Metric strip (no card chrome, divider-separated)\n ============================================================ */\n.metric-strip {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n overflow: hidden;\n flex-shrink: 0;\n}\n\n.metric-item {\n padding: 14px 18px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n cursor: help;\n border-right: 1px solid var(--rule-2);\n transition: background 200ms ease;\n min-width: 0;\n}\n.metric-item:last-child { border-right: 0; }\n.metric-item:hover { background: var(--surface-2); }\n\n.m-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.m-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1;\n}\n\n.m-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: -0.005em;\n}\n\n/* ============================================================\n Savings card\n ============================================================ */\n.card.savings {\n flex-shrink: 0;\n gap: 12px;\n background:\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 50%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .18);\n}\n\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .32);\n}\n\n.savings-body {\n display: grid;\n grid-template-columns: auto 1fr;\n align-items: center;\n gap: 18px;\n}\n\n.savings-figure {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.savings-money {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n}\n\n.savings-tokens {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.savings-bar {\n position: relative;\n height: 8px;\n border-radius: 999px;\n overflow: hidden;\n background: var(--surface-3);\n display: flex;\n}\n\n.savings-actual {\n height: 100%;\n background: rgba(215, 230, 247, .55);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.savings-saved {\n height: 100%;\n background: var(--signal-green);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12);\n}\n\n.savings-legend {\n grid-column: 2;\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 24px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.08em;\n color: var(--text-mute);\n margin-top: 8px;\n}\n\n.sl-row {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.sl-row b {\n color: var(--mist);\n font-weight: 500;\n letter-spacing: 0.04em;\n}\n\n.sl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.sl-dot.actual {\n background: var(--mist);\n}\n\n.sl-dot.saved {\n background: var(--signal-green);\n}\n\n/* ============================================================\n Recent turns table\n ============================================================ */\n.turns-card {\n flex: 1;\n padding: 0;\n overflow: hidden;\n}\n\n.turns-card .card-head {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.turns-scroll {\n flex: 1;\n overflow-y: auto;\n min-height: 0;\n}\n\n.turns-table {\n width: 100%;\n border-collapse: collapse;\n}\n\n.turns-table thead th {\n position: sticky;\n top: 0;\n background: var(--ink);\n padding: 9px 16px;\n text-align: left;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n font-weight: 500;\n border-bottom: 1px solid var(--rule);\n z-index: 1;\n}\n\n.turns-table thead th.num {\n text-align: right;\n}\n\n.turns-table tbody td {\n padding: 8px 16px;\n border-bottom: 1px solid var(--rule-2);\n color: var(--text-dim);\n font-size: 12px;\n}\n\n.turns-table tbody td.num {\n text-align: right;\n font-family: var(--font-mono);\n}\n\n.turns-table tbody td.cost {\n color: var(--money);\n font-family: var(--font-mono);\n font-weight: 500;\n}\n\n.turns-table tbody td.ts {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n}\n\n.turns-table tbody td.proj {\n color: var(--mist);\n}\n\n.turns-table tbody tr:hover {\n background: rgba(155, 194, 239, .03);\n}\n\n.turns-table tbody tr:last-child td {\n border-bottom: 0;\n}\n\n/* Model pills */\n.model-pill {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 2px 8px;\n border-radius: 999px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.04em;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .5);\n color: var(--mist);\n}\n\n.model-pill .sq {\n width: 6px;\n height: 6px;\n border-radius: 2px;\n background: var(--text-mute);\n}\n\n.model-pill.opus {\n color: #FF8A66;\n border-color: rgba(255, 99, 56, .32);\n background: rgba(255, 99, 56, .07);\n}\n\n.model-pill.opus .sq {\n background: var(--c-opus);\n}\n\n.model-pill.sonnet {\n color: #FFC766;\n border-color: rgba(255, 185, 56, .32);\n background: rgba(255, 185, 56, .07);\n}\n\n.model-pill.sonnet .sq {\n background: var(--c-sonnet);\n}\n\n.model-pill.haiku {\n color: #A878FF;\n border-color: rgba(116, 56, 255, .42);\n background: rgba(116, 56, 255, .10);\n}\n\n.model-pill.haiku .sq {\n background: var(--c-haiku);\n}\n\n.model-pill.unknown {\n color: #5BDDF7;\n border-color: rgba(18, 203, 245, .32);\n background: rgba(18, 203, 245, .07);\n font-style: italic;\n}\n\n.model-pill.unknown .sq {\n background: var(--c-unknown);\n}\n\n/* ============================================================\n Right column — Cost hero\n ============================================================ */\n.cost-hero {\n position: relative;\n overflow: hidden;\n background:\n radial-gradient(120% 80% at 50% 110%, rgba(44, 93, 184, .18) 0%, rgba(4, 8, 26, .20) 60%),\n var(--surface-1);\n padding: 16px 18px 18px;\n gap: 10px;\n flex-shrink: 0;\n}\n\n.big-money {\n position: relative;\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 42px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n margin-top: 2px;\n}\n\n.big-money em {\n font-family: inherit;\n font-style: normal;\n font-weight: inherit;\n color: inherit;\n letter-spacing: inherit;\n opacity: 1;\n}\n\n.cost-sub {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin-top: 4px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.cs-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n font-family: var(--font-mono);\n font-size: 11px;\n}\n\n.cs-k {\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n/* File paths stay as-is (no uppercase/wide tracking) and may overflow — clip. */\n.cs-k.path {\n text-transform: none;\n letter-spacing: 0.01em;\n font-size: 11px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n max-width: 14rem;\n}\n\n.cs-v {\n color: var(--mist);\n}\n\n/* ============================================================\n Moat card\n ============================================================ */\n.moat {\n flex: 1;\n gap: 8px;\n}\n\n/* Hot-files list: cap height + scroll so a long list never squeezes the Moat */\n#hot-files-list {\n max-height: 190px;\n overflow-y: auto;\n}\n\n.moat-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.03em;\n line-height: 1;\n color: var(--mist);\n margin-top: 2px;\n}\n\n.moat-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 18px;\n color: var(--sky);\n letter-spacing: 0;\n margin-left: 6px;\n}\n\n.gate-mini {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.gate-row {\n display: grid;\n grid-template-columns: auto auto 1fr;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-dim);\n padding: 3px 0;\n}\n\n.gate-row .g-ts {\n color: var(--text-mute);\n font-size: 9px;\n min-width: 38px;\n}\n\n.gate-row .g-decision {\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 999px;\n}\n\n.gate-row .g-decision.block {\n color: var(--signal-rose);\n background: rgba(220, 90, 90, .06);\n}\n\n.gate-row .g-decision.allow {\n color: var(--text-mute);\n background: rgba(155, 194, 239, .03);\n}\n\n.gate-row .g-q {\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ============================================================\n Tooltips\n ============================================================ */\n.has-tooltip {\n position: relative;\n}\n\n/* Global JS-positioned tooltip — viewport-clamped */\n.global-tooltip {\n position: fixed;\n top: 0;\n left: 0;\n background: linear-gradient(180deg, rgba(18, 37, 73, .98), rgba(10, 21, 48, .98));\n color: var(--mist);\n border: 1px solid var(--rule-hover);\n border-radius: 12px;\n padding: 14px 16px;\n font-family: var(--font-sans);\n font-size: 15px;\n font-weight: 400;\n text-transform: none;\n letter-spacing: 0;\n white-space: normal;\n width: 320px;\n max-width: calc(100vw - 24px);\n text-align: left;\n line-height: 1.55;\n box-shadow: 0 16px 36px rgba(0, 0, 0, .7);\n backdrop-filter: blur(10px);\n z-index: 99999;\n opacity: 0;\n pointer-events: none;\n transform: translateY(6px);\n transition: opacity 180ms ease, transform 180ms ease;\n}\n\n.global-tooltip.on {\n opacity: 1;\n transform: translateY(0);\n}\n\n/* ============================================================\n Footer\n ============================================================ */\n.foot {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 24px;\n border-top: 1px solid var(--rule);\n background: linear-gradient(0deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.foot em {\n font-family: var(--font-serif);\n font-style: italic;\n text-transform: none;\n letter-spacing: 0;\n color: var(--sky);\n font-size: 12px;\n}\n\n.foot .mono {\n color: var(--text-dim);\n text-transform: none;\n letter-spacing: 0.04em;\n}\n\n/* ============================================================\n Empty state\n ============================================================ */\n.empty {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.06em;\n color: var(--text-mute);\n text-align: center;\n padding: 16px 8px;\n font-style: italic;\n text-transform: none;\n}\n\n/* Scrollbar styling */\n.turns-scroll::-webkit-scrollbar,\n.proj-chart::-webkit-scrollbar,\n.gate-mini::-webkit-scrollbar,\n#hot-files-list::-webkit-scrollbar {\n width: 6px;\n}\n\n.turns-scroll::-webkit-scrollbar-thumb,\n.proj-chart::-webkit-scrollbar-thumb,\n.gate-mini::-webkit-scrollbar-thumb,\n#hot-files-list::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.turns-scroll::-webkit-scrollbar-track,\n.proj-chart::-webkit-scrollbar-track,\n.gate-mini::-webkit-scrollbar-track,\n#hot-files-list::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ============================================================\n Staggered cascade on first paint (one-time, MOTION 6)\n ============================================================ */\n@keyframes cascade-in {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .col-left > *,\n .col-center > *,\n .col-right > * {\n opacity: 0;\n animation: cascade-in 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards;\n will-change: transform, opacity;\n }\n .col-left > *:nth-child(1) { animation-delay: 0ms; }\n .col-left > *:nth-child(2) { animation-delay: 120ms; }\n .col-center > *:nth-child(1) { animation-delay: 40ms; }\n .col-center > *:nth-child(2) { animation-delay: 140ms; }\n .col-center > *:nth-child(3) { animation-delay: 240ms; }\n .col-right > *:nth-child(1) { animation-delay: 80ms; }\n .col-right > *:nth-child(2) { animation-delay: 200ms; }\n .col-right > *:nth-child(3) { animation-delay: 320ms; }\n .col-right > *:nth-child(4) { animation-delay: 440ms; }\n\n /* Clear will-change after animation completes */\n .col-left > *,\n .col-center > *,\n .col-right > * {\n animation-fill-mode: forwards;\n }\n}\n\n/* ============================================================\n Source / basis annotations\n ============================================================ */\n.card-source {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.08em;\n text-transform: lowercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-top: auto;\n padding-top: 8px;\n border-top: 1px solid var(--rule-2);\n width: 100%;\n}\n\n.src-badge {\n font-family: var(--font-mono);\n font-size: 8px;\n font-weight: 500;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 4px;\n flex-shrink: 0;\n}\n\n.src-badge.verified {\n color: var(--signal-green);\n background: rgba(123, 255, 199, .08);\n border: 1px solid rgba(123, 255, 199, .25);\n}\n\n.src-badge.estimated,\n.src-badge.estimated.floor {\n color: var(--signal-amber);\n background: rgba(255, 185, 56, .10);\n border: 1px solid rgba(255, 185, 56, .30);\n}\n\n.src-badge.priced {\n color: var(--signal-cyan);\n background: rgba(155, 194, 239, .08);\n border: 1px solid rgba(155, 194, 239, .25);\n}\n\n/* Eyebrow that contains a badge */\n.card-eyebrow .src-badge {\n margin-left: 4px;\n}\n\n/* Savings audit row — live formula reveal */\n.savings-audit {\n margin-top: 10px;\n padding: 10px 12px;\n border: 1px dashed rgba(255, 185, 56, .25);\n border-radius: 8px;\n background: rgba(255, 185, 56, .04);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: 0.04em;\n color: var(--text-mute);\n text-align: center;\n}\n\n.savings-audit b {\n color: var(--text-dim);\n font-weight: 500;\n}\n\n.savings-audit .audit-result {\n color: var(--money);\n}\n\n/* ============================================================\n FAQ dialog\n ============================================================ */\n.dialog.dialog-faq {\n max-width: min(80vw, 1100px);\n width: 100%;\n max-height: 86vh;\n display: flex;\n flex-direction: column;\n padding: 28px 32px 24px;\n gap: 6px;\n}\n\n.dialog.dialog-faq .dialog-path {\n margin-bottom: 4px;\n word-break: normal;\n overflow-wrap: anywhere;\n}\n\n.faq-content {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n margin-top: 18px;\n padding-right: 8px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content::-webkit-scrollbar {\n width: 6px;\n}\n\n.faq-content::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.faq-content::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.faq-content details {\n border: 1px solid var(--rule);\n border-radius: 12px;\n background: var(--surface-1);\n overflow: hidden;\n transition: background 180ms, border-color 180ms;\n flex-shrink: 0;\n}\n\n.faq-content details:hover {\n border-color: rgba(155, 194, 239, .22);\n}\n\n.faq-content details[open] {\n background: var(--surface-2);\n border-color: var(--rule-hover);\n}\n\n.faq-content summary {\n cursor: pointer;\n padding: 14px 20px;\n font-family: var(--font-sans);\n font-size: 14px;\n font-weight: 500;\n color: var(--mist);\n list-style: none;\n display: flex;\n align-items: center;\n gap: 12px;\n user-select: none;\n}\n\n.faq-content summary::-webkit-details-marker {\n display: none;\n}\n\n.faq-content summary::before {\n content: \"›\";\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 14px;\n transition: transform 220ms ease, color 220ms ease;\n}\n\n.faq-content details[open] summary::before {\n transform: rotate(90deg);\n color: var(--sky);\n}\n\n.faq-content .faq-body {\n padding: 0 22px 20px 46px;\n color: var(--text-dim);\n font-size: 13.5px;\n line-height: 1.7;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content .faq-body p {\n margin: 0;\n}\n\n.faq-content .faq-body ul {\n margin: 0;\n padding-left: 20px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.faq-content .faq-body li {\n margin: 0;\n}\n\n.faq-content .faq-body b,\n.faq-content .faq-body strong {\n color: var(--mist);\n font-weight: 500;\n}\n\n.faq-content .faq-body code {\n font-family: var(--font-mono);\n font-size: 12px;\n background: rgba(155, 194, 239, .08);\n padding: 2px 6px;\n border-radius: 4px;\n color: var(--mist);\n border: 1px solid rgba(155, 194, 239, .12);\n word-break: break-word;\n}\n\n.faq-content .faq-body a {\n color: var(--blue-bright);\n text-decoration: underline;\n text-decoration-color: rgba(92, 143, 230, .40);\n text-underline-offset: 3px;\n transition: color 140ms, text-decoration-color 140ms;\n}\n\n.faq-content .faq-body a:hover {\n color: var(--mist);\n text-decoration-color: var(--sky);\n}\n\n.faq-content .faq-body table {\n width: 100%;\n border-collapse: collapse;\n margin: 4px 0;\n font-size: 13px;\n table-layout: fixed;\n}\n\n.faq-content .faq-body thead td {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 6px;\n border-bottom: 1px solid var(--rule);\n font-weight: 500;\n}\n\n.faq-content .faq-body td {\n padding: 9px 10px;\n border-bottom: 1px solid var(--rule-2);\n vertical-align: top;\n word-break: break-word;\n}\n\n.faq-content .faq-body tr:last-child td {\n border-bottom: 0;\n}\n\n.faq-content .faq-body td:first-child {\n color: var(--text-dim);\n width: 38%;\n}\n\n.faq-content .faq-body td:first-child code {\n font-size: 11.5px;\n}\n\n.faq-content .faq-body .formula-box {\n font-family: var(--font-mono);\n font-size: 12.5px;\n background: rgba(255, 185, 56, .06);\n padding: 12px 14px;\n border-radius: 8px;\n border: 1px dashed rgba(255, 185, 56, .30);\n color: var(--mist);\n letter-spacing: 0.02em;\n}\n\n.faq-content .faq-body .link-list {\n list-style: none;\n padding-left: 0;\n}\n\n.faq-content .faq-body .link-list li {\n padding-left: 18px;\n position: relative;\n}\n\n.faq-content .faq-body .link-list li::before {\n content: \"›\";\n position: absolute;\n left: 0;\n color: var(--sky);\n font-family: var(--font-mono);\n}\n\n.faq-content .faq-body .warning {\n margin-top: 14px;\n padding: 12px 14px;\n background: rgba(255, 185, 56, .06);\n border: 1px solid rgba(255, 185, 56, .25);\n border-left: 3px solid var(--signal-amber);\n border-radius: 8px;\n font-size: 12.5px;\n color: var(--text-dim);\n}\n\n.faq-content .faq-body .warning .icon {\n color: var(--signal-amber);\n margin-right: 8px;\n font-weight: 500;\n}\n\n/* ============================================================\n Project dialog\n ============================================================ */\n.dialog-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(4, 8, 26, .78);\n backdrop-filter: blur(10px);\n z-index: 10000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n animation: dlg-fade 180ms ease;\n}\n\n@keyframes dlg-fade {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\n.dialog {\n position: relative;\n width: 100%;\n max-width: 520px;\n background:\n radial-gradient(120% 80% at 50% 0%, rgba(44, 93, 184, .22) 0%, rgba(4, 8, 26, .20) 60%),\n linear-gradient(180deg, rgba(18, 37, 73, .88) 0%, rgba(10, 21, 48, .96) 100%);\n border: 1px solid var(--rule-hover);\n border-radius: 18px;\n padding: 28px 32px 32px;\n box-shadow:\n 0 30px 80px -20px rgba(0, 0, 0, .7),\n inset 0 1px 0 rgba(255, 255, 255, .04);\n animation: dlg-rise 220ms cubic-bezier(.2, .7, .2, 1);\n}\n\n@keyframes dlg-rise {\n from {\n opacity: 0;\n transform: translateY(8px) scale(.98);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.dialog-close {\n position: absolute;\n top: 14px;\n right: 14px;\n width: 30px;\n height: 30px;\n border-radius: 50%;\n color: var(--text-mute);\n font-size: 22px;\n line-height: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, color 180ms;\n}\n\n.dialog-close:hover {\n background: rgba(155, 194, 239, .10);\n color: var(--mist);\n}\n\n.dialog-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 10px;\n}\n\n.dialog-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.dialog-name {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 28px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1.1;\n}\n\n.dialog-path {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n margin-top: 6px;\n word-break: break-all;\n}\n\n.dialog-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 18px 24px;\n margin-top: 22px;\n padding-top: 20px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dg-cell {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.dg-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.dg-v {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 22px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.dg-v.money {\n color: var(--money);\n}\n\n.dg-v-sm {\n font-size: 13px;\n font-family: var(--font-mono);\n font-weight: 400;\n color: var(--text-dim);\n letter-spacing: 0;\n}\n\n/* ============================================================\n v0.3 visual refresh\n - merged model-family legend into donut (count column)\n - Projects -> colored bar chart\n - elevated Savings card\n ============================================================ */\n\n/* Left column sizing: donut natural height, projects fills + scrolls */\n.donut-card { flex: 0 0 auto; }\n\n/* Donut legend now carries a count column */\n.dl-row {\n grid-template-columns: auto 1fr auto auto;\n gap: 8px;\n padding: 1px 0;\n}\n.dl-count {\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n}\n.dl-pct {\n min-width: 30px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n/* ---- Projects bar chart ---- */\n.projects-card {\n flex: 1 1 auto;\n min-height: 0;\n gap: 10px;\n}\n.proj-chart {\n display: flex;\n flex-direction: column;\n gap: 9px;\n overflow-y: auto;\n min-height: 0;\n flex: 1;\n padding-right: 2px;\n}\n.proj-row {\n display: flex;\n flex-direction: column;\n gap: 7px;\n width: 100%;\n text-align: left;\n padding: 8px;\n border-radius: 9px;\n background: transparent;\n border: 0;\n cursor: pointer;\n transition: background 150ms ease;\n}\n.proj-row:hover { background: rgba(155, 194, 239, .055); }\n.pr-top {\n display: grid;\n grid-template-columns: auto 1fr auto auto;\n align-items: center;\n gap: 9px;\n}\n.pr-dot {\n width: 9px;\n height: 9px;\n border-radius: 3px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 9px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n.pr-name {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text-dim);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n letter-spacing: 0.01em;\n transition: color 150ms ease;\n}\n.proj-row:hover .pr-name { color: var(--mist); }\n.pr-turns {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--mist);\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.02em;\n}\n.pr-arrow {\n font-family: var(--font-mono);\n font-size: 13px;\n color: var(--text-mute);\n transition: color 150ms ease, transform 150ms ease;\n}\n.proj-row:hover .pr-arrow {\n color: var(--pc, var(--sky));\n transform: translateX(2px);\n}\n.pr-bar {\n position: relative;\n height: 5px;\n border-radius: 999px;\n background: var(--surface-3);\n overflow: hidden;\n}\n.pr-fill {\n display: block;\n height: 100%;\n border-radius: 999px;\n background: linear-gradient(90deg,\n color-mix(in oklch, var(--pc, var(--sky)) 45%, transparent) 0%,\n var(--pc, var(--sky)) 100%);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18);\n transition: width 640ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n/* ---- Elevated Savings card (priority focus) ---- */\n.card.savings {\n background:\n radial-gradient(120% 140% at 10% -10%, rgba(123, 255, 199, .14) 0%, rgba(4, 8, 26, .08) 44%),\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 52%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .24);\n box-shadow:\n inset 0 1px 0 rgba(123, 255, 199, .08),\n 0 20px 46px -30px rgba(123, 255, 199, .55);\n}\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .38);\n}\n.savings-money {\n font-size: 40px;\n text-shadow: 0 0 26px rgba(123, 255, 199, .22);\n}\n.savings-bar { height: 9px; }\n.savings-saved {\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, .18),\n 0 0 12px -2px var(--signal-green);\n}\n\n/* Project dialog: name gets a project-colored accent dot */\n.dialog-name.has-accent {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n.dialog-name.has-accent::before {\n content: \"\";\n width: 12px;\n height: 12px;\n border-radius: 4px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 12px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n\n\n/* ============================================================\n v0.3.1 — date + active project folded into the top nav\n ============================================================ */\n\n/* Let the right cluster shrink so the active path can ellipsize */\n.topnav-right { min-width: 0; }\n\n/* Compact date beside the brand */\n.nav-date {\n display: inline-flex;\n align-items: baseline;\n gap: 6px;\n margin-left: 8px;\n padding-left: 12px;\n border-left: 1px solid var(--rule);\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.nav-date .nd-day { color: var(--mist); font-weight: 500; }\n.nav-date .nd-weekday { color: var(--text-dim); }\n.nav-date .nd-month { color: var(--text-mute); }\n\n/* Active project, compact, tail-truncated */\n.nav-active {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n max-width: 300px;\n padding-right: 12px;\n margin-right: 2px;\n border-right: 1px solid var(--rule);\n cursor: help;\n}\n.na-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n flex-shrink: 0;\n}\n.na-value {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--mist);\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* keep the project folder (tail) visible, ellipsize the drive prefix */\n direction: rtl;\n text-align: left;\n}\n\n/* Tighten nav on narrow widths */\n@media (max-width: 1100px) {\n .nav-active { max-width: 200px; }\n .nav-date .nd-month { display: none; }\n}\n\n\n/* Column headers signal they are hover-explainable */\n.turns-table thead th.has-tooltip { cursor: help; }\n.turns-table thead th.has-tooltip:hover { color: var(--text-dim); }\n\n/* ---- Recent-turns pager ---- */\n.turns-pager {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 10px;\n padding: 8px 2px 0;\n margin-top: 6px;\n border-top: 1px solid var(--rule-2);\n}\n.turns-pager.hidden { display: none; }\n.turns-pager button {\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n background: rgba(4, 8, 26, .55);\n border: 1px solid var(--rule);\n border-radius: 7px;\n padding: 4px 10px;\n cursor: pointer;\n transition: background 150ms, border-color 150ms, color 150ms, transform 150ms;\n}\n.turns-pager button:hover:not(:disabled) {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n.turns-pager button:disabled {\n opacity: .35;\n cursor: default;\n}\n#turns-page-label {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n min-width: 84px;\n text-align: center;\n}\n","<svg width=\"107\" height=\"107\" viewBox=\"0 0 107 107\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<rect x=\"0.5\" y=\"0.5\" width=\"106\" height=\"106\" rx=\"7.5\" fill=\"url(#paint0_radial_21_11)\"/>\n<rect x=\"0.5\" y=\"0.5\" width=\"106\" height=\"106\" rx=\"7.5\" stroke=\"url(#paint1_linear_21_11)\"/>\n<path d=\"M26.408 72.558C25.5813 72.558 24.6513 72.4753 23.618 72.31C22.626 72.1447 21.6753 71.938 20.766 71.69C19.898 71.442 19.216 71.1733 18.72 70.884C18.4307 70.7187 18.2033 70.5533 18.038 70.388C17.914 70.1813 17.852 69.8507 17.852 69.396L17.542 59.662C17.542 58.8353 17.8107 58.422 18.348 58.422C18.8027 58.422 19.1127 58.794 19.278 59.538L19.836 61.894C21.2413 67.8047 23.866 70.76 27.71 70.76C29.7767 70.76 31.4093 70.078 32.608 68.714C33.848 67.3087 34.468 65.3453 34.468 62.824C34.468 60.3853 33.8273 58.112 32.546 56.004C31.306 53.896 29.3013 51.8087 26.532 49.742C23.4733 47.5513 21.2413 45.4433 19.836 43.418C18.4307 41.3513 17.728 39.1607 17.728 36.846C17.728 33.7873 18.782 31.3487 20.89 29.53C23.0393 27.67 25.7673 26.74 29.074 26.74C30.314 26.74 31.5127 26.8847 32.67 27.174C33.8273 27.4633 34.778 27.8767 35.522 28.414C35.77 28.5793 35.956 28.7653 36.08 28.972C36.2453 29.1787 36.328 29.4473 36.328 29.778L36.514 39.14C36.514 39.8427 36.2453 40.194 35.708 40.194C35.336 40.194 35.0673 39.9047 34.902 39.326L34.468 37.776C33.5587 34.5933 32.608 32.2787 31.616 30.832C30.6653 29.344 29.2807 28.6 27.462 28.6C25.6847 28.6 24.2587 29.1787 23.184 30.336C22.1507 31.4933 21.634 33.126 21.634 35.234C21.634 37.0113 22.2127 38.706 23.37 40.318C24.5273 41.93 26.5527 43.8933 29.446 46.208C32.546 48.7293 34.7987 51.168 36.204 53.524C37.6507 55.8387 38.374 58.3187 38.374 60.964C38.374 63.2787 37.8573 65.304 36.824 67.04C35.7907 68.776 34.3647 70.14 32.546 71.132C30.7687 72.0827 28.7227 72.558 26.408 72.558ZM44.1831 84.71C43.0671 84.71 42.1784 84.3173 41.5171 83.532C40.8558 82.788 40.5251 81.92 40.5251 80.928C40.5251 80.1427 40.7524 79.4813 41.2071 78.944C41.6618 78.4067 42.2818 78.138 43.0671 78.138C43.7284 78.138 44.2038 78.2827 44.4931 78.572C44.7824 78.8613 44.9891 79.192 45.1131 79.564C45.2371 79.936 45.3611 80.2667 45.4851 80.556C45.6091 80.8453 45.8571 80.99 46.2291 80.99C46.6838 80.99 47.0971 80.6593 47.4691 79.998C47.8411 79.378 48.3578 78.0347 49.0191 75.968C49.4324 74.728 49.6804 73.6533 49.7631 72.744C49.8871 71.8347 49.8664 70.8633 49.7011 69.83C49.5771 68.7967 49.2671 67.536 48.7711 66.048L42.0131 44.534C41.7238 43.542 41.4344 42.9013 41.1451 42.612C40.8971 42.3227 40.4838 42.116 39.9051 41.992L38.9751 41.806C38.4378 41.682 38.1691 41.4133 38.1691 41C38.1691 40.5867 38.4584 40.38 39.0371 40.38H49.2671C49.8458 40.38 50.1351 40.5867 50.1351 41C50.1351 41.4547 49.8664 41.7233 49.3291 41.806L48.3371 41.93C47.3451 42.054 46.7044 42.302 46.4151 42.674C46.1671 43.0047 46.1878 43.6247 46.4771 44.534L51.9331 62.7C52.0571 63.1133 52.2431 63.32 52.4911 63.32C52.7804 63.32 52.9871 63.1133 53.1111 62.7L58.3811 45.65C58.7118 44.534 58.7944 43.7073 58.6291 43.17C58.5051 42.5913 57.9264 42.2193 56.8931 42.054L55.5911 41.806C55.0538 41.682 54.7851 41.4133 54.7851 41C54.7851 40.5867 55.0744 40.38 55.6531 40.38H63.5271C64.1058 40.38 64.3951 40.6073 64.3951 41.062C64.3951 41.4753 64.1471 41.7647 63.6511 41.93L63.0931 42.116C62.4731 42.3227 61.9564 42.6947 61.5431 43.232C61.1298 43.7693 60.6958 44.6993 60.2411 46.022L51.0031 74.852C50.0938 77.7453 49.2671 79.8947 48.5231 81.3C47.8204 82.7053 47.1384 83.6147 46.4771 84.028C45.8158 84.4827 45.0511 84.71 44.1831 84.71ZM64.9342 72C64.3142 72 64.0042 71.7727 64.0042 71.318C64.0042 70.946 64.2729 70.698 64.8102 70.574L65.5542 70.45C66.4222 70.2847 66.9802 70.0367 67.2282 69.706C67.5176 69.334 67.6622 68.714 67.6622 67.846V47.014C67.6622 46.27 67.5382 45.774 67.2902 45.526C67.0836 45.2367 66.6909 45.0507 66.1122 44.968L64.8102 44.782C64.2729 44.7407 64.0042 44.5133 64.0042 44.1C64.0042 43.7693 64.3349 43.542 64.9962 43.418C66.2776 43.2113 67.2489 42.8807 67.9102 42.426C68.6129 41.9713 69.3362 41.4133 70.0802 40.752C70.4522 40.38 70.7622 40.194 71.0102 40.194C71.3822 40.194 71.5682 40.442 71.5682 40.938V43.728C71.5682 44.1 71.6922 44.348 71.9402 44.472C72.2296 44.5547 72.5396 44.4307 72.8702 44.1C74.5236 42.5293 75.9702 41.4547 77.2102 40.876C78.4916 40.2973 79.8349 40.008 81.2402 40.008C83.1002 40.008 84.5882 40.6693 85.7042 41.992C86.8202 43.2733 87.3782 45.1747 87.3782 47.696V67.846C87.3782 68.714 87.5022 69.334 87.7502 69.706C88.0396 70.0367 88.6182 70.264 89.4862 70.388L90.8502 70.574C91.3049 70.6567 91.5322 70.9047 91.5322 71.318C91.5322 71.7727 91.2842 72 90.7882 72H80.3722C79.7936 72 79.5042 71.7727 79.5042 71.318C79.5042 70.9047 79.7316 70.6567 80.1862 70.574L81.0542 70.45C81.9222 70.326 82.4802 70.078 82.7282 69.706C83.0176 69.334 83.1622 68.714 83.1622 67.846V48.378C83.1622 46.3527 82.7902 44.9267 82.0462 44.1C81.3022 43.2733 80.2482 42.86 78.8842 42.86C77.5616 42.86 76.3629 43.2527 75.2882 44.038C74.2549 44.782 73.4282 45.7947 72.8082 47.076C72.1882 48.316 71.8782 49.7007 71.8782 51.23V67.846C71.8782 68.714 72.0022 69.334 72.2502 69.706C72.5396 70.078 73.1182 70.3053 73.9862 70.388L75.6602 70.574C76.1149 70.6567 76.3422 70.884 76.3422 71.256C76.3422 71.752 76.0322 72 75.4122 72H64.9342Z\" fill=\"white\"/>\n<defs>\n<radialGradient id=\"paint0_radial_21_11\" cx=\"0\" cy=\"0\" r=\"1\" gradientUnits=\"userSpaceOnUse\" gradientTransform=\"translate(14 16) rotate(43.5498) scale(167.638 156.2)\">\n<stop stop-color=\"#2C5DB8\"/>\n<stop offset=\"1\" stop-color=\"#04081A\"/>\n</radialGradient>\n<linearGradient id=\"paint1_linear_21_11\" x1=\"1.77511\" y1=\"1.50277e-06\" x2=\"105.225\" y2=\"107\" gradientUnits=\"userSpaceOnUse\">\n<stop stop-color=\"#5C8FE6\"/>\n<stop offset=\"1\" stop-color=\"#335080\"/>\n</linearGradient>\n</defs>\n</svg>\n"],"mappings":"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A;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA;;;ACAA;;;AZwBA,IAAM,iBAAiB;AACvB,IAAM,UAAW,gBAAgC;AAIjD,IAAM,WAAW,OAAO,QAAQ,IAAI,sBAAsB,KAAK;AAQ/D,eAAsB,eACpB,OACA,gBAAgB,MACgB;AAChC,QAAM,OAAO,MAAM,aAAa,eAAe,gBAAgB,cAAc;AAC7E,MAAI,SAAS,eAAe;AAC1B,QAAI;AAAA,MACF,kBAAkB,aAAa,6BAAwB,IAAI;AAAA,IAC7D;AAAA,EACF;AACA,QAAM,MAAM,IAAI,KAAK;AAErB,MAAI,IAAI,KAAK,CAAC,MAAM,EAAE,KAAK,eAAU,WAAW,mBAAmB,OAAO,CAAC,CAAC;AAE5E,MAAI,IAAI,cAAc,CAAC,MAAM;AAC3B,MAAE,OAAO,gBAAgB,yBAAyB;AAClD,MAAE,OAAO,iBAAiB,UAAU;AACpC,WAAO,EAAE,KAAK,aAAQ;AAAA,EACxB,CAAC;AAED,MAAI,IAAI,gBAAgB,CAAC,MAAM;AAC7B,MAAE,OAAO,gBAAgB,8BAA8B;AACvD,MAAE,OAAO,iBAAiB,uBAAuB;AACjD,WAAO,EAAE,KAAK,eAAU;AAAA,EAC1B,CAAC;AAED,MAAI,IAAI,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,KAAK,CAAC,CAAC;AAE9C,MAAI,IAAI,SAAS,OAAO,MAAM;AAC5B,UAAM,OAAO,MAAM,qBAAqB,OAAO,QAAQ;AACvD,WAAO,EAAE,KAAK,IAAI;AAAA,EACpB,CAAC;AAED,QAAM,aAAa,MAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,YAAY,CAAC;AAE1E,SAAO;AAAA,IACL;AAAA,IACA,KAAK,oBAAoB,IAAI;AAAA,IAC7B,MAAM,OAAO;AACX,YAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,mBAAW,MAAM,CAAC,QAAS,MAAM,OAAO,GAAG,IAAI,QAAQ,CAAE;AAAA,MAC3D,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":["readFile","mkdir","readFile","writeFile","dirname","join","readFile","basename"]}
|