From 55e27c58b14a2984f80bfc192a0fc2684510f5de Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Wed, 16 Sep 2026 23:48:39 -0400 Subject: [PATCH 1/2] feat: wire up theme providers and sync --- client/src/components/ThemeSync.tsx | 37 +++++++++ client/src/index.tsx | 21 ++++- client/src/models/theme.ts | 22 +++++ client/src/styles/globals.css | 2 +- client/src/webpages/App.tsx | 2 +- client/src/webpages/dashboard/Dashboard.css | 2 +- client/src/webpages/dashboard/Dashboard.tsx | 8 +- .../src/webpages/settings/AccountSettings.tsx | 82 +++++++++++++++++++ 8 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 client/src/components/ThemeSync.tsx create mode 100644 client/src/models/theme.ts diff --git a/client/src/components/ThemeSync.tsx b/client/src/components/ThemeSync.tsx new file mode 100644 index 000000000..dd4b19f16 --- /dev/null +++ b/client/src/components/ThemeSync.tsx @@ -0,0 +1,37 @@ +import { gql } from '@apollo/client'; +import { useTheme } from 'next-themes'; +import { useEffect } from 'react'; + +import { useGQLUserThemePreferenceQuery } from '../graphql/generated'; +import { NEXT_THEME_FOR_PREFERENCE } from '../models/theme'; + +gql` + query UserThemePreference { + me { + id + interfacePreferences { + themePreference + } + } + } +`; + +/** + * Applies the signed-in user's persisted theme preference to next-themes. + * Renders nothing; mount once inside the authenticated shell so it never + * runs on the login/signup pages (those follow the system scheme). + */ +export default function ThemeSync() { + const { setTheme } = useTheme(); + const { data } = useGQLUserThemePreferenceQuery(); + + const themePreference = data?.me?.interfacePreferences?.themePreference; + + useEffect(() => { + if (themePreference) { + setTheme(NEXT_THEME_FOR_PREFERENCE[themePreference]); + } + }, [themePreference, setTheme]); + + return null; +} diff --git a/client/src/index.tsx b/client/src/index.tsx index e28b7498c..c0920aba0 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -11,6 +11,7 @@ import { } from '@apollo/client'; import { KeyFieldsContext } from '@apollo/client/cache/inmemory/policies'; import { RetryLink } from '@apollo/client/link/retry'; +import { ThemeProvider } from 'next-themes'; import { createRoot } from 'react-dom/client'; import { HelmetProvider } from 'react-helmet-async'; import stringify from 'safe-stable-stringify'; @@ -148,10 +149,22 @@ const root = createRoot(document.getElementById('root')!); root.render( - - - - + {/* defaultTheme is "light" (not "system") until the app-wide dark + restyle lands — otherwise every dark-OS user gets unconverted + components on day one. Users can still opt into System/Dark in + account settings. */} + + + + + + , ); diff --git a/client/src/models/theme.ts b/client/src/models/theme.ts new file mode 100644 index 000000000..d3aca4361 --- /dev/null +++ b/client/src/models/theme.ts @@ -0,0 +1,22 @@ +import { GQLThemePreference } from '../graphql/generated'; + +export const THEME_PREFERENCES = [ + GQLThemePreference.System, + GQLThemePreference.Light, + GQLThemePreference.Dark, +] as const; + +export const THEME_PREFERENCE_LABELS: Record = { + [GQLThemePreference.System]: 'System', + [GQLThemePreference.Light]: 'Light', + [GQLThemePreference.Dark]: 'Dark', +}; + +export const NEXT_THEME_FOR_PREFERENCE: Record< + GQLThemePreference, + 'system' | 'light' | 'dark' +> = { + [GQLThemePreference.System]: 'system', + [GQLThemePreference.Light]: 'light', + [GQLThemePreference.Dark]: 'dark', +}; diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index 1e451f22b..5fab08383 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -31,7 +31,7 @@ body { @layer base { :root { - --background: 0 0% 100%; + --background: 0 0% 97.6%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; diff --git a/client/src/webpages/App.tsx b/client/src/webpages/App.tsx index 9b87bbe93..66204a168 100644 --- a/client/src/webpages/App.tsx +++ b/client/src/webpages/App.tsx @@ -163,7 +163,7 @@ export default function App() { ); return ( -
+
); diff --git a/client/src/webpages/dashboard/Dashboard.css b/client/src/webpages/dashboard/Dashboard.css index 6e132a337..35a2cb3c3 100644 --- a/client/src/webpages/dashboard/Dashboard.css +++ b/client/src/webpages/dashboard/Dashboard.css @@ -42,7 +42,7 @@ body { @layer base { :root { - --background: 0 0% 100%; + --background: 0 0% 97.6%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; diff --git a/client/src/webpages/dashboard/Dashboard.tsx b/client/src/webpages/dashboard/Dashboard.tsx index 71c08d14f..5fa83de09 100644 --- a/client/src/webpages/dashboard/Dashboard.tsx +++ b/client/src/webpages/dashboard/Dashboard.tsx @@ -26,6 +26,7 @@ import useDynamicLegacyCSS from '@/hooks/useDynamicLegacyCSS'; import ErrorBoundary from '@/components/ErrorBoundary'; import Sidebar, { MenuItem } from '@/components/Sidebar'; +import ThemeSync from '@/components/ThemeSync'; import { GQLUserPermission, @@ -704,11 +705,14 @@ export default function Dashboard() { return (
Home + {isUsingLegacyCSS ? ( <> -
+
( + GQLThemePreference.Light, + ); + + useEffect(() => { + const savedPreference = + themePreferenceData?.me?.interfacePreferences?.themePreference; + if (savedPreference) { + setThemePreference(savedPreference); + } + }, [themePreferenceData?.me?.interfacePreferences?.themePreference]); + + // Theme changes apply (and persist) immediately rather than waiting for + // the Save button, so the user gets instant feedback and can back out. + const handleThemePreferenceChange = useCallback( + (value: string) => { + const preference = value as GQLThemePreference; + const previous = themePreference; + setThemePreference(preference); + setTheme(NEXT_THEME_FOR_PREFERENCE[preference]); + saveThemePreference({ + variables: { themePreference: preference }, + refetchQueries: [namedOperations.Query.UserThemePreference], + }).catch(() => { + setThemePreference(previous); + setTheme(NEXT_THEME_FOR_PREFERENCE[previous]); + toast.error('Error Saving Theme', { + description: + "We couldn't save your theme preference. Please try again.", + }); + }); + }, + [themePreference, setTheme, saveThemePreference], + ); + const [changePassword, { loading: isChangePasswordLoading }] = useGQLChangePasswordMutation({ onError: (error) => { @@ -635,6 +689,34 @@ export default function AccountSettings() {
)} +
+ + Appearance + + + Choose how Coop looks to you. "System" follows your device's color + scheme. + +
+ + +
+
+
Wellness From 1aa326c5690fc60ba5ca83bca741fc39bd4f74fc Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Wed, 16 Sep 2026 23:49:09 -0400 Subject: [PATCH 2/2] feat: appearance setting --- client/src/graphql/generated.ts | 181 ++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 841ec5a21..60ee7294d 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -5328,6 +5328,22 @@ export type GQLZentropiLabelerVersionInput = { readonly label: Scalars['String']['input']; }; +export type GQLUserThemePreferenceQueryVariables = Exact<{ + [key: string]: never; +}>; + +export type GQLUserThemePreferenceQuery = { + readonly __typename: 'Query'; + readonly me?: { + readonly __typename: 'User'; + readonly id: string; + readonly interfacePreferences: { + readonly __typename: 'UserInterfacePreferences'; + readonly themePreference?: GQLThemePreference | null; + }; + } | null; +}; + export type GQLApiAuthQueryVariables = Exact<{ [key: string]: never }>; export type GQLApiAuthQuery = { @@ -24919,6 +24935,18 @@ export type GQLUpdateAccountInfoMutation = { readonly updateAccountInfo?: boolean | null; }; +export type GQLSetThemePreferenceMutationVariables = Exact<{ + themePreference: GQLThemePreference; +}>; + +export type GQLSetThemePreferenceMutation = { + readonly __typename: 'Mutation'; + readonly setThemePreference?: { + readonly __typename: 'SetThemePreferenceSuccessResponse'; + readonly _?: boolean | null; + } | null; +}; + export type GQLChangePasswordMutationVariables = Exact<{ input: GQLChangePasswordInput; }>; @@ -26431,6 +26459,107 @@ export const GQLActionFragmentFragmentDoc = gql` } } `; +export const GQLUserThemePreferenceDocument = gql` + query UserThemePreference { + me { + id + interfacePreferences { + themePreference + } + } + } +`; + +/** + * __useGQLUserThemePreferenceQuery__ + * + * To run a query within a React component, call `useGQLUserThemePreferenceQuery` and pass it any options that fit your needs. + * When your component renders, `useGQLUserThemePreferenceQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGQLUserThemePreferenceQuery({ + * variables: { + * }, + * }); + */ +export function useGQLUserThemePreferenceQuery( + baseOptions?: Apollo.QueryHookOptions< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables + >(GQLUserThemePreferenceDocument, options); +} +export function useGQLUserThemePreferenceLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables + >(GQLUserThemePreferenceDocument, options); +} +// @ts-ignore +export function useGQLUserThemePreferenceSuspenseQuery( + baseOptions?: Apollo.SuspenseQueryHookOptions< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables + >, +): Apollo.UseSuspenseQueryResult< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables +>; +export function useGQLUserThemePreferenceSuspenseQuery( + baseOptions?: + | Apollo.SkipToken + | Apollo.SuspenseQueryHookOptions< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables + >, +): Apollo.UseSuspenseQueryResult< + GQLUserThemePreferenceQuery | undefined, + GQLUserThemePreferenceQueryVariables +>; +export function useGQLUserThemePreferenceSuspenseQuery( + baseOptions?: + | Apollo.SkipToken + | Apollo.SuspenseQueryHookOptions< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables + >, +) { + const options = + baseOptions === Apollo.skipToken + ? baseOptions + : { ...defaultOptions, ...baseOptions }; + return Apollo.useSuspenseQuery< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables + >(GQLUserThemePreferenceDocument, options); +} +export type GQLUserThemePreferenceQueryHookResult = ReturnType< + typeof useGQLUserThemePreferenceQuery +>; +export type GQLUserThemePreferenceLazyQueryHookResult = ReturnType< + typeof useGQLUserThemePreferenceLazyQuery +>; +export type GQLUserThemePreferenceSuspenseQueryHookResult = ReturnType< + typeof useGQLUserThemePreferenceSuspenseQuery +>; +export type GQLUserThemePreferenceQueryResult = Apollo.QueryResult< + GQLUserThemePreferenceQuery, + GQLUserThemePreferenceQueryVariables +>; export const GQLApiAuthDocument = gql` query ApiAuth { apiKey @@ -43604,6 +43733,56 @@ export type GQLUpdateAccountInfoMutationOptions = Apollo.BaseMutationOptions< GQLUpdateAccountInfoMutation, GQLUpdateAccountInfoMutationVariables >; +export const GQLSetThemePreferenceDocument = gql` + mutation SetThemePreference($themePreference: ThemePreference!) { + setThemePreference(themePreference: $themePreference) { + _ + } + } +`; +export type GQLSetThemePreferenceMutationFn = Apollo.MutationFunction< + GQLSetThemePreferenceMutation, + GQLSetThemePreferenceMutationVariables +>; + +/** + * __useGQLSetThemePreferenceMutation__ + * + * To run a mutation, you first call `useGQLSetThemePreferenceMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGQLSetThemePreferenceMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [gqlSetThemePreferenceMutation, { data, loading, error }] = useGQLSetThemePreferenceMutation({ + * variables: { + * themePreference: // value for 'themePreference' + * }, + * }); + */ +export function useGQLSetThemePreferenceMutation( + baseOptions?: Apollo.MutationHookOptions< + GQLSetThemePreferenceMutation, + GQLSetThemePreferenceMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + GQLSetThemePreferenceMutation, + GQLSetThemePreferenceMutationVariables + >(GQLSetThemePreferenceDocument, options); +} +export type GQLSetThemePreferenceMutationHookResult = ReturnType< + typeof useGQLSetThemePreferenceMutation +>; +export type GQLSetThemePreferenceMutationResult = + Apollo.MutationResult; +export type GQLSetThemePreferenceMutationOptions = Apollo.BaseMutationOptions< + GQLSetThemePreferenceMutation, + GQLSetThemePreferenceMutationVariables +>; export const GQLChangePasswordDocument = gql` mutation ChangePassword($input: ChangePasswordInput!) { changePassword(input: $input) { @@ -45800,6 +45979,7 @@ export type GQLSetOrgDefaultSafetySettingsMutationOptions = >; export const namedOperations = { Query: { + UserThemePreference: 'UserThemePreference', ApiAuth: 'ApiAuth', HashBanks: 'HashBanks', HashBankById: 'HashBankById', @@ -45999,6 +46179,7 @@ export const namedOperations = { SetAllUserStrikeThreshold: 'SetAllUserStrikeThreshold', UpdateUserStrikeTTL: 'UpdateUserStrikeTTL', UpdateAccountInfo: 'UpdateAccountInfo', + SetThemePreference: 'SetThemePreference', ChangePassword: 'ChangePassword', DeleteUser: 'DeleteUser', UpdateRole: 'UpdateRole',