diff --git a/packages/common/src/messages/settings.ts b/packages/common/src/messages/settings.ts index f933cc8b6d4..f480f70e25c 100644 --- a/packages/common/src/messages/settings.ts +++ b/packages/common/src/messages/settings.ts @@ -14,6 +14,8 @@ export const settingsMessages = { lightMode: 'Light', autoMode: 'Auto', matrixMode: 'Matrix', + defaultPalette: 'Default', + classicPalette: 'Classic', signOut: 'Sign Out', appearanceTitle: 'Appearance', @@ -29,7 +31,7 @@ export const settingsMessages = { desktopAppCardTitle: 'Download the Desktop App', appearanceDescription: - 'Enable dark mode or use the default setting to match your system preferences.', + 'Choose a theme palette and enable dark mode or use the default setting to match your system preferences.', inboxSettingsCardDescription: 'Configure who is able to send messages to your inbox.', commentSettingsCardDescription: diff --git a/packages/common/src/models/Theme.ts b/packages/common/src/models/Theme.ts index 59a1d1ccca8..f15d3f450bb 100644 --- a/packages/common/src/models/Theme.ts +++ b/packages/common/src/models/Theme.ts @@ -1,3 +1,22 @@ +/** Color mode - auto follows system, light/dark are explicit. Used in segmented control. */ +export enum ThemeMode { + AUTO = 'auto', + LIGHT = 'light', + DARK = 'dark' +} + +/** Theme palette - selected in dropdown. Default and classic have light/dark variants. */ +export enum ThemePalette { + DEFAULT = 'default', + CLASSIC = 'classic', + MATRIX = 'matrix' +} + +/** + * @deprecated Use ThemePalette + ThemeMode. Legacy theme enum. + * - LIGHT/DARK/AUTO = mode (map to ThemeMode) + * - MATRIX = palette (map to ThemePalette.MATRIX) + */ export enum Theme { DARK = 'dark', AUTO = 'auto', diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 8d147efaf2f..afcc086ab38 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -14,7 +14,8 @@ export enum FeatureFlags { FAST_REFERRAL = 'fast_referral', REACT_QUERY_SYNC = 'react_query_sync', COLLAPSED_EXPLORE_HEADER = 'collapsed_explore_header', - LAUNCHPAD_VERIFICATION = 'launchpad_verification' + LAUNCHPAD_VERIFICATION = 'launchpad_verification', + NEW_THEME_MODEL = 'new_theme_model' } type FlagDefaults = Record @@ -43,5 +44,6 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.FAST_REFERRAL]: false, [FeatureFlags.REACT_QUERY_SYNC]: false, [FeatureFlags.COLLAPSED_EXPLORE_HEADER]: false, - [FeatureFlags.LAUNCHPAD_VERIFICATION]: true + [FeatureFlags.LAUNCHPAD_VERIFICATION]: true, + [FeatureFlags.NEW_THEME_MODEL]: false } diff --git a/packages/common/src/store/ui/index.ts b/packages/common/src/store/ui/index.ts index 251c1f707e2..1ea98ea4512 100644 --- a/packages/common/src/store/ui/index.ts +++ b/packages/common/src/store/ui/index.ts @@ -57,7 +57,12 @@ export { } from './coinflow-modal/slice' export { default as themeReducer, actions as themeActions } from './theme/slice' -export type { SetThemeAction, SetSystemAppearanceAction } from './theme/slice' +export type { + SetThemeAction, + SetThemePaletteAction, + SetThemeModeAction, + SetSystemAppearanceAction +} from './theme/slice' export * as themeSelectors from './theme/selectors' export { default as toastReducer, actions as toastActions } from './toast/slice' diff --git a/packages/common/src/store/ui/theme/selectors.ts b/packages/common/src/store/ui/theme/selectors.ts index d8724f84cee..a92e157a322 100644 --- a/packages/common/src/store/ui/theme/selectors.ts +++ b/packages/common/src/store/ui/theme/selectors.ts @@ -1,3 +1,4 @@ +import { ThemePalette } from '~/models/Theme' import { CommonState } from '~/store/commonStore' const getBaseState = (state: CommonState) => state.ui.theme @@ -6,6 +7,16 @@ export const getTheme = (state: CommonState) => { return getBaseState(state).theme } +/** Palette from dropdown (default, classic, matrix). Falls back to classic for legacy. */ +export const getThemePalette = (state: CommonState): ThemePalette | null => { + return getBaseState(state).themePalette +} + +/** Mode from segmented control (auto, light, dark). */ +export const getThemeMode = (state: CommonState) => { + return getBaseState(state).themeMode +} + export const getSystemAppearance = (state: CommonState) => { return getBaseState(state).systemAppearance } diff --git a/packages/common/src/store/ui/theme/slice.ts b/packages/common/src/store/ui/theme/slice.ts index b11ebfc1d05..8521f6c1486 100644 --- a/packages/common/src/store/ui/theme/slice.ts +++ b/packages/common/src/store/ui/theme/slice.ts @@ -1,10 +1,20 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import { SystemAppearance, Theme } from '../../../models/Theme' +import { + SystemAppearance, + Theme, + ThemeMode, + ThemePalette +} from '../../../models/Theme' import { Nullable } from '../../../utils' export type ThemeState = { + /** @deprecated Use themePalette + themeMode. Legacy - when MATRIX, palette is matrix. */ theme: Nullable + /** Palette selected in dropdown: default, classic, matrix */ + themePalette: Nullable + /** Mode selected in segmented control: auto, light, dark. Ignored when palette is matrix. */ + themeMode: Nullable systemAppearance: Nullable } @@ -12,12 +22,22 @@ export type SetThemeAction = PayloadAction<{ theme: Theme }> +export type SetThemePaletteAction = PayloadAction<{ + themePalette: ThemePalette +}> + +export type SetThemeModeAction = PayloadAction<{ + themeMode: ThemeMode +}> + export type SetSystemAppearanceAction = PayloadAction<{ systemAppearance: SystemAppearance }> const initialState: ThemeState = { theme: null, + themePalette: null, + themeMode: null, systemAppearance: null } @@ -27,6 +47,36 @@ const themeSlice = createSlice({ reducers: { setTheme: (state, action: SetThemeAction) => { state.theme = action.payload.theme + // Sync legacy theme to new format for backward compat + if (action.payload.theme === Theme.MATRIX) { + state.themePalette = ThemePalette.MATRIX + } else { + state.themeMode = + action.payload.theme === Theme.LIGHT + ? ThemeMode.LIGHT + : action.payload.theme === Theme.DARK + ? ThemeMode.DARK + : ThemeMode.AUTO + // Preserve palette when updating mode; default to classic for legacy + if (state.themePalette === null) { + state.themePalette = ThemePalette.CLASSIC + } + } + }, + setThemePalette: (state, action: SetThemePaletteAction) => { + state.themePalette = action.payload.themePalette + if (action.payload.themePalette === ThemePalette.MATRIX) { + state.theme = Theme.MATRIX + } + }, + setThemeMode: (state, action: SetThemeModeAction) => { + state.themeMode = action.payload.themeMode + state.theme = + action.payload.themeMode === ThemeMode.LIGHT + ? Theme.LIGHT + : action.payload.themeMode === ThemeMode.DARK + ? Theme.DARK + : Theme.AUTO }, setSystemAppearance: (state, action: SetSystemAppearanceAction) => { state.systemAppearance = action.payload.systemAppearance @@ -34,6 +84,7 @@ const themeSlice = createSlice({ } }) -export const { setTheme, setSystemAppearance } = themeSlice.actions +export const { setTheme, setThemePalette, setThemeMode, setSystemAppearance } = + themeSlice.actions export default themeSlice.reducer export const actions = themeSlice.actions diff --git a/packages/harmony/src/components/button/Button/Button.tsx b/packages/harmony/src/components/button/Button/Button.tsx index 0de055fe3f0..7b7d4008850 100644 --- a/packages/harmony/src/components/button/Button/Button.tsx +++ b/packages/harmony/src/components/button/Button/Button.tsx @@ -2,6 +2,7 @@ import { CSSProperties, forwardRef } from 'react' import { CSSObject, useTheme } from '@emotion/react' +import { isDarkTheme } from '../../../foundations/theme/theme' import { BaseButton } from '../BaseButton/BaseButton' import { ButtonProps } from './types' @@ -125,8 +126,9 @@ export const Button = forwardRef( boxShadow: `0 0 0 1px inset ${themeColors.border.strong}` } const tertiaryStyles: CSSObject = { - background: - type === 'dark' ? 'rgba(50, 51, 77, 0.6)' : 'rgb(255, 255, 255, 0.85)', + background: isDarkTheme(type) + ? 'rgba(50, 51, 77, 0.6)' + : 'rgb(255, 255, 255, 0.85)', color: themeColors.text.default, backdropFilter: 'blur(6px)', boxShadow: `0 0 0 1px inset ${themeColors.border.default}, ${shadows.near}`, diff --git a/packages/harmony/src/foundations/color/color.ts b/packages/harmony/src/foundations/color/color.ts index 8f8ba2f534f..853235b7a95 100644 --- a/packages/harmony/src/foundations/color/color.ts +++ b/packages/harmony/src/foundations/color/color.ts @@ -2,17 +2,35 @@ import { primitiveTheme } from './primitive' import { semanticTheme } from './semantic' export const colorTheme = { + defaultLight: { + ...primitiveTheme.defaultLight, + ...semanticTheme.defaultLight + }, + defaultDark: { + ...primitiveTheme.defaultDark, + ...semanticTheme.defaultDark + }, + classicLight: { + ...primitiveTheme.classicLight, + ...semanticTheme.classicLight + }, + classicDark: { + ...primitiveTheme.classicDark, + ...semanticTheme.classicDark + }, + matrix: { + ...primitiveTheme.matrix, + ...semanticTheme.matrix + }, + /** @deprecated Use classicLight */ day: { ...primitiveTheme.day, ...semanticTheme.day }, + /** @deprecated Use classicDark */ dark: { ...primitiveTheme.dark, ...semanticTheme.dark - }, - matrix: { - ...primitiveTheme.matrix, - ...semanticTheme.matrix } } diff --git a/packages/harmony/src/foundations/color/primitive.css b/packages/harmony/src/foundations/color/primitive.css index f23f71d5796..d2d52c2ac11 100644 --- a/packages/harmony/src/foundations/color/primitive.css +++ b/packages/harmony/src/foundations/color/primitive.css @@ -120,6 +120,218 @@ html[data-theme='day'] { --harmony-glass-overlay: #ffffffd9; } +/* Classic Light - same as day (legacy alias) */ +html[data-theme='classic-light'] { + /* Primary Colors */ + --harmony-primary: #cc0fe0ff; + --harmony-p-100: #d63fe6ff; + --harmony-p-200: #d127e3ff; + --harmony-p-300: #cc0fe0ff; + --harmony-p-400: #b80ecaff; + --harmony-p-500: #a30cb3ff; + /* Secondary Colors */ + --harmony-secondary: #7e1bccff; + --harmony-s-100: #9849d6ff; + --harmony-s-200: #8b32d1ff; + --harmony-s-300: #7e1bccff; + --harmony-s-400: #7118b8ff; + --harmony-s-500: #6516a3ff; + /* Neutral Colors */ + --harmony-neutral: #6c6780ff; + --harmony-n-25: #fafafaff; + --harmony-n-50: #f7f7f8ff; + --harmony-n-100: #efeff1ff; + --harmony-n-150: #e7e7eaff; + --harmony-n-200: #d6d5dbff; + --harmony-n-300: #c3c1cbff; + --harmony-n-400: #a2a0afff; + --harmony-n-500: #938fa3ff; + --harmony-n-600: #817d95ff; + --harmony-n-700: #736e88ff; + --harmony-n-800: #6c6780ff; + --harmony-n-900: #615d73ff; + --harmony-n-950: #524f62ff; + /* Special Colors */ + --harmony-white: #ffffffff; + --harmony-background: #f6f5f7ff; + --harmony-blue: #1ba1f1ff; + --harmony-orange: #ff9400ff; + --harmony-red: #d0021bff; + --harmony-dark-red: #bb0218ff; + --harmony-green: #0f9e48ff; + --harmony-light-green: #13c65aff; + --harmony-gradient-color-1: #5b23e1ff; + --harmony-gradient-color-2: #a22febff; + --harmony-gradient: linear-gradient(315deg, #5b23e1ff 0%, #a22febff 100%); + --harmony-coin-gradient: linear-gradient( + 85deg, + #cc0fe0ff -4.82%, + #7e1bccff 49.8%, + #1ba1f1ff 104.43% + ); + --harmony-trending-blue: #216fdcff; + --harmony-glass-overlay: #ffffffd9; +} + +/* Classic Dark - same as dark (legacy alias) */ +html[data-theme='classic-dark'] { + /* Primary Colors */ + --harmony-primary: #d767e1ff; + --harmony-p-100: #924699ff; + --harmony-p-200: #b356bbff; + --harmony-p-300: #d767e1ff; + --harmony-p-400: #f479ffff; + --harmony-p-500: #f7a4ffff; + /* Secondary Colors */ + --harmony-secondary: #c67cffff; + --harmony-s-100: #9a66caff; + --harmony-s-200: #b071e4ff; + --harmony-s-300: #c67cffff; + --harmony-s-400: #cf90ffff; + --harmony-s-500: #d7a3ffff; + /* Neutral Colors */ + --harmony-neutral: #c6c8d6ff; + --harmony-n-25: #30354aff; + --harmony-n-50: #353a51ff; + --harmony-n-100: #3c425dff; + --harmony-n-150: #444b69ff; + --harmony-n-200: #4f577aff; + --harmony-n-300: #656f9bff; + --harmony-n-400: #7b83a7ff; + --harmony-n-500: #969bb6ff; + --harmony-n-600: #a6aac0ff; + --harmony-n-700: #b6b9cbff; + --harmony-n-800: #c6c8d6ff; + --harmony-n-900: #d6d7e1ff; + --harmony-n-950: #e6e7edff; + /* Special Colors */ + --harmony-white: #2f3348ff; + --harmony-background: #202131ff; + --harmony-blue: #58b9f4ff; + --harmony-orange: #efa947ff; + --harmony-red: #f9344cff; + --harmony-dark-red: #c43047ff; + --harmony-green: #6cdf44ff; + --harmony-light-green: #15d864ff; + --harmony-gradient-color-1: #9469eeff; + --harmony-gradient-color-2: #c781fcff; + --harmony-gradient: linear-gradient(315deg, #9469eeff 0%, #c781fcff 100%); + --harmony-coin-gradient: linear-gradient( + 85deg, + #d767e1ff -4.82%, + #c67cffff 49.8%, + #58b9f4ff 104.43% + ); + --harmony-trending-blue: #2a85e8ff; + --harmony-glass-overlay: #2f334899; +} + +/* Default Light (Neue design system) */ +html[data-theme='default-light'] { + /* Primary Colors */ + --harmony-primary: #7f6ad6; + --harmony-p-100: #e9e3ff; + --harmony-p-200: #cfc3ff; + --harmony-p-300: #7f6ad6; + --harmony-p-400: #5b44b8; + --harmony-p-500: #3b2a86; + /* Secondary Colors */ + --harmony-secondary: #a74cff; + --harmony-s-100: #f0e0ff; + --harmony-s-200: #ddbbff; + --harmony-s-300: #a74cff; + --harmony-s-400: #7d2fdb; + --harmony-s-500: #4f159e; + /* Neutral Colors */ + --harmony-neutral: #343b49; + --harmony-n-25: #f7f7f8; + --harmony-n-50: #f0f1f3; + --harmony-n-100: #e6e8ec; + --harmony-n-150: #d8dbe2; + --harmony-n-200: #c7ccd6; + --harmony-n-300: #b2b8c5; + --harmony-n-400: #9aa1b0; + --harmony-n-500: #7f8798; + --harmony-n-600: #636c7e; + --harmony-n-700: #4a5263; + --harmony-n-800: #343b49; + --harmony-n-900: #232834; + --harmony-n-950: #141821; + /* Special Colors */ + --harmony-white: #ffffff; + --harmony-background: #f6f5f7; + --harmony-blue: #7f6ad6; + --harmony-orange: #ff9400; + --harmony-red: #f94d62; + --harmony-dark-red: #c44357; + --harmony-green: #0f9e48; + --harmony-light-green: #a74cff; + --harmony-gradient-color-1: #6f55d8; + --harmony-gradient-color-2: #d767e1; + --harmony-gradient: linear-gradient(315deg, #6f55d8 0%, #d767e1 100%); + --harmony-coin-gradient: linear-gradient( + 85deg, + #7f6ad6 -4.82%, + #a74cff 49.8%, + #7f6ad6 104.43% + ); + --harmony-trending-blue: #2a85e8; + --harmony-glass-overlay: #00000014; +} + +/* Default Dark (Neue design system) */ +html[data-theme='default-dark'] { + /* Primary Colors */ + --harmony-primary: #806ad8; + --harmony-p-100: #261f40; + --harmony-p-200: #443873; + --harmony-p-300: #806ad8; + --harmony-p-400: #9e8ee8; + --harmony-p-500: #d8cfff; + /* Secondary Colors */ + --harmony-secondary: #c67cff; + --harmony-s-100: #9a66ca; + --harmony-s-200: #b071e4; + --harmony-s-300: #c67cff; + --harmony-s-400: #cf90ff; + --harmony-s-500: #d7a3ff; + /* Neutral Colors */ + --harmony-neutral: #949494; + --harmony-n-25: #0f0f0f; + --harmony-n-50: #141414; + --harmony-n-100: #1f1f1f; + --harmony-n-150: #292929; + --harmony-n-200: #333333; + --harmony-n-300: #474747; + --harmony-n-400: #636363; + --harmony-n-500: #6b6b6b; + --harmony-n-600: #757575; + --harmony-n-700: #858585; + --harmony-n-800: #949494; + --harmony-n-900: #a3a3a3; + --harmony-n-950: #b2b2b2; + /* Special Colors */ + --harmony-white: #0f0f0f; + --harmony-background: #000000; + --harmony-blue: #806ad8; + --harmony-orange: #efb360; + --harmony-red: #f94d62; + --harmony-dark-red: #c44357; + --harmony-green: #84df64; + --harmony-light-green: #c67cff; + --harmony-gradient-color-1: #6f55d8; + --harmony-gradient-color-2: #d767e1; + --harmony-gradient: linear-gradient(315deg, #6f55d8 0%, #d767e1 100%); + --harmony-coin-gradient: linear-gradient( + 85deg, + #806ad8 -4.82%, + #c67cff 49.8%, + #806ad8 104.43% + ); + --harmony-trending-blue: #2a85e8; + --harmony-glass-overlay: #0f0f0f99; +} + /* Dark Theme */ html[data-theme='dark'] { /* Primary Colors */ diff --git a/packages/harmony/src/foundations/color/primitive.ts b/packages/harmony/src/foundations/color/primitive.ts index 396cb8ea690..646368684f1 100644 --- a/packages/harmony/src/foundations/color/primitive.ts +++ b/packages/harmony/src/foundations/color/primitive.ts @@ -1,205 +1,367 @@ -export const primitiveTheme = { - day: { - static: { - primary: '#CC0FE0FF', - secondary: '#7E1BCCFF', - white: '#FFFFFFFF', - staticWhite: '#FFFFFFFF', - black: '#000000' - }, - primary: { - p100: '#D63FE6FF', - p200: '#D127E3FF', - p300: '#CC0FE0FF', - p400: '#B80ECAFF', - p500: '#A30CB3FF', - default: '#CC0FE0FF', - primary: '#CC0FE0FF' - }, - secondary: { - s100: '#9849D6FF', - s200: '#8B32D1FF', - s300: '#7E1BCCFF', - s400: '#7118B8FF', - s500: '#6516A3FF', - default: '#7E1BCCFF', - secondary: '#7E1BCCFF' - }, - neutral: { - n25: '#FAFAFAFF', - n50: '#F7F7F8FF', - n100: '#EFEFF1FF', - n150: '#E7E7EAFF', - n200: '#D6D5DBFF', - n300: '#C3C1CBFF', - n400: '#A2A0AFFF', - n500: '#938FA3FF', - n600: '#817D95FF', - n700: '#736E88FF', - n800: '#6C6780FF', - n900: '#615D73FF', - n950: '#524F62FF', - default: '#6C6780FF', - neutral: '#6C6780FF' - }, - special: { - background: '#F6F5F7FF', - blue: '#1BA1F1FF', - darkRed: '#BB0218FF', - glassOverlay: '#FFFFFFD9', - gradientStop1: '#5B23E1FF', - gradientStop2: '#A22FEBFF', - gradient: 'linear-gradient(315deg, #5B23E1FF 0%, #A22FEBFF 100%)', - coinGradient: - 'linear-gradient(85deg, #CC0FE0FF -4.82%, #7E1BCCFF 49.8%, #1BA1F1FF 104.43%)', - coinGradientColor1: '#CC0FE0FF', - coinGradientColor2: '#7E1BCCFF', - coinGradientColor3: '#1BA1F1FF', - green: '#0F9E48FF', - lightGreen: '#13C65AFF', - orange: '#FF9400FF', - red: '#D0021BFF', - trendingBlue: '#216FDCFF', - white: '#FFFFFFFF', - default: '#1BA1F1FF', - special: '#1BA1F1FF' - } - }, - dark: { - static: { - primary: '#B94CC2FF', - secondary: '#7E1BCCFF', - white: '#2F3348FF', - staticWhite: '#F6F8FCFF', - black: '#000000' - }, - primary: { - p100: '#924699FF', - p200: '#B356BBFF', - p300: '#D767E1FF', - p400: '#F479FFFF', - p500: '#F7A4FFFF', - default: '#D767E1FF', - primary: '#D767E1FF' - }, - secondary: { - s100: '#9A66CAFF', - s200: '#B071E4FF', - s300: '#C67CFFFF', - s400: '#CF90FFFF', - s500: '#D7A3FFFF', - default: '#C67CFFFF', - secondary: '#C67CFFFF' - }, - neutral: { - n25: '#30354AFF', - n50: '#353A51FF', - n100: '#3C425DFF', - n150: '#444B69FF', - n200: '#4F577AFF', - n300: '#656F9BFF', - n400: '#7B83A7FF', - n500: '#969BB6FF', - n600: '#A6AAC0FF', - n700: '#B6B9CBFF', - n800: '#C6C8D6FF', - n900: '#D6D7E1FF', - n950: '#E6E7EDFF', - default: '#C6C8D6FF', - neutral: '#C6C8D6FF' - }, - special: { - background: '#202131FF', - blue: '#58B9F4FF', - darkRed: '#C43047FF', - glassOverlay: '#2F334899', - gradientStop1: '#9469EEFF', - gradientStop2: '#C781FCFF', - gradient: 'linear-gradient(315deg, #9469EEFF 0%, #C781FCFF 100%)', - coinGradient: - 'linear-gradient(85deg, #D767E1FF -4.82%, #C67CFFFF 49.8%, #58B9F4FF 104.43%)', - coinGradientColor1: '#D767E1FF', - coinGradientColor2: '#C67CFFFF', - coinGradientColor3: '#58B9F4FF', - green: '#6CDF44FF', - lightGreen: '#15D864FF', - orange: '#EFA947FF', - red: '#F9344CFF', - trendingBlue: '#2A85E8FF', - white: '#2F3348FF', - default: '#58B9F4FF', - special: '#58B9F4FF' - } - }, - matrix: { - static: { - primary: '#B94CC2FF', - secondary: '#7E1BCCFF', - white: '#020804FF', - staticWhite: '#F6F8FCFF', - black: '#000000' - }, - primary: { - p100: '#08A008FF', - p200: '#09B509FF', - p300: '#0BD90BFF', - p400: '#0BE40BFF', - p500: '#3DF23DFF', - default: '#0BD90BFF', - primary: '#0BD90BFF' - }, - secondary: { - s100: '#00A73CFF', - s200: '#00BD44FF', - s300: '#00E252FF', - s400: '#00ED56FF', - s500: '#3CFB6BFF', - default: '#00E252FF', - secondary: '#00E252FF' - }, - neutral: { - n25: '#010B06FF', - n50: '#041308FF', - n100: '#0A1F0DFF', - n150: '#0F2F15FF', - n200: '#123F1BFF', - n300: '#166527FF', - n400: '#1C8736FF', - n500: '#1F9E3FFF', - n600: '#2EB954FF', - n700: '#34D561FF', - n800: '#3EF56BFF', - n900: '#54FF80FF', - n950: '#9BFFA7FF', - default: '#3EF56BFF', - neutral: '#3EF56BFF' - }, - special: { - background: '#000000FF', - blue: '#20E290FF', - darkRed: '#D63C5AFF', - glassOverlay: '#01010199', - gradientStop1: '#6CDF44FF', - gradientStop2: '#13C65AFF', - gradient: 'linear-gradient(315deg, #6CDF44FF 0%, #13C65AFF 100%)', - coinGradient: - 'linear-gradient(85deg, #0BD90BFF -4.82%, #00E252FF 49.8%, #20E290FF 104.43%)', - coinGradientColor1: '#0BD90BFF', - coinGradientColor2: '#00E252FF', - coinGradientColor3: '#20E290FF', - green: '#2EB954FF', - lightGreen: '#0BF90BFF', - orange: '#FFA524FF', - red: '#F9344CFF', - trendingBlue: '#1EEB6FFF', - white: '#020804FF', - default: '#20E290FF', - special: '#20E290FF' - } +/** + * Default [Neue] theme - modern design system (light variant) + * From Figma Default [Neue].tokens.json + */ +const defaultLightPrimitives = { + static: { + primary: '#7F6AD6', + secondary: '#A74CFF', + white: '#FFFFFF', + staticWhite: '#F6F8FC', + black: '#0F0F0F' + }, + primary: { + p100: '#E9E3FF', + p200: '#CFC3FF', + p300: '#7F6AD6', + p400: '#5B44B8', + p500: '#3B2A86', + default: '#7F6AD6', + primary: '#7F6AD6' + }, + secondary: { + s100: '#F0E0FF', + s200: '#DDBBFF', + s300: '#A74CFF', + s400: '#7D2FDB', + s500: '#4F159E', + default: '#A74CFF', + secondary: '#A74CFF' + }, + neutral: { + n25: '#F7F7F8', + n50: '#F0F1F3', + n100: '#E6E8EC', + n150: '#D8DBE2', + n200: '#C7CCD6', + n300: '#B2B8C5', + n400: '#9AA1B0', + n500: '#7F8798', + n600: '#636C7E', + n700: '#4A5263', + n800: '#343B49', + n900: '#232834', + n950: '#141821', + default: '#343B49', + neutral: '#343B49' + }, + special: { + background: '#F6F5F7', + blue: '#7F6AD6', + darkRed: '#C44357', + glassOverlay: '#00000014', + gradientStop1: '#6F55D8', + gradientStop2: '#D767E1', + gradient: 'linear-gradient(315deg, #6F55D8 0%, #D767E1 100%)', + coinGradient: + 'linear-gradient(85deg, #7F6AD6 -4.82%, #A74CFF 49.8%, #7F6AD6 104.43%)', + coinGradientColor1: '#7F6AD6', + coinGradientColor2: '#A74CFF', + coinGradientColor3: '#7F6AD6', + green: '#0F9E48', + lightGreen: '#A74CFF', + orange: '#FF9400', + red: '#F94D62', + trendingBlue: '#2A85E8', + white: '#FFFFFF', + default: '#7F6AD6', + special: '#7F6AD6' + } +} + +/** + * Dark [Neue] theme - modern design system (dark variant) + * From Figma Dark [Neue].tokens.json + */ +const defaultDarkPrimitives = { + static: { + primary: '#917FD8', + secondary: '#C67CFF', + white: '#0F0F0F', + staticWhite: '#F6F8FC', + black: '#FFFFFF' + }, + primary: { + p100: '#261F40', + p200: '#443873', + p300: '#806AD8', + p400: '#9E8EE8', + p500: '#D8CFFF', + default: '#806AD8', + primary: '#806AD8' + }, + secondary: { + s100: '#9A66CA', + s200: '#B071E4', + s300: '#C67CFF', + s400: '#CF90FF', + s500: '#D7A3FF', + default: '#C67CFF', + secondary: '#C67CFF' + }, + neutral: { + n25: '#0F0F0F', + n50: '#141414', + n100: '#1F1F1F', + n150: '#292929', + n200: '#333333', + n300: '#474747', + n400: '#636363', + n500: '#6B6B6B', + n600: '#757575', + n700: '#858585', + n800: '#949494', + n900: '#A3A3A3', + n950: '#B2B2B2', + default: '#949494', + neutral: '#949494' + }, + special: { + background: '#000000', + blue: '#806AD8', + darkRed: '#C44357', + glassOverlay: '#0F0F0F99', + gradientStop1: '#6F55D8', + gradientStop2: '#D767E1', + gradient: 'linear-gradient(315deg, #6F55D8 0%, #D767E1 100%)', + coinGradient: + 'linear-gradient(85deg, #806AD8 -4.82%, #C67CFF 49.8%, #806AD8 104.43%)', + coinGradientColor1: '#806AD8', + coinGradientColor2: '#C67CFF', + coinGradientColor3: '#806AD8', + green: '#84DF64', + lightGreen: '#C67CFF', + orange: '#EFB360', + red: '#F94D62', + trendingBlue: '#2A85E8', + white: '#0F0F0F', + default: '#806AD8', + special: '#806AD8' + } +} + +/** Classic theme light variant (legacy day) */ +const classicLightPrimitives = { + static: { + primary: '#CC0FE0FF', + secondary: '#7E1BCCFF', + white: '#FFFFFFFF', + staticWhite: '#FFFFFFFF', + black: '#000000' + }, + primary: { + p100: '#D63FE6FF', + p200: '#D127E3FF', + p300: '#CC0FE0FF', + p400: '#B80ECAFF', + p500: '#A30CB3FF', + default: '#CC0FE0FF', + primary: '#CC0FE0FF' + }, + secondary: { + s100: '#9849D6FF', + s200: '#8B32D1FF', + s300: '#7E1BCCFF', + s400: '#7118B8FF', + s500: '#6516A3FF', + default: '#7E1BCCFF', + secondary: '#7E1BCCFF' + }, + neutral: { + n25: '#FAFAFAFF', + n50: '#F7F7F8FF', + n100: '#EFEFF1FF', + n150: '#E7E7EAFF', + n200: '#D6D5DBFF', + n300: '#C3C1CBFF', + n400: '#A2A0AFFF', + n500: '#938FA3FF', + n600: '#817D95FF', + n700: '#736E88FF', + n800: '#6C6780FF', + n900: '#615D73FF', + n950: '#524F62FF', + default: '#6C6780FF', + neutral: '#6C6780FF' + }, + special: { + background: '#F6F5F7FF', + blue: '#1BA1F1FF', + darkRed: '#BB0218FF', + glassOverlay: '#FFFFFFD9', + gradientStop1: '#5B23E1FF', + gradientStop2: '#A22FEBFF', + gradient: 'linear-gradient(315deg, #5B23E1FF 0%, #A22FEBFF 100%)', + coinGradient: + 'linear-gradient(85deg, #CC0FE0FF -4.82%, #7E1BCCFF 49.8%, #1BA1F1FF 104.43%)', + coinGradientColor1: '#CC0FE0FF', + coinGradientColor2: '#7E1BCCFF', + coinGradientColor3: '#1BA1F1FF', + green: '#0F9E48FF', + lightGreen: '#13C65AFF', + orange: '#FF9400FF', + red: '#D0021BFF', + trendingBlue: '#216FDCFF', + white: '#FFFFFFFF', + default: '#1BA1F1FF', + special: '#1BA1F1FF' + } +} + +/** Classic theme dark variant (legacy dark) */ +const classicDarkPrimitives = { + static: { + primary: '#B94CC2FF', + secondary: '#7E1BCCFF', + white: '#2F3348FF', + staticWhite: '#F6F8FCFF', + black: '#000000' + }, + primary: { + p100: '#924699FF', + p200: '#B356BBFF', + p300: '#D767E1FF', + p400: '#F479FFFF', + p500: '#F7A4FFFF', + default: '#D767E1FF', + primary: '#D767E1FF' + }, + secondary: { + s100: '#9A66CAFF', + s200: '#B071E4FF', + s300: '#C67CFFFF', + s400: '#CF90FFFF', + s500: '#D7A3FFFF', + default: '#C67CFFFF', + secondary: '#C67CFFFF' + }, + neutral: { + n25: '#30354AFF', + n50: '#353A51FF', + n100: '#3C425DFF', + n150: '#444B69FF', + n200: '#4F577AFF', + n300: '#656F9BFF', + n400: '#7B83A7FF', + n500: '#969BB6FF', + n600: '#A6AAC0FF', + n700: '#B6B9CBFF', + n800: '#C6C8D6FF', + n900: '#D6D7E1FF', + n950: '#E6E7EDFF', + default: '#C6C8D6FF', + neutral: '#C6C8D6FF' + }, + special: { + background: '#202131FF', + blue: '#58B9F4FF', + darkRed: '#C43047FF', + glassOverlay: '#2F334899', + gradientStop1: '#9469EEFF', + gradientStop2: '#C781FCFF', + gradient: 'linear-gradient(315deg, #9469EEFF 0%, #C781FCFF 100%)', + coinGradient: + 'linear-gradient(85deg, #D767E1FF -4.82%, #C67CFFFF 49.8%, #58B9F4FF 104.43%)', + coinGradientColor1: '#D767E1FF', + coinGradientColor2: '#C67CFFFF', + coinGradientColor3: '#58B9F4FF', + green: '#6CDF44FF', + lightGreen: '#15D864FF', + orange: '#EFA947FF', + red: '#F9344CFF', + trendingBlue: '#2A85E8FF', + white: '#2F3348FF', + default: '#58B9F4FF', + special: '#58B9F4FF' + } +} + +/** Matrix theme (single mode, no light/dark variant) */ +const matrixPrimitives = { + static: { + primary: '#B94CC2FF', + secondary: '#7E1BCCFF', + white: '#020804FF', + staticWhite: '#F6F8FCFF', + black: '#000000' + }, + primary: { + p100: '#08A008FF', + p200: '#09B509FF', + p300: '#0BD90BFF', + p400: '#0BE40BFF', + p500: '#3DF23DFF', + default: '#0BD90BFF', + primary: '#0BD90BFF' + }, + secondary: { + s100: '#00A73CFF', + s200: '#00BD44FF', + s300: '#00E252FF', + s400: '#00ED56FF', + s500: '#3CFB6BFF', + default: '#00E252FF', + secondary: '#00E252FF' + }, + neutral: { + n25: '#010B06FF', + n50: '#041308FF', + n100: '#0A1F0DFF', + n150: '#0F2F15FF', + n200: '#123F1BFF', + n300: '#166527FF', + n400: '#1C8736FF', + n500: '#1F9E3FFF', + n600: '#2EB954FF', + n700: '#34D561FF', + n800: '#3EF56BFF', + n900: '#54FF80FF', + n950: '#9BFFA7FF', + default: '#3EF56BFF', + neutral: '#3EF56BFF' + }, + special: { + background: '#000000FF', + blue: '#20E290FF', + darkRed: '#D63C5AFF', + glassOverlay: '#01010199', + gradientStop1: '#6CDF44FF', + gradientStop2: '#13C65AFF', + gradient: 'linear-gradient(315deg, #6CDF44FF 0%, #13C65AFF 100%)', + coinGradient: + 'linear-gradient(85deg, #0BD90BFF -4.82%, #00E252FF 49.8%, #20E290FF 104.43%)', + coinGradientColor1: '#0BD90BFF', + coinGradientColor2: '#00E252FF', + coinGradientColor3: '#20E290FF', + green: '#2EB954FF', + lightGreen: '#0BF90BFF', + orange: '#FFA524FF', + red: '#F9344CFF', + trendingBlue: '#1EEB6FFF', + white: '#020804FF', + default: '#20E290FF', + special: '#20E290FF' } } -export type PrimitiveColors = typeof primitiveTheme.day +export const primitiveTheme = { + /** Default theme light variant (Neue design system) */ + defaultLight: defaultLightPrimitives, + /** Default theme dark variant (Neue design system) */ + defaultDark: defaultDarkPrimitives, + /** Classic theme light variant (legacy day) */ + classicLight: classicLightPrimitives, + /** Classic theme dark variant (legacy dark) */ + classicDark: classicDarkPrimitives, + /** Matrix theme (single mode, no light/dark) */ + matrix: matrixPrimitives, + /** @deprecated Use classicLight. Backward compatibility. */ + day: classicLightPrimitives, + /** @deprecated Use classicDark. Backward compatibility. */ + dark: classicDarkPrimitives +} + +export type PrimitiveColors = typeof primitiveTheme.defaultLight export type PrimaryColors = keyof PrimitiveColors['primary'] export type SecondaryColors = keyof PrimitiveColors['secondary'] export type NeutralColors = keyof PrimitiveColors['neutral'] diff --git a/packages/harmony/src/foundations/color/semantic.ts b/packages/harmony/src/foundations/color/semantic.ts index f5a189b69e5..8cb01ce6f64 100644 --- a/packages/harmony/src/foundations/color/semantic.ts +++ b/packages/harmony/src/foundations/color/semantic.ts @@ -78,9 +78,15 @@ const createSemanticTheme = (primitives: PrimitiveColors) => ({ }) export const semanticTheme = { + defaultLight: createSemanticTheme(primitiveTheme.defaultLight), + defaultDark: createSemanticTheme(primitiveTheme.defaultDark), + classicLight: createSemanticTheme(primitiveTheme.classicLight), + classicDark: createSemanticTheme(primitiveTheme.classicDark), + matrix: createSemanticTheme(primitiveTheme.matrix), + /** @deprecated Use classicLight */ day: createSemanticTheme(primitiveTheme.day), - dark: createSemanticTheme(primitiveTheme.dark), - matrix: createSemanticTheme(primitiveTheme.matrix) + /** @deprecated Use classicDark */ + dark: createSemanticTheme(primitiveTheme.dark) } export type SemanticColors = typeof semanticTheme.day diff --git a/packages/harmony/src/foundations/theme/theme.ts b/packages/harmony/src/foundations/theme/theme.ts index 4d580239a3b..ac44abd6ea0 100644 --- a/packages/harmony/src/foundations/theme/theme.ts +++ b/packages/harmony/src/foundations/theme/theme.ts @@ -7,7 +7,7 @@ import { Spacing, spacing, iconSizes } from '../spacing' import { typography } from '../typography' import type { Typography } from '../typography' -import type { Theme } from './types' +import type { Theme, ThemeMode, ThemePalette } from './types' const commonFoundations = { shadows, @@ -31,15 +31,27 @@ export type HarmonyTheme = { iconSizes: typeof iconSizes } -export const dayTheme: HarmonyTheme = { - type: 'day', - color: colorTheme.day, +export const defaultLightTheme: HarmonyTheme = { + type: 'default-light', + color: colorTheme.defaultLight, ...commonFoundations } -export const darkTheme: HarmonyTheme = { - type: 'dark', - color: colorTheme.dark, +export const defaultDarkTheme: HarmonyTheme = { + type: 'default-dark', + color: colorTheme.defaultDark, + ...commonFoundations +} + +export const classicLightTheme: HarmonyTheme = { + type: 'classic-light', + color: colorTheme.classicLight, + ...commonFoundations +} + +export const classicDarkTheme: HarmonyTheme = { + type: 'classic-dark', + color: colorTheme.classicDark, ...commonFoundations } @@ -49,8 +61,46 @@ export const matrixTheme: HarmonyTheme = { ...commonFoundations } -export const themes = { - day: dayTheme, - dark: darkTheme, - matrix: matrixTheme +export const themes: Record = { + 'default-light': defaultLightTheme, + 'default-dark': defaultDarkTheme, + 'classic-light': classicLightTheme, + 'classic-dark': classicDarkTheme, + matrix: matrixTheme, + /** @deprecated Use classicLightTheme */ + day: classicLightTheme, + /** @deprecated Use classicDarkTheme */ + dark: classicDarkTheme } + +/** System light/dark preference for auto mode */ +export type SystemAppearance = 'light' | 'dark' + +/** + * Resolve palette + mode + system preference to a concrete Theme. + * Use when palette is default/classic and mode is auto/light/dark. + * For matrix palette, mode is ignored. + */ +export function resolveTheme( + palette: ThemePalette, + mode: ThemeMode, + systemAppearance: SystemAppearance +): Theme { + if (palette === 'matrix') return 'matrix' + const resolvedMode = mode === 'auto' ? systemAppearance : mode + if (palette === 'default') { + return resolvedMode === 'light' ? 'default-light' : 'default-dark' + } + return resolvedMode === 'light' ? 'classic-light' : 'classic-dark' +} + +/** Check if theme is a light variant (light backgrounds) */ +export const isLightTheme = (theme: Theme): boolean => + theme === 'default-light' || theme === 'classic-light' || theme === 'day' + +/** Check if theme is a dark variant (dark backgrounds, including matrix) */ +export const isDarkTheme = (theme: Theme): boolean => + theme === 'default-dark' || + theme === 'classic-dark' || + theme === 'dark' || + theme === 'matrix' diff --git a/packages/harmony/src/foundations/theme/types.ts b/packages/harmony/src/foundations/theme/types.ts index dfb49607fdd..d6fcd1f9bf0 100644 --- a/packages/harmony/src/foundations/theme/types.ts +++ b/packages/harmony/src/foundations/theme/types.ts @@ -2,6 +2,22 @@ import { Interpolation } from '@emotion/react' import { HarmonyTheme } from './theme' -export type Theme = 'day' | 'dark' | 'matrix' +/** Resolved theme - which palette + mode combination to display */ +export type Theme = + | 'default-light' + | 'default-dark' + | 'classic-light' + | 'classic-dark' + | 'matrix' + /** @deprecated Use classic-light */ + | 'day' + /** @deprecated Use classic-dark */ + | 'dark' + +/** Theme palette - selected in dropdown (default, classic, matrix) */ +export type ThemePalette = 'default' | 'classic' | 'matrix' + +/** Color mode - auto follows system, light/dark are explicit */ +export type ThemeMode = 'auto' | 'light' | 'dark' export type WithCSS = T & { css?: Interpolation } diff --git a/packages/mobile/src/app/ThemeProvider.tsx b/packages/mobile/src/app/ThemeProvider.tsx index 353544d883e..8c631314956 100644 --- a/packages/mobile/src/app/ThemeProvider.tsx +++ b/packages/mobile/src/app/ThemeProvider.tsx @@ -3,6 +3,8 @@ import { useEffect } from 'react' import { Theme, + ThemeMode, + ThemePalette, SystemAppearance, LEGACY_THEME_DEFAULT } from '@audius/common/models' @@ -15,35 +17,90 @@ import { useDispatch, useSelector } from 'react-redux' import { useAsync } from 'react-use' import { ThemeProvider as HarmonyThemeProvider } from '@audius/harmony-native' -import { THEME_STORAGE_KEY } from 'app/constants/storage-keys' +import { + THEME_MODE_KEY, + THEME_PALETTE_KEY, + THEME_STORAGE_KEY +} from 'app/constants/storage-keys' import type { AppState } from 'app/store' -const { getTheme, getSystemAppearance } = themeSelectors -const { setTheme, setSystemAppearance } = themeActions +const { getTheme, getThemePalette, getThemeMode, getSystemAppearance } = + themeSelectors +const { setTheme, setThemePalette, setThemeMode, setSystemAppearance } = + themeActions type ThemeProviderProps = { children: ReactNode } -const selectHarmonyTheme = (state: AppState) => { +type HarmonyThemeName = + | 'default-light' + | 'default-dark' + | 'classic-light' + | 'classic-dark' + | 'matrix' + | 'day' + | 'dark' + +/** + * Resolve palette + mode + system preference to concrete theme name. + * Default palette uses Neue colors; Classic uses legacy day/dark. + */ +const resolveToHarmonyTheme = ( + palette: ThemePalette, + mode: ThemeMode, + systemAppearance: SystemAppearance +): HarmonyThemeName => { + if (palette === ThemePalette.MATRIX) return 'matrix' + const resolvedMode = + mode === ThemeMode.AUTO + ? systemAppearance === SystemAppearance.DARK + ? 'dark' + : 'light' + : mode === ThemeMode.LIGHT + ? 'light' + : 'dark' + if (palette === ThemePalette.DEFAULT) { + return resolvedMode === 'light' ? 'default-light' : 'default-dark' + } + return resolvedMode === 'light' ? 'classic-light' : 'classic-dark' +} + +const selectHarmonyTheme = (state: AppState): HarmonyThemeName => { const theme = getTheme(state) + const themePalette = getThemePalette(state) + const themeMode = getThemeMode(state) const systemAppearance = getSystemAppearance(state) + if (themePalette != null) { + const mode = + themeMode ?? + (theme === Theme.LIGHT + ? ThemeMode.LIGHT + : theme === Theme.DARK + ? ThemeMode.DARK + : ThemeMode.AUTO) + const sysAppearance = + systemAppearance ?? + (theme === Theme.DARK ? SystemAppearance.DARK : SystemAppearance.LIGHT) + return resolveToHarmonyTheme(themePalette, mode, sysAppearance) + } + switch (theme) { case Theme.LIGHT: - return 'day' + return 'classic-light' case Theme.DARK: - return 'dark' + return 'classic-dark' case Theme.MATRIX: return 'matrix' case Theme.AUTO: default: switch (systemAppearance) { case SystemAppearance.DARK: - return 'dark' + return 'classic-dark' case SystemAppearance.LIGHT: default: - return 'day' + return 'classic-light' } } } @@ -56,7 +113,11 @@ export const ThemeProvider = (props: ThemeProviderProps) => { const theme = useSelector(selectHarmonyTheme) useAsync(async () => { - const savedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY) + const [savedTheme, savedPalette, savedMode] = await Promise.all([ + AsyncStorage.getItem(THEME_STORAGE_KEY), + AsyncStorage.getItem(THEME_PALETTE_KEY), + AsyncStorage.getItem(THEME_MODE_KEY) + ]) // Handle legacy "default" value - treat as AUTO const theme = @@ -65,6 +126,19 @@ export const ThemeProvider = (props: ThemeProviderProps) => { : ((savedTheme as Nullable) ?? Theme.AUTO) dispatch(setTheme({ theme })) + + if ( + savedPalette && + Object.values(ThemePalette).includes(savedPalette as ThemePalette) + ) { + dispatch(setThemePalette({ themePalette: savedPalette as ThemePalette })) + } + if ( + savedMode && + Object.values(ThemeMode).includes(savedMode as ThemeMode) + ) { + dispatch(setThemeMode({ themeMode: savedMode as ThemeMode })) + } }, [dispatch]) useEffect(() => { diff --git a/packages/mobile/src/components/navigation-container/navigationThemes.ts b/packages/mobile/src/components/navigation-container/navigationThemes.ts index db20f6af228..5dbf67ebdbe 100644 --- a/packages/mobile/src/components/navigation-container/navigationThemes.ts +++ b/packages/mobile/src/components/navigation-container/navigationThemes.ts @@ -1,36 +1,43 @@ import type { Theme } from '@react-navigation/native' import { DefaultTheme, DarkTheme } from '@react-navigation/native' -import { darkTheme, defaultTheme, matrixTheme } from 'app/utils/theme' +import type { ResolvedThemeName } from 'app/utils/theme' +import { + darkTheme, + defaultTheme, + matrixTheme, + defaultLightThemeColors, + defaultDarkThemeColors +} from 'app/utils/theme' -const defaultNavigationtTheme: Theme = { +const createLightNavTheme = (palette: { + background: string + white: string +}) => ({ ...DefaultTheme, colors: { ...DefaultTheme.colors, - background: defaultTheme.background, - card: defaultTheme.white + background: palette.background, + card: palette.white } -} -const darkNavigationTheme = { - ...DarkTheme, - colors: { - ...DarkTheme.colors, - background: darkTheme.background, - card: darkTheme.white - } -} +}) -const matrixNavigationTheme = { +const createDarkNavTheme = (palette: { + background: string + white: string +}) => ({ ...DarkTheme, colors: { ...DarkTheme.colors, - background: matrixTheme.background, - card: matrixTheme.white + background: palette.background, + card: palette.white } -} +}) -export const navigationThemes = { - default: defaultNavigationtTheme, - dark: darkNavigationTheme, - matrix: matrixNavigationTheme +export const navigationThemes: Record = { + 'default-light': createLightNavTheme(defaultLightThemeColors), + 'default-dark': createDarkNavTheme(defaultDarkThemeColors), + 'classic-light': createLightNavTheme(defaultTheme), + 'classic-dark': createDarkNavTheme(darkTheme), + matrix: createDarkNavTheme(matrixTheme) } diff --git a/packages/mobile/src/components/trending-rewards-drawer/TrendingRewardsDrawer.tsx b/packages/mobile/src/components/trending-rewards-drawer/TrendingRewardsDrawer.tsx index e3dbeb9828f..1cd35e7f77a 100644 --- a/packages/mobile/src/components/trending-rewards-drawer/TrendingRewardsDrawer.tsx +++ b/packages/mobile/src/components/trending-rewards-drawer/TrendingRewardsDrawer.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react' import { useRemoteVar } from '@audius/common/hooks' -import { Theme } from '@audius/common/models' import { StringKeys } from '@audius/common/services' import { audioRewardsPageSelectors, @@ -21,7 +20,7 @@ import TweetEmbed from 'app/components/tweet-embed' import { useNavigation } from 'app/hooks/useNavigation' import type { AppScreenParamList } from 'app/screens/app-screen' import { makeStyles } from 'app/styles' -import { useThemeVariant } from 'app/utils/theme' +import { isDarkTheme, useThemeVariant } from 'app/utils/theme' import { AppDrawer, useDrawerState } from '../drawer/AppDrawer' const { getTrendingRewardsModalType } = audioRewardsPageSelectors @@ -143,7 +142,7 @@ const useTweetId = (type: TrendingRewardsModalType) => { const useIsDark = () => { const themeVariant = useThemeVariant() - return themeVariant === Theme.DARK + return isDarkTheme(themeVariant) } export const TrendingRewardsDrawer = (titleIcon) => { diff --git a/packages/mobile/src/constants/storage-keys.ts b/packages/mobile/src/constants/storage-keys.ts index e6f4d25c251..7b8e8ae6cb2 100644 --- a/packages/mobile/src/constants/storage-keys.ts +++ b/packages/mobile/src/constants/storage-keys.ts @@ -5,4 +5,6 @@ export const IP_STORAGE_KEY = 'user-ip-timetstamp' export const SESSION_COUNT_KEY = '@session-count' export const ENTROPY_KEY = 'hedgehog-entropy-key' export const THEME_STORAGE_KEY = 'theme' +export const THEME_PALETTE_KEY = 'themePalette' +export const THEME_MODE_KEY = 'themeMode' export const SEARCH_HISTORY_KEY = '@search-history' diff --git a/packages/mobile/src/harmony-native/foundations/color/color.ts b/packages/mobile/src/harmony-native/foundations/color/color.ts index 2ec5577c2a6..017cc0aebf4 100644 --- a/packages/mobile/src/harmony-native/foundations/color/color.ts +++ b/packages/mobile/src/harmony-native/foundations/color/color.ts @@ -9,6 +9,44 @@ const baseLinearGradient = { } const primitiveOverrides = { + defaultLight: { + special: { + gradient: { + ...baseLinearGradient, + colors: [ + harmonyPrimitiveTheme.defaultLight.special.gradientStop1, + harmonyPrimitiveTheme.defaultLight.special.gradientStop2 + ] + }, + coinGradient: { + ...baseLinearGradient, + colors: [ + harmonyPrimitiveTheme.defaultLight.special.coinGradientColor1, + harmonyPrimitiveTheme.defaultLight.special.coinGradientColor2, + harmonyPrimitiveTheme.defaultLight.special.coinGradientColor3 + ] + } + } + }, + defaultDark: { + special: { + gradient: { + ...baseLinearGradient, + colors: [ + harmonyPrimitiveTheme.defaultDark.special.gradientStop1, + harmonyPrimitiveTheme.defaultDark.special.gradientStop2 + ] + }, + coinGradient: { + ...baseLinearGradient, + colors: [ + harmonyPrimitiveTheme.defaultDark.special.coinGradientColor1, + harmonyPrimitiveTheme.defaultDark.special.coinGradientColor2, + harmonyPrimitiveTheme.defaultDark.special.coinGradientColor3 + ] + } + } + }, day: { special: { gradient: { @@ -69,6 +107,26 @@ const primitiveOverrides = { } const semanticOverrides = { + defaultLight: { + text: { + heading: primitiveOverrides.defaultLight.special.gradient, + artistCoin: primitiveOverrides.defaultLight.special.coinGradient + }, + icon: { + heading: primitiveOverrides.defaultLight.special.gradient, + artistCoin: primitiveOverrides.defaultLight.special.coinGradient + } + }, + defaultDark: { + text: { + heading: primitiveOverrides.defaultDark.special.gradient, + artistCoin: primitiveOverrides.defaultDark.special.coinGradient + }, + icon: { + heading: primitiveOverrides.defaultDark.special.gradient, + artistCoin: primitiveOverrides.defaultDark.special.coinGradient + } + }, day: { text: { heading: primitiveOverrides.day.special.gradient, @@ -102,6 +160,36 @@ const semanticOverrides = { } export const colorTheme = { + defaultLight: { + ...harmonyTheme['default-light'].color, + special: { + ...harmonyTheme['default-light'].color.special, + ...primitiveOverrides.defaultLight.special + }, + text: { + ...harmonyTheme['default-light'].color.text, + ...semanticOverrides.defaultLight.text + }, + icon: { + ...harmonyTheme['default-light'].color.icon, + ...semanticOverrides.defaultLight.icon + } + }, + defaultDark: { + ...harmonyTheme['default-dark'].color, + special: { + ...harmonyTheme['default-dark'].color.special, + ...primitiveOverrides.defaultDark.special + }, + text: { + ...harmonyTheme['default-dark'].color.text, + ...semanticOverrides.defaultDark.text + }, + icon: { + ...harmonyTheme['default-dark'].color.icon, + ...semanticOverrides.defaultDark.icon + } + }, day: { ...harmonyTheme.day.color, special: { diff --git a/packages/mobile/src/harmony-native/foundations/theme/theme.ts b/packages/mobile/src/harmony-native/foundations/theme/theme.ts index f3843ea4605..5598185237e 100644 --- a/packages/mobile/src/harmony-native/foundations/theme/theme.ts +++ b/packages/mobile/src/harmony-native/foundations/theme/theme.ts @@ -43,13 +43,23 @@ const commonFoundations = { } export const theme = { - day: { - type: harmonyThemes.day.type, + 'default-light': { + type: harmonyThemes['default-light'].type, + color: colorTheme.defaultLight, + ...commonFoundations + }, + 'default-dark': { + type: harmonyThemes['default-dark'].type, + color: colorTheme.defaultDark, + ...commonFoundations + }, + 'classic-light': { + type: harmonyThemes['classic-light'].type, color: colorTheme.day, ...commonFoundations }, - dark: { - type: harmonyThemes.dark.type, + 'classic-dark': { + type: harmonyThemes['classic-dark'].type, color: colorTheme.dark, ...commonFoundations }, @@ -57,6 +67,18 @@ export const theme = { type: harmonyThemes.matrix.type, color: colorTheme.matrix, ...commonFoundations + }, + /** @deprecated Use classic-light */ + day: { + type: harmonyThemes.day.type, + color: colorTheme.day, + ...commonFoundations + }, + /** @deprecated Use classic-dark */ + dark: { + type: harmonyThemes.dark.type, + color: colorTheme.dark, + ...commonFoundations } } diff --git a/packages/mobile/src/screens/rewards-screen/ChallengeRewardsTile.tsx b/packages/mobile/src/screens/rewards-screen/ChallengeRewardsTile.tsx index 33cce0ab57f..325321c2a80 100644 --- a/packages/mobile/src/screens/rewards-screen/ChallengeRewardsTile.tsx +++ b/packages/mobile/src/screens/rewards-screen/ChallengeRewardsTile.tsx @@ -44,7 +44,7 @@ import type { ProfileTabScreenParamList } from 'app/screens/app-screen/ProfileTa import { make, track } from 'app/services/analytics' import { makeStyles } from 'app/styles' import { getChallengeConfig } from 'app/utils/challenges' -import { Theme, useThemeVariant } from 'app/utils/theme' +import { isDarkTheme, useThemeVariant } from 'app/utils/theme' import { Panel } from './Panel' const { setVisibility } = modalsActions @@ -106,8 +106,7 @@ export const ChallengeRewardsTile = () => { const navigation = useNavigation() const { spacing } = useTheme() const themeVariant = useThemeVariant() - const isDarkMode = - themeVariant === Theme.DARK || themeVariant === Theme.MATRIX + const isDarkMode = isDarkTheme(themeVariant) const userChallengesLoading = useSelector(getUserChallengesLoading) const userChallenges = useSelector(getUserChallenges) const { data: currentAccount } = useCurrentAccount() diff --git a/packages/mobile/src/screens/root-screen/StatusBar.tsx b/packages/mobile/src/screens/root-screen/StatusBar.tsx index c625feaab06..aeb05cb0080 100644 --- a/packages/mobile/src/screens/root-screen/StatusBar.tsx +++ b/packages/mobile/src/screens/root-screen/StatusBar.tsx @@ -2,7 +2,7 @@ import { useAccountStatus } from '@audius/common/api' import { Status } from '@audius/common/models' import { NavigationBar, StatusBar as RNStatusBar } from 'react-native-bars' -import { Theme, useThemeVariant } from 'app/utils/theme' +import { isDarkTheme, useThemeVariant } from 'app/utils/theme' type ThemedStatusBarProps = { isAppLoaded: boolean @@ -14,8 +14,7 @@ export const StatusBar = (props: ThemedStatusBarProps) => { const { data: accountStatus } = useAccountStatus() // Status & nav bar content (the android software buttons) should be light - const shouldRenderLightContent = - theme === Theme.DARK || theme === Theme.MATRIX + const shouldRenderLightContent = isDarkTheme(theme) const statusBarStyle = shouldRenderLightContent ? 'light-content' diff --git a/packages/mobile/src/screens/settings-screen/AppearanceSettingsRow.tsx b/packages/mobile/src/screens/settings-screen/AppearanceSettingsRow.tsx index a0af0bd276d..359100f78d2 100644 --- a/packages/mobile/src/screens/settings-screen/AppearanceSettingsRow.tsx +++ b/packages/mobile/src/screens/settings-screen/AppearanceSettingsRow.tsx @@ -1,16 +1,19 @@ import { useCallback } from 'react' import { useCurrentUserId } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { settingsMessages as messages } from '@audius/common/messages' -import { Name, Theme } from '@audius/common/models' +import { Name, Theme, ThemeMode, ThemePalette } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { useTierAndVerifiedForUser, themeActions, - themeSelectors + themeSelectors, + musicConfettiActions } from '@audius/common/store' import { useDispatch, useSelector } from 'react-redux' -import { IconAppearance } from '@audius/harmony-native' +import { IconAppearance, Flex } from '@audius/harmony-native' import { SegmentedControl } from 'app/components/core' import { make, track } from 'app/services/analytics' @@ -18,15 +21,37 @@ import { SettingsRowLabel } from './SettingRowLabel' import { SettingsRow } from './SettingsRow' import { SettingsRowContent } from './SettingsRowContent' import { SettingsRowDescription } from './SettingsRowDescription' -const { setTheme } = themeActions -const { getTheme } = themeSelectors + +const { setTheme, setThemePalette, setThemeMode } = themeActions +const { show: showMusicConfetti } = musicConfettiActions +const { getTheme, getThemePalette, getThemeMode } = themeSelectors export const AppearanceSettingsRow = () => { const theme = useSelector(getTheme) + const themePalette = useSelector(getThemePalette) + const themeMode = useSelector(getThemeMode) const { data: accountId } = useCurrentUserId() const dispatch = useDispatch() - const { tier } = useTierAndVerifiedForUser(accountId) + const { isEnabled: isNewThemeModelEnabled } = useFeatureFlag( + FeatureFlags.NEW_THEME_MODEL + ) + + const showMatrix = + tier === 'gold' || + tier === 'platinum' || + process.env.NODE_ENV === 'development' + + const effectivePalette = + themePalette ?? + (theme === Theme.MATRIX ? ThemePalette.MATRIX : ThemePalette.CLASSIC) + const effectiveMode = + themeMode ?? + (theme === Theme.LIGHT + ? ThemeMode.LIGHT + : theme === Theme.DARK + ? ThemeMode.DARK + : ThemeMode.AUTO) const appearanceOptions = [ { key: Theme.AUTO, text: messages.autoMode }, @@ -34,20 +59,73 @@ export const AppearanceSettingsRow = () => { { key: Theme.DARK, text: messages.darkMode } ] - if (tier === 'gold' || tier === 'platinum') { + if (showMatrix) { appearanceOptions.push({ key: Theme.MATRIX, text: messages.matrixMode }) } + const paletteOptions = [ + { key: ThemePalette.DEFAULT, text: messages.defaultPalette }, + { key: ThemePalette.CLASSIC, text: messages.classicPalette }, + { key: ThemePalette.MATRIX, text: messages.matrixMode } + ] + + const modeOptions = [ + { key: ThemeMode.AUTO, text: messages.autoMode }, + { key: ThemeMode.LIGHT, text: messages.lightMode }, + { key: ThemeMode.DARK, text: messages.darkMode } + ] + const handleSetTheme = useCallback( - (theme: Theme) => { - dispatch(setTheme({ theme })) + (selectedTheme: Theme) => { + dispatch(setTheme({ theme: selectedTheme })) + track( + make({ + eventName: Name.SETTINGS_CHANGE_THEME, + mode: selectedTheme.toLowerCase() as + | 'dark' + | 'light' + | 'matrix' + | 'auto' + }) + ) + }, + [dispatch] + ) - const trackEvent = make({ - eventName: Name.SETTINGS_CHANGE_THEME, - mode: theme.toLocaleLowerCase() as 'dark' | 'light' | 'matrix' | 'auto' - }) + const handlePaletteChange = useCallback( + (value: ThemePalette) => { + dispatch(setThemePalette({ themePalette: value })) + if (value === ThemePalette.MATRIX) { + dispatch(setTheme({ theme: Theme.MATRIX })) + dispatch(showMusicConfetti()) + } + track( + make({ + eventName: Name.SETTINGS_CHANGE_THEME, + mode: 'palette', + palette: value + } as any) + ) + }, + [dispatch] + ) - track(trackEvent) + const handleModeChange = useCallback( + (option: ThemeMode) => { + dispatch(setThemeMode({ themeMode: option })) + const themeValue = + option === ThemeMode.LIGHT + ? Theme.LIGHT + : option === ThemeMode.DARK + ? Theme.DARK + : Theme.AUTO + dispatch(setTheme({ theme: themeValue })) + track( + make({ + eventName: Name.SETTINGS_CHANGE_THEME, + mode: option.toLowerCase() as 'dark' | 'light' | 'auto' + }) + ) }, [dispatch] ) @@ -62,12 +140,32 @@ export const AppearanceSettingsRow = () => { {messages.appearanceDescription} - + {isNewThemeModelEnabled ? ( + + + {effectivePalette !== ThemePalette.MATRIX ? ( + + ) : null} + + ) : ( + + )} ) diff --git a/packages/mobile/src/store/theme/sagas.ts b/packages/mobile/src/store/theme/sagas.ts index 5ecd0190e4d..c05b210d7e3 100644 --- a/packages/mobile/src/store/theme/sagas.ts +++ b/packages/mobile/src/store/theme/sagas.ts @@ -1,20 +1,47 @@ -import type { SetThemeAction } from '@audius/common/store' +import type { + SetThemeAction, + SetThemePaletteAction, + SetThemeModeAction +} from '@audius/common/store' import { themeActions } from '@audius/common/store' import { takeEvery, call } from 'typed-redux-saga' -import { THEME_STORAGE_KEY } from 'app/constants/storage-keys' +import { + THEME_MODE_KEY, + THEME_PALETTE_KEY, + THEME_STORAGE_KEY +} from 'app/constants/storage-keys' import { localStorage } from 'app/services/local-storage' -const { setTheme } = themeActions + +const { setTheme, setThemePalette, setThemeMode } = themeActions function* setThemeAsync(action: SetThemeAction) { const { theme } = action.payload yield* call([localStorage, 'setItem'], THEME_STORAGE_KEY, theme) } +function* setThemePaletteAsync(action: SetThemePaletteAction) { + const { themePalette } = action.payload + yield* call([localStorage, 'setItem'], THEME_PALETTE_KEY, themePalette) +} + +function* setThemeModeAsync(action: SetThemeModeAction) { + const { themeMode } = action.payload + yield* call([localStorage, 'setItem'], THEME_MODE_KEY, themeMode) +} + function* watchSetTheme() { yield* takeEvery(setTheme, setThemeAsync) } +function* watchSetThemePalette() { + yield* takeEvery(setThemePalette, setThemePaletteAsync) +} + +function* watchSetThemeMode() { + yield* takeEvery(setThemeMode, setThemeModeAsync) +} + export default function sagas() { - return [watchSetTheme] + return [watchSetTheme, watchSetThemePalette, watchSetThemeMode] } diff --git a/packages/mobile/src/styles/makeAnimations.ts b/packages/mobile/src/styles/makeAnimations.ts index 8bfd2fe1e47..54948427a63 100644 --- a/packages/mobile/src/styles/makeAnimations.ts +++ b/packages/mobile/src/styles/makeAnimations.ts @@ -1,11 +1,13 @@ import { Theme } from '@audius/common/models' -import type { ThemeColors } from 'app/utils/theme' +import type { ResolvedThemeName, ThemeColors } from 'app/utils/theme' import { useThemeVariant, darkTheme, matrixTheme, - defaultTheme + defaultTheme, + defaultLightThemeColors, + defaultDarkThemeColors } from 'app/utils/theme' type AnimationCreatorConfig = { palette: ThemeColors; type: Theme } @@ -17,17 +19,29 @@ export const makeAnimations = ( palette: defaultTheme, type: Theme.LIGHT }) + const darkAnimations = animationCreator({ + palette: darkTheme, + type: Theme.DARK + }) + const matrixAnimations = animationCreator({ + palette: matrixTheme, + type: Theme.MATRIX + }) + const defaultLightAnimations = animationCreator({ + palette: defaultLightThemeColors, + type: Theme.LIGHT + }) + const defaultDarkAnimations = animationCreator({ + palette: defaultDarkThemeColors, + type: Theme.DARK + }) - const themedAnimations = { - [Theme.LIGHT]: lightAnimations, - [Theme.DARK]: animationCreator({ - palette: darkTheme, - type: Theme.DARK - }), - [Theme.MATRIX]: animationCreator({ - palette: matrixTheme, - type: Theme.MATRIX - }) + const themedAnimations: Record = { + 'default-light': defaultLightAnimations, + 'default-dark': defaultDarkAnimations, + 'classic-light': lightAnimations, + 'classic-dark': darkAnimations, + matrix: matrixAnimations } return function useAnimations(): TReturn { diff --git a/packages/mobile/src/styles/makeStyles.ts b/packages/mobile/src/styles/makeStyles.ts index f342aed892f..7e2193b3c4a 100644 --- a/packages/mobile/src/styles/makeStyles.ts +++ b/packages/mobile/src/styles/makeStyles.ts @@ -2,11 +2,13 @@ import { Theme } from '@audius/common/models' import type { ImageStyle, TextStyle, ViewStyle } from 'react-native' import { StyleSheet } from 'react-native' -import type { ThemeColors } from 'app/utils/theme' +import type { ResolvedThemeName, ThemeColors } from 'app/utils/theme' import { matrixTheme, defaultTheme, darkTheme, + defaultLightThemeColors, + defaultDarkThemeColors, useThemeVariant } from 'app/utils/theme' @@ -31,7 +33,23 @@ export const makeStyles = >( ): (() => T) => { const baseOptions = { spacing, typography } - const lightStylesheet = StyleSheet.create( + const defaultLightStylesheet = StyleSheet.create( + styles({ + type: Theme.LIGHT, + palette: defaultLightThemeColors, + ...baseOptions + }) + ) + + const defaultDarkStylesheet = StyleSheet.create( + styles({ + type: Theme.DARK, + palette: defaultDarkThemeColors, + ...baseOptions + }) + ) + + const classicLightStylesheet = StyleSheet.create( styles({ type: Theme.LIGHT, palette: defaultTheme, @@ -39,7 +57,7 @@ export const makeStyles = >( }) ) - const darkStylesheet = StyleSheet.create( + const classicDarkStylesheet = StyleSheet.create( styles({ type: Theme.DARK, palette: darkTheme, @@ -55,10 +73,12 @@ export const makeStyles = >( }) ) - const themedStylesheets = { - [Theme.LIGHT]: lightStylesheet, - [Theme.DARK]: darkStylesheet, - [Theme.MATRIX]: matrixStylesheet + const themedStylesheets: Record = { + 'default-light': defaultLightStylesheet, + 'default-dark': defaultDarkStylesheet, + 'classic-light': classicLightStylesheet, + 'classic-dark': classicDarkStylesheet, + matrix: matrixStylesheet } return function useStyles() { diff --git a/packages/mobile/src/utils/theme.ts b/packages/mobile/src/utils/theme.ts index 8fb12474d5e..30e9b1f14a8 100644 --- a/packages/mobile/src/utils/theme.ts +++ b/packages/mobile/src/utils/theme.ts @@ -1,11 +1,17 @@ -import { Theme } from '@audius/common/models' +import { + Theme, + ThemeMode, + ThemePalette, + SystemAppearance +} from '@audius/common/models' import type { CommonState } from '@audius/common/store' import { themeSelectors } from '@audius/common/store' -import type { HarmonyTheme } from '@audius/harmony/src/foundations/theme/theme' -import { themes } from '@audius/harmony/src/foundations/theme/theme' import { useSelector } from 'react-redux' -const { getTheme, getSystemAppearance } = themeSelectors +import { theme as harmonyNativeTheme } from '@audius/harmony-native' + +const { getTheme, getThemePalette, getThemeMode, getSystemAppearance } = + themeSelectors export { Theme } from '@audius/common/models' @@ -75,10 +81,10 @@ export type ThemeColors = { focus: string } -const createMobileThemeFromHarmony = ( - harmonyTheme: HarmonyTheme -): ThemeColors => { - const { color } = harmonyTheme +const createMobileThemeFromHarmony = (theme: { + color: Record +}): ThemeColors => { + const { color } = theme return { background: color.special.background, @@ -146,11 +152,44 @@ const createMobileThemeFromHarmony = ( } } -export const defaultTheme = createMobileThemeFromHarmony(themes.day) -export const darkTheme = createMobileThemeFromHarmony(themes.dark) -export const matrixTheme = createMobileThemeFromHarmony(themes.matrix) +export const defaultLightThemeColors = createMobileThemeFromHarmony( + harmonyNativeTheme['default-light'] +) +export const defaultDarkThemeColors = createMobileThemeFromHarmony( + harmonyNativeTheme['default-dark'] +) +export const defaultTheme = createMobileThemeFromHarmony( + harmonyNativeTheme['classic-light'] +) +export const darkTheme = createMobileThemeFromHarmony( + harmonyNativeTheme['classic-dark'] +) +export const matrixTheme = createMobileThemeFromHarmony( + harmonyNativeTheme.matrix +) + +export type ResolvedThemeName = + | 'default-light' + | 'default-dark' + | 'classic-light' + | 'classic-dark' + | 'matrix' -export const themeColorsByThemeVariant = { +/** True when theme has dark backgrounds (status/nav bar should use light content) */ +export const isDarkTheme = (theme: ResolvedThemeName): boolean => + theme === 'default-dark' || theme === 'classic-dark' || theme === 'matrix' + +export const themeColorsByThemeVariant: Record = + { + 'default-light': defaultLightThemeColors, + 'default-dark': defaultDarkThemeColors, + 'classic-light': defaultTheme, + 'classic-dark': darkTheme, + matrix: matrixTheme + } + +/** @deprecated Use useResolvedThemeName + themeColorsByThemeVariant */ +export const legacyThemeColorsByThemeVariant = { [Theme.LIGHT]: defaultTheme, [Theme.DARK]: darkTheme, [Theme.MATRIX]: matrixTheme @@ -159,14 +198,60 @@ export const themeColorsByThemeVariant = { export const selectSystemTheme = (state: CommonState) => { const systemAppearance = getSystemAppearance(state) const systemTheme = systemAppearance === 'dark' ? Theme.DARK : Theme.LIGHT - return themeColorsByThemeVariant[systemTheme] + return legacyThemeColorsByThemeVariant[systemTheme] } -export const useThemeVariant = (): keyof typeof themeColorsByThemeVariant => { +/** Resolve palette + mode + system preference to theme name */ +const resolveThemeName = ( + themePalette: ThemePalette | null, + themeMode: ThemeMode | null, + theme: Theme | null, + systemAppearance: SystemAppearance | null +): ResolvedThemeName => { + if (themePalette != null) { + if (themePalette === ThemePalette.MATRIX) return 'matrix' + const mode = + themeMode ?? + (theme === Theme.LIGHT + ? ThemeMode.LIGHT + : theme === Theme.DARK + ? ThemeMode.DARK + : ThemeMode.AUTO) + const sysAppearance = + systemAppearance ?? + (theme === Theme.DARK ? SystemAppearance.DARK : SystemAppearance.LIGHT) + const resolvedMode = + mode === ThemeMode.AUTO + ? sysAppearance === SystemAppearance.DARK + ? 'dark' + : 'light' + : mode === ThemeMode.LIGHT + ? 'light' + : 'dark' + if (themePalette === ThemePalette.DEFAULT) { + return resolvedMode === 'light' ? 'default-light' : 'default-dark' + } + return resolvedMode === 'light' ? 'classic-light' : 'classic-dark' + } + if (theme === Theme.MATRIX) return 'matrix' + if (theme === Theme.LIGHT) return 'classic-light' + if (theme === Theme.DARK) return 'classic-dark' + const sysAppearance = systemAppearance ?? SystemAppearance.LIGHT + return sysAppearance === SystemAppearance.DARK + ? 'classic-dark' + : 'classic-light' +} + +export const useResolvedThemeName = (): ResolvedThemeName => { const theme = useSelector(getTheme) + const themePalette = useSelector(getThemePalette) + const themeMode = useSelector(getThemeMode) const systemAppearance = useSelector(getSystemAppearance) - const systemTheme = systemAppearance === 'dark' ? Theme.DARK : Theme.LIGHT - return theme === Theme.AUTO ? systemTheme : (theme ?? systemTheme) + return resolveThemeName(themePalette, themeMode, theme, systemAppearance) +} + +export const useThemeVariant = (): ResolvedThemeName => { + return useResolvedThemeName() } export const useThemeColors = () => { @@ -186,9 +271,9 @@ export const useColor = (color: string): string => { // Uses normalColor when in light/dark mode, but "special color" when in other mode export const useSpecialColor = (normalColor: string, specialColor: string) => { - const theme = useSelector(getTheme) + const resolvedTheme = useResolvedThemeName() const themeVariant = useThemeColors() - if (theme === Theme.MATRIX) { + if (resolvedTheme === 'matrix') { return (themeVariant as any)[specialColor] } return (themeVariant as any)[normalColor] diff --git a/packages/web/src/app/AppProviders.tsx b/packages/web/src/app/AppProviders.tsx index 923bd42eccb..e369d9c09a6 100644 --- a/packages/web/src/app/AppProviders.tsx +++ b/packages/web/src/app/AppProviders.tsx @@ -16,7 +16,12 @@ import { useIsMobile } from 'hooks/useIsMobile' import { env } from 'services/env' import { queryClient } from 'services/query-client' import { configureStore } from 'store/configureStore' -import { getSystemAppearance, getTheme } from 'utils/theme/theme' +import { + getSystemAppearance, + getTheme, + getThemeModeFromStorage, + getThemePaletteFromStorage +} from 'utils/theme/theme' import { wagmiAdapter } from './ReownAppKitModal' import { createRoutes } from './routes' @@ -29,10 +34,15 @@ export const AppProviders = ({ children }: AppProvidersProps) => { const isMobile = useIsMobile() const [{ store, persistor }] = useState(() => { + const theme = getTheme() + const themePalette = getThemePaletteFromStorage() + const themeMode = getThemeModeFromStorage() const initialStoreState = { ui: { theme: { - theme: getTheme(), + theme, + themePalette, + themeMode, systemAppearance: getSystemAppearance() } } diff --git a/packages/web/src/app/ThemeProvider.tsx b/packages/web/src/app/ThemeProvider.tsx index 67f8916281e..a82fc9e2ef4 100644 --- a/packages/web/src/app/ThemeProvider.tsx +++ b/packages/web/src/app/ThemeProvider.tsx @@ -1,8 +1,17 @@ import { ReactNode, useEffect } from 'react' -import { Theme, SystemAppearance } from '@audius/common/models' +import { + SystemAppearance, + Theme as LegacyTheme, + ThemeMode, + ThemePalette +} from '@audius/common/models' import { themeActions, themeSelectors } from '@audius/common/store' -import { ThemeProvider as HarmonyThemeProvider } from '@audius/harmony' +import { + resolveTheme, + ThemeProvider as HarmonyThemeProvider +} from '@audius/harmony' +import type { Theme } from '@audius/harmony' import { useDispatch } from 'react-redux' import { AppState } from 'store/types' @@ -11,28 +20,44 @@ import { PREFERS_DARK_MEDIA_QUERY } from 'utils/theme/theme' const { setSystemAppearance } = themeActions -const { getTheme, getSystemAppearance } = themeSelectors +const { getTheme, getThemePalette, getThemeMode, getSystemAppearance } = + themeSelectors -const selectHarmonyTheme = (state: AppState) => { - const theme = getTheme(state) +const selectHarmonyTheme = (state: AppState): Theme => { + const themePalette = getThemePalette(state) + const themeMode = getThemeMode(state) + const legacyTheme = getTheme(state) const systemAppearance = getSystemAppearance(state) - switch (theme) { - case Theme.LIGHT: - return 'day' - case Theme.DARK: - return 'dark' - case Theme.MATRIX: + const sysAppearance: 'light' | 'dark' = + systemAppearance === SystemAppearance.DARK ? 'dark' : 'light' + const mode: 'auto' | 'light' | 'dark' = + themeMode === ThemeMode.AUTO + ? 'auto' + : themeMode === ThemeMode.DARK + ? 'dark' + : 'light' + + if (themePalette != null) { + const palette: 'default' | 'classic' | 'matrix' = + themePalette === ThemePalette.DEFAULT + ? 'default' + : themePalette === ThemePalette.MATRIX + ? 'matrix' + : 'classic' + return resolveTheme(palette, mode, sysAppearance) + } + + switch (legacyTheme) { + case LegacyTheme.LIGHT: + return 'classic-light' + case LegacyTheme.DARK: + return 'classic-dark' + case LegacyTheme.MATRIX: return 'matrix' - case Theme.AUTO: + case LegacyTheme.AUTO: default: - switch (systemAppearance) { - case SystemAppearance.DARK: - return 'dark' - case SystemAppearance.LIGHT: - default: - return 'day' - } + return sysAppearance === 'dark' ? 'classic-dark' : 'classic-light' } } diff --git a/packages/web/src/app/web-player/WebPlayer.module.css b/packages/web/src/app/web-player/WebPlayer.module.css index 25886ba3dd9..47394eca5ba 100644 --- a/packages/web/src/app/web-player/WebPlayer.module.css +++ b/packages/web/src/app/web-player/WebPlayer.module.css @@ -40,6 +40,8 @@ margin-left: var(--nav-width); margin-bottom: 88px; width: 100%; + /* Use theme background - ensures classic-dark/default-dark etc. apply correctly */ + background-color: var(--harmony-background); /* Show scrollbar always so the page doesn't jump around. */ overflow-y: scroll; overflow-x: hidden; diff --git a/packages/web/src/assets/styles/colors.css b/packages/web/src/assets/styles/colors.css index b02e3d6a7cf..171b446b181 100644 --- a/packages/web/src/assets/styles/colors.css +++ b/packages/web/src/assets/styles/colors.css @@ -34,7 +34,9 @@ } :root, -html[data-theme='day-v2'] { +html[data-theme='day'], +html[data-theme='classic-light'], +html[data-theme='default-light'] { --image-shadow: rgba(10, 10, 10, 0.35); --tile-shadow-1: rgba(133, 129, 153, 0.1); --tile-shadow-2: #e3e3e3; @@ -98,7 +100,7 @@ html[data-theme='day-v2'] { } html[data-theme='dark'], -html[data-theme='dark-v2'] { +html[data-theme='classic-dark'] { --darkmode-static-white: var(--harmony-white); --page-header-gradient-color-1: #7652cc; --page-header-gradient-color-2: #b05ce6; @@ -160,8 +162,78 @@ html[data-theme='dark-v2'] { --track-slider-handle: var(--harmony-white); } -html[data-theme='matrix'], -html[data-theme='matrix-v2'] { +/* Default theme (Neue) - neutral grays, uses harmony tokens for header gradient */ +html[data-theme='default-dark'] { + --darkmode-static-white: var(--harmony-white); + --page-header-gradient-color-1: #6f55d8; + --page-header-gradient-color-2: #d767e1; + --page-header-gradient: linear-gradient( + -22deg, + var(--page-header-gradient-color-1) 0%, + var(--page-header-gradient-color-2) 100% + ); + --page-header-gradient-1: var(--harmony-n-25); + --page-header-gradient-2: rgba(15, 15, 15, 0.85); + --page-header-gradient-2-alt: rgba(15, 15, 15, 0.95); + --page-header-text-shadow-color: rgba(111, 85, 216, 0.3); + --page-header-text-shadow: 0px 2px 4px var(--page-header-text-shadow-color); + /* Inherit other dark theme vars from a base - we need the full set */ + --tile-shadow-1: rgba(17, 17, 34, 0.1); + --tile-shadow-2: #1a1a1a; + --tile-shadow-3: rgba(17, 17, 34, 0.25); + --tile-shadow-1-alt: rgba(17, 17, 34, 0.2); + --currently-playing-border: none; + --currently-playing-default-shadow: rgba(17, 17, 17, 0.1); + --card-image-shadow: rgba(17, 17, 17, 0.25); + --input-background: rgba(31, 31, 31, 0.8); + --search-bar-background: rgba(31, 31, 31, 0.8); + --search-bar-expanded: rgba(31, 31, 31, 1); + --search-bar-border: rgba(143, 149, 152, 0.1); + --search-bar-shadow: rgba(0, 0, 0, 0.3); + --slider-handle: var(--harmony-n-950); + --table-shadow: rgba(17, 17, 34, 0.15); + --mask: rgba(0, 0, 0, 0.7); + --skeleton-gradient: linear-gradient( + 90deg, + #1a1a1a 25%, + #141414 37%, + #1a1a1a 63% + ); + --skeleton: var(--harmony-n-50); + --skeleton-highlight: var(--harmony-n-100); + --profile-completion-shadow-1: rgba(0, 0, 0, 0); + --profile-completion-shadow-2: rgba(17, 17, 34, 0.15); + --default-profile-picture-background: var(--harmony-n-200); + --notification-panel-box-shadow: rgba(0, 0, 0, 0.25); + --notification-border: rgba(51, 51, 51, 0.5); + --notification-background: #1f1f1f; + --notification-border-hover: rgba(51, 51, 51, 0.5); + --notification-background-hover: #1f1f1f; + --notification-background-box-shadow: 0 2px 15px -4px rgba(0, 0, 0, 0.3); + --notification-border-active: #333333; + --notification-background-active: #141414; + --notification-unread-border: #474747; + --notification-unread-background: #292929; + --notification-unread-border-hover: #474747; + --notification-unread-background-hover: #292929; + --notification-unread-background-box-shadow: none; + --notification-unread-border-active: #474747; + --notification-unread-background-active: #1a1a1a; + --secondary-gradient: linear-gradient(315deg, #6f55d8 0%, #d767e1 100%); + --next-and-previous-buttons: var(--harmony-neutral); + --play-button-triangle: var(--harmony-white); + --track-slider-rail: var(--harmony-neutral); + --track-slider-handle: var(--harmony-white); +} + +html[data-theme='default-light'] { + /* Override header gradient to use default theme's surface color */ + --page-header-gradient-1: var(--harmony-n-25); + --page-header-gradient-2: rgba(247, 247, 248, 0.85); + --page-header-gradient-2-alt: rgba(247, 247, 248, 0.95); +} + +html[data-theme='matrix'] { --darkmode-static-white: var(--harmony-white); --page-header-gradient-color-1: #4ff069; --page-header-gradient-color-2: #09bd51; diff --git a/packages/web/src/components/frosted/Frosted.tsx b/packages/web/src/components/frosted/Frosted.tsx index 8226ef082fc..e81a30b7d72 100644 --- a/packages/web/src/components/frosted/Frosted.tsx +++ b/packages/web/src/components/frosted/Frosted.tsx @@ -32,7 +32,7 @@ export const Frosted = ({ // backdrop-filter frosted glass effect. background: isChromeOrSafari ? 'linear-gradient(180deg, var(--harmony-n-25) 0%, var(--harmony-n-25) 20%, var(--page-header-gradient-2) 65%)' - : 'linear-gradient(180deg, var(--harmony-n-25) 0%, var(--page-n-25) 40%, var(--page-header-gradient-2-alt) 85%)' + : 'linear-gradient(180deg, var(--harmony-n-25) 0%, var(--harmony-n-25) 40%, var(--page-header-gradient-2-alt) 85%)' }} {...props} > diff --git a/packages/web/src/hooks/useConnectExternalWallets.ts b/packages/web/src/hooks/useConnectExternalWallets.ts index b6675120552..eab8c79fd1d 100644 --- a/packages/web/src/hooks/useConnectExternalWallets.ts +++ b/packages/web/src/hooks/useConnectExternalWallets.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react' import { useCurrentAccountUser } from '@audius/common/api' import { Name, Chain, Feature } from '@audius/common/models' +import { isLightTheme } from '@audius/harmony' import { useTheme } from '@emotion/react' import type { NamespaceTypeMap } from '@reown/appkit' import { mainnet } from '@reown/appkit/networks' @@ -88,13 +89,13 @@ export const useConnectExternalWallets = ( await disconnect() } appkitModal.updateFeatures({ socials: false, email: false }) - appkitModal.setThemeMode(theme.type === 'day' ? 'light' : 'dark') + appkitModal.setThemeMode(isLightTheme(theme.type) ? 'light' : 'dark') // If the user is signed in using an external wallet, they'll be connected // to the audiusChain network. Reset that to mainnet to connect properly. await appkitModal.switchNetwork(mainnet) await openAppKitModal({ view: 'Connect', namespace }) }, - [disconnect, isConnected, openAppKitModal, record, theme.type] + [disconnect, isConnected, openAppKitModal, record, theme] ) /** diff --git a/packages/web/src/pages/not-found-page/NotFoundPage.tsx b/packages/web/src/pages/not-found-page/NotFoundPage.tsx index 1e06fbc4096..57bd5c7a358 100644 --- a/packages/web/src/pages/not-found-page/NotFoundPage.tsx +++ b/packages/web/src/pages/not-found-page/NotFoundPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useContext } from 'react' import { Name } from '@audius/common/models' import { route } from '@audius/common/utils' -import { Button } from '@audius/harmony' +import { Button, isLightTheme } from '@audius/harmony' import { useTheme } from '@emotion/react' import cn from 'classnames' import Lottie from 'lottie-react' @@ -62,7 +62,7 @@ export const NotFoundPage = () => { })} css={{ backgroundImage: `url(${tiledBackground})`, - backgroundBlendMode: theme.type === 'day' ? 'none' : 'color-burn' + backgroundBlendMode: isLightTheme(theme.type) ? 'none' : 'color-burn' }} >
diff --git a/packages/web/src/pages/requires-update/RequiresUpdate.tsx b/packages/web/src/pages/requires-update/RequiresUpdate.tsx index 2d8d531b17f..a49aadd0038 100644 --- a/packages/web/src/pages/requires-update/RequiresUpdate.tsx +++ b/packages/web/src/pages/requires-update/RequiresUpdate.tsx @@ -1,4 +1,4 @@ -import { Box, Button, useTheme } from '@audius/harmony' +import { Box, Button, isLightTheme, useTheme } from '@audius/harmony' import tileBackground from 'assets/img/notFoundTiledBackround.png' @@ -25,7 +25,7 @@ export const RequiresUpdate = (props: RequiresUpdateProps) => { className={styles.content} css={{ backgroundImage: `url(${tileBackground})`, - backgroundBlendMode: theme.type === 'day' ? 'none' : 'color-burn' + backgroundBlendMode: isLightTheme(theme.type) ? 'none' : 'color-burn' }} >
{messages.title}
diff --git a/packages/web/src/pages/settings-page/components/desktop/SettingsPage.tsx b/packages/web/src/pages/settings-page/components/desktop/SettingsPage.tsx index aad248d69e4..396f86cc059 100644 --- a/packages/web/src/pages/settings-page/components/desktop/SettingsPage.tsx +++ b/packages/web/src/pages/settings-page/components/desktop/SettingsPage.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useCurrentAccountUser, useQueryContext } from '@audius/common/api' -import { useIsManagedAccount } from '@audius/common/hooks' +import { useFeatureFlag, useIsManagedAccount } from '@audius/common/hooks' import { settingsMessages } from '@audius/common/messages' -import { Name, Theme } from '@audius/common/models' +import { Name, Theme, ThemeMode, ThemePalette } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { API_TERMS, ARTIST_COIN_TERMS } from '@audius/common/src/utils/route' import { BrowserNotificationSetting, @@ -22,6 +23,7 @@ import { Button, Flex, IconAppearance, + Select, IconEmailAddress, IconError, IconKey, @@ -66,7 +68,7 @@ import { import { isElectron } from 'utils/clientUtil' import { push } from 'utils/navigation' import { useSelector } from 'utils/reducer' -import { THEME_KEY } from 'utils/theme/theme' +import { THEME_KEY, THEME_MODE_KEY, THEME_PALETTE_KEY } from 'utils/theme/theme' import packageInfo from '../../../../../package.json' @@ -84,8 +86,8 @@ import { WormholeConversionSettingsCard } from './WormholeConversionSettingsCard const { show } = musicConfettiActions const { signOut: signOutAction } = signOutActions -const { setTheme } = themeActions -const { getTheme } = themeSelectors +const { setTheme, setThemePalette, setThemeMode } = themeActions +const { getTheme, getThemePalette, getThemeMode } = themeSelectors const { getBrowserNotificationSettings, getEmailFrequency } = settingsPageSelectors const { @@ -138,6 +140,8 @@ export const SettingsPage = () => { }) const { handle, userId, isVerified } = accountData ?? {} const theme = useSelector(getTheme) + const themePalette = useSelector(getThemePalette) + const themeMode = useSelector(getThemeMode) const emailFrequency = useSelector(getEmailFrequency) const notificationSettings = useSelector(getBrowserNotificationSettings) const { tier } = useTierAndVerifiedForUser(userId) @@ -145,6 +149,9 @@ export const SettingsPage = () => { tier === 'gold' || tier === 'platinum' || process.env.NODE_ENV === 'development' + const { isEnabled: isNewThemeModelEnabled } = useFeatureFlag( + FeatureFlags.NEW_THEME_MODEL + ) const [isSignOutModalVisible, setIsSignOutModalVisible] = useState(false) const [ @@ -336,35 +343,85 @@ export const SettingsPage = () => { [dispatch, notificationSettings.permission] ) - const toggleTheme = (option: Theme) => { + const effectivePalette = + themePalette ?? + (theme === Theme.MATRIX ? ThemePalette.MATRIX : ThemePalette.CLASSIC) + const effectiveMode = + themeMode ?? + (theme === Theme.LIGHT + ? ThemeMode.LIGHT + : theme === Theme.DARK + ? ThemeMode.DARK + : ThemeMode.AUTO) + + const onPaletteChange = (value: ThemePalette) => { + dispatch(setThemePalette({ themePalette: value })) + if (value === ThemePalette.MATRIX) { + dispatch(setTheme({ theme: Theme.MATRIX })) + dispatch(show()) + } + if (typeof window !== 'undefined') { + window.localStorage.setItem(THEME_PALETTE_KEY, value) + if (value === ThemePalette.MATRIX) { + window.localStorage.setItem(THEME_KEY, Theme.MATRIX) + } + } dispatch( make(Name.SETTINGS_CHANGE_THEME, { - mode: option.toLowerCase() as 'dark' | 'light' | 'matrix' | 'auto' + mode: 'palette', + palette: value }) ) - dispatch(setTheme({ theme: option })) - if (option === Theme.MATRIX) { - dispatch(show()) - } + } + + const onModeChange = (option: ThemeMode) => { + dispatch(setThemeMode({ themeMode: option })) + const theme = + option === ThemeMode.LIGHT + ? Theme.LIGHT + : option === ThemeMode.DARK + ? Theme.DARK + : Theme.AUTO + dispatch(setTheme({ theme })) if (typeof window !== 'undefined') { - window.localStorage.setItem(THEME_KEY, option) + window.localStorage.setItem(THEME_MODE_KEY, option) + window.localStorage.setItem(THEME_KEY, theme) } + dispatch( + make(Name.SETTINGS_CHANGE_THEME, { + mode: option.toLowerCase() as 'dark' | 'light' | 'auto' + }) + ) } - const appearanceOptions = useMemo(() => { + const paletteOptions = useMemo(() => { + const options: { value: ThemePalette; label: string }[] = [ + { value: ThemePalette.DEFAULT, label: settingsMessages.defaultPalette }, + { value: ThemePalette.CLASSIC, label: settingsMessages.classicPalette } + ] + if (showMatrix) { + options.push({ + value: ThemePalette.MATRIX, + label: settingsMessages.matrixMode + }) + } + return options + }, [showMatrix]) + + const modeOptions = useMemo( + () => [ + { key: ThemeMode.AUTO, text: settingsMessages.autoMode }, + { key: ThemeMode.LIGHT, text: settingsMessages.lightMode }, + { key: ThemeMode.DARK, text: settingsMessages.darkMode } + ], + [] + ) + + const legacyThemeOptions = useMemo(() => { const options = [ - { - key: Theme.AUTO, - text: settingsMessages.autoMode - }, - { - key: Theme.LIGHT, - text: settingsMessages.lightMode - }, - { - key: Theme.DARK, - text: settingsMessages.darkMode - } + { key: Theme.AUTO, text: settingsMessages.autoMode }, + { key: Theme.DARK, text: settingsMessages.darkMode }, + { key: Theme.LIGHT, text: settingsMessages.lightMode } ] if (showMatrix) { options.push({ key: Theme.MATRIX, text: settingsMessages.matrixMode }) @@ -372,6 +429,24 @@ export const SettingsPage = () => { return options }, [showMatrix]) + const toggleLegacyTheme = useCallback( + (option: Theme) => { + dispatch( + make(Name.SETTINGS_CHANGE_THEME, { + mode: option.toLowerCase() as 'dark' | 'light' | 'matrix' | 'auto' + }) + ) + dispatch(setTheme({ theme: option })) + if (option === Theme.MATRIX) { + dispatch(show()) + } + if (typeof window !== 'undefined') { + window.localStorage.setItem(THEME_KEY, option) + } + }, + [dispatch] + ) + const isMobile = useIsMobile() const isDownloadDesktopEnabled = !isMobile && !isElectron() @@ -392,14 +467,35 @@ export const SettingsPage = () => { description={settingsMessages.appearanceDescription} isFull={true} > - toggleTheme(option)} - key={`tab-slider-${appearanceOptions.length}`} - /> + {isNewThemeModelEnabled ? ( + + + value={effectivePalette} + options={paletteOptions} + onChange={(value) => onPaletteChange(value)} + label={settingsMessages.appearanceTitle} + optionsLabel='Theme' + /> + {effectivePalette !== ThemePalette.MATRIX ? ( + onModeChange(option)} + key={`tab-slider-${effectivePalette}`} + /> + ) : null} + + ) : ( + toggleLegacyTheme(option)} + key={`tab-slider-legacy-${legacyThemeOptions.length}`} + /> + )} ) : null} {!isManagedAccount ? ( diff --git a/packages/web/src/pages/settings-page/components/mobile/SettingsPage.tsx b/packages/web/src/pages/settings-page/components/mobile/SettingsPage.tsx index c9a5a1b2f6d..c949102bda1 100644 --- a/packages/web/src/pages/settings-page/components/mobile/SettingsPage.tsx +++ b/packages/web/src/pages/settings-page/components/mobile/SettingsPage.tsx @@ -1,7 +1,16 @@ import { useCallback, useContext, useEffect, useState, FC } from 'react' import { useCurrentAccountUser } from '@audius/common/api' -import { Name, SquareSizes, Theme } from '@audius/common/models' +import { useFeatureFlag } from '@audius/common/hooks' +import { settingsMessages } from '@audius/common/messages' +import { + Name, + SquareSizes, + Theme, + ThemeMode, + ThemePalette +} from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { settingsPageActions, themeSelectors, @@ -19,6 +28,7 @@ import { ModalContentText, ModalFooter, SegmentedControl, + Select, Text, IconAudiusLogoHorizontalColor, IconLogoCircleUSDCPng @@ -36,7 +46,12 @@ import NavContext, { LeftPreset } from 'components/nav/mobile/NavContext' import Page from 'components/page/Page' import { useProfilePicture } from 'hooks/useProfilePicture' import useScrollToTop from 'hooks/useScrollToTop' -import { isDarkMode } from 'utils/theme/theme' +import { + isDarkMode, + THEME_KEY, + THEME_MODE_KEY, + THEME_PALETTE_KEY +} from 'utils/theme/theme' import AboutSettingsPage from './AboutSettingsPage' import AccountSettingsPage from './AccountSettingsPage' @@ -49,8 +64,8 @@ const { getNotificationSettings: getNotificationSettingsAction, getPushNotificationSettings: getPushNotificationSettingsAction } = settingsPageActions -const { setTheme } = themeActions -const { getTheme } = themeSelectors +const { setTheme, setThemePalette, setThemeMode } = themeActions +const { getTheme, getThemePalette, getThemeMode } = themeSelectors const { show } = musicConfettiActions const { ACCOUNT_SETTINGS_PAGE, @@ -71,8 +86,7 @@ export enum SubPage { const messages = { pageTitle: 'Settings', appearanceTitle: 'Appearance', - appearance: - 'Enable dark mode or use the default setting to match your system preferences.', + appearance: settingsMessages.appearanceDescription, aboutTitle: 'About', cast: 'Select your prefered casting method.', title: 'Settings', @@ -115,8 +129,27 @@ export const SettingsPage = (props: SettingsPageProps) => { }) const { userId, handle, name } = accountData ?? {} const theme = useSelector(getTheme) + const themePalette = useSelector(getThemePalette) + const themeMode = useSelector(getThemeMode) const { tier } = useTierAndVerifiedForUser(userId) - const showMatrix = tier === 'gold' || tier === 'platinum' + const showMatrix = + tier === 'gold' || + tier === 'platinum' || + process.env.NODE_ENV === 'development' + const { isEnabled: isNewThemeModelEnabled } = useFeatureFlag( + FeatureFlags.NEW_THEME_MODEL + ) + + const effectivePalette = + themePalette ?? + (theme === Theme.MATRIX ? ThemePalette.MATRIX : ThemePalette.CLASSIC) + const effectiveMode = + themeMode ?? + (theme === Theme.LIGHT + ? ThemeMode.LIGHT + : theme === Theme.DARK + ? ThemeMode.DARK + : ThemeMode.AUTO) // Check for verification query param and show appropriate modal const verificationStatus = searchParams.get('verification') @@ -168,7 +201,67 @@ export const SettingsPage = (props: SettingsPageProps) => { size: SquareSizes.SIZE_150_BY_150 }) - const toggleTheme = (option: Theme) => { + const onPaletteChange = (value: ThemePalette) => { + dispatch(setThemePalette({ themePalette: value })) + if (value === ThemePalette.MATRIX) { + dispatch(setTheme({ theme: Theme.MATRIX })) + dispatch(show()) + } + if (typeof window !== 'undefined') { + window.localStorage.setItem(THEME_PALETTE_KEY, value) + if (value === ThemePalette.MATRIX) { + window.localStorage.setItem(THEME_KEY, Theme.MATRIX) + } + } + dispatch( + make(Name.SETTINGS_CHANGE_THEME, { mode: 'palette', palette: value }) + ) + } + + const onModeChange = (option: ThemeMode) => { + dispatch(setThemeMode({ themeMode: option })) + const theme = + option === ThemeMode.LIGHT + ? Theme.LIGHT + : option === ThemeMode.DARK + ? Theme.DARK + : Theme.AUTO + dispatch(setTheme({ theme })) + if (typeof window !== 'undefined') { + window.localStorage.setItem(THEME_MODE_KEY, option) + window.localStorage.setItem(THEME_KEY, theme) + } + dispatch( + make(Name.SETTINGS_CHANGE_THEME, { + mode: option.toLowerCase() as 'dark' | 'light' | 'auto' + }) + ) + } + + const paletteOptions = [ + { value: ThemePalette.DEFAULT, label: settingsMessages.defaultPalette }, + { value: ThemePalette.CLASSIC, label: settingsMessages.classicPalette }, + ...(showMatrix + ? [{ value: ThemePalette.MATRIX, label: settingsMessages.matrixMode }] + : []) + ] + + const modeOptions = [ + { key: ThemeMode.AUTO, text: settingsMessages.autoMode }, + { key: ThemeMode.LIGHT, text: settingsMessages.lightMode }, + { key: ThemeMode.DARK, text: settingsMessages.darkMode } + ] + + const legacyThemeOptions = [ + { key: Theme.AUTO, text: settingsMessages.autoMode }, + { key: Theme.DARK, text: settingsMessages.darkMode }, + { key: Theme.LIGHT, text: settingsMessages.lightMode }, + ...(showMatrix + ? [{ key: Theme.MATRIX, text: settingsMessages.matrixMode }] + : []) + ] + + const toggleLegacyTheme = (option: Theme) => { dispatch( make(Name.SETTINGS_CHANGE_THEME, { mode: option.toLowerCase() as 'dark' | 'light' | 'matrix' | 'auto' @@ -178,6 +271,9 @@ export const SettingsPage = (props: SettingsPageProps) => { if (option === Theme.MATRIX) { dispatch(show()) } + if (typeof window !== 'undefined') { + window.localStorage.setItem(THEME_KEY, option) + } } // Render out subPage if we're on one. @@ -186,37 +282,38 @@ export const SettingsPage = (props: SettingsPageProps) => { return } - const renderThemeSlider = () => { - const options = [ - { - key: Theme.AUTO, - text: 'Auto' - }, - { - key: Theme.DARK, - text: 'Dark' - }, - { - key: Theme.LIGHT, - text: 'Light' - } - ] - - if (showMatrix) { - options.push({ key: Theme.MATRIX, text: messages.matrixMode }) - } - - return ( + const renderThemeControls = () => + isNewThemeModelEnabled ? ( + + + value={effectivePalette} + options={paletteOptions} + onChange={(value) => onPaletteChange(value)} + label={messages.appearanceTitle} + optionsLabel='Theme' + /> + {effectivePalette !== ThemePalette.MATRIX ? ( + onModeChange(option)} + key={`tab-slider-${effectivePalette}`} + /> + ) : null} + + ) : ( toggleTheme(option)} - key={`tab-slider-${options.length}`} + onSelectOption={(option) => toggleLegacyTheme(option)} + key={`tab-slider-legacy-${legacyThemeOptions.length}`} /> ) - } return ( { title={messages.appearanceTitle} body={messages.appearance} > - {renderThemeSlider()} + {renderThemeControls()} diff --git a/packages/web/src/utils/theme/theme.ts b/packages/web/src/utils/theme/theme.ts index 89baa65a297..5aa15bfb7f4 100644 --- a/packages/web/src/utils/theme/theme.ts +++ b/packages/web/src/utils/theme/theme.ts @@ -1,11 +1,15 @@ import { SystemAppearance, Theme, + ThemeMode, + ThemePalette, LEGACY_THEME_DEFAULT } from '@audius/common/models' import { useSelector } from 'react-redux' export const THEME_KEY = 'theme' +export const THEME_PALETTE_KEY = 'themePalette' +export const THEME_MODE_KEY = 'themeMode' export const PREFERS_DARK_MEDIA_QUERY = '(prefers-color-scheme: dark)' const doesPreferDarkMode = () => { @@ -40,6 +44,31 @@ export const getTheme = (): Theme | null => { return Theme.AUTO } +/** Derive themePalette from stored theme (for migration from legacy) */ +export const getThemePaletteFromStorage = (): ThemePalette | null => { + if (typeof window === 'undefined') return null + const stored = window.localStorage.getItem(THEME_PALETTE_KEY) + if (stored && Object.values(ThemePalette).includes(stored as ThemePalette)) { + return stored as ThemePalette + } + const theme = getTheme() + if (theme === Theme.MATRIX) return ThemePalette.MATRIX + return null +} + +/** Derive themeMode from stored theme (for migration from legacy) */ +export const getThemeModeFromStorage = (): ThemeMode | null => { + if (typeof window === 'undefined') return null + const stored = window.localStorage.getItem(THEME_MODE_KEY) + if (stored && Object.values(ThemeMode).includes(stored as ThemeMode)) { + return stored as ThemeMode + } + const theme = getTheme() + if (theme === Theme.LIGHT) return ThemeMode.LIGHT + if (theme === Theme.DARK) return ThemeMode.DARK + return ThemeMode.AUTO +} + export const getSystemAppearance = () => doesPreferDarkMode() ? SystemAppearance.DARK : SystemAppearance.LIGHT