diff --git a/dashboard/src/__tests__/AnalyticsPage.test.tsx b/dashboard/src/__tests__/AnalyticsPage.test.tsx new file mode 100644 index 00000000..6b5556da --- /dev/null +++ b/dashboard/src/__tests__/AnalyticsPage.test.tsx @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '../i18n/context'; +import AnalyticsPage from '../pages/AnalyticsPage'; +import type { AnalyticsSummary, RateLimitAnalyticsResponse } from '../types'; + +const mockGetAnalyticsSummary = vi.fn(); +const mockGetRateLimitAnalytics = vi.fn(); + +vi.mock('../api/client', () => ({ + getAnalyticsSummary: (...args: unknown[]) => mockGetAnalyticsSummary(...args), + getRateLimitAnalytics: (...args: unknown[]) => mockGetRateLimitAnalytics(...args), +})); + +vi.mock('../store/useStore', () => ({ + useStore: vi.fn((sel: (s: Record) => unknown) => sel({ sseConnected: false, sseError: null })), +})); + +const mockAnalyticsSummary: AnalyticsSummary = { + sessionVolume: [ + { date: '2026-05-16', created: 5 }, + { date: '2026-05-17', created: 8 }, + { date: '2026-05-18', created: 3 }, + ], + tokenUsageByModel: [ + { model: 'claude-sonnet-4.6', inputTokens: 50000, outputTokens: 25000, cacheCreationTokens: 10000, cacheReadTokens: 5000, estimatedCostUsd: 12.50 }, + { model: 'claude-opus-4.7', inputTokens: 20000, outputTokens: 15000, cacheCreationTokens: 0, cacheReadTokens: 2000, estimatedCostUsd: 8.75 }, + ], + costTrends: [ + { date: '2026-05-16', cost: 5.20, sessions: 5 }, + { date: '2026-05-17', cost: 8.30, sessions: 8 }, + { date: '2026-05-18', cost: 7.75, sessions: 3 }, + ], + topApiKeys: [ + { keyId: 'key-1', keyName: 'ops-primary', sessions: 10, messages: 150, estimatedCostUsd: 15.00 }, + ], + durationTrends: [ + { date: '2026-05-16', avgDurationSec: 120, count: 5 }, + { date: '2026-05-17', avgDurationSec: 180, count: 8 }, + { date: '2026-05-18', avgDurationSec: 90, count: 3 }, + ], + errorRates: { + totalSessions: 16, + failedSessions: 1, + failureRate: 0.0625, + infraFailures: 0, + adjustedFailureRate: 0.0625, + permissionPrompts: 12, + approvals: 10, + autoApprovals: 8, + }, + generatedAt: '2026-05-18T12:00:00.000Z', +}; + +const mockRateLimitResponse: RateLimitAnalyticsResponse = { + global: { max: 100, timeWindowMs: 60000 }, + perKey: [ + { keyId: 'key-1', keyName: 'ops-primary', activeSessions: 3, maxSessions: 10, tokensInWindow: 5000, maxTokens: 100000, spendInWindowUsd: 2.50, maxSpendUsd: 50, windowMs: 60000 }, + ], + forecast: { estimatedSessionsRemaining: 7, bottleneck: null }, + generatedAt: '2026-05-18T12:00:00.000Z', +}; + +function renderPage(): void { + render( + + + + + , + ); +} + +describe('AnalyticsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state initially', () => { + mockGetAnalyticsSummary.mockReturnValue(new Promise(() => {})); + mockGetRateLimitAnalytics.mockReturnValue(new Promise(() => {})); + + renderPage(); + + expect(screen.getByRole('status')).toBeDefined(); + expect(screen.getByText('Loading analytics...')).toBeDefined(); + }); + + it('fetches and displays analytics data with KPI banner and chart sections', async () => { + mockGetAnalyticsSummary.mockResolvedValue(mockAnalyticsSummary); + mockGetRateLimitAnalytics.mockResolvedValue(mockRateLimitResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Analytics' })).toBeDefined(); + }); + + // KPI banner items rendered + expect(screen.getAllByText('Total Cost').length).toBeGreaterThan(0); + expect(screen.getAllByText('Total Tokens').length).toBeGreaterThan(0); + expect(screen.getAllByText('Sessions').length).toBeGreaterThan(0); + expect(screen.getAllByText('Avg Duration').length).toBeGreaterThan(0); + expect(screen.getAllByText('Error Rate').length).toBeGreaterThan(0); + + // Summary cards rendered + expect(screen.getAllByText('16').length).toBeGreaterThan(0); + + // Chart section headings + expect(screen.getAllByText('Session Volume Over Time').length).toBeGreaterThan(0); + expect(screen.getAllByText('Token Usage by Model').length).toBeGreaterThan(0); + expect(screen.getAllByText('Cost Trends (USD per Day)').length).toBeGreaterThan(0); + expect(screen.getAllByText('Top API Keys by Usage').length).toBeGreaterThan(0); + expect(screen.getAllByText('Avg Session Duration Over Time').length).toBeGreaterThan(0); + + // Top API key rendered + expect(screen.getByText('ops-primary')).toBeDefined(); + }); + + it('shows empty chart state when no data', async () => { + const emptySummary: AnalyticsSummary = { + ...mockAnalyticsSummary, + sessionVolume: [], + tokenUsageByModel: [], + costTrends: [], + topApiKeys: [], + durationTrends: [], + }; + mockGetAnalyticsSummary.mockResolvedValue(emptySummary); + mockGetRateLimitAnalytics.mockResolvedValue(mockRateLimitResponse); + + renderPage(); + + await waitFor(() => { + const emptyMessages = screen.getAllByText('No data available yet'); + expect(emptyMessages.length).toBeGreaterThanOrEqual(3); + }); + }); + + it('shows error state when API fails', async () => { + mockGetAnalyticsSummary.mockRejectedValue(new Error('Server error')); + mockGetRateLimitAnalytics.mockRejectedValue(new Error('Server error')); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /retry/i })).toBeDefined(); + }); + }); + + it('displays rate limit analytics section when available', async () => { + mockGetAnalyticsSummary.mockResolvedValue(mockAnalyticsSummary); + mockGetRateLimitAnalytics.mockResolvedValue(mockRateLimitResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Analytics' })).toBeDefined(); + }); + + // Rate limit data is passed to RateLimitChart + RateLimitForecastCard + expect(mockGetRateLimitAnalytics).toHaveBeenCalledTimes(1); + }); + + it('shows data consistency warning when sessions exist but charts have no data', async () => { + const inconsistentSummary: AnalyticsSummary = { + ...mockAnalyticsSummary, + sessionVolume: [], + tokenUsageByModel: [], + durationTrends: [], + // errorRates.totalSessions = 16 > 0, but chart arrays empty + }; + mockGetAnalyticsSummary.mockResolvedValue(inconsistentSummary); + mockGetRateLimitAnalytics.mockResolvedValue(mockRateLimitResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/Data aggregation in progress/)).toBeDefined(); + }); + }); + + it('renders model distribution bar when tokenUsageByModel has data', async () => { + mockGetAnalyticsSummary.mockResolvedValue(mockAnalyticsSummary); + mockGetRateLimitAnalytics.mockResolvedValue(null); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Model Distribution')).toBeDefined(); + expect(screen.getByText('claude-sonnet-4.6')).toBeDefined(); + expect(screen.getByText('claude-opus-4.7')).toBeDefined(); + }); + }); + + it('calculates error rate correctly and color-codes high error rates', async () => { + const highErrorSummary: AnalyticsSummary = { + ...mockAnalyticsSummary, + errorRates: { + totalSessions: 20, + failedSessions: 3, + failureRate: 0.15, + infraFailures: 0, + adjustedFailureRate: 0.15, + permissionPrompts: 5, + approvals: 4, + autoApprovals: 2, + }, + }; + mockGetAnalyticsSummary.mockResolvedValue(highErrorSummary); + mockGetRateLimitAnalytics.mockResolvedValue(null); + + renderPage(); + + await waitFor(() => { + // 3/20 = 15% > 5% threshold → should show "15.0%" in KPI banner + expect(screen.getAllByText('15.0%').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/dashboard/src/__tests__/ConfirmDialog.test.tsx b/dashboard/src/__tests__/ConfirmDialog.test.tsx index 2b783062..9b760010 100644 --- a/dashboard/src/__tests__/ConfirmDialog.test.tsx +++ b/dashboard/src/__tests__/ConfirmDialog.test.tsx @@ -169,6 +169,6 @@ describe('ConfirmDialog', () => { />, ); const confirmBtn = screen.getByText('Confirm'); - expect(confirmBtn.className).toContain('text-[var(--color-accent)]'); + expect(confirmBtn.className).toContain('text-[var(--color-cta-bg)]'); }); }); diff --git a/dashboard/src/__tests__/CostPage.test.tsx b/dashboard/src/__tests__/CostPage.test.tsx new file mode 100644 index 00000000..de54d143 --- /dev/null +++ b/dashboard/src/__tests__/CostPage.test.tsx @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '../i18n/context'; +import CostPage from '../pages/CostPage'; +import type { AnalyticsCostsResponse, CostSummaryResponse, CostByModelResponse } from '../types'; + +const mockGetAnalyticsCosts = vi.fn(); +const mockGetCostSummary = vi.fn(); +const mockGetCostByModel = vi.fn(); +const mockGetSessions = vi.fn(); +const mockGetBudgetSettings = vi.fn(); + +vi.mock('../api/client', () => ({ + getAnalyticsCosts: (...args: unknown[]) => mockGetAnalyticsCosts(...args), + getCostSummary: (...args: unknown[]) => mockGetCostSummary(...args), + getCostByModel: (...args: unknown[]) => mockGetCostByModel(...args), + getSessions: (...args: unknown[]) => mockGetSessions(...args), +})); + +vi.mock('../store/useStore', () => ({ + useStore: vi.fn((sel: (s: Record) => unknown) => sel({ sseConnected: false, sseError: null })), +})); + +vi.mock('../utils/budgetSettings', () => ({ + getBudgetSettings: () => mockGetBudgetSettings(), +})); + +const mockCostsResponse: AnalyticsCostsResponse = { + totalCostUsd: 45.67, + totalSessions: 42, + byModel: [ + { model: 'claude-sonnet-4.6', estimatedCostUsd: 30.00, inputTokens: 100000, outputTokens: 50000, cacheCreationTokens: 20000, cacheReadTokens: 10000 }, + { model: 'claude-opus-4.7', estimatedCostUsd: 15.67, inputTokens: 40000, outputTokens: 30000, cacheCreationTokens: 0, cacheReadTokens: 5000 }, + ], + byKey: [ + { keyId: 'key-1', keyName: 'ops-primary', estimatedCostUsd: 45.67, sessions: 42, messages: 500 }, + ], + dailyTrends: [ + { date: '2026-05-17', estimatedCostUsd: 5.20, sessions: 5 }, + { date: '2026-05-18', estimatedCostUsd: 8.30, sessions: 8 }, + { date: '2026-05-19', estimatedCostUsd: 7.75, sessions: 6 }, + { date: '2026-05-20', estimatedCostUsd: 12.42, sessions: 10 }, + { date: '2026-05-21', estimatedCostUsd: 6.00, sessions: 7 }, + { date: '2026-05-22', estimatedCostUsd: 4.00, sessions: 4 }, + { date: '2026-05-23', estimatedCostUsd: 2.00, sessions: 2 }, + ], + generatedAt: '2026-05-23T06:00:00.000Z', +}; + +const mockCostSummary: CostSummaryResponse = { + from: null, + to: null, + totalInputTokens: 140000, + totalOutputTokens: 80000, + totalCacheCreationTokens: 20000, + totalCacheReadTokens: 15000, + cacheHitRate: 0.35, + estimatedCostUsd: 45.67, + burnRateUsdPerHour: 1.50, + sessions: 42, +}; + +const mockCostByModel: CostByModelResponse = { + from: null, + to: null, + models: [ + { model: 'claude-sonnet-4.6', inputTokens: 100000, outputTokens: 50000, cacheCreationTokens: 20000, cacheReadTokens: 10000, estimatedCostUsd: 30.00, cacheHitRate: 0.40 }, + { model: 'claude-opus-4.7', inputTokens: 40000, outputTokens: 30000, cacheCreationTokens: 0, cacheReadTokens: 5000, estimatedCostUsd: 15.67, cacheHitRate: 0.25 }, + ], + totalModels: 2, + totalCostUsd: 45.67, +}; + +const emptySessionsResponse = { + sessions: [], + pagination: { page: 1, limit: 50, total: 0, totalPages: 0 }, +}; + +function setupMocks(overrides?: { costs?: AnalyticsCostsResponse | null; summary?: CostSummaryResponse | null; byModel?: CostByModelResponse | null }) { + mockGetAnalyticsCosts.mockResolvedValue(overrides?.costs ?? mockCostsResponse); + mockGetCostSummary.mockResolvedValue(overrides?.summary ?? mockCostSummary); + mockGetCostByModel.mockResolvedValue(overrides?.byModel ?? mockCostByModel); + mockGetSessions.mockResolvedValue(emptySessionsResponse); + mockGetBudgetSettings.mockReturnValue({ budgetDailyCapUsd: 100, budgetMonthlyCapUsd: 1000, budgetAlertEnabled: true }); +} + +function renderPage(): void { + render( + + + + + , + ); +} + +describe('CostPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading skeleton state with header', () => { + mockGetAnalyticsCosts.mockReturnValue(new Promise(() => {})); + mockGetCostSummary.mockReturnValue(new Promise(() => {})); + mockGetCostByModel.mockReturnValue(new Promise(() => {})); + mockGetSessions.mockReturnValue(new Promise(() => {})); + + renderPage(); + + expect(screen.getByText('Cost & Billing')).toBeDefined(); + }); + + it('shows empty state when no cost data', async () => { + const emptyCosts: AnalyticsCostsResponse = { + totalCostUsd: 0, + totalSessions: 0, + byModel: [], + byKey: [], + dailyTrends: [], + generatedAt: '2026-05-23T06:00:00.000Z', + }; + setupMocks({ costs: emptyCosts }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('No cost data yet')).toBeDefined(); + }); + }); + + it('shows error state when data fetch fails', async () => { + // Promise.allSettled absorbs rejections; getAnalyticsCosts rejection + // means costData stays null → component falls through to empty/error. + // Synchronous throw bypasses allSettled and hits outer catch → dataError set. + mockGetAnalyticsCosts.mockImplementation(() => { throw new Error('Server error'); }); + mockGetCostSummary.mockResolvedValue(mockCostSummary); + mockGetCostByModel.mockResolvedValue(mockCostByModel); + mockGetSessions.mockResolvedValue(emptySessionsResponse); + mockGetBudgetSettings.mockReturnValue({ budgetDailyCapUsd: 100, budgetMonthlyCapUsd: 1000, budgetAlertEnabled: true }); + + renderPage(); + + await waitFor(() => { + // ErrorState renders the error message + expect(screen.getAllByText('Server error').length).toBeGreaterThan(0); + }); + }); + + it('fetches and displays cost data with model names', async () => { + setupMocks(); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Cost & Billing')).toBeDefined(); + }); + + // Model names should be visible in the model breakdown + expect(screen.getAllByText('claude-sonnet-4.6').length).toBeGreaterThan(0); + expect(screen.getAllByText('claude-opus-4.7').length).toBeGreaterThan(0); + }); + + it('displays time range picker with 7d, 30d, and 90d options', async () => { + setupMocks(); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: '7 Days' })).toBeDefined(); + }); + expect(screen.getByRole('button', { name: '30 Days' })).toBeDefined(); + expect(screen.getByRole('button', { name: '90 Days' })).toBeDefined(); + }); + + it('switches active time range on click', async () => { + setupMocks(); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: '7 Days' })).toBeDefined(); + }); + + const sevenDayBtn = screen.getByRole('button', { name: '7 Days' }); + fireEvent.click(sevenDayBtn); + + expect(sevenDayBtn.getAttribute('aria-pressed')).toBe('true'); + }); + + it('renders budget overview section with disabled alerts', async () => { + // NOTE: BudgetOverview has a rule-of-hooks violation — useT() is called + // inside a conditional return. When budgetAlertEnabled=false, the + // warning path may not render correctly. This test verifies the page + // still renders without crashing when alerts are disabled. + setupMocks(); + mockGetBudgetSettings.mockReturnValue({ budgetDailyCapUsd: 100, budgetMonthlyCapUsd: 1000, budgetAlertEnabled: false }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Cost & Billing')).toBeDefined(); + }); + + // Page renders without crashing even with budget alerts disabled + expect(screen.getByText(/Daily Spend/)).toBeDefined(); + }); + + it('renders budget overview section when budgetAlertEnabled is true', async () => { + setupMocks(); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Cost & Billing')).toBeDefined(); + }); + + // Budget overview section should be present (BudgetProgressBar renders within it) + // The component renders daily + monthly progress bars and a forecast chart + expect(screen.getByText(/Daily Spend/)).toBeDefined(); + }); + + it('handles zero-cost edge case showing empty state', async () => { + const zeroCostResponse: AnalyticsCostsResponse = { + ...mockCostsResponse, + totalCostUsd: 0, + byModel: [], + dailyTrends: mockCostsResponse.dailyTrends.map(d => ({ ...d, estimatedCostUsd: 0 })), + }; + setupMocks({ costs: zeroCostResponse }); + + renderPage(); + + await waitFor(() => { + // All daily costs are zero → hasData check fails → empty state + expect(screen.getByText('No cost data yet')).toBeDefined(); + }); + }); +}); diff --git a/dashboard/src/__tests__/PipelineStatusBadge.test.tsx b/dashboard/src/__tests__/PipelineStatusBadge.test.tsx index e2f48ff0..d4ee1285 100644 --- a/dashboard/src/__tests__/PipelineStatusBadge.test.tsx +++ b/dashboard/src/__tests__/PipelineStatusBadge.test.tsx @@ -7,7 +7,7 @@ describe('PipelineStatusBadge', () => { render(); const badge = screen.getByText('running'); expect(badge).toBeDefined(); - expect(badge.className).toContain('text-cyan'); + expect(badge.className).toContain('text-[var(--color-accent-cyan)]'); }); it('renders completed status with green color', () => { diff --git a/dashboard/src/components/ActivityStream.tsx b/dashboard/src/components/ActivityStream.tsx index 329980dc..783a1c6b 100644 --- a/dashboard/src/components/ActivityStream.tsx +++ b/dashboard/src/components/ActivityStream.tsx @@ -29,17 +29,17 @@ interface ActivityStreamProps { } const EVENT_META: Record = { - session_status_change: { icon: RefreshCw, label: 'Status', color: 'var(--color-accent)' }, + session_status_change: { icon: RefreshCw, label: 'Status', color: 'var(--color-cta-bg)' }, session_message: { icon: MessageSquare, label: 'Message', color: 'var(--color-success)' }, session_approval: { icon: ShieldAlert, label: 'Approval', color: 'var(--color-warning)' }, session_ended: { icon: Power, label: 'Ended', color: 'var(--color-error)' }, session_created: { icon: PlusCircle, label: 'Created', color: 'var(--color-accent-indigo)' }, session_stall: { icon: AlertTriangle, label: 'Stall', color: 'var(--color-warning-dark)' }, session_dead: { icon: Skull, label: 'Dead', color: 'var(--color-error-dark)' }, - session_subagent_start: { icon: Users, label: 'Subagent', color: 'var(--color-accent)' }, + session_subagent_start: { icon: Users, label: 'Subagent', color: 'var(--color-cta-bg)' }, session_subagent_stop: { icon: UserCheck, label: 'Subagent Done', color: 'var(--color-success)' }, session_verification: { icon: ShieldAlert, label: 'Verification', color: 'var(--color-info)' }, - shutdown: { icon: Power, label: 'Shutdown', color: 'var(--color-accent)' }, + shutdown: { icon: Power, label: 'Shutdown', color: 'var(--color-cta-bg)' }, }; export function safeStr(val: unknown, fallback: string = 'unknown'): string { @@ -187,7 +187,7 @@ export default function ActivityStream({ setFilterType((e.target.value || null) as GlobalSSEEventType | null)} - className="min-h-[44px] text-xs bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded px-2 py-2 text-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-accent)]" + className="min-h-[44px] text-xs bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded px-2 py-2 text-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-cta-bg)]" > {Object.entries(EVENT_META).map(([key, meta]) => ( diff --git a/dashboard/src/components/ConfirmDialog.tsx b/dashboard/src/components/ConfirmDialog.tsx index e5be14aa..0d8f03ed 100644 --- a/dashboard/src/components/ConfirmDialog.tsx +++ b/dashboard/src/components/ConfirmDialog.tsx @@ -27,7 +27,7 @@ const VARIANT_STYLES = { }, default: { confirm: - 'bg-[var(--color-accent)]/10 hover:bg-[var(--color-accent)]/20 text-[var(--color-accent)] border border-[var(--color-accent)]/30', + 'bg-[var(--color-cta-bg)]/10 hover:bg-[var(--color-cta-bg)]/20 text-[var(--color-cta-bg)] border border-[var(--color-cta-bg)]/30', }, } as const; diff --git a/dashboard/src/components/CreatePipelineModal.tsx b/dashboard/src/components/CreatePipelineModal.tsx index 826a2a65..b721a128 100644 --- a/dashboard/src/components/CreatePipelineModal.tsx +++ b/dashboard/src/components/CreatePipelineModal.tsx @@ -145,7 +145,7 @@ export default function CreatePipelineModal({ open, onClose }: CreatePipelineMod onChange={(e) => setPipelineName(e.target.value)} placeholder="my-pipeline" aria-label={t("aria.pipelineName")} - className="w-full min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-accent)]" + className="w-full min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-cta-bg)]" /> @@ -166,21 +166,21 @@ export default function CreatePipelineModal({ open, onClose }: CreatePipelineMod value={step.workDir} onChange={(e) => updateStep(i, 'workDir', e.target.value)} placeholder="/home/user/project" - className="min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-accent)] font-mono" + className="min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-cta-bg)] font-mono" /> updateStep(i, 'name', e.target.value)} placeholder="name" - className="min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-accent)]" + className="min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-cta-bg)]" /> updateStep(i, 'prompt', e.target.value)} placeholder="Initial prompt..." - className="min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-accent)]" + className="min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus-visible:outline-none focus:border-[var(--color-cta-bg)]" /> diff --git a/dashboard/src/components/Layout.tsx b/dashboard/src/components/Layout.tsx index 4ec78e93..52b6982d 100644 --- a/dashboard/src/components/Layout.tsx +++ b/dashboard/src/components/Layout.tsx @@ -391,7 +391,7 @@ export default function Layout() { return ( -
+
{/* Skip-to-content link */} @@ -462,8 +462,8 @@ export default function Layout() { className={({ isActive }) => `flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-all min-h-[44px] ${ isActive - ? 'border-l-2 border-[var(--color-accent-on-light)] bg-[var(--color-accent-on-light)]/10 text-[var(--color-accent-on-light)] dark:border-cyan dark:bg-cyan/10 dark:text-cyan glow-nav-active' - : 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] border-l-2 border-transparent dark:text-[var(--color-text-muted)] dark:hover:bg-void-lighter dark:hover:text-[var(--color-text-primary)]' + ? 'border-l-2 border-[var(--color-accent-on-light)] bg-[var(--color-accent-on-light)]/10 text-[var(--color-accent-on-light)] dark:border-[var(--color-accent-cyan)] dark:bg-[var(--color-cta-bg)]/10 dark:text-[var(--color-accent-cyan)] glow-nav-active' + : 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] border-l-2 border-transparent dark:text-[var(--color-text-muted)] dark:hover:bg-[var(--color-void-lighter)] dark:hover:text-[var(--color-text-primary)]' } ${isCollapsed ? 'justify-center' : ''}` } title={isCollapsed ? label : undefined} @@ -495,8 +495,8 @@ export default function Layout() { className={({ isActive }) => `flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-all min-h-[44px] ${ isActive - ? 'border-l-2 border-[var(--color-accent-on-light)] bg-[var(--color-accent-on-light)]/10 text-[var(--color-accent-on-light)] dark:border-cyan dark:bg-cyan/10 dark:text-cyan glow-nav-active' - : 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] border-l-2 border-transparent dark:text-[var(--color-text-muted)] dark:hover:bg-void-lighter dark:hover:text-[var(--color-text-primary)]' + ? 'border-l-2 border-[var(--color-accent-on-light)] bg-[var(--color-accent-on-light)]/10 text-[var(--color-accent-on-light)] dark:border-[var(--color-accent-cyan)] dark:bg-[var(--color-cta-bg)]/10 dark:text-[var(--color-accent-cyan)] glow-nav-active' + : 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] border-l-2 border-transparent dark:text-[var(--color-text-muted)] dark:hover:bg-[var(--color-void-lighter)] dark:hover:text-[var(--color-text-primary)]' } ${isCollapsed ? 'justify-center' : ''}` } title={isCollapsed ? 'Settings' : undefined} @@ -509,7 +509,7 @@ export default function Layout() { @@ -590,11 +590,11 @@ export default function Layout() { {/* Version + theme toggle */} -
+
@@ -624,7 +624,7 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) {
{loadError ?? 'Session data is using polling fallback while real-time updates recover.'}
{!sseConnected && sseError && } @@ -632,7 +632,7 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) { )} {selectedIds.length > 0 && ( -
+
{selectedIds.length} session{selectedIds.length === 1 ? '' : 's'} selected
@@ -661,7 +661,7 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) { onClick={() => setSelectedIds([])} disabled={bulkAction !== null} aria-label={t("aria.clearSelection")} - className="min-h-[44px] rounded-md border border-void-lighter px-3 py-2 text-sm text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-void-lighter)] hover:text-[var(--color-text-primary)] disabled:pointer-events-none disabled:opacity-40" + className="min-h-[44px] rounded-md border border-[var(--color-void-lighter)] px-3 py-2 text-sm text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-void-lighter)] hover:text-[var(--color-text-primary)] disabled:pointer-events-none disabled:opacity-40" > Clear @@ -715,12 +715,12 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) { - + $ ag create "brief"
@@ -729,14 +729,14 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) { ) : ( <>
-
+
@@ -750,7 +750,7 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) {
-
+
- + @@ -842,7 +842,7 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) { {deferredSearch.length === 0 && pagination.totalPages > 1 && ( -
+
Page {pagination.page} of {pagination.totalPages} @@ -852,7 +852,7 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) { onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={pagination.page <= 1} aria-label={t("aria.prevPage")} - className="flex min-h-[44px] items-center gap-1 rounded-md border border-void-lighter px-3 py-2 transition-colors hover:border-[var(--color-void-lighter)] hover:text-[var(--color-text-primary)] disabled:pointer-events-none disabled:opacity-40" + className="flex min-h-[44px] items-center gap-1 rounded-md border border-[var(--color-void-lighter)] px-3 py-2 transition-colors hover:border-[var(--color-void-lighter)] hover:text-[var(--color-text-primary)] disabled:pointer-events-none disabled:opacity-40" > Previous @@ -861,7 +861,7 @@ export default function SessionTable({ maxRows }: SessionTableProps = {}) { onClick={() => setPage((current) => Math.min(pagination.totalPages, current + 1))} disabled={pagination.page >= pagination.totalPages} aria-label={t("aria.nextPage")} - className="flex min-h-[44px] items-center gap-1 rounded-md border border-void-lighter px-3 py-2 transition-colors hover:border-[var(--color-void-lighter)] hover:text-[var(--color-text-primary)] disabled:pointer-events-none disabled:opacity-40" + className="flex min-h-[44px] items-center gap-1 rounded-md border border-[var(--color-void-lighter)] px-3 py-2 transition-colors hover:border-[var(--color-void-lighter)] hover:text-[var(--color-text-primary)] disabled:pointer-events-none disabled:opacity-40" > Next diff --git a/dashboard/src/components/overview/StatusDot.tsx b/dashboard/src/components/overview/StatusDot.tsx index 5f0a9b6c..4a1d5a65 100644 --- a/dashboard/src/components/overview/StatusDot.tsx +++ b/dashboard/src/components/overview/StatusDot.tsx @@ -8,12 +8,12 @@ import type { SessionHealthState } from '../../types'; const STATUS_COLORS: Record = { idle: 'var(--color-success)', - working: 'var(--color-accent)', + working: 'var(--color-cta-bg)', permission_prompt: 'var(--color-warning)', bash_approval: 'var(--color-warning)', plan_mode: 'var(--color-dot-orange)', ask_question: 'var(--color-error)', - settings: 'var(--color-accent)', + settings: 'var(--color-cta-bg)', error: 'var(--color-dot-red)', rate_limit: 'var(--color-dot-red)', compacting: 'var(--color-warning)', diff --git a/dashboard/src/components/overview/VirtualizedSessionList.tsx b/dashboard/src/components/overview/VirtualizedSessionList.tsx index 7b91640b..b58f2752 100644 --- a/dashboard/src/components/overview/VirtualizedSessionList.tsx +++ b/dashboard/src/components/overview/VirtualizedSessionList.tsx @@ -183,7 +183,7 @@ function VirtualizedRow(props: { aria-label={`Select session ${formatSessionName(session.displayName, session.id.slice(0, 8))}`} checked={selected} onChange={(e) => onToggleSelect(session.id, e.target.checked)} - className="h-4 w-4 rounded border border-void-lighter bg-void text-cyan focus:ring-1 focus:ring-cyan" + className="h-4 w-4 rounded border border-[var(--color-void-lighter)] bg-[var(--color-void-dark)] text-[var(--color-accent-cyan)] focus:ring-1 focus:ring-[var(--color-accent-cyan)]" />
@@ -198,7 +198,7 @@ function VirtualizedRow(props: {
{formatSessionName(session.displayName, session.id.slice(0, 8))} @@ -224,7 +224,7 @@ function VirtualizedRow(props: { {session.permissionMode} ) : ( - + default )} @@ -234,7 +234,7 @@ function VirtualizedRow(props: {
{currentAction === 'working' && ( - + running @@ -315,10 +315,10 @@ export function VirtualizedSessionList({ }; return ( -
+
{showHeader && (
@@ -327,7 +327,7 @@ export function VirtualizedSessionList({ aria-label={t("aria.selectAll")} checked={allVisibleSelected} onChange={(e) => onToggleSelectAll(e.target.checked)} - className="h-4 w-4 rounded border border-void-lighter bg-void text-cyan focus:ring-1 focus:ring-cyan" + className="h-4 w-4 rounded border border-[var(--color-void-lighter)] bg-[var(--color-void-dark)] text-[var(--color-accent-cyan)] focus:ring-1 focus:ring-[var(--color-accent-cyan)]" />
Status
diff --git a/dashboard/src/components/pipeline/PipelineStatusBadge.tsx b/dashboard/src/components/pipeline/PipelineStatusBadge.tsx index 577ae770..64b15f9f 100644 --- a/dashboard/src/components/pipeline/PipelineStatusBadge.tsx +++ b/dashboard/src/components/pipeline/PipelineStatusBadge.tsx @@ -7,7 +7,7 @@ interface PipelineStatusBadgeProps { } const STATUS_STYLES: Record = { - running: 'bg-cyan/10 text-cyan border-cyan/30', + running: 'bg-[var(--color-cta-bg)]/10 text-[var(--color-accent-cyan)] border-[var(--color-accent-cyan)]/30', completed: 'bg-emerald-400/10 text-emerald-400 border-emerald-400/30', failed: 'bg-[var(--color-danger)]/10 text-[var(--color-danger)] border-[var(--color-danger)]/30', pending: 'bg-[var(--color-void-lighter)] text-[var(--color-text-muted)] border-[var(--color-void-lighter)]', diff --git a/dashboard/src/components/routines/CalendarGrid.tsx b/dashboard/src/components/routines/CalendarGrid.tsx index 607b003e..0185593f 100644 --- a/dashboard/src/components/routines/CalendarGrid.tsx +++ b/dashboard/src/components/routines/CalendarGrid.tsx @@ -157,9 +157,9 @@ export default function CalendarGrid({ onClick={() => onSelectDate(day)} disabled={!inCurrentMonth} className={` - relative p-2 min-h-[4rem] text-left transition-colors focus-visible:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:ring-inset + relative p-2 min-h-[4rem] text-left transition-colors focus-visible:outline-none focus:ring-2 focus:ring-[var(--color-cta-bg)] focus:ring-inset ${!inCurrentMonth ? 'opacity-30 cursor-default' : 'hover:bg-[var(--color-void-dark)] cursor-pointer'} - ${isSelected ? 'bg-[var(--color-accent)]/10 ring-1 ring-[var(--color-accent)]/30' : ''} + ${isSelected ? 'bg-[var(--color-cta-bg)]/10 ring-1 ring-[var(--color-cta-bg)]/30' : ''} `} aria-label={`${format(day, 'EEEE, MMMM d, yyyy')}${hasRoutines ? `, ${dayRoutines.length} routine${dayRoutines.length > 1 ? 's' : ''}` : ''}`} aria-current={today ? 'date' : undefined} @@ -167,7 +167,7 @@ export default function CalendarGrid({ {format(day, 'd')} diff --git a/dashboard/src/components/routines/RoutineCard.tsx b/dashboard/src/components/routines/RoutineCard.tsx index f9a7b4bc..a64f447d 100644 --- a/dashboard/src/components/routines/RoutineCard.tsx +++ b/dashboard/src/components/routines/RoutineCard.tsx @@ -113,7 +113,7 @@ export default function RoutineCard({ ); diff --git a/dashboard/src/components/session/DriverControlBar.tsx b/dashboard/src/components/session/DriverControlBar.tsx index a388acef..5a40b23d 100644 --- a/dashboard/src/components/session/DriverControlBar.tsx +++ b/dashboard/src/components/session/DriverControlBar.tsx @@ -92,7 +92,7 @@ export function DriverControlBar({ {/* Current driver indicator */}
- + {hasDriver ? ( Driver: {participants!.driver!.subscriberId} @@ -123,7 +123,7 @@ export function DriverControlBar({ diff --git a/dashboard/src/pages/NotFoundPage.tsx b/dashboard/src/pages/NotFoundPage.tsx index 14927dca..30adfd11 100644 --- a/dashboard/src/pages/NotFoundPage.tsx +++ b/dashboard/src/pages/NotFoundPage.tsx @@ -15,7 +15,7 @@ export default function NotFoundPage() {

{t('errors.notFound')}

{t('errors.goHome')} diff --git a/dashboard/src/pages/PipelineDetailPage.tsx b/dashboard/src/pages/PipelineDetailPage.tsx index 992afdcb..7147e2d8 100644 --- a/dashboard/src/pages/PipelineDetailPage.tsx +++ b/dashboard/src/pages/PipelineDetailPage.tsx @@ -142,8 +142,8 @@ export default function PipelineDetailPage() {
{/* Steps Table */} -
-
+
+

{t('pipelines.stepsLabel')} ({pipeline.stages.length})

@@ -156,7 +156,7 @@ export default function PipelineDetailPage() {
handleToggleSelectAll(e.target.checked)} - className="h-4 w-4 rounded border border-void-lighter bg-void text-cyan focus:ring-1 focus:ring-cyan" + className="h-4 w-4 rounded border border-[var(--color-void-lighter)] bg-[var(--color-void-dark)] text-[var(--color-accent-cyan)] focus:ring-1 focus:ring-[var(--color-accent-cyan)]" /> Status
- + @@ -167,7 +167,7 @@ export default function PipelineDetailPage() { {pipeline.stages.map((stage, i) => ( @@ -704,7 +704,7 @@ export default function SessionHistoryPage() { checked={selectedIds.has(record.id)} onChange={() => toggleSelect(record.id)} onClick={(e) => e.stopPropagation()} - className="h-4 w-4 rounded border-[var(--color-void-lighter)] bg-[var(--color-void-light)] text-[var(--color-accent-cyan)] focus:ring-cyan-500/30" + className="h-4 w-4 rounded border-[var(--color-void-lighter)] bg-[var(--color-void-light)] text-[var(--color-accent-cyan)] focus:ring-[var(--color-accent-cyan)]/30" /> diff --git a/dashboard/src/pages/SettingsPage.tsx b/dashboard/src/pages/SettingsPage.tsx index a3a07ba7..367bb9ae 100644 --- a/dashboard/src/pages/SettingsPage.tsx +++ b/dashboard/src/pages/SettingsPage.tsx @@ -280,7 +280,7 @@ export default function SettingsPage() { title={description} className={`min-h-[44px] px-2.5 py-1 text-xs rounded border transition-colors ${ theme === value || (theme === 'auto' && value === 'light') - ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)] font-medium dark:bg-[var(--color-accent)]/10 dark:text-[var(--color-accent)]' + ? 'border-[var(--color-cta-bg)] bg-[var(--color-cta-bg)]/10 text-[var(--color-cta-bg)] font-medium dark:bg-[var(--color-cta-bg)]/10 dark:text-[var(--color-cta-bg)]' : 'border-[var(--color-border-strong)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)]' }`} > @@ -338,7 +338,7 @@ export default function SettingsPage() { title={description} className={`min-h-[44px] px-2.5 py-1 text-xs rounded border transition-colors ${ readingFont === value - ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)] font-medium dark:bg-[var(--color-accent)]/10 dark:text-[var(--color-accent)]' + ? 'border-[var(--color-cta-bg)] bg-[var(--color-cta-bg)]/10 text-[var(--color-cta-bg)] font-medium dark:bg-[var(--color-cta-bg)]/10 dark:text-[var(--color-cta-bg)]' : 'border-[var(--color-border-strong)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)]' }`} >
# {t('pipelines.statusLabel')} {t('common.name')}
#{i + 1} @@ -179,7 +179,7 @@ export default function PipelineDetailPage() { {stage.sessionId ? ( {stage.name} diff --git a/dashboard/src/pages/RoutinesPage.tsx b/dashboard/src/pages/RoutinesPage.tsx index ef87eccf..e135a6fd 100644 --- a/dashboard/src/pages/RoutinesPage.tsx +++ b/dashboard/src/pages/RoutinesPage.tsx @@ -91,7 +91,7 @@ export default function RoutinesPage() { {t('sessionHistory.nameColumn')}