diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index fc0ec8bf2d..8139f27e05 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -364,6 +364,8 @@ Home--additional-content-content = You can drag and drop a prof Home--compare-recordings-info = You can also compare recordings. Open the comparing interface. Home--your-recent-uploaded-recordings-title = Your recent uploaded recordings +Home--dark-mode-title = Dark mode + # We replace the elements such as and with links to the # documentation to use these tools. Home--load-files-from-other-tools2 = diff --git a/res/css/categories.css b/res/css/categories.css index 8d1d533854..1535799653 100644 --- a/res/css/categories.css +++ b/res/css/categories.css @@ -20,6 +20,18 @@ --category-color-darkgrey: var(--grey-50); } +:root.dark-mode { + --category-color-purple: var(--purple-60); + --category-color-green: var(--green-80); + --category-color-orange: var(--orange-60); + --category-color-yellow: var(--yellow-70); + --category-color-magenta: var(--magenta-70); + --category-color-gray: var(--grey-50); + --category-color-grey: var(--grey-50); + --category-color-darkgray: var(--grey-60); + --category-color-darkgrey: var(--grey-60); +} + /** * These classes should be used to create a small color swatch to describe a * category. They should be used with the class `colored-square` that's defined diff --git a/res/css/focus.css b/res/css/focus.css index a6291dcff1..3925aa3858 100644 --- a/res/css/focus.css +++ b/res/css/focus.css @@ -18,15 +18,15 @@ input[type='range']:focus-visible, select:focus-visible, button:focus-visible { box-shadow: - 0 0 0 1px var(--blue-50) inset, - 0 0 0 1px var(--blue-50), - 0 0 0 4px var(--blue-50-a30); + 0 0 0 1px var(--focus-border-color) inset, + 0 0 0 1px var(--focus-border-color), + 0 0 0 4px var(--focus-shadow-color); outline: 0; } a:focus-visible { box-shadow: - 0 0 0 2px var(--blue-50), - 0 0 0 6px var(--blue-50-a30); + 0 0 0 2px var(--focus-border-color), + 0 0 0 6px var(--focus-shadow-color); outline: 0; } diff --git a/res/css/global.css b/res/css/global.css index 72c99c9064..1ba824979b 100644 --- a/res/css/global.css +++ b/res/css/global.css @@ -2,6 +2,98 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +:root { + --base-foreground-color: #000; + --base-background-color: #fff; + --base-border-color: var(--grey-30); + --base-shadow-color: rgb(0 0 0 / 0.2); + --link-foreground-color: var(--blue-60); + --link-visited-foreground-color: var(--purple-70); + --link-active-foreground-color: var(--red-50); + --clickable-foreground-color: var(--grey-90); + --clickable-background-color: var(--grey-90-a10); + --clickable-border-color: var(--grey-90-a30); + --clickable-hover-background-color: var(--grey-90-a20); + --clickable-active-background-color: var(--grey-90-a30); + --clickable-ghost-hover-background-color: var(--grey-90-a10); + --clickable-ghost-active-background-color: var(--grey-90-a20); + --clickable-checked-background-color: var(--blue-60); + --clickable-checked-hover-background-color: var(--blue-70); + --clickable-checked-active-background-color: var(--blue-80); + --focus-border-color: var(--blue-50); + --focus-shadow-color: var(--blue-50-a30); + --kbd-foreground-color: var(--base-foreground-color); + --kbd-background-color: #f6f6f6; + --kbd-border-color: #ccc; + --kbd-shadow-color: #bbb; + --home-border-color: #ccc; + --home-shadow-color: #0b1f50; + --row-odd-background-color: #f5f5f5; + --panel-foreground-color: var(--ink-70); + --panel-background-color: var(--grey-10); + --panel-border-color: var(--grey-30); + --track-selected-background-color: #edf6ff; + --tooltip-number-foreground-color: var(--grey-60); + --colored-border-color: rgb(0 0 0 / 0.1); + + background-color: var(--base-background-color); + color: var(--base-foreground-color); +} + +:root.dark-mode { + --base-foreground-color: var(--grey-20); + --base-background-color: var(--ink-90); + --base-border-color: var(--grey-50); + --base-shadow-color: rgb(0 0 0 / 0.4); + --link-foreground-color: var(--blue-40); + --link-visited-foreground-color: var(--purple-40); + --link-active-foreground-color: var(--red-50); + --clickable-foreground-color: var(--grey-20); + --clickable-background-color: var(--grey-10-a10); + --clickable-border-color: var(--grey-10-a40); + --clickable-hover-background-color: var(--grey-10-a20); + --clickable-active-background-color: var(--grey-10-a40); + --clickable-ghost-hover-background-color: var(--grey-10-a10); + --clickable-ghost-active-background-color: var(--grey-10-a20); + --clickable-checked-background-color: var(--blue-50); + --clickable-checked-hover-background-color: var(--blue-60); + --clickable-checked-active-background-color: var(--blue-70); + --focus-border-color: var(--blue-40); + --focus-shadow-color: var(--blue-50-a30); + --kbd-background-color: var(--ink-70); + --kbd-border-color: var(--ink-60); + --kbd-shadow-color: var(--ink-50); + --home-border-color: var(--ink-70); + --home-shadow-color: var(--ink-90); + --row-odd-background-color: color-mix( + in hsl, + var(--ink-80) 50%, + var(--ink-90) 50% + ); + --panel-foreground-color: var(--grey-20); + --panel-background-color: var(--ink-80); + --panel-border-color: var(--ink-60); + --selected-track-background-color: color-mix( + in hsl, + var(--ink-90) 80%, + var(--teal-50) 20% + ); + --tooltip-number-foreground-color: var(--grey-40); + --colored-border-color: rgb(237 237 240 / 0.1); +} + +a { + color: var(--link-foreground-color); +} + +a:visited { + color: var(--link-visited-foreground-color); +} + +a:active { + color: var(--link-active-foreground-color); +} + /** * This class should be used to create a small colored square. It's used * especially for categories and network mime types. @@ -11,7 +103,7 @@ width: 9px; height: 9px; box-sizing: border-box; - border: 0.5px solid rgb(0 0 0 / 0.1); + border: 0.5px solid var(--colored-border-color); margin-right: 3px; /* Opt-out of forced colors so the color is applied */ @@ -19,7 +111,7 @@ } .colored-border { - border: 2px solid rgb(0 0 0 / 0.1); + border: 2px solid var(--colored-border-color); margin-right: 3px; /* Opt-out of forced colors so the color is applied */ diff --git a/res/css/photon/button.css b/res/css/photon/button.css index aeff4be412..280ac9a87c 100644 --- a/res/css/photon/button.css +++ b/res/css/photon/button.css @@ -4,6 +4,15 @@ /* See https://design.firefox.com/photon/components/buttons.html for the spec */ .photon-button { + --internal-primary-foreground-color: #fff; + --internal-primary-background-color: var(--blue-60); + --internal-primary-hover-background-color: var(--blue-70); + --internal-primary-active-background-color: var(--blue-80); + --internal-destructive-foreground-color: #fff; + --internal-destructive-background-color: var(--red-60); + --internal-destructive-hover-background-color: var(--red-70); + --internal-destructive-active-background-color: var(--red-80); + /* These flex and sizing properties aren't necessary when a real { + throw new Error('dummy error'); + }); + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(getItem); + + const warn = jest.fn(() => {}); + jest.spyOn(console, 'warn').mockImplementation(warn); + + expect(isDarkMode()).toBe(false); + + expect(getItem).toHaveBeenCalledWith('theme'); + expect(warn).toHaveBeenCalledWith( + 'localStorage access denied', + expect.objectContaining({ message: 'dummy error' }) + ); + }); + + it('listens to storage event', function () { + resetForTest(); + + expect(isDarkMode()).toBe(false); + + // The value is cached. + const getItem = jest.fn(() => 'dark'); + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(getItem); + expect(isDarkMode()).toBe(false); + + // Different key should be ignored. + window.dispatchEvent(new StorageEvent('storage', { key: 'something' })); + expect(isDarkMode()).toBe(false); + + window.dispatchEvent(new StorageEvent('storage', { key: 'theme' })); + expect(isDarkMode()).toBe(true); + + // The value is cached. + const getItem2 = jest.fn(() => null); + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(getItem2); + expect(isDarkMode()).toBe(true); + + window.dispatchEvent(new StorageEvent('storage', { key: 'theme' })); + expect(isDarkMode()).toBe(false); + }); +}); + +describe('initTheme', function () { + it('sets the document element class', function () { + resetForTest(); + + const getItem = jest.fn(); + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(getItem); + + initTheme(); + + expect(getItem).toHaveBeenCalledWith('theme'); + expect(document.documentElement.className).toBe(''); + + resetForTest(); + + const getItem2 = jest.fn(() => 'dark'); + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(getItem2); + + initTheme(); + + expect(getItem).toHaveBeenCalledWith('theme'); + expect(document.documentElement.className).toBe('dark-mode'); + }); +}); diff --git a/src/test/unit/dark-mode.ts b/src/test/unit/dark-mode.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/types/globals/Window.d.ts b/src/types/globals/Window.d.ts index 583727ee12..d173cbe557 100644 --- a/src/types/globals/Window.d.ts +++ b/src/types/globals/Window.d.ts @@ -25,6 +25,9 @@ declare global { } interface Window { + useDarkMode?: () => void; + useLightMode?: () => void; + // Google Analytics ga?: GoogleAnalytics; // profiler.firefox.com and globals injected via frame scripts. diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 845e33409a..b3d07aad97 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { lightDark, maybeLightDark } from './dark-mode'; + /** * These are the colors from Photon. They are inlined to provide easy access. If updating * please change the CSS variables as well. @@ -74,97 +76,142 @@ export const INK_80 = '#202340'; export const INK_90 = '#0f1126'; type ColorStyles = { - readonly selectedFillStyle: string; - readonly unselectedFillStyle: string; - readonly selectedTextColor: string; + readonly _selectedFillStyle: string | [string, string]; + readonly _unselectedFillStyle: string | [string, string]; + readonly _selectedTextColor: string | [string, string]; + readonly getSelectedFillStyle: () => string; + readonly getUnselectedFillStyle: () => string; + readonly getSelectedTextColor: () => string; readonly gravity: number; }; -const GRAY_STYLE = { - selectedFillStyle: GREY_40, - unselectedFillStyle: GREY_40 + '60', - selectedTextColor: '#000', +const DEFAULT_STYLE: ColorStyles = { + _selectedFillStyle: ['#ffffff', '#0f1126'], + _unselectedFillStyle: ['#ffffff60', '#0f112660'], + _selectedTextColor: ['#000000', GREY_20], + getSelectedFillStyle: function () { + return maybeLightDark(this._selectedFillStyle); + }, + getUnselectedFillStyle: function () { + return maybeLightDark(this._unselectedFillStyle); + }, + getSelectedTextColor: function () { + return maybeLightDark(this._selectedTextColor); + }, + gravity: 0, +}; + +const PSEUDO_TRANSPARENT_STYLE: ColorStyles = { + ...DEFAULT_STYLE, + _selectedFillStyle: [GREY_30, GREY_70], + _unselectedFillStyle: [GREY_30 + '60', GREY_70 + '60'], + _selectedTextColor: ['#000', GREY_20], + gravity: 8, +}; + +const GRAY_STYLE: ColorStyles = { + ...DEFAULT_STYLE, + _selectedFillStyle: [GREY_40, GREY_50], + _unselectedFillStyle: [GREY_40 + '60', GREY_50 + '60'], + _selectedTextColor: ['#000', GREY_20], gravity: 10, }; -const DARK_GRAY_STYLE = { - selectedFillStyle: GREY_50, - unselectedFillStyle: GREY_50 + '60', - selectedTextColor: '#fff', +const DARK_GRAY_STYLE: ColorStyles = { + ...DEFAULT_STYLE, + _selectedFillStyle: [GREY_50, GREY_60], + _unselectedFillStyle: [GREY_50 + '60', GREY_60 + '60'], + _selectedTextColor: '#fff', gravity: 11, }; const STYLE_MAP: { [key: string]: ColorStyles } = { transparent: { - selectedFillStyle: 'transparent', - unselectedFillStyle: 'transparent', - selectedTextColor: '#000', + ...DEFAULT_STYLE, + _selectedFillStyle: 'transparent', + _unselectedFillStyle: 'transparent', + _selectedTextColor: ['#000', GREY_20], gravity: 0, }, lightblue: { - selectedFillStyle: BLUE_40, + ...DEFAULT_STYLE, + _selectedFillStyle: BLUE_40, // Colors are assumed to have the form #RRGGBB, so concatenating 2 more digits to // the end defines the transparency #RRGGBBAA. - unselectedFillStyle: BLUE_40 + '60', - selectedTextColor: '#000', + _unselectedFillStyle: BLUE_40 + '60', + _selectedTextColor: ['#000', GREY_20], gravity: 1, }, red: { - selectedFillStyle: RED_60, - unselectedFillStyle: RED_60 + '60', - selectedTextColor: '#fff', + ...DEFAULT_STYLE, + _selectedFillStyle: RED_60, + _unselectedFillStyle: RED_60 + '60', + _selectedTextColor: '#fff', gravity: 1, }, lightred: { - selectedFillStyle: RED_70 + '60', - unselectedFillStyle: RED_70 + '30', - selectedTextColor: '#000', + ...DEFAULT_STYLE, + _selectedFillStyle: RED_70 + '60', + _unselectedFillStyle: RED_70 + '30', + _selectedTextColor: ['#000', GREY_20], gravity: 1, }, orange: { - selectedFillStyle: ORANGE_50, - unselectedFillStyle: ORANGE_50 + '60', - selectedTextColor: '#fff', + ...DEFAULT_STYLE, + _selectedFillStyle: [ORANGE_50, ORANGE_60], + _unselectedFillStyle: [ORANGE_50 + '60', ORANGE_60 + '60'], + _selectedTextColor: '#fff', gravity: 2, }, blue: { - selectedFillStyle: BLUE_60, - unselectedFillStyle: BLUE_60 + '60', - selectedTextColor: '#fff', + ...DEFAULT_STYLE, + _selectedFillStyle: BLUE_60, + _unselectedFillStyle: BLUE_60 + '60', + _selectedTextColor: '#fff', gravity: 3, }, green: { - selectedFillStyle: GREEN_60, - unselectedFillStyle: GREEN_60 + '60', - selectedTextColor: '#fff', + ...DEFAULT_STYLE, + _selectedFillStyle: [GREEN_60, GREEN_80], + _unselectedFillStyle: [GREEN_60 + '60', GREEN_80 + '60'], + _selectedTextColor: '#fff', gravity: 4, }, purple: { - selectedFillStyle: PURPLE_70, - unselectedFillStyle: PURPLE_70 + '60', - selectedTextColor: '#fff', + ...DEFAULT_STYLE, + _selectedFillStyle: [PURPLE_70, PURPLE_60], + _unselectedFillStyle: [PURPLE_70 + '60', PURPLE_60 + '60'], + _selectedTextColor: '#fff', gravity: 5, }, yellow: { - selectedFillStyle: '#ffe129', // This yellow has more contrast than YELLOW_50. - unselectedFillStyle: YELLOW_50 + '70', - selectedTextColor: '#000', + ...DEFAULT_STYLE, + _selectedFillStyle: [ + // This yellow has more contrast than YELLOW_50. + '#ffe129', + YELLOW_70, + ], + _unselectedFillStyle: [YELLOW_50 + '70', YELLOW_60 + '70'], + _selectedTextColor: ['#000', GREY_20], gravity: 6, }, brown: { - selectedFillStyle: ORANGE_70, - unselectedFillStyle: ORANGE_70 + '60', - selectedTextColor: '#fff', + ...DEFAULT_STYLE, + _selectedFillStyle: ORANGE_70, + _unselectedFillStyle: ORANGE_70 + '60', + _selectedTextColor: '#fff', gravity: 7, }, magenta: { - selectedFillStyle: MAGENTA_60, - unselectedFillStyle: MAGENTA_60 + '60', - selectedTextColor: '#fff', + ...DEFAULT_STYLE, + _selectedFillStyle: [MAGENTA_60, MAGENTA_70], + _unselectedFillStyle: [MAGENTA_60 + '60', MAGENTA_70 + '60'], + _selectedTextColor: '#fff', gravity: 8, }, lightgreen: { - selectedFillStyle: GREEN_50, - unselectedFillStyle: GREEN_50 + '60', - selectedTextColor: '#fff', + ...DEFAULT_STYLE, + _selectedFillStyle: [GREEN_50, GREEN_70], + _unselectedFillStyle: [GREEN_50 + '60', GREEN_70 + '60'], + _selectedTextColor: '#fff', gravity: 9, }, gray: GRAY_STYLE, @@ -200,12 +247,15 @@ export function mapCategoryColorNameToStackChartStyles( colorName: string ): ColorStyles { if (colorName === 'transparent') { - return { - selectedFillStyle: GREY_30, - unselectedFillStyle: GREY_30 + '60', - selectedTextColor: '#000', - gravity: 8, - }; + return PSEUDO_TRANSPARENT_STYLE; } return mapCategoryColorNameToStyles(colorName); } + +export function getForegroundColor(): string { + return lightDark('#000000', GREY_20); +} + +export function getBackgroundColor(): string { + return lightDark('#ffffff', INK_90); +} diff --git a/src/utils/dark-mode.ts b/src/utils/dark-mode.ts new file mode 100644 index 0000000000..80f3d2d811 --- /dev/null +++ b/src/utils/dark-mode.ts @@ -0,0 +1,62 @@ +let _isDarkModeSetup = false; +let _isDarkMode = false; + +export function isDarkMode() { + if (!_isDarkModeSetup) { + try { + function readSetting() { + const theme = window.localStorage.getItem('theme'); + if (theme === 'dark') { + _isDarkMode = true; + } else { + _isDarkMode = false; + } + } + readSetting(); + window.addEventListener('storage', (event: StorageEvent) => { + if (event.key === 'theme') { + readSetting(); + } + }); + } catch (e) { + console.warn('localStorage access denied', e); + } + _isDarkModeSetup = true; + } + + return _isDarkMode; +} + +export function lightDark(light: string, dark: string): string { + return isDarkMode() ? dark : light; +} + +export function maybeLightDark(value: string | [string, string]): string { + if (typeof value === 'string') { + return value; + } + return lightDark(value[0], value[1]); +} + +export function initTheme() { + if (isDarkMode()) { + document.documentElement.classList.add('dark-mode'); + } +} + +export function setDarkMode() { + _isDarkMode = true; + window.localStorage.setItem('theme', 'dark'); + document.documentElement.classList.add('dark-mode'); +} + +export function setLightMode() { + _isDarkMode = false; + window.localStorage.removeItem('theme'); + document.documentElement.classList.remove('dark-mode'); +} + +export function resetForTest() { + _isDarkModeSetup = false; + _isDarkMode = false; +}