diff --git a/app/src/components/shared/static/LegalPageLayout.tsx b/app/src/components/shared/static/LegalPageLayout.tsx
index 905a69501..13142d629 100644
--- a/app/src/components/shared/static/LegalPageLayout.tsx
+++ b/app/src/components/shared/static/LegalPageLayout.tsx
@@ -19,7 +19,7 @@ export default function LegalPageLayout({ title, sections }: LegalPageLayoutProp
style={{
paddingTop: spacing['4xl'],
paddingBottom: spacing['4xl'],
- backgroundColor: colors.white,
+ backgroundColor: colors.background.primary,
paddingLeft: '6.125%',
paddingRight: '6.125%',
}}
diff --git a/app/src/components/shared/static/SupportedProject.tsx b/app/src/components/shared/static/SupportedProject.tsx
index eea4f6653..051f6ea39 100644
--- a/app/src/components/shared/static/SupportedProject.tsx
+++ b/app/src/components/shared/static/SupportedProject.tsx
@@ -29,7 +29,7 @@ export default function SupportedProject({ project }: SupportedProjectProps) {
margin: '16px 0',
padding: '16px',
borderLeft: `4px solid ${colors.primary[500]}`,
- backgroundColor: colors.gray[50],
+ backgroundColor: colors.background.secondary,
}}
>
void;
+ toggleTheme: () => void;
+}
+
+const STORAGE_KEY = 'policyengine-theme';
+const MEDIA_QUERY = '(prefers-color-scheme: dark)';
+
+const fallbackThemeContext: ThemeContextValue = {
+ preference: 'system',
+ resolvedTheme: 'light',
+ setPreference: () => {},
+ toggleTheme: () => {},
+};
+
+const ThemeContext = createContext(null);
+
+function isThemePreference(value: string | null): value is ThemePreference {
+ return value === 'light' || value === 'dark' || value === 'system';
+}
+
+function getStoredPreference(): ThemePreference {
+ if (typeof window === 'undefined') {
+ return 'system';
+ }
+
+ try {
+ const storedPreference = window.localStorage.getItem(STORAGE_KEY);
+ return isThemePreference(storedPreference) ? storedPreference : 'system';
+ } catch {
+ return 'system';
+ }
+}
+
+function getSystemTheme(): ResolvedTheme {
+ if (typeof window === 'undefined') {
+ return 'light';
+ }
+
+ return window.matchMedia(MEDIA_QUERY).matches ? 'dark' : 'light';
+}
+
+function resolveTheme(preference: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme {
+ return preference === 'system' ? systemTheme : preference;
+}
+
+function applyTheme(theme: ResolvedTheme) {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ document.documentElement.dataset.peTheme = theme;
+ document.documentElement.style.colorScheme = theme;
+}
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [preference, setPreferenceState] = useState('system');
+ const [systemTheme, setSystemTheme] = useState('light');
+
+ useEffect(() => {
+ const storedPreference = getStoredPreference();
+ const currentSystemTheme = getSystemTheme();
+
+ setPreferenceState(storedPreference);
+ setSystemTheme(currentSystemTheme);
+ applyTheme(resolveTheme(storedPreference, currentSystemTheme));
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const mediaQuery = window.matchMedia(MEDIA_QUERY);
+ const handleChange = () => {
+ const nextSystemTheme = mediaQuery.matches ? 'dark' : 'light';
+ setSystemTheme(nextSystemTheme);
+
+ if (preference === 'system') {
+ applyTheme(nextSystemTheme);
+ }
+ };
+
+ mediaQuery.addEventListener('change', handleChange);
+ return () => mediaQuery.removeEventListener('change', handleChange);
+ }, [preference]);
+
+ const resolvedTheme = resolveTheme(preference, systemTheme);
+
+ useEffect(() => {
+ applyTheme(resolvedTheme);
+ }, [resolvedTheme]);
+
+ const setPreference = useCallback((nextPreference: ThemePreference) => {
+ setPreferenceState(nextPreference);
+
+ try {
+ if (nextPreference === 'system') {
+ window.localStorage.removeItem(STORAGE_KEY);
+ } else {
+ window.localStorage.setItem(STORAGE_KEY, nextPreference);
+ }
+ } catch {
+ // Ignore storage failures and keep the in-memory preference.
+ }
+ }, []);
+
+ const toggleTheme = useCallback(() => {
+ setPreference(resolvedTheme === 'dark' ? 'light' : 'dark');
+ }, [resolvedTheme, setPreference]);
+
+ const value = useMemo(
+ () => ({
+ preference,
+ resolvedTheme,
+ setPreference,
+ toggleTheme,
+ }),
+ [preference, resolvedTheme, setPreference, toggleTheme]
+ );
+
+ return {children};
+}
+
+export function useTheme() {
+ return useContext(ThemeContext) ?? fallbackThemeContext;
+}
diff --git a/app/src/designTokens/colors.ts b/app/src/designTokens/colors.ts
index 3d7b353f7..9f1327902 100644
--- a/app/src/designTokens/colors.ts
+++ b/app/src/designTokens/colors.ts
@@ -1,3 +1,5 @@
+const cssVar = (name: string) => `var(${name})`;
+
/**
* PolicyEngine color palette.
*
@@ -58,20 +60,24 @@ export const colors = {
},
background: {
- primary: '#FFFFFF',
- secondary: '#F5F9FF',
- tertiary: '#F1F5F9',
- sider: '#FFFFFF',
+ primary: cssVar('--tw-color-bg-primary'),
+ secondary: cssVar('--tw-color-bg-secondary'),
+ tertiary: cssVar('--tw-color-bg-tertiary'),
+ sider: cssVar('--tw-color-bg-primary'),
+ elevated: cssVar('--tw-color-card'),
+ accent: cssVar('--pe-color-surface-accent'),
+ overlay: cssVar('--pe-color-surface-overlay'),
+ hover: cssVar('--pe-color-surface-hover'),
},
text: {
- primary: '#000000',
- secondary: '#5A5A5A',
- tertiary: '#9CA3AF',
+ primary: cssVar('--tw-color-text-primary'),
+ secondary: cssVar('--tw-color-text-secondary'),
+ tertiary: cssVar('--tw-color-text-tertiary'),
inverse: '#FFFFFF',
- title: '#000000',
- link: '#2C7A7B',
- linkHover: '#285E61',
+ title: cssVar('--tw-color-text-primary'),
+ link: cssVar('--tw-color-text-link'),
+ linkHover: cssVar('--tw-color-text-link-hover'),
warning: '#d9480f',
},
@@ -80,15 +86,15 @@ export const colors = {
},
border: {
- light: '#E2E8F0',
- medium: '#CBD5E1',
- dark: '#94A3B8',
+ light: cssVar('--tw-color-border-light'),
+ medium: cssVar('--tw-color-border-medium'),
+ dark: cssVar('--tw-color-border-dark'),
},
shadow: {
- light: 'rgba(16, 24, 40, 0.05)',
- medium: 'rgba(16, 24, 40, 0.1)',
- dark: 'rgba(16, 24, 40, 0.2)',
+ light: cssVar('--pe-shadow-light'),
+ medium: cssVar('--pe-shadow-medium'),
+ dark: cssVar('--pe-shadow-dark'),
},
} as const;
diff --git a/app/src/tests/unit/contexts/ThemeContext.test.tsx b/app/src/tests/unit/contexts/ThemeContext.test.tsx
new file mode 100644
index 000000000..7a44c8cfd
--- /dev/null
+++ b/app/src/tests/unit/contexts/ThemeContext.test.tsx
@@ -0,0 +1,71 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
+
+function ThemeProbe() {
+ const { resolvedTheme, toggleTheme } = useTheme();
+
+ return (
+
+ );
+}
+
+function mockMatchMedia(matches: boolean) {
+ Object.defineProperty(window, 'matchMedia', {
+ configurable: true,
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+}
+
+describe('ThemeProvider', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute('data-pe-theme');
+ document.documentElement.style.colorScheme = '';
+ });
+
+ test('given no saved preference then follows system dark mode', async () => {
+ mockMatchMedia(true);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.peTheme).toBe('dark');
+ expect(screen.getByRole('button', { name: 'dark' })).toBeInTheDocument();
+ });
+ });
+
+ test('given dark mode then toggle stores a light preference', async () => {
+ mockMatchMedia(true);
+
+ render(
+
+
+
+ );
+
+ await screen.findByRole('button', { name: 'dark' });
+ fireEvent.click(screen.getByRole('button', { name: 'dark' }));
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.peTheme).toBe('light');
+ expect(localStorage.getItem('policyengine-theme')).toBe('light');
+ });
+ });
+});
diff --git a/app/test-utils/render.tsx b/app/test-utils/render.tsx
index 72288263b..cd4e8a2e0 100644
--- a/app/test-utils/render.tsx
+++ b/app/test-utils/render.tsx
@@ -6,6 +6,7 @@ import { AppProvider } from '../src/contexts/AppContext';
import { CountryProvider } from '../src/contexts/CountryContext';
import { LocationProvider } from '../src/contexts/LocationContext';
import { NavigationProvider } from '../src/contexts/NavigationContext';
+import { ThemeProvider } from '../src/contexts/ThemeContext';
import { store } from '../src/store';
function RouterContextBridge({ children }: { children: React.ReactNode }) {
@@ -30,15 +31,17 @@ export function render(ui: React.ReactNode, countryId: string = 'us') {
return testingLibraryRender(ui, {
wrapper: ({ children }: { children: React.ReactNode }) => (
-
-
-
-
- {children}
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
),
});
diff --git a/app/test-utils/renderWithCountry.tsx b/app/test-utils/renderWithCountry.tsx
index 3758b3bec..cc3c89076 100644
--- a/app/test-utils/renderWithCountry.tsx
+++ b/app/test-utils/renderWithCountry.tsx
@@ -6,6 +6,7 @@ import { AppMode, AppProvider } from '../src/contexts/AppContext';
import { CountryProvider } from '../src/contexts/CountryContext';
import { LocationProvider } from '../src/contexts/LocationContext';
import { NavigationProvider } from '../src/contexts/NavigationContext';
+import { ThemeProvider } from '../src/contexts/ThemeContext';
import { store } from '../src/store';
function RouterContextBridge({ children }: { children: React.ReactNode }) {
@@ -35,20 +36,22 @@ export function renderWithCountry(
return testingLibraryRender(ui, {
wrapper: ({ children }: { children: React.ReactNode }) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
),
});
diff --git a/app/website.html b/app/website.html
index ea65eb47b..b40f6ea58 100644
--- a/app/website.html
+++ b/app/website.html
@@ -18,6 +18,27 @@
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
/>
+
+
PolicyEngine
+ {resolvedTheme}
+
+ );
+}
+
+function mockMatchMedia(matches: boolean) {
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+}
+
+function mockLocalStorage() {
+ const store = new Map();
+ const storage = {
+ get length() {
+ return store.size;
+ },
+ key: (index: number) => Array.from(store.keys())[index] ?? null,
+ getItem: (key: string) => store.get(String(key)) ?? null,
+ setItem: (key: string, value: string) =>
+ store.set(String(key), String(value)),
+ removeItem: (key: string) => store.delete(String(key)),
+ clear: () => store.clear(),
+ };
+
+ Object.defineProperty(window, "localStorage", {
+ configurable: true,
+ writable: true,
+ value: storage,
+ });
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ writable: true,
+ value: storage,
+ });
+}
+
+describe("ThemeProvider", () => {
+ beforeEach(() => {
+ mockLocalStorage();
+ localStorage.clear();
+ document.documentElement.removeAttribute("data-pe-theme");
+ document.documentElement.style.colorScheme = "";
+ });
+
+ test("given no saved preference then follows system dark mode", async () => {
+ mockMatchMedia(true);
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.peTheme).toBe("dark");
+ expect(screen.getByRole("button", { name: "dark" })).toBeInTheDocument();
+ });
+ });
+
+ test("given dark mode then toggle stores a light preference", async () => {
+ mockMatchMedia(true);
+
+ render(
+
+
+ ,
+ );
+
+ await screen.findByRole("button", { name: "dark" });
+ fireEvent.click(screen.getByRole("button", { name: "dark" }));
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.peTheme).toBe("light");
+ expect(localStorage.getItem("policyengine-theme")).toBe("light");
+ });
+ });
+});
diff --git a/website/src/app/globals.css b/website/src/app/globals.css
index acf657ad5..2a6a4d55a 100644
--- a/website/src/app/globals.css
+++ b/website/src/app/globals.css
@@ -1,4 +1,4 @@
-@import 'tailwindcss' prefix(tw);
+@import "tailwindcss" prefix(tw);
@import "@policyengine/ui-kit/styles.css";
@theme {
@@ -83,8 +83,9 @@
--color-ring: #319795;
/* Typography */
- --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
+ --font-sans:
+ "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: "JetBrains Mono", "Fira Code", Consolas, monospace;
--font-size-xs: 12px;
--font-size-sm: 14px;
@@ -134,6 +135,112 @@
--spacing-container: 976px;
}
+:root,
+:root[data-pe-theme="light"] {
+ color-scheme: light;
+ --pe-color-surface-accent: #e6fffa;
+ --pe-color-surface-overlay: rgb(255 255 255 / 0.9);
+ --pe-color-surface-hover: #f9fafb;
+ --pe-shadow-light: rgb(16 24 40 / 0.05);
+ --pe-shadow-medium: rgb(16 24 40 / 0.1);
+ --pe-shadow-dark: rgb(16 24 40 / 0.2);
+
+ /* Compatibility variables for existing arbitrary Tailwind values. */
+ --color-primary-400: var(--tw-color-primary-400);
+ --color-primary-500: var(--tw-color-primary-500);
+ --color-primary-600: var(--tw-color-primary-600);
+}
+
+:root[data-pe-theme="dark"] {
+ color-scheme: dark;
+
+ --tw-color-primary-50: #0f302f;
+ --tw-color-primary-100: #15504e;
+ --tw-color-primary-200: #1f6f6c;
+ --tw-color-primary-300: #2b918d;
+ --tw-color-primary-400: #38b2ac;
+ --tw-color-primary-500: #4fd1c5;
+ --tw-color-primary-600: #63d9cf;
+ --tw-color-primary-700: #81e6d9;
+ --tw-color-primary-800: #b2f5ea;
+ --tw-color-primary-900: #e6fffa;
+
+ --tw-color-secondary-50: #101a1c;
+ --tw-color-secondary-100: #142225;
+ --tw-color-secondary-200: #1e3337;
+ --tw-color-secondary-300: #2b484d;
+ --tw-color-secondary-400: #49666a;
+ --tw-color-secondary-500: #73898b;
+ --tw-color-secondary-600: #9fb0b0;
+ --tw-color-secondary-700: #c5d0cf;
+ --tw-color-secondary-800: #e1e8e7;
+ --tw-color-secondary-900: #f5f8f8;
+
+ --tw-color-gray-50: #0d1719;
+ --tw-color-gray-100: #132124;
+ --tw-color-gray-200: #1d3034;
+ --tw-color-gray-300: #2c454a;
+ --tw-color-gray-400: #587276;
+ --tw-color-gray-500: #8ca0a2;
+ --tw-color-gray-600: #b5c4c5;
+ --tw-color-gray-700: #d3dddc;
+ --tw-color-gray-800: #ebf0ef;
+ --tw-color-gray-900: #f7fafa;
+
+ --tw-color-bg-primary: #071112;
+ --tw-color-bg-secondary: #0d1c1e;
+ --tw-color-bg-tertiary: #13272a;
+ --tw-color-text-primary: #f7fafa;
+ --tw-color-text-secondary: #bdcaca;
+ --tw-color-text-tertiary: #899fa1;
+ --tw-color-text-inverse: #061314;
+ --tw-color-text-link: #63d9cf;
+ --tw-color-text-link-hover: #81e6d9;
+ --tw-color-border-light: #20383c;
+ --tw-color-border-medium: #315257;
+ --tw-color-border-dark: #537276;
+
+ --tw-color-background: #071112;
+ --tw-color-foreground: #f7fafa;
+ --tw-color-primary: #4fd1c5;
+ --tw-color-primary-foreground: #061314;
+ --tw-color-secondary: #132124;
+ --tw-color-secondary-foreground: #f7fafa;
+ --tw-color-muted: #132124;
+ --tw-color-muted-foreground: #bdcaca;
+ --tw-color-accent: #173235;
+ --tw-color-accent-foreground: #f7fafa;
+ --tw-color-popover: #0d1c1e;
+ --tw-color-popover-foreground: #f7fafa;
+ --tw-color-card: #0d1c1e;
+ --tw-color-card-foreground: #f7fafa;
+ --tw-color-border: #20383c;
+ --tw-color-input: #20383c;
+ --tw-color-ring: #4fd1c5;
+
+ --pe-color-surface-accent: #102d2c;
+ --pe-color-surface-overlay: rgb(13 28 30 / 0.9);
+ --pe-color-surface-hover: #173235;
+ --pe-shadow-light: rgb(0 0 0 / 0.28);
+ --pe-shadow-medium: rgb(0 0 0 / 0.36);
+ --pe-shadow-dark: rgb(0 0 0 / 0.48);
+}
+
+:root[data-pe-theme="dark"] .tw\:bg-white {
+ background-color: var(--tw-color-card);
+}
+
+:root[data-pe-theme="dark"] .tw\:text-gray-900,
+:root[data-pe-theme="dark"] .tw\:text-gray-800,
+:root[data-pe-theme="dark"] .tw\:text-gray-700 {
+ color: var(--tw-color-foreground);
+}
+
+:root[data-pe-theme="dark"] .tw\:bg-gray-50,
+:root[data-pe-theme="dark"] .tw\:bg-gray-100 {
+ background-color: var(--tw-color-bg-secondary);
+}
+
@layer base {
*,
::before,
@@ -166,7 +273,7 @@
width: 100%;
}
- [data-slot='table-container'],
+ [data-slot="table-container"],
tr {
background-color: var(--tw-color-bg-primary);
}
@@ -185,13 +292,13 @@
background-color: var(--tw-color-bg-primary);
}
- input[type='number']::-webkit-inner-spin-button,
- input[type='number']::-webkit-outer-spin-button {
+ input[type="number"]::-webkit-inner-spin-button,
+ input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
- input[type='number'] {
+ input[type="number"] {
-moz-appearance: textfield;
}
}
diff --git a/website/src/app/layout.tsx b/website/src/app/layout.tsx
index 41aaa2447..01bc947bc 100644
--- a/website/src/app/layout.tsx
+++ b/website/src/app/layout.tsx
@@ -2,9 +2,26 @@ import type { Metadata } from "next";
import Script from "next/script";
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
+import { ThemeProvider } from "@/contexts/ThemeContext";
import "./globals.css";
const GA_MEASUREMENT_ID = "G-2YHG89FY0N";
+const THEME_INIT_SCRIPT = `
+(function() {
+ try {
+ var storedPreference = window.localStorage.getItem('policyengine-theme');
+ var preference = storedPreference === 'light' || storedPreference === 'dark'
+ ? storedPreference
+ : 'system';
+ var theme = preference === 'system'
+ ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
+ : preference;
+
+ document.documentElement.dataset.peTheme = theme;
+ document.documentElement.style.colorScheme = theme;
+ } catch (error) {}
+})();
+`;
export const metadata: Metadata = {
metadataBase: new URL("https://www.policyengine.org"),
@@ -25,8 +42,14 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
+
+
+