Skip to content

Commit eee7ebb

Browse files
authored
[ZEPPELIN-6323] Apply dark mode to the new UI
### What is this PR for? This PR adds dark mode support and system theme integration for the Zeppelin New UI. There were multiple demands such as [ZEPPELIN-5062](https://issues.apache.org/jira/browse/ZEPPELIN-5602) and [ZEPPELIN-4024](https://issues.apache.org/jira/browse/ZEPPELIN-4024). #### Example: Follow system theme + other parts https://github.com/user-attachments/assets/2159d54e-6403-4f80-91f0-3f66a93881e1 #### Example: Change with button + notebook https://github.com/user-attachments/assets/59bdf1bc-86a3-42e1-a3d0-1d89d955ed7d #### Automatic System Theme Detection & Sync - Automatically detect OS-level dark/light mode settings - Real-time detection and application of system theme changes - Theme cycle pattern: `auto(system) → opposite theme → original theme → auto` #### Comprehensive Dark Mode UI Support - Applied dark mode styles across all major components - Added dark mode overrides for Ant Design components - Full Monaco Editor dark theme support - Consistent color scheme and visual hierarchy #### Enhanced User Experience - Eliminated FOUC (Flash of Unstyled Content) with logic handled in `index.html` - Easy theme switching via a toggle button - Persisted user preferences in local storage - Theme state maintained after page reloads With dark mode support and system theme integration, Zeppelin delivers a modern, user-friendly experience. Users can either rely on system theme settings for automatic adaptation or manually select their preferred theme. ### What type of PR is it? Improvement ### Todos * [ ] I created a dark mode [background image](https://github.com/dididy/zeppelin/blob/5d078a40c17e0561202e809da0761016516b86f2/zeppelin-web-angular/src/assets/images/bg-dark.png) used for login and loading with ChatGPT, but I need to verify whether there are any copyright issues. * [ ] Due to the limited environment setup, I wasn’t able to check all the cases where graphs are rendered. I think we can leave this as a follow-up issue to work on later. ### What is the Jira issue? * [[ZEPPELIN-6323](https://issues.apache.org/jira/browse/ZEPPELIN-6323)] ### How should this be tested? ### Screenshots (if appropriate) ### Questions: * Does the license files need to update? No * Is there breaking changes for older versions? No * Does this needs documentation? No Closes #5078 from dididy/feat/darkmode. Signed-off-by: ChanHo Lee <[email protected]>
1 parent 190ecd6 commit eee7ebb

28 files changed

+1063
-79
lines changed

LICENSE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ The text of each license is also included at licenses/LICENSE-[project]-[version
243243
(Apache 2.0) Embedded MongoDB (https://github.com/flapdoodle-oss/de.flapdoodle.embed.mongo)
244244
(Apache 2.0) s3proxy (https://github.com/gaul/s3proxy)
245245
(Apache 2.0) kubernetes-client (https://github.com/fabric8io/kubernetes-client)
246+
(Apache 2.0) Material Symbols Outlined (https://fonts.google.com/icons)
246247

247248
========================================================================
248249
BSD 3-Clause licenses

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,7 @@
10281028
<exclude>**/src/fonts/simple-line*</exclude>
10291029
<exclude>**/src/fonts/Source-Code-Pro*</exclude>
10301030
<exclude>**/src/fonts/source-code-pro*</exclude>
1031+
<exclude>**/src/assets/fonts/MaterialSymbolsOutlined.woff2</exclude>
10311032
<exclude>**/src/**/**.test.js</exclude>
10321033
<exclude>**/e2e/**/**.spec.js</exclude>
10331034
<exclude>package-lock.json</exclude>

zeppelin-web-angular/angular.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
}
6969
],
7070
"styles": [
71+
"src/styles/theme/dark-theme-overrides.css",
7172
"src/styles/theme/dark/antd-dark.less",
7273
"src/styles/theme/light/antd-light.less",
7374
"src/styles.less",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS,
8+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
* See the License for the specific language governing permissions and
10+
* limitations under the License.
11+
*/
12+
13+
import { expect, Locator, Page } from '@playwright/test';
14+
15+
export class ThemePage {
16+
readonly page: Page;
17+
readonly themeToggleButton: Locator;
18+
readonly rootElement: Locator;
19+
20+
constructor(page: Page) {
21+
this.page = page;
22+
this.themeToggleButton = page.locator('zeppelin-theme-toggle button');
23+
this.rootElement = page.locator('html');
24+
}
25+
26+
async toggleTheme() {
27+
await this.themeToggleButton.click();
28+
}
29+
30+
async assertDarkTheme() {
31+
await expect(this.rootElement).toHaveClass(/dark/);
32+
await expect(this.rootElement).toHaveAttribute('data-theme', 'dark');
33+
await expect(this.themeToggleButton).toHaveText('dark_mode');
34+
}
35+
36+
async assertLightTheme() {
37+
await expect(this.rootElement).toHaveClass(/light/);
38+
await expect(this.rootElement).toHaveAttribute('data-theme', 'light');
39+
await expect(this.themeToggleButton).toHaveText('light_mode');
40+
}
41+
42+
async assertSystemTheme() {
43+
await expect(this.themeToggleButton).toHaveText('smart_toy');
44+
}
45+
46+
async setThemeInLocalStorage(theme: 'light' | 'dark' | 'system') {
47+
await this.page.evaluate(themeValue => {
48+
if (typeof window !== 'undefined' && window.localStorage) {
49+
window.localStorage.setItem('zeppelin-theme', themeValue);
50+
}
51+
}, theme);
52+
}
53+
54+
async clearLocalStorage() {
55+
await this.page.evaluate(() => {
56+
if (typeof window !== 'undefined' && window.localStorage) {
57+
window.localStorage.clear();
58+
}
59+
});
60+
}
61+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS,
8+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
* See the License for the specific language governing permissions and
10+
* limitations under the License.
11+
*/
12+
13+
import { expect, test } from '@playwright/test';
14+
import { ZeppelinHelper } from '../../helper';
15+
import { ThemePage } from '../../models/theme.page';
16+
import { addPageAnnotationBeforeEach, PAGES } from '../../utils';
17+
18+
test.describe('Dark Mode Theme Switching', () => {
19+
addPageAnnotationBeforeEach(PAGES.SHARE.THEME_TOGGLE);
20+
let zeppelinHelper: ZeppelinHelper;
21+
let themePage: ThemePage;
22+
23+
test.beforeEach(async ({ page }) => {
24+
zeppelinHelper = new ZeppelinHelper(page);
25+
themePage = new ThemePage(page);
26+
await page.goto('/', { waitUntil: 'load' });
27+
await zeppelinHelper.waitForZeppelinReady();
28+
// Ensure a clean localStorage for each test
29+
await themePage.clearLocalStorage();
30+
});
31+
32+
test('Scenario: User can switch to dark mode and persistence is maintained', async ({ page }) => {
33+
// GIVEN: User is on the main page, which starts in 'system' mode by default (localStorage cleared).
34+
await test.step('GIVEN the page starts in system mode', async () => {
35+
await themePage.assertSystemTheme(); // Robot icon for system theme
36+
});
37+
38+
// WHEN: Explicitly set theme to light mode for the rest of the test.
39+
await test.step('WHEN the user explicitly sets theme to light mode', async () => {
40+
await themePage.setThemeInLocalStorage('light');
41+
await page.reload();
42+
await zeppelinHelper.waitForZeppelinReady();
43+
await themePage.assertLightTheme(); // Now it should be light mode with sun icon
44+
});
45+
46+
// WHEN: User switches to dark mode by setting localStorage and reloading.
47+
await test.step('WHEN the user switches to dark mode', async () => {
48+
await themePage.setThemeInLocalStorage('dark');
49+
await page.reload();
50+
await zeppelinHelper.waitForZeppelinReady();
51+
});
52+
53+
// THEN: The theme changes to dark mode.
54+
await test.step('THEN the page switches to dark mode', async () => {
55+
await themePage.assertDarkTheme();
56+
});
57+
58+
// AND: User refreshes the page.
59+
await test.step('AND the user refreshes the page', async () => {
60+
await page.reload();
61+
await zeppelinHelper.waitForZeppelinReady();
62+
});
63+
64+
// THEN: Dark mode is maintained after refresh.
65+
await test.step('THEN dark mode is maintained after refresh', async () => {
66+
await themePage.assertDarkTheme();
67+
});
68+
69+
// AND: User clicks the toggle again to switch back to light mode.
70+
await test.step('AND the user clicks the toggle to switch back to light mode', async () => {
71+
await themePage.toggleTheme();
72+
});
73+
74+
// THEN: The theme switches to system mode.
75+
await test.step('THEN the theme switches to system mode', async () => {
76+
await themePage.assertSystemTheme();
77+
});
78+
});
79+
80+
test('Scenario: System Theme and Local Storage Interaction', async ({ page, context }) => {
81+
// Ensure localStorage is clear for each sub-scenario
82+
await themePage.clearLocalStorage();
83+
84+
await test.step('GIVEN: No localStorage, System preference is Light', async () => {
85+
await page.emulateMedia({ colorScheme: 'light' });
86+
await page.goto('/', { waitUntil: 'load' });
87+
await zeppelinHelper.waitForZeppelinReady();
88+
// When no explicit theme is set, it defaults to 'system' mode
89+
// Even in system mode with light preference, the icon should be robot
90+
await expect(themePage.rootElement).toHaveClass(/light/);
91+
await expect(themePage.rootElement).toHaveAttribute('data-theme', 'light');
92+
await themePage.assertSystemTheme(); // Should show robot icon
93+
});
94+
95+
await test.step('GIVEN: No localStorage, System preference is Dark (initial system state)', async () => {
96+
await themePage.setThemeInLocalStorage('system');
97+
await page.goto('/', { waitUntil: 'load' });
98+
await zeppelinHelper.waitForZeppelinReady();
99+
await themePage.assertSystemTheme(); // Robot icon for system theme
100+
});
101+
102+
await test.step("GIVEN: localStorage is 'dark', System preference is Light", async () => {
103+
await themePage.setThemeInLocalStorage('dark');
104+
await page.emulateMedia({ colorScheme: 'light' });
105+
await page.goto('/', { waitUntil: 'load' });
106+
await zeppelinHelper.waitForZeppelinReady();
107+
await themePage.assertDarkTheme(); // localStorage should override system
108+
});
109+
110+
await test.step("GIVEN: localStorage is 'system', THEN: Emulate system preference change to Light", async () => {
111+
await themePage.setThemeInLocalStorage('system');
112+
await page.emulateMedia({ colorScheme: 'light' });
113+
await page.goto('/', { waitUntil: 'load' });
114+
await zeppelinHelper.waitForZeppelinReady();
115+
await expect(themePage.rootElement).toHaveClass(/light/);
116+
await expect(themePage.rootElement).toHaveAttribute('data-theme', 'light');
117+
await themePage.assertSystemTheme(); // Robot icon for system theme
118+
});
119+
120+
await test.step("GIVEN: localStorage is 'system', THEN: Emulate system preference change to Dark", async () => {
121+
await themePage.setThemeInLocalStorage('system');
122+
await page.emulateMedia({ colorScheme: 'dark' });
123+
await page.goto('/', { waitUntil: 'load' });
124+
await zeppelinHelper.waitForZeppelinReady();
125+
await expect(themePage.rootElement).toHaveClass(/dark/);
126+
await expect(themePage.rootElement).toHaveAttribute('data-theme', 'dark');
127+
await themePage.assertSystemTheme(); // Robot icon for system theme
128+
});
129+
});
130+
});

zeppelin-web-angular/e2e/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export const PAGES = {
7676
PAGE_HEADER: 'src/app/share/page-header/page-header.component',
7777
RESIZE_HANDLE: 'src/app/share/resize-handle/resize-handle.component',
7878
SHORTCUT: 'src/app/share/shortcut/shortcut.component',
79-
SPIN: 'src/app/share/spin/spin.component'
79+
SPIN: 'src/app/share/spin/spin.component',
80+
THEME_TOGGLE: 'src/app/share/theme-toggle/theme-toggle.component'
8081
},
8182

8283
// Visualizations

zeppelin-web-angular/src/app/app.component.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@
1010
* limitations under the License.
1111
*/
1212

13-
import { Component } from '@angular/core';
13+
import { Component, OnInit } from '@angular/core';
1414
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
1515
import { filter, map } from 'rxjs/operators';
1616

17-
import { TicketService } from '@zeppelin/services';
17+
import { ThemeService, TicketService } from '@zeppelin/services';
1818

1919
@Component({
2020
selector: 'zeppelin-root',
2121
templateUrl: './app.component.html',
2222
styleUrls: ['./app.component.less']
2323
})
24-
export class AppComponent {
24+
export class AppComponent implements OnInit {
2525
logout$ = this.ticketService.logout$;
2626
loading$ = this.router.events.pipe(
2727
filter(data => data instanceof NavigationEnd || data instanceof NavigationStart),
@@ -35,5 +35,9 @@ export class AppComponent {
3535
})
3636
);
3737

38-
constructor(private router: Router, private ticketService: TicketService) {}
38+
constructor(private router: Router, private ticketService: TicketService, private themeService: ThemeService) {}
39+
40+
ngOnInit(): void {
41+
this.themeService.updateMonacoTheme();
42+
}
3943
}

zeppelin-web-angular/src/app/languages/load.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,9 @@ import { editor, languages } from 'monaco-editor';
1414
import { conf as ScalaConf, language as ScalaLanguage } from './scala';
1515

1616
export const loadMonacoBefore = () => {
17-
editor.defineTheme('zeppelin-theme', {
18-
base: 'vs',
19-
inherit: true,
20-
rules: [],
21-
colors: {
22-
'editor.lineHighlightBackground': '#0000FF10'
23-
}
24-
});
25-
editor.setTheme('zeppelin-theme');
17+
const savedTheme = localStorage.getItem('zeppelin-theme') || 'light';
18+
const monacoTheme = savedTheme === 'dark' ? 'vs-dark' : 'vs';
19+
editor.setTheme(monacoTheme);
2620
languages.register({ id: 'scala' });
2721
languages.setMonarchTokensProvider('scala', ScalaLanguage);
2822
languages.setLanguageConfiguration('scala', ScalaConf);

zeppelin-web-angular/src/app/pages/login/login.component.less

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
left: 0;
2626
width: 100%;
2727
height: 100%;
28-
background-image: url("../../../assets/images/bg.jpg");
2928
background-size: cover;
3029
filter: blur(4px);
3130
background-repeat: no-repeat;
@@ -67,3 +66,19 @@
6766
}
6867
}
6968
});
69+
70+
:host-context(.light) {
71+
.content {
72+
&:after {
73+
background-image: url("../../../assets/images/bg.jpg");
74+
}
75+
}
76+
}
77+
78+
:host-context(.dark) {
79+
.content {
80+
&:after {
81+
background-image: url("../../../assets/images/bg-dark.png");
82+
}
83+
}
84+
}

zeppelin-web-angular/src/app/pages/workspace/interpreter/interpreter.component.less

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,6 @@
2929
}
3030
}
3131

32-
.editable-tag {
33-
background: @white;
34-
border-style: dashed;
35-
}
36-
3732
.content {
3833
padding: @card-padding-base / 2;
3934

@@ -43,3 +38,22 @@
4338
}
4439
}
4540
});
41+
42+
:host-context(.light) {
43+
background: #f0f0f0;
44+
45+
.editable-tag {
46+
background: #fff;
47+
border-style: dashed;
48+
}
49+
}
50+
51+
:host-context(.dark) {
52+
background: #141414;
53+
54+
.editable-tag {
55+
background: #1f1f1f;
56+
border-style: dashed;
57+
border-color: #434343;
58+
}
59+
}

0 commit comments

Comments
 (0)