mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-15 03:24:49 +00:00
feat(app): rebuild Plans on live quota (real 5h/weekly windows + auto-detected tier)
Replace the $-budget model as the primary view: per-provider cards (Claude/Codex) from getQuota show the real 5-hour/weekly windows (% used, resets-in, severity colors), the auto-detected subscription tier, connection states, and the credit footer. Manual budget plans demoted to a secondary section. typecheck clean; 148/148 tests pass. Needs a full relaunch for live quota.
This commit is contained in:
parent
880f84d593
commit
a216b841ae
4 changed files with 207 additions and 75 deletions
|
|
@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
|
|||
getSessions: vi.fn(),
|
||||
getCompareModels: vi.fn(),
|
||||
getCompare: vi.fn(),
|
||||
getQuota: vi.fn(),
|
||||
getPlans: vi.fn(),
|
||||
getActReport: vi.fn(),
|
||||
getYield: vi.fn(),
|
||||
|
|
@ -104,6 +105,10 @@ describe('App shortcuts', () => {
|
|||
mocks.getModels.mockResolvedValue([])
|
||||
mocks.getSessions.mockResolvedValue([])
|
||||
mocks.getCompareModels.mockResolvedValue([])
|
||||
mocks.getQuota.mockResolvedValue([
|
||||
{ provider: 'claude', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
|
||||
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
|
||||
])
|
||||
mocks.getPlans.mockResolvedValue({})
|
||||
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
|
||||
mocks.getYield.mockResolvedValue({
|
||||
|
|
@ -180,7 +185,7 @@ describe('App shortcuts', () => {
|
|||
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '7', metaKey: true })
|
||||
expect(await screen.findByText('No plans configured')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Connect Claude — log in with the Claude CLI')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: ',', metaKey: true })
|
||||
expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0)
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JsonPlanSummary, StatusJson } from '../lib/types'
|
||||
import type { JsonPlanSummary, QuotaProvider, StatusJson } from '../lib/types'
|
||||
import { Plans } from './Plans'
|
||||
|
||||
const { getPlans } = vi.hoisted(() => ({
|
||||
const { getPlans, getQuota } = vi.hoisted(() => ({
|
||||
getPlans: vi.fn<(period: string) => Promise<StatusJson>>(),
|
||||
getQuota: vi.fn<() => Promise<QuotaProvider[]>>(),
|
||||
}))
|
||||
vi.mock('../lib/ipc', async orig => {
|
||||
const actual = await orig<typeof import('../lib/ipc')>()
|
||||
return { ...actual, codeburn: { getPlans } }
|
||||
return { ...actual, codeburn: { getPlans, getQuota } }
|
||||
})
|
||||
|
||||
const periodStart = new Date(2026, 5, 15).toISOString()
|
||||
|
|
@ -70,34 +71,51 @@ const statusWithPlans: StatusJson = {
|
|||
},
|
||||
}
|
||||
|
||||
function quotaProviders(): QuotaProvider[] {
|
||||
const now = Date.now()
|
||||
return [
|
||||
{
|
||||
provider: 'claude',
|
||||
connection: 'connected',
|
||||
primary: { label: 'Weekly', percent: 0.92, resetsAt: new Date(now + (3 * 24 + 14) * 60 * 60_000 + 30 * 60_000).toISOString() },
|
||||
details: [
|
||||
{ label: '5-hour', percent: 0.25, resetsAt: new Date(now + 2 * 60 * 60_000 + 30 * 60_000).toISOString() },
|
||||
{ label: 'Weekly', percent: 0.92, resetsAt: new Date(now + (3 * 24 + 14) * 60 * 60_000 + 30 * 60_000).toISOString() },
|
||||
],
|
||||
planLabel: 'Max 20x',
|
||||
footerLines: [],
|
||||
},
|
||||
{
|
||||
provider: 'codex',
|
||||
connection: 'disconnected',
|
||||
primary: null,
|
||||
details: [],
|
||||
planLabel: null,
|
||||
footerLines: [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('Plans', () => {
|
||||
beforeEach(() => {
|
||||
getPlans.mockReset()
|
||||
getQuota.mockReset()
|
||||
getQuota.mockResolvedValue(quotaProviders())
|
||||
})
|
||||
|
||||
it('renders plan rows from StatusJson with clamped tracks, overage, pace, and cycle caption', async () => {
|
||||
it('renders live quota windows, tier, severity, disconnected hint, and manual plans below', async () => {
|
||||
getPlans.mockResolvedValue(statusWithPlans)
|
||||
|
||||
const { container } = render(<Plans period="30days" />)
|
||||
|
||||
expect(await screen.findByText('Claude Max')).toBeInTheDocument()
|
||||
expect([...container.querySelectorAll('.plrow b')].map(row => row.textContent)).toEqual([
|
||||
'Claude Max',
|
||||
'API usage',
|
||||
'Cursor Pro',
|
||||
])
|
||||
expect(screen.getByText('Cycle Jun 15 – Jul 14 · day 26 of 30')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cycle: Jun 15 – Jul 14')).toBeInTheDocument()
|
||||
expect(screen.getByText('$200.00 / month · claude')).toBeInTheDocument()
|
||||
expect(screen.getByText('$230.00 · 115% · $30.00 over')).toBeInTheDocument()
|
||||
|
||||
const claudeFill = container.querySelector('[data-testid="plan-track-claude"] i')
|
||||
expect(claudeFill).toHaveStyle({ width: '100%' })
|
||||
expect(claudeFill).toHaveClass('over')
|
||||
|
||||
const hotPace = screen.getByText('On pace to exceed; projected $254.00 by Jul 14')
|
||||
expect(hotPace).toHaveClass('pace', 'hot')
|
||||
expect(await screen.findByText('Max 20x')).toBeInTheDocument()
|
||||
expect(screen.getByText('25% used · resets in 2h 29m')).toBeInTheDocument()
|
||||
expect(screen.getByText('92% used · resets in 3d 14h')).toBeInTheDocument()
|
||||
expect(container.querySelector('[data-testid="quota-track-5-hour"] i')).toHaveClass('accent')
|
||||
expect(container.querySelector('[data-testid="quota-track-Weekly"] i')).toHaveClass('bad')
|
||||
expect(screen.getByText('Connect Codex — log in with the Codex CLI')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Budget plans' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Cursor Pro')).toBeInTheDocument()
|
||||
expect(screen.getByText('$20.00 / month · cursor')).toBeInTheDocument()
|
||||
expect(screen.getByText('$8.20 · 41%')).toBeInTheDocument()
|
||||
|
|
@ -105,13 +123,26 @@ describe('Plans', () => {
|
|||
expect(cursorFill).toHaveStyle({ width: '41%' })
|
||||
expect(cursorFill).not.toHaveClass('over')
|
||||
expect(screen.getByText('On track')).toHaveClass('pace', 'ok')
|
||||
expect(screen.queryByText('Claude Max')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('API usage')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('API usage')).toBeInTheDocument()
|
||||
expect(screen.getByText('codex · pay as you go, no plan')).toBeInTheDocument()
|
||||
expect(screen.getByText('$31.02 this cycle')).toBeInTheDocument()
|
||||
const codexFill = container.querySelector('[data-testid="plan-track-codex"] i')
|
||||
expect(codexFill).toHaveStyle({ width: '15%' })
|
||||
expect(codexFill).toHaveClass('mut')
|
||||
it('keeps manual budget overage and clamped-track behavior', async () => {
|
||||
getPlans.mockResolvedValue({
|
||||
...baseStatus,
|
||||
plans: {
|
||||
grok: { ...claudePlan, id: 'supergrok', provider: 'grok' },
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<Plans period="30days" />)
|
||||
|
||||
expect(await screen.findByText('SuperGrok')).toBeInTheDocument()
|
||||
expect(screen.getByText('$230.00 · 115% · $30.00 over')).toBeInTheDocument()
|
||||
const fill = container.querySelector('[data-testid="plan-track-grok"] i')
|
||||
expect(fill).toHaveStyle({ width: '100%' })
|
||||
expect(fill).toHaveClass('over')
|
||||
expect(screen.getByText('On pace to exceed; projected $254.00 by Jul 14')).toHaveClass('pace', 'hot')
|
||||
})
|
||||
|
||||
it('renders near status as an amber non-exceeding projection when below budget', async () => {
|
||||
|
|
@ -149,10 +180,9 @@ describe('Plans', () => {
|
|||
render(<Plans period="month" />)
|
||||
|
||||
expect(await screen.findByText('Cursor Pro')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cycle Jun 15 – Jul 14 · day 26 of 30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an honest empty state when StatusJson has no plan summaries', async () => {
|
||||
it('omits the budget section when StatusJson has no manual plan summaries', async () => {
|
||||
getPlans.mockResolvedValue({
|
||||
currency: 'USD',
|
||||
today: { cost: 0, savings: 0, calls: 0 },
|
||||
|
|
@ -161,8 +191,8 @@ describe('Plans', () => {
|
|||
|
||||
render(<Plans period="month" />)
|
||||
|
||||
expect(await screen.findByText('No plans configured')).toBeInTheDocument()
|
||||
expect(screen.getByText('Add a plan in the CLI settings to see budget pacing here.')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Connect Codex — log in with the Codex CLI')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Budget plans' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the CLI locate state when getPlans reports not-found', async () => {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import type { Section } from '../components/Sidebar'
|
|||
import { usePolled } from '../hooks/usePolled'
|
||||
import { formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import type { JsonPlanSummary, Period, PlanId, PlanProvider, StatusJson } from '../lib/types'
|
||||
import type { JsonPlanSummary, Period, PlanId, PlanProvider, QuotaProvider, QuotaWindow, StatusJson } from '../lib/types'
|
||||
import type { SettingsPane } from './Settings'
|
||||
|
||||
const PROVIDER_ORDER: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok']
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
const PLAN_NAMES: Record<PlanId, string> = {
|
||||
'claude-pro': 'Claude Pro',
|
||||
|
|
@ -25,12 +24,6 @@ function fmtPct(n: number): string {
|
|||
return Number.isInteger(n) ? `${n}%` : `${n.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function parseIsoDay(iso: string): number | null {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
||||
}
|
||||
|
||||
function cycleEndDate(plan: JsonPlanSummary): Date | null {
|
||||
const date = new Date(plan.periodEnd)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
|
@ -47,25 +40,6 @@ function formatShortDate(value: string | Date): string {
|
|||
}).format(date)
|
||||
}
|
||||
|
||||
function cycleLabels(plan: JsonPlanSummary | undefined): { caption: string; pop: string } | null {
|
||||
if (!plan) return null
|
||||
const startDay = parseIsoDay(plan.periodStart)
|
||||
const endDay = parseIsoDay(plan.periodEnd)
|
||||
const start = formatShortDate(plan.periodStart)
|
||||
const inclusiveEnd = cycleEndDate(plan)
|
||||
const end = inclusiveEnd ? formatShortDate(inclusiveEnd) : 'unknown'
|
||||
const pop = `Cycle: ${start} – ${end}`
|
||||
|
||||
if (startDay === null || endDay === null) return { caption: `Cycle ${start} – ${end}`, pop }
|
||||
|
||||
const totalDays = Math.max(1, Math.round((endDay - startDay) / MS_PER_DAY))
|
||||
const day = Math.min(totalDays, Math.max(1, totalDays - plan.daysUntilReset))
|
||||
return {
|
||||
caption: `Cycle ${start} – ${end} · day ${day} of ${totalDays}`,
|
||||
pop,
|
||||
}
|
||||
}
|
||||
|
||||
function planSummaries(status: StatusJson): JsonPlanSummary[] {
|
||||
const plans = status.plans
|
||||
if (plans) {
|
||||
|
|
@ -78,48 +52,149 @@ function planSummaries(status: StatusJson): JsonPlanSummary[] {
|
|||
return status.plan ? [status.plan] : []
|
||||
}
|
||||
|
||||
function manualPlanSummaries(status: StatusJson): JsonPlanSummary[] {
|
||||
return planSummaries(status).filter(plan => plan.provider !== 'claude' && plan.provider !== 'codex')
|
||||
}
|
||||
|
||||
export function Plans({ period, refreshToken = 0, onNavigate }: { period: Period; refreshToken?: number; onNavigate?: (section: Section, pane?: SettingsPane) => void }) {
|
||||
const report = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken])
|
||||
const plans = report.data ? planSummaries(report.data) : []
|
||||
const cycle = cycleLabels(plans[0])
|
||||
const quota = usePolled<QuotaProvider[]>(() => codeburn.getQuota(), [refreshToken])
|
||||
const budgetReport = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken])
|
||||
const manualPlans = budgetReport.data ? manualPlanSummaries(budgetReport.data) : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bar">
|
||||
<div className="t">Plans</div>
|
||||
{cycle ? <span className="scope">{cycle.caption}</span> : <span className="scope">Cycle unavailable</span>}
|
||||
<div className="sp" />
|
||||
<span className="scope">{cycle ? cycle.pop : 'Cycle unavailable'}</span>
|
||||
<button type="button" className="btn btn-s" onClick={() => onNavigate?.('settings', 'plans')}>
|
||||
Add plan…
|
||||
</button>
|
||||
</div>
|
||||
<div className="body">{renderBody(report.data, report.error, plans)}</div>
|
||||
<div className="body">
|
||||
{renderQuota(quota.data, quota.error)}
|
||||
{renderBudgetPlans(budgetReport.data, budgetReport.error, manualPlans)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderBody(data: StatusJson | null, error: ReturnType<typeof usePolled<StatusJson>>['error'], plans: JsonPlanSummary[]) {
|
||||
function renderQuota(data: QuotaProvider[] | null, error: ReturnType<typeof usePolled<QuotaProvider[]>>['error']) {
|
||||
if (!data) {
|
||||
if (error) return <CliErrorPanel error={error} subject="plan pacing" />
|
||||
if (error) {
|
||||
return (
|
||||
<Panel title="Live quota">
|
||||
<p className="quota-connection-note quota-terminal">Live quota is unavailable.</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Panel title="Plans">
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>Scanning plan usage…</p>
|
||||
<Panel title="Live quota">
|
||||
<p className="quota-connection-note">Loading quota…</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
if (plans.length === 0) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Panel title="No plans configured">
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>
|
||||
Add a plan in the CLI settings to see budget pacing here.
|
||||
</p>
|
||||
<Panel title="Live quota">
|
||||
<p className="quota-connection-note">No quota providers available.</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
return plans.map(plan => <PlanPanel key={`${plan.provider}-${plan.id}`} plan={plan} />)
|
||||
return data.map(provider => <QuotaPanel key={provider.provider} quota={provider} />)
|
||||
}
|
||||
|
||||
function renderBudgetPlans(data: StatusJson | null, error: ReturnType<typeof usePolled<StatusJson>>['error'], plans: JsonPlanSummary[]) {
|
||||
if (!data && error) {
|
||||
return (
|
||||
<section className="budget-plans">
|
||||
<h2 className="plans-section-heading">Budget plans</h2>
|
||||
<CliErrorPanel error={error} subject="plan pacing" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
if (plans.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="budget-plans">
|
||||
<h2 className="plans-section-heading">Budget plans</h2>
|
||||
{plans.map(plan => <PlanPanel key={`${plan.provider}-${plan.id}`} plan={plan} />)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function QuotaPanel({ quota }: { quota: QuotaProvider }) {
|
||||
const providerName = quota.provider === 'claude' ? 'Claude' : 'Codex'
|
||||
return (
|
||||
<Panel
|
||||
className="quota-card"
|
||||
title={<span className="quota-title">{providerName}{quota.planLabel ? <small>{quota.planLabel}</small> : null}</span>}
|
||||
right={<ConnectionIndicator connection={quota.connection} />}
|
||||
>
|
||||
<QuotaContent quota={quota} providerName={providerName} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionIndicator({ connection }: { connection: QuotaProvider['connection'] }) {
|
||||
const label = connection === 'transientFailure' ? 'waiting' : connection === 'terminalFailure' ? 'error' : connection
|
||||
return <span className={`quota-connection quota-connection-${connection}`}><i />{label}</span>
|
||||
}
|
||||
|
||||
function QuotaContent({ quota, providerName }: { quota: QuotaProvider; providerName: string }) {
|
||||
if (quota.connection === 'disconnected') {
|
||||
return <p className="quota-connection-note">Connect {providerName} — log in with the {providerName} CLI</p>
|
||||
}
|
||||
if (quota.connection === 'loading') return <p className="quota-connection-note">Loading quota…</p>
|
||||
if (quota.connection === 'stale' || quota.connection === 'transientFailure') {
|
||||
return <p className="quota-connection-note">waiting on the CLI…</p>
|
||||
}
|
||||
if (quota.connection === 'terminalFailure') {
|
||||
return <p className="quota-connection-note quota-terminal">Quota is currently unavailable.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="quota-windows">
|
||||
{quota.details.map((window, index) => <QuotaMeter key={`${window.label}-${index}`} window={window} />)}
|
||||
</div>
|
||||
{quota.footerLines.length > 0 ? (
|
||||
<div className="quota-footer">{quota.footerLines.map((line, index) => <span key={`${line}-${index}`}>{line}</span>)}</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function QuotaMeter({ window }: { window: QuotaWindow }) {
|
||||
const percent = Math.round(window.percent * 100)
|
||||
const severity = window.percent >= 0.9 ? 'bad' : window.percent >= 0.7 ? 'warn' : 'accent'
|
||||
const reset = formatResetTime(window.resetsAt)
|
||||
return (
|
||||
<div className="quota-window">
|
||||
<div className="quota-window-labels">
|
||||
<span>{window.label}</span>
|
||||
<span>{percent}% used{reset ? ` · resets ${reset}` : ''}</span>
|
||||
</div>
|
||||
<div className="track" data-testid={`quota-track-${window.label}`}>
|
||||
<i className={severity} style={{ width: `${Math.min(100, Math.max(0, percent))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatResetTime(resetsAt: string | null): string | null {
|
||||
if (!resetsAt) return null
|
||||
const reset = Date.parse(resetsAt)
|
||||
if (!Number.isFinite(reset)) return null
|
||||
const remainingMinutes = Math.floor((reset - Date.now()) / 60_000)
|
||||
if (remainingMinutes <= 0) return 'now'
|
||||
const days = Math.floor(remainingMinutes / (24 * 60))
|
||||
const hours = Math.floor((remainingMinutes % (24 * 60)) / 60)
|
||||
const minutes = remainingMinutes % 60
|
||||
if (days > 0) return `in ${days}d${hours > 0 ? ` ${hours}h` : ''}`
|
||||
if (hours > 0) return `in ${hours}h${minutes > 0 ? ` ${minutes}m` : ''}`
|
||||
return `in ${minutes}m`
|
||||
}
|
||||
|
||||
function PlanPanel({ plan }: { plan: JsonPlanSummary }) {
|
||||
|
|
|
|||
|
|
@ -408,6 +408,28 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
|
|||
.track i, .tglon { background: var(--accent); box-shadow: none; }
|
||||
.track i.over { background: var(--bad); }
|
||||
.track i.mut { background: var(--bar); }
|
||||
.track i.accent { background: var(--accent); }
|
||||
.track i.warn { background: var(--warn); }
|
||||
.track i.bad { background: var(--bad); }
|
||||
|
||||
/* Plans: live subscription quota, followed by manual dollar budgets. */
|
||||
.quota-title { display: inline-flex; align-items: baseline; gap: 8px; }
|
||||
.quota-title small { color: var(--mut); font-size: 10.5px; font-weight: 520; }
|
||||
.quota-connection { display: inline-flex; align-items: center; gap: 5px; color: var(--mut); font-size: 10px; font-weight: 520; }
|
||||
.quota-connection i { width: 6px; height: 6px; border-radius: 50%; background: var(--mut2); }
|
||||
.quota-connection-connected i { background: var(--ok); }
|
||||
.quota-connection-stale i, .quota-connection-transientFailure i { background: var(--warn); }
|
||||
.quota-connection-terminalFailure i { background: var(--bad); }
|
||||
.quota-connection-note { margin: 0; color: var(--mut); font-size: 12px; }
|
||||
.quota-connection-note.quota-terminal { color: var(--bad); }
|
||||
.quota-windows { display: grid; gap: 15px; }
|
||||
.quota-window-labels { display: flex; align-items: baseline; gap: 12px; color: var(--ink); font-size: 12px; }
|
||||
.quota-window-labels span:last-child { margin-left: auto; color: var(--mut); font-family: var(--mono); font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.quota-window .track { margin-top: 7px; }
|
||||
.quota-footer { display: flex; flex-direction: column; gap: 3px; margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line2); color: var(--mut); font-size: 10.5px; }
|
||||
.plans-section-heading { margin: 2px 0 8px; color: var(--mut); font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.budget-plans { display: grid; width: 100%; gap: 12px; }
|
||||
.budget-plans > .panel { width: 100%; }
|
||||
|
||||
.ov-dashboard { display: grid; width: 100%; gap: 12px; }
|
||||
.ov-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); overflow: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--card-shadow); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue