feat(app): motion layer (gsap) — count-ups, chart grow-in, section fade, skeletons, toasts

- one gate (motionEnabled): off under reduced-motion, missing matchMedia,
  and vitest; every path checks it first
- mount/filter-change only: count-up and bar grow-in key off the
  period|provider|range key, so 30s poll refreshes snap values silently
  instead of re-animating
- first-load skeleton shimmer replaces bare scanning text (kept sr-only
  for screen readers); slide-in toast host for Settings/export feedback
  (validation errors stay inline); CSS hover-lift + press micro-
  interactions, all with a reduced-motion escape hatch
- gsap 3.15.0 + @gsap/react 2.1.2 (+74KB raw JS; G4 flame work shares it)

244/244, typecheck + build green.
This commit is contained in:
iamtoruk 2026-07-16 04:39:09 -07:00
parent 26ffd60e0f
commit daf944e714
20 changed files with 476 additions and 61 deletions

18
app/package-lock.json generated
View file

@ -8,6 +8,8 @@
"name": "codeburn-desktop",
"version": "0.0.0",
"dependencies": {
"@gsap/react": "^2.1.2",
"gsap": "^3.15.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
@ -1044,6 +1046,16 @@
}
}
},
"node_modules/@gsap/react": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@gsap/react/-/react-2.1.2.tgz",
"integrity": "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==",
"license": "SEE LICENSE AT https://gsap.com/standard-license",
"peerDependencies": {
"gsap": "^3.12.5",
"react": ">=17"
}
},
"node_modules/@hapi/address": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
@ -3078,6 +3090,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",

View file

@ -13,6 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"gsap": "^3.15.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},

View file

@ -5,11 +5,13 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import { Hint } from './components/Hint'
import { Panel } from './components/Panel'
import { Sidebar, type Section } from './components/Sidebar'
import { ToastHost } from './components/ToastHost'
import { rangeLabel, TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { usePolled } from './hooks/usePolled'
import { readDailyBudget } from './lib/budget'
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { motionClass } from './lib/motion'
import { codeburn } from './lib/ipc'
import { localDateKey } from './lib/period'
import { OverviewContent } from './sections/Overview'
@ -215,6 +217,7 @@ export function App() {
return (
<Window>
<Sidebar active={section} onNavigate={navigate} status={<StatusLine polled={overview} />} />
<ToastHost />
<div className="ct">
<DailyBudgetBanner payload={overview.data ?? null} provider={provider} />
<ErrorBoundary key={section}>
@ -239,7 +242,7 @@ export function App() {
configSource={claudeConfigSource}
onConfigSelect={onConfigSelect}
/>
<div className="body">
<div className={motionClass('body', 'section-fade')}>
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} />
) : section === 'sessions' ? (

View file

@ -0,0 +1,18 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { SectionSkeleton } from './Skeleton'
describe('SectionSkeleton', () => {
it('renders shimmer blocks and keeps the loading label for screen readers', () => {
const { container } = render(<SectionSkeleton label="Scanning spend…" rows={4} chart />)
expect(container.querySelectorAll('.skel').length).toBeGreaterThan(0)
expect(container.querySelector('.skel-chart')).toBeInTheDocument()
const label = screen.getByText('Scanning spend…')
expect(label).toHaveClass('sr-only')
expect(label).toHaveAttribute('role', 'status')
})
})

View file

@ -0,0 +1,21 @@
/**
* First-load placeholder: CSS shimmer blocks shown while a section has no data
* and no error. The visible blocks are aria-hidden; the real loading text is
* kept for screen readers via a visually-hidden status node.
*/
export function SectionSkeleton({ label, rows = 4, chart = false }: { label: string; rows?: number; chart?: boolean }) {
return (
<div className="panel skel-card">
<span className="sr-only" role="status">{label}</span>
<div className="phead skel-head" aria-hidden="true">
<span className="skel skel-line" style={{ width: '38%' }} />
</div>
<div className="pbody skel-body" aria-hidden="true">
{chart && <span className="skel skel-chart" />}
{Array.from({ length: rows }, (_, index) => (
<span key={index} className="skel skel-line" style={{ width: `${88 - index * 13}%` }} />
))}
</div>
</div>
)
}

View file

@ -1,4 +1,7 @@
import { useRef } from 'react'
import { formatUsd } from '../lib/format'
import { useBarGrowIn } from '../lib/motion'
import { SERIES_LABELS, type SeriesKey, seriesClassForKey, seriesClassForModel, seriesKeyForModel } from '../lib/modelSeries'
import { formatChartDate } from '../lib/period'
import type { DailyHistoryEntry } from '../lib/types'
@ -9,7 +12,9 @@ function modelSpend(day: DailyHistoryEntry): number {
return day.topModels.reduce((sum, model) => sum + Math.max(0, model.cost), 0)
}
export function StackedBars({ daily, fallbackLabel = 'All models' }: { daily: DailyHistoryEntry[]; fallbackLabel?: string }) {
export function StackedBars({ daily, fallbackLabel = 'All models', animateKey = '' }: { daily: DailyHistoryEntry[]; fallbackLabel?: string; animateKey?: string }) {
const barsRef = useRef<HTMLDivElement>(null)
useBarGrowIn(barsRef, '.c', [animateKey])
const presentSeries = new Set<SeriesKey>()
let usesFallback = false
for (const day of daily) {
@ -32,7 +37,7 @@ export function StackedBars({ daily, fallbackLabel = 'All models' }: { daily: Da
return (
<div className="sbars-wrap">
<div className="sbars" aria-label="Daily spend by model">
<div className="sbars" aria-label="Daily spend by model" ref={barsRef}>
{daily.map(day => (
<div className="c" key={day.date} data-date={day.date} title={`${day.date} · ${formatUsd(day.cost)}`}>
{modelSpend(day) > 0 ? (

View file

@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { act, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ToastHost } from './ToastHost'
import { dismissToast, showToast } from '../lib/toast'
afterEach(() => {
dismissToast()
vi.useRealTimers()
})
describe('ToastHost', () => {
it('shows an action toast and auto-dismisses it after 3s', () => {
vi.useFakeTimers()
render(<ToastHost />)
act(() => { showToast('Exported to /tmp/out') })
expect(screen.getByRole('status')).toHaveTextContent('Exported to /tmp/out')
act(() => { vi.advanceTimersByTime(2999) })
expect(screen.getByRole('status')).toBeInTheDocument()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('keeps only the most recent toast (one at a time)', () => {
vi.useFakeTimers()
render(<ToastHost />)
act(() => { showToast('First') })
act(() => { showToast('Second', 'error') })
const toasts = screen.getAllByRole('status')
expect(toasts).toHaveLength(1)
expect(toasts[0]).toHaveTextContent('Second')
expect(toasts[0]).toHaveClass('toast-error')
})
})

View file

@ -0,0 +1,36 @@
import { useEffect, useReducer, useRef } from 'react'
import { createPortal } from 'react-dom'
import { getToast, isPrimaryHost, registerToastHost, subscribeToasts } from '../lib/toast'
import { motionClass } from '../lib/motion'
/** Bottom-right toast surface for action feedback. One at a time, role=status,
* slide-in when motion is on, auto-dismissed by the store. Ref-counted so only
* the primary host paints even if more than one is mounted. */
export function ToastHost() {
const idRef = useRef(0)
const [, force] = useReducer((n: number) => n + 1, 0)
useEffect(() => {
const { id, release } = registerToastHost()
idRef.current = id
const unsubscribe = subscribeToasts(force)
force()
return () => {
release()
unsubscribe()
}
}, [])
const toast = getToast()
if (typeof document === 'undefined' || !isPrimaryHost(idRef.current) || !toast) return null
return createPortal(
<div className="toast-host" aria-live="polite">
<div key={toast.id} className={motionClass(`toast toast-${toast.kind}`, 'toast-in')} role="status">
{toast.text}
</div>
</div>,
document.body,
)
}

View file

@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import gsap from 'gsap'
import { motionClass, motionEnabled, reducedMotion } from './motion'
import { StackedBars } from '../components/StackedBars'
import type { DailyHistoryEntry } from './types'
function mockMatchMedia(matches: boolean): void {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia
}
function entry(day: number): DailyHistoryEntry {
return {
date: `2026-07-${String(day).padStart(2, '0')}`,
cost: day,
savingsUSD: 0,
calls: 1,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
}
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('motion gate', () => {
it('reducedMotion mirrors the prefers-reduced-motion query', () => {
mockMatchMedia(true)
expect(reducedMotion()).toBe(true)
mockMatchMedia(false)
expect(reducedMotion()).toBe(false)
})
it('reducedMotion is false when matchMedia is unavailable', () => {
Reflect.deleteProperty(window, 'matchMedia')
expect(reducedMotion()).toBe(false)
})
it('motionEnabled stays off under vitest even without a reduced-motion preference', () => {
mockMatchMedia(false)
expect(motionEnabled()).toBe(false)
})
it('motionClass drops the animation class while motion is off', () => {
mockMatchMedia(false)
expect(motionClass('body', 'section-fade')).toBe('body')
})
it('never drives a chart grow-in through gsap while the gate is closed', () => {
const spy = vi.spyOn(gsap, 'from')
mockMatchMedia(false)
const { container } = render(<StackedBars daily={[entry(1), entry(2)]} animateKey="a" />)
expect(spy).not.toHaveBeenCalled()
// The bars still render at their natural, un-transformed size.
expect(container.querySelectorAll('.sbars .c')).toHaveLength(2)
})
})

View file

@ -0,0 +1,59 @@
import type { RefObject } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
/** True while the vitest suite is running; animations stay off so tests observe
* the final, settled DOM rather than an in-flight tween. `process` is undefined
* in the packaged renderer, so the typeof guard keeps this false there. */
function underTest(): boolean {
return typeof process !== 'undefined' && Boolean(process.env?.VITEST)
}
/** Reads the user's reduced-motion preference. This is the real gate the tests
* exercise (via a matchMedia mock); it is safe when matchMedia is absent. */
export function reducedMotion(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
try {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
} catch {
return false
}
}
/** The single switch every animation path checks first. Off under vitest, when
* matchMedia is unavailable, or when the user asked for reduced motion. */
export function motionEnabled(): boolean {
if (underTest()) return false
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
return !reducedMotion()
}
/** Append `animated` to `base` only when motion is on, so a class-based
* (CSS keyframe) animation never renders under reduced motion or in tests. */
export function motionClass(base: string, animated: string): string {
return motionEnabled() ? `${base} ${animated}` : base
}
/**
* Grow bars up from their baseline (scaleY 0 1, bottom-anchored) with a short
* stagger, capped so the whole sweep stays under 400ms regardless of bar count.
* Runs on mount and whenever `deps` change (a filter switch) but NOT on the 30s
* poll re-render, because callers pass a stable filter key not the data as
* the dependency.
*/
export function useBarGrowIn(scope: RefObject<HTMLElement | null>, selector: string, deps: unknown[]): void {
useGSAP(() => {
if (!motionEnabled()) return
const bars = gsap.utils.toArray<HTMLElement>(selector, scope.current)
if (!bars.length) return
const each = Math.min(0.02, 0.26 / Math.max(1, bars.length - 1))
gsap.from(bars, {
scaleY: 0,
transformOrigin: 'bottom',
duration: 0.14,
ease: 'power1.out',
stagger: each,
})
}, { scope, dependencies: deps })
}

76
app/renderer/lib/toast.ts Normal file
View file

@ -0,0 +1,76 @@
export type ToastKind = 'ok' | 'error'
export type Toast = { id: number; text: string; kind: ToastKind }
let current: Toast | null = null
let seq = 0
let timer: ReturnType<typeof setTimeout> | null = null
const hosts: number[] = []
let hostSeq = 0
const listeners = new Set<() => void>()
function emit(): void {
for (const listener of listeners) listener()
}
function clearTimer(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
}
/** Show a toast, replacing any current one (only ever one at a time), and start
* its auto-dismiss timer. */
export function showToast(text: string, kind: ToastKind = 'ok', durationMs = 3000): void {
seq += 1
current = { id: seq, text, kind }
clearTimer()
timer = setTimeout(() => {
current = null
timer = null
emit()
}, durationMs)
emit()
}
export function dismissToast(): void {
clearTimer()
current = null
emit()
}
export function getToast(): Toast | null {
return current
}
export function subscribeToasts(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** Register a Toast host. Only the first-registered host renders (so App and a
* standalone-tested Settings can both mount one without doubling the toast). The
* store resets when the last host unmounts, keeping tests isolated. */
export function registerToastHost(): { id: number; release: () => void } {
const id = ++hostSeq
hosts.push(id)
emit()
return {
id,
release: () => {
const index = hosts.indexOf(id)
if (index >= 0) hosts.splice(index, 1)
if (hosts.length === 0) {
clearTimer()
current = null
}
emit()
},
}
}
export function isPrimaryHost(id: number): boolean {
return hosts.length > 0 && hosts[0] === id
}

View file

@ -4,6 +4,7 @@ import { CliErrorPanel } from '../components/CliErrorPanel'
import { EmptyNote } from '../components/EmptyState'
import { seriesColorForModel } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import type { Section } from '../components/Sidebar'
@ -89,11 +90,7 @@ function ModelsUsage({
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="model usage" />
return (
<Panel title="Models">
<EmptyNote>Scanning model usage</EmptyNote>
</Panel>
)
return <SectionSkeleton label="Scanning model usage…" rows={5} />
}
return (

View file

@ -3,6 +3,7 @@ import { Fragment, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { EmptyNote } from '../components/EmptyState'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
@ -45,11 +46,7 @@ export function OptimizeContent({
if (!overview.data) {
if (overview.error) return <CliErrorPanel error={overview.error} subject="optimize findings" />
return (
<Panel title="Optimize">
<EmptyNote>Scanning optimize findings</EmptyNote>
</Panel>
)
return <SectionSkeleton label="Scanning optimize findings…" rows={5} />
}
const yieldData = yieldReport.error ? null : yieldReport.data

View file

@ -1,12 +1,14 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import gsap from 'gsap'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { ActivityHeatmap } from '../components/ActivityHeatmap'
import { EmptyNote } from '../components/EmptyState'
import { ListRow } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { StaleBanner } from '../components/StaleBanner'
import { motionEnabled, useBarGrowIn } from '../lib/motion'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
@ -336,24 +338,33 @@ function streakDays(daily: DailyHistoryEntry[], now: Date): number {
return streak
}
function CountUp({ value }: { value: number }) {
/**
* Hero cost with a count-up that fires on mount and whenever the filter key
* changes (a user action), but never on the 30s poll: a value that arrives
* under the same `animateKey` snaps in place instead of re-animating.
*/
function CountUp({ value, animateKey }: { value: number; animateKey: string }) {
const ref = useRef<HTMLDivElement>(null)
const keyRef = useRef<string | null>(null)
useEffect(() => {
const element = ref.current
if (!element) return
let frame = 0
const start = performance.now()
const duration = 850
const step = (now: number) => {
const t = Math.min(1, (now - start) / duration)
const eased = 1 - Math.pow(1 - t, 3)
element.textContent = formatUsd(value * eased)
if (t < 1) frame = requestAnimationFrame(step)
const keyChanged = keyRef.current !== animateKey
keyRef.current = animateKey
if (!keyChanged || !motionEnabled()) {
element.textContent = formatUsd(value)
return
}
frame = requestAnimationFrame(step)
return () => cancelAnimationFrame(frame)
}, [value])
const counter = { n: 0 }
const tween = gsap.to(counter, {
n: value,
duration: 0.7,
ease: 'power2.out',
onUpdate: () => { element.textContent = formatUsd(counter.n) },
})
return () => { tween.kill() }
}, [value, animateKey])
return <div ref={ref} className="ov-hero-num" data-countup={value}>{formatUsd(value)}</div>
}
@ -432,7 +443,7 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) {
)
}
function DailyChart({ daily }: { daily: DailyHistoryEntry[] }) {
function DailyChart({ daily, animateKey = '' }: { daily: DailyHistoryEntry[]; animateKey?: string }) {
const max = Math.max(...daily.map(day => day.cost), 0)
const peakIndex = daily.reduce((peak, day, index) => day.cost > (daily[peak]?.cost ?? -1) ? index : peak, 0)
const peak = daily[peakIndex]
@ -442,6 +453,8 @@ function DailyChart({ daily }: { daily: DailyHistoryEntry[] }) {
const [tip, setTip] = useState<{ day: DailyHistoryEntry; x: number; y: number } | null>(null)
const [tipPosition, setTipPosition] = useState<{ left: number; top: number } | null>(null)
const tipRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<HTMLDivElement>(null)
useBarGrowIn(chartRef, '.col', [animateKey])
useLayoutEffect(() => {
if (!tip) {
@ -463,7 +476,7 @@ function DailyChart({ daily }: { daily: DailyHistoryEntry[] }) {
return (
<>
<div className="chart">
<div className="chart" ref={chartRef}>
{daily.map((day, index) => (
<button
type="button"
@ -564,11 +577,12 @@ export function OverviewContent({
if (!data) {
if (error) return <CliErrorPanel error={error} subject="your usage" />
return <Panel title="Overview"><EmptyNote>Scanning sessions</EmptyNote></Panel>
return <SectionSkeleton label="Scanning sessions…" rows={3} chart />
}
const now = new Date()
const rangeActive = !!range
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
const stats = deriveStats(data, now)
const periodDaily = sliceDailyToPeriod(data.history.daily, period, now)
// Daily chart: contiguous zero-filled calendar window. A custom range spans
@ -604,7 +618,7 @@ export function OverviewContent({
<div className="ov-card ov-hero-split" aria-label="Key performance indicators">
<div className="ov-hero-main">
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={data.current.cost} />
<CountUp value={data.current.cost} animateKey={animateKey} />
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
{localSaved > 0 && (
@ -624,7 +638,7 @@ export function OverviewContent({
<div className="ov-card ov-panel ov-chart-widget">
<div className="ov-panel-head"><h3>Daily spend</h3><span className="r">{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}</span></div>
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} animateKey={animateKey} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
</div>
<div className="ov-insight-band">

View file

@ -2,11 +2,13 @@ import { useRef } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import type { Section } from '../components/Sidebar'
import { StaleBanner } from '../components/StaleBanner'
import { usePolled } from '../hooks/usePolled'
import { formatConverted } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { motionClass } from '../lib/motion'
import type { JsonPlanSummary, Period, PlanId, PlanProvider, QuotaProvider, QuotaWindow, StatusJson } from '../lib/types'
import type { SettingsPane } from './Settings'
@ -80,7 +82,7 @@ export function Plans({ period, refreshToken = 0, onNavigate }: { period: Period
Add plan
</button>
</div>
<div className="body">
<div className={motionClass('body', 'section-fade')}>
{budgetReport.data && budgetReport.error && <StaleBanner error={budgetReport.error} />}
{renderQuota(quota.data, quota.error)}
{renderBudgetPlans(budgetReport.data, budgetReport.error, manualPlans)}
@ -98,11 +100,7 @@ function renderQuota(data: QuotaProvider[] | null, error: ReturnType<typeof useP
</Panel>
)
}
return (
<Panel title="Live quota">
<p className="quota-connection-note">loading quota</p>
</Panel>
)
return <SectionSkeleton label="loading quota…" rows={3} />
}
if (data.length === 0) {

View file

@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { render, screen, within } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -114,6 +114,19 @@ const rows: SessionRow[] = [
describe('Sessions', () => {
beforeEach(() => getSessions.mockReset())
it('shows the first-load skeleton, then yields to the session list', async () => {
let resolve!: (value: SessionRow[]) => void
getSessions.mockReturnValue(new Promise<SessionRow[]>(r => { resolve = r }))
const { container } = render(<Sessions period="30days" provider="all" />)
expect(container.querySelector('.skel')).toBeInTheDocument()
expect(screen.getByText('Scanning sessions…')).toHaveClass('sr-only')
resolve(rows)
await waitFor(() => expect(container.querySelector('.session-list')).toBeInTheDocument())
expect(container.querySelector('.skel')).not.toBeInTheDocument()
})
it('shows a summary of every filtered session and groups providers', async () => {
getSessions.mockResolvedValue(rows)
const { container } = render(<Sessions period="30days" provider="all" />)

View file

@ -4,6 +4,7 @@ import { CliErrorPanel } from '../components/CliErrorPanel'
import { EmptyNote } from '../components/EmptyState'
import { Panel } from '../components/Panel'
import { ProviderLogo } from '../components/ProviderLogo'
import { SectionSkeleton } from '../components/Skeleton'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import { Stat } from '../components/Stat'
@ -141,11 +142,7 @@ export function Sessions({
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="sessions" />
return (
<Panel title="Sessions">
<EmptyNote>Scanning sessions</EmptyNote>
</Panel>
)
return <SectionSkeleton label="Scanning sessions…" rows={5} />
}
if (!report.data.length) {

View file

@ -10,6 +10,9 @@ import { usePolled } from '../hooks/usePolled'
import { readDailyBudget } from '../lib/budget'
import { formatConverted, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { motionClass } from '../lib/motion'
import { showToast } from '../lib/toast'
import { ToastHost } from '../components/ToastHost'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, ShareStatus, StatusJson } from '../lib/types'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy'
@ -91,7 +94,8 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
return (
<>
<div className="bar"><div className="t">Settings</div></div>
<div className="body set-body">
<ToastHost />
<div className={motionClass('body set-body', 'section-fade')}>
<nav className="set-rail" aria-label="Settings sections">
{RAIL_ITEMS.map(item => (
<button key={item.id} className={pane === item.id ? 'set-rail-item on' : 'set-rail-item'} aria-current={pane === item.id ? 'page' : undefined} onClick={() => setPane(item.id)}>
@ -126,7 +130,6 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
const [budgetKind, setBudgetKind] = useState<'off' | 'usd' | 'tokens'>(() => readDailyBudget()?.kind ?? 'off')
const [budgetInput, setBudgetInput] = useState(() => { const budget = readDailyBudget(); return budget ? String(budget.value) : '' })
const [budgetError, setBudgetError] = useState('')
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null)
useEffect(() => {
if (theme === 'system') document.documentElement.removeAttribute('data-theme')
@ -149,7 +152,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
writeSetting('codeburn.theme', next)
}
const finishCurrency = (result: ActionResult) => {
setMessage({ text: result.ok ? 'Updated' : result.stderr || 'Unable to update currency', error: !result.ok })
showToast(result.ok ? 'Updated' : result.stderr || 'Unable to update currency', result.ok ? 'ok' : 'error')
if (result.ok) setCurrencyNonce(value => value + 1)
}
const currencies = [...CURRENCIES]
@ -184,7 +187,6 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
{budgetError && <p className="set-action-msg error">{budgetError}</p>}
{message && <p className={message.error ? 'set-action-msg error' : 'set-action-msg'}>{message.text}</p>}
</div>
</div>
</section>
@ -297,11 +299,10 @@ function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refre
const [nonce, setNonce] = useState(0)
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, nonce])
const [presetId, setPresetId] = useState(PLAN_PRESETS[0]!.id)
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null)
const configured = plans.data ? planSummaries(plans.data) : []
const finish = (result: ActionResult) => {
setMessage({ text: result.ok ? (result.stdout.trim() || 'Plan updated') : (result.stderr || 'Plan action failed'), error: !result.ok })
showToast(result.ok ? (result.stdout.trim() || 'Plan updated') : (result.stderr || 'Plan action failed'), result.ok ? 'ok' : 'error')
if (result.ok) setNonce(value => value + 1)
}
const remove = (plan: JsonPlanSummary) => {
@ -320,7 +321,6 @@ function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refre
</div>
<div className="about-sec set-last-sec">
<div className="about-row"><label className="tx" htmlFor="settings-plan-preset">Add a plan</label><span className="r"><Dropdown id="settings-plan-preset" ariaLabel="Add a plan" value={presetId} options={PLAN_PRESETS.map(preset => ({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} /><button className="btnp btnp-primary" onClick={add}>Add</button></span></div>
{message && <p className={message.error ? 'set-action-msg error' : 'set-action-msg'}>{message.text}</p>}
</div>
</div>
<p className="set-cap">Presets: Claude Pro, Claude Max 20x, Claude Max 5x, Cursor Pro, SuperGrok, and SuperGrok Heavy. <button className="set-text-button" onClick={() => onNavigate?.('plans')}>Open Plans </button></p>
@ -333,7 +333,6 @@ function ExportPane({ period, refreshToken }: { period: Period; refreshToken: nu
const [provider, setProvider] = useState('all')
const [destination, setDestination] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null)
const providers = Object.keys(overview.data?.current.providers ?? {})
const chooseDirectory = async () => {
@ -343,10 +342,9 @@ function ExportPane({ period, refreshToken }: { period: Period; refreshToken: nu
const exportNow = async () => {
if (!destination) return
setExporting(true)
setMessage(null)
try {
const result = await codeburn.exportData(format, provider, destination)
setMessage({ text: result.ok ? `Exported to ${destination}` : (result.stderr || 'Export failed'), error: !result.ok })
showToast(result.ok ? `Exported to ${destination}` : (result.stderr || 'Export failed'), result.ok ? 'ok' : 'error')
} finally {
setExporting(false)
}
@ -360,7 +358,7 @@ function ExportPane({ period, refreshToken }: { period: Period; refreshToken: nu
<div className="about-row"><label className="tx" htmlFor="settings-export-provider">Provider</label><span className="r"><Dropdown id="settings-export-provider" ariaLabel="Provider" value={provider} options={[{ value: 'all', label: 'All providers' }, ...providers.map(value => ({ value, label: value.charAt(0).toUpperCase() + value.slice(1) }))]} onChange={setProvider} width={150} /></span></div>
<div className="about-row"><span className="tx">Destination</span><span className="r set-export-destination"><span className="set-mono">{destination ?? 'Choose a folder…'}</span><button className="btnp" onClick={() => void chooseDirectory()}>Choose folder</button></span></div>
</div>
<div className="about-sec set-last-sec"><div className="about-row"><span className="tx" /><span className="r"><button className="btnp btnp-primary" disabled={!destination || exporting} onClick={() => void exportNow()}>{exporting ? 'Exporting…' : 'Export'}</button></span></div>{message && <p className={message.error ? 'set-action-msg error' : 'set-action-msg'}>{message.text}</p>}</div>
<div className="about-sec set-last-sec"><div className="about-row"><span className="tx" /><span className="r"><button className="btnp btnp-primary" disabled={!destination || exporting} onClick={() => void exportNow()}>{exporting ? 'Exporting…' : 'Export'}</button></span></div></div>
</div>
<p className="set-cap">CSV writes a folder (summary, daily, models, projects, sessions, tools, mcp). JSON writes one file (schema codeburn.export.v2).</p>
</section>

View file

@ -5,6 +5,7 @@ import { EmptyNote } from '../components/EmptyState'
import { ListRow } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { Sankey } from '../components/Sankey'
import { SectionSkeleton } from '../components/Skeleton'
import { StackedBars } from '../components/StackedBars'
import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
@ -60,14 +61,11 @@ export function SpendContent({
if (!overview.data) {
if (overview.error) return <CliErrorPanel error={overview.error} subject="spend" />
return (
<Panel title="Spend">
<EmptyNote>Scanning spend</EmptyNote>
</Panel>
)
return <SectionSkeleton label="Scanning spend…" rows={3} chart />
}
return <SpendPage data={overview.data} flow={flow} provider={provider} range={range} staleError={overview.error} />
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
return <SpendPage data={overview.data} flow={flow} provider={provider} range={range} staleError={overview.error} animateKey={animateKey} />
}
function SpendPage({
@ -76,12 +74,14 @@ function SpendPage({
provider,
range,
staleError,
animateKey,
}: {
data: MenubarPayload
flow: ReturnType<typeof usePolled<SpendFlow>>
provider: string
range: DateRange | null
staleError: CliError | null
animateKey: string
}) {
// `history.daily` is SPARSE (active days only), so zero-fill a contiguous
// calendar window client-side; date keys are localDateKey / the CLI dateKey,
@ -148,7 +148,7 @@ function SpendPage({
{staleError && <StaleBanner error={staleError} />}
<div className="spend-top-row">
<Panel title="Daily spend by model" className="spend-chart-panel">
{chartHasSpend ? <StackedBars daily={chartDaily} fallbackLabel={providerLabel(provider)} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
{chartHasSpend ? <StackedBars daily={chartDaily} fallbackLabel={providerLabel(provider)} animateKey={animateKey} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
</Panel>
<ProjectBreakdown projects={projects} />
</div>

View file

@ -777,3 +777,53 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
@media (max-width: 720px) {
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* Motion (Batch G3). Every JS-driven path is gated by motionEnabled();
the pure-CSS bits below add a reduced-motion escape hatch of their own. */
/* Visually-hidden text kept for screen readers (e.g. skeleton loading label). */
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* First-load skeleton: shimmer blocks standing in for a panel's real content. */
.skel-card { width: 100%; }
.skel-body { display: flex; flex-direction: column; gap: var(--sp-3); }
.skel { position: relative; display: block; overflow: hidden; border-radius: 6px; background: var(--fill); }
.skel-line { height: 12px; }
.skel-chart { height: 150px; margin-bottom: var(--sp-1); border-radius: 8px; }
.skel::after {
content: ''; position: absolute; inset: 0; transform: translateX(-100%);
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--ink) 9%, transparent), transparent);
animation: skel-shimmer 1.3s ease-in-out infinite;
}
@keyframes skel-shimmer { 100% { transform: translateX(100%); } }
/* Section body fade/slide on section switch (keyed remount in App). */
.section-fade { animation: section-in 180ms ease-out; }
@keyframes section-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* Action toasts: single, bottom-right, slide-in. */
.toast-host { position: fixed; right: 18px; bottom: 18px; z-index: 2000; display: flex; flex-direction: column; gap: var(--sp-2); pointer-events: none; }
.toast {
pointer-events: auto; max-width: 340px; padding: 10px 14px; border: 1px solid var(--line);
border-left: 3px solid var(--ok); border-radius: 9px; background: var(--panel); color: var(--ink);
font-size: var(--fs-body); font-weight: var(--fw-medium); line-height: 1.4;
box-shadow: 0 8px 24px rgba(0,0,0,.16);
}
.toast-error { border-left-color: var(--bad); }
.toast-in { animation: toast-in 200ms ease-out; }
@keyframes toast-in { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: none; } }
/* Micro-interactions: card hover-lift + button press. Transform only, no reflow. */
.panel, .ov-card { transition: transform 140ms ease, box-shadow 140ms ease; }
.panel:hover, .ov-card:hover { transform: translateY(-1px); box-shadow: var(--card-shadow), 0 6px 16px rgba(0,0,0,.10); }
.btnp, .btn, .ov-coach-cta { transition: transform 120ms ease; }
.btnp:active, .btn:active, .ov-coach-cta:active { transform: scale(0.98); }
@media (prefers-reduced-motion: reduce) {
.skel::after, .section-fade, .toast-in { animation: none; }
.panel, .ov-card, .btnp, .btn, .ov-coach-cta { transition: none; }
.panel:hover, .ov-card:hover, .btnp:active, .btn:active, .ov-coach-cta:active { transform: none; }
}