Initial commit: React frontend (SLP Software) + AIDLC workflow docs
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
DEFAULT_THEME,
|
||||
THEME_STORAGE_KEY,
|
||||
resolveInitialTheme,
|
||||
themeClassName,
|
||||
type ThemeName,
|
||||
} from './tokens';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeName;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function readStoredTheme(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
} catch {
|
||||
// BR-3: treat unavailable/unreadable storage the same as "nothing stored" — fall back to default.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistTheme(theme: ThemeName): void {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Ignore write failures (e.g. storage disabled) — theme still works for this session via state.
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<ThemeName>(() => resolveInitialTheme(readStoredTheme()));
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('theme-red', 'theme-purple');
|
||||
root.classList.add(themeClassName(theme));
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((current) => {
|
||||
const next: ThemeName = current === 'red' ? 'purple' : 'red';
|
||||
persistTheme(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { DEFAULT_THEME };
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ThemeProvider, useTheme } from '../ThemeProvider';
|
||||
import { THEME_STORAGE_KEY } from '../tokens';
|
||||
|
||||
function ThemeProbe() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="current-theme">{theme}</span>
|
||||
<button onClick={toggleTheme}>toggle</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.className = '';
|
||||
});
|
||||
|
||||
it('defaults to red when nothing is stored (BR-1)', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeProbe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('red');
|
||||
expect(document.documentElement.classList.contains('theme-red')).toBe(true);
|
||||
});
|
||||
|
||||
it('persists the chosen theme to localStorage on toggle (BR-2)', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeProbe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('purple');
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('purple');
|
||||
expect(document.documentElement.classList.contains('theme-purple')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the default theme when the stored value is invalid (BR-3)', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'blue');
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeProbe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('red');
|
||||
});
|
||||
|
||||
it('uses a valid stored theme on load', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'purple');
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeProbe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('purple');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Theme values (BR-3: only 'red' and 'purple' are valid).
|
||||
* Actual token colors live in src/index.css (.theme-red / .theme-purple);
|
||||
* this module only owns the *selectable* theme identifiers and the
|
||||
* localStorage key used for persistence (BR-2).
|
||||
*/
|
||||
|
||||
export type ThemeName = 'red' | 'purple';
|
||||
|
||||
export const THEMES: ThemeName[] = ['red', 'purple'];
|
||||
|
||||
export const DEFAULT_THEME: ThemeName = 'red';
|
||||
|
||||
export const THEME_STORAGE_KEY = 'slp-software-theme';
|
||||
|
||||
/** BR-3: fall back to the default theme for anything that isn't a valid ThemeName. */
|
||||
export function isValidTheme(value: unknown): value is ThemeName {
|
||||
return value === 'red' || value === 'purple';
|
||||
}
|
||||
|
||||
export function resolveInitialTheme(storedValue: string | null): ThemeName {
|
||||
if (isValidTheme(storedValue)) {
|
||||
return storedValue;
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
export function themeClassName(theme: ThemeName): string {
|
||||
return `theme-${theme}`;
|
||||
}
|
||||
Reference in New Issue
Block a user