@jefuriiij/synthra 0.1.7 → 0.1.9

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/dist/cli/index.js CHANGED
@@ -18,7 +18,7 @@ var init_package = __esm({
18
18
  "package.json"() {
19
19
  package_default = {
20
20
  name: "@jefuriiij/synthra",
21
- version: "0.1.7",
21
+ version: "0.1.8",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -435,849 +435,849 @@ async function computeDashboardData(activePaths, recentN = 25) {
435
435
  }
436
436
 
437
437
  // src/dashboard/public/index.html
438
- var public_default = `<!doctype html>
439
- <html lang="en">
440
- <head>
441
- <meta charset="UTF-8" />
442
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
443
- <title>Synthra \xB7 Dashboard</title>
444
- <link rel="preconnect" href="https://fonts.googleapis.com">
445
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
446
- <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">
447
- <link rel="stylesheet" href="./style.css" />
448
- </head>
449
- <body>
450
-
451
- <!-- ============ Top nav ============ -->
452
- <header class="topnav">
453
- <div class="brand">
454
- <div class="brand-mark"></div>
455
- <div class="brand-name">Synth<em>ra</em></div>
456
- <div class="brand-eyebrow">Dashboard</div>
457
- </div>
458
- <div class="top-right">
459
- <span class="status-pill">
460
- <span class="dot" id="dot"></span>
461
- <span id="status">connecting\u2026</span>
462
- </span>
463
- </div>
464
- <div class="topnav-right">
465
- <span class="port-badge">port <span class="mono" id="port-num">8901</span></span>
466
- <button class="faq-btn has-tooltip" id="faq-btn" data-tooltip="Open the FAQ \u2014 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>
467
- </div>
468
- </header>
469
-
470
- <!-- ============ Hero strip ============ -->
471
- <section class="hero-strip">
472
- <div class="date-block">
473
- <div class="d-day" id="hero-day">\u2014</div>
474
- <div class="d-rest">
475
- <div id="hero-weekday">\u2014</div>
476
- <div class="d-mute" id="hero-month">\u2014</div>
477
- </div>
478
- </div>
479
-
480
- <div class="hero-spacer"></div>
481
-
482
- <div class="active-block">
483
- <div class="ab-label">Active project</div>
484
- <div class="ab-value" id="active-project">\u2014</div>
485
- </div>
486
-
487
- </section>
488
-
489
- <!-- ============ Main 3-column grid ============ -->
490
- <main class="grid-main">
491
-
492
- <!-- ===== Left ===== -->
493
- <aside class="col-left">
494
- <div class="panel">
495
- <div class="p-head">Legend</div>
496
-
497
- <div class="p-section">
498
- <div class="ps-head">Model family</div>
499
- <div class="check"><span class="dot-sq opus"></span><span>Opus</span></div>
500
- <div class="check"><span class="dot-sq sonnet"></span><span>Sonnet</span></div>
501
- <div class="check"><span class="dot-sq haiku"></span><span>Haiku</span></div>
502
- <div class="check"><span class="dot-sq unknown"></span><span>Other</span></div>
503
- </div>
504
-
505
- <div class="p-section">
506
- <div class="ps-head">Projects</div>
507
- <div class="proj-filter" id="proj-filter"></div>
508
- </div>
509
- </div>
510
-
511
- <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.">
512
- <div class="card-head">
513
- <div class="card-eyebrow">Model usage</div>
514
- <div class="card-meta">by turns</div>
515
- </div>
516
- <div class="donut-wrap">
517
- <svg viewBox="0 0 140 140" class="donut" id="donut-svg" aria-hidden="true">
518
- <circle cx="70" cy="70" r="52" class="donut-track"/>
519
- </svg>
520
- <div class="donut-center">
521
- <div class="donut-total" id="donut-total">0</div>
522
- <div class="donut-total-k">turns</div>
523
- </div>
524
- </div>
525
- <div class="donut-legend" id="donut-legend"></div>
526
- </div>
527
- </aside>
528
-
529
- <!-- ===== Center ===== -->
530
- <div class="col-center">
531
-
532
- <!-- Metric strip \u2014 divider-separated, no individual card chrome -->
533
- <div class="metric-strip">
534
- <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.">
535
- <div class="m-label">Turns</div>
536
- <div class="m-value" id="m-turns">0</div>
537
- </div>
538
- <div class="metric-item has-tooltip" data-tooltip="\u2193 Input \u2014 new, uncached tokens you sent to Claude. Usually small (a few hundred per turn) because most of the conversation history comes from prompt cache.">
539
- <div class="m-label">\u2193 Input</div>
540
- <div class="m-value" id="m-input">0</div>
541
- </div>
542
- <div class="metric-item has-tooltip" data-tooltip="\u2191 Output \u2014 tokens Claude generated in its responses. Most expensive line item per turn (~5\xD7 input rate on Opus). High output usually means long code edits.">
543
- <div class="m-label">\u2191 Output</div>
544
- <div class="m-value" id="m-output">0</div>
545
- </div>
546
- <div class="metric-item has-tooltip" data-tooltip="\u27F2 Cache read \u2014 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.">
547
- <div class="m-label">\u27F2 Cache R</div>
548
- <div class="m-value" id="m-cache-r">0</div>
549
- </div>
550
- <div class="metric-item has-tooltip" data-tooltip="\uFF0B Cache write \u2014 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.">
551
- <div class="m-label">\uFF0B Cache W</div>
552
- <div class="m-value" id="m-cache-w">0</div>
553
- </div>
554
- </div>
555
-
556
- <!-- Savings hero -->
557
- <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 \xD7 $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.">
558
- <div class="card-head">
559
- <div class="card-eyebrow">Synthra savings <span class="src-badge estimated">floor</span></div>
560
- <div class="card-meta" id="savings-pct">\u2014 off</div>
561
- </div>
562
- <div class="savings-body">
563
- <div class="savings-figure">
564
- <div class="savings-money" id="savings-money">$0.00</div>
565
- <div class="savings-tokens"><span id="savings-tokens">0</span> tokens avoided</div>
566
- </div>
567
- <div class="savings-bar">
568
- <div class="savings-actual" id="savings-actual-bar" style="width:100%"></div>
569
- <div class="savings-saved" id="savings-saved-bar" style="width:0%"></div>
570
- </div>
571
- <div class="savings-legend">
572
- <div class="sl-row"><span class="sl-dot actual"></span>You paid <b id="savings-actual-amt">$0.00</b></div>
573
- <div class="sl-row"><span class="sl-dot saved"></span>Baseline <b id="savings-baseline-amt">$0.00</b></div>
574
- </div>
575
- </div>
576
- <div class="savings-audit">
577
- <span class="audit-formula">
578
- <b id="audit-blocks">0</b> blocks \xD7 <b>500</b> tokens \xD7 <b>$3</b> / M input rate = <b id="audit-result" class="audit-result">$0.00</b>
579
- </span>
580
- </div>
581
- </div>
582
-
583
- <!-- Recent turns -->
584
- <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.">
585
- <div class="card-head">
586
- <div class="card-eyebrow">Recent turns</div>
587
- <div class="card-meta" id="turns-count">\u2014 shown</div>
588
- </div>
589
- <div class="turns-scroll">
590
- <table class="turns-table">
591
- <thead>
592
- <tr>
593
- <th>Time</th>
594
- <th>Project</th>
595
- <th>Model</th>
596
- <th class="num">In</th>
597
- <th class="num">Out</th>
598
- <th class="num">Cache R/W</th>
599
- <th class="num">Cost</th>
600
- </tr>
601
- </thead>
602
- <tbody id="turns-body"></tbody>
603
- </table>
604
- <p class="empty hidden" id="turns-empty">No turns logged yet. Run <code>syn .</code> in any project and chat with Claude.</p>
605
- </div>
606
- </div>
607
-
608
- </div>
609
-
610
- <!-- ===== Right ===== -->
611
- <aside class="col-right">
612
-
613
- <!-- Cost hero -->
614
- <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.">
615
- <div class="card-head">
616
- <div class="card-eyebrow">Total spend \xB7 <em>all time</em></div>
617
- </div>
618
- <div class="big-money" id="big-cost">$0.<em>00</em></div>
619
- <div class="cost-sub">
620
- <div class="cs-row">
621
- <span class="cs-k">Tokens (in+out)</span>
622
- <span class="cs-v" id="cs-tokens">0</span>
623
- </div>
624
- <div class="cs-row">
625
- <span class="cs-k">Avg / turn</span>
626
- <span class="cs-v" id="cs-avg">$0.00</span>
627
- </div>
628
- </div>
629
- </div>
630
-
631
- <!-- The Moat -->
632
- <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.">
633
- <div class="card-head">
634
- <div class="card-eyebrow">The <em>Moat</em></div>
635
- <div class="card-meta">PreToolUse</div>
636
- </div>
637
- <div class="moat-value"><span id="blocks">0</span> <em>blocks</em></div>
638
- <div class="gate-mini" id="gate-mini"></div>
639
- </div>
640
-
641
- </aside>
642
- </main>
643
-
644
- <!-- ============ Project dialog ============ -->
645
- <div class="dialog-backdrop hidden" id="dialog-backdrop" role="dialog" aria-modal="true">
646
- <div class="dialog">
647
- <button class="dialog-close" id="dialog-close" aria-label="Close">\xD7</button>
648
- <div class="dialog-eyebrow">Project \xB7 <em>details</em></div>
649
- <div class="dialog-name" id="d-name">\u2014</div>
650
- <div class="dialog-path" id="d-path">\u2014</div>
651
- <div class="dialog-grid">
652
- <div class="dg-cell">
653
- <div class="dg-k">Total cost</div>
654
- <div class="dg-v money" id="d-cost">$0.00</div>
655
- </div>
656
- <div class="dg-cell">
657
- <div class="dg-k">Turns</div>
658
- <div class="dg-v" id="d-turns">0</div>
659
- </div>
660
- <div class="dg-cell">
661
- <div class="dg-k">\u2193 Raw input</div>
662
- <div class="dg-v" id="d-input">0</div>
663
- </div>
664
- <div class="dg-cell">
665
- <div class="dg-k">\u2191 Output</div>
666
- <div class="dg-v" id="d-output">0</div>
667
- </div>
668
- <div class="dg-cell">
669
- <div class="dg-k">\u27F2 Cache read</div>
670
- <div class="dg-v" id="d-cache-r">0</div>
671
- </div>
672
- <div class="dg-cell">
673
- <div class="dg-k">\uFF0B Cache write</div>
674
- <div class="dg-v" id="d-cache-w">0</div>
675
- </div>
676
- <div class="dg-cell">
677
- <div class="dg-k">Moat blocks</div>
678
- <div class="dg-v" id="d-blocks">0</div>
679
- </div>
680
- <div class="dg-cell">
681
- <div class="dg-k">Last active</div>
682
- <div class="dg-v dg-v-sm" id="d-last">\u2014</div>
683
- </div>
684
- </div>
685
- </div>
686
- </div>
687
-
688
- <!-- ============ FAQ dialog ============ -->
689
- <div class="dialog-backdrop hidden" id="faq-backdrop" role="dialog" aria-modal="true" aria-label="Dashboard FAQ">
690
- <div class="dialog dialog-faq">
691
- <button class="dialog-close" id="faq-close" aria-label="Close">\xD7</button>
692
- <div class="dialog-eyebrow">FAQ \xB7 <em>where every number comes from</em></div>
693
- <div class="dialog-name">Understanding your dashboard</div>
694
- <div class="dialog-path">Synthra reports what Claude actually used \u2014 read from transcripts, not estimated.</div>
695
-
696
- <div class="faq-content">
697
-
698
- <details open>
699
- <summary>Where does Synthra get these numbers from?</summary>
700
- <div class="faq-body">
701
- <p>Synthra <strong>does not estimate</strong> token counts \u2014 it reads them directly from Claude's own log files. Every number on this dashboard is traceable:</p>
702
- <table>
703
- <tr><td>Turns, \u2193 In, \u2191 Out, Cache R/W</td><td>Parsed from Claude's transcript JSONLs at <code>~/.claude/projects/&lt;encoded-cwd&gt;/*.jsonl</code></td></tr>
704
- <tr><td>Model used per turn</td><td>From each turn's <code>model</code> field in the same transcripts</td></tr>
705
- <tr><td>Moat blocks + gate decisions</td><td>From <code>.synthra-graph/gate_log.jsonl</code> inside each project</td></tr>
706
- <tr><td>Active project, project list</td><td>From <code>~/.synthra/projects.json</code> (built up as you run <code>syn .</code>)</td></tr>
707
- <tr><td>Total spend (USD)</td><td>Above token counts \xD7 Anthropic's published per-model rates \u2014 see "How is cost calculated?" below</td></tr>
708
- <tr><td>Synthra savings (USD)</td><td><strong>Estimated</strong> \u2014 see "About the Savings (floor) card" below</td></tr>
709
- </table>
710
- <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>
711
- </div>
712
- </details>
713
-
714
- <details>
715
- <summary>What do the columns in Recent Turns mean?</summary>
716
- <div class="faq-body">
717
- <table>
718
- <tr><td><code>Time</code></td><td>When this turn happened (local time)</td></tr>
719
- <tr><td><code>Project</code></td><td>Which directory the turn ran in</td></tr>
720
- <tr><td><code>Model</code></td><td>Claude model used \u2014 Opus, Sonnet, or Haiku (color-coded in the model pill)</td></tr>
721
- <tr><td><code>In</code></td><td>Raw input tokens \u2014 brand-new content sent to Claude this turn, not cached</td></tr>
722
- <tr><td><code>Out</code></td><td>Tokens Claude generated in its response</td></tr>
723
- <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>
724
- <tr><td><code>Cost</code></td><td>Per-turn USD estimate using Anthropic's published rates</td></tr>
725
- </table>
726
- <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" \u2014 everything else is a cheap cache read. This is normal and saves significant money.</p>
727
- </div>
728
- </details>
729
-
730
- <details>
731
- <summary>How is cost calculated?</summary>
732
- <div class="faq-body">
733
- <p>Each token type has a different per-million-token rate. Synthra uses these rates (defined in <code>src/shared/pricing.ts</code>):</p>
734
- <table>
735
- <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>
736
- <tr><td>Raw Input</td><td>$1.00/M</td><td>$3.00/M</td><td>$15.00/M</td></tr>
737
- <tr><td>Cache Write</td><td>$1.25/M</td><td>$3.75/M</td><td>$18.75/M</td></tr>
738
- <tr><td>Cache Read</td><td>$0.10/M</td><td>$0.30/M</td><td>$1.50/M</td></tr>
739
- <tr><td>Output</td><td>$5.00/M</td><td>$15.00/M</td><td>$75.00/M</td></tr>
740
- </table>
741
- <p><strong>Cost</strong> = (Input \xD7 input rate) + (Output \xD7 output rate) + (Cache Read \xD7 read rate) + (Cache Write \xD7 write rate)</p>
742
- <p>Cache reads are <strong>10\xD7 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>
743
- <div class="warning"><span class="icon">\u26A0</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 \u2014 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>
744
- </div>
745
- </details>
746
-
747
- <details>
748
- <summary>What is the total context size per turn?</summary>
749
- <div class="faq-body">
750
- <p><code>Total context = Raw Input + Cache Read + Cache Write</code></p>
751
- <p>Example: if Raw Input is 6, Cache Read is 60K, and Cache Write is 13K, your turn used ~73K tokens of context \u2014 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>
752
- <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>
753
- </div>
754
- </details>
755
-
756
- <details>
757
- <summary>About the "Savings (floor)" card</summary>
758
- <div class="faq-body">
759
- <p>This is the only number on the dashboard that's <strong>estimated</strong> rather than measured. Here's the math:</p>
760
- <p class="formula-box">savings = blocks \xD7 500 tokens \xD7 $3 per million input rate</p>
761
- <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>
762
- <p>It under-counts on purpose because the formula ignores:</p>
763
- <ul>
764
- <li><strong>Cache thrash</strong> \u2014 the blocked tool result would have been written to the cache at ~125% of the input rate, which we don't count</li>
765
- <li><strong>Cascading reads</strong> \u2014 Claude usually follows a Grep with several <code>Read</code> calls, which the block also prevents but we don't credit</li>
766
- <li><strong>Bigger codebases</strong> \u2014 actual Grep results often exceed 500 tokens by 3\u20136\xD7 in a real repo</li>
767
- </ul>
768
- <p>Real savings are typically <strong>2\u20135\xD7 the floor</strong>. The audit row on the Savings card shows the formula live so you can verify the math.</p>
769
- </div>
770
- </details>
771
-
772
- <details>
773
- <summary>What is "The Moat"?</summary>
774
- <div class="faq-body">
775
- <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>
776
- <p>If yes, Synthra returns <code>{"decision":"block"}</code> \u2014 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 \u2014 not prose policy. Claude literally can't disobey.</p>
777
- <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>
778
- <p>Every decision is logged to <code>.synthra-graph/gate_log.jsonl</code> for the project.</p>
779
- </div>
780
- </details>
781
-
782
- <details>
783
- <summary>How does Synthra build the codebase graph?</summary>
784
- <div class="faq-body">
785
- <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>
786
- <p>TypeScript, JavaScript, JSX/TSX, Python, Svelte, Vue, Go, Rust, Java, Kotlin, PHP, Ruby, C/C++, C#, and Dart.</p>
787
- <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>
788
- <p>The graph is what makes pre-injection and The Moat work \u2014 both query it before Claude ever has to Grep.</p>
789
- </div>
790
- </details>
791
-
792
- <details>
793
- <summary>Where does Synthra store data on disk?</summary>
794
- <div class="faq-body">
795
- <p>Synthra uses two folders per project, intentionally separated:</p>
796
- <table>
797
- <tr><td><code>.synthra-graph/</code></td><td><strong>Gitignored</strong>. Heavy generated state \u2014 the graph, symbol index, token + gate logs, session info. Rebuilt by <code>syn scan</code>.</td></tr>
798
- <tr><td><code>.synthra/</code></td><td><strong>Git-tracked.</strong> Decisions, context notes, branch-scoped memory \u2014 the part teammates inherit when they clone the repo.</td></tr>
799
- </table>
800
- <p>Plus one global file at <code>~/.synthra/projects.json</code> that tracks every project on this machine.</p>
801
- </div>
802
- </details>
803
-
804
- <details>
805
- <summary>How does branch-aware memory work?</summary>
806
- <div class="faq-body">
807
- <p>Inside <code>.synthra/</code>, context is partitioned by git branch:</p>
808
- <ul>
809
- <li><code>.synthra/context-store.json</code> \u2014 decisions on the default branch</li>
810
- <li><code>.synthra/CONTEXT.md</code> \u2014 narrative notes on the default branch</li>
811
- <li><code>.synthra/branches/&lt;sanitized-name&gt;/</code> \u2014 overrides on feature branches</li>
812
- </ul>
813
- <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>
814
- </div>
815
- </details>
816
-
817
- <details>
818
- <summary>How does Synthra actually reduce my Claude bill?</summary>
819
- <div class="faq-body">
820
- <p>Three mechanisms, in order of impact:</p>
821
- <ul>
822
- <li><strong>Pre-injection</strong> \u2014 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 \u2014 it already knows.</li>
823
- <li><strong>The Moat</strong> \u2014 the PreToolUse hook deterministically blocks exploratory Grep/Glob when the graph already has high-confidence context. Counts on the Moat card.</li>
824
- <li><strong>Branch-aware memory</strong> \u2014 decisions and CONTEXT notes persist in <code>.synthra/</code>, so Claude doesn't have to be re-told what was decided last session.</li>
825
- </ul>
826
- <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>
827
- </div>
828
- </details>
829
-
830
- <details>
831
- <summary>Why do long conversations get expensive?</summary>
832
- <div class="faq-body">
833
- <p>Each turn re-sends the entire conversation history as input. Even cached, the cumulative input grows roughly <strong>quadratically</strong>:</p>
834
- <table>
835
- <thead><tr><td><strong>Turns</strong></td><td><strong>Per-turn input</strong></td><td><strong>Cumulative input</strong></td></tr></thead>
836
- <tr><td>10</td><td>~2K (mostly cached)</td><td>~110K</td></tr>
837
- <tr><td>30</td><td>~2K</td><td>~930K</td></tr>
838
- <tr><td>50</td><td>~2K</td><td>~2.55M</td></tr>
839
- </table>
840
- <p>Prompt caching helps a lot (cache reads are 10\xD7 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>
841
- <p><strong>Tip:</strong> Use <code>/compact</code> in Claude Code or start fresh sessions when a thread feels stale.</p>
842
- </div>
843
- </details>
844
-
845
- <details>
846
- <summary>Sources &amp; references</summary>
847
- <div class="faq-body">
848
- <p>Synthra is open source. Every number on this dashboard can be cross-checked:</p>
849
- <ul class="link-list">
850
- <li><a href="https://github.com/jefuriiij/synthra" target="_blank" rel="noopener noreferrer">github.com/jefuriiij/synthra</a> \u2014 source code, issues, roadmap</li>
851
- <li><a href="https://www.npmjs.com/package/@jefuriiij/synthra" target="_blank" rel="noopener noreferrer">npm: @jefuriiij/synthra</a> \u2014 release history, install instructions</li>
852
- <li><a href="https://www.anthropic.com/pricing" target="_blank" rel="noopener noreferrer">anthropic.com/pricing</a> \u2014 official rate table Synthra uses</li>
853
- <li><a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic docs \xB7 prompt caching</a> \u2014 explains cache read/write behavior</li>
854
- <li><code>src/shared/pricing.ts</code> \u2014 the file in this repo holding the rate table</li>
855
- <li><code>src/dashboard/delta.ts</code> \u2014 where dashboard aggregates are computed</li>
856
- <li><code>src/server/routes/gate.ts</code> \u2014 the Moat implementation</li>
857
- </ul>
858
- </div>
859
- </details>
860
-
861
- </div>
862
- </div>
863
- </div>
864
-
865
- <!-- ============ Footer ============ -->
866
- <footer class="foot">
867
- <div>Synth<em>ra</em> \xB7 v0.1.7</div>
868
- <div>Cost figures approximate \xB7 @jefuriiij</div>
869
- </footer>
870
-
871
- <script>
872
- const $ = (sel) => document.querySelector(sel);
873
- const SAVED_RATE_PER_M = 3.00; // USD per million tokens \u2014 conservative input rate
874
-
875
- // ----- model classification -----
876
- function modelFamily(model) {
877
- if (!model) return 'unknown';
878
- const m = model.toLowerCase();
879
- if (m === '<synthetic>') return 'unknown';
880
- if (m.includes('opus')) return 'opus';
881
- if (m.includes('sonnet')) return 'sonnet';
882
- if (m.includes('haiku')) return 'haiku';
883
- return 'unknown';
884
- }
885
- function modelLabel(model) {
886
- if (!model || model === '<synthetic>') return 'synthetic';
887
- return model.replace(/^claude-/, '');
888
- }
889
-
890
- // ----- formatting -----
891
- function fmt(n) {
892
- if (typeof n !== 'number' || !Number.isFinite(n)) return '0';
893
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + '<em>M</em>';
894
- if (n >= 1_000) return (n / 1_000).toFixed(1) + '<em>k</em>';
895
- return n.toLocaleString();
896
- }
897
- function fmtPlain(n) {
898
- if (typeof n !== 'number' || !Number.isFinite(n)) return '0';
899
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
900
- if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
901
- return n.toLocaleString();
902
- }
903
- function fmtCostBig(usd) {
904
- if (typeof usd !== 'number' || !Number.isFinite(usd)) usd = 0;
905
- let s;
906
- if (usd >= 1) s = usd.toFixed(2);
907
- else if (usd >= 0.01) s = usd.toFixed(3);
908
- else s = usd.toFixed(4);
909
- const dot = s.indexOf('.');
910
- if (dot === -1) return '$' + s;
911
- return '$' + s.slice(0, dot) + '.<em>' + s.slice(dot + 1) + '</em>';
912
- }
913
- function fmtCostFlat(usd) {
914
- if (typeof usd !== 'number' || !Number.isFinite(usd)) usd = 0;
915
- if (usd >= 1) return '$' + usd.toFixed(2);
916
- if (usd >= 0.01) return '$' + usd.toFixed(3);
917
- return '$' + usd.toFixed(4);
918
- }
919
- function fmtTs(iso) {
920
- try {
921
- const d = new Date(iso);
922
- const today = new Date();
923
- const isToday = d.toDateString() === today.toDateString();
924
- if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
925
- return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
926
- } catch { return iso; }
927
- }
928
-
929
- let lastData = null;
930
-
931
- // ----- date in hero -----
932
- function setHeroDate() {
933
- const now = new Date();
934
- $('#hero-day').textContent = now.getDate();
935
- $('#hero-weekday').textContent = now.toLocaleDateString([], { weekday: 'short' });
936
- $('#hero-month').textContent = now.toLocaleDateString([], { month: 'long' });
937
- }
938
-
939
- // ----- renderers -----
940
- function renderSession(turns) {
941
- const agg = turns.reduce((a, t) => {
942
- a.turns += 1;
943
- a.input += t.input || 0;
944
- a.output += t.output || 0;
945
- a.cacheR += t.cache_read || 0;
946
- a.cacheW += t.cache_create || 0;
947
- return a;
948
- }, { turns: 0, input: 0, output: 0, cacheR: 0, cacheW: 0 });
949
-
950
- $('#m-turns').innerHTML = fmt(agg.turns);
951
- $('#m-input').innerHTML = fmt(agg.input);
952
- $('#m-output').innerHTML = fmt(agg.output);
953
- $('#m-cache-r').innerHTML = fmt(agg.cacheR);
954
- $('#m-cache-w').innerHTML = fmt(agg.cacheW);
955
- }
956
-
957
- function renderSavings(g) {
958
- const tokensSaved = g.estimated_tokens_saved || 0;
959
- const blocks = g.blocked_count || 0;
960
- const savedUsd = tokensSaved * SAVED_RATE_PER_M / 1_000_000;
961
- const actualUsd = g.estimated_cost_usd || 0;
962
- const baselineUsd = actualUsd + savedUsd;
963
- const savedPct = baselineUsd > 0 ? (savedUsd / baselineUsd) * 100 : 0;
964
- const actualPct = baselineUsd > 0 ? (actualUsd / baselineUsd) * 100 : 100;
965
-
966
- $('#savings-money').textContent = fmtCostFlat(savedUsd);
967
- $('#savings-pct').textContent = savedPct.toFixed(1) + '% off';
968
- $('#savings-tokens').textContent = fmtPlain(tokensSaved);
969
- $('#savings-actual-bar').style.width = actualPct.toFixed(2) + '%';
970
- $('#savings-saved-bar').style.width = savedPct.toFixed(2) + '%';
971
- $('#savings-actual-amt').textContent = fmtCostFlat(actualUsd);
972
- $('#savings-baseline-amt').textContent = fmtCostFlat(baselineUsd);
973
-
974
- // Audit row \u2014 live formula
975
- $('#audit-blocks').textContent = blocks.toLocaleString();
976
- $('#audit-result').textContent = fmtCostFlat(savedUsd);
977
- }
978
-
979
- function renderCostHero(g) {
980
- $('#big-cost').innerHTML = fmtCostBig(g.estimated_cost_usd);
981
- const totalTokens = (g.total_input_tokens || 0) + (g.total_output_tokens || 0);
982
- $('#cs-tokens').textContent = fmtPlain(totalTokens);
983
- const avg = g.total_turns > 0 ? g.estimated_cost_usd / g.total_turns : 0;
984
- $('#cs-avg').textContent = fmtCostFlat(avg);
985
- }
986
-
987
- function renderMoat(g) {
988
- $('#blocks').textContent = fmtPlain(g.blocked_count);
989
- }
990
-
991
- function renderTurns(turns) {
992
- const tbody = $('#turns-body');
993
- const empty = $('#turns-empty');
994
- tbody.innerHTML = '';
995
- if (!turns.length) {
996
- empty.classList.remove('hidden');
997
- $('#turns-count').textContent = '0 shown';
998
- return;
999
- }
1000
- empty.classList.add('hidden');
1001
- $('#turns-count').textContent = turns.length + ' shown';
1002
- const frag = document.createDocumentFragment();
1003
- for (const t of turns) {
1004
- const family = modelFamily(t.model);
1005
- const tr = document.createElement('tr');
1006
- tr.innerHTML =
1007
- '<td class="ts">' + fmtTs(t.ts || t.written_at) + '</td>' +
1008
- '<td class="proj">' + (t.project_name || '\u2014') + '</td>' +
1009
- '<td><span class="model-pill ' + family + '"><span class="sq"></span>' + modelLabel(t.model) + '</span></td>' +
1010
- '<td class="num">' + fmtPlain(t.input || 0) + '</td>' +
1011
- '<td class="num">' + fmtPlain(t.output || 0) + '</td>' +
1012
- '<td class="num">' + fmtPlain(t.cache_read || 0) + ' / ' + fmtPlain(t.cache_create || 0) + '</td>' +
1013
- '<td class="num cost">' + fmtCostFlat(t.cost_usd || 0) + '</td>';
1014
- frag.appendChild(tr);
1015
- }
1016
- tbody.appendChild(frag);
1017
- }
1018
-
1019
- function renderGateMini(gates) {
1020
- const el = $('#gate-mini');
1021
- el.innerHTML = '';
1022
- if (!gates.length) {
1023
- el.innerHTML = '<div class="empty">No gate decisions yet.</div>';
1024
- return;
1025
- }
1026
- const frag = document.createDocumentFragment();
1027
- for (const g of gates.slice(0, 12)) {
1028
- const row = document.createElement('div');
1029
- row.className = 'gate-row';
1030
- const cls = g.decision === 'block' ? 'block' : 'allow';
1031
- row.innerHTML =
1032
- '<span class="g-ts">' + fmtTs(g.ts) + '</span>' +
1033
- '<span class="g-decision ' + cls + '">' + (g.decision || '\u2014').toUpperCase() + '</span>' +
1034
- '<span class="g-q">' + (g.query || g.tool || '\u2014') + '</span>';
1035
- frag.appendChild(row);
1036
- }
1037
- el.appendChild(frag);
1038
- }
1039
-
1040
- function renderProjectLegend(projects) {
1041
- const el = $('#proj-filter');
1042
- const incoming = projects.map((p) => p.name).join('|');
1043
- if (el.dataset.signature === incoming) return;
1044
- el.dataset.signature = incoming;
1045
- el.innerHTML = '';
1046
- if (!projects.length) {
1047
- el.innerHTML = '<div class="empty">No projects yet.</div>';
1048
- return;
1049
- }
1050
- const frag = document.createDocumentFragment();
1051
- for (const p of projects) {
1052
- const row = document.createElement('button');
1053
- row.className = 'check check-clickable';
1054
- row.type = 'button';
1055
- row.innerHTML =
1056
- '<span class="dot-sq" style="background: var(--sky); opacity:.55"></span>' +
1057
- '<span class="pf-name" title="' + p.path + '">' + p.name + '</span>' +
1058
- '<span class="pf-arrow">\u203A</span>';
1059
- row.addEventListener('click', () => openProjectDialog(p.name));
1060
- frag.appendChild(row);
1061
- }
1062
- el.appendChild(frag);
1063
- }
1064
-
1065
- // ----- Project dialog -----
1066
- function lastActiveFor(name) {
1067
- const turns = lastData?.recent_turns || [];
1068
- for (const t of turns) {
1069
- if (t.project_name === name) {
1070
- const ts = t.ts || t.written_at;
1071
- if (!ts) return '\u2014';
1072
- try {
1073
- return new Date(ts).toLocaleString([], {
1074
- year: 'numeric', month: 'short', day: 'numeric',
1075
- hour: '2-digit', minute: '2-digit',
1076
- });
1077
- } catch { return ts; }
1078
- }
1079
- }
1080
- return '\u2014';
1081
- }
1082
-
1083
- function openProjectDialog(name) {
1084
- const p = (lastData?.projects || []).find((x) => x.name === name);
1085
- if (!p) return;
1086
- $('#d-name').textContent = p.name;
1087
- $('#d-path').textContent = p.path || '';
1088
- $('#d-cost').textContent = fmtCostFlat(p.estimated_cost_usd);
1089
- $('#d-turns').textContent = fmtPlain(p.total_turns);
1090
- $('#d-input').textContent = fmtPlain(p.total_input_tokens);
1091
- $('#d-output').textContent = fmtPlain(p.total_output_tokens);
1092
- $('#d-cache-r').textContent = fmtPlain(p.total_cache_read);
1093
- $('#d-cache-w').textContent = fmtPlain(p.total_cache_create);
1094
- $('#d-blocks').textContent = fmtPlain(p.blocked_count);
1095
- $('#d-last').textContent = lastActiveFor(p.name);
1096
- $('#dialog-backdrop').classList.remove('hidden');
1097
- }
1098
-
1099
- function closeProjectDialog() {
1100
- $('#dialog-backdrop').classList.add('hidden');
1101
- }
1102
-
1103
- $('#dialog-close').addEventListener('click', closeProjectDialog);
1104
- $('#dialog-backdrop').addEventListener('click', (e) => {
1105
- if (e.target.id === 'dialog-backdrop') closeProjectDialog();
1106
- });
1107
-
1108
- // ----- FAQ dialog -----
1109
- const faqBackdrop = $('#faq-backdrop');
1110
- function openFaq() { faqBackdrop.classList.remove('hidden'); }
1111
- function closeFaq() { faqBackdrop.classList.add('hidden'); }
1112
- $('#faq-btn').addEventListener('click', openFaq);
1113
- $('#faq-close').addEventListener('click', closeFaq);
1114
- faqBackdrop.addEventListener('click', (e) => {
1115
- if (e.target.id === 'faq-backdrop') closeFaq();
1116
- });
1117
-
1118
- document.addEventListener('keydown', (e) => {
1119
- if (e.key === 'Escape') { closeProjectDialog(); closeFaq(); }
1120
- });
1121
-
1122
- // Reflect the actual port the dashboard is served on
1123
- const portEl = $('#port-num');
1124
- if (portEl && window.location.port) portEl.textContent = window.location.port;
1125
-
1126
- // ----- Donut chart (model usage by turn count) -----
1127
- function renderDonut(turns) {
1128
- const counts = { opus: 0, sonnet: 0, haiku: 0, unknown: 0 };
1129
- for (const t of turns) counts[modelFamily(t.model)] += 1;
1130
- const total = counts.opus + counts.sonnet + counts.haiku + counts.unknown;
1131
-
1132
- const segs = [
1133
- { fam: 'opus', label: 'Opus', n: counts.opus, color: 'var(--c-opus)' },
1134
- { fam: 'sonnet', label: 'Sonnet', n: counts.sonnet, color: 'var(--c-sonnet)' },
1135
- { fam: 'haiku', label: 'Haiku', n: counts.haiku, color: 'var(--c-haiku)' },
1136
- { fam: 'unknown', label: 'Other', n: counts.unknown, color: 'var(--c-unknown)' },
1137
- ].filter((s) => s.n > 0);
1138
-
1139
- const svg = $('#donut-svg');
1140
- svg.querySelectorAll('.donut-seg').forEach((el) => el.remove());
1141
-
1142
- const C = 2 * Math.PI * 52; // \u2248 326.7
1143
- let offset = 0;
1144
- const ns = 'http://www.w3.org/2000/svg';
1145
- if (total === 0) {
1146
- // pleasant empty state \u2014 leave just the track
1147
- } else {
1148
- for (const s of segs) {
1149
- const arc = (s.n / total) * C;
1150
- const c = document.createElementNS(ns, 'circle');
1151
- c.setAttribute('cx', '70');
1152
- c.setAttribute('cy', '70');
1153
- c.setAttribute('r', '52');
1154
- c.setAttribute('fill', 'none');
1155
- c.setAttribute('stroke', s.color);
1156
- c.setAttribute('stroke-width', '14');
1157
- c.setAttribute('stroke-dasharray', arc + ' ' + C);
1158
- c.setAttribute('stroke-dashoffset', String(-offset));
1159
- c.setAttribute('transform', 'rotate(-90 70 70)');
1160
- c.setAttribute('stroke-linecap', segs.length === 1 ? 'round' : 'butt');
1161
- c.classList.add('donut-seg');
1162
- svg.appendChild(c);
1163
- offset += arc;
1164
- }
1165
- }
1166
-
1167
- $('#donut-total').textContent = total;
1168
-
1169
- const legend = $('#donut-legend');
1170
- legend.innerHTML = '';
1171
- const lf = document.createDocumentFragment();
1172
- const display = segs.length ? segs : [
1173
- { fam: 'opus', label: 'Opus', n: 0, color: 'var(--c-opus)' },
1174
- { fam: 'sonnet', label: 'Sonnet', n: 0, color: 'var(--c-sonnet)' },
1175
- { fam: 'haiku', label: 'Haiku', n: 0, color: 'var(--c-haiku)' },
1176
- ];
1177
- for (const s of display) {
1178
- const pct = total > 0 ? Math.round((s.n / total) * 100) : 0;
1179
- const row = document.createElement('div');
1180
- row.className = 'dl-row';
1181
- row.innerHTML =
1182
- '<span class="dl-dot" style="background:' + s.color + '"></span>' +
1183
- '<span class="dl-name">' + s.label + '</span>' +
1184
- '<span class="dl-pct">' + pct + '%</span>';
1185
- lf.appendChild(row);
1186
- }
1187
- legend.appendChild(lf);
1188
- }
1189
-
1190
- // ----- master render -----
1191
- function applyData(data) {
1192
- const turns = data.recent_turns || [];
1193
- const gates = data.recent_gates || [];
1194
-
1195
- renderSession(turns);
1196
- renderSavings(data.global);
1197
- renderCostHero(data.global);
1198
- renderMoat(data.global);
1199
- renderTurns(turns);
1200
- renderGateMini(gates);
1201
- renderDonut(turns);
1202
- }
1203
-
1204
- // ----- polling -----
1205
- async function tick() {
1206
- try {
1207
- const res = await fetch('/data');
1208
- if (!res.ok) throw new Error('HTTP ' + res.status);
1209
- const data = await res.json();
1210
- lastData = data;
1211
- $('#active-project').textContent = data.active?.project_root || '\u2014';
1212
- renderProjectLegend(data.projects || []);
1213
- applyData(data);
1214
- $('#status').textContent = 'live \xB7 ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1215
- $('#dot').classList.add('live'); $('#dot').classList.remove('dead');
1216
- } catch (e) {
1217
- $('#status').textContent = 'offline';
1218
- $('#dot').classList.add('dead'); $('#dot').classList.remove('live');
1219
- }
1220
- }
1221
-
1222
- // ----- Viewport-clamped tooltip -----
1223
- const tooltipEl = document.createElement('div');
1224
- tooltipEl.className = 'global-tooltip';
1225
- document.body.appendChild(tooltipEl);
1226
- let activeTooltipTarget = null;
1227
-
1228
- function positionTooltip(target) {
1229
- const rect = target.getBoundingClientRect();
1230
- const ttRect = tooltipEl.getBoundingClientRect();
1231
- const margin = 12;
1232
- let top = rect.top - ttRect.height - 10;
1233
- let left = rect.left + rect.width / 2 - ttRect.width / 2;
1234
- if (left < margin) left = margin;
1235
- if (left + ttRect.width > window.innerWidth - margin) {
1236
- left = window.innerWidth - ttRect.width - margin;
1237
- }
1238
- if (top < margin) {
1239
- top = rect.bottom + 10;
1240
- }
1241
- if (top + ttRect.height > window.innerHeight - margin) {
1242
- top = window.innerHeight - ttRect.height - margin;
1243
- }
1244
- tooltipEl.style.top = top + 'px';
1245
- tooltipEl.style.left = left + 'px';
1246
- }
1247
-
1248
- function showTooltip(target) {
1249
- const text = target.getAttribute('data-tooltip');
1250
- if (!text) return;
1251
- tooltipEl.textContent = text;
1252
- tooltipEl.classList.add('on');
1253
- positionTooltip(target);
1254
- }
1255
-
1256
- function hideTooltip() {
1257
- tooltipEl.classList.remove('on');
1258
- }
1259
-
1260
- document.addEventListener('mouseover', (e) => {
1261
- const t = (e.target instanceof Element) ? e.target.closest('.has-tooltip') : null;
1262
- if (t !== activeTooltipTarget) {
1263
- activeTooltipTarget = t;
1264
- if (t) showTooltip(t);
1265
- else hideTooltip();
1266
- }
1267
- });
1268
- document.addEventListener('scroll', hideTooltip, true);
1269
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTooltip(); });
1270
-
1271
- setHeroDate();
1272
- tick();
1273
- setInterval(tick, 2000);
1274
- </script>
1275
- </body>
1276
- </html>
438
+ var public_default = `<!doctype html>\r
439
+ <html lang="en">\r
440
+ <head>\r
441
+ <meta charset="UTF-8" />\r
442
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />\r
443
+ <title>Synthra \xB7 Dashboard</title>\r
444
+ <link rel="preconnect" href="https://fonts.googleapis.com">\r
445
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\r
446
+ <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">\r
447
+ <link rel="stylesheet" href="./style.css" />\r
448
+ </head>\r
449
+ <body>\r
450
+ \r
451
+ <!-- ============ Top nav ============ -->\r
452
+ <header class="topnav">\r
453
+ <div class="brand">\r
454
+ <div class="brand-mark"></div>\r
455
+ <div class="brand-name">Synth<em>ra</em></div>\r
456
+ <div class="brand-eyebrow">Dashboard</div>\r
457
+ </div>\r
458
+ <div class="top-right">\r
459
+ <span class="status-pill">\r
460
+ <span class="dot" id="dot"></span>\r
461
+ <span id="status">connecting\u2026</span>\r
462
+ </span>\r
463
+ </div>\r
464
+ <div class="topnav-right">\r
465
+ <span class="port-badge">port <span class="mono" id="port-num">8901</span></span>\r
466
+ <button class="faq-btn has-tooltip" id="faq-btn" data-tooltip="Open the FAQ \u2014 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>\r
467
+ </div>\r
468
+ </header>\r
469
+ \r
470
+ <!-- ============ Hero strip ============ -->\r
471
+ <section class="hero-strip">\r
472
+ <div class="date-block">\r
473
+ <div class="d-day" id="hero-day">\u2014</div>\r
474
+ <div class="d-rest">\r
475
+ <div id="hero-weekday">\u2014</div>\r
476
+ <div class="d-mute" id="hero-month">\u2014</div>\r
477
+ </div>\r
478
+ </div>\r
479
+ \r
480
+ <div class="hero-spacer"></div>\r
481
+ \r
482
+ <div class="active-block">\r
483
+ <div class="ab-label">Active project</div>\r
484
+ <div class="ab-value" id="active-project">\u2014</div>\r
485
+ </div>\r
486
+ \r
487
+ </section>\r
488
+ \r
489
+ <!-- ============ Main 3-column grid ============ -->\r
490
+ <main class="grid-main">\r
491
+ \r
492
+ <!-- ===== Left ===== -->\r
493
+ <aside class="col-left">\r
494
+ <div class="panel">\r
495
+ <div class="p-head">Legend</div>\r
496
+ \r
497
+ <div class="p-section">\r
498
+ <div class="ps-head">Model family</div>\r
499
+ <div class="check"><span class="dot-sq opus"></span><span>Opus</span></div>\r
500
+ <div class="check"><span class="dot-sq sonnet"></span><span>Sonnet</span></div>\r
501
+ <div class="check"><span class="dot-sq haiku"></span><span>Haiku</span></div>\r
502
+ <div class="check"><span class="dot-sq unknown"></span><span>Other</span></div>\r
503
+ </div>\r
504
+ \r
505
+ <div class="p-section">\r
506
+ <div class="ps-head">Projects</div>\r
507
+ <div class="proj-filter" id="proj-filter"></div>\r
508
+ </div>\r
509
+ </div>\r
510
+ \r
511
+ <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.">\r
512
+ <div class="card-head">\r
513
+ <div class="card-eyebrow">Model usage</div>\r
514
+ <div class="card-meta">by turns</div>\r
515
+ </div>\r
516
+ <div class="donut-wrap">\r
517
+ <svg viewBox="0 0 140 140" class="donut" id="donut-svg" aria-hidden="true">\r
518
+ <circle cx="70" cy="70" r="52" class="donut-track"/>\r
519
+ </svg>\r
520
+ <div class="donut-center">\r
521
+ <div class="donut-total" id="donut-total">0</div>\r
522
+ <div class="donut-total-k">turns</div>\r
523
+ </div>\r
524
+ </div>\r
525
+ <div class="donut-legend" id="donut-legend"></div>\r
526
+ </div>\r
527
+ </aside>\r
528
+ \r
529
+ <!-- ===== Center ===== -->\r
530
+ <div class="col-center">\r
531
+ \r
532
+ <!-- Metric strip \u2014 divider-separated, no individual card chrome -->\r
533
+ <div class="metric-strip">\r
534
+ <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.">\r
535
+ <div class="m-label">Turns</div>\r
536
+ <div class="m-value" id="m-turns">0</div>\r
537
+ </div>\r
538
+ <div class="metric-item has-tooltip" data-tooltip="\u2193 Input \u2014 new, uncached tokens you sent to Claude. Usually small (a few hundred per turn) because most of the conversation history comes from prompt cache.">\r
539
+ <div class="m-label">\u2193 Input</div>\r
540
+ <div class="m-value" id="m-input">0</div>\r
541
+ </div>\r
542
+ <div class="metric-item has-tooltip" data-tooltip="\u2191 Output \u2014 tokens Claude generated in its responses. Most expensive line item per turn (~5\xD7 input rate on Opus). High output usually means long code edits.">\r
543
+ <div class="m-label">\u2191 Output</div>\r
544
+ <div class="m-value" id="m-output">0</div>\r
545
+ </div>\r
546
+ <div class="metric-item has-tooltip" data-tooltip="\u27F2 Cache read \u2014 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.">\r
547
+ <div class="m-label">\u27F2 Cache R</div>\r
548
+ <div class="m-value" id="m-cache-r">0</div>\r
549
+ </div>\r
550
+ <div class="metric-item has-tooltip" data-tooltip="\uFF0B Cache write \u2014 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.">\r
551
+ <div class="m-label">\uFF0B Cache W</div>\r
552
+ <div class="m-value" id="m-cache-w">0</div>\r
553
+ </div>\r
554
+ </div>\r
555
+ \r
556
+ <!-- Savings hero -->\r
557
+ <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 \xD7 $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.">\r
558
+ <div class="card-head">\r
559
+ <div class="card-eyebrow">Synthra savings <span class="src-badge estimated">floor</span></div>\r
560
+ <div class="card-meta" id="savings-pct">\u2014 off</div>\r
561
+ </div>\r
562
+ <div class="savings-body">\r
563
+ <div class="savings-figure">\r
564
+ <div class="savings-money" id="savings-money">$0.00</div>\r
565
+ <div class="savings-tokens"><span id="savings-tokens">0</span> tokens avoided</div>\r
566
+ </div>\r
567
+ <div class="savings-bar">\r
568
+ <div class="savings-actual" id="savings-actual-bar" style="width:100%"></div>\r
569
+ <div class="savings-saved" id="savings-saved-bar" style="width:0%"></div>\r
570
+ </div>\r
571
+ <div class="savings-legend">\r
572
+ <div class="sl-row"><span class="sl-dot actual"></span>You paid <b id="savings-actual-amt">$0.00</b></div>\r
573
+ <div class="sl-row"><span class="sl-dot saved"></span>Baseline <b id="savings-baseline-amt">$0.00</b></div>\r
574
+ </div>\r
575
+ </div>\r
576
+ <div class="savings-audit">\r
577
+ <span class="audit-formula">\r
578
+ <b id="audit-blocks">0</b> blocks \xD7 <b>500</b> tokens \xD7 <b>$3</b> / M input rate = <b id="audit-result" class="audit-result">$0.00</b>\r
579
+ </span>\r
580
+ </div>\r
581
+ </div>\r
582
+ \r
583
+ <!-- Recent turns -->\r
584
+ <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.">\r
585
+ <div class="card-head">\r
586
+ <div class="card-eyebrow">Recent turns</div>\r
587
+ <div class="card-meta" id="turns-count">\u2014 shown</div>\r
588
+ </div>\r
589
+ <div class="turns-scroll">\r
590
+ <table class="turns-table">\r
591
+ <thead>\r
592
+ <tr>\r
593
+ <th>Time</th>\r
594
+ <th>Project</th>\r
595
+ <th>Model</th>\r
596
+ <th class="num">In</th>\r
597
+ <th class="num">Out</th>\r
598
+ <th class="num">Cache R/W</th>\r
599
+ <th class="num">Cost</th>\r
600
+ </tr>\r
601
+ </thead>\r
602
+ <tbody id="turns-body"></tbody>\r
603
+ </table>\r
604
+ <p class="empty hidden" id="turns-empty">No turns logged yet. Run <code>syn .</code> in any project and chat with Claude.</p>\r
605
+ </div>\r
606
+ </div>\r
607
+ \r
608
+ </div>\r
609
+ \r
610
+ <!-- ===== Right ===== -->\r
611
+ <aside class="col-right">\r
612
+ \r
613
+ <!-- Cost hero -->\r
614
+ <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.">\r
615
+ <div class="card-head">\r
616
+ <div class="card-eyebrow">Total spend \xB7 <em>all time</em></div>\r
617
+ </div>\r
618
+ <div class="big-money" id="big-cost">$0.<em>00</em></div>\r
619
+ <div class="cost-sub">\r
620
+ <div class="cs-row">\r
621
+ <span class="cs-k">Tokens (in+out)</span>\r
622
+ <span class="cs-v" id="cs-tokens">0</span>\r
623
+ </div>\r
624
+ <div class="cs-row">\r
625
+ <span class="cs-k">Avg / turn</span>\r
626
+ <span class="cs-v" id="cs-avg">$0.00</span>\r
627
+ </div>\r
628
+ </div>\r
629
+ </div>\r
630
+ \r
631
+ <!-- The Moat -->\r
632
+ <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.">\r
633
+ <div class="card-head">\r
634
+ <div class="card-eyebrow">The <em>Moat</em></div>\r
635
+ <div class="card-meta">PreToolUse</div>\r
636
+ </div>\r
637
+ <div class="moat-value"><span id="blocks">0</span> <em>blocks</em></div>\r
638
+ <div class="gate-mini" id="gate-mini"></div>\r
639
+ </div>\r
640
+ \r
641
+ </aside>\r
642
+ </main>\r
643
+ \r
644
+ <!-- ============ Project dialog ============ -->\r
645
+ <div class="dialog-backdrop hidden" id="dialog-backdrop" role="dialog" aria-modal="true">\r
646
+ <div class="dialog">\r
647
+ <button class="dialog-close" id="dialog-close" aria-label="Close">\xD7</button>\r
648
+ <div class="dialog-eyebrow">Project \xB7 <em>details</em></div>\r
649
+ <div class="dialog-name" id="d-name">\u2014</div>\r
650
+ <div class="dialog-path" id="d-path">\u2014</div>\r
651
+ <div class="dialog-grid">\r
652
+ <div class="dg-cell">\r
653
+ <div class="dg-k">Total cost</div>\r
654
+ <div class="dg-v money" id="d-cost">$0.00</div>\r
655
+ </div>\r
656
+ <div class="dg-cell">\r
657
+ <div class="dg-k">Turns</div>\r
658
+ <div class="dg-v" id="d-turns">0</div>\r
659
+ </div>\r
660
+ <div class="dg-cell">\r
661
+ <div class="dg-k">\u2193 Raw input</div>\r
662
+ <div class="dg-v" id="d-input">0</div>\r
663
+ </div>\r
664
+ <div class="dg-cell">\r
665
+ <div class="dg-k">\u2191 Output</div>\r
666
+ <div class="dg-v" id="d-output">0</div>\r
667
+ </div>\r
668
+ <div class="dg-cell">\r
669
+ <div class="dg-k">\u27F2 Cache read</div>\r
670
+ <div class="dg-v" id="d-cache-r">0</div>\r
671
+ </div>\r
672
+ <div class="dg-cell">\r
673
+ <div class="dg-k">\uFF0B Cache write</div>\r
674
+ <div class="dg-v" id="d-cache-w">0</div>\r
675
+ </div>\r
676
+ <div class="dg-cell">\r
677
+ <div class="dg-k">Moat blocks</div>\r
678
+ <div class="dg-v" id="d-blocks">0</div>\r
679
+ </div>\r
680
+ <div class="dg-cell">\r
681
+ <div class="dg-k">Last active</div>\r
682
+ <div class="dg-v dg-v-sm" id="d-last">\u2014</div>\r
683
+ </div>\r
684
+ </div>\r
685
+ </div>\r
686
+ </div>\r
687
+ \r
688
+ <!-- ============ FAQ dialog ============ -->\r
689
+ <div class="dialog-backdrop hidden" id="faq-backdrop" role="dialog" aria-modal="true" aria-label="Dashboard FAQ">\r
690
+ <div class="dialog dialog-faq">\r
691
+ <button class="dialog-close" id="faq-close" aria-label="Close">\xD7</button>\r
692
+ <div class="dialog-eyebrow">FAQ \xB7 <em>where every number comes from</em></div>\r
693
+ <div class="dialog-name">Understanding your dashboard</div>\r
694
+ <div class="dialog-path">Synthra reports what Claude actually used \u2014 read from transcripts, not estimated.</div>\r
695
+ \r
696
+ <div class="faq-content">\r
697
+ \r
698
+ <details open>\r
699
+ <summary>Where does Synthra get these numbers from?</summary>\r
700
+ <div class="faq-body">\r
701
+ <p>Synthra <strong>does not estimate</strong> token counts \u2014 it reads them directly from Claude's own log files. Every number on this dashboard is traceable:</p>\r
702
+ <table>\r
703
+ <tr><td>Turns, \u2193 In, \u2191 Out, Cache R/W</td><td>Parsed from Claude's transcript JSONLs at <code>~/.claude/projects/&lt;encoded-cwd&gt;/*.jsonl</code></td></tr>\r
704
+ <tr><td>Model used per turn</td><td>From each turn's <code>model</code> field in the same transcripts</td></tr>\r
705
+ <tr><td>Moat blocks + gate decisions</td><td>From <code>.synthra-graph/gate_log.jsonl</code> inside each project</td></tr>\r
706
+ <tr><td>Active project, project list</td><td>From <code>~/.synthra/projects.json</code> (built up as you run <code>syn .</code>)</td></tr>\r
707
+ <tr><td>Total spend (USD)</td><td>Above token counts \xD7 Anthropic's published per-model rates \u2014 see "How is cost calculated?" below</td></tr>\r
708
+ <tr><td>Synthra savings (USD)</td><td><strong>Estimated</strong> \u2014 see "About the Savings (floor) card" below</td></tr>\r
709
+ </table>\r
710
+ <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>\r
711
+ </div>\r
712
+ </details>\r
713
+ \r
714
+ <details>\r
715
+ <summary>What do the columns in Recent Turns mean?</summary>\r
716
+ <div class="faq-body">\r
717
+ <table>\r
718
+ <tr><td><code>Time</code></td><td>When this turn happened (local time)</td></tr>\r
719
+ <tr><td><code>Project</code></td><td>Which directory the turn ran in</td></tr>\r
720
+ <tr><td><code>Model</code></td><td>Claude model used \u2014 Opus, Sonnet, or Haiku (color-coded in the model pill)</td></tr>\r
721
+ <tr><td><code>In</code></td><td>Raw input tokens \u2014 brand-new content sent to Claude this turn, not cached</td></tr>\r
722
+ <tr><td><code>Out</code></td><td>Tokens Claude generated in its response</td></tr>\r
723
+ <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>\r
724
+ <tr><td><code>Cost</code></td><td>Per-turn USD estimate using Anthropic's published rates</td></tr>\r
725
+ </table>\r
726
+ <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" \u2014 everything else is a cheap cache read. This is normal and saves significant money.</p>\r
727
+ </div>\r
728
+ </details>\r
729
+ \r
730
+ <details>\r
731
+ <summary>How is cost calculated?</summary>\r
732
+ <div class="faq-body">\r
733
+ <p>Each token type has a different per-million-token rate. Synthra uses these rates (defined in <code>src/shared/pricing.ts</code>):</p>\r
734
+ <table>\r
735
+ <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>\r
736
+ <tr><td>Raw Input</td><td>$1.00/M</td><td>$3.00/M</td><td>$15.00/M</td></tr>\r
737
+ <tr><td>Cache Write</td><td>$1.25/M</td><td>$3.75/M</td><td>$18.75/M</td></tr>\r
738
+ <tr><td>Cache Read</td><td>$0.10/M</td><td>$0.30/M</td><td>$1.50/M</td></tr>\r
739
+ <tr><td>Output</td><td>$5.00/M</td><td>$15.00/M</td><td>$75.00/M</td></tr>\r
740
+ </table>\r
741
+ <p><strong>Cost</strong> = (Input \xD7 input rate) + (Output \xD7 output rate) + (Cache Read \xD7 read rate) + (Cache Write \xD7 write rate)</p>\r
742
+ <p>Cache reads are <strong>10\xD7 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>\r
743
+ <div class="warning"><span class="icon">\u26A0</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 \u2014 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>\r
744
+ </div>\r
745
+ </details>\r
746
+ \r
747
+ <details>\r
748
+ <summary>What is the total context size per turn?</summary>\r
749
+ <div class="faq-body">\r
750
+ <p><code>Total context = Raw Input + Cache Read + Cache Write</code></p>\r
751
+ <p>Example: if Raw Input is 6, Cache Read is 60K, and Cache Write is 13K, your turn used ~73K tokens of context \u2014 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>\r
752
+ <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>\r
753
+ </div>\r
754
+ </details>\r
755
+ \r
756
+ <details>\r
757
+ <summary>About the "Savings (floor)" card</summary>\r
758
+ <div class="faq-body">\r
759
+ <p>This is the only number on the dashboard that's <strong>estimated</strong> rather than measured. Here's the math:</p>\r
760
+ <p class="formula-box">savings = blocks \xD7 500 tokens \xD7 $3 per million input rate</p>\r
761
+ <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>\r
762
+ <p>It under-counts on purpose because the formula ignores:</p>\r
763
+ <ul>\r
764
+ <li><strong>Cache thrash</strong> \u2014 the blocked tool result would have been written to the cache at ~125% of the input rate, which we don't count</li>\r
765
+ <li><strong>Cascading reads</strong> \u2014 Claude usually follows a Grep with several <code>Read</code> calls, which the block also prevents but we don't credit</li>\r
766
+ <li><strong>Bigger codebases</strong> \u2014 actual Grep results often exceed 500 tokens by 3\u20136\xD7 in a real repo</li>\r
767
+ </ul>\r
768
+ <p>Real savings are typically <strong>2\u20135\xD7 the floor</strong>. The audit row on the Savings card shows the formula live so you can verify the math.</p>\r
769
+ </div>\r
770
+ </details>\r
771
+ \r
772
+ <details>\r
773
+ <summary>What is "The Moat"?</summary>\r
774
+ <div class="faq-body">\r
775
+ <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>\r
776
+ <p>If yes, Synthra returns <code>{"decision":"block"}</code> \u2014 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 \u2014 not prose policy. Claude literally can't disobey.</p>\r
777
+ <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>\r
778
+ <p>Every decision is logged to <code>.synthra-graph/gate_log.jsonl</code> for the project.</p>\r
779
+ </div>\r
780
+ </details>\r
781
+ \r
782
+ <details>\r
783
+ <summary>How does Synthra build the codebase graph?</summary>\r
784
+ <div class="faq-body">\r
785
+ <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>\r
786
+ <p>TypeScript, JavaScript, JSX/TSX, Python, Svelte, Vue, Go, Rust, Java, Kotlin, PHP, Ruby, C/C++, C#, and Dart.</p>\r
787
+ <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>\r
788
+ <p>The graph is what makes pre-injection and The Moat work \u2014 both query it before Claude ever has to Grep.</p>\r
789
+ </div>\r
790
+ </details>\r
791
+ \r
792
+ <details>\r
793
+ <summary>Where does Synthra store data on disk?</summary>\r
794
+ <div class="faq-body">\r
795
+ <p>Synthra uses two folders per project, intentionally separated:</p>\r
796
+ <table>\r
797
+ <tr><td><code>.synthra-graph/</code></td><td><strong>Gitignored</strong>. Heavy generated state \u2014 the graph, symbol index, token + gate logs, session info. Rebuilt by <code>syn scan</code>.</td></tr>\r
798
+ <tr><td><code>.synthra/</code></td><td><strong>Git-tracked.</strong> Decisions, context notes, branch-scoped memory \u2014 the part teammates inherit when they clone the repo.</td></tr>\r
799
+ </table>\r
800
+ <p>Plus one global file at <code>~/.synthra/projects.json</code> that tracks every project on this machine.</p>\r
801
+ </div>\r
802
+ </details>\r
803
+ \r
804
+ <details>\r
805
+ <summary>How does branch-aware memory work?</summary>\r
806
+ <div class="faq-body">\r
807
+ <p>Inside <code>.synthra/</code>, context is partitioned by git branch:</p>\r
808
+ <ul>\r
809
+ <li><code>.synthra/context-store.json</code> \u2014 decisions on the default branch</li>\r
810
+ <li><code>.synthra/CONTEXT.md</code> \u2014 narrative notes on the default branch</li>\r
811
+ <li><code>.synthra/branches/&lt;sanitized-name&gt;/</code> \u2014 overrides on feature branches</li>\r
812
+ </ul>\r
813
+ <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>\r
814
+ </div>\r
815
+ </details>\r
816
+ \r
817
+ <details>\r
818
+ <summary>How does Synthra actually reduce my Claude bill?</summary>\r
819
+ <div class="faq-body">\r
820
+ <p>Three mechanisms, in order of impact:</p>\r
821
+ <ul>\r
822
+ <li><strong>Pre-injection</strong> \u2014 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 \u2014 it already knows.</li>\r
823
+ <li><strong>The Moat</strong> \u2014 the PreToolUse hook deterministically blocks exploratory Grep/Glob when the graph already has high-confidence context. Counts on the Moat card.</li>\r
824
+ <li><strong>Branch-aware memory</strong> \u2014 decisions and CONTEXT notes persist in <code>.synthra/</code>, so Claude doesn't have to be re-told what was decided last session.</li>\r
825
+ </ul>\r
826
+ <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>\r
827
+ </div>\r
828
+ </details>\r
829
+ \r
830
+ <details>\r
831
+ <summary>Why do long conversations get expensive?</summary>\r
832
+ <div class="faq-body">\r
833
+ <p>Each turn re-sends the entire conversation history as input. Even cached, the cumulative input grows roughly <strong>quadratically</strong>:</p>\r
834
+ <table>\r
835
+ <thead><tr><td><strong>Turns</strong></td><td><strong>Per-turn input</strong></td><td><strong>Cumulative input</strong></td></tr></thead>\r
836
+ <tr><td>10</td><td>~2K (mostly cached)</td><td>~110K</td></tr>\r
837
+ <tr><td>30</td><td>~2K</td><td>~930K</td></tr>\r
838
+ <tr><td>50</td><td>~2K</td><td>~2.55M</td></tr>\r
839
+ </table>\r
840
+ <p>Prompt caching helps a lot (cache reads are 10\xD7 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>\r
841
+ <p><strong>Tip:</strong> Use <code>/compact</code> in Claude Code or start fresh sessions when a thread feels stale.</p>\r
842
+ </div>\r
843
+ </details>\r
844
+ \r
845
+ <details>\r
846
+ <summary>Sources &amp; references</summary>\r
847
+ <div class="faq-body">\r
848
+ <p>Synthra is open source. Every number on this dashboard can be cross-checked:</p>\r
849
+ <ul class="link-list">\r
850
+ <li><a href="https://github.com/jefuriiij/synthra" target="_blank" rel="noopener noreferrer">github.com/jefuriiij/synthra</a> \u2014 source code, issues, roadmap</li>\r
851
+ <li><a href="https://www.npmjs.com/package/@jefuriiij/synthra" target="_blank" rel="noopener noreferrer">npm: @jefuriiij/synthra</a> \u2014 release history, install instructions</li>\r
852
+ <li><a href="https://www.anthropic.com/pricing" target="_blank" rel="noopener noreferrer">anthropic.com/pricing</a> \u2014 official rate table Synthra uses</li>\r
853
+ <li><a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic docs \xB7 prompt caching</a> \u2014 explains cache read/write behavior</li>\r
854
+ <li><code>src/shared/pricing.ts</code> \u2014 the file in this repo holding the rate table</li>\r
855
+ <li><code>src/dashboard/delta.ts</code> \u2014 where dashboard aggregates are computed</li>\r
856
+ <li><code>src/server/routes/gate.ts</code> \u2014 the Moat implementation</li>\r
857
+ </ul>\r
858
+ </div>\r
859
+ </details>\r
860
+ \r
861
+ </div>\r
862
+ </div>\r
863
+ </div>\r
864
+ \r
865
+ <!-- ============ Footer ============ -->\r
866
+ <footer class="foot">\r
867
+ <div>Synth<em>ra</em> \xB7 v0.1.8</div>\r
868
+ <div>Cost figures approximate \xB7 @jefuriiij</div>\r
869
+ </footer>\r
870
+ \r
871
+ <script>\r
872
+ const $ = (sel) => document.querySelector(sel);\r
873
+ const SAVED_RATE_PER_M = 3.00; // USD per million tokens \u2014 conservative input rate\r
874
+ \r
875
+ // ----- model classification -----\r
876
+ function modelFamily(model) {\r
877
+ if (!model) return 'unknown';\r
878
+ const m = model.toLowerCase();\r
879
+ if (m === '<synthetic>') return 'unknown';\r
880
+ if (m.includes('opus')) return 'opus';\r
881
+ if (m.includes('sonnet')) return 'sonnet';\r
882
+ if (m.includes('haiku')) return 'haiku';\r
883
+ return 'unknown';\r
884
+ }\r
885
+ function modelLabel(model) {\r
886
+ if (!model || model === '<synthetic>') return 'synthetic';\r
887
+ return model.replace(/^claude-/, '');\r
888
+ }\r
889
+ \r
890
+ // ----- formatting -----\r
891
+ function fmt(n) {\r
892
+ if (typeof n !== 'number' || !Number.isFinite(n)) return '0';\r
893
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + '<em>M</em>';\r
894
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + '<em>k</em>';\r
895
+ return n.toLocaleString();\r
896
+ }\r
897
+ function fmtPlain(n) {\r
898
+ if (typeof n !== 'number' || !Number.isFinite(n)) return '0';\r
899
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';\r
900
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';\r
901
+ return n.toLocaleString();\r
902
+ }\r
903
+ function fmtCostBig(usd) {\r
904
+ if (typeof usd !== 'number' || !Number.isFinite(usd)) usd = 0;\r
905
+ let s;\r
906
+ if (usd >= 1) s = usd.toFixed(2);\r
907
+ else if (usd >= 0.01) s = usd.toFixed(3);\r
908
+ else s = usd.toFixed(4);\r
909
+ const dot = s.indexOf('.');\r
910
+ if (dot === -1) return '$' + s;\r
911
+ return '$' + s.slice(0, dot) + '.<em>' + s.slice(dot + 1) + '</em>';\r
912
+ }\r
913
+ function fmtCostFlat(usd) {\r
914
+ if (typeof usd !== 'number' || !Number.isFinite(usd)) usd = 0;\r
915
+ if (usd >= 1) return '$' + usd.toFixed(2);\r
916
+ if (usd >= 0.01) return '$' + usd.toFixed(3);\r
917
+ return '$' + usd.toFixed(4);\r
918
+ }\r
919
+ function fmtTs(iso) {\r
920
+ try {\r
921
+ const d = new Date(iso);\r
922
+ const today = new Date();\r
923
+ const isToday = d.toDateString() === today.toDateString();\r
924
+ if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\r
925
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });\r
926
+ } catch { return iso; }\r
927
+ }\r
928
+ \r
929
+ let lastData = null;\r
930
+ \r
931
+ // ----- date in hero -----\r
932
+ function setHeroDate() {\r
933
+ const now = new Date();\r
934
+ $('#hero-day').textContent = now.getDate();\r
935
+ $('#hero-weekday').textContent = now.toLocaleDateString([], { weekday: 'short' });\r
936
+ $('#hero-month').textContent = now.toLocaleDateString([], { month: 'long' });\r
937
+ }\r
938
+ \r
939
+ // ----- renderers -----\r
940
+ function renderSession(turns) {\r
941
+ const agg = turns.reduce((a, t) => {\r
942
+ a.turns += 1;\r
943
+ a.input += t.input || 0;\r
944
+ a.output += t.output || 0;\r
945
+ a.cacheR += t.cache_read || 0;\r
946
+ a.cacheW += t.cache_create || 0;\r
947
+ return a;\r
948
+ }, { turns: 0, input: 0, output: 0, cacheR: 0, cacheW: 0 });\r
949
+ \r
950
+ $('#m-turns').innerHTML = fmt(agg.turns);\r
951
+ $('#m-input').innerHTML = fmt(agg.input);\r
952
+ $('#m-output').innerHTML = fmt(agg.output);\r
953
+ $('#m-cache-r').innerHTML = fmt(agg.cacheR);\r
954
+ $('#m-cache-w').innerHTML = fmt(agg.cacheW);\r
955
+ }\r
956
+ \r
957
+ function renderSavings(g) {\r
958
+ const tokensSaved = g.estimated_tokens_saved || 0;\r
959
+ const blocks = g.blocked_count || 0;\r
960
+ const savedUsd = tokensSaved * SAVED_RATE_PER_M / 1_000_000;\r
961
+ const actualUsd = g.estimated_cost_usd || 0;\r
962
+ const baselineUsd = actualUsd + savedUsd;\r
963
+ const savedPct = baselineUsd > 0 ? (savedUsd / baselineUsd) * 100 : 0;\r
964
+ const actualPct = baselineUsd > 0 ? (actualUsd / baselineUsd) * 100 : 100;\r
965
+ \r
966
+ $('#savings-money').textContent = fmtCostFlat(savedUsd);\r
967
+ $('#savings-pct').textContent = savedPct.toFixed(1) + '% off';\r
968
+ $('#savings-tokens').textContent = fmtPlain(tokensSaved);\r
969
+ $('#savings-actual-bar').style.width = actualPct.toFixed(2) + '%';\r
970
+ $('#savings-saved-bar').style.width = savedPct.toFixed(2) + '%';\r
971
+ $('#savings-actual-amt').textContent = fmtCostFlat(actualUsd);\r
972
+ $('#savings-baseline-amt').textContent = fmtCostFlat(baselineUsd);\r
973
+ \r
974
+ // Audit row \u2014 live formula\r
975
+ $('#audit-blocks').textContent = blocks.toLocaleString();\r
976
+ $('#audit-result').textContent = fmtCostFlat(savedUsd);\r
977
+ }\r
978
+ \r
979
+ function renderCostHero(g) {\r
980
+ $('#big-cost').innerHTML = fmtCostBig(g.estimated_cost_usd);\r
981
+ const totalTokens = (g.total_input_tokens || 0) + (g.total_output_tokens || 0);\r
982
+ $('#cs-tokens').textContent = fmtPlain(totalTokens);\r
983
+ const avg = g.total_turns > 0 ? g.estimated_cost_usd / g.total_turns : 0;\r
984
+ $('#cs-avg').textContent = fmtCostFlat(avg);\r
985
+ }\r
986
+ \r
987
+ function renderMoat(g) {\r
988
+ $('#blocks').textContent = fmtPlain(g.blocked_count);\r
989
+ }\r
990
+ \r
991
+ function renderTurns(turns) {\r
992
+ const tbody = $('#turns-body');\r
993
+ const empty = $('#turns-empty');\r
994
+ tbody.innerHTML = '';\r
995
+ if (!turns.length) {\r
996
+ empty.classList.remove('hidden');\r
997
+ $('#turns-count').textContent = '0 shown';\r
998
+ return;\r
999
+ }\r
1000
+ empty.classList.add('hidden');\r
1001
+ $('#turns-count').textContent = turns.length + ' shown';\r
1002
+ const frag = document.createDocumentFragment();\r
1003
+ for (const t of turns) {\r
1004
+ const family = modelFamily(t.model);\r
1005
+ const tr = document.createElement('tr');\r
1006
+ tr.innerHTML =\r
1007
+ '<td class="ts">' + fmtTs(t.ts || t.written_at) + '</td>' +\r
1008
+ '<td class="proj">' + (t.project_name || '\u2014') + '</td>' +\r
1009
+ '<td><span class="model-pill ' + family + '"><span class="sq"></span>' + modelLabel(t.model) + '</span></td>' +\r
1010
+ '<td class="num">' + fmtPlain(t.input || 0) + '</td>' +\r
1011
+ '<td class="num">' + fmtPlain(t.output || 0) + '</td>' +\r
1012
+ '<td class="num">' + fmtPlain(t.cache_read || 0) + ' / ' + fmtPlain(t.cache_create || 0) + '</td>' +\r
1013
+ '<td class="num cost">' + fmtCostFlat(t.cost_usd || 0) + '</td>';\r
1014
+ frag.appendChild(tr);\r
1015
+ }\r
1016
+ tbody.appendChild(frag);\r
1017
+ }\r
1018
+ \r
1019
+ function renderGateMini(gates) {\r
1020
+ const el = $('#gate-mini');\r
1021
+ el.innerHTML = '';\r
1022
+ if (!gates.length) {\r
1023
+ el.innerHTML = '<div class="empty">No gate decisions yet.</div>';\r
1024
+ return;\r
1025
+ }\r
1026
+ const frag = document.createDocumentFragment();\r
1027
+ for (const g of gates.slice(0, 12)) {\r
1028
+ const row = document.createElement('div');\r
1029
+ row.className = 'gate-row';\r
1030
+ const cls = g.decision === 'block' ? 'block' : 'allow';\r
1031
+ row.innerHTML =\r
1032
+ '<span class="g-ts">' + fmtTs(g.ts) + '</span>' +\r
1033
+ '<span class="g-decision ' + cls + '">' + (g.decision || '\u2014').toUpperCase() + '</span>' +\r
1034
+ '<span class="g-q">' + (g.query || g.tool || '\u2014') + '</span>';\r
1035
+ frag.appendChild(row);\r
1036
+ }\r
1037
+ el.appendChild(frag);\r
1038
+ }\r
1039
+ \r
1040
+ function renderProjectLegend(projects) {\r
1041
+ const el = $('#proj-filter');\r
1042
+ const incoming = projects.map((p) => p.name).join('|');\r
1043
+ if (el.dataset.signature === incoming) return;\r
1044
+ el.dataset.signature = incoming;\r
1045
+ el.innerHTML = '';\r
1046
+ if (!projects.length) {\r
1047
+ el.innerHTML = '<div class="empty">No projects yet.</div>';\r
1048
+ return;\r
1049
+ }\r
1050
+ const frag = document.createDocumentFragment();\r
1051
+ for (const p of projects) {\r
1052
+ const row = document.createElement('button');\r
1053
+ row.className = 'check check-clickable';\r
1054
+ row.type = 'button';\r
1055
+ row.innerHTML =\r
1056
+ '<span class="dot-sq" style="background: var(--sky); opacity:.55"></span>' +\r
1057
+ '<span class="pf-name" title="' + p.path + '">' + p.name + '</span>' +\r
1058
+ '<span class="pf-arrow">\u203A</span>';\r
1059
+ row.addEventListener('click', () => openProjectDialog(p.name));\r
1060
+ frag.appendChild(row);\r
1061
+ }\r
1062
+ el.appendChild(frag);\r
1063
+ }\r
1064
+ \r
1065
+ // ----- Project dialog -----\r
1066
+ function lastActiveFor(name) {\r
1067
+ const turns = lastData?.recent_turns || [];\r
1068
+ for (const t of turns) {\r
1069
+ if (t.project_name === name) {\r
1070
+ const ts = t.ts || t.written_at;\r
1071
+ if (!ts) return '\u2014';\r
1072
+ try {\r
1073
+ return new Date(ts).toLocaleString([], {\r
1074
+ year: 'numeric', month: 'short', day: 'numeric',\r
1075
+ hour: '2-digit', minute: '2-digit',\r
1076
+ });\r
1077
+ } catch { return ts; }\r
1078
+ }\r
1079
+ }\r
1080
+ return '\u2014';\r
1081
+ }\r
1082
+ \r
1083
+ function openProjectDialog(name) {\r
1084
+ const p = (lastData?.projects || []).find((x) => x.name === name);\r
1085
+ if (!p) return;\r
1086
+ $('#d-name').textContent = p.name;\r
1087
+ $('#d-path').textContent = p.path || '';\r
1088
+ $('#d-cost').textContent = fmtCostFlat(p.estimated_cost_usd);\r
1089
+ $('#d-turns').textContent = fmtPlain(p.total_turns);\r
1090
+ $('#d-input').textContent = fmtPlain(p.total_input_tokens);\r
1091
+ $('#d-output').textContent = fmtPlain(p.total_output_tokens);\r
1092
+ $('#d-cache-r').textContent = fmtPlain(p.total_cache_read);\r
1093
+ $('#d-cache-w').textContent = fmtPlain(p.total_cache_create);\r
1094
+ $('#d-blocks').textContent = fmtPlain(p.blocked_count);\r
1095
+ $('#d-last').textContent = lastActiveFor(p.name);\r
1096
+ $('#dialog-backdrop').classList.remove('hidden');\r
1097
+ }\r
1098
+ \r
1099
+ function closeProjectDialog() {\r
1100
+ $('#dialog-backdrop').classList.add('hidden');\r
1101
+ }\r
1102
+ \r
1103
+ $('#dialog-close').addEventListener('click', closeProjectDialog);\r
1104
+ $('#dialog-backdrop').addEventListener('click', (e) => {\r
1105
+ if (e.target.id === 'dialog-backdrop') closeProjectDialog();\r
1106
+ });\r
1107
+ \r
1108
+ // ----- FAQ dialog -----\r
1109
+ const faqBackdrop = $('#faq-backdrop');\r
1110
+ function openFaq() { faqBackdrop.classList.remove('hidden'); }\r
1111
+ function closeFaq() { faqBackdrop.classList.add('hidden'); }\r
1112
+ $('#faq-btn').addEventListener('click', openFaq);\r
1113
+ $('#faq-close').addEventListener('click', closeFaq);\r
1114
+ faqBackdrop.addEventListener('click', (e) => {\r
1115
+ if (e.target.id === 'faq-backdrop') closeFaq();\r
1116
+ });\r
1117
+ \r
1118
+ document.addEventListener('keydown', (e) => {\r
1119
+ if (e.key === 'Escape') { closeProjectDialog(); closeFaq(); }\r
1120
+ });\r
1121
+ \r
1122
+ // Reflect the actual port the dashboard is served on\r
1123
+ const portEl = $('#port-num');\r
1124
+ if (portEl && window.location.port) portEl.textContent = window.location.port;\r
1125
+ \r
1126
+ // ----- Donut chart (model usage by turn count) -----\r
1127
+ function renderDonut(turns) {\r
1128
+ const counts = { opus: 0, sonnet: 0, haiku: 0, unknown: 0 };\r
1129
+ for (const t of turns) counts[modelFamily(t.model)] += 1;\r
1130
+ const total = counts.opus + counts.sonnet + counts.haiku + counts.unknown;\r
1131
+ \r
1132
+ const segs = [\r
1133
+ { fam: 'opus', label: 'Opus', n: counts.opus, color: 'var(--c-opus)' },\r
1134
+ { fam: 'sonnet', label: 'Sonnet', n: counts.sonnet, color: 'var(--c-sonnet)' },\r
1135
+ { fam: 'haiku', label: 'Haiku', n: counts.haiku, color: 'var(--c-haiku)' },\r
1136
+ { fam: 'unknown', label: 'Other', n: counts.unknown, color: 'var(--c-unknown)' },\r
1137
+ ].filter((s) => s.n > 0);\r
1138
+ \r
1139
+ const svg = $('#donut-svg');\r
1140
+ svg.querySelectorAll('.donut-seg').forEach((el) => el.remove());\r
1141
+ \r
1142
+ const C = 2 * Math.PI * 52; // \u2248 326.7\r
1143
+ let offset = 0;\r
1144
+ const ns = 'http://www.w3.org/2000/svg';\r
1145
+ if (total === 0) {\r
1146
+ // pleasant empty state \u2014 leave just the track\r
1147
+ } else {\r
1148
+ for (const s of segs) {\r
1149
+ const arc = (s.n / total) * C;\r
1150
+ const c = document.createElementNS(ns, 'circle');\r
1151
+ c.setAttribute('cx', '70');\r
1152
+ c.setAttribute('cy', '70');\r
1153
+ c.setAttribute('r', '52');\r
1154
+ c.setAttribute('fill', 'none');\r
1155
+ c.setAttribute('stroke', s.color);\r
1156
+ c.setAttribute('stroke-width', '14');\r
1157
+ c.setAttribute('stroke-dasharray', arc + ' ' + C);\r
1158
+ c.setAttribute('stroke-dashoffset', String(-offset));\r
1159
+ c.setAttribute('transform', 'rotate(-90 70 70)');\r
1160
+ c.setAttribute('stroke-linecap', segs.length === 1 ? 'round' : 'butt');\r
1161
+ c.classList.add('donut-seg');\r
1162
+ svg.appendChild(c);\r
1163
+ offset += arc;\r
1164
+ }\r
1165
+ }\r
1166
+ \r
1167
+ $('#donut-total').textContent = total;\r
1168
+ \r
1169
+ const legend = $('#donut-legend');\r
1170
+ legend.innerHTML = '';\r
1171
+ const lf = document.createDocumentFragment();\r
1172
+ const display = segs.length ? segs : [\r
1173
+ { fam: 'opus', label: 'Opus', n: 0, color: 'var(--c-opus)' },\r
1174
+ { fam: 'sonnet', label: 'Sonnet', n: 0, color: 'var(--c-sonnet)' },\r
1175
+ { fam: 'haiku', label: 'Haiku', n: 0, color: 'var(--c-haiku)' },\r
1176
+ ];\r
1177
+ for (const s of display) {\r
1178
+ const pct = total > 0 ? Math.round((s.n / total) * 100) : 0;\r
1179
+ const row = document.createElement('div');\r
1180
+ row.className = 'dl-row';\r
1181
+ row.innerHTML =\r
1182
+ '<span class="dl-dot" style="background:' + s.color + '"></span>' +\r
1183
+ '<span class="dl-name">' + s.label + '</span>' +\r
1184
+ '<span class="dl-pct">' + pct + '%</span>';\r
1185
+ lf.appendChild(row);\r
1186
+ }\r
1187
+ legend.appendChild(lf);\r
1188
+ }\r
1189
+ \r
1190
+ // ----- master render -----\r
1191
+ function applyData(data) {\r
1192
+ const turns = data.recent_turns || [];\r
1193
+ const gates = data.recent_gates || [];\r
1194
+ \r
1195
+ renderSession(turns);\r
1196
+ renderSavings(data.global);\r
1197
+ renderCostHero(data.global);\r
1198
+ renderMoat(data.global);\r
1199
+ renderTurns(turns);\r
1200
+ renderGateMini(gates);\r
1201
+ renderDonut(turns);\r
1202
+ }\r
1203
+ \r
1204
+ // ----- polling -----\r
1205
+ async function tick() {\r
1206
+ try {\r
1207
+ const res = await fetch('/data');\r
1208
+ if (!res.ok) throw new Error('HTTP ' + res.status);\r
1209
+ const data = await res.json();\r
1210
+ lastData = data;\r
1211
+ $('#active-project').textContent = data.active?.project_root || '\u2014';\r
1212
+ renderProjectLegend(data.projects || []);\r
1213
+ applyData(data);\r
1214
+ $('#status').textContent = 'live \xB7 ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\r
1215
+ $('#dot').classList.add('live'); $('#dot').classList.remove('dead');\r
1216
+ } catch (e) {\r
1217
+ $('#status').textContent = 'offline';\r
1218
+ $('#dot').classList.add('dead'); $('#dot').classList.remove('live');\r
1219
+ }\r
1220
+ }\r
1221
+ \r
1222
+ // ----- Viewport-clamped tooltip -----\r
1223
+ const tooltipEl = document.createElement('div');\r
1224
+ tooltipEl.className = 'global-tooltip';\r
1225
+ document.body.appendChild(tooltipEl);\r
1226
+ let activeTooltipTarget = null;\r
1227
+ \r
1228
+ function positionTooltip(target) {\r
1229
+ const rect = target.getBoundingClientRect();\r
1230
+ const ttRect = tooltipEl.getBoundingClientRect();\r
1231
+ const margin = 12;\r
1232
+ let top = rect.top - ttRect.height - 10;\r
1233
+ let left = rect.left + rect.width / 2 - ttRect.width / 2;\r
1234
+ if (left < margin) left = margin;\r
1235
+ if (left + ttRect.width > window.innerWidth - margin) {\r
1236
+ left = window.innerWidth - ttRect.width - margin;\r
1237
+ }\r
1238
+ if (top < margin) {\r
1239
+ top = rect.bottom + 10;\r
1240
+ }\r
1241
+ if (top + ttRect.height > window.innerHeight - margin) {\r
1242
+ top = window.innerHeight - ttRect.height - margin;\r
1243
+ }\r
1244
+ tooltipEl.style.top = top + 'px';\r
1245
+ tooltipEl.style.left = left + 'px';\r
1246
+ }\r
1247
+ \r
1248
+ function showTooltip(target) {\r
1249
+ const text = target.getAttribute('data-tooltip');\r
1250
+ if (!text) return;\r
1251
+ tooltipEl.textContent = text;\r
1252
+ tooltipEl.classList.add('on');\r
1253
+ positionTooltip(target);\r
1254
+ }\r
1255
+ \r
1256
+ function hideTooltip() {\r
1257
+ tooltipEl.classList.remove('on');\r
1258
+ }\r
1259
+ \r
1260
+ document.addEventListener('mouseover', (e) => {\r
1261
+ const t = (e.target instanceof Element) ? e.target.closest('.has-tooltip') : null;\r
1262
+ if (t !== activeTooltipTarget) {\r
1263
+ activeTooltipTarget = t;\r
1264
+ if (t) showTooltip(t);\r
1265
+ else hideTooltip();\r
1266
+ }\r
1267
+ });\r
1268
+ document.addEventListener('scroll', hideTooltip, true);\r
1269
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTooltip(); });\r
1270
+ \r
1271
+ setHeroDate();\r
1272
+ tick();\r
1273
+ setInterval(tick, 2000);\r
1274
+ </script>\r
1275
+ </body>\r
1276
+ </html>\r
1277
1277
  `;
1278
1278
 
1279
1279
  // src/dashboard/public/style.css
1280
- var style_default = '/* Synthra dashboard \xB7 v0.2 \xB7 Cool Marine\n Darkened surfaces; brand blue reserved for hero elements only.\n Layout: top nav + hero strip + 3-column main, fits 1280\xD7720. */\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 auto 1fr auto;\n position: relative;\n}\n\n/* Layered backdrop \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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-filter::-webkit-scrollbar,\n.gate-mini::-webkit-scrollbar {\n width: 6px;\n}\n\n.turns-scroll::-webkit-scrollbar-thumb,\n.proj-filter::-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-filter::-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\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 \u2014 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: "\u203A";\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: "\u203A";\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}';
1280
+ var style_default = '/* Synthra dashboard \xB7 v0.2 \xB7 Cool Marine\r\n Darkened surfaces; brand blue reserved for hero elements only.\r\n Layout: top nav + hero strip + 3-column main, fits 1280\xD7720. */\r\n\r\n:root {\r\n /* Core palette */\r\n --ink: #04081A;\r\n --navy: #0A1530;\r\n --navy-2: #122549;\r\n --deep-blue: #1B3A78;\r\n --blue: #2C5DB8;\r\n --blue-bright: #5C8FE6;\r\n --sky: #9BC2EF;\r\n --mist: #D7E6F7;\r\n --bone: #F4F7FC;\r\n\r\n /* Text */\r\n --text: #ECF2FB;\r\n --text-dim: #A9BBD6;\r\n --text-mute: #6D80A0;\r\n\r\n /* Rules / dividers */\r\n --rule: rgba(155, 194, 239, .14);\r\n --rule-2: rgba(155, 194, 239, .06);\r\n --rule-hover: rgba(155, 194, 239, .28);\r\n\r\n /* Surfaces (darker than v0.1.2) */\r\n --surface-1: rgba(18, 37, 73, .14);\r\n --surface-2: rgba(18, 37, 73, .22);\r\n --surface-3: rgba(4, 8, 26, .55);\r\n\r\n /* Signal accents (OKLCH shared chroma) */\r\n --signal-cyan: oklch(78% 0.14 220);\r\n --signal-amber: oklch(78% 0.14 75);\r\n --signal-rose: oklch(70% 0.14 20);\r\n --signal-green: oklch(75% 0.14 155);\r\n --signal-violet: oklch(72% 0.14 285);\r\n\r\n /* Model family colors */\r\n --c-opus: #FF6338;\r\n --c-sonnet: #FFB938;\r\n --c-haiku: #7438FF;\r\n --c-unknown: #12CBF5;\r\n\r\n /* Money */\r\n --money: var(--signal-green);\r\n\r\n /* Type */\r\n --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;\r\n --font-serif: "Instrument Serif", "Times New Roman", serif;\r\n --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;\r\n}\r\n\r\n/* ============================================================\r\n Reset + base\r\n ============================================================ */\r\n*,\r\n*::before,\r\n*::after {\r\n box-sizing: border-box;\r\n}\r\n\r\nhtml,\r\nbody {\r\n margin: 0;\r\n padding: 0;\r\n}\r\n\r\nhtml,\r\nbody {\r\n height: 100vh;\r\n overflow: hidden;\r\n}\r\n\r\nbody {\r\n background: var(--ink);\r\n color: var(--text);\r\n font-family: var(--font-sans);\r\n font-size: 13px;\r\n line-height: 1.5;\r\n -webkit-font-smoothing: antialiased;\r\n text-rendering: optimizeLegibility;\r\n display: grid;\r\n grid-template-rows: auto auto 1fr auto;\r\n position: relative;\r\n}\r\n\r\n/* Layered backdrop \u2014 quieter */\r\nbody::before,\r\nbody::after {\r\n content: "";\r\n position: fixed;\r\n inset: 0;\r\n pointer-events: none;\r\n z-index: 0;\r\n}\r\n\r\nbody::before {\r\n background-image: radial-gradient(circle, rgba(155, 194, 239, .06) 1px, transparent 1.2px);\r\n background-size: 22px 22px;\r\n}\r\n\r\nbody::after {\r\n background:\r\n radial-gradient(60% 40% at 50% 105%, rgba(44, 93, 184, .16) 0%, rgba(10, 21, 48, 0) 65%),\r\n radial-gradient(30% 25% at 50% 0%, rgba(92, 143, 230, .06) 0%, transparent 70%);\r\n}\r\n\r\nbody>* {\r\n position: relative;\r\n z-index: 1;\r\n}\r\n\r\nbutton {\r\n font: inherit;\r\n cursor: pointer;\r\n border: 0;\r\n background: transparent;\r\n color: inherit;\r\n}\r\n\r\na {\r\n color: inherit;\r\n text-decoration: none;\r\n}\r\n\r\n/* ============================================================\r\n Top nav\r\n ============================================================ */\r\n.topnav {\r\n display: grid;\r\n grid-template-columns: 1fr auto 1fr;\r\n align-items: center;\r\n height: 52px;\r\n padding: 0 24px;\r\n border-bottom: 1px solid var(--rule);\r\n background: linear-gradient(180deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\r\n backdrop-filter: blur(10px);\r\n}\r\n\r\n.brand {\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n}\r\n\r\n.brand-mark {\r\n width: 22px;\r\n height: 22px;\r\n border-radius: 7px;\r\n background: radial-gradient(120% 120% at 30% 30%, #6FA6E8 0%, #2C5DB8 45%, #0A1530 100%);\r\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22), 0 4px 12px -6px #2C5DB8;\r\n}\r\n\r\n.brand-name {\r\n font-size: 15px;\r\n font-weight: 600;\r\n letter-spacing: -0.01em;\r\n color: var(--mist);\r\n}\r\n\r\n.brand-name em {\r\n font-family: var(--font-serif);\r\n font-style: italic;\r\n font-weight: 400;\r\n color: var(--sky);\r\n letter-spacing: 0;\r\n}\r\n\r\n.brand-eyebrow {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n margin-left: 6px;\r\n padding-left: 10px;\r\n border-left: 1px solid var(--rule);\r\n}\r\n\r\n.top-right {\r\n display: flex;\r\n align-items: center;\r\n gap: 12px;\r\n grid-column: 2;\r\n justify-self: center;\r\n}\r\n\r\n.topnav-right {\r\n grid-column: 3;\r\n justify-self: end;\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n}\r\n\r\n.port-badge {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.12em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n padding: 6px 10px;\r\n border: 1px solid var(--rule);\r\n border-radius: 999px;\r\n background: rgba(4, 8, 26, .55);\r\n}\r\n\r\n.port-badge .mono {\r\n color: var(--text-dim);\r\n letter-spacing: 0.04em;\r\n text-transform: none;\r\n}\r\n\r\n.faq-btn {\r\n width: 30px;\r\n height: 30px;\r\n border-radius: 50%;\r\n border: 1px solid var(--rule);\r\n background: rgba(4, 8, 26, .55);\r\n color: var(--text-dim);\r\n font-family: var(--font-mono);\r\n font-size: 13px;\r\n font-weight: 500;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n transition: background 180ms, border-color 180ms, color 180ms, transform 180ms;\r\n}\r\n\r\n.faq-btn:hover {\r\n background: rgba(155, 194, 239, .10);\r\n border-color: var(--rule-hover);\r\n color: var(--mist);\r\n transform: translateY(-1px);\r\n}\r\n\r\n.status-pill {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 8px;\r\n padding: 6px 12px;\r\n border: 1px solid var(--rule);\r\n border-radius: 999px;\r\n background: rgba(4, 8, 26, .55);\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.12em;\r\n text-transform: uppercase;\r\n color: var(--text-dim);\r\n transition: border-color 240ms ease;\r\n}\r\n\r\n.status-pill:has(.dot.live) {\r\n border-color: rgba(155, 194, 239, .45);\r\n color: var(--mist);\r\n animation: pill-glow 2.4s ease-in-out infinite;\r\n}\r\n\r\n.status-pill:has(.dot.dead) {\r\n border-color: rgba(220, 90, 90, .40);\r\n color: oklch(80% 0.10 20);\r\n}\r\n\r\n@keyframes pill-glow {\r\n\r\n 0%,\r\n 100% {\r\n box-shadow: 0 0 14px -4px rgba(155, 194, 239, .30), inset 0 0 12px -8px rgba(155, 194, 239, .30);\r\n }\r\n\r\n 50% {\r\n box-shadow: 0 0 26px -2px rgba(155, 194, 239, .55), inset 0 0 18px -6px rgba(155, 194, 239, .45);\r\n }\r\n}\r\n\r\n.dot {\r\n width: 7px;\r\n height: 7px;\r\n border-radius: 2px;\r\n background: var(--text-mute);\r\n transition: background 200ms;\r\n}\r\n\r\n.dot.live {\r\n background: var(--signal-cyan);\r\n animation: dot-pulse 1.8s ease-in-out infinite;\r\n}\r\n\r\n.dot.dead {\r\n background: var(--signal-rose);\r\n box-shadow: 0 0 0 3px rgba(220, 90, 90, .10);\r\n}\r\n\r\n@keyframes dot-pulse {\r\n\r\n 0%,\r\n 100% {\r\n box-shadow:\r\n 0 0 0 3px rgba(155, 194, 239, .10),\r\n 0 0 6px rgba(155, 194, 239, .50);\r\n }\r\n\r\n 50% {\r\n box-shadow:\r\n 0 0 0 6px rgba(155, 194, 239, .05),\r\n 0 0 14px rgba(155, 194, 239, .90);\r\n }\r\n}\r\n\r\n/* ============================================================\r\n Hero strip\r\n ============================================================ */\r\n.hero-strip {\r\n display: flex;\r\n align-items: center;\r\n gap: 24px;\r\n padding: 14px 24px;\r\n border-bottom: 1px solid var(--rule);\r\n background: linear-gradient(90deg, rgba(27, 58, 120, .10) 0%, rgba(4, 8, 26, 0) 100%);\r\n position: relative;\r\n overflow: hidden;\r\n}\r\n\r\n.hero-spacer {\r\n flex: 1;\r\n}\r\n\r\n.date-block {\r\n display: flex;\r\n align-items: center;\r\n gap: 12px;\r\n}\r\n\r\n.d-day {\r\n font-family: var(--font-sans);\r\n font-weight: 500;\r\n font-size: 38px;\r\n line-height: 1;\r\n letter-spacing: -0.04em;\r\n color: var(--mist);\r\n}\r\n\r\n.d-rest {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n color: var(--text-dim);\r\n}\r\n\r\n.d-rest .d-mute {\r\n color: var(--text-mute);\r\n}\r\n\r\n.active-block {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n text-align: right;\r\n max-width: 360px;\r\n overflow: hidden;\r\n}\r\n\r\n.ab-label {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n}\r\n\r\n.ab-value {\r\n font-family: var(--font-mono);\r\n font-size: 12px;\r\n color: var(--mist);\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n max-width: 360px;\r\n}\r\n\r\n/* ============================================================\r\n Main grid\r\n ============================================================ */\r\n.grid-main {\r\n display: grid;\r\n grid-template-columns: 260px 1fr 340px;\r\n gap: 16px;\r\n padding: 16px 24px;\r\n min-height: 0;\r\n z-index: 10;\r\n}\r\n\r\n.col-left,\r\n.col-center,\r\n.col-right {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 12px;\r\n min-height: 0;\r\n}\r\n\r\n/* ============================================================\r\n Panels / cards \u2014 darker\r\n ============================================================ */\r\n.panel,\r\n.card {\r\n position: relative;\r\n border: 1px solid var(--rule);\r\n border-radius: 14px;\r\n background: var(--surface-1);\r\n padding: 14px 16px;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 12px;\r\n min-height: 0;\r\n transition: border-color 180ms ease, background 180ms ease;\r\n}\r\n\r\n.card.has-tooltip {\r\n cursor: help;\r\n}\r\n\r\n.card.has-tooltip:hover {\r\n border-color: var(--rule-hover);\r\n background: var(--surface-2);\r\n}\r\n\r\n.card-head {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: baseline;\r\n gap: 12px;\r\n}\r\n\r\n.card-eyebrow {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n}\r\n\r\n.card-eyebrow em {\r\n font-family: var(--font-serif);\r\n font-style: italic;\r\n font-weight: 400;\r\n font-size: 12px;\r\n color: var(--sky);\r\n letter-spacing: 0;\r\n text-transform: none;\r\n}\r\n\r\n.card-meta {\r\n font-family: var(--font-mono);\r\n font-size: 9px;\r\n letter-spacing: 0.12em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n}\r\n\r\n/* Legend panel */\r\n.panel {\r\n padding: 14px 14px 16px;\r\n gap: 14px;\r\n flex-shrink: 0;\r\n}\r\n\r\n.p-head {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n padding-bottom: 10px;\r\n border-bottom: 1px solid var(--rule-2);\r\n}\r\n\r\n.p-section {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n}\r\n\r\n.p-section+.p-section {\r\n padding-top: 12px;\r\n border-top: 1px solid var(--rule-2);\r\n}\r\n\r\n.ps-head {\r\n font-family: var(--font-mono);\r\n font-size: 9px;\r\n letter-spacing: 0.16em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n margin-bottom: 4px;\r\n}\r\n\r\n.check {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 8px;\r\n padding: 3px 6px;\r\n font-family: var(--font-mono);\r\n font-size: 11px;\r\n color: var(--text-dim);\r\n letter-spacing: 0.02em;\r\n}\r\n\r\nbutton.check {\r\n border: 0;\r\n background: transparent;\r\n width: 100%;\r\n text-align: left;\r\n}\r\n\r\n.check-clickable {\r\n cursor: pointer;\r\n border-radius: 6px;\r\n padding: 5px 6px;\r\n transition: background 140ms, color 140ms, transform 140ms;\r\n}\r\n\r\n.check-clickable .pf-arrow {\r\n margin-left: auto;\r\n color: var(--text-mute);\r\n font-family: var(--font-mono);\r\n font-size: 12px;\r\n transition: color 140ms, transform 140ms;\r\n}\r\n\r\n.check-clickable:hover {\r\n background: rgba(155, 194, 239, .07);\r\n color: var(--mist);\r\n}\r\n\r\n.check-clickable:hover .pf-arrow {\r\n color: var(--sky);\r\n transform: translateX(2px);\r\n}\r\n\r\n.dot-sq {\r\n width: 8px;\r\n height: 8px;\r\n border-radius: 2px;\r\n background: var(--text-mute);\r\n flex-shrink: 0;\r\n}\r\n\r\n.dot-sq.opus {\r\n background: var(--c-opus);\r\n}\r\n\r\n.dot-sq.sonnet {\r\n background: var(--c-sonnet);\r\n}\r\n\r\n.dot-sq.haiku {\r\n background: var(--c-haiku);\r\n}\r\n\r\n.dot-sq.unknown {\r\n background: var(--c-unknown);\r\n}\r\n\r\n.proj-filter {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 1px;\r\n max-height: 90px;\r\n overflow-y: auto;\r\n}\r\n\r\n.pf-name {\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n max-width: 140px;\r\n}\r\n\r\n/* ============================================================\r\n Donut card (model usage)\r\n ============================================================ */\r\n.donut-card {\r\n flex: 1;\r\n gap: 10px;\r\n}\r\n\r\n.donut-wrap {\r\n position: relative;\r\n width: 140px;\r\n height: 140px;\r\n margin: 4px auto 0;\r\n}\r\n\r\n.donut {\r\n width: 100%;\r\n height: 100%;\r\n display: block;\r\n}\r\n\r\n.donut-track {\r\n fill: none;\r\n stroke: rgba(155, 194, 239, .07);\r\n stroke-width: 14;\r\n}\r\n\r\n.donut-seg {\r\n transition: stroke-dashoffset 400ms ease, stroke-dasharray 400ms ease;\r\n}\r\n\r\n.donut-center {\r\n position: absolute;\r\n inset: 0;\r\n display: flex;\r\n flex-direction: column;\r\n justify-content: center;\r\n align-items: center;\r\n pointer-events: none;\r\n}\r\n\r\n.donut-total {\r\n font-family: var(--font-sans);\r\n font-weight: 500;\r\n font-size: 26px;\r\n letter-spacing: -0.02em;\r\n color: var(--mist);\r\n line-height: 1;\r\n}\r\n\r\n.donut-total-k {\r\n font-family: var(--font-mono);\r\n font-size: 9px;\r\n letter-spacing: 0.16em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n margin-top: 4px;\r\n}\r\n\r\n.donut-legend {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 4px;\r\n margin-top: 6px;\r\n padding-top: 10px;\r\n border-top: 1px solid var(--rule-2);\r\n}\r\n\r\n.dl-row {\r\n display: grid;\r\n grid-template-columns: auto 1fr auto;\r\n align-items: center;\r\n gap: 8px;\r\n font-family: var(--font-mono);\r\n font-size: 11px;\r\n color: var(--text-dim);\r\n}\r\n\r\n.dl-dot {\r\n width: 8px;\r\n height: 8px;\r\n border-radius: 2px;\r\n}\r\n\r\n.dl-name {\r\n color: var(--text-dim);\r\n}\r\n\r\n.dl-pct {\r\n color: var(--mist);\r\n font-weight: 500;\r\n}\r\n\r\n/* ============================================================\r\n Center column \u2014 Metric strip (no card chrome, divider-separated)\r\n ============================================================ */\r\n.metric-strip {\r\n display: grid;\r\n grid-template-columns: repeat(5, 1fr);\r\n border: 1px solid var(--rule);\r\n border-radius: 14px;\r\n background: var(--surface-1);\r\n overflow: hidden;\r\n flex-shrink: 0;\r\n}\r\n\r\n.metric-item {\r\n padding: 14px 18px;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 8px;\r\n cursor: help;\r\n border-right: 1px solid var(--rule-2);\r\n transition: background 200ms ease;\r\n min-width: 0;\r\n}\r\n.metric-item:last-child { border-right: 0; }\r\n.metric-item:hover { background: var(--surface-2); }\r\n\r\n.m-label {\r\n font-family: var(--font-mono);\r\n font-size: 9px;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n}\r\n\r\n.m-value {\r\n font-family: var(--font-sans);\r\n font-weight: 500;\r\n font-size: 26px;\r\n letter-spacing: -0.025em;\r\n color: var(--mist);\r\n line-height: 1;\r\n}\r\n\r\n.m-value em {\r\n font-family: var(--font-serif);\r\n font-style: italic;\r\n font-weight: 400;\r\n color: var(--sky);\r\n letter-spacing: -0.005em;\r\n}\r\n\r\n/* ============================================================\r\n Savings card\r\n ============================================================ */\r\n.card.savings {\r\n flex-shrink: 0;\r\n gap: 12px;\r\n background:\r\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 50%),\r\n var(--surface-1);\r\n border-color: rgba(123, 255, 199, .18);\r\n}\r\n\r\n.card.savings:hover {\r\n border-color: rgba(123, 255, 199, .32);\r\n}\r\n\r\n.savings-body {\r\n display: grid;\r\n grid-template-columns: auto 1fr;\r\n align-items: center;\r\n gap: 18px;\r\n}\r\n\r\n.savings-figure {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n}\r\n\r\n.savings-money {\r\n font-family: var(--font-sans);\r\n font-weight: 500;\r\n font-size: 34px;\r\n letter-spacing: -0.035em;\r\n line-height: 1;\r\n color: var(--money);\r\n}\r\n\r\n.savings-tokens {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.10em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n margin-top: 4px;\r\n}\r\n\r\n.savings-bar {\r\n position: relative;\r\n height: 8px;\r\n border-radius: 999px;\r\n overflow: hidden;\r\n background: var(--surface-3);\r\n display: flex;\r\n}\r\n\r\n.savings-actual {\r\n height: 100%;\r\n background: rgba(215, 230, 247, .55);\r\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\r\n}\r\n\r\n.savings-saved {\r\n height: 100%;\r\n background: var(--signal-green);\r\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\r\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12);\r\n}\r\n\r\n.savings-legend {\r\n grid-column: 2;\r\n display: flex;\r\n justify-content: center;\r\n align-items: center;\r\n gap: 24px;\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.08em;\r\n color: var(--text-mute);\r\n margin-top: 8px;\r\n}\r\n\r\n.sl-row {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n}\r\n\r\n.sl-row b {\r\n color: var(--mist);\r\n font-weight: 500;\r\n letter-spacing: 0.04em;\r\n}\r\n\r\n.sl-dot {\r\n width: 8px;\r\n height: 8px;\r\n border-radius: 2px;\r\n}\r\n\r\n.sl-dot.actual {\r\n background: var(--mist);\r\n}\r\n\r\n.sl-dot.saved {\r\n background: var(--signal-green);\r\n}\r\n\r\n/* ============================================================\r\n Recent turns table\r\n ============================================================ */\r\n.turns-card {\r\n flex: 1;\r\n padding: 0;\r\n overflow: hidden;\r\n}\r\n\r\n.turns-card .card-head {\r\n padding: 14px 16px 10px;\r\n border-bottom: 1px solid var(--rule-2);\r\n}\r\n\r\n.turns-scroll {\r\n flex: 1;\r\n overflow-y: auto;\r\n min-height: 0;\r\n}\r\n\r\n.turns-table {\r\n width: 100%;\r\n border-collapse: collapse;\r\n}\r\n\r\n.turns-table thead th {\r\n position: sticky;\r\n top: 0;\r\n background: var(--ink);\r\n padding: 9px 16px;\r\n text-align: left;\r\n font-family: var(--font-mono);\r\n font-size: 9px;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n font-weight: 500;\r\n border-bottom: 1px solid var(--rule);\r\n z-index: 1;\r\n}\r\n\r\n.turns-table thead th.num {\r\n text-align: right;\r\n}\r\n\r\n.turns-table tbody td {\r\n padding: 8px 16px;\r\n border-bottom: 1px solid var(--rule-2);\r\n color: var(--text-dim);\r\n font-size: 12px;\r\n}\r\n\r\n.turns-table tbody td.num {\r\n text-align: right;\r\n font-family: var(--font-mono);\r\n}\r\n\r\n.turns-table tbody td.cost {\r\n color: var(--money);\r\n font-family: var(--font-mono);\r\n font-weight: 500;\r\n}\r\n\r\n.turns-table tbody td.ts {\r\n font-family: var(--font-mono);\r\n font-size: 11px;\r\n color: var(--text-mute);\r\n}\r\n\r\n.turns-table tbody td.proj {\r\n color: var(--mist);\r\n}\r\n\r\n.turns-table tbody tr:hover {\r\n background: rgba(155, 194, 239, .03);\r\n}\r\n\r\n.turns-table tbody tr:last-child td {\r\n border-bottom: 0;\r\n}\r\n\r\n/* Model pills */\r\n.model-pill {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n padding: 2px 8px;\r\n border-radius: 999px;\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.04em;\r\n border: 1px solid var(--rule);\r\n background: rgba(4, 8, 26, .5);\r\n color: var(--mist);\r\n}\r\n\r\n.model-pill .sq {\r\n width: 6px;\r\n height: 6px;\r\n border-radius: 2px;\r\n background: var(--text-mute);\r\n}\r\n\r\n.model-pill.opus {\r\n color: #FF8A66;\r\n border-color: rgba(255, 99, 56, .32);\r\n background: rgba(255, 99, 56, .07);\r\n}\r\n\r\n.model-pill.opus .sq {\r\n background: var(--c-opus);\r\n}\r\n\r\n.model-pill.sonnet {\r\n color: #FFC766;\r\n border-color: rgba(255, 185, 56, .32);\r\n background: rgba(255, 185, 56, .07);\r\n}\r\n\r\n.model-pill.sonnet .sq {\r\n background: var(--c-sonnet);\r\n}\r\n\r\n.model-pill.haiku {\r\n color: #A878FF;\r\n border-color: rgba(116, 56, 255, .42);\r\n background: rgba(116, 56, 255, .10);\r\n}\r\n\r\n.model-pill.haiku .sq {\r\n background: var(--c-haiku);\r\n}\r\n\r\n.model-pill.unknown {\r\n color: #5BDDF7;\r\n border-color: rgba(18, 203, 245, .32);\r\n background: rgba(18, 203, 245, .07);\r\n font-style: italic;\r\n}\r\n\r\n.model-pill.unknown .sq {\r\n background: var(--c-unknown);\r\n}\r\n\r\n/* ============================================================\r\n Right column \u2014 Cost hero\r\n ============================================================ */\r\n.cost-hero {\r\n position: relative;\r\n overflow: hidden;\r\n background:\r\n radial-gradient(120% 80% at 50% 110%, rgba(44, 93, 184, .18) 0%, rgba(4, 8, 26, .20) 60%),\r\n var(--surface-1);\r\n padding: 16px 18px 18px;\r\n gap: 10px;\r\n flex-shrink: 0;\r\n}\r\n\r\n.big-money {\r\n position: relative;\r\n font-family: var(--font-sans);\r\n font-weight: 500;\r\n font-size: 42px;\r\n letter-spacing: -0.035em;\r\n line-height: 1;\r\n color: var(--money);\r\n margin-top: 2px;\r\n}\r\n\r\n.big-money em {\r\n font-family: inherit;\r\n font-style: normal;\r\n font-weight: inherit;\r\n color: inherit;\r\n letter-spacing: inherit;\r\n opacity: 1;\r\n}\r\n\r\n.cost-sub {\r\n position: relative;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 6px;\r\n margin-top: 4px;\r\n padding-top: 10px;\r\n border-top: 1px solid var(--rule-2);\r\n}\r\n\r\n.cs-row {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: baseline;\r\n font-family: var(--font-mono);\r\n font-size: 11px;\r\n}\r\n\r\n.cs-k {\r\n font-size: 10px;\r\n letter-spacing: 0.12em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n}\r\n\r\n.cs-v {\r\n color: var(--mist);\r\n}\r\n\r\n/* ============================================================\r\n Moat card\r\n ============================================================ */\r\n.moat {\r\n flex: 1;\r\n gap: 8px;\r\n}\r\n\r\n.moat-value {\r\n font-family: var(--font-sans);\r\n font-weight: 500;\r\n font-size: 34px;\r\n letter-spacing: -0.03em;\r\n line-height: 1;\r\n color: var(--mist);\r\n margin-top: 2px;\r\n}\r\n\r\n.moat-value em {\r\n font-family: var(--font-serif);\r\n font-style: italic;\r\n font-weight: 400;\r\n font-size: 18px;\r\n color: var(--sky);\r\n letter-spacing: 0;\r\n margin-left: 6px;\r\n}\r\n\r\n.gate-mini {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 4px;\r\n margin-top: 6px;\r\n padding-top: 10px;\r\n border-top: 1px solid var(--rule-2);\r\n overflow-y: auto;\r\n flex: 1;\r\n min-height: 0;\r\n}\r\n\r\n.gate-row {\r\n display: grid;\r\n grid-template-columns: auto auto 1fr;\r\n align-items: center;\r\n gap: 8px;\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n color: var(--text-dim);\r\n padding: 3px 0;\r\n}\r\n\r\n.gate-row .g-ts {\r\n color: var(--text-mute);\r\n font-size: 9px;\r\n min-width: 38px;\r\n}\r\n\r\n.gate-row .g-decision {\r\n font-size: 9px;\r\n letter-spacing: 0.12em;\r\n text-transform: uppercase;\r\n padding: 2px 6px;\r\n border-radius: 999px;\r\n}\r\n\r\n.gate-row .g-decision.block {\r\n color: var(--signal-rose);\r\n background: rgba(220, 90, 90, .06);\r\n}\r\n\r\n.gate-row .g-decision.allow {\r\n color: var(--text-mute);\r\n background: rgba(155, 194, 239, .03);\r\n}\r\n\r\n.gate-row .g-q {\r\n color: var(--mist);\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n\r\n/* ============================================================\r\n Tooltips\r\n ============================================================ */\r\n.has-tooltip {\r\n position: relative;\r\n}\r\n\r\n/* Global JS-positioned tooltip \u2014 viewport-clamped */\r\n.global-tooltip {\r\n position: fixed;\r\n top: 0;\r\n left: 0;\r\n background: linear-gradient(180deg, rgba(18, 37, 73, .98), rgba(10, 21, 48, .98));\r\n color: var(--mist);\r\n border: 1px solid var(--rule-hover);\r\n border-radius: 12px;\r\n padding: 14px 16px;\r\n font-family: var(--font-sans);\r\n font-size: 15px;\r\n font-weight: 400;\r\n text-transform: none;\r\n letter-spacing: 0;\r\n white-space: normal;\r\n width: 320px;\r\n max-width: calc(100vw - 24px);\r\n text-align: left;\r\n line-height: 1.55;\r\n box-shadow: 0 16px 36px rgba(0, 0, 0, .7);\r\n backdrop-filter: blur(10px);\r\n z-index: 99999;\r\n opacity: 0;\r\n pointer-events: none;\r\n transform: translateY(6px);\r\n transition: opacity 180ms ease, transform 180ms ease;\r\n}\r\n\r\n.global-tooltip.on {\r\n opacity: 1;\r\n transform: translateY(0);\r\n}\r\n\r\n/* ============================================================\r\n Footer\r\n ============================================================ */\r\n.foot {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n padding: 8px 24px;\r\n border-top: 1px solid var(--rule);\r\n background: linear-gradient(0deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\r\n backdrop-filter: blur(10px);\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.12em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n}\r\n\r\n.foot em {\r\n font-family: var(--font-serif);\r\n font-style: italic;\r\n text-transform: none;\r\n letter-spacing: 0;\r\n color: var(--sky);\r\n font-size: 12px;\r\n}\r\n\r\n.foot .mono {\r\n color: var(--text-dim);\r\n text-transform: none;\r\n letter-spacing: 0.04em;\r\n}\r\n\r\n/* ============================================================\r\n Empty state\r\n ============================================================ */\r\n.empty {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.06em;\r\n color: var(--text-mute);\r\n text-align: center;\r\n padding: 16px 8px;\r\n font-style: italic;\r\n text-transform: none;\r\n}\r\n\r\n/* Scrollbar styling */\r\n.turns-scroll::-webkit-scrollbar,\r\n.proj-filter::-webkit-scrollbar,\r\n.gate-mini::-webkit-scrollbar {\r\n width: 6px;\r\n}\r\n\r\n.turns-scroll::-webkit-scrollbar-thumb,\r\n.proj-filter::-webkit-scrollbar-thumb,\r\n.gate-mini::-webkit-scrollbar-thumb {\r\n background: var(--rule);\r\n border-radius: 999px;\r\n}\r\n\r\n.turns-scroll::-webkit-scrollbar-track,\r\n.proj-filter::-webkit-scrollbar-track,\r\n.gate-mini::-webkit-scrollbar-track {\r\n background: transparent;\r\n}\r\n\r\n.hidden {\r\n display: none !important;\r\n}\r\n\r\n/* ============================================================\r\n Staggered cascade on first paint (one-time, MOTION 6)\r\n ============================================================ */\r\n@keyframes cascade-in {\r\n from { opacity: 0; transform: translateY(10px); }\r\n to { opacity: 1; transform: translateY(0); }\r\n}\r\n\r\n@media (prefers-reduced-motion: no-preference) {\r\n .col-left > *,\r\n .col-center > *,\r\n .col-right > * {\r\n opacity: 0;\r\n animation: cascade-in 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards;\r\n will-change: transform, opacity;\r\n }\r\n .col-left > *:nth-child(1) { animation-delay: 0ms; }\r\n .col-left > *:nth-child(2) { animation-delay: 120ms; }\r\n .col-center > *:nth-child(1) { animation-delay: 40ms; }\r\n .col-center > *:nth-child(2) { animation-delay: 140ms; }\r\n .col-center > *:nth-child(3) { animation-delay: 240ms; }\r\n .col-right > *:nth-child(1) { animation-delay: 80ms; }\r\n .col-right > *:nth-child(2) { animation-delay: 200ms; }\r\n\r\n /* Clear will-change after animation completes */\r\n .col-left > *,\r\n .col-center > *,\r\n .col-right > * {\r\n animation-fill-mode: forwards;\r\n }\r\n}\r\n\r\n/* ============================================================\r\n Source / basis annotations\r\n ============================================================ */\r\n.card-source {\r\n font-family: var(--font-mono);\r\n font-size: 9px;\r\n letter-spacing: 0.08em;\r\n text-transform: lowercase;\r\n color: var(--text-mute);\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n margin-top: auto;\r\n padding-top: 8px;\r\n border-top: 1px solid var(--rule-2);\r\n width: 100%;\r\n}\r\n\r\n.src-badge {\r\n font-family: var(--font-mono);\r\n font-size: 8px;\r\n font-weight: 500;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n padding: 2px 6px;\r\n border-radius: 4px;\r\n flex-shrink: 0;\r\n}\r\n\r\n.src-badge.verified {\r\n color: var(--signal-green);\r\n background: rgba(123, 255, 199, .08);\r\n border: 1px solid rgba(123, 255, 199, .25);\r\n}\r\n\r\n.src-badge.estimated,\r\n.src-badge.estimated.floor {\r\n color: var(--signal-amber);\r\n background: rgba(255, 185, 56, .10);\r\n border: 1px solid rgba(255, 185, 56, .30);\r\n}\r\n\r\n.src-badge.priced {\r\n color: var(--signal-cyan);\r\n background: rgba(155, 194, 239, .08);\r\n border: 1px solid rgba(155, 194, 239, .25);\r\n}\r\n\r\n/* Eyebrow that contains a badge */\r\n.card-eyebrow .src-badge {\r\n margin-left: 4px;\r\n}\r\n\r\n/* Savings audit row \u2014 live formula reveal */\r\n.savings-audit {\r\n margin-top: 10px;\r\n padding: 10px 12px;\r\n border: 1px dashed rgba(255, 185, 56, .25);\r\n border-radius: 8px;\r\n background: rgba(255, 185, 56, .04);\r\n font-family: var(--font-mono);\r\n font-size: 10.5px;\r\n letter-spacing: 0.04em;\r\n color: var(--text-mute);\r\n text-align: center;\r\n}\r\n\r\n.savings-audit b {\r\n color: var(--text-dim);\r\n font-weight: 500;\r\n}\r\n\r\n.savings-audit .audit-result {\r\n color: var(--money);\r\n}\r\n\r\n/* ============================================================\r\n FAQ dialog\r\n ============================================================ */\r\n.dialog.dialog-faq {\r\n max-width: min(80vw, 1100px);\r\n width: 100%;\r\n max-height: 86vh;\r\n display: flex;\r\n flex-direction: column;\r\n padding: 28px 32px 24px;\r\n gap: 6px;\r\n}\r\n\r\n.dialog.dialog-faq .dialog-path {\r\n margin-bottom: 4px;\r\n word-break: normal;\r\n overflow-wrap: anywhere;\r\n}\r\n\r\n.faq-content {\r\n flex: 1 1 auto;\r\n min-height: 0;\r\n overflow-y: auto;\r\n margin-top: 18px;\r\n padding-right: 8px;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 10px;\r\n}\r\n\r\n.faq-content::-webkit-scrollbar {\r\n width: 6px;\r\n}\r\n\r\n.faq-content::-webkit-scrollbar-thumb {\r\n background: var(--rule);\r\n border-radius: 999px;\r\n}\r\n\r\n.faq-content::-webkit-scrollbar-track {\r\n background: transparent;\r\n}\r\n\r\n.faq-content details {\r\n border: 1px solid var(--rule);\r\n border-radius: 12px;\r\n background: var(--surface-1);\r\n overflow: hidden;\r\n transition: background 180ms, border-color 180ms;\r\n flex-shrink: 0;\r\n}\r\n\r\n.faq-content details:hover {\r\n border-color: rgba(155, 194, 239, .22);\r\n}\r\n\r\n.faq-content details[open] {\r\n background: var(--surface-2);\r\n border-color: var(--rule-hover);\r\n}\r\n\r\n.faq-content summary {\r\n cursor: pointer;\r\n padding: 14px 20px;\r\n font-family: var(--font-sans);\r\n font-size: 14px;\r\n font-weight: 500;\r\n color: var(--mist);\r\n list-style: none;\r\n display: flex;\r\n align-items: center;\r\n gap: 12px;\r\n user-select: none;\r\n}\r\n\r\n.faq-content summary::-webkit-details-marker {\r\n display: none;\r\n}\r\n\r\n.faq-content summary::before {\r\n content: "\u203A";\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 16px;\r\n height: 16px;\r\n color: var(--text-mute);\r\n font-family: var(--font-mono);\r\n font-size: 14px;\r\n transition: transform 220ms ease, color 220ms ease;\r\n}\r\n\r\n.faq-content details[open] summary::before {\r\n transform: rotate(90deg);\r\n color: var(--sky);\r\n}\r\n\r\n.faq-content .faq-body {\r\n padding: 0 22px 20px 46px;\r\n color: var(--text-dim);\r\n font-size: 13.5px;\r\n line-height: 1.7;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 10px;\r\n}\r\n\r\n.faq-content .faq-body p {\r\n margin: 0;\r\n}\r\n\r\n.faq-content .faq-body ul {\r\n margin: 0;\r\n padding-left: 20px;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 6px;\r\n}\r\n\r\n.faq-content .faq-body li {\r\n margin: 0;\r\n}\r\n\r\n.faq-content .faq-body b,\r\n.faq-content .faq-body strong {\r\n color: var(--mist);\r\n font-weight: 500;\r\n}\r\n\r\n.faq-content .faq-body code {\r\n font-family: var(--font-mono);\r\n font-size: 12px;\r\n background: rgba(155, 194, 239, .08);\r\n padding: 2px 6px;\r\n border-radius: 4px;\r\n color: var(--mist);\r\n border: 1px solid rgba(155, 194, 239, .12);\r\n word-break: break-word;\r\n}\r\n\r\n.faq-content .faq-body a {\r\n color: var(--blue-bright);\r\n text-decoration: underline;\r\n text-decoration-color: rgba(92, 143, 230, .40);\r\n text-underline-offset: 3px;\r\n transition: color 140ms, text-decoration-color 140ms;\r\n}\r\n\r\n.faq-content .faq-body a:hover {\r\n color: var(--mist);\r\n text-decoration-color: var(--sky);\r\n}\r\n\r\n.faq-content .faq-body table {\r\n width: 100%;\r\n border-collapse: collapse;\r\n margin: 4px 0;\r\n font-size: 13px;\r\n table-layout: fixed;\r\n}\r\n\r\n.faq-content .faq-body thead td {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.10em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n padding-bottom: 6px;\r\n border-bottom: 1px solid var(--rule);\r\n font-weight: 500;\r\n}\r\n\r\n.faq-content .faq-body td {\r\n padding: 9px 10px;\r\n border-bottom: 1px solid var(--rule-2);\r\n vertical-align: top;\r\n word-break: break-word;\r\n}\r\n\r\n.faq-content .faq-body tr:last-child td {\r\n border-bottom: 0;\r\n}\r\n\r\n.faq-content .faq-body td:first-child {\r\n color: var(--text-dim);\r\n width: 38%;\r\n}\r\n\r\n.faq-content .faq-body td:first-child code {\r\n font-size: 11.5px;\r\n}\r\n\r\n.faq-content .faq-body .formula-box {\r\n font-family: var(--font-mono);\r\n font-size: 12.5px;\r\n background: rgba(255, 185, 56, .06);\r\n padding: 12px 14px;\r\n border-radius: 8px;\r\n border: 1px dashed rgba(255, 185, 56, .30);\r\n color: var(--mist);\r\n letter-spacing: 0.02em;\r\n}\r\n\r\n.faq-content .faq-body .link-list {\r\n list-style: none;\r\n padding-left: 0;\r\n}\r\n\r\n.faq-content .faq-body .link-list li {\r\n padding-left: 18px;\r\n position: relative;\r\n}\r\n\r\n.faq-content .faq-body .link-list li::before {\r\n content: "\u203A";\r\n position: absolute;\r\n left: 0;\r\n color: var(--sky);\r\n font-family: var(--font-mono);\r\n}\r\n\r\n.faq-content .faq-body .warning {\r\n margin-top: 14px;\r\n padding: 12px 14px;\r\n background: rgba(255, 185, 56, .06);\r\n border: 1px solid rgba(255, 185, 56, .25);\r\n border-left: 3px solid var(--signal-amber);\r\n border-radius: 8px;\r\n font-size: 12.5px;\r\n color: var(--text-dim);\r\n}\r\n\r\n.faq-content .faq-body .warning .icon {\r\n color: var(--signal-amber);\r\n margin-right: 8px;\r\n font-weight: 500;\r\n}\r\n\r\n/* ============================================================\r\n Project dialog\r\n ============================================================ */\r\n.dialog-backdrop {\r\n position: fixed;\r\n inset: 0;\r\n background: rgba(4, 8, 26, .78);\r\n backdrop-filter: blur(10px);\r\n z-index: 10000;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: 24px;\r\n animation: dlg-fade 180ms ease;\r\n}\r\n\r\n@keyframes dlg-fade {\r\n from {\r\n opacity: 0;\r\n }\r\n\r\n to {\r\n opacity: 1;\r\n }\r\n}\r\n\r\n.dialog {\r\n position: relative;\r\n width: 100%;\r\n max-width: 520px;\r\n background:\r\n radial-gradient(120% 80% at 50% 0%, rgba(44, 93, 184, .22) 0%, rgba(4, 8, 26, .20) 60%),\r\n linear-gradient(180deg, rgba(18, 37, 73, .88) 0%, rgba(10, 21, 48, .96) 100%);\r\n border: 1px solid var(--rule-hover);\r\n border-radius: 18px;\r\n padding: 28px 32px 32px;\r\n box-shadow:\r\n 0 30px 80px -20px rgba(0, 0, 0, .7),\r\n inset 0 1px 0 rgba(255, 255, 255, .04);\r\n animation: dlg-rise 220ms cubic-bezier(.2, .7, .2, 1);\r\n}\r\n\r\n@keyframes dlg-rise {\r\n from {\r\n opacity: 0;\r\n transform: translateY(8px) scale(.98);\r\n }\r\n\r\n to {\r\n opacity: 1;\r\n transform: translateY(0) scale(1);\r\n }\r\n}\r\n\r\n.dialog-close {\r\n position: absolute;\r\n top: 14px;\r\n right: 14px;\r\n width: 30px;\r\n height: 30px;\r\n border-radius: 50%;\r\n color: var(--text-mute);\r\n font-size: 22px;\r\n line-height: 1;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n transition: background 180ms, color 180ms;\r\n}\r\n\r\n.dialog-close:hover {\r\n background: rgba(155, 194, 239, .10);\r\n color: var(--mist);\r\n}\r\n\r\n.dialog-eyebrow {\r\n font-family: var(--font-mono);\r\n font-size: 10px;\r\n letter-spacing: 0.14em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n margin-bottom: 10px;\r\n}\r\n\r\n.dialog-eyebrow em {\r\n font-family: var(--font-serif);\r\n font-style: italic;\r\n font-weight: 400;\r\n font-size: 12px;\r\n color: var(--sky);\r\n letter-spacing: 0;\r\n text-transform: none;\r\n}\r\n\r\n.dialog-name {\r\n font-family: var(--font-sans);\r\n font-weight: 500;\r\n font-size: 28px;\r\n letter-spacing: -0.025em;\r\n color: var(--mist);\r\n line-height: 1.1;\r\n}\r\n\r\n.dialog-path {\r\n font-family: var(--font-mono);\r\n font-size: 11px;\r\n color: var(--text-mute);\r\n margin-top: 6px;\r\n word-break: break-all;\r\n}\r\n\r\n.dialog-grid {\r\n display: grid;\r\n grid-template-columns: repeat(2, 1fr);\r\n gap: 18px 24px;\r\n margin-top: 22px;\r\n padding-top: 20px;\r\n border-top: 1px solid var(--rule-2);\r\n}\r\n\r\n.dg-cell {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 4px;\r\n}\r\n\r\n.dg-k {\r\n font-family: var(--font-mono);\r\n font-size: 9px;\r\n letter-spacing: 0.16em;\r\n text-transform: uppercase;\r\n color: var(--text-mute);\r\n}\r\n\r\n.dg-v {\r\n font-family: var(--font-sans);\r\n font-weight: 500;\r\n font-size: 22px;\r\n letter-spacing: -0.02em;\r\n color: var(--mist);\r\n line-height: 1;\r\n}\r\n\r\n.dg-v.money {\r\n color: var(--money);\r\n}\r\n\r\n.dg-v-sm {\r\n font-size: 13px;\r\n font-family: var(--font-mono);\r\n font-weight: 400;\r\n color: var(--text-dim);\r\n letter-spacing: 0;\r\n}';
1281
1281
 
1282
1282
  // src/dashboard/server.ts
1283
1283
  var FALLBACK_RANGE = 9;
@@ -1420,149 +1420,149 @@ exit 0
1420
1420
  `;
1421
1421
 
1422
1422
  // src/hooks/scripts/stop.ps1
1423
- var stop_default = `# Stop hook \u2014 Windows PowerShell.
1424
- # Reads Claude's transcript JSONL from $hookInput.transcript_path, sums
1425
- # usage.* token counts across all assistant turns since the last offset, and
1426
- # POSTs the totals to /log. Uses a per-transcript .stopoffset file to avoid
1427
- # double-counting on session resume.
1428
-
1429
- $ErrorActionPreference = "SilentlyContinue"
1430
-
1431
- $raw = [Console]::In.ReadToEnd()
1432
- if (-not $raw) { exit 0 }
1433
- try { $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop } catch { exit 0 }
1434
-
1435
- $transcript = $hookInput.transcript_path
1436
- if (-not $transcript -or -not (Test-Path $transcript)) { exit 0 }
1437
-
1438
- $portFile = Join-Path $PWD ".synthra-graph\\mcp_port"
1439
- if (-not (Test-Path $portFile)) { exit 0 }
1440
- $port = (Get-Content -Path $portFile -Raw).Trim()
1441
- if (-not $port) { exit 0 }
1442
-
1443
- $offsetFile = "$transcript.stopoffset"
1444
- $startOffset = 0
1445
- if (Test-Path $offsetFile) {
1446
- $val = (Get-Content -Path $offsetFile -Raw).Trim()
1447
- if ($val -match '^\\d+$') { $startOffset = [int]$val }
1448
- }
1449
-
1450
- $lines = Get-Content -Path $transcript
1451
- $inT = 0; $outT = 0; $cc = 0; $cr = 0; $model = ""
1452
- $lineNum = 0
1453
- foreach ($line in $lines) {
1454
- $lineNum++
1455
- if ($lineNum -le $startOffset) { continue }
1456
- if (-not $line) { continue }
1457
- try { $e = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue }
1458
- $usage = $e.message.usage
1459
- if (-not $usage) { continue }
1460
- $inT += [int]($usage.input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
1461
- $outT += [int]($usage.output_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
1462
- $cc += [int]($usage.cache_creation_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
1463
- $cr += [int]($usage.cache_read_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
1464
- if ($e.message.model) { $model = $e.message.model }
1465
- }
1466
-
1467
- Set-Content -Path $offsetFile -Value $lineNum -Encoding ASCII
1468
-
1469
- if ($inT -eq 0 -and $outT -eq 0) { exit 0 }
1470
-
1471
- $payload = @{
1472
- input_tokens = $inT
1473
- output_tokens = $outT
1474
- cache_creation_input_tokens = $cc
1475
- cache_read_input_tokens = $cr
1476
- model = $model
1477
- description = "synthra-stop-hook"
1478
- project = $PWD.Path
1479
- } | ConvertTo-Json -Compress
1480
-
1481
- try {
1482
- Invoke-RestMethod -Uri "http://127.0.0.1:$port/log" -Method POST \`
1483
- -Body $payload -ContentType "application/json" -TimeoutSec 3 | Out-Null
1484
- } catch {
1485
- # silent
1486
- }
1487
-
1488
- # Refresh CONTEXT.md from the branch-scoped store.
1489
- $ctxPayload = @{ transcript_path = $transcript } | ConvertTo-Json -Compress
1490
- try {
1491
- Invoke-RestMethod -Uri "http://127.0.0.1:$port/context-update" -Method POST \`
1492
- -Body $ctxPayload -ContentType "application/json" -TimeoutSec 3 | Out-Null
1493
- } catch {
1494
- # silent
1495
- }
1496
- exit 0
1423
+ var stop_default = `# Stop hook \u2014 Windows PowerShell.\r
1424
+ # Reads Claude's transcript JSONL from $hookInput.transcript_path, sums\r
1425
+ # usage.* token counts across all assistant turns since the last offset, and\r
1426
+ # POSTs the totals to /log. Uses a per-transcript .stopoffset file to avoid\r
1427
+ # double-counting on session resume.\r
1428
+ \r
1429
+ $ErrorActionPreference = "SilentlyContinue"\r
1430
+ \r
1431
+ $raw = [Console]::In.ReadToEnd()\r
1432
+ if (-not $raw) { exit 0 }\r
1433
+ try { $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop } catch { exit 0 }\r
1434
+ \r
1435
+ $transcript = $hookInput.transcript_path\r
1436
+ if (-not $transcript -or -not (Test-Path $transcript)) { exit 0 }\r
1437
+ \r
1438
+ $portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\r
1439
+ if (-not (Test-Path $portFile)) { exit 0 }\r
1440
+ $port = (Get-Content -Path $portFile -Raw).Trim()\r
1441
+ if (-not $port) { exit 0 }\r
1442
+ \r
1443
+ $offsetFile = "$transcript.stopoffset"\r
1444
+ $startOffset = 0\r
1445
+ if (Test-Path $offsetFile) {\r
1446
+ $val = (Get-Content -Path $offsetFile -Raw).Trim()\r
1447
+ if ($val -match '^\\d+$') { $startOffset = [int]$val }\r
1448
+ }\r
1449
+ \r
1450
+ $lines = Get-Content -Path $transcript\r
1451
+ $inT = 0; $outT = 0; $cc = 0; $cr = 0; $model = ""\r
1452
+ $lineNum = 0\r
1453
+ foreach ($line in $lines) {\r
1454
+ $lineNum++\r
1455
+ if ($lineNum -le $startOffset) { continue }\r
1456
+ if (-not $line) { continue }\r
1457
+ try { $e = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue }\r
1458
+ $usage = $e.message.usage\r
1459
+ if (-not $usage) { continue }\r
1460
+ $inT += [int]($usage.input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1461
+ $outT += [int]($usage.output_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1462
+ $cc += [int]($usage.cache_creation_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1463
+ $cr += [int]($usage.cache_read_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1464
+ if ($e.message.model) { $model = $e.message.model }\r
1465
+ }\r
1466
+ \r
1467
+ Set-Content -Path $offsetFile -Value $lineNum -Encoding ASCII\r
1468
+ \r
1469
+ if ($inT -eq 0 -and $outT -eq 0) { exit 0 }\r
1470
+ \r
1471
+ $payload = @{\r
1472
+ input_tokens = $inT\r
1473
+ output_tokens = $outT\r
1474
+ cache_creation_input_tokens = $cc\r
1475
+ cache_read_input_tokens = $cr\r
1476
+ model = $model\r
1477
+ description = "synthra-stop-hook"\r
1478
+ project = $PWD.Path\r
1479
+ } | ConvertTo-Json -Compress\r
1480
+ \r
1481
+ try {\r
1482
+ Invoke-RestMethod -Uri "http://127.0.0.1:$port/log" -Method POST \`\r
1483
+ -Body $payload -ContentType "application/json" -TimeoutSec 3 | Out-Null\r
1484
+ } catch {\r
1485
+ # silent\r
1486
+ }\r
1487
+ \r
1488
+ # Refresh CONTEXT.md from the branch-scoped store.\r
1489
+ $ctxPayload = @{ transcript_path = $transcript } | ConvertTo-Json -Compress\r
1490
+ try {\r
1491
+ Invoke-RestMethod -Uri "http://127.0.0.1:$port/context-update" -Method POST \`\r
1492
+ -Body $ctxPayload -ContentType "application/json" -TimeoutSec 3 | Out-Null\r
1493
+ } catch {\r
1494
+ # silent\r
1495
+ }\r
1496
+ exit 0\r
1497
1497
  `;
1498
1498
 
1499
1499
  // src/hooks/scripts/stop.sh
1500
- var stop_default2 = `#!/usr/bin/env bash
1501
- # Stop hook \u2014 bash. Reads transcript JSONL, sums usage.* across new lines,
1502
- # POSTs totals to /log. Uses a .stopoffset file to avoid double-counting.
1503
- # Requires \`jq\` for robust JSON parsing; falls back to silent no-op if absent.
1504
-
1505
- set +e
1506
-
1507
- INPUT=$(cat 2>/dev/null)
1508
- if [ -z "$INPUT" ]; then exit 0; fi
1509
-
1510
- TRANSCRIPT=$(printf '%s' "$INPUT" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')
1511
- if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then exit 0; fi
1512
-
1513
- PORT_FILE="$PWD/.synthra-graph/mcp_port"
1514
- if [ ! -f "$PORT_FILE" ]; then exit 0; fi
1515
- PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
1516
- if [ -z "$PORT" ]; then exit 0; fi
1517
-
1518
- if ! command -v jq >/dev/null 2>&1; then exit 0; fi
1519
-
1520
- OFFSET_FILE="\${TRANSCRIPT}.stopoffset"
1521
- START_OFFSET=0
1522
- if [ -f "$OFFSET_FILE" ]; then
1523
- START_OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null | tr -d '[:space:]')
1524
- case "$START_OFFSET" in ''|*[!0-9]*) START_OFFSET=0 ;; esac
1525
- fi
1526
-
1527
- TOTAL_LINES=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')
1528
- TOTAL_LINES=\${TOTAL_LINES:-0}
1529
-
1530
- if [ "$TOTAL_LINES" -le "$START_OFFSET" ]; then exit 0; fi
1531
-
1532
- USAGE=$(tail -n +$((START_OFFSET + 1)) "$TRANSCRIPT" 2>/dev/null \\
1533
- | jq -s '
1534
- map(select(.message.usage != null) | .message)
1535
- | reduce .[] as $m (
1536
- {in:0, out:0, cc:0, cr:0, model:""};
1537
- .in += ($m.usage.input_tokens // 0)
1538
- | .out += ($m.usage.output_tokens // 0)
1539
- | .cc += ($m.usage.cache_creation_input_tokens // 0)
1540
- | .cr += ($m.usage.cache_read_input_tokens // 0)
1541
- | .model = ($m.model // .model)
1542
- )
1543
- ' 2>/dev/null)
1544
-
1545
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE"
1546
-
1547
- IN=$(printf '%s' "$USAGE" | jq -r '.in // 0')
1548
- OUT=$(printf '%s' "$USAGE" | jq -r '.out // 0')
1549
- CC=$(printf '%s' "$USAGE" | jq -r '.cc // 0')
1550
- CR=$(printf '%s' "$USAGE" | jq -r '.cr // 0')
1551
- MODEL=$(printf '%s' "$USAGE" | jq -r '.model // ""')
1552
-
1553
- if [ "$IN" = "0" ] && [ "$OUT" = "0" ]; then exit 0; fi
1554
-
1555
- curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
1556
- --data "$(jq -nc --argjson i "$IN" --argjson o "$OUT" --argjson cc "$CC" --argjson cr "$CR" --arg m "$MODEL" --arg p "$PWD" \\
1557
- '{input_tokens:$i, output_tokens:$o, cache_creation_input_tokens:$cc, cache_read_input_tokens:$cr, model:$m, description:"synthra-stop-hook", project:$p}')" \\
1558
- "http://127.0.0.1:$PORT/log" >/dev/null 2>&1
1559
-
1560
- # Refresh CONTEXT.md from the branch-scoped store.
1561
- curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
1562
- --data "$(jq -nc --arg t "$TRANSCRIPT" '{transcript_path:$t}')" \\
1563
- "http://127.0.0.1:$PORT/context-update" >/dev/null 2>&1
1564
-
1565
- exit 0
1500
+ var stop_default2 = `#!/usr/bin/env bash\r
1501
+ # Stop hook \u2014 bash. Reads transcript JSONL, sums usage.* across new lines,\r
1502
+ # POSTs totals to /log. Uses a .stopoffset file to avoid double-counting.\r
1503
+ # Requires \`jq\` for robust JSON parsing; falls back to silent no-op if absent.\r
1504
+ \r
1505
+ set +e\r
1506
+ \r
1507
+ INPUT=$(cat 2>/dev/null)\r
1508
+ if [ -z "$INPUT" ]; then exit 0; fi\r
1509
+ \r
1510
+ TRANSCRIPT=$(printf '%s' "$INPUT" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')\r
1511
+ if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then exit 0; fi\r
1512
+ \r
1513
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"\r
1514
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi\r
1515
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')\r
1516
+ if [ -z "$PORT" ]; then exit 0; fi\r
1517
+ \r
1518
+ if ! command -v jq >/dev/null 2>&1; then exit 0; fi\r
1519
+ \r
1520
+ OFFSET_FILE="\${TRANSCRIPT}.stopoffset"\r
1521
+ START_OFFSET=0\r
1522
+ if [ -f "$OFFSET_FILE" ]; then\r
1523
+ START_OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null | tr -d '[:space:]')\r
1524
+ case "$START_OFFSET" in ''|*[!0-9]*) START_OFFSET=0 ;; esac\r
1525
+ fi\r
1526
+ \r
1527
+ TOTAL_LINES=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')\r
1528
+ TOTAL_LINES=\${TOTAL_LINES:-0}\r
1529
+ \r
1530
+ if [ "$TOTAL_LINES" -le "$START_OFFSET" ]; then exit 0; fi\r
1531
+ \r
1532
+ USAGE=$(tail -n +$((START_OFFSET + 1)) "$TRANSCRIPT" 2>/dev/null \\\r
1533
+ | jq -s '\r
1534
+ map(select(.message.usage != null) | .message)\r
1535
+ | reduce .[] as $m (\r
1536
+ {in:0, out:0, cc:0, cr:0, model:""};\r
1537
+ .in += ($m.usage.input_tokens // 0)\r
1538
+ | .out += ($m.usage.output_tokens // 0)\r
1539
+ | .cc += ($m.usage.cache_creation_input_tokens // 0)\r
1540
+ | .cr += ($m.usage.cache_read_input_tokens // 0)\r
1541
+ | .model = ($m.model // .model)\r
1542
+ )\r
1543
+ ' 2>/dev/null)\r
1544
+ \r
1545
+ printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE"\r
1546
+ \r
1547
+ IN=$(printf '%s' "$USAGE" | jq -r '.in // 0')\r
1548
+ OUT=$(printf '%s' "$USAGE" | jq -r '.out // 0')\r
1549
+ CC=$(printf '%s' "$USAGE" | jq -r '.cc // 0')\r
1550
+ CR=$(printf '%s' "$USAGE" | jq -r '.cr // 0')\r
1551
+ MODEL=$(printf '%s' "$USAGE" | jq -r '.model // ""')\r
1552
+ \r
1553
+ if [ "$IN" = "0" ] && [ "$OUT" = "0" ]; then exit 0; fi\r
1554
+ \r
1555
+ curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1556
+ --data "$(jq -nc --argjson i "$IN" --argjson o "$OUT" --argjson cc "$CC" --argjson cr "$CR" --arg m "$MODEL" --arg p "$PWD" \\\r
1557
+ '{input_tokens:$i, output_tokens:$o, cache_creation_input_tokens:$cc, cache_read_input_tokens:$cr, model:$m, description:"synthra-stop-hook", project:$p}')" \\\r
1558
+ "http://127.0.0.1:$PORT/log" >/dev/null 2>&1\r
1559
+ \r
1560
+ # Refresh CONTEXT.md from the branch-scoped store.\r
1561
+ curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1562
+ --data "$(jq -nc --arg t "$TRANSCRIPT" '{transcript_path:$t}')" \\\r
1563
+ "http://127.0.0.1:$PORT/context-update" >/dev/null 2>&1\r
1564
+ \r
1565
+ exit 0\r
1566
1566
  `;
1567
1567
 
1568
1568
  // src/hooks/installer.ts
@@ -2231,7 +2231,7 @@ async function buildGraph(root, parsed) {
2231
2231
  };
2232
2232
  }
2233
2233
  function buildSymbolIndex(graph) {
2234
- const out = {};
2234
+ const out = /* @__PURE__ */ Object.create(null);
2235
2235
  for (const node of graph.nodes) {
2236
2236
  if (node.kind !== "symbol") continue;
2237
2237
  const list = out[node.name] ?? (out[node.name] = []);
@@ -3042,7 +3042,8 @@ async function writeSymbolIndex(path, index) {
3042
3042
  await writeJson(path, index, true);
3043
3043
  }
3044
3044
  async function readSymbolIndex(path) {
3045
- return readJson(path);
3045
+ const parsed = await readJson(path);
3046
+ return Object.assign(/* @__PURE__ */ Object.create(null), parsed);
3046
3047
  }
3047
3048
 
3048
3049
  // src/cli/bootstrap.ts
@@ -4680,6 +4681,8 @@ async function dashboardCommand(rawPath) {
4680
4681
  import { mkdir as mkdir10, readFile as readFile14, writeFile as writeFile9 } from "fs/promises";
4681
4682
  import { homedir as homedir3 } from "os";
4682
4683
  import { join as join10 } from "path";
4684
+ import { createInterface } from "readline/promises";
4685
+ import spawn from "cross-spawn";
4683
4686
  var PKG_NAME = "@jefuriiij/synthra";
4684
4687
  var CACHE_DIR = join10(homedir3(), ".synthra");
4685
4688
  var CACHE_PATH = join10(CACHE_DIR, "version-check.json");
@@ -4762,14 +4765,49 @@ async function checkForUpdate() {
4762
4765
  const hasUpdate = latest ? isNewer(latest, current) : false;
4763
4766
  return { current, latest, hasUpdate };
4764
4767
  }
4765
- async function logUpdateHintIfNeeded() {
4768
+ async function promptYesNo(question) {
4769
+ if (!process.stdin.isTTY) return false;
4770
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4771
+ try {
4772
+ const answer = (await rl.question(question)).trim().toLowerCase();
4773
+ return answer === "y" || answer === "yes";
4774
+ } finally {
4775
+ rl.close();
4776
+ }
4777
+ }
4778
+ function runNpmUpdate() {
4779
+ return new Promise((resolve5) => {
4780
+ const proc = spawn("npm", ["install", "-g", PKG_NAME + "@latest"], {
4781
+ stdio: "inherit"
4782
+ });
4783
+ proc.on("error", () => resolve5(false));
4784
+ proc.on("exit", (code) => resolve5(code === 0));
4785
+ });
4786
+ }
4787
+ async function promptForUpdateOrLog() {
4766
4788
  try {
4767
4789
  const r = await checkForUpdate();
4768
- if (r.hasUpdate && r.latest) {
4790
+ if (!r.hasUpdate || !r.latest) return;
4791
+ if (!process.stdin.isTTY) {
4769
4792
  log.info(
4770
4793
  `Synthra ${r.latest} is available (you have ${r.current}) \u2014 run: npm install -g @jefuriiij/synthra@latest`
4771
4794
  );
4795
+ return;
4772
4796
  }
4797
+ log.info(`Synthra ${r.latest} is available (you have ${r.current}).`);
4798
+ const yes = await promptYesNo("[syn] Update now? [y/N]: ");
4799
+ if (!yes) {
4800
+ log.info("Skipping update \u2014 continuing with current version.");
4801
+ return;
4802
+ }
4803
+ log.info(`Running: npm install -g ${PKG_NAME}@latest`);
4804
+ const ok2 = await runNpmUpdate();
4805
+ if (!ok2) {
4806
+ log.warn("npm install failed \u2014 continuing with current version.");
4807
+ return;
4808
+ }
4809
+ log.info(`\u2713 Updated to ${r.latest}. Please re-run: syn .`);
4810
+ process.exit(0);
4773
4811
  } catch {
4774
4812
  }
4775
4813
  }
@@ -4805,11 +4843,11 @@ async function serveCommand(rawPath) {
4805
4843
  }
4806
4844
 
4807
4845
  // src/cli/start-claude.ts
4808
- import spawn from "cross-spawn";
4846
+ import spawn2 from "cross-spawn";
4809
4847
  var MCP_NAME = "synthra";
4810
4848
  function runClaude(bin, args, cwd, stdio = "pipe") {
4811
4849
  return new Promise((resolve5) => {
4812
- const proc = spawn(bin, args, {
4850
+ const proc = spawn2(bin, args, {
4813
4851
  cwd,
4814
4852
  stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"]
4815
4853
  });
@@ -4889,7 +4927,7 @@ async function defaultFlow(rawPath, opts) {
4889
4927
  const projectRoot = resolve4(rawPath);
4890
4928
  const paths = resolvePaths(projectRoot);
4891
4929
  const cfg = loadConfig();
4892
- void logUpdateHintIfNeeded();
4930
+ await promptForUpdateOrLog();
4893
4931
  await recordProject(projectRoot);
4894
4932
  const scan = await scanCommand(rawPath);
4895
4933
  const mcpHandle = await startServer(paths);