feat(app): window chrome, sidebar nav, and Overview smoke view

Ports the wireframe window shell into React with exact markup/classes:
- Shared components: Window, Sidebar (traffic lights, gradient mark, six
  nav items with ⌘ keycaps + active `on` rail, status line), TopBar
  (title, scope, period SegTabs Today/7D/30D/Month/6M/Custom, ProviderPop),
  Panel, Stat, SegTabs, ProviderPop, Hint.
- usePolled(fetcher, deps): generic 30s poll + loading/error state,
  errors normalized to CliError.
- App.tsx: local section state switches the content outlet; the Overview
  placeholder round-trips getOverview('30days','all') and renders the raw
  current.cost, with an honest first-run "locate CLI" state when the
  codeburn binary isn't found (never a crash).
- Sidebar.test.tsx: six nav items, click routes onNavigate, active `on`.
This commit is contained in:
iamtoruk 2026-07-10 15:17:11 -07:00
parent e22e5511ba
commit 6e3729cfa9
13 changed files with 497 additions and 0 deletions

153
app/renderer/App.tsx Normal file
View file

@ -0,0 +1,153 @@
import { useState } from 'react'
import { Hint } from './components/Hint'
import { Panel } from './components/Panel'
import { Sidebar, type Section } from './components/Sidebar'
import { Stat } from './components/Stat'
import { TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { usePolled } from './hooks/usePolled'
import { codeburn } from './lib/ipc'
import type { MenubarPayload, Period } from './lib/types'
const SECTION_TITLES: Record<Section, string> = {
overview: 'Overview',
spend: 'Spend',
optimize: 'Optimize',
models: 'Models',
plans: 'Plans',
settings: 'Settings',
}
const PERIOD_LABELS: Record<Period, string> = {
today: 'Today',
week: 'Last 7 days',
month: 'This month',
'30days': 'Last 30 days',
all: 'All time',
}
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
function isPeriod(value: string): value is Period {
return (STANDARD_PERIODS as string[]).includes(value)
}
function fmtUsd(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
export function App() {
const [section, setSection] = useState<Section>('overview')
const [period, setPeriod] = useState<Period>('30days')
const provider = 'all'
// Polled at the app level: proves the spawn→IPC→render path and feeds the
// sidebar status line. Section-specific fetching lands in T2T7.
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
const onPeriodChange = (value: string) => {
// 6M / Custom (date ranges) are not wired until T8; ignore for now so the
// highlight never lies about what was fetched.
if (isPeriod(value)) setPeriod(value)
}
const scope = `${PERIOD_LABELS[period]} · All providers`
return (
<Window>
<Sidebar active={section} onNavigate={setSection} status={<StatusLine polled={overview} />} />
<div className="ct">
<TopBar
title={SECTION_TITLES[section]}
scope={section === 'settings' ? undefined : scope}
period={period}
onPeriodChange={onPeriodChange}
providerLabel="All providers"
/>
<div className="body">
{section === 'overview' ? (
<OverviewPlaceholder polled={overview} />
) : (
<SectionPlaceholder title={SECTION_TITLES[section]} />
)}
</div>
<Hint
items={[
{ k: '⌘K', label: 'Command' },
{ k: '⌘E', label: 'Export view' },
]}
right={overview.loading ? 'refreshing…' : 'auto-refresh 30s'}
/>
</div>
</Window>
)
}
function StatusLine({ polled }: { polled: ReturnType<typeof usePolled<MenubarPayload>> }) {
if (polled.data) {
return (
<>
{polled.data.current.label} <b>{fmtUsd(polled.data.current.cost)}</b>
</>
)
}
if (polled.error?.kind === 'not-found') return <>CLI not found</>
if (polled.loading) return <>scanning</>
return <></>
}
/**
* Task 0 smoke view: renders the raw `current.cost` from getOverview to prove
* the end-to-end path. The real Overview (stat cards, capsule chart, sessions)
* is Task 2. CLI-missing shows an honest first-run state, never a crash.
*/
function OverviewPlaceholder({ polled }: { polled: ReturnType<typeof usePolled<MenubarPayload>> }) {
if (polled.error?.kind === 'not-found') {
return (
<Panel title="Locate the codeburn CLI">
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads your usage by running the <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command,
but it isn&apos;t on your PATH yet.
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>, then reopen this window.
</p>
</Panel>
)
}
if (polled.error) {
return (
<Panel title="Couldn't read usage">
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{polled.error.message}</p>
</Panel>
)
}
if (!polled.data) {
return (
<Panel title="Overview">
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>Scanning sessions</p>
</Panel>
)
}
const current = polled.data.current
return (
<div className="stats" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
<Stat label={current.label} value={fmtUsd(current.cost)} delta={`${current.sessions} sessions`} tone="info" />
<Stat label="Calls" value={current.calls.toLocaleString('en-US')} delta="this period" />
</div>
)
}
function SectionPlaceholder({ title }: { title: string }) {
return (
<Panel title={title}>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>
{title} lands in a later task. The shell, data bridge, and design system are in place.
</p>
</Panel>
)
}

View file

@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
export type HintItem = { k?: string; label: ReactNode }
/** The `.hint` footer strip: keycap hints on the left, optional right-aligned note. */
export function Hint({ items, right }: { items: HintItem[]; right?: ReactNode }) {
return (
<div className="hint">
{items.map((item, i) => (
<span key={i}>
{item.k && <span className="k">{item.k}</span>}
{item.label}
</span>
))}
{right !== undefined && <span className="r">{right}</span>}
</div>
)
}

View file

@ -0,0 +1,30 @@
import type { CSSProperties, ReactNode } from 'react'
/** Dash0-style card: optional `.phead` title strip + `.pbody` content. */
export function Panel({
title,
right,
className,
bodyStyle,
children,
}: {
title?: ReactNode
right?: ReactNode
className?: string
bodyStyle?: CSSProperties
children?: ReactNode
}) {
return (
<div className={className ? `panel ${className}` : 'panel'}>
{title !== undefined && (
<div className="phead">
<b>{title}</b>
{right !== undefined && <span className="r">{right}</span>}
</div>
)}
<div className="pbody" style={bodyStyle}>
{children}
</div>
</div>
)
}

View file

@ -0,0 +1,8 @@
/** The `.pop` provider selector. Mutation (opening the menu) lands in T8. */
export function ProviderPop({ label, onClick }: { label: string; onClick?: () => void }) {
return (
<div className="pop" role="button" tabIndex={0} onClick={onClick}>
{label}
</div>
)
}

View file

@ -0,0 +1,33 @@
export type SegOption = { value: string; label: string }
/** The `.seg` segmented control used for period and lens switching. */
export function SegTabs({
options,
value,
onChange,
style,
}: {
options: SegOption[]
value: string
onChange: (value: string) => void
style?: React.CSSProperties
}) {
return (
<div className="seg" style={style}>
{options.map(opt => (
<span
key={opt.value}
className={opt.value === value ? 'on' : undefined}
role="button"
tabIndex={0}
onClick={() => onChange(opt.value)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') onChange(opt.value)
}}
>
{opt.label}
</span>
))}
</div>
)
}

View file

@ -0,0 +1,28 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Sidebar } from './Sidebar'
describe('Sidebar', () => {
it('renders all six nav items', () => {
render(<Sidebar active="overview" onNavigate={() => {}} />)
expect(screen.getAllByRole('button')).toHaveLength(6)
for (const label of ['Overview', 'Spend', 'Optimize', 'Models', 'Plans', 'Settings']) {
expect(screen.getByRole('button', { name: new RegExp(label) })).toBeInTheDocument()
}
})
it('calls onNavigate with the section id when a nav item is clicked', () => {
const onNavigate = vi.fn()
render(<Sidebar active="overview" onNavigate={onNavigate} />)
fireEvent.click(screen.getByRole('button', { name: /Spend/ }))
expect(onNavigate).toHaveBeenCalledWith('spend')
})
it('marks the active item with the "on" class', () => {
render(<Sidebar active="models" onNavigate={() => {}} />)
expect(screen.getByRole('button', { name: /Models/ })).toHaveClass('on')
expect(screen.getByRole('button', { name: /Overview/ })).not.toHaveClass('on')
})
})

View file

@ -0,0 +1,46 @@
import type { ReactNode } from 'react'
export type Section = 'overview' | 'spend' | 'optimize' | 'models' | 'plans' | 'settings'
export const NAV_ITEMS: Array<{ id: Section; label: string; key: string }> = [
{ id: 'overview', label: 'Overview', key: '⌘1' },
{ id: 'spend', label: 'Spend', key: '⌘2' },
{ id: 'optimize', label: 'Optimize', key: '⌘3' },
{ id: 'models', label: 'Models', key: '⌘4' },
{ id: 'plans', label: 'Plans', key: '⌘5' },
{ id: 'settings', label: 'Settings', key: '⌘,' },
]
export function Sidebar({
active,
onNavigate,
status,
}: {
active: Section
onNavigate: (section: Section) => void
status?: ReactNode
}) {
return (
<nav className="sb">
<div className="lights"><i /><i /><i /></div>
<div className="app"><b>CodeBurn</b></div>
{NAV_ITEMS.map(item => (
<div
key={item.id}
className={item.id === active ? 'ni on' : 'ni'}
role="button"
tabIndex={0}
onClick={() => onNavigate(item.id)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id)
}}
>
{item.label}
<span className="k">{item.key}</span>
</div>
))}
<div className="push" />
<div className="status">{status}</div>
</nav>
)
}

View file

@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
export type StatTone = 'info' | 'bad' | 'hot' | 'ok'
/** A `.panel.stat` metric card: label strip + big value + tinted delta line. */
export function Stat({
label,
value,
delta,
tone,
}: {
label: ReactNode
value: ReactNode
delta?: ReactNode
tone?: StatTone
}) {
return (
<div className="panel stat">
<div className="phead"><b>{label}</b></div>
<div className="pbody">
<div className="v">{value}</div>
{delta !== undefined && <div className={tone ? `d ${tone}` : 'd'}>{delta}</div>}
</div>
</div>
)
}

View file

@ -0,0 +1,41 @@
import type { ReactNode } from 'react'
import { ProviderPop } from './ProviderPop'
import { SegTabs, type SegOption } from './SegTabs'
/** Full period vocabulary per the wireframe. 6M/Custom (date ranges) land in T8. */
export const PERIOD_OPTIONS: SegOption[] = [
{ value: 'today', label: 'Today' },
{ value: 'week', label: '7D' },
{ value: '30days', label: '30D' },
{ value: 'month', label: 'Month' },
{ value: '6m', label: '6M' },
{ value: 'custom', label: 'Custom' },
]
/** The `.bar` top bar: title, scope caption, period SegTabs, provider ProviderPop. */
export function TopBar({
title,
scope,
period,
onPeriodChange,
providerLabel,
onProviderClick,
}: {
title: ReactNode
scope?: ReactNode
period: string
onPeriodChange: (value: string) => void
providerLabel: string
onProviderClick?: () => void
}) {
return (
<div className="bar">
<div className="t">{title}</div>
{scope !== undefined && <span className="scope">{scope}</span>}
<div className="sp" />
<SegTabs options={PERIOD_OPTIONS} value={period} onChange={onPeriodChange} />
<ProviderPop label={providerLabel} onClick={onProviderClick} />
</div>
)
}

View file

@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
/** The `.win` shell: sidebar + content area live inside as children. */
export function Window({ children }: { children: ReactNode }) {
return <div className="win">{children}</div>
}

View file

@ -0,0 +1,60 @@
import { useCallback, useEffect, useState } from 'react'
import { normalizeCliError } from '../lib/ipc'
import type { CliError } from '../lib/types'
export type Polled<T> = {
data: T | null
error: CliError | null
loading: boolean
/** Re-run the fetcher immediately (period/provider change, manual refresh). */
refresh: () => void
}
/**
* Generic CLI-backed data hook: fetches on mount + whenever `deps` change, then
* re-polls every `intervalMs`. Errors are normalized to the CliError shape so
* sections can branch on `error.kind`. Last-good data is retained on error.
*/
export function usePolled<T>(fetcher: () => Promise<T>, deps: unknown[], intervalMs = 30_000): Polled<T> {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<CliError | null>(null)
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
let cancelled = false
setLoading(true)
fetcher()
.then(result => {
if (cancelled) return
setData(result)
setError(null)
})
.catch(err => {
if (!cancelled) setError(normalizeCliError(err))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
// deps are intentionally the caller-provided dependency list.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
useEffect(() => {
const cancel = load()
const id = setInterval(() => load(), intervalMs)
return () => {
cancel()
clearInterval(id)
}
}, [load, intervalMs])
const refresh = useCallback(() => {
load()
}, [load])
return { data, error, loading, refresh }
}

34
app/renderer/index.html Normal file
View file

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' ws: http://localhost:5173"
/>
<title>CodeBurn</title>
<style>
/* App-shell glue only — indigo.css is the verbatim wireframe port.
Make the window fill the OS window instead of the doc-page card. */
html,
body,
#root {
height: 100%;
margin: 0;
}
#root {
display: flex;
}
.win {
flex: 1;
min-width: 0;
border-radius: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

14
app/renderer/main.tsx Normal file
View file

@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles/indigo.css'
const root = document.getElementById('root')
if (!root) throw new Error('#root not found')
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)