From 8178c3d0b02c8a8b6560d33f75c6cfff9cd09e76 Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Sat, 11 Jul 2026 09:23:22 -0400 Subject: [PATCH 1/3] feat(ux): theming foundation + dark mode + visual polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX-audit "visual consistency, responsive & theming" pass. Root-cause fixes at the primitive level (the audit noted ~4 edits fix most of the drift): - Card + Badge primitives → theme tokens. Card was `bg-white text-gray-900`, propagating a permanently-light surface to 50+ consumers; now `bg-card text-card-foreground border-border`. Badge keeps its semantic color families but gains dark: variants so status chips read on a dark surface. - New token-driven Alert primitive (info/warning/destructive) replaces the hand-rolled `border-*-200 bg-*-50` callouts (proposal error, chat secrets warning + stream error) — consistent padding/weight and dark-safe. - Dark mode is now REACHABLE and functional: new ThemeToggle (light/dark/ system) in the nav wires the already-configured next-themes. Verified live (Playwright): the nav flips white→near-black, cards/text/borders all theme correctly. - Top nav → tokens + mobile: `bg-background border-border`, active pill uses the primary token, and the item row is `overflow-x-auto` so the 9 items stay usable on narrow screens (was a fixed non-wrapping row). - empty-state, chat bubbles, metric-delta → tokens (+ tabular-nums on the metric delta for digit alignment; dark: green/red delta colors). - Chart a11y: the LLM-vs-UBI convergence overlay distinguished series by color alone — the UBI line is now dashed (deuteranopia-safe). Deferred to a tracked follow-up: the long-tail hardcoded link/callout color sweep (~49 `text-blue-600` links), full chart theme-color migration + axis labels/units, and a centralized date formatter. A human dark-mode QA pass across every surface is recommended before declaring dark mode fully done. Co-Authored-By: Claude Opus 4.8 Signed-off-by: SoundMindsAI --- ui/src/app/chat/[id]/page.tsx | 17 ++++---- ui/src/components/chat/message-stream.tsx | 2 +- ui/src/components/common/empty-state.tsx | 6 +-- ui/src/components/common/metric-delta.tsx | 16 +++++--- ui/src/components/layout/theme-toggle.tsx | 41 +++++++++++++++++++ ui/src/components/layout/top-nav.tsx | 18 ++++---- .../proposals/proposal-error-alert.tsx | 10 ++--- .../comparison/convergence-overlay.tsx | 3 ++ ui/src/components/ui/alert.tsx | 36 ++++++++++++++++ ui/src/components/ui/badge.tsx | 18 +++++--- ui/src/components/ui/card.tsx | 4 +- 11 files changed, 131 insertions(+), 40 deletions(-) create mode 100644 ui/src/components/layout/theme-toggle.tsx create mode 100644 ui/src/components/ui/alert.tsx diff --git a/ui/src/app/chat/[id]/page.tsx b/ui/src/app/chat/[id]/page.tsx index f9a4802a..d1b352e1 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, @@ -198,8 +199,9 @@ function ChatDetailInner({ id }: { id: string }) { {!warningDismissed && ( -
@@ -214,7 +216,7 @@ function ChatDetailInner({ id }: { id: string }) { > Dismiss -
+ )} @@ -223,19 +225,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..a9b38c51 --- /dev/null +++ b/ui/src/components/layout/theme-toggle.tsx @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { Monitor, Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; + +/** + * Light / dark / system theme cycler for the nav. next-themes is already wired + * (attribute="class", enableSystem); this is the missing user-facing control. + * Renders a stable placeholder until mounted to avoid a hydration mismatch + * (the resolved theme is only known client-side). + */ +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const order = ['light', 'dark', 'system'] as const; + const current = (theme ?? 'system') as (typeof order)[number]; + const next = order[(order.indexOf(current) + 1) % order.length]!; + const Icon = current === 'dark' ? Moon : current === 'light' ? Sun : Monitor; + + 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'; From 35384ba06a96151a5387f84482bb3fd9af44724c Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Sat, 11 Jul 2026 09:26:36 -0400 Subject: [PATCH 2/3] test+lint(ux): Alert/ThemeToggle tests; disable set-state-in-effect on mount guard Signed-off-by: SoundMindsAI --- .../components/ui/alert-theme.test.tsx | 44 +++++++++++++++++++ ui/src/components/layout/theme-toggle.tsx | 4 ++ 2 files changed, 48 insertions(+) create mode 100644 ui/src/__tests__/components/ui/alert-theme.test.tsx 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..6e7835a3 --- /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 mockTheme = 'light'; +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: mockTheme, 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('cycles light → dark on click', async () => { + mockTheme = '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/components/layout/theme-toggle.tsx b/ui/src/components/layout/theme-toggle.tsx index a9b38c51..2b807347 100644 --- a/ui/src/components/layout/theme-toggle.tsx +++ b/ui/src/components/layout/theme-toggle.tsx @@ -19,6 +19,10 @@ import { Button } from '@/components/ui/button'; export function ThemeToggle() { const { theme, 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 order = ['light', 'dark', 'system'] as const; From dc57f3b5cc9c08ec7623185ff72abed9cf339b9a Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Sat, 11 Jul 2026 09:39:06 -0400 Subject: [PATCH 3/3] fix(ux): address Gemini review on PR #620 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThemeToggle: toggle off resolvedTheme (light↔dark) so every click is a visible change — a blind light→dark→system cycle no-ops on the first click when system already resolves to the landed theme. - chat secrets warning: initialize warningDismissed to false and read sessionStorage in a mount effect, avoiding the SSR/client hydration mismatch (server always rendered false; a client with the dismissed flag rendered true). Co-Authored-By: Claude Opus 4.8 Signed-off-by: SoundMindsAI --- .../components/ui/alert-theme.test.tsx | 8 +++--- ui/src/app/chat/[id]/page.tsx | 14 +++++++--- ui/src/components/layout/theme-toggle.tsx | 26 +++++++++++-------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/ui/src/__tests__/components/ui/alert-theme.test.tsx b/ui/src/__tests__/components/ui/alert-theme.test.tsx index 6e7835a3..4dace230 100644 --- a/ui/src/__tests__/components/ui/alert-theme.test.tsx +++ b/ui/src/__tests__/components/ui/alert-theme.test.tsx @@ -8,9 +8,9 @@ import { describe, expect, it, vi } from 'vitest'; import { Alert } from '@/components/ui/alert'; const setTheme = vi.fn(); -let mockTheme = 'light'; +let mockResolvedTheme = 'light'; vi.mock('next-themes', () => ({ - useTheme: () => ({ theme: mockTheme, setTheme }), + useTheme: () => ({ resolvedTheme: mockResolvedTheme, theme: mockResolvedTheme, setTheme }), })); describe('Alert primitive', () => { @@ -34,8 +34,8 @@ describe('Alert primitive', () => { }); describe('ThemeToggle', () => { - it('cycles light → dark on click', async () => { - mockTheme = 'light'; + 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')); diff --git a/ui/src/app/chat/[id]/page.tsx b/ui/src/app/chat/[id]/page.tsx index d1b352e1..cb00f0ea 100644 --- a/ui/src/app/chat/[id]/page.tsx +++ b/ui/src/app/chat/[id]/page.tsx @@ -31,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( diff --git a/ui/src/components/layout/theme-toggle.tsx b/ui/src/components/layout/theme-toggle.tsx index 2b807347..385ef169 100644 --- a/ui/src/components/layout/theme-toggle.tsx +++ b/ui/src/components/layout/theme-toggle.tsx @@ -4,20 +4,25 @@ 'use client'; -import { Monitor, Moon, Sun } from 'lucide-react'; +import { Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; /** - * Light / dark / system theme cycler for the nav. next-themes is already wired + * Light ↔ dark theme toggle for the nav. next-themes is already wired * (attribute="class", enableSystem); this is the missing user-facing control. - * Renders a stable placeholder until mounted to avoid a hydration mismatch - * (the resolved theme is only known client-side). + * + * 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 { theme, setTheme } = useTheme(); + 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 @@ -25,10 +30,9 @@ export function ThemeToggle() { // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => setMounted(true), []); - const order = ['light', 'dark', 'system'] as const; - const current = (theme ?? 'system') as (typeof order)[number]; - const next = order[(order.indexOf(current) + 1) % order.length]!; - const Icon = current === 'dark' ? Moon : current === 'light' ? Sun : Monitor; + const isDark = resolvedTheme === 'dark'; + const next = isDark ? 'light' : 'dark'; + const Icon = isDark ? Moon : Sun; return ( ); }