token-studio 4.8.0

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.
Files changed (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
@@ -0,0 +1,226 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import './styles.css';
3
+
4
+ const REFRESH_MS = 7000;
5
+
6
+ export function LiveApp() {
7
+ const [snapshot, setSnapshot] = useState(null);
8
+ const [error, setError] = useState(null);
9
+ const [copied, setCopied] = useState(false);
10
+
11
+ useEffect(() => {
12
+ let alive = true;
13
+ async function load() {
14
+ try {
15
+ const response = await fetch('/api/live');
16
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
17
+ const data = await response.json();
18
+ if (alive) {
19
+ setSnapshot(data);
20
+ setError(null);
21
+ }
22
+ } catch (err) {
23
+ if (alive) setError(err.message);
24
+ }
25
+ }
26
+ load();
27
+ const timer = setInterval(load, REFRESH_MS);
28
+ return () => {
29
+ alive = false;
30
+ clearInterval(timer);
31
+ };
32
+ }, []);
33
+
34
+ const totals = snapshot?.totals || {};
35
+ const generated = useMemo(() => snapshot?.generatedAt
36
+ ? new Date(snapshot.generatedAt).toLocaleTimeString()
37
+ : 'waiting'
38
+ , [snapshot]);
39
+ const statuslineCommand = `npx token-studio statusline --format=text --window-minutes=${snapshot?.windowMinutes || 15}`;
40
+
41
+ async function copyStatuslineCommand() {
42
+ try {
43
+ await navigator.clipboard?.writeText(statuslineCommand);
44
+ setCopied(true);
45
+ window.setTimeout(() => setCopied(false), 1600);
46
+ } catch {
47
+ setCopied(false);
48
+ }
49
+ }
50
+
51
+ return (
52
+ <main className="live-shell">
53
+ <nav className="live-nav">
54
+ <div>
55
+ <span className="live-dot"/>
56
+ <strong>Token Studio Live</strong>
57
+ </div>
58
+ <div className="live-links">
59
+ <button type="button" onClick={copyStatuslineCommand}>{copied ? '已复制' : '复制 statusline'}</button>
60
+ <a href="/">看板</a>
61
+ <a href="/review">复盘</a>
62
+ <span>{generated}</span>
63
+ </div>
64
+ </nav>
65
+
66
+ <section className="live-hero">
67
+ <div>
68
+ <p className="live-kicker">Local metadata only · no transcript access</p>
69
+ <h1>今天的 token 是否正在失控</h1>
70
+ <p>每 5-10 秒读取本地 SQLite 中的结构化 token 元数据,观察最近 {snapshot?.windowMinutes || 15} 分钟的活跃 session、模型和 cache 情况。</p>
71
+ </div>
72
+ <div className={`live-status ${snapshot?.status === 'active' ? 'active' : ''}`}>
73
+ {error ? 'error' : snapshot?.status || 'loading'}
74
+ </div>
75
+ </section>
76
+
77
+ {error && <div className="live-error">实时数据加载失败:{error}</div>}
78
+
79
+ {snapshot?.warnings?.length > 0 && (
80
+ <section className="live-warnings" aria-label="实时用量预警">
81
+ {snapshot.warnings.map(warning => (
82
+ <article key={warning.type} className={`live-warning ${warning.level || 'medium'}`}>
83
+ <div>
84
+ <span>{warning.level || 'medium'}</span>
85
+ <strong>{warning.message}</strong>
86
+ <p>{warning.evidence}</p>
87
+ </div>
88
+ <b>{warning.action}</b>
89
+ </article>
90
+ ))}
91
+ </section>
92
+ )}
93
+
94
+ {snapshot?.budgetWindows?.length > 0 && (
95
+ <section className="budget-windows" aria-label="预算窗口">
96
+ {snapshot.budgetWindows.map(window => (
97
+ <article key={window.id || window.label} className={`budget-window status-${window.status}`}>
98
+ <div className="budget-window-head">
99
+ <div>
100
+ <span>{window.source || 'all sources'} · {window.windowMinutes}m {window.windowType || 'rolling'} · warn {Math.round(Number(window.warningThreshold || 0.75) * 100)}%</span>
101
+ <strong>{window.label}</strong>
102
+ </div>
103
+ <b>{budgetStatusLabel(window.status)}</b>
104
+ </div>
105
+ <div className="budget-meter" aria-hidden="true">
106
+ <span style={{width: `${Math.min(100, Math.max(window.tokenShare || 0, window.costShare || 0) * 100)}%`}}/>
107
+ </div>
108
+ <div className="budget-window-grid">
109
+ <BudgetMetric label="Tokens" value={`${formatNumber(window.totalTokens)} / ${window.tokenBudget ? formatNumber(window.tokenBudget) : '—'}`} />
110
+ <BudgetMetric label="官方价" value={`$${formatMoney(window.costUSD)} / ${window.costBudgetUSD ? `$${formatMoney(window.costBudgetUSD)}` : '—'}`} />
111
+ <BudgetMetric label="预计 Tokens" value={formatNumber(window.projectedTokens)} />
112
+ <BudgetMetric label="Burn Rate" value={`${formatNumber(window.burnRateTokensPerHour)}/h`} />
113
+ <BudgetMetric label="Reset" value={window.resetInMinutes == null ? '—' : `${Math.round(window.resetInMinutes)}m`} />
114
+ </div>
115
+ </article>
116
+ ))}
117
+ </section>
118
+ )}
119
+
120
+ {snapshot?.budgetWindows?.length > 0 && Number(totals.totalTokens || 0) === 0 && (
121
+ <section className="live-first-run-note" aria-label="预算窗口说明">
122
+ <strong>预算窗口已配置,但最近窗口没有事件级 token 数据。</strong>
123
+ <span>
124
+ `/live` 只读取最近 {snapshot?.windowMinutes || 15} 分钟的 token_events。
125
+ 如果当前库只有 daily/session 聚合数据,预算配置仍会保留,但实时 burn rate 和窗口消耗会显示为空。
126
+ </span>
127
+ </section>
128
+ )}
129
+
130
+ <section className="live-grid">
131
+ <MetricCard label="最近 Token" value={formatNumber(totals.totalTokens)} />
132
+ <MetricCard label="Burn Rate / hour" value={formatNumber(totals.burnRateTokensPerHour)} />
133
+ <MetricCard label="Cache Hit" value={`${formatPercent(totals.cacheHitRate)}%`} />
134
+ <MetricCard label="官方价换算" value={`$${formatMoney(totals.costUSD)}`} />
135
+ </section>
136
+
137
+ <section className="live-panels">
138
+ <LivePanel title="活跃 Sessions">
139
+ {snapshot?.activeSessions?.length ? snapshot.activeSessions.map(session => (
140
+ <div className="live-row" key={`${session.device}:${session.source}:${session.sessionId}`}>
141
+ <div>
142
+ <strong>{session.projectPath || session.sessionId}</strong>
143
+ <span>{session.source} · {session.model || 'unknown'} · {session.lastActivity}</span>
144
+ </div>
145
+ <b>{formatNumber(session.totalTokens)}</b>
146
+ </div>
147
+ )) : <EmptyState text="最近窗口内没有活跃 session。" />}
148
+ </LivePanel>
149
+
150
+ <LivePanel title="来源分布">
151
+ {snapshot?.bySource?.length ? snapshot.bySource.map(row => (
152
+ <div className="live-row compact" key={row.key}>
153
+ <div>
154
+ <strong>{row.key}</strong>
155
+ <span>{row.sessions} sessions</span>
156
+ </div>
157
+ <b>{formatNumber(row.totalTokens)}</b>
158
+ </div>
159
+ )) : <EmptyState text="暂无来源分布。" />}
160
+ </LivePanel>
161
+
162
+ <LivePanel title="模型分布">
163
+ {snapshot?.byModel?.length ? snapshot.byModel.map(row => (
164
+ <div className="live-row compact" key={row.key}>
165
+ <div>
166
+ <strong>{row.key}</strong>
167
+ <span>{row.sessions} sessions</span>
168
+ </div>
169
+ <b>{formatNumber(row.totalTokens)}</b>
170
+ </div>
171
+ )) : <EmptyState text="暂无模型分布。" />}
172
+ </LivePanel>
173
+ </section>
174
+ </main>
175
+ );
176
+ }
177
+
178
+ function MetricCard({ label, value }) {
179
+ return (
180
+ <div className="live-card">
181
+ <span>{label}</span>
182
+ <strong>{value}</strong>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function LivePanel({ title, children }) {
188
+ return (
189
+ <section className="live-panel">
190
+ <h2>{title}</h2>
191
+ {children}
192
+ </section>
193
+ );
194
+ }
195
+
196
+ function EmptyState({ text }) {
197
+ return <div className="live-empty">{text}</div>;
198
+ }
199
+
200
+ function BudgetMetric({ label, value }) {
201
+ return (
202
+ <div>
203
+ <span>{label}</span>
204
+ <strong>{value}</strong>
205
+ </div>
206
+ );
207
+ }
208
+
209
+ function budgetStatusLabel(status) {
210
+ if (status === 'exceeded') return '已超预算';
211
+ if (status === 'over-pace') return '会超预算';
212
+ if (status === 'near-limit') return '接近预算';
213
+ return '正常';
214
+ }
215
+
216
+ function formatNumber(value) {
217
+ return Math.round(Number(value || 0)).toLocaleString('en-US');
218
+ }
219
+
220
+ function formatMoney(value) {
221
+ return Number(value || 0).toFixed(2);
222
+ }
223
+
224
+ function formatPercent(value) {
225
+ return Number(value || 0).toFixed(1);
226
+ }
@@ -0,0 +1,446 @@
1
+ :root {
2
+ --live-bg: #f6f3ec;
3
+ --live-paper: #fffdf8;
4
+ --live-ink: #24221e;
5
+ --live-muted: #706a61;
6
+ --live-rule: #ddd5c7;
7
+ --live-accent: #216e65;
8
+ --live-warn: #9a4f16;
9
+ }
10
+
11
+ body {
12
+ margin: 0;
13
+ background: var(--live-bg);
14
+ color: var(--live-ink);
15
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
16
+ }
17
+
18
+ .live-shell {
19
+ min-height: 100vh;
20
+ padding: 22px;
21
+ overflow-x: hidden;
22
+ }
23
+
24
+ .live-nav {
25
+ height: 46px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ gap: 16px;
30
+ border-bottom: 1px solid var(--live-rule);
31
+ }
32
+
33
+ .live-nav > div,
34
+ .live-links {
35
+ display: inline-flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ min-width: 0;
39
+ }
40
+
41
+ .live-dot {
42
+ width: 9px;
43
+ height: 9px;
44
+ border-radius: 999px;
45
+ background: var(--live-accent);
46
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-accent), transparent 84%);
47
+ }
48
+
49
+ .live-links a,
50
+ .live-links span,
51
+ .live-links button {
52
+ color: var(--live-muted);
53
+ font-size: 13px;
54
+ text-decoration: none;
55
+ white-space: nowrap;
56
+ }
57
+
58
+ .live-links a:hover { color: var(--live-ink); }
59
+
60
+ .live-links button {
61
+ height: 28px;
62
+ padding: 0 10px;
63
+ border: 1px solid var(--live-rule);
64
+ border-radius: 8px;
65
+ background: var(--live-paper);
66
+ cursor: pointer;
67
+ }
68
+
69
+ .live-links button:hover {
70
+ color: var(--live-ink);
71
+ }
72
+
73
+ .live-hero {
74
+ display: grid;
75
+ grid-template-columns: minmax(0, 1fr) auto;
76
+ gap: 18px;
77
+ align-items: end;
78
+ padding: 34px 0 26px;
79
+ }
80
+
81
+ .live-kicker {
82
+ margin: 0 0 8px;
83
+ color: var(--live-accent);
84
+ font-size: 12px;
85
+ font-weight: 700;
86
+ letter-spacing: 0.08em;
87
+ text-transform: uppercase;
88
+ }
89
+
90
+ .live-hero h1 {
91
+ margin: 0;
92
+ max-width: 780px;
93
+ font-size: 38px;
94
+ line-height: 1.06;
95
+ letter-spacing: 0;
96
+ overflow-wrap: anywhere;
97
+ }
98
+
99
+ .live-hero p {
100
+ max-width: 760px;
101
+ margin: 12px 0 0;
102
+ color: var(--live-muted);
103
+ line-height: 1.65;
104
+ }
105
+
106
+ .live-status {
107
+ min-width: 92px;
108
+ height: 36px;
109
+ padding: 0 14px;
110
+ border: 1px solid var(--live-rule);
111
+ border-radius: 999px;
112
+ display: inline-flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ color: var(--live-muted);
116
+ background: var(--live-paper);
117
+ font-size: 12px;
118
+ font-weight: 700;
119
+ text-transform: uppercase;
120
+ }
121
+
122
+ .live-status.active {
123
+ color: var(--live-accent);
124
+ border-color: color-mix(in srgb, var(--live-accent), white 62%);
125
+ }
126
+
127
+ .live-error {
128
+ margin: 0 0 18px;
129
+ padding: 12px 14px;
130
+ border: 1px solid color-mix(in srgb, var(--live-warn), white 58%);
131
+ border-radius: 8px;
132
+ background: color-mix(in srgb, var(--live-warn), white 90%);
133
+ color: var(--live-warn);
134
+ }
135
+
136
+ .live-warnings {
137
+ display: grid;
138
+ grid-template-columns: repeat(2, minmax(0, 1fr));
139
+ gap: 12px;
140
+ margin: 0 0 12px;
141
+ }
142
+
143
+ .live-warning {
144
+ min-width: 0;
145
+ padding: 14px 16px;
146
+ display: grid;
147
+ gap: 10px;
148
+ border: 1px solid color-mix(in srgb, var(--live-warn), white 58%);
149
+ border-radius: 8px;
150
+ background: color-mix(in srgb, var(--live-warn), white 92%);
151
+ }
152
+
153
+ .live-warning.low {
154
+ border-color: color-mix(in srgb, var(--live-accent), white 64%);
155
+ background: color-mix(in srgb, var(--live-accent), white 93%);
156
+ }
157
+
158
+ .live-warning span {
159
+ color: var(--live-warn);
160
+ font-size: 11px;
161
+ font-weight: 800;
162
+ letter-spacing: 0.08em;
163
+ text-transform: uppercase;
164
+ }
165
+
166
+ .live-warning.low span {
167
+ color: var(--live-accent);
168
+ }
169
+
170
+ .live-warning strong,
171
+ .live-warning b {
172
+ display: block;
173
+ color: var(--live-ink);
174
+ font-size: 13px;
175
+ line-height: 1.4;
176
+ }
177
+
178
+ .live-warning p {
179
+ margin: 5px 0 0;
180
+ color: var(--live-muted);
181
+ font-size: 12px;
182
+ line-height: 1.45;
183
+ }
184
+
185
+ .live-warning b {
186
+ font-weight: 600;
187
+ }
188
+
189
+ .budget-windows {
190
+ display: grid;
191
+ grid-template-columns: repeat(2, minmax(0, 1fr));
192
+ gap: 12px;
193
+ margin: 0 0 12px;
194
+ }
195
+
196
+ .budget-window {
197
+ min-width: 0;
198
+ padding: 15px;
199
+ border: 1px solid var(--live-rule);
200
+ border-radius: 8px;
201
+ background: var(--live-paper);
202
+ }
203
+
204
+ .budget-window.status-near-limit,
205
+ .budget-window.status-over-pace,
206
+ .budget-window.status-exceeded {
207
+ border-color: color-mix(in srgb, var(--live-warn), white 56%);
208
+ }
209
+
210
+ .budget-window-head {
211
+ display: flex;
212
+ justify-content: space-between;
213
+ gap: 12px;
214
+ align-items: start;
215
+ }
216
+
217
+ .budget-window-head div {
218
+ display: grid;
219
+ gap: 4px;
220
+ min-width: 0;
221
+ }
222
+
223
+ .budget-window-head span {
224
+ color: var(--live-muted);
225
+ font-size: 11px;
226
+ font-weight: 700;
227
+ text-transform: uppercase;
228
+ letter-spacing: 0.04em;
229
+ }
230
+
231
+ .budget-window-head strong,
232
+ .budget-window-head b {
233
+ color: var(--live-ink);
234
+ font-size: 13px;
235
+ }
236
+
237
+ .budget-window-head b {
238
+ white-space: nowrap;
239
+ color: var(--live-accent);
240
+ }
241
+
242
+ .budget-window.status-near-limit .budget-window-head b,
243
+ .budget-window.status-over-pace .budget-window-head b,
244
+ .budget-window.status-exceeded .budget-window-head b {
245
+ color: var(--live-warn);
246
+ }
247
+
248
+ .budget-meter {
249
+ height: 7px;
250
+ margin: 12px 0;
251
+ border-radius: 999px;
252
+ background: color-mix(in srgb, var(--live-rule), white 42%);
253
+ overflow: hidden;
254
+ }
255
+
256
+ .budget-meter span {
257
+ display: block;
258
+ height: 100%;
259
+ border-radius: inherit;
260
+ background: var(--live-accent);
261
+ }
262
+
263
+ .budget-window.status-near-limit .budget-meter span,
264
+ .budget-window.status-over-pace .budget-meter span,
265
+ .budget-window.status-exceeded .budget-meter span {
266
+ background: var(--live-warn);
267
+ }
268
+
269
+ .budget-window-grid {
270
+ display: grid;
271
+ grid-template-columns: repeat(5, minmax(0, 1fr));
272
+ gap: 10px;
273
+ }
274
+
275
+ .budget-window-grid div {
276
+ display: grid;
277
+ gap: 3px;
278
+ min-width: 0;
279
+ }
280
+
281
+ .budget-window-grid span {
282
+ color: var(--live-muted);
283
+ font-size: 11px;
284
+ }
285
+
286
+ .budget-window-grid strong {
287
+ color: var(--live-ink);
288
+ font-size: 12px;
289
+ font-variant-numeric: tabular-nums;
290
+ overflow: hidden;
291
+ text-overflow: ellipsis;
292
+ white-space: nowrap;
293
+ }
294
+
295
+ .live-grid {
296
+ display: grid;
297
+ grid-template-columns: repeat(4, minmax(0, 1fr));
298
+ gap: 12px;
299
+ }
300
+
301
+ .live-card,
302
+ .live-panel {
303
+ border: 1px solid var(--live-rule);
304
+ border-radius: 8px;
305
+ background: var(--live-paper);
306
+ }
307
+
308
+ .live-card {
309
+ min-height: 92px;
310
+ padding: 18px;
311
+ display: grid;
312
+ align-content: space-between;
313
+ }
314
+
315
+ .live-card span {
316
+ color: var(--live-muted);
317
+ font-size: 12px;
318
+ }
319
+
320
+ .live-card strong {
321
+ font-size: 27px;
322
+ line-height: 1;
323
+ font-variant-numeric: tabular-nums;
324
+ }
325
+
326
+ .live-panels {
327
+ margin-top: 12px;
328
+ display: grid;
329
+ grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.8fr) minmax(260px, 0.8fr);
330
+ gap: 12px;
331
+ align-items: start;
332
+ }
333
+
334
+ .live-panel {
335
+ padding: 16px;
336
+ }
337
+
338
+ .live-panel h2 {
339
+ margin: 0 0 12px;
340
+ font-size: 15px;
341
+ }
342
+
343
+ .live-row {
344
+ display: grid;
345
+ grid-template-columns: minmax(0, 1fr) auto;
346
+ gap: 12px;
347
+ align-items: center;
348
+ padding: 12px 0;
349
+ border-top: 1px solid var(--live-rule);
350
+ }
351
+
352
+ .live-row:first-of-type {
353
+ border-top: 0;
354
+ }
355
+
356
+ .live-row div {
357
+ min-width: 0;
358
+ display: grid;
359
+ gap: 4px;
360
+ }
361
+
362
+ .live-row strong,
363
+ .live-row span {
364
+ min-width: 0;
365
+ overflow: hidden;
366
+ text-overflow: ellipsis;
367
+ white-space: nowrap;
368
+ }
369
+
370
+ .live-row strong {
371
+ font-size: 13px;
372
+ }
373
+
374
+ .live-row span {
375
+ color: var(--live-muted);
376
+ font-size: 12px;
377
+ }
378
+
379
+ .live-row b {
380
+ font-variant-numeric: tabular-nums;
381
+ font-size: 13px;
382
+ }
383
+
384
+ .live-row.compact {
385
+ padding: 10px 0;
386
+ }
387
+
388
+ .live-empty {
389
+ padding: 26px 12px;
390
+ border-top: 1px solid var(--live-rule);
391
+ color: var(--live-muted);
392
+ text-align: center;
393
+ font-size: 13px;
394
+ }
395
+
396
+ .live-first-run-note {
397
+ display: grid;
398
+ gap: 6px;
399
+ margin: 12px 0;
400
+ padding: 14px 16px;
401
+ border: 1px solid var(--live-rule);
402
+ border-radius: 8px;
403
+ background: color-mix(in oklab, var(--live-paper), var(--live-teal) 5%);
404
+ }
405
+
406
+ .live-first-run-note strong {
407
+ font-size: 13px;
408
+ }
409
+
410
+ .live-first-run-note span {
411
+ color: var(--live-muted);
412
+ font-size: 12.5px;
413
+ line-height: 1.55;
414
+ }
415
+
416
+ @media (max-width: 840px) {
417
+ .live-shell { padding: 16px; }
418
+ .live-nav {
419
+ height: auto;
420
+ padding-bottom: 12px;
421
+ align-items: flex-start;
422
+ flex-direction: column;
423
+ }
424
+ .live-links {
425
+ width: 100%;
426
+ overflow-x: auto;
427
+ }
428
+ .live-hero {
429
+ grid-template-columns: 1fr;
430
+ align-items: start;
431
+ }
432
+ .live-hero h1 {
433
+ max-width: 100%;
434
+ font-size: 27px;
435
+ line-height: 1.14;
436
+ }
437
+ .live-grid,
438
+ .live-panels,
439
+ .live-warnings,
440
+ .budget-windows {
441
+ grid-template-columns: 1fr;
442
+ }
443
+ .budget-window-grid {
444
+ grid-template-columns: repeat(2, minmax(0, 1fr));
445
+ }
446
+ }
@@ -0,0 +1,20 @@
1
+ import { createRoot } from 'react-dom/client';
2
+ import { App } from './dashboard/App.jsx';
3
+ import { ReviewApp } from './review/ReviewApp.jsx';
4
+ import { LiveApp } from './live/LiveApp.jsx';
5
+
6
+ function Root() {
7
+ if (window.location.pathname === '/review') {
8
+ document.title = 'ROI Review · Token Studio ROI';
9
+ return <ReviewApp />;
10
+ }
11
+ if (window.location.pathname === '/live') {
12
+ document.title = 'Live Monitor · Token Studio ROI';
13
+ return <LiveApp />;
14
+ }
15
+
16
+ document.title = 'Token Studio ROI';
17
+ return <App />;
18
+ }
19
+
20
+ createRoot(document.getElementById('root')).render(<Root />);