From c04849a058ea3a56ccb3c02718a70263e13b8404 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 10:34:33 -0500 Subject: [PATCH 1/6] feat: add theme provider with persisted theme cookie --- src/features/Theme/ThemeProvider.tsx | 65 ++++++++++++++++++++++++++++ src/features/Theme/index.ts | 2 + src/features/Theme/theme.ts | 6 +++ 3 files changed, 73 insertions(+) create mode 100644 src/features/Theme/ThemeProvider.tsx create mode 100644 src/features/Theme/index.ts create mode 100644 src/features/Theme/theme.ts diff --git a/src/features/Theme/ThemeProvider.tsx b/src/features/Theme/ThemeProvider.tsx new file mode 100644 index 0000000..54c6dce --- /dev/null +++ b/src/features/Theme/ThemeProvider.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { THEME_COOKIE, type Theme } from './theme'; + +const THEME_MAX_AGE = 60 * 60 * 24 * 365; + +type ThemeContextValue = { + theme: Theme; + setTheme: (theme: Theme) => void; + previewTheme: (theme: Theme) => void; +}; + +const applyTheme = (theme: Theme) => { + if (typeof document === 'undefined') return; + document.documentElement.dataset.theme = theme; +}; + +const ThemeContext = createContext({ + theme: 'dark', + setTheme: applyTheme, + previewTheme: applyTheme, +}); + +export const ThemeProvider = ({ + initialTheme, + children, +}: { + initialTheme: Theme; + children: React.ReactNode; +}) => { + const [theme, setThemeState] = useState(initialTheme); + + const setTheme = useCallback((next: Theme) => { + setThemeState(next); + applyTheme(next); + + if (typeof document !== 'undefined') { + document.cookie = `${THEME_COOKIE}=${next}; path=/; max-age=${THEME_MAX_AGE}; samesite=lax`; + } + + try { + localStorage.setItem(THEME_COOKIE, next); + } catch {} + }, []); + + const previewTheme = useCallback((next: Theme) => applyTheme(next), []); + + const value = useMemo( + () => ({ theme, setTheme, previewTheme }), + [theme, setTheme, previewTheme], + ); + + return ( + {children} + ); +}; + +export const useTheme = () => useContext(ThemeContext); diff --git a/src/features/Theme/index.ts b/src/features/Theme/index.ts new file mode 100644 index 0000000..3483e31 --- /dev/null +++ b/src/features/Theme/index.ts @@ -0,0 +1,2 @@ +export { ThemeProvider, useTheme } from './ThemeProvider'; +export { resolveTheme, THEME_COOKIE, type Theme } from './theme'; diff --git a/src/features/Theme/theme.ts b/src/features/Theme/theme.ts new file mode 100644 index 0000000..0f003e9 --- /dev/null +++ b/src/features/Theme/theme.ts @@ -0,0 +1,6 @@ +export type Theme = 'dark' | 'light'; + +export const THEME_COOKIE = 'polycord_theme'; + +export const resolveTheme = (value: string | undefined): Theme => + value === 'light' ? 'light' : 'dark'; From 12dd9a8a321fdd107056693d370e63de75ebfa96 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 10:34:42 -0500 Subject: [PATCH 2/6] feat: add light theme tokens --- src/app/globals.css | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/app/globals.css b/src/app/globals.css index 35f6637..ec233e1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -41,6 +41,41 @@ --font-DM: "DM Sans", sans-serif; } +html[data-theme="light"] { + --color-primary: #2c3a49; + --color-primary-light: #1d2731; + --color-primary-lighter: #0f161d; + --color-primary-dark: #5b6b7b; + --color-primary-darker: #d9e3ee; + + --color-background-dark: #ffffff; + --color-background-light: #6b7280; + --color-background-darker: #eef1f5; + --color-background-main: #f3f5f8; + --color-background-lighter: #9aa3ae; + --color-background-darkest: #e2e6ec; +} + +html[data-theme="light"] body { + color: #1a1d22; +} + +html[data-theme="light"] .text-white { + color: #1a1d22; +} + +html[data-theme="light"] .border-white\/10 { + border-color: rgba(0, 0, 0, 0.08); +} + +html[data-theme="light"] .bg-white\/10 { + background-color: rgba(0, 0, 0, 0.06); +} + +html[data-theme="light"] .hover\:bg-\[\#161617\]:hover { + background-color: #e7eaf0; +} + @keyframes slideDown { from { transform: translateY(-10px); From d1b3b4a6880c6e9cc48d27dfd4a99326a7546d31 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 10:34:43 -0500 Subject: [PATCH 3/6] feat: apply persisted theme in root layout --- src/app/[lang]/layout.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index f61f7a7..d059d0a 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; +import { cookies } from 'next/headers'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; import { RouteProgressProvider } from '@/features/Navigation/RouteProgress'; +import { resolveTheme, THEME_COOKIE, ThemeProvider } from '@/features/Theme'; import { locales } from '@/utils/locales'; import '../globals.css'; @@ -27,12 +29,17 @@ export default async function RootLayout({ const messages = await getMessages({ locale: lang }); + const cookieStore = await cookies(); + const theme = resolveTheme(cookieStore.get(THEME_COOKIE)?.value); + return ( - + - - {children} - + + + {children} + + ); From dc9bb6776dc8613b7d8991133246312175f7cc0a Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 10:34:52 -0500 Subject: [PATCH 4/6] feat: apply theme live and limit settings language to locales --- src/features/Settings/SettingsPage.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index 5bd7a04..f9c6699 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -16,7 +16,9 @@ import { TextInput, Toggle, } from '@/components/Form'; -import { languageOptions, type TimeFormat } from '@/constants/languages'; +import { getLanguageName, type TimeFormat } from '@/constants/languages'; +import { useTheme } from '@/features/Theme'; +import { locales } from '@/utils/locales'; import { CompareTable } from './CompareTable'; import { type SettingsFormValues, settingsSchema } from './schema'; @@ -108,6 +110,7 @@ export const SettingsPage: React.FC = ({ }) => { const t = useTranslations('Settings'); const currentLocale = useLocale(); + const { theme: committedTheme, setTheme, previewTheme } = useTheme(); const [activeSection, setActiveSection] = useState('account'); const [exportStatus, setExportStatus] = useState< 'idle' | 'loading' | 'success' | 'error' @@ -121,9 +124,10 @@ export const SettingsPage: React.FC = ({ const initialValues: SettingsFormValues = useMemo( () => ({ ...defaultValues, + theme: committedTheme, timeFormat: defaultValues.timeFormat || getStoredTimeFormat(), }), - [defaultValues], + [defaultValues, committedTheme], ); const { @@ -162,6 +166,7 @@ export const SettingsPage: React.FC = ({ window.dispatchEvent(new Event('timeFormatChanged')); } + setTheme(data.theme); reset(data); } catch { setSaveStatus('error'); @@ -176,6 +181,7 @@ export const SettingsPage: React.FC = ({ const handleDiscard = () => { reset(); + previewTheme(committedTheme); setExportStatus('idle'); setDeleteStatus('idle'); setSaveStatus('idle'); @@ -211,9 +217,9 @@ export const SettingsPage: React.FC = ({ const localizedLanguageOptions = useMemo( () => - languageOptions(currentLocale).map((option) => ({ - ...option, - label: `${option.label} (${option.value.toUpperCase()})`, + locales.map((locale) => ({ + value: locale, + label: `${getLanguageName(locale, currentLocale)} (${locale.toUpperCase()})`, })), [currentLocale], ); @@ -537,7 +543,10 @@ export const SettingsPage: React.FC = ({ render={({ field }) => (