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
7 changes: 6 additions & 1 deletion apps/web/src/__tests__/components/AccountPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ vi.mock('#/lib/queryClient', () => ({
queryClient: { clear: vi.fn(), resetQueries: vi.fn() },
}));

import { ThemeProvider } from '#/lib/theme';
import { AccountPage } from '#/routes/_authenticated/account';

const makeClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });

function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={makeClient()}>{children}</QueryClientProvider>;
return (
<ThemeProvider>
<QueryClientProvider client={makeClient()}>{children}</QueryClientProvider>
</ThemeProvider>
);
}

const defaultResponse = {
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/__tests__/components/AuthenticatedLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,20 @@ vi.mock('#/lib/queryClient', () => ({
queryClient: { clear: vi.fn() },
}));

import { ThemeProvider } from '#/lib/theme';
import { AuthenticatedLayout } from '#/routes/_authenticated/route';

function Wrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}

describe('AuthenticatedLayout', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders navigation links', () => {
render(<AuthenticatedLayout />);
render(<AuthenticatedLayout />, { wrapper: Wrapper });
// "Job Finder" appears in both mobile header and desktop sidebar
expect(screen.getAllByText('Job Finder').length).toBeGreaterThanOrEqual(1);
// "Dashboard" / "Account" appear in sidebar and bottom nav
Expand All @@ -50,19 +55,19 @@ describe('AuthenticatedLayout', () => {
});

it('renders the Outlet for page content', () => {
render(<AuthenticatedLayout />);
render(<AuthenticatedLayout />, { wrapper: Wrapper });
expect(screen.getByTestId('outlet')).toBeInTheDocument();
});

it('renders a sign out button', () => {
render(<AuthenticatedLayout />);
render(<AuthenticatedLayout />, { wrapper: Wrapper });
const signOutBtns = screen.getAllByRole('button', { name: /sign out/i });
expect(signOutBtns.length).toBeGreaterThanOrEqual(1);
});

it('calls logout mutation and navigates to /login on sign out', async () => {
mockGqlRequest.mockResolvedValue({ logout: true });
render(<AuthenticatedLayout />);
render(<AuthenticatedLayout />, { wrapper: Wrapper });

// Click the first sign-out button (mobile header icon button)
fireEvent.click(screen.getAllByRole('button', { name: /sign out/i })[0]);
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
import '@testing-library/jest-dom/vitest';

if (typeof window.matchMedia !== 'function') {
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
});
}
59 changes: 59 additions & 0 deletions apps/web/src/lib/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createContext, useContext, useEffect, useState } from 'react';

export type Theme = 'light' | 'dark' | 'system';

export const THEME_STORAGE_KEY = 'theme';

function isTheme(value: string | null): value is Theme {
return value === 'light' || value === 'dark' || value === 'system';
}

function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

function applyResolvedTheme(theme: Theme): void {
const resolved = theme === 'system' ? getSystemTheme() : theme;
document.documentElement.classList.toggle('dark', resolved === 'dark');
}

// Inlined into the document head so the theme is applied before hydration
// and before first paint, avoiding a flash of the wrong theme.
export const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('${THEME_STORAGE_KEY}');var d=t==='dark'||((t==='system'||!t)&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(d)document.documentElement.classList.add('dark');}catch(e){}})();`;

interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
const stored = localStorage.getItem(THEME_STORAGE_KEY);
return isTheme(stored) ? stored : 'system';
});

useEffect(() => {
applyResolvedTheme(theme);
if (theme !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => applyResolvedTheme('system');
media.addEventListener('change', onChange);
return () => media.removeEventListener('change', onChange);
}, [theme]);

const setTheme = (next: Theme) => {
localStorage.setItem(THEME_STORAGE_KEY, next);
setThemeState(next);
};

return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
}

export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
return ctx;
}
20 changes: 12 additions & 8 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
import { TanStackDevtools } from '@tanstack/react-devtools';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '#/lib/queryClient';
import { THEME_INIT_SCRIPT, ThemeProvider } from '#/lib/theme';

import appCss from '../styles.css?url';

Expand Down Expand Up @@ -35,18 +36,21 @@ function NotFound() {

function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body>
<QueryClientProvider client={queryClient}>
{children}
<TanStackDevtools
config={{ position: 'bottom-right' }}
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]}
/>
</QueryClientProvider>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{children}
<TanStackDevtools
config={{ position: 'bottom-right' }}
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]}
/>
</QueryClientProvider>
</ThemeProvider>
<Scripts />
</body>
</html>
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/routes/_authenticated/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { useForm } from 'react-hook-form';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { MonitorIcon, MoonIcon, SunIcon } from 'lucide-react';
import { gqlClient } from '#/graphql/client';
import { clearAuthIndicator } from '#/lib/auth';
import { queryClient } from '#/lib/queryClient';
import { useTheme, type Theme } from '#/lib/theme';

export const Route = createFileRoute('/_authenticated/account')({
component: AccountPage,
Expand Down Expand Up @@ -252,11 +254,18 @@ const inputCls =

const labelCls = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';

const THEME_OPTIONS: { value: Theme; label: string; icon: React.ReactNode }[] = [
{ value: 'light', label: 'Light', icon: <SunIcon size={16} /> },
{ value: 'dark', label: 'Dark', icon: <MoonIcon size={16} /> },
{ value: 'system', label: 'System', icon: <MonitorIcon size={16} /> },
];

// ── Component ──────────────────────────────────────────────────────────────

export function AccountPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const { theme, setTheme } = useTheme();
Comment on lines 265 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restore the AccountPage test provider.

useTheme() now throws before the existing direct AccountPage renders, causing the reported 31 CI test failures. Wrap the shared account-page test render helper in ThemeProvider (or mock useTheme) so the existing form tests can run.

🧰 Tools
🪛 GitHub Actions: CI / 1_Test.txt

[error] 268-268: useTheme called without a ThemeProvider while rendering AccountPage (triggering: "useTheme must be used within a ThemeProvider").

🪛 GitHub Actions: CI / Test

[error] 268-268: useTheme must be used within a ThemeProvider (thrown while rendering AccountPage during tests)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/account.tsx` around lines 265 - 268,
Update the shared account-page test render helper to provide ThemeProvider
context before rendering AccountPage, or mock useTheme consistently in those
tests. Ensure direct AccountPage renders no longer throw while preserving the
existing form test behavior.

Sources: Linters/SAST tools, Pipeline failures


// Active sessions
const { data: sessionsData } = useQuery({
Expand Down Expand Up @@ -485,6 +494,36 @@ export function AccountPage() {
<div className="max-w-xl mx-auto px-4 sm:px-6 py-6 sm:py-10 space-y-10">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Account settings</h1>

{/* ── Appearance ── */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Appearance</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Choose how Job Finder looks on this device.
</p>
</div>
<div className="inline-flex rounded-lg border border-gray-300 dark:border-gray-600 p-1 gap-1">
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setTheme(option.value)}
aria-pressed={theme === option.value}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
theme === option.value
? 'bg-blue-600 text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{option.icon}
{option.label}
</button>
))}
</div>
</section>

<hr className="border-gray-200 dark:border-gray-700" />

{/* ── Profile ── */}
<section className="space-y-4">
<div>
Expand Down
47 changes: 39 additions & 8 deletions apps/web/src/routes/_authenticated/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { isAuthenticated, getIsAuthenticated } from '#/lib/auth';
import { gqlClient } from '#/graphql/client';
import { queryClient } from '#/lib/queryClient';
import { useTheme, type Theme } from '#/lib/theme';
import { useHotkeys } from '#/hooks/useHotkeys';
import { CommandPalette } from '#/components/CommandPalette';
import { ShortcutCheatSheet } from '#/components/ShortcutCheatSheet';
Expand All @@ -16,9 +17,35 @@ import {
BriefcaseIcon,
LayoutDashboardIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
SunIcon,
UserIcon,
} from 'lucide-react';

const THEME_CYCLE: Record<Theme, Theme> = { light: 'dark', dark: 'system', system: 'light' };
const THEME_ICON: Record<Theme, React.ReactNode> = {
light: <SunIcon size={18} />,
dark: <MoonIcon size={18} />,
system: <MonitorIcon size={18} />,
};
const THEME_LABEL: Record<Theme, string> = { light: 'Light', dark: 'Dark', system: 'System' };

function ThemeToggleButton({ className = '' }: { className?: string }) {
const { theme, setTheme } = useTheme();
return (
<button
type="button"
onClick={() => setTheme(THEME_CYCLE[theme])}
aria-label={`Theme: ${THEME_LABEL[theme]}. Click to switch theme.`}
title={`Theme: ${THEME_LABEL[theme]}`}
className={`p-2 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 rounded-lg transition-colors ${className}`}
>
{THEME_ICON[theme]}
</button>
);
}

const LOGOUT_MUTATION = `mutation { logout }`;

export const Route = createFileRoute('/_authenticated')({
Expand Down Expand Up @@ -51,19 +78,23 @@ export function AuthenticatedLayout() {
{/* Mobile top header */}
<header className="lg:hidden fixed top-0 inset-x-0 z-40 h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-4">
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">Job Finder</span>
<button
onClick={handleLogout}
aria-label="Sign out"
className="p-2 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 rounded-lg transition-colors"
>
<LogOutIcon size={18} />
</button>
<div className="flex items-center gap-1">
<ThemeToggleButton />
<button
onClick={handleLogout}
aria-label="Sign out"
className="p-2 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 rounded-lg transition-colors"
>
<LogOutIcon size={18} />
</button>
</div>
</header>

{/* Desktop sidebar */}
<aside className="hidden lg:flex w-60 flex-shrink-0 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex-col">
<div className="px-6 py-5 border-b border-gray-200 dark:border-gray-700">
<div className="px-6 py-5 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">Job Finder</span>
<ThemeToggleButton />
</div>

<nav className="flex-1 px-3 py-4 space-y-1">
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@import 'tailwindcss';

@custom-variant dark (&:where(.dark, .dark *));

* {
box-sizing: border-box;
}
Expand All @@ -13,3 +15,11 @@ body,
body {
margin: 0;
}

body,
body * {
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
Comment on lines +19 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Respect reduced-motion preferences.

This forces colour transitions on every descendant, including for users who request reduced motion. Add a prefers-reduced-motion: reduce override that disables or effectively shortens these transitions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/styles.css` around lines 19 - 25, Update the global transition
rule for body and its descendants to respect prefers-reduced-motion: reduce by
adding an override that disables or effectively shortens the color,
background-color, and border-color transitions, while preserving the existing
transitions for users without the preference.

Loading