Skip to content
Open
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
47 changes: 47 additions & 0 deletions app/src/api/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* TypeScript types for theme configuration.
*
* These types describe the shape of theme-related data used across
* the frontend. They are intentionally kept separate from the React
* hook so that non-React utilities (e.g. Storybook, tests, SSR) can
* import them without pulling in React.
*/

/** Supported theme modes. */
export type ThemeMode = 'light' | 'dark' | 'system';

/** A single design-token value that varies by theme. */
export interface ThemeToken {
light: string;
dark: string;
}

/** Palette subset used for accessibility contrast checks. */
export interface ThemePalette {
background: ThemeToken;
foreground: ThemeToken;
primary: ThemeToken;
primaryForeground: ThemeToken;
muted: ThemeToken;
mutedForeground: ThemeToken;
border: ThemeToken;
}

/** Full theme configuration object. */
export interface ThemeConfig {
/** Display name shown in the settings UI. */
name: string;
/** Which mode this config represents. */
mode: ThemeMode;
/** Core colour palette. */
palette: ThemePalette;
/** Minimum contrast ratio target (WCAG AA = 4.5, AAA = 7). */
contrastTarget: number;
}

/** Persisted user preference stored in localStorage. */
export interface ThemePreference {
mode: ThemeMode;
/** ISO-8601 timestamp of last change. */
updatedAt: string;
}
81 changes: 81 additions & 0 deletions app/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useState, useEffect, useCallback } from 'react';

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

export interface UseThemeReturn {
/** Current persisted preference ('light' | 'dark' | 'system'). */
theme: ThemeMode;
/** Update the persisted preference and apply it immediately. */
setTheme: (mode: ThemeMode) => void;
/** Whether the effective (resolved) theme is dark right now. */
isDark: boolean;
}

const STORAGE_KEY = 'finmind-theme';

function getSystemPrefersDark(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}

function resolveEffective(mode: ThemeMode): boolean {
if (mode === 'system') return getSystemPrefersDark();
return mode === 'dark';
}

function readStored(): ThemeMode {
if (typeof window === 'undefined') return 'system';
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'light' || raw === 'dark' || raw === 'system') return raw;
return 'system';
}

function applyToDOM(dark: boolean): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.toggle('dark', dark);
root.setAttribute('data-theme', dark ? 'dark' : 'light');
}

/**
* React hook for theme management with dark/light/system support.
*
* - Persists the user preference to `localStorage`.
* - Applies a `dark` class + `data-theme` attribute on `<html>`.
* - Reacts to OS-level colour-scheme changes when set to `system`.
*
* Accessibility: toggling themes via `data-theme` lets CSS custom-properties
* drive contrast ratios, ensuring WCAG AA compliance when the design tokens
* are configured correctly.
*/
export function useTheme(): UseThemeReturn {
const [theme, setThemeState] = useState<ThemeMode>(readStored);
const [isDark, setIsDark] = useState(() => resolveEffective(readStored()));

const setTheme = useCallback((mode: ThemeMode) => {
localStorage.setItem(STORAGE_KEY, mode);
setThemeState(mode);
const dark = resolveEffective(mode);
setIsDark(dark);
applyToDOM(dark);
}, []);

// Apply on mount
useEffect(() => {
applyToDOM(isDark);
}, []); // eslint-disable-line react-hooks/exhaustive-deps

// Listen for OS-level changes when mode is 'system'
useEffect(() => {
if (theme !== 'system') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setIsDark(e.matches);
applyToDOM(e.matches);
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [theme]);

return { theme, setTheme, isDark };
}