diff --git a/frontend/src/operator/webui/src/app/app.component.html b/frontend/src/operator/webui/src/app/app.component.html
index ea094dbb6a2..138c6651911 100644
--- a/frontend/src/operator/webui/src/app/app.component.html
+++ b/frontend/src/operator/webui/src/app/app.component.html
@@ -1,7 +1,16 @@
-
+
Cloud Android
+
diff --git a/frontend/src/operator/webui/src/app/app.component.scss b/frontend/src/operator/webui/src/app/app.component.scss
index 08792390bf2..61105d7b9d8 100644
--- a/frontend/src/operator/webui/src/app/app.component.scss
+++ b/frontend/src/operator/webui/src/app/app.component.scss
@@ -6,15 +6,48 @@
bottom: 0;
left: 0;
right: 0;
- background: #2a2a2a;
- color: #eee;
+
+ // Light Mode Variables (Default)
+ --bg-primary: #f5f5f5;
+ --bg-drawer: #e8e8e8;
+ --bg-device-pane: #ffffff;
+ --bg-toolbar: #073042;
+ --text-primary: #073042;
+ --text-toolbar: #eee;
+ --text-version: #555555;
+ --button-color: #073042;
+ --card-header-bg: #073042;
+ --card-header-text: #eee;
+ --card-bg: #ffffff;
+
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ transition:
+ background-color 0.3s ease,
+ color 0.3s ease;
+
+ &.dark-theme {
+ // Dark Mode Variables
+ --bg-primary: #121212;
+ --bg-drawer: #1e1e1e;
+ --bg-device-pane: #2d2d2d;
+ --bg-toolbar: #1f2937;
+ --text-primary: #e8eaed;
+ --text-toolbar: #e8eaed;
+ --text-version: #9aa0a6;
+ --button-color: #8ab4f8;
+ --card-header-bg: #2d3748;
+ --card-header-text: #e8eaed;
+ --card-bg: #141414;
+ }
}
.cloud-android-toolbar {
- background: #073042;
- color: #eee;
+ background: var(--bg-toolbar);
+ color: var(--text-toolbar);
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.5);
text-align: center;
+ transition: background-color 0.3s ease;
}
.cloud-android-title {
@@ -23,16 +56,20 @@
.drawer-container {
flex: 1;
- background: #cfcfcf;
+ background: var(--bg-drawer);
+ transition: background-color 0.3s ease;
}
.device-pane {
margin: 0 auto;
width: 275px;
- background: rgba(245, 245, 245, 0.8);
- color: #073042;
+ background: var(--bg-device-pane);
+ color: var(--text-primary);
filter: drop-shadow(4px 4px 10px rgba(0, 0, 0, 0.25));
border-radius: 0px 16px 16px 0px;
+ transition:
+ background-color 0.3s ease,
+ color 0.3s ease;
}
.version-info {
@@ -40,4 +77,5 @@
bottom: 0;
right: 0;
margin: 0px 5px 5px 0px;
+ color: var(--text-version);
}
diff --git a/frontend/src/operator/webui/src/app/app.component.ts b/frontend/src/operator/webui/src/app/app.component.ts
index a7b54153be5..3169dde9ae8 100644
--- a/frontend/src/operator/webui/src/app/app.component.ts
+++ b/frontend/src/operator/webui/src/app/app.component.ts
@@ -1,7 +1,8 @@
import {Component, HostListener, Injectable, ChangeDetectionStrategy} from '@angular/core';
import {DisplaysService} from './displays.service';
-import {BUILD_VERSION} from '../environments/version'
+import {ThemeService} from './theme.service';
+import {BUILD_VERSION} from '../environments/version';
@Injectable()
@Component({
@@ -13,7 +14,11 @@ import {BUILD_VERSION} from '../environments/version'
})
export class AppComponent {
readonly version = BUILD_VERSION;
- constructor(private displaysService: DisplaysService) {}
+
+ constructor(
+ private displaysService: DisplaysService,
+ public themeService: ThemeService,
+ ) {}
@HostListener('window:message', ['$event'])
onWindowMessage(e: MessageEvent) {
diff --git a/frontend/src/operator/webui/src/app/device-pane/device-pane.component.scss b/frontend/src/operator/webui/src/app/device-pane/device-pane.component.scss
index 98493da6065..f588402ad95 100644
--- a/frontend/src/operator/webui/src/app/device-pane/device-pane.component.scss
+++ b/frontend/src/operator/webui/src/app/device-pane/device-pane.component.scss
@@ -45,7 +45,7 @@
button {
display: inline-block;
- color: #073042;
+ color: var(--button-color, #073042);
}
.group-name {
diff --git a/frontend/src/operator/webui/src/app/theme.service.spec.ts b/frontend/src/operator/webui/src/app/theme.service.spec.ts
new file mode 100644
index 00000000000..fdc23cc95e8
--- /dev/null
+++ b/frontend/src/operator/webui/src/app/theme.service.spec.ts
@@ -0,0 +1,87 @@
+import {TestBed} from '@angular/core/testing';
+
+import {ThemeService} from './theme.service';
+
+describe('ThemeService', () => {
+ beforeEach(() => {
+ window.localStorage.removeItem('theme');
+ TestBed.configureTestingModule({
+ providers: [ThemeService],
+ });
+ });
+
+ afterEach(() => {
+ window.localStorage.removeItem('theme');
+ });
+
+ it('should be created', () => {
+ const service = TestBed.inject(ThemeService);
+ expect(service).toBeTruthy();
+ });
+
+ it('should toggle dark mode state and save to localStorage', () => {
+ const service = TestBed.inject(ThemeService);
+ const initialState = service.isDarkMode;
+ service.toggleDarkMode();
+ expect(service.isDarkMode).toBe(!initialState);
+ expect(window.localStorage.getItem('theme')).toBe(
+ service.isDarkMode ? 'dark' : 'light',
+ );
+ });
+
+ it('should respect saved theme from localStorage on initialization', () => {
+ window.localStorage.setItem('theme', 'dark');
+ const service = TestBed.inject(ThemeService);
+ expect(service.isDarkMode).toBe(true);
+ });
+
+ describe('system theme preference', () => {
+ let queryListener: ((e: MediaQueryListEvent) => void) | undefined;
+ let removeEventListenerSpy: jasmine.Spy;
+
+ beforeEach(() => {
+ queryListener = undefined;
+ removeEventListenerSpy = jasmine.createSpy('removeEventListener');
+
+ spyOn(window, 'matchMedia').and.callFake((query: string) => {
+ return {
+ matches: false,
+ addEventListener: (
+ event: string,
+ listener: (e: MediaQueryListEvent) => void,
+ ) => (queryListener = listener),
+ removeEventListener: removeEventListenerSpy,
+ } as unknown as MediaQueryList;
+ });
+ });
+
+ it('should use system theme when no theme is saved', () => {
+ const service = TestBed.inject(ThemeService);
+ expect(service.isDarkMode).toBe(false);
+ expect(queryListener).toBeDefined();
+
+ queryListener!({matches: true} as MediaQueryListEvent);
+ expect(service.isDarkMode).toBe(true);
+
+ queryListener!({matches: false} as MediaQueryListEvent);
+ expect(service.isDarkMode).toBe(false);
+ });
+
+ it('should ignore system theme when a saved theme is present', () => {
+ window.localStorage.setItem('theme', 'dark');
+ const service = TestBed.inject(ThemeService);
+ expect(service.isDarkMode).toBe(true);
+ expect(queryListener).toBeUndefined();
+ });
+
+ it('should remove listener on ngOnDestroy', () => {
+ const service = TestBed.inject(ThemeService);
+ service.ngOnDestroy();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'change',
+ queryListener,
+ );
+ });
+ });
+});
diff --git a/frontend/src/operator/webui/src/app/theme.service.ts b/frontend/src/operator/webui/src/app/theme.service.ts
new file mode 100644
index 00000000000..341155c66ad
--- /dev/null
+++ b/frontend/src/operator/webui/src/app/theme.service.ts
@@ -0,0 +1,70 @@
+import {Injectable, OnDestroy, NgZone} from '@angular/core';
+
+function getSavedTheme(): string | null {
+ try {
+ return window.localStorage.getItem('theme');
+ } catch {
+ return null;
+ }
+}
+
+function setSavedTheme(theme: string): void {
+ try {
+ window.localStorage.setItem('theme', theme);
+ } catch {
+ // Ignore.
+ }
+}
+
+/**
+ * Manages light and dark theme state, system preference detection
+ * (prefers-color-scheme), and persisting user theme preference in localStorage.
+ */
+@Injectable({
+ providedIn: 'root',
+})
+export class ThemeService implements OnDestroy {
+ isDarkMode = false;
+ private query?: MediaQueryList;
+ private queryListener?: (e: MediaQueryListEvent) => void;
+
+ constructor(private ngZone: NgZone) {
+ const savedTheme = getSavedTheme();
+ if (savedTheme !== null) {
+ this.isDarkMode = savedTheme === 'dark';
+ } else {
+ this.listenToPrefersColorSchemeChanges();
+ }
+ }
+
+ toggleDarkMode(): void {
+ this.isDarkMode = !this.isDarkMode;
+ setSavedTheme(this.isDarkMode ? 'dark' : 'light');
+ this.stopListeningToPrefersColorSchemeChanges();
+ }
+
+ private listenToPrefersColorSchemeChanges(): void {
+ if (window.matchMedia) {
+ this.query = window.matchMedia('(prefers-color-scheme: dark)');
+ this.queryListener = (e: MediaQueryListEvent) => {
+ if (getSavedTheme() === null) {
+ this.ngZone.run(() => (this.isDarkMode = e.matches));
+ }
+ };
+ this.query.addEventListener('change', this.queryListener);
+ this.isDarkMode = this.query.matches;
+ }
+ }
+
+ private stopListeningToPrefersColorSchemeChanges(): void {
+ if (this.queryListener) {
+ this.query?.removeEventListener('change', this.queryListener);
+ this.query = undefined;
+ this.queryListener = undefined;
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.stopListeningToPrefersColorSchemeChanges();
+ }
+}
diff --git a/frontend/src/operator/webui/src/app/view-pane/view-pane.component.scss b/frontend/src/operator/webui/src/app/view-pane/view-pane.component.scss
index 25ff9b38029..5d522ba7529 100644
--- a/frontend/src/operator/webui/src/app/view-pane/view-pane.component.scss
+++ b/frontend/src/operator/webui/src/app/view-pane/view-pane.component.scss
@@ -15,21 +15,24 @@ ktd-grid {
flex-direction: column;
height: 98%;
width: 98%;
- background-color: #000;
+ background-color: var(--card-bg, #000);
border-radius: 10px 10px 10px 10px;
.viewer-header {
display: flex;
align-items: center;
- background-color: #073042;
+ background-color: var(--card-header-bg, #073042);
border-style: solid;
- border-color: #073042;
+ border-color: var(--card-header-bg, #073042);
border-radius: 10px 10px 0px 0px;
font-size: 20px;
font-weight: 400;
- color: #eee;
+ color: var(--card-header-text, #eee);
height: 40px;
padding-left: 20px;
+ transition:
+ background-color 0.3s ease,
+ border-color 0.3s ease;
.grid-item-remove-handle {
position: absolute;
@@ -72,7 +75,7 @@ ktd-grid {
} // hidden
.loading-message {
- color: white;
+ color: var(--text-primary, white);
font-size: 15px;
font-weight: 400;
padding: 10px;