diff --git a/services/web/app/src/Features/Project/UserSettingsHelper.mjs b/services/web/app/src/Features/Project/UserSettingsHelper.mjs index d7ef8cb433..90c7b1e931 100644 --- a/services/web/app/src/Features/Project/UserSettingsHelper.mjs +++ b/services/web/app/src/Features/Project/UserSettingsHelper.mjs @@ -51,6 +51,8 @@ async function buildUserSettings(req, res, user) { return { mode: user.ace.mode, editorTheme: user.ace.theme, + editorLightTheme: user.ace.lightTheme, + editorDarkTheme: user.ace.darkTheme, fontSize: user.ace.fontSize, autoComplete: user.ace.autoComplete, autoPairDelimiters: user.ace.autoPairDelimiters, diff --git a/services/web/app/src/Features/User/UserController.mjs b/services/web/app/src/Features/User/UserController.mjs index e5f22180f3..1f3e43d113 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/services/web/app/src/Features/User/UserController.mjs @@ -368,6 +368,12 @@ async function updateUserSettings(req, res, next) { if (body.editorTheme != null) { user.ace.theme = body.editorTheme } + if (body.editorLightTheme != null) { + user.ace.lightTheme = body.editorLightTheme + } + if (body.editorDarkTheme != null) { + user.ace.darkTheme = body.editorDarkTheme + } if (body.overallTheme != null) { user.ace.overallTheme = body.overallTheme } diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index 54aac48a4a..e95cdef18f 100644 --- a/services/web/app/src/models/User.mjs +++ b/services/web/app/src/models/User.mjs @@ -89,6 +89,10 @@ export const UserSchema = new Schema( mode: { type: String, default: 'none' }, theme: { type: String, default: 'textmate' }, overallTheme: { type: String, default: '' }, + // When overallTheme is `system`, we switch between `lightTheme` and `darkTheme` based on system settings + // When overallTheme is `light-` or empty, we use the `theme` option. + lightTheme: { type: String, default: 'textmate' }, + darkTheme: { type: String, default: 'overleaf_dark' }, fontSize: { type: Number, default: '12' }, autoComplete: { type: Boolean, default: true }, autoPairDelimiters: { type: Boolean, default: true }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4a479942d9..99fae2acca 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -528,6 +528,8 @@ "editor_only": "", "editor_only_hide_pdf": "", "editor_theme": "", + "editor_theme_dark": "", + "editor_theme_light": "", "edits_become_suggestions": "", "educational_disclaimer": "", "educational_disclaimer_heading": "", @@ -1861,6 +1863,8 @@ "thanks_settings_updated": "", "the_add_on_will_remain_active_until": "", "the_code_editor_color_scheme": "", + "the_code_editor_color_scheme_dark_mode": "", + "the_code_editor_color_scheme_light_mode": "", "the_following_files_already_exist_in_this_project": "", "the_following_files_and_folders_already_exist_in_this_project": "", "the_following_folder_already_exists_in_this_project": "", diff --git a/services/web/frontend/js/features/editor-left-menu/components/left-menu-mask.tsx b/services/web/frontend/js/features/editor-left-menu/components/left-menu-mask.tsx index 88079eda6d..9a2d068f07 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/left-menu-mask.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/left-menu-mask.tsx @@ -5,20 +5,28 @@ import { useUserSettingsContext } from '@/shared/context/user-settings-context' export default memo(function LeftMenuMask() { const { setLeftMenuShown } = useLayoutContext() const { userSettings } = useUserSettingsContext() - const { editorTheme, overallTheme } = userSettings - const [original] = useState({ editorTheme, overallTheme }) + const { editorTheme, editorLightTheme, editorDarkTheme, overallTheme } = + userSettings + const [original] = useState({ + editorTheme, + overallTheme, + editorLightTheme, + editorDarkTheme, + }) const maskRef = useRef(null) useEffect(() => { if (maskRef.current) { if ( editorTheme !== original.editorTheme || + editorLightTheme !== original.editorLightTheme || + editorDarkTheme !== original.editorDarkTheme || overallTheme !== original.overallTheme ) { maskRef.current.style.opacity = '0' } } - }, [editorTheme, overallTheme, original]) + }, [editorTheme, editorLightTheme, editorDarkTheme, overallTheme, original]) return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings-menu.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings-menu.tsx index f692c6e7fa..8565497448 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings-menu.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings-menu.tsx @@ -50,8 +50,8 @@ export default function SettingsMenu() { - + diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx index d7db7c5c13..f30b08df8b 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx @@ -2,12 +2,43 @@ import { useTranslation } from 'react-i18next' import { useProjectSettingsContext } from '../../context/project-settings-context' import SettingsMenuSelect from './settings-menu-select' import { useEditorThemesOptionGroups } from '../../hooks/use-editor-theme-option-groups' +import { isIEEEBranded } from '@/utils/is-ieee-branded' export default function SettingsEditorTheme() { const { t } = useTranslation() - const { editorTheme, setEditorTheme } = useProjectSettingsContext() - const optGroups = useEditorThemesOptionGroups() + const { + editorTheme, + setEditorTheme, + editorLightTheme, + editorDarkTheme, + setEditorLightTheme, + setEditorDarkTheme, + overallTheme, + } = useProjectSettingsContext() + + if (overallTheme === 'system' && !isIEEEBranded()) { + return ( + <> + + + + ) + } return ( void setMode: (mode: UserSettings['mode']) => void setEditorTheme: (editorTheme: UserSettings['editorTheme']) => void + setEditorLightTheme: ( + editorLightTheme: UserSettings['editorLightTheme'] + ) => void + setEditorDarkTheme: (editorDarkTheme: UserSettings['editorDarkTheme']) => void setOverallTheme: (overallTheme: UserSettings['overallTheme']) => void setFontSize: (fontSize: UserSettings['fontSize']) => void setFontFamily: (fontFamily: UserSettings['fontFamily']) => void @@ -62,6 +66,10 @@ export const ProjectSettingsProvider: FC = ({ setSyntaxValidation, editorTheme, setEditorTheme, + editorLightTheme, + setEditorLightTheme, + editorDarkTheme, + setEditorDarkTheme, overallTheme, setOverallTheme, mode, @@ -102,6 +110,10 @@ export const ProjectSettingsProvider: FC = ({ setSyntaxValidation, editorTheme, setEditorTheme, + editorLightTheme, + setEditorLightTheme, + editorDarkTheme, + setEditorDarkTheme, overallTheme, setOverallTheme, mode, @@ -138,6 +150,10 @@ export const ProjectSettingsProvider: FC = ({ setSyntaxValidation, editorTheme, setEditorTheme, + editorLightTheme, + setEditorLightTheme, + editorDarkTheme, + setEditorDarkTheme, overallTheme, setOverallTheme, mode, diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx index a41cc0aa10..8f153e513a 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx @@ -14,6 +14,8 @@ export default function useUserWideSettings() { autoPairDelimiters, syntaxValidation, editorTheme, + editorLightTheme, + editorDarkTheme, mode, fontSize, fontFamily, @@ -53,6 +55,20 @@ export default function useUserWideSettings() { [saveUserSettings] ) + const setEditorLightTheme = useCallback( + (editorLightTheme: UserSettings['editorLightTheme']) => { + saveUserSettings('editorLightTheme', editorLightTheme) + }, + [saveUserSettings] + ) + + const setEditorDarkTheme = useCallback( + (editorDarkTheme: UserSettings['editorDarkTheme']) => { + saveUserSettings('editorDarkTheme', editorDarkTheme) + }, + [saveUserSettings] + ) + const setMode = useCallback( (mode: UserSettings['mode']) => { saveUserSettings('mode', mode) @@ -118,6 +134,10 @@ export default function useUserWideSettings() { setSyntaxValidation, editorTheme, setEditorTheme, + editorLightTheme, + setEditorLightTheme, + editorDarkTheme, + setEditorDarkTheme, overallTheme, setOverallTheme, mode, diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting.tsx index acb80ee0ab..19c460c6f4 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting.tsx @@ -2,13 +2,49 @@ import { useProjectSettingsContext } from '@/features/editor-left-menu/context/p import DropdownSetting from '../dropdown-setting' import { useTranslation } from 'react-i18next' import { useEditorThemesOptionGroups } from '@/features/editor-left-menu/hooks/use-editor-theme-option-groups' +import { isIEEEBranded } from '@/utils/is-ieee-branded' export default function EditorThemeSetting() { - const { editorTheme, setEditorTheme } = useProjectSettingsContext() + const { + editorTheme, + setEditorTheme, + editorLightTheme, + setEditorLightTheme, + editorDarkTheme, + setEditorDarkTheme, + overallTheme, + } = useProjectSettingsContext() const { t } = useTranslation() const optGroups = useEditorThemesOptionGroups() + if (overallTheme === 'system' && !isIEEEBranded()) { + return ( + <> + + + + ) + } + return ( ) } diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 32c5cc1306..d96740fb98 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -61,6 +61,7 @@ import { SearchQuery } from '@codemirror/search' import { beforeChangeDocEffect } from '@/features/source-editor/extensions/before-change-doc' import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme' import { useEditorSelectionContext } from '@/shared/context/editor-selection-context' +import { useActiveEditorTheme } from '@/shared/hooks/use-active-editor-theme' function useCodeMirrorScope(view: EditorView) { const { fileTreeData } = useFileTreeData() @@ -82,7 +83,6 @@ function useCodeMirrorScope(view: EditorView) { fontSize, lineHeight, autoComplete, - editorTheme, autoPairDelimiters, mode, syntaxValidation, @@ -91,6 +91,7 @@ function useCodeMirrorScope(view: EditorView) { enableNewEditor, } = userSettings const activeOverallTheme = useActiveOverallTheme() + const editorTheme = useActiveEditorTheme() const { onlineUserCursorHighlights } = useOnlineUsersContext() diff --git a/services/web/frontend/js/shared/context/user-settings-context.tsx b/services/web/frontend/js/shared/context/user-settings-context.tsx index 8f45074925..f7e9e1820b 100644 --- a/services/web/frontend/js/shared/context/user-settings-context.tsx +++ b/services/web/frontend/js/shared/context/user-settings-context.tsx @@ -16,6 +16,8 @@ const defaultSettings: UserSettings = { autoPairDelimiters: true, syntaxValidation: false, editorTheme: 'textmate', + editorDarkTheme: 'overleaf_dark', + editorLightTheme: 'textmate', overallTheme: '', mode: 'default', fontSize: 12, diff --git a/services/web/frontend/js/shared/hooks/use-active-editor-theme.tsx b/services/web/frontend/js/shared/hooks/use-active-editor-theme.tsx new file mode 100644 index 0000000000..5d6e3ccddc --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-active-editor-theme.tsx @@ -0,0 +1,20 @@ +import { useActiveOverallTheme } from './use-active-overall-theme' +import { useUserSettingsContext } from '../context/user-settings-context' +import { isIEEEBranded } from '@/utils/is-ieee-branded' + +export const useActiveEditorTheme = () => { + const activeOverallTheme = useActiveOverallTheme() + const { + userSettings: { + overallTheme, + editorTheme, + editorLightTheme, + editorDarkTheme, + }, + } = useUserSettingsContext() + if (overallTheme !== 'system' || isIEEEBranded()) { + return editorTheme + } else { + return activeOverallTheme === 'dark' ? editorDarkTheme : editorLightTheme + } +} diff --git a/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx b/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx index 2a7b391c83..bb95ecfe57 100644 --- a/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx +++ b/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx @@ -6,13 +6,6 @@ import { useSplitTestContext } from '../context/split-test-context' export type ActiveOverallTheme = 'dark' | 'light' -const mediaWatcher = window.matchMedia?.('(prefers-color-scheme: dark)') ?? { - // If matchMedia is not supported, use the default (dark) theme - matches: true, - addEventListener: () => {}, - removeEventListener: () => {}, -} - function getTheme( overallTheme: OverallTheme, prefersDark: boolean @@ -33,8 +26,13 @@ export const useActiveOverallTheme = ( featureFlag?: string ): ActiveOverallTheme => { const { splitTestVariants } = useSplitTestContext() - const [browserPrefersDarkMode, setBrowserPrefersDarkMode] = useState( - mediaWatcher.matches + const [browserPrefersDarkMode, setBrowserPrefersDarkMode] = useState(() => + // If matchMedia is not supported, use the default (dark) theme + { + return ( + window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? true + ) + } ) const { userSettings: { overallTheme }, @@ -50,6 +48,9 @@ export const useActiveOverallTheme = ( }, [overallTheme, browserPrefersDarkMode, featureFlag, splitTestVariants]) useEffect(() => { + const mediaWatcher = window.matchMedia?.('(prefers-color-scheme: dark)') + if (!mediaWatcher) return + const listener = (e: MediaQueryListEvent) => { setBrowserPrefersDarkMode(e.matches) } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 19b9083841..08286cb179 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -673,6 +673,8 @@ "editor_only": "Editor only", "editor_only_hide_pdf": "Editor only <0>(hide PDF)", "editor_theme": "Editor theme", + "editor_theme_dark": "Dark editor theme", + "editor_theme_light": "Light editor theme", "edits_become_suggestions": "Edits become suggestions", "educational_disclaimer": "I confirm that users will be students or faculty using Overleaf primarily for study and teaching, and can provide evidence of this if requested.", "educational_disclaimer_heading": "Educational discount confirmation", @@ -2381,6 +2383,8 @@ "thanks_settings_updated": "Thanks, your settings have been updated.", "the_add_on_will_remain_active_until": "The add-on will remain active until the end of the current billing period.", "the_code_editor_color_scheme": "The code editor color scheme", + "the_code_editor_color_scheme_dark_mode": "The code editor color scheme for dark mode", + "the_code_editor_color_scheme_light_mode": "The code editor color scheme for light mode", "the_file_supplied_is_of_an_unsupported_type ": "The link to open this content on Overleaf pointed to the wrong kind of file. Valid file types are .tex documents and .zip files. If this keeps happening for links on a particular site, please report this to them.", "the_following_files_already_exist_in_this_project": "The following files already exist in this project:", "the_following_files_and_folders_already_exist_in_this_project": "The following files and folders already exist in this project:", diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx index 2f7fc14001..f92eda150a 100644 --- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx +++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx @@ -5,6 +5,8 @@ import SettingsEditorTheme from '../../../../../../frontend/js/features/editor-l import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context' import { EditorProviders } from '../../../../helpers/editor-providers' +const MOCK_IEEE_BRAND_ID = 123 + describe('', function () { const editorThemes = [ { name: 'editortheme-1', dark: false }, @@ -21,23 +23,18 @@ describe('', function () { beforeEach(function () { window.metaAttributesCache.set('ol-editorThemes', editorThemes) window.metaAttributesCache.set('ol-legacyEditorThemes', legacyEditorThemes) + window.metaAttributesCache.set('ol-brandVariation', { + brand_id: undefined, + }) + window.metaAttributesCache.get('ol-ExposedSettings').ieeeBrandId = + MOCK_IEEE_BRAND_ID }) afterEach(function () { fetchMock.removeRoutes().clearHistory() }) - it('shows correct menu', async function () { - render( - - - - - - ) - - const select = screen.getByLabelText('Editor theme') - + function checkSelect(select: HTMLElement) { for (const theme of editorThemes) { const option = within(select).getByText(theme.name.replace(/_/g, ' ')) expect(option.getAttribute('value')).to.equal(theme.name) @@ -49,5 +46,67 @@ describe('', function () { ) expect(option.getAttribute('value')).to.equal(theme.name) } + } + + describe('with default theme', function () { + beforeEach(function () { + render( + + + + + + ) + }) + + it('shows correct menu', async function () { + const select = screen.getByLabelText('Editor theme') + expect(select).to.exist + checkSelect(select) + }) + }) + + describe('with system theme', function () { + beforeEach(function () { + render( + + + + + + ) + }) + + it('shows correct menu', async function () { + const select = screen.queryByLabelText('Editor theme') + expect(select).to.not.exist + const lightSelect = screen.getByLabelText('Light editor theme') + expect(lightSelect).to.exist + checkSelect(lightSelect) + const darkSelect = screen.getByLabelText('Dark editor theme') + expect(darkSelect).to.exist + checkSelect(darkSelect) + }) + }) + + describe('with IEEE branding', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-brandVariation', { + brand_id: MOCK_IEEE_BRAND_ID, + }) + render( + + + + + + ) + }) + + it('ignores the system theme and shows single selection', async function () { + const select = screen.getByLabelText('Editor theme') + expect(select).to.exist + checkSelect(select) + }) }) }) diff --git a/services/web/test/frontend/features/settings-modal/settings/editor-theme-setting.test.tsx b/services/web/test/frontend/features/settings-modal/settings/editor-theme-setting.test.tsx index aceb26f264..1d95dd01f9 100644 --- a/services/web/test/frontend/features/settings-modal/settings/editor-theme-setting.test.tsx +++ b/services/web/test/frontend/features/settings-modal/settings/editor-theme-setting.test.tsx @@ -6,6 +6,8 @@ import { EditorProviders } from '../../../helpers/editor-providers' import EditorThemeSetting from '@/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting' import userEvent from '@testing-library/user-event' +const MOCK_IEEE_BRAND_ID = 123 + describe('', function () { const editorThemes = [ { name: 'editortheme-1', dark: false }, @@ -21,21 +23,18 @@ describe('', function () { beforeEach(function () { window.metaAttributesCache.set('ol-editorThemes', editorThemes) window.metaAttributesCache.set('ol-legacyEditorThemes', legacyEditorThemes) + window.metaAttributesCache.set('ol-brandVariation', { + brand_id: undefined, + }) + window.metaAttributesCache.get('ol-ExposedSettings').ieeeBrandId = + MOCK_IEEE_BRAND_ID }) afterEach(function () { fetchMock.removeRoutes().clearHistory() }) - it('each option is shown and can be selected', async function () { - render( - - - - - - ) - + async function checkSelect(select: HTMLElement, settingName: string) { const saveSettingsMock = fetchMock.post( `express:/user/settings`, { @@ -43,16 +42,13 @@ describe('', function () { }, { delay: 0 } ) - - const select = screen.getByLabelText('Editor theme') - for (const theme of editorThemes) { const option = within(select).getByText(theme.name.replace(/_/g, ' ')) expect(option.getAttribute('value')).to.equal(theme.name) await userEvent.selectOptions(select, [option]) expect( saveSettingsMock.callHistory.called(`/user/settings`, { - body: { editorTheme: theme.name }, + body: { [settingName]: theme.name }, }) ).to.be.true } @@ -65,9 +61,71 @@ describe('', function () { await userEvent.selectOptions(select, [option]) expect( saveSettingsMock.callHistory.called(`/user/settings`, { - body: { editorTheme: theme.name }, + body: { [settingName]: theme.name }, }) ).to.be.true } + } + + describe('with default theme', function () { + beforeEach(function () { + render( + + + + + + ) + }) + + it('each option is shown and can be selected', async function () { + const select = screen.getByLabelText('Editor theme') + expect(select).to.exist + await checkSelect(select, 'editorTheme') + }) + }) + + describe('with system theme', function () { + beforeEach(function () { + render( + + + + + + ) + }) + + it('splits the setting into two', async function () { + const select = screen.queryByLabelText('Editor theme') + expect(select).to.not.exist + const lightModeSelect = screen.getByLabelText('Light editor theme') + expect(lightModeSelect).to.exist + await checkSelect(lightModeSelect, 'editorLightTheme') + const darkModeSelect = screen.getByLabelText('Dark editor theme') + expect(darkModeSelect).to.exist + await checkSelect(darkModeSelect, 'editorDarkTheme') + }) + }) + + describe('with IEEE branding', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-brandVariation', { + brand_id: MOCK_IEEE_BRAND_ID, + }) + render( + + + + + + ) + }) + + it('ignores the system theme and shows single selection', async function () { + const select = screen.getByLabelText('Editor theme') + expect(select).to.exist + await checkSelect(select, 'editorTheme') + }) }) }) diff --git a/services/web/test/frontend/shared/hooks/use-active-editor-theme.spec.tsx b/services/web/test/frontend/shared/hooks/use-active-editor-theme.spec.tsx new file mode 100644 index 0000000000..7af37b861b --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-active-editor-theme.spec.tsx @@ -0,0 +1,78 @@ +import { useActiveEditorTheme } from '@/shared/hooks/use-active-editor-theme' +import { EditorProviders } from '../../helpers/editor-providers' +import { SplitTestProvider } from '@/shared/context/split-test-context' + +const MOCK_IEEE_BRAND_ID = 123 + +const TestComponent = ({ overallTheme }: { overallTheme: string }) => { + return ( + + + + + + ) +} + +const TestComponentInner = () => { + const editorTheme = useActiveEditorTheme() + return
{editorTheme}
+} + +describe('useActiveEditorTheme', function () { + describe('when overall theme is specific mode', function () { + it('Uses editorTheme when in dark mode', function () { + cy.mount() + cy.findByTestId('editor-theme').should('have.text', 'default-theme') + }) + + it('Uses editorTheme when in light mode', function () { + cy.mount() + cy.findByTestId('editor-theme').should('have.text', 'default-theme') + }) + }) + + describe('when overall theme is system', function () { + function stubMediaQuery(prefersDark: boolean, isIEEE = false) { + cy.window().then(win => { + win.metaAttributesCache.set('ol-brandVariation', { + brand_id: isIEEE ? MOCK_IEEE_BRAND_ID : undefined, + }) + win.metaAttributesCache.get('ol-ExposedSettings').ieeeBrandId = + MOCK_IEEE_BRAND_ID + cy.stub(win, 'matchMedia') + .withArgs('(prefers-color-scheme: dark)') + .returns({ + matches: prefersDark, + addEventListener: () => {}, + removeEventListener: () => {}, + } as any) + }) + } + + it('uses editorDarkTheme when in dark mode', function () { + stubMediaQuery(true) + cy.mount() + cy.findByTestId('editor-theme').should('have.text', 'dark-theme') + }) + + it('uses editorLightTheme when in light mode', function () { + stubMediaQuery(false) + cy.mount() + cy.findByTestId('editor-theme').should('have.text', 'light-theme') + }) + + it('uses editorTheme when in IEEE document', function () { + stubMediaQuery(false, true) + cy.mount() + cy.findByTestId('editor-theme').should('have.text', 'default-theme') + }) + }) +}) diff --git a/services/web/test/frontend/shared/hooks/use-active-overall-theme.spec.tsx b/services/web/test/frontend/shared/hooks/use-active-overall-theme.spec.tsx new file mode 100644 index 0000000000..18e461ed40 --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-active-overall-theme.spec.tsx @@ -0,0 +1,81 @@ +import { EditorProviders } from '../../helpers/editor-providers' +import { SplitTestProvider } from '@/shared/context/split-test-context' +import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme' + +const MOCK_IEEE_BRAND_ID = 123 + +const TestComponent = ({ overallTheme }: { overallTheme: string }) => { + return ( + + + + + + ) +} + +const TestComponentInner = () => { + const overallTheme = useActiveOverallTheme() + return
{overallTheme}
+} + +describe('useActiveOverallTheme', function () { + beforeEach(function () { + cy.window().then(win => { + win.metaAttributesCache.set('ol-brandVariation', { brand_id: undefined }) + win.metaAttributesCache.get('ol-ExposedSettings').ieeeBrandId = + MOCK_IEEE_BRAND_ID + }) + }) + + it('Is dark in default mode', function () { + cy.mount() + cy.findByTestId('overall-theme').should('have.text', 'dark') + }) + + it('Is light when in light mode', function () { + cy.mount() + cy.findByTestId('overall-theme').should('have.text', 'light') + }) + + describe('when overall theme is system', function () { + function stubMediaQuery(prefersDark: boolean) { + cy.window().then(win => { + cy.stub(win, 'matchMedia') + .withArgs('(prefers-color-scheme: dark)') + .returns({ + matches: prefersDark, + addEventListener: () => {}, + removeEventListener: () => {}, + } as any) + }) + } + + it('is dark when browser prefers dark', function () { + stubMediaQuery(true) + cy.mount() + cy.findByTestId('overall-theme').should('have.text', 'dark') + }) + + it('is light when browser prefers light', function () { + stubMediaQuery(false) + cy.mount() + cy.findByTestId('overall-theme').should('have.text', 'light') + }) + + it('uses dark when in IEEE document', function () { + stubMediaQuery(false) + cy.window().then(win => { + win.metaAttributesCache.set('ol-brandVariation', { + brand_id: MOCK_IEEE_BRAND_ID, + }) + }) + cy.mount() + cy.findByTestId('overall-theme').should('have.text', 'dark') + }) + }) +}) diff --git a/services/web/types/user-settings.ts b/services/web/types/user-settings.ts index 9b0ee2f066..ac818eaadd 100644 --- a/services/web/types/user-settings.ts +++ b/services/web/types/user-settings.ts @@ -9,6 +9,8 @@ export type UserSettings = { autoPairDelimiters: boolean syntaxValidation: boolean editorTheme: string + editorLightTheme: string + editorDarkTheme: string overallTheme: OverallTheme mode: Keybindings fontSize: number