Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions ui/src/__tests__/components/ui/alert-theme.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Alert variant="destructive" data-testid="a">
boom
</Alert>,
);
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(<Alert data-testid="b">note</Alert>);
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(<ThemeToggle />);
fireEvent.click(screen.getByTestId('theme-toggle'));
expect(setTheme).toHaveBeenCalledWith('dark');
});
});
31 changes: 18 additions & 13 deletions ui/src/app/chat/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,10 +31,16 @@ function ChatDetailInner({ id }: { id: string }) {
const [overlayMessages, setOverlayMessages] = useState<ReactiveMessage[]>([]);
const [streaming, setStreaming] = useState(false);
const [streamError, setStreamError] = useState<string | null>(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<AbortController | null>(null);

const serverMessages: ReactiveMessage[] = useMemo(
Expand Down Expand Up @@ -198,8 +205,9 @@ function ChatDetailInner({ id }: { id: string }) {
</div>

{!warningDismissed && (
<div
className="flex items-start justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"
<Alert

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The warningDismissed state is initialized by reading from sessionStorage directly during the initial render:

const [warningDismissed, setWarningDismissed] = useState(() => {
  if (typeof window === 'undefined') return false;
  return sessionStorage.getItem(SECRETS_WARNING_KEY) === '1';
});

Because this component is rendered on the server (where typeof window === 'undefined' is true), warningDismissed will always initialize to false on the server. If a user has previously dismissed the warning, the client will initialize it to true on the initial render. This mismatch between the server-rendered HTML (which includes the Alert banner) and the client-rendered HTML (which does not) will cause a hydration mismatch error in Next.js.

Recommended Fix

Initialize the state to false and read from sessionStorage inside a useEffect hook after the component has mounted:

const [warningDismissed, setWarningDismissed] = useState(false);

useEffect(() => {
  if (typeof window !== 'undefined') {
    setWarningDismissed(sessionStorage.getItem(SECRETS_WARNING_KEY) === '1');
  }
}, []);

variant="warning"
className="flex items-start justify-between gap-3"
data-testid="secrets-warning"
>
<span>
Expand All @@ -214,7 +222,7 @@ function ChatDetailInner({ id }: { id: string }) {
>
Dismiss
</button>
</div>
</Alert>
)}

<Card>
Expand All @@ -223,19 +231,16 @@ function ChatDetailInner({ id }: { id: string }) {
</CardHeader>
<CardContent className="space-y-3">
{conversation.isError ? (
<p className="text-sm text-red-700">
<p className="text-sm text-destructive">
Failed to load conversation: {conversation.error.message}
</p>
) : (
<MessageStream messages={localMessages} />
)}
{streamError && (
<div
className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700"
data-testid="stream-error"
>
<Alert variant="destructive" data-testid="stream-error">
{streamError}
</div>
</Alert>
)}
</CardContent>
</Card>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/chat/message-stream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function MessageRow({ message }: { message: ReactiveMessage }) {
<div className={isUser ? 'flex justify-end' : 'flex justify-start'}>
<Card
className={`max-w-2xl whitespace-pre-wrap p-3 text-sm ${
isUser ? 'border-blue-200 bg-blue-50/40' : 'border-gray-200 bg-white'
isUser ? 'border-primary/20 bg-primary/5' : 'border-border bg-card'
}`}
data-testid={`message-bubble-${message.role}`}
>
Expand Down
6 changes: 3 additions & 3 deletions ui/src/components/common/empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ export function EmptyState({ title, message, action, className }: EmptyStateProp
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-gray-200 bg-gray-50 p-12 text-center',
'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/40 p-12 text-center',
className,
)}
role="status"
data-testid="empty-state"
>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{message && <p className="max-w-md text-sm text-gray-600">{message}</p>}
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
{message && <p className="max-w-md text-sm text-muted-foreground">{message}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
Expand Down
16 changes: 10 additions & 6 deletions ui/src/components/common/metric-delta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,26 @@ export interface MetricDeltaProps {
*/
export function MetricDelta({ baseline, achieved, precision = 3, className }: MetricDeltaProps) {
if (achieved == null) {
return <span className={cn('text-gray-500', className)}>—</span>;
return <span className={cn('text-muted-foreground', className)}>—</span>;
}
if (baseline == null || baseline === 0) {
return (
<span className={cn('text-gray-700', className)}>
{achieved.toFixed(precision)} <span className="text-gray-500">(new)</span>
<span className={cn('tabular-nums text-foreground', className)}>
{achieved.toFixed(precision)} <span className="text-muted-foreground">(new)</span>
</span>
);
}
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';
Comment on lines +35 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The deltaPct calculation (on line 32) does not handle negative baselines correctly:

const deltaPct = ((achieved - baseline) / baseline) * 100;

If baseline is negative, a positive change (e.g., from -10 to -5) will result in a negative percentage, and a negative change (e.g., from -10 to -15) will result in a positive percentage. This will cause the wrong color class (text-green-700 vs text-red-700) to be applied here.

Recommended Fix

Update the deltaPct calculation to use Math.abs(baseline):

const deltaPct = ((achieved - baseline) / Math.abs(baseline)) * 100;

return (
<span className={cn('inline-flex items-baseline gap-1', className)}>
<span className="text-gray-700">
<span className={cn('inline-flex items-baseline gap-1 tabular-nums', className)}>
<span className="text-foreground">
{baseline.toFixed(precision)} → {achieved.toFixed(precision)}
</span>
<span className={cn('font-medium', colorClass)} data-testid="metric-delta-pct">
Expand Down
49 changes: 49 additions & 0 deletions ui/src/components/layout/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => setTheme(next)}
aria-label={mounted ? `Switch to ${next} theme` : 'Toggle theme'}
data-testid="theme-toggle"
>
{mounted ? <Icon className="size-4" aria-hidden="true" /> : <Sun className="size-4" />}
</Button>
);
}
18 changes: 11 additions & 7 deletions ui/src/components/layout/top-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -29,15 +30,17 @@ export function TopNav() {
const pathname = usePathname() ?? '/';
return (
<nav
className="border-b border-gray-200 bg-white"
className="border-b border-border bg-background"
aria-label="Primary navigation"
data-testid="top-nav"
>
<div className="mx-auto flex max-w-7xl items-center gap-6 px-6 py-3">
<Link href="/" className="text-base font-semibold tracking-tight">
<div className="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3 sm:gap-6 sm:px-6">
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
RelyLoop
</Link>
<ul className="flex items-center gap-1">
{/* Horizontal scroll below the breakpoint so the 9 items stay usable on
narrow screens instead of overflowing the viewport. */}
<ul className="flex flex-1 items-center gap-1 overflow-x-auto">
{NAV_ITEMS.map(({ href, label }) => {
const active = isActive(pathname, href);
return (
Expand All @@ -47,10 +50,10 @@ export function TopNav() {
data-active={active ? 'true' : 'false'}
aria-current={active ? 'page' : undefined}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
'block whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
active
? 'bg-gray-900 text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900',
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{label}
Expand All @@ -59,6 +62,7 @@ export function TopNav() {
);
})}
</ul>
<ThemeToggle />
</div>
</nav>
);
Expand Down
10 changes: 4 additions & 6 deletions ui/src/components/proposals/proposal-error-alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,17 @@

'use client';

import { Alert } from '@/components/ui/alert';

export interface ProposalErrorAlertProps {
error: string;
}

export function ProposalErrorAlert({ error }: ProposalErrorAlertProps) {
return (
<div
role="alert"
data-testid="proposal-error-alert"
className="rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-900"
>
<Alert variant="destructive" data-testid="proposal-error-alert">
<p className="font-semibold">Open-PR worker reported an error</p>
<p className="mt-1 whitespace-pre-wrap">{error}</p>
</div>
</Alert>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
36 changes: 36 additions & 0 deletions ui/src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>, VariantProps<typeof alertVariants> {}

export function Alert({ className, variant, ...props }: AlertProps) {
return <div role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
}

export { alertVariants };
18 changes: 12 additions & 6 deletions ui/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/ui/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
<div
ref={ref}
className={cn(
'rounded-lg border border-gray-200 bg-white text-gray-900 shadow-sm',
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
className,
)}
{...props}
Expand Down Expand Up @@ -41,7 +41,7 @@ const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-gray-600', className)} {...props} />
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';

Expand Down
Loading