From e50b2756eb3cdff8f2ddec5ebb189ced7af5beac Mon Sep 17 00:00:00 2001 From: Tooru Fujisawa Date: Sat, 10 Jan 2026 01:18:58 +0900 Subject: [PATCH 01/38] Add hidden dark/light switch --- src/index.tsx | 20 ++++++++++++++++++++ src/types/globals/Window.d.ts | 3 +++ 2 files changed, 23 insertions(+) diff --git a/src/index.tsx b/src/index.tsx index 20a3d4a286..9727cd09ce 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,6 +25,26 @@ import { } from './utils/window-console'; import { ensureExists } from './utils/types'; +function initTheme() { + const theme = window.localStorage.getItem('theme'); + if (theme === 'dark') { + document.documentElement.classList.add('dark-mode'); + } +} +window.useDarkMode = function () { + window.localStorage.setItem('theme', 'dark'); + document.documentElement.classList.add('dark-mode'); +}; +window.useLightMode = function () { + window.localStorage.removeItem('theme'); + document.documentElement.classList.remove('dark-mode'); +}; +try { + initTheme(); +} catch (e) { + console.log('initTheme failed', e); +} + // Mock out Google Analytics for anything that's not production so that we have run-time // code coverage in development and testing. // Note that ga isn't included nowadays. We still keep this code because we 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. From 4e831d744d8e8472f43333a59a2d26a5b6f68002 Mon Sep 17 00:00:00 2001 From: Tooru Fujisawa Date: Sat, 27 Dec 2025 15:41:03 +0900 Subject: [PATCH 02/38] Apply dark mode to common elements --- res/css/global.css | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/res/css/global.css b/res/css/global.css index 72c99c9064..92eb3ad3fa 100644 --- a/res/css/global.css +++ b/res/css/global.css @@ -2,6 +2,37 @@ * 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; + --link-foreground-color: var(--blue-60); + --link-visited-foreground-color: var(--purple-70); + --link-active-foreground-color: var(--red-50); + + 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); + --link-foreground-color: var(--blue-40); + --link-visited-foreground-color: var(--purple-40); + --link-active-foreground-color: var(--red-50); +} + +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. From 7362a0b976774a913a9efd1b4d8e1fc308ae6599 Mon Sep 17 00:00:00 2001 From: Tooru Fujisawa Date: Sat, 10 Jan 2026 01:26:49 +0900 Subject: [PATCH 03/38] Apply dark mode to photon components --- res/css/focus.css | 10 ++-- res/css/global.css | 24 ++++++++ res/css/photon/button.css | 66 ++++++++++++++++----- res/css/photon/checkbox.css | 14 ++--- res/css/photon/input.css | 32 +++++++--- res/css/photon/label.css | 2 +- res/css/photon/message-bar.css | 101 +++++++++++++++++++++++++------- res/css/photon/radio-button.css | 14 ++--- res/img/svg/info-icon-light.svg | 4 ++ res/img/svg/warning-light.svg | 4 ++ 10 files changed, 208 insertions(+), 63 deletions(-) create mode 100644 res/img/svg/info-icon-light.svg create mode 100644 res/img/svg/warning-light.svg 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 92eb3ad3fa..980deace3a 100644 --- a/res/css/global.css +++ b/res/css/global.css @@ -8,6 +8,18 @@ --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); background-color: var(--base-background-color); color: var(--base-foreground-color); @@ -19,6 +31,18 @@ --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); } a { 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 Date: Sat, 10 Jan 2026 04:28:49 +0900 Subject: [PATCH 36/38] Add Dark mode checkbox --- locales/en-US/app.ftl | 2 + src/components/app/Home.css | 10 ++++ src/components/app/Home.tsx | 31 +++++++++++ src/index.tsx | 8 --- src/test/components/Home.test.tsx | 34 ++++++++++++- .../__snapshots__/Home.test.tsx.snap | 51 +++++++++++++++++++ src/utils/dark-mode.ts | 12 +++++ 7 files changed, 139 insertions(+), 9 deletions(-) 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/src/components/app/Home.css b/src/components/app/Home.css index 1925a52fbb..2a5539506a 100644 --- a/src/components/app/Home.css +++ b/src/components/app/Home.css @@ -88,6 +88,16 @@ grid-column: 1 / -1; /* from start to end */ } +.homeDarkModeBox { + position: absolute; + top: 4px; + left: 4px; +} + +#home-dark-mode { + vertical-align: middle; +} + .homeActions > p:first-child, .homeRecentUploadedRecordingsTitle { margin-top: 0; diff --git a/src/components/app/Home.tsx b/src/components/app/Home.tsx index 99e4d67898..aac14c7f37 100644 --- a/src/components/app/Home.tsx +++ b/src/components/app/Home.tsx @@ -9,6 +9,11 @@ import { InnerNavigationLink } from 'firefox-profiler/components/shared/InnerNav import { ListOfPublishedProfiles } from './ListOfPublishedProfiles'; import explicitConnect from 'firefox-profiler/utils/connect'; +import { + isDarkMode, + setLightMode, + setDarkMode, +} from 'firefox-profiler/utils/dark-mode'; import PerfScreenshot from 'firefox-profiler-res/img/jpg/perf-screenshot-2021-05-06.jpg'; import FirefoxPopupScreenshot from 'firefox-profiler-res/img/jpg/firefox-profiler-button-2021-05-06.jpg'; import { @@ -214,6 +219,7 @@ type HomeProps = ConnectedProps< type HomeState = { popupInstallPhase: PopupInstallPhase; + darkMode: boolean; }; type PopupInstallPhase = @@ -256,6 +262,7 @@ class HomeImpl extends React.PureComponent { this.state = { popupInstallPhase: popupInstallPhase as PopupInstallPhase, + darkMode: isDarkMode(), }; } @@ -574,8 +581,18 @@ class HomeImpl extends React.PureComponent { this.props.triggerLoadingFromUrl(url); }; + _onDarkModeChange = (event: React.ChangeEvent) => { + if (event.target.checked) { + setDarkMode(); + } else { + setLightMode(); + } + this.setState({ darkMode: isDarkMode() }); + }; + override render() { const { specialMessage } = this.props; + const { darkMode } = this.state; return (
@@ -686,6 +703,20 @@ class HomeImpl extends React.PureComponent { {/* End of grid container */} +
+ +
); diff --git a/src/index.tsx b/src/index.tsx index 9727cd09ce..2b0c36409c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,14 +31,6 @@ function initTheme() { document.documentElement.classList.add('dark-mode'); } } -window.useDarkMode = function () { - window.localStorage.setItem('theme', 'dark'); - document.documentElement.classList.add('dark-mode'); -}; -window.useLightMode = function () { - window.localStorage.removeItem('theme'); - document.documentElement.classList.remove('dark-mode'); -}; try { initTheme(); } catch (e) { diff --git a/src/test/components/Home.test.tsx b/src/test/components/Home.test.tsx index 93c1f6d189..b2631cdbed 100644 --- a/src/test/components/Home.test.tsx +++ b/src/test/components/Home.test.tsx @@ -4,7 +4,7 @@ import { Provider } from 'react-redux'; -import { render } from 'firefox-profiler/test/fixtures/testing-library'; +import { render, act } from 'firefox-profiler/test/fixtures/testing-library'; import { Home } from '../../components/app/Home'; import createStore from '../../app-logic/create-store'; import { mockWebChannel } from '../fixtures/mocks/web-channel'; @@ -116,4 +116,36 @@ describe('app/Home', function () { expect(webChannelUnavailableMessage).toMatchSnapshot(); }); + + it('allows switching between the dark mode', async () => { + const { getByText } = setup(SAFARI); + + const setItem = jest.fn(); + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(setItem); + const removeItem = jest.fn(); + jest.spyOn(Storage.prototype, 'removeItem').mockImplementation(removeItem); + + const darkModeSpan = getByText('Dark mode'); + const darkModeLabel = darkModeSpan.closest('label')!; + const darkModeCheckbox = darkModeLabel.querySelector('input')!; + expect(darkModeCheckbox.checked).toBe(false); + + act(() => { + darkModeCheckbox.click(); + }); + + expect(darkModeCheckbox.checked).toBe(true); + expect(setItem).toHaveBeenCalledWith('theme', 'dark'); + + expect(document.documentElement.className).toBe('dark-mode'); + + act(() => { + darkModeCheckbox.click(); + }); + + expect(darkModeCheckbox.checked).toBe(false); + expect(removeItem).toHaveBeenCalledWith('theme'); + + expect(document.documentElement.className).toBe(''); + }); }); diff --git a/src/test/components/__snapshots__/Home.test.tsx.snap b/src/test/components/__snapshots__/Home.test.tsx.snap index e222c0bf09..67c45d6e8d 100644 --- a/src/test/components/__snapshots__/Home.test.tsx.snap +++ b/src/test/components/__snapshots__/Home.test.tsx.snap @@ -226,6 +226,23 @@ own importer Drop a saved profile here +
+ +
`; @@ -511,6 +528,23 @@ own importer Drop a saved profile here +
+ +
`; @@ -736,6 +770,23 @@ own importer Drop a saved profile here +
+ +
`; diff --git a/src/utils/dark-mode.ts b/src/utils/dark-mode.ts index 80642b6588..fa1aa2f135 100644 --- a/src/utils/dark-mode.ts +++ b/src/utils/dark-mode.ts @@ -37,3 +37,15 @@ export function maybeLightDark(value: string | [string, string]): string { } return lightDark(value[0], value[1]); } + +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'); +} From 40612192bd032215a6c7203155066048a3014fa2 Mon Sep 17 00:00:00 2001 From: Tooru Fujisawa Date: Sat, 10 Jan 2026 04:18:27 +0900 Subject: [PATCH 37/38] Fix HCM coloring on dark mode --- res/css/style.css | 6 +++--- src/components/shared/FilterNavigatorBar.css | 14 +++++++------- src/components/shared/TreeView.css | 8 ++++---- src/components/timeline/Selection.css | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/res/css/style.css b/res/css/style.css index 3fc73fe5ae..fac5320156 100644 --- a/res/css/style.css +++ b/res/css/style.css @@ -46,9 +46,9 @@ body { * These variable can be declared in any rule, as long as their value is still * a valid color (which means we can use `light-dark()` or any other color functions). * The colors can be applied on SVG icons with `filter: url(#--var-name)` */ - --button-icon-color: ButtonText; - --button-icon-hover-color: SelectedItem; - --button-icon-active-color: SelectedItem; + --button-icon-color: ButtonText !important; + --button-icon-hover-color: SelectedItem !important; + --button-icon-active-color: SelectedItem !important; } } diff --git a/src/components/shared/FilterNavigatorBar.css b/src/components/shared/FilterNavigatorBar.css index 3888bd0cf4..e0180811e6 100644 --- a/src/components/shared/FilterNavigatorBar.css +++ b/src/components/shared/FilterNavigatorBar.css @@ -162,13 +162,13 @@ @media (forced-colors: active) { .filterNavigatorBar { - --internal-background-color: ButtonFace; - --internal-hover-background-color: SelectedItemText; - --internal-hover-color: SelectedItem; - --internal-active-background-color: SelectedItemText; - --internal-selected-background-color: SelectedItem; - --internal-selected-color: SelectedItemText; - --internal-separator-img: url(../../../res/img/svg/scope-bar-separator-hcm-light.svg); + --internal-background-color: ButtonFace !important; + --internal-hover-background-color: SelectedItemText !important; + --internal-hover-color: SelectedItem !important; + --internal-active-background-color: SelectedItemText !important; + --internal-selected-background-color: SelectedItem !important; + --internal-selected-color: SelectedItemText !important; + --internal-separator-img: url(../../../res/img/svg/scope-bar-separator-hcm-light.svg) !important; } .filterNavigatorBarItem { diff --git a/src/components/shared/TreeView.css b/src/components/shared/TreeView.css index a2eacb7d04..5bdbefabf8 100644 --- a/src/components/shared/TreeView.css +++ b/src/components/shared/TreeView.css @@ -38,10 +38,10 @@ @media (forced-colors: active) { .treeView { - --internal-row-toggle-button-color: ButtonText; - --internal-row-toggle-button-active-color: SelectedItem; - --internal-selected-row-toggle-button-color: SelectedItemText; - --internal-selected-row-toggle-button-active-color: ButtonText; + --internal-row-toggle-button-color: ButtonText !important; + --internal-row-toggle-button-active-color: SelectedItem !important; + --internal-selected-row-toggle-button-color: SelectedItemText !important; + --internal-selected-row-toggle-button-active-color: ButtonText !important; } } diff --git a/src/components/timeline/Selection.css b/src/components/timeline/Selection.css index f8e756d147..afa682ad3b 100644 --- a/src/components/timeline/Selection.css +++ b/src/components/timeline/Selection.css @@ -225,9 +225,9 @@ } .timelineSelection { - --grippy-range-background-color: ButtonText; - --grippy-range-border-color: ButtonBorder; - --grippy-range-hover-background-color: SelectedItem; + --grippy-range-background-color: ButtonText !important; + --grippy-range-border-color: ButtonBorder !important; + --grippy-range-hover-background-color: SelectedItem !important; --selection-outbound-opacity: 0.6; } From 72a2efd94c9eb84c02053e2515046587d1a9b202 Mon Sep 17 00:00:00 2001 From: Tooru Fujisawa Date: Sat, 10 Jan 2026 14:18:18 +0900 Subject: [PATCH 38/38] Move more code to the dark-mode.ts and add tests --- src/index.tsx | 13 +----- src/test/unit/dark-mode.test.ts | 77 +++++++++++++++++++++++++++++++++ src/test/unit/dark-mode.ts | 0 src/utils/dark-mode.ts | 11 +++++ 4 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 src/test/unit/dark-mode.test.ts create mode 100644 src/test/unit/dark-mode.ts diff --git a/src/index.tsx b/src/index.tsx index 2b0c36409c..51421b2551 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -24,18 +24,9 @@ import { logDevelopmentTips, } from './utils/window-console'; import { ensureExists } from './utils/types'; +import { initTheme } from './utils/dark-mode'; -function initTheme() { - const theme = window.localStorage.getItem('theme'); - if (theme === 'dark') { - document.documentElement.classList.add('dark-mode'); - } -} -try { - initTheme(); -} catch (e) { - console.log('initTheme failed', e); -} +initTheme(); // Mock out Google Analytics for anything that's not production so that we have run-time // code coverage in development and testing. diff --git a/src/test/unit/dark-mode.test.ts b/src/test/unit/dark-mode.test.ts new file mode 100644 index 0000000000..60d80d48f4 --- /dev/null +++ b/src/test/unit/dark-mode.test.ts @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 { initTheme, isDarkMode, resetForTest } from '../../utils/dark-mode'; + +describe('isDarkMode', function () { + it('does not throw on access failure', function () { + resetForTest(); + + const getItem = jest.fn(() => { + 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/utils/dark-mode.ts b/src/utils/dark-mode.ts index fa1aa2f135..80f3d2d811 100644 --- a/src/utils/dark-mode.ts +++ b/src/utils/dark-mode.ts @@ -38,6 +38,12 @@ export function maybeLightDark(value: string | [string, string]): string { 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'); @@ -49,3 +55,8 @@ export function setLightMode() { window.localStorage.removeItem('theme'); document.documentElement.classList.remove('dark-mode'); } + +export function resetForTest() { + _isDarkModeSetup = false; + _isDarkMode = false; +}