mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 22:01:25 +00:00
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:
parent
26ffd60e0f
commit
daf944e714
20 changed files with 476 additions and 61 deletions
18
app/package-lock.json
generated
18
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
18
app/renderer/components/Skeleton.test.tsx
Normal file
18
app/renderer/components/Skeleton.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
21
app/renderer/components/Skeleton.tsx
Normal file
21
app/renderer/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
40
app/renderer/components/ToastHost.test.tsx
Normal file
40
app/renderer/components/ToastHost.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
36
app/renderer/components/ToastHost.tsx
Normal file
36
app/renderer/components/ToastHost.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
73
app/renderer/lib/motion.test.tsx
Normal file
73
app/renderer/lib/motion.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
59
app/renderer/lib/motion.ts
Normal file
59
app/renderer/lib/motion.ts
Normal 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
76
app/renderer/lib/toast.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" />)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue