-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ux): theming foundation + dark mode + visual polish #620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const deltaPct = ((achieved - baseline) / baseline) * 100;If Recommended FixUpdate the 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"> | ||
|
|
||
| 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> | ||
| ); | ||
| } |
| 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 }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
warningDismissedstate is initialized by reading fromsessionStoragedirectly during the initial render:Because this component is rendered on the server (where
typeof window === 'undefined'is true),warningDismissedwill always initialize tofalseon the server. If a user has previously dismissed the warning, the client will initialize it totrueon the initial render. This mismatch between the server-rendered HTML (which includes theAlertbanner) and the client-rendered HTML (which does not) will cause a hydration mismatch error in Next.js.Recommended Fix
Initialize the state to
falseand read fromsessionStorageinside auseEffecthook after the component has mounted: