diff --git a/ui/src/__tests__/components/ui/alert-theme.test.tsx b/ui/src/__tests__/components/ui/alert-theme.test.tsx new file mode 100644 index 00000000..4dace230 --- /dev/null +++ b/ui/src/__tests__/components/ui/alert-theme.test.tsx @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Alert } from '@/components/ui/alert'; + +const setTheme = vi.fn(); +let mockResolvedTheme = 'light'; +vi.mock('next-themes', () => ({ + useTheme: () => ({ resolvedTheme: mockResolvedTheme, theme: mockResolvedTheme, setTheme }), +})); + +describe('Alert primitive', () => { + it('renders role=alert and dark-safe variant classes', () => { + render( + + boom + , + ); + const el = screen.getByTestId('a'); + expect(el).toHaveAttribute('role', 'alert'); + // Keeps the light family AND a dark: pair (dark-safe). + expect(el.className).toContain('bg-red-50'); + expect(el.className).toContain('dark:bg-red-950'); + }); + + it('defaults to the info variant', () => { + render(note); + expect(screen.getByTestId('b').className).toContain('bg-blue-50'); + }); +}); + +describe('ThemeToggle', () => { + it('toggles resolved light → dark on click (always a visible change)', async () => { + mockResolvedTheme = 'light'; + const { ThemeToggle } = await import('@/components/layout/theme-toggle'); + render(); + fireEvent.click(screen.getByTestId('theme-toggle')); + expect(setTheme).toHaveBeenCalledWith('dark'); + }); +}); diff --git a/ui/src/app/chat/[id]/page.tsx b/ui/src/app/chat/[id]/page.tsx index f9a4802a..cb00f0ea 100644 --- a/ui/src/app/chat/[id]/page.tsx +++ b/ui/src/app/chat/[id]/page.tsx @@ -12,6 +12,7 @@ import { toast } from 'sonner'; import { Composer } from '@/components/chat/composer'; import { ExamplePrompts } from '@/components/chat/example-prompts'; import { MessageStream, type ReactiveMessage } from '@/components/chat/message-stream'; +import { Alert } from '@/components/ui/alert'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { type MessageWire, @@ -30,10 +31,16 @@ function ChatDetailInner({ id }: { id: string }) { const [overlayMessages, setOverlayMessages] = useState([]); const [streaming, setStreaming] = useState(false); const [streamError, setStreamError] = useState(null); - const [warningDismissed, setWarningDismissed] = useState(() => { - if (typeof window === 'undefined') return false; - return sessionStorage.getItem(SECRETS_WARNING_KEY) === '1'; - }); + // Initialize to false (matches the server render) and read sessionStorage + // after mount — reading it in the initial state would make the server render + // (always false) disagree with a client that has the dismissed flag, + // producing a hydration mismatch (Gemini review). + const [warningDismissed, setWarningDismissed] = useState(false); + useEffect(() => { + // Intentional one-shot mount read to avoid the SSR/client hydration mismatch. + // eslint-disable-next-line react-hooks/set-state-in-effect + if (sessionStorage.getItem(SECRETS_WARNING_KEY) === '1') setWarningDismissed(true); + }, []); const abortRef = useRef(null); const serverMessages: ReactiveMessage[] = useMemo( @@ -198,8 +205,9 @@ function ChatDetailInner({ id }: { id: string }) { {!warningDismissed && ( -
@@ -214,7 +222,7 @@ function ChatDetailInner({ id }: { id: string }) { > Dismiss -
+ )} @@ -223,19 +231,16 @@ function ChatDetailInner({ id }: { id: string }) { {conversation.isError ? ( -

+

Failed to load conversation: {conversation.error.message}

) : ( )} {streamError && ( -
+ {streamError} -
+ )}
diff --git a/ui/src/components/chat/message-stream.tsx b/ui/src/components/chat/message-stream.tsx index 1c2110df..66bdcba2 100644 --- a/ui/src/components/chat/message-stream.tsx +++ b/ui/src/components/chat/message-stream.tsx @@ -84,7 +84,7 @@ function MessageRow({ message }: { message: ReactiveMessage }) {
diff --git a/ui/src/components/common/empty-state.tsx b/ui/src/components/common/empty-state.tsx index f0e75d0e..655c368c 100644 --- a/ui/src/components/common/empty-state.tsx +++ b/ui/src/components/common/empty-state.tsx @@ -15,14 +15,14 @@ export function EmptyState({ title, message, action, className }: EmptyStateProp return (
-

{title}

- {message &&

{message}

} +

{title}

+ {message &&

{message}

} {action &&
{action}
}
); diff --git a/ui/src/components/common/metric-delta.tsx b/ui/src/components/common/metric-delta.tsx index 1025e809..6d2aefad 100644 --- a/ui/src/components/common/metric-delta.tsx +++ b/ui/src/components/common/metric-delta.tsx @@ -20,22 +20,26 @@ export interface MetricDeltaProps { */ export function MetricDelta({ baseline, achieved, precision = 3, className }: MetricDeltaProps) { if (achieved == null) { - return ; + return ; } if (baseline == null || baseline === 0) { return ( - - {achieved.toFixed(precision)} (new) + + {achieved.toFixed(precision)} (new) ); } const deltaPct = ((achieved - baseline) / baseline) * 100; const sign = deltaPct >= 0 ? '+' : ''; const colorClass = - deltaPct > 0 ? 'text-green-700' : deltaPct < 0 ? 'text-red-700' : 'text-gray-700'; + deltaPct > 0 + ? 'text-green-700 dark:text-green-400' + : deltaPct < 0 + ? 'text-red-700 dark:text-red-400' + : 'text-foreground'; return ( - - + + {baseline.toFixed(precision)} → {achieved.toFixed(precision)} diff --git a/ui/src/components/layout/theme-toggle.tsx b/ui/src/components/layout/theme-toggle.tsx new file mode 100644 index 00000000..385ef169 --- /dev/null +++ b/ui/src/components/layout/theme-toggle.tsx @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; + +/** + * Light ↔ dark theme toggle for the nav. next-themes is already wired + * (attribute="class", enableSystem); this is the missing user-facing control. + * + * Toggles off `resolvedTheme` (the actually-displayed theme, whether from an + * explicit choice or the system preference) so every click produces a visible + * change — a blind light→dark→system cycle can no-op on the first click when + * system already resolves to the theme it lands on (Gemini review). Renders a + * stable placeholder until mounted to avoid a hydration mismatch (the resolved + * theme is only known client-side). + */ +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + // Canonical next-themes hydration guard: the resolved theme is only known + // client-side, so render a stable placeholder until mount. The synchronous + // set-on-mount is intentional (one-shot, empty deps). + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => setMounted(true), []); + + const isDark = resolvedTheme === 'dark'; + const next = isDark ? 'light' : 'dark'; + const Icon = isDark ? Moon : Sun; + + return ( + + ); +} diff --git a/ui/src/components/layout/top-nav.tsx b/ui/src/components/layout/top-nav.tsx index 62e28b26..3d1e39c5 100644 --- a/ui/src/components/layout/top-nav.tsx +++ b/ui/src/components/layout/top-nav.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { ThemeToggle } from '@/components/layout/theme-toggle'; import { cn } from '@/lib/utils'; export const NAV_ITEMS = [ @@ -29,15 +30,17 @@ export function TopNav() { const pathname = usePathname() ?? '/'; return ( ); diff --git a/ui/src/components/proposals/proposal-error-alert.tsx b/ui/src/components/proposals/proposal-error-alert.tsx index 8fdc4527..8c034bde 100644 --- a/ui/src/components/proposals/proposal-error-alert.tsx +++ b/ui/src/components/proposals/proposal-error-alert.tsx @@ -4,19 +4,17 @@ 'use client'; +import { Alert } from '@/components/ui/alert'; + export interface ProposalErrorAlertProps { error: string; } export function ProposalErrorAlert({ error }: ProposalErrorAlertProps) { return ( -
+

Open-PR worker reported an error

{error}

-
+ ); } diff --git a/ui/src/components/studies/comparison/convergence-overlay.tsx b/ui/src/components/studies/comparison/convergence-overlay.tsx index 0f70194b..839728ac 100644 --- a/ui/src/components/studies/comparison/convergence-overlay.tsx +++ b/ui/src/components/studies/comparison/convergence-overlay.tsx @@ -81,6 +81,9 @@ export function ConvergenceOverlay({ llmCurve, ubiCurve }: ConvergenceOverlayPro dataKey="ubi" name="UBI" stroke="#16a34a" + // Dashed so the two series are distinguishable without relying + // on color alone (deuteranopia-safe). + strokeDasharray="5 4" connectNulls dot={false} /> diff --git a/ui/src/components/ui/alert.tsx b/ui/src/components/ui/alert.tsx new file mode 100644 index 00000000..99b84023 --- /dev/null +++ b/ui/src/components/ui/alert.tsx @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +/** + * Token-driven inline callout. Replaces the hand-rolled `border-*-200 bg-*-50` + * boxes scattered across the app (which were light-only and drifted in + * padding/weight). Variants keep the semantic color families but carry `dark:` + * pairs so they read correctly on a dark surface. + */ +const alertVariants = cva('rounded-md border p-3 text-sm', { + variants: { + variant: { + info: 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-200', + warning: + 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200', + destructive: + 'border-red-200 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200', + }, + }, + defaultVariants: { variant: 'info' }, +}); + +export interface AlertProps + extends React.HTMLAttributes, VariantProps {} + +export function Alert({ className, variant, ...props }: AlertProps) { + return
; +} + +export { alertVariants }; diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index e750d6d2..b57559e1 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -11,13 +11,19 @@ const badgeVariants = cva( 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { variants: { + // Light classes kept (semantic color families: blue=active, green=ok, + // amber=warn, red=fail); dark: variants added so chips read correctly on + // a dark surface instead of floating as light pastels. variant: { - default: 'border-transparent bg-blue-100 text-blue-900', - secondary: 'border-transparent bg-gray-100 text-gray-700', - destructive: 'border-transparent bg-red-100 text-red-900', - outline: 'border-gray-200 text-gray-700', - success: 'border-transparent bg-green-100 text-green-900', - warning: 'border-transparent bg-amber-100 text-amber-900', + default: 'border-transparent bg-blue-100 text-blue-900 dark:bg-blue-950 dark:text-blue-200', + secondary: + 'border-transparent bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200', + destructive: 'border-transparent bg-red-100 text-red-900 dark:bg-red-950 dark:text-red-200', + outline: 'border-gray-200 text-gray-700 dark:border-gray-700 dark:text-gray-200', + success: + 'border-transparent bg-green-100 text-green-900 dark:bg-green-950 dark:text-green-200', + warning: + 'border-transparent bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200', }, }, defaultVariants: { diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx index 9dd42820..0d328b76 100644 --- a/ui/src/components/ui/card.tsx +++ b/ui/src/components/ui/card.tsx @@ -10,7 +10,7 @@ const Card = React.forwardRef >(({ className, ...props }, ref) => ( -

+

)); CardDescription.displayName = 'CardDescription';