From daf944e71465103eb45360ce5146b6f579d33aa8 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Thu, 16 Jul 2026 04:39:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(app):=20motion=20layer=20(gsap)=20?= =?UTF-8?q?=E2=80=94=20count-ups,=20chart=20grow-in,=20section=20fade,=20s?= =?UTF-8?q?keletons,=20toasts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/package-lock.json | 18 +++++ app/package.json | 2 + app/renderer/App.tsx | 5 +- app/renderer/components/Skeleton.test.tsx | 18 +++++ app/renderer/components/Skeleton.tsx | 21 ++++++ app/renderer/components/StackedBars.tsx | 9 ++- app/renderer/components/ToastHost.test.tsx | 40 ++++++++++++ app/renderer/components/ToastHost.tsx | 36 ++++++++++ app/renderer/lib/motion.test.tsx | 73 +++++++++++++++++++++ app/renderer/lib/motion.ts | 59 +++++++++++++++++ app/renderer/lib/toast.ts | 76 ++++++++++++++++++++++ app/renderer/sections/Models.tsx | 7 +- app/renderer/sections/Optimize.tsx | 7 +- app/renderer/sections/Overview.tsx | 50 +++++++++----- app/renderer/sections/Plans.tsx | 10 ++- app/renderer/sections/Sessions.test.tsx | 15 ++++- app/renderer/sections/Sessions.tsx | 7 +- app/renderer/sections/Settings.tsx | 20 +++--- app/renderer/sections/Spend.tsx | 14 ++-- app/renderer/styles/plain.css | 50 ++++++++++++++ 20 files changed, 476 insertions(+), 61 deletions(-) create mode 100644 app/renderer/components/Skeleton.test.tsx create mode 100644 app/renderer/components/Skeleton.tsx create mode 100644 app/renderer/components/ToastHost.test.tsx create mode 100644 app/renderer/components/ToastHost.tsx create mode 100644 app/renderer/lib/motion.test.tsx create mode 100644 app/renderer/lib/motion.ts create mode 100644 app/renderer/lib/toast.ts diff --git a/app/package-lock.json b/app/package-lock.json index 66dd9b8..4ffcbdf 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index 221fe5f..94b5867 100644 --- a/app/package.json +++ b/app/package.json @@ -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" }, diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index e900991..e064cff 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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 ( } /> +
@@ -239,7 +242,7 @@ export function App() { configSource={claudeConfigSource} onConfigSelect={onConfigSelect} /> -
+
{section === 'overview' ? ( ) : section === 'sessions' ? ( diff --git a/app/renderer/components/Skeleton.test.tsx b/app/renderer/components/Skeleton.test.tsx new file mode 100644 index 0000000..5be0532 --- /dev/null +++ b/app/renderer/components/Skeleton.test.tsx @@ -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() + + 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') + }) +}) diff --git a/app/renderer/components/Skeleton.tsx b/app/renderer/components/Skeleton.tsx new file mode 100644 index 0000000..7adcb4a --- /dev/null +++ b/app/renderer/components/Skeleton.tsx @@ -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 ( +
+ {label} + + +
+ ) +} diff --git a/app/renderer/components/StackedBars.tsx b/app/renderer/components/StackedBars.tsx index 5d3d101..f484c43 100644 --- a/app/renderer/components/StackedBars.tsx +++ b/app/renderer/components/StackedBars.tsx @@ -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(null) + useBarGrowIn(barsRef, '.c', [animateKey]) const presentSeries = new Set() let usesFallback = false for (const day of daily) { @@ -32,7 +37,7 @@ export function StackedBars({ daily, fallbackLabel = 'All models' }: { daily: Da return (
-
+
{daily.map(day => (
{modelSpend(day) > 0 ? ( diff --git a/app/renderer/components/ToastHost.test.tsx b/app/renderer/components/ToastHost.test.tsx new file mode 100644 index 0000000..c81de91 --- /dev/null +++ b/app/renderer/components/ToastHost.test.tsx @@ -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() + + 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() + + 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') + }) +}) diff --git a/app/renderer/components/ToastHost.tsx b/app/renderer/components/ToastHost.tsx new file mode 100644 index 0000000..7948f3d --- /dev/null +++ b/app/renderer/components/ToastHost.tsx @@ -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( +
+
+ {toast.text} +
+
, + document.body, + ) +} diff --git a/app/renderer/lib/motion.test.tsx b/app/renderer/lib/motion.test.tsx new file mode 100644 index 0000000..1fae8cd --- /dev/null +++ b/app/renderer/lib/motion.test.tsx @@ -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() + expect(spy).not.toHaveBeenCalled() + // The bars still render at their natural, un-transformed size. + expect(container.querySelectorAll('.sbars .c')).toHaveLength(2) + }) +}) diff --git a/app/renderer/lib/motion.ts b/app/renderer/lib/motion.ts new file mode 100644 index 0000000..83cbe00 --- /dev/null +++ b/app/renderer/lib/motion.ts @@ -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, selector: string, deps: unknown[]): void { + useGSAP(() => { + if (!motionEnabled()) return + const bars = gsap.utils.toArray(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 }) +} diff --git a/app/renderer/lib/toast.ts b/app/renderer/lib/toast.ts new file mode 100644 index 0000000..00998a4 --- /dev/null +++ b/app/renderer/lib/toast.ts @@ -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 | 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 +} diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx index 7891f5c..280dfe8 100644 --- a/app/renderer/sections/Models.tsx +++ b/app/renderer/sections/Models.tsx @@ -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 - return ( - - Scanning model usage… - - ) + return } return ( diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index 2a451cc..466b892 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -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 - return ( - - Scanning optimize findings… - - ) + return } const yieldData = yieldReport.error ? null : yieldReport.data diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 38b47f9..a3aac58 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -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(null) + const keyRef = useRef(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
{formatUsd(value)}
} @@ -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(null) + const chartRef = useRef(null) + useBarGrowIn(chartRef, '.col', [animateKey]) useLayoutEffect(() => { if (!tip) { @@ -463,7 +476,7 @@ function DailyChart({ daily }: { daily: DailyHistoryEntry[] }) { return ( <> -
+
{daily.map((day, index) => (
-
+
{budgetReport.data && budgetReport.error && } {renderQuota(quota.data, quota.error)} {renderBudgetPlans(budgetReport.data, budgetReport.error, manualPlans)} @@ -98,11 +100,7 @@ function renderQuota(data: QuotaProvider[] | null, error: ReturnType ) } - return ( - -

loading quota…

-
- ) + return } if (data.length === 0) { diff --git a/app/renderer/sections/Sessions.test.tsx b/app/renderer/sections/Sessions.test.tsx index 2409e29..8ff8302 100644 --- a/app/renderer/sections/Sessions.test.tsx +++ b/app/renderer/sections/Sessions.test.tsx @@ -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(r => { resolve = r })) + const { container } = render() + + 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() diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index a4a4880..1558879 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -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 - return ( - - Scanning sessions… - - ) + return } if (!report.data.length) { diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index b57e27e..9460584 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -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 ( <>
Settings
-
+ +
@@ -297,11 +299,10 @@ function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refre const [nonce, setNonce] = useState(0) const plans = usePolled(() => 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
({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} />
- {message &&

{message.text}

}

Presets: Claude Pro, Claude Max 20x, Claude Max 5x, Cursor Pro, SuperGrok, and SuperGrok Heavy.

@@ -333,7 +333,6 @@ function ExportPane({ period, refreshToken }: { period: Period; refreshToken: nu const [provider, setProvider] = useState('all') const [destination, setDestination] = useState(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
({ value, label: value.charAt(0).toUpperCase() + value.slice(1) }))]} onChange={setProvider} width={150} />
Destination{destination ?? 'Choose a folder…'}
-
{message &&

{message.text}

}
+

CSV writes a folder (summary, daily, models, projects, sessions, tools, mcp). JSON writes one file (schema codeburn.export.v2).

diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index ea40455..a9dc3b3 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -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 - return ( - - Scanning spend… - - ) + return } - return + const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}` + return } function SpendPage({ @@ -76,12 +74,14 @@ function SpendPage({ provider, range, staleError, + animateKey, }: { data: MenubarPayload flow: ReturnType> 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 && }
- {chartHasSpend ? : No model spend in this range yet.} + {chartHasSpend ? : No model spend in this range yet.}
diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 4ea8992..86900d5 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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; } +}