mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-21 02:07:12 +00:00
feat(desktop): add findings tips, collapsible activity, loading overlay, empty state, star banner
This commit is contained in:
parent
026795cd34
commit
864a7e29a1
8 changed files with 568 additions and 99 deletions
|
|
@ -15,6 +15,11 @@ import { TrendInsight } from './components/TrendInsight'
|
|||
import { ForecastInsight } from './components/ForecastInsight'
|
||||
import { PulseInsight } from './components/PulseInsight'
|
||||
import { StatsInsight } from './components/StatsInsight'
|
||||
import { FindingsSection } from './components/FindingsSection'
|
||||
import { ActivitySection } from './components/ActivitySection'
|
||||
import { LoadingOverlay } from './components/LoadingOverlay'
|
||||
import { EmptyProviderState } from './components/EmptyProviderState'
|
||||
import { StarBanner } from './components/StarBanner'
|
||||
|
||||
const payloadCache = new PayloadCache<MenubarPayload>()
|
||||
|
||||
|
|
@ -28,6 +33,10 @@ const PERIODS: Array<{ id: Period; label: string }> = [
|
|||
{ id: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
today: 'Today', week: '7 Days', '30days': '30 Days', month: 'Month', all: 'All',
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60_000
|
||||
|
||||
export function App() {
|
||||
|
|
@ -75,14 +84,12 @@ export function App() {
|
|||
}
|
||||
}, [period, provider, currency])
|
||||
|
||||
// Initial + interval refresh
|
||||
useEffect(() => {
|
||||
refresh(true)
|
||||
const id = setInterval(() => refresh(false), REFRESH_INTERVAL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [refresh])
|
||||
|
||||
// Tray menu "Refresh" event
|
||||
useEffect(() => {
|
||||
const unlisten = listen('codeburn://refresh', () => refresh(true))
|
||||
return () => { unlisten.then(fn => fn()) }
|
||||
|
|
@ -101,9 +108,7 @@ export function App() {
|
|||
invoke('open_terminal_command', { args: ['report'] }).catch(console.error)
|
||||
}
|
||||
|
||||
const openOptimize = () => {
|
||||
invoke('open_terminal_command', { args: ['optimize'] }).catch(console.error)
|
||||
}
|
||||
const isFilteredEmpty = provider !== 'all' && payload.current.cost <= 0 && payload.current.calls === 0
|
||||
|
||||
return (
|
||||
<div className="popover">
|
||||
|
|
@ -122,104 +127,90 @@ export function App() {
|
|||
currency={currency}
|
||||
/>
|
||||
|
||||
<section className="hero">
|
||||
<div className="hero-label">
|
||||
<span className="hero-dot" /> {payload.current.label}
|
||||
</div>
|
||||
<div className="hero-amount">
|
||||
{formatCurrency(payload.current.cost, currency)}
|
||||
</div>
|
||||
<div className="hero-meta">
|
||||
<span>{payload.current.calls.toLocaleString()} calls</span>
|
||||
<span>{payload.current.sessions} sessions</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav className="period-tabs">
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`period ${period === p.id ? 'period-active' : ''}`}
|
||||
onClick={() => setPeriod(p.id)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="insight-area">
|
||||
<InsightPills
|
||||
selected={insight}
|
||||
onSelect={setInsight}
|
||||
modes={['trend', 'forecast', 'pulse', 'stats']}
|
||||
/>
|
||||
{insight === 'trend' && (
|
||||
<TrendInsight days={payload.history.daily} currency={currency} />
|
||||
)}
|
||||
{insight === 'forecast' && (
|
||||
<ForecastInsight days={payload.history.daily} currency={currency} />
|
||||
)}
|
||||
{insight === 'pulse' && (
|
||||
<PulseInsight payload={payload} currency={currency} />
|
||||
)}
|
||||
{insight === 'stats' && (
|
||||
<StatsInsight payload={payload} currency={currency} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && payload.current.calls === 0 && payload.current.sessions === 0 ? (
|
||||
<section className="empty-state">
|
||||
<h2 className="section-title">No session data yet</h2>
|
||||
<p>
|
||||
CodeBurn reads local session logs from your AI coding tools. It looks like
|
||||
none of the supported tools have written any sessions on this machine yet.
|
||||
</p>
|
||||
<p>Supported sources:</p>
|
||||
<ul>
|
||||
<li><code>~/.claude/projects/</code> (Claude Code)</li>
|
||||
<li><code>~/.codex/sessions/</code> (Codex CLI)</li>
|
||||
<li>Cursor IDE local database</li>
|
||||
<li>GitHub Copilot session events</li>
|
||||
</ul>
|
||||
<p>Run one of those tools for a session, then hit Refresh.</p>
|
||||
<div className="main-content">
|
||||
<section className="hero">
|
||||
<div className="hero-label">
|
||||
<span className="hero-dot" /> {payload.current.label}
|
||||
</div>
|
||||
<div className="hero-amount">
|
||||
{formatCurrency(payload.current.cost, currency)}
|
||||
</div>
|
||||
<div className="hero-meta">
|
||||
<span>{payload.current.calls.toLocaleString()} calls</span>
|
||||
<span>{payload.current.sessions} sessions</span>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="activity">
|
||||
<h2 className="section-title">Activity</h2>
|
||||
{payload.current.topActivities.length === 0 && (
|
||||
<p className="empty">No activity for this period.</p>
|
||||
)}
|
||||
{payload.current.topActivities.map(a => (
|
||||
<div key={a.name} className="row">
|
||||
<div className="row-label">{a.name}</div>
|
||||
<div className="row-cost">{formatCompactCurrency(a.cost, currency)}</div>
|
||||
<div className="row-turns">{a.turns}</div>
|
||||
<div className="row-oneshot">
|
||||
{a.oneShotRate == null ? '—' : `${Math.round(a.oneShotRate * 100)}%`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="period-tabs">
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`period ${period === p.id ? 'period-active' : ''}`}
|
||||
onClick={() => setPeriod(p.id)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<ModelsSection
|
||||
models={payload.current.topModels}
|
||||
inputTokens={payload.current.inputTokens}
|
||||
outputTokens={payload.current.outputTokens}
|
||||
cacheHitPercent={payload.current.cacheHitPercent}
|
||||
currency={currency}
|
||||
/>
|
||||
{isFilteredEmpty ? (
|
||||
<EmptyProviderState provider={provider} period={period} />
|
||||
) : (
|
||||
<>
|
||||
<div className="insight-area">
|
||||
<InsightPills
|
||||
selected={insight}
|
||||
onSelect={setInsight}
|
||||
modes={['trend', 'forecast', 'pulse', 'stats']}
|
||||
/>
|
||||
{insight === 'trend' && (
|
||||
<TrendInsight days={payload.history.daily} currency={currency} />
|
||||
)}
|
||||
{insight === 'forecast' && (
|
||||
<ForecastInsight days={payload.history.daily} currency={currency} />
|
||||
)}
|
||||
{insight === 'pulse' && (
|
||||
<PulseInsight payload={payload} currency={currency} />
|
||||
)}
|
||||
{insight === 'stats' && (
|
||||
<StatsInsight payload={payload} currency={currency} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{payload.optimize.findingCount > 0 && (
|
||||
<section className="findings">
|
||||
<button className="findings-cta" onClick={openOptimize}>
|
||||
<span className="findings-count">{payload.optimize.findingCount} findings</span>
|
||||
<span className="findings-savings">
|
||||
save ~{formatCompactCurrency(payload.optimize.savingsUSD, currency)}
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
{!loading && payload.current.calls === 0 && payload.current.sessions === 0 ? (
|
||||
<section className="empty-state">
|
||||
<h2 className="section-title">No session data yet</h2>
|
||||
<p>
|
||||
CodeBurn reads local session logs from your AI coding tools. It looks like
|
||||
none of the supported tools have written any sessions on this machine yet.
|
||||
</p>
|
||||
<p>Supported sources:</p>
|
||||
<ul>
|
||||
<li><code>~/.claude/projects/</code> (Claude Code)</li>
|
||||
<li><code>~/.codex/sessions/</code> (Codex CLI)</li>
|
||||
<li>Cursor IDE local database</li>
|
||||
<li>GitHub Copilot session events</li>
|
||||
</ul>
|
||||
<p>Run one of those tools for a session, then hit Refresh.</p>
|
||||
</section>
|
||||
) : (
|
||||
<ActivitySection payload={payload} currency={currency} />
|
||||
)}
|
||||
|
||||
<ModelsSection
|
||||
models={payload.current.topModels}
|
||||
inputTokens={payload.current.inputTokens}
|
||||
outputTokens={payload.current.outputTokens}
|
||||
cacheHitPercent={payload.current.cacheHitPercent}
|
||||
currency={currency}
|
||||
/>
|
||||
|
||||
<FindingsSection payload={payload} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading && <LoadingOverlay periodLabel={PERIOD_LABELS[period] ?? period} />}
|
||||
</div>
|
||||
|
||||
<footer className="footer">
|
||||
<select
|
||||
|
|
@ -237,6 +228,8 @@ export function App() {
|
|||
<button className="quit" onClick={() => invoke('quit_app').catch(console.error)} title="Quit CodeBurn">×</button>
|
||||
</footer>
|
||||
|
||||
<StarBanner />
|
||||
|
||||
{error && <div className="error-toast">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
55
desktop/src/components/ActivitySection.tsx
Normal file
55
desktop/src/components/ActivitySection.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState } from 'react'
|
||||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency } from '../lib/currency'
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
export function ActivitySection({ payload, currency }: Props) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const activities = payload.current.topActivities
|
||||
if (activities.length === 0) return null
|
||||
|
||||
const maxCost = Math.max(...activities.map(a => a.cost), 0.01)
|
||||
|
||||
return (
|
||||
<section className="activity-section">
|
||||
<button className="section-header" onClick={() => setExpanded(!expanded)}>
|
||||
<div className="section-header-left">
|
||||
<span className="section-dot" />
|
||||
<span className="section-caption">Activity</span>
|
||||
</div>
|
||||
<div className="section-header-right">
|
||||
<span className="col-header">Cost</span>
|
||||
<span className="col-header col-header-sm">Turns</span>
|
||||
<span className="col-header col-header-sm">1-shot</span>
|
||||
<span className={`chevron ${expanded ? 'chevron-open' : ''}`}>›</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="section-body">
|
||||
{activities.map(a => (
|
||||
<div key={a.name} className="activity-row">
|
||||
<div className="row-bar-container">
|
||||
<div
|
||||
className="row-bar-fill"
|
||||
style={{ width: `${Math.max(2, (a.cost / maxCost) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="row-label">{a.name}</div>
|
||||
<div className="row-cost">{formatCompactCurrency(a.cost, currency)}</div>
|
||||
<div className="row-turns">{a.turns}</div>
|
||||
<div className="row-oneshot">
|
||||
{a.oneShotRate == null ? '—' : `${Math.round(a.oneShotRate * 100)}%`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
33
desktop/src/components/EmptyProviderState.tsx
Normal file
33
desktop/src/components/EmptyProviderState.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
type Props = {
|
||||
provider: string
|
||||
period: string
|
||||
}
|
||||
|
||||
const PERIOD_PHRASES: Record<string, string> = {
|
||||
today: 'today',
|
||||
week: 'the last 7 days',
|
||||
'30days': 'the last 30 days',
|
||||
month: 'this month',
|
||||
all: 'all time',
|
||||
}
|
||||
|
||||
const DISPLAY_NAMES: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
cursor: 'Cursor',
|
||||
copilot: 'Copilot',
|
||||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
}
|
||||
|
||||
export function EmptyProviderState({ provider, period }: Props) {
|
||||
const name = DISPLAY_NAMES[provider] ?? provider
|
||||
const phrase = PERIOD_PHRASES[period] ?? period
|
||||
|
||||
return (
|
||||
<div className="empty-provider">
|
||||
<div className="empty-provider-icon">📭</div>
|
||||
<div className="empty-provider-text">No {name} data for {phrase}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
desktop/src/components/FindingsSection.tsx
Normal file
64
desktop/src/components/FindingsSection.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useState } from 'react'
|
||||
import type { MenubarPayload } from '../lib/payload'
|
||||
import { computeTipGroups, type TipGroup } from '../lib/tips'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload
|
||||
}
|
||||
|
||||
export function FindingsSection({ payload }: Props) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const groups = computeTipGroups(payload)
|
||||
const totalSignals = groups.reduce((s, g) => s + g.items.length, 0)
|
||||
if (totalSignals === 0) return null
|
||||
|
||||
return (
|
||||
<section className="findings-section">
|
||||
<button className="findings-header" onClick={() => setExpanded(!expanded)}>
|
||||
<div className="findings-header-left">
|
||||
<span className="findings-icon">💡</span>
|
||||
<span className="findings-title">Tips for you</span>
|
||||
</div>
|
||||
<div className="findings-header-right">
|
||||
<span className="findings-count">{totalSignals} signals</span>
|
||||
<span className={`chevron ${expanded ? 'chevron-open' : ''}`}>›</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="findings-body">
|
||||
{groups.map(g => g.items.length > 0 && (
|
||||
<TipsGroupView key={g.label} group={g} />
|
||||
))}
|
||||
{payload.optimize.findingCount > 0 && (
|
||||
<button
|
||||
className="findings-open-optimize"
|
||||
onClick={() => invoke('open_terminal_command', { args: ['optimize'] }).catch(() => {})}
|
||||
>
|
||||
Open Full Optimize →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TipsGroupView({ group }: { group: TipGroup }) {
|
||||
return (
|
||||
<div className="tips-group">
|
||||
<div className="tips-group-header">
|
||||
<span className="tips-group-icon">{group.icon}</span>
|
||||
<span className="tips-group-label">{group.label}</span>
|
||||
</div>
|
||||
{group.items.map((item, i) => (
|
||||
<div key={i} className="tips-item">
|
||||
<span className="tips-bullet" />
|
||||
<span className="tips-text">{item.text}</span>
|
||||
{item.trailing && <span className="tips-trailing">{item.trailing}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
desktop/src/components/LoadingOverlay.tsx
Normal file
15
desktop/src/components/LoadingOverlay.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
type Props = { periodLabel: string }
|
||||
|
||||
export function LoadingOverlay({ periodLabel }: Props) {
|
||||
return (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
<div className="loading-flame">
|
||||
<div className="flame-outline">🔥</div>
|
||||
<div className="flame-fill">🔥</div>
|
||||
</div>
|
||||
<div className="loading-text">Loading {periodLabel}…</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
desktop/src/components/StarBanner.tsx
Normal file
36
desktop/src/components/StarBanner.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'codeburn.starBannerDismissed'
|
||||
const GITHUB_URL = 'https://github.com/getagentseal/codeburn'
|
||||
|
||||
export function StarBanner() {
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true'
|
||||
})
|
||||
|
||||
if (dismissed) return null
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true')
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="star-banner">
|
||||
<span className="star-banner-icon">⭐</span>
|
||||
<a
|
||||
className="star-banner-link"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>Enjoying CodeBurn?</span>{' '}
|
||||
<span className="star-banner-cta">Star us on GitHub</span>
|
||||
</a>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="star-banner-close" onClick={dismiss} title="Hide this banner">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
desktop/src/lib/tips.ts
Normal file
70
desktop/src/lib/tips.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { MenubarPayload, DailyEntry } from './payload'
|
||||
import { formatDateKey, addDays, startOfDay, firstOfMonth, daysInMonth, dayOfMonth } from './dates'
|
||||
|
||||
export type TipItem = { text: string; trailing: string | null }
|
||||
export type TipGroup = { label: string; icon: string; items: TipItem[] }
|
||||
|
||||
function historyStats(history: DailyEntry[]) {
|
||||
const now = new Date()
|
||||
const today = startOfDay(now)
|
||||
const costByDate = new Map(history.map(d => [d.date, d.cost]))
|
||||
|
||||
const lastWeekStart = formatDateKey(addDays(today, -6))
|
||||
const priorWeekStart = formatDateKey(addDays(today, -13))
|
||||
const priorWeekEnd = formatDateKey(addDays(today, -7))
|
||||
const thisWeek = history.filter(d => d.date >= lastWeekStart).reduce((s, d) => s + d.cost, 0)
|
||||
const prior = history.filter(d => d.date >= priorWeekStart && d.date <= priorWeekEnd).reduce((s, d) => s + d.cost, 0)
|
||||
const weekDelta = prior > 0 ? ((thisWeek - prior) / prior) * 100 : null
|
||||
|
||||
let streak = 0
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const key = formatDateKey(addDays(today, -i))
|
||||
if ((costByDate.get(key) ?? 0) > 0) streak++
|
||||
else break
|
||||
}
|
||||
|
||||
const fom = firstOfMonth(now)
|
||||
const fomStr = formatDateKey(fom)
|
||||
const mtd = history.filter(d => d.date >= fomStr).reduce((s, d) => s + d.cost, 0)
|
||||
const dom = dayOfMonth(now)
|
||||
const projected = dom > 0 ? (mtd / dom) * daysInMonth(now) : null
|
||||
|
||||
const prevMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1))
|
||||
const prevFirstStr = formatDateKey(prevMonth)
|
||||
const prevLastStr = formatDateKey(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)))
|
||||
const prevEntries = history.filter(d => d.date >= prevFirstStr && d.date <= prevLastStr)
|
||||
const prevTotal = prevEntries.length > 0 ? prevEntries.reduce((s, d) => s + d.cost, 0) : null
|
||||
|
||||
return { weekDelta, streak, projected, prevTotal }
|
||||
}
|
||||
|
||||
export function computeTipGroups(payload: MenubarPayload): TipGroup[] {
|
||||
const stats = historyStats(payload.history.daily)
|
||||
const { cacheHitPercent, oneShotRate } = payload.current
|
||||
|
||||
const wins: TipItem[] = []
|
||||
if (cacheHitPercent >= 80) wins.push({ text: `Cache hit at ${Math.round(cacheHitPercent)}% — most prompts reuse cache`, trailing: null })
|
||||
if (oneShotRate != null && oneShotRate >= 0.75) wins.push({ text: `${Math.round(oneShotRate * 100)}% one-shot — edits landing first try`, trailing: null })
|
||||
if (stats.weekDelta != null && stats.weekDelta < -10) wins.push({ text: `Spend down ${Math.round(Math.abs(stats.weekDelta))}% vs last 7 days`, trailing: null })
|
||||
if (stats.streak >= 5) wins.push({ text: `${stats.streak}-day usage streak`, trailing: null })
|
||||
|
||||
const improvements: TipItem[] = payload.optimize.topFindings.slice(0, 3).map(f => ({
|
||||
text: f.title,
|
||||
trailing: `$${f.savingsUSD.toFixed(2)}`,
|
||||
}))
|
||||
|
||||
const risks: TipItem[] = []
|
||||
if (stats.weekDelta != null && stats.weekDelta > 25) risks.push({ text: `Spend up ${Math.round(stats.weekDelta)}% vs prior 7 days`, trailing: null })
|
||||
if (cacheHitPercent > 0 && cacheHitPercent < 50) risks.push({ text: `Cache hit only ${Math.round(cacheHitPercent)}% — paying for cold prompts`, trailing: null })
|
||||
if (oneShotRate != null && oneShotRate < 0.5) risks.push({ text: `${Math.round(oneShotRate * 100)}% one-shot — lots of iteration`, trailing: null })
|
||||
if (stats.projected != null && stats.prevTotal != null && stats.projected > stats.prevTotal * 1.3) {
|
||||
const pct = Math.round(((stats.projected - stats.prevTotal) / stats.prevTotal) * 100)
|
||||
risks.push({ text: `On pace for $${stats.projected.toFixed(2)} this month (+${pct}% vs last)`, trailing: null })
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "What's working", icon: '✓', items: wins },
|
||||
{ label: 'What to improve', icon: '↗', items: improvements },
|
||||
{ label: 'Risks', icon: '⚠', items: risks },
|
||||
]
|
||||
}
|
||||
|
|
@ -588,3 +588,206 @@ html, body, #root {
|
|||
font-variant-numeric: tabular-nums;
|
||||
color: var(--brand-accent);
|
||||
}
|
||||
|
||||
/* ---- activity section ---- */
|
||||
.activity-section { padding: var(--spacing-sm) var(--spacing-lg); }
|
||||
.activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr auto 40px 40px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: 3px 0;
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.col-header-sm { min-width: 36px; }
|
||||
|
||||
/* ---- findings section ---- */
|
||||
.findings-section {
|
||||
margin: var(--spacing-sm) var(--spacing-lg);
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.findings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
padding: 0;
|
||||
}
|
||||
.findings-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.findings-icon { font-size: 11px; }
|
||||
.findings-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.findings-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.findings-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.findings-body { margin-top: 10px; }
|
||||
.tips-group { margin-bottom: 10px; }
|
||||
.tips-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--brand-accent);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.tips-group-icon { font-size: 10px; }
|
||||
.tips-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
padding: 2px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tips-bullet {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.tips-text { flex: 1; }
|
||||
.tips-trailing {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.findings-open-optimize {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--brand-accent);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
/* ---- loading overlay ---- */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: rgba(250, 247, 243, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.loading-overlay { background: rgba(28, 24, 22, 0.6); }
|
||||
}
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.loading-flame {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 48px;
|
||||
line-height: 64px;
|
||||
text-align: center;
|
||||
}
|
||||
.flame-outline {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.flame-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
animation: flameFill 1.4s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes flameFill {
|
||||
from { clip-path: inset(100% 0 0 0); }
|
||||
to { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.loading-text {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.main-content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ---- empty provider ---- */
|
||||
.empty-provider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.empty-provider-icon {
|
||||
font-size: 26px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.empty-provider-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- star banner ---- */
|
||||
.star-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(201, 82, 29, 0.08);
|
||||
border-top: 0.5px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
.star-banner-icon { font-size: 10px; }
|
||||
.star-banner-link {
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.star-banner-cta {
|
||||
color: var(--brand-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.star-banner-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue