[grid] UI Light/Dark Mode Toggle (#16364)

* [grid] UI Light/Dark Mode Toggle
* Fix review comment

---------

Signed-off-by: Viet Nguyen Duc <nguyenducviet4496@gmail.com>
diff --git a/javascript/grid-ui/src/components/ThemeToggle/ThemeToggle.tsx b/javascript/grid-ui/src/components/ThemeToggle/ThemeToggle.tsx
new file mode 100644
index 0000000..7d4092c
--- /dev/null
+++ b/javascript/grid-ui/src/components/ThemeToggle/ThemeToggle.tsx
@@ -0,0 +1,54 @@
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React from 'react'
+import { IconButton, Tooltip } from '@mui/material'
+import { LightMode, DarkMode, AutoMode } from '@mui/icons-material'
+import { useTheme } from '../../contexts/ThemeContext'
+
+export const ThemeToggle: React.FC = () => {
+  const { themeMode, setThemeMode } = useTheme()
+
+  const handleClick = () => {
+    const nextMode = themeMode === 'light' ? 'dark' : themeMode === 'dark' ? 'system' : 'light'
+    setThemeMode(nextMode)
+  }
+
+  const getIcon = () => {
+    if (themeMode === 'light') return <LightMode />
+    if (themeMode === 'dark') return <DarkMode />
+    return <AutoMode />
+  }
+
+  const getTooltip = () => {
+    if (themeMode === 'light') return 'Switch to dark mode'
+    if (themeMode === 'dark') return 'Switch to system mode'
+    return 'Switch to light mode'
+  }
+
+  return (
+    <Tooltip title={getTooltip()}>
+      <IconButton
+        color="inherit"
+        onClick={handleClick}
+        aria-label="Toggle theme"
+      >
+        {getIcon()}
+      </IconButton>
+    </Tooltip>
+  )
+}
\ No newline at end of file
diff --git a/javascript/grid-ui/src/components/TopBar/TopBar.tsx b/javascript/grid-ui/src/components/TopBar/TopBar.tsx
index dbfdcca..eb0afc3 100644
--- a/javascript/grid-ui/src/components/TopBar/TopBar.tsx
+++ b/javascript/grid-ui/src/components/TopBar/TopBar.tsx
@@ -27,6 +27,7 @@
 import { Help as HelpIcon } from '@mui/icons-material'
 import React from 'react'
 import seleniumGridLogo from '../../assets/selenium-grid-logo.svg'
+import { ThemeToggle } from '../ThemeToggle/ThemeToggle'
 
 const AppBar = styled(MuiAppBar)(({ theme }) => ({
   zIndex: theme.zIndex.drawer + 1,
@@ -93,14 +94,16 @@
                 component="h1"
                 variant="h4"
                 noWrap
+                sx={{ color: (theme) => theme.palette.mode === 'dark' ? 'primary.main' : 'inherit' }}
               >
                 Selenium Grid
               </Typography>
-              <Typography variant="body2">
+              <Typography variant="body2" sx={{ color: (theme) => theme.palette.mode === 'dark' ? 'primary.main' : 'inherit' }}>
                 {subheader}
               </Typography>
             </Box>
           </Box>
+          <ThemeToggle />
         </Toolbar>
       </AppBar>
     </Box>
diff --git a/javascript/grid-ui/src/contexts/ThemeContext.tsx b/javascript/grid-ui/src/contexts/ThemeContext.tsx
new file mode 100644
index 0000000..a6f37d9
--- /dev/null
+++ b/javascript/grid-ui/src/contexts/ThemeContext.tsx
@@ -0,0 +1,76 @@
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+
+import React, { createContext, useContext, useState, useEffect } from 'react'
+import { ThemeProvider } from '@mui/material/styles'
+import { CssBaseline } from '@mui/material'
+import { lightTheme, darkTheme } from '../theme/themes'
+
+type ThemeMode = 'light' | 'dark' | 'system'
+
+const ThemeContext = createContext<{
+  themeMode: ThemeMode
+  setThemeMode: (mode: ThemeMode) => void
+}>({
+  themeMode: 'system',
+  setThemeMode: () => {}
+})
+
+export const useTheme = () => useContext(ThemeContext)
+
+export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+  const [themeMode, setThemeMode] = useState<ThemeMode>('system')
+  const [systemPrefersDark, setSystemPrefersDark] = useState(false)
+
+  useEffect(() => {
+    if (typeof window !== 'undefined' && window.localStorage) {
+      const saved = localStorage.getItem('theme-mode') as ThemeMode
+      if (saved) setThemeMode(saved)
+    }
+    if (typeof window !== 'undefined' && window.matchMedia) {
+      setSystemPrefersDark(window.matchMedia('(prefers-color-scheme: dark)').matches)
+    }
+  }, [])
+
+  useEffect(() => {
+    if (typeof window !== 'undefined' && window.localStorage) {
+      localStorage.setItem('theme-mode', themeMode)
+    }
+  }, [themeMode])
+
+  useEffect(() => {
+    if (typeof window !== 'undefined' && window.matchMedia) {
+      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+      const handler = (e: MediaQueryListEvent) => setSystemPrefersDark(e.matches)
+      mediaQuery.addEventListener('change', handler)
+      return () => mediaQuery.removeEventListener('change', handler)
+    }
+  }, [])
+
+  const isDark = themeMode === 'dark' || (themeMode === 'system' && systemPrefersDark)
+  const currentTheme = isDark ? darkTheme : lightTheme
+
+  return (
+    <ThemeContext.Provider value={{ themeMode, setThemeMode }}>
+      <ThemeProvider theme={currentTheme}>
+        <CssBaseline />
+        {children}
+      </ThemeProvider>
+    </ThemeContext.Provider>
+  )
+}
diff --git a/javascript/grid-ui/src/index.tsx b/javascript/grid-ui/src/index.tsx
index 76a9e3e..cb5511f 100644
--- a/javascript/grid-ui/src/index.tsx
+++ b/javascript/grid-ui/src/index.tsx
@@ -15,14 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import { CssBaseline } from '@mui/material'
-import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'
+import { StyledEngineProvider } from '@mui/material/styles'
 import React from 'react'
 import ReactDOM from 'react-dom/client'
 import { HashRouter as Router } from 'react-router-dom'
 import App from './App'
 import * as serviceWorker from './serviceWorker'
-import theme from './theme/theme'
+import { CustomThemeProvider } from './contexts/ThemeContext'
 import './index.css'
 
 const root = ReactDOM.createRoot(
@@ -32,12 +31,11 @@
 root.render(
   <React.StrictMode>
     <StyledEngineProvider injectFirst>
-      <ThemeProvider theme={theme}>
-        <CssBaseline/>
+      <CustomThemeProvider>
         <Router>
           <App/>
         </Router>
-      </ThemeProvider>
+      </CustomThemeProvider>
     </StyledEngineProvider>
   </React.StrictMode>
 )
diff --git a/javascript/grid-ui/src/tests/__mocks__/useTheme.tsx b/javascript/grid-ui/src/tests/__mocks__/useTheme.tsx
new file mode 100644
index 0000000..8262448
--- /dev/null
+++ b/javascript/grid-ui/src/tests/__mocks__/useTheme.tsx
@@ -0,0 +1,25 @@
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { lightTheme } from '../../theme/themes'
+
+export const useTheme = jest.fn(() => ({
+  themeMode: 'light',
+  setThemeMode: jest.fn(),
+  currentTheme: lightTheme,
+  isDark: false
+}))
\ No newline at end of file
diff --git a/javascript/grid-ui/src/tests/components/ThemeToggle.test.tsx b/javascript/grid-ui/src/tests/components/ThemeToggle.test.tsx
new file mode 100644
index 0000000..6686817
--- /dev/null
+++ b/javascript/grid-ui/src/tests/components/ThemeToggle.test.tsx
@@ -0,0 +1,85 @@
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { ThemeToggle } from '../../components/ThemeToggle/ThemeToggle'
+import { CustomThemeProvider } from '../../contexts/ThemeContext'
+
+const mockMatchMedia = (matches: boolean) => ({
+  matches,
+  addEventListener: jest.fn(),
+  removeEventListener: jest.fn()
+})
+
+beforeEach(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: jest.fn().mockImplementation(() => mockMatchMedia(false))
+  })
+})
+
+it('cycles through theme modes on click', () => {
+  render(
+    <CustomThemeProvider>
+      <ThemeToggle />
+    </CustomThemeProvider>
+  )
+  
+  const button = screen.getByRole('button')
+  
+  // Should start with system mode (AutoMode icon)
+  expect(button).toHaveAttribute('aria-label', 'Toggle theme')
+  expect(screen.getByTestId('AutoModeIcon')).toBeInTheDocument()
+  
+  // Click to light mode
+  fireEvent.click(button)
+  expect(screen.getByTestId('LightModeIcon')).toBeInTheDocument()
+  expect(screen.queryByTestId('AutoModeIcon')).not.toBeInTheDocument()
+  
+  // Click to dark mode
+  fireEvent.click(button)
+  expect(screen.getByTestId('DarkModeIcon')).toBeInTheDocument()
+  expect(screen.queryByTestId('LightModeIcon')).not.toBeInTheDocument()
+  
+  // Click back to system mode
+  fireEvent.click(button)
+  expect(screen.getByTestId('AutoModeIcon')).toBeInTheDocument()
+  expect(screen.queryByTestId('DarkModeIcon')).not.toBeInTheDocument()
+})
+
+it('responds to system preference changes', () => {
+  const listeners: Array<(e: any) => void> = []
+  const mockMediaQuery = {
+    matches: false,
+    addEventListener: jest.fn((_, handler) => listeners.push(handler)),
+    removeEventListener: jest.fn()
+  }
+  
+  window.matchMedia = jest.fn(() => mockMediaQuery)
+  
+  render(
+    <CustomThemeProvider>
+      <ThemeToggle />
+    </CustomThemeProvider>
+  )
+  
+  // Simulate system preference change
+  listeners.forEach(listener => listener({ matches: true }))
+  
+  expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith('change', expect.any(Function))
+})
\ No newline at end of file
diff --git a/javascript/grid-ui/src/tests/components/TopBar.test.tsx b/javascript/grid-ui/src/tests/components/TopBar.test.tsx
index a7d1c63..196e900 100644
--- a/javascript/grid-ui/src/tests/components/TopBar.test.tsx
+++ b/javascript/grid-ui/src/tests/components/TopBar.test.tsx
@@ -19,14 +19,29 @@
 import TopBar from '../../components/TopBar/TopBar'
 import { render, screen } from '@testing-library/react'
 import userEvent from '@testing-library/user-event'
+import { CustomThemeProvider } from '../../contexts/ThemeContext'
 
 const user = userEvent.setup()
 
+beforeEach(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: jest.fn().mockImplementation(() => ({
+      matches: false,
+      addEventListener: jest.fn(),
+      removeEventListener: jest.fn()
+    }))
+  })
+})
+
 it('renders basic information', () => {
   const subheaderText = 'Hello, world!'
   const handleClick = jest.fn()
-  render(<TopBar subheader={subheaderText} drawerOpen
-                 toggleDrawer={handleClick}/>)
+  render(
+    <CustomThemeProvider>
+      <TopBar subheader={subheaderText} drawerOpen toggleDrawer={handleClick}/>
+    </CustomThemeProvider>
+  )
   expect(screen.getByText('Selenium Grid')).toBeInTheDocument()
   expect(screen.getByRole('img')).toHaveAttribute('alt', 'Selenium Grid Logo')
   expect(screen.getByText(subheaderText)).toBeInTheDocument()
@@ -35,27 +50,40 @@
 it('can toggle drawer if error flag is not set and the drawer is open',
   async () => {
     const handleClick = jest.fn()
-    render(<TopBar subheader="4.0.0" drawerOpen toggleDrawer={handleClick}/>)
-    const button = screen.getByRole('button')
-    expect(button.getAttribute('aria-label')).toBe('close drawer')
-    await user.click(button)
+    render(
+      <CustomThemeProvider>
+        <TopBar subheader="4.0.0" drawerOpen toggleDrawer={handleClick}/>
+      </CustomThemeProvider>
+    )
+    const drawerButton = screen.getByLabelText('close drawer')
+    expect(drawerButton.getAttribute('aria-label')).toBe('close drawer')
+    await user.click(drawerButton)
     expect(handleClick).toHaveBeenCalledTimes(1)
   })
 
 it('can toggle drawer if error flag is not set and the drawer is closed',
   async () => {
     const handleClick = jest.fn()
-    render(<TopBar subheader="4.0.0" toggleDrawer={handleClick}/>)
-    const button = screen.getByRole('button')
-    expect(button.getAttribute('aria-label')).toBe('open drawer')
-    await user.click(button)
+    render(
+      <CustomThemeProvider>
+        <TopBar subheader="4.0.0" toggleDrawer={handleClick}/>
+      </CustomThemeProvider>
+    )
+    const drawerButton = screen.getByLabelText('open drawer')
+    expect(drawerButton.getAttribute('aria-label')).toBe('open drawer')
+    await user.click(drawerButton)
     expect(handleClick).toHaveBeenCalledTimes(1)
   })
 
 it('should not toggle drawer if error flag is set', async () => {
   const handleClick = jest.fn()
-  render(<TopBar subheader="4.0.0" error toggleDrawer={handleClick}/>)
-  expect(screen.queryByRole('button')).not.toBeInTheDocument()
+  render(
+    <CustomThemeProvider>
+      <TopBar subheader="4.0.0" error toggleDrawer={handleClick}/>
+    </CustomThemeProvider>
+  )
+  expect(screen.queryByLabelText('close drawer')).not.toBeInTheDocument()
+  expect(screen.queryByLabelText('open drawer')).not.toBeInTheDocument()
   const link = screen.getByRole('link')
   expect(link.getAttribute('href')).toBe('#help')
   await user.click(link)
diff --git a/javascript/grid-ui/src/theme/theme.tsx b/javascript/grid-ui/src/theme/theme.tsx
index 06453c3..39194d1 100644
--- a/javascript/grid-ui/src/theme/theme.tsx
+++ b/javascript/grid-ui/src/theme/theme.tsx
@@ -15,26 +15,9 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import { createTheme, Theme } from '@mui/material/styles'
-import typography from './typography'
+import { lightTheme } from './themes'
 
-// A custom theme for this app
-const theme: Theme = createTheme({
-  palette: {
-    primary: {
-      main: '#615E9B'
-    },
-    secondary: {
-      main: '#F7F8F8'
-    },
-    error: {
-      main: '#FF1744'
-    },
-    background: {
-      default: '#F7F8F8'
-    }
-  },
-  typography
-})
+// Backward compatibility - export light theme as default
+const theme = lightTheme
 
 export default theme
diff --git a/javascript/grid-ui/src/theme/themes.tsx b/javascript/grid-ui/src/theme/themes.tsx
new file mode 100644
index 0000000..f88fb80
--- /dev/null
+++ b/javascript/grid-ui/src/theme/themes.tsx
@@ -0,0 +1,72 @@
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { createTheme, Theme } from '@mui/material/styles'
+import typography from './typography'
+
+export const lightTheme: Theme = createTheme({
+  palette: {
+    mode: 'light',
+    primary: {
+      main: '#615E9B'
+    },
+    secondary: {
+      main: '#F7F8F8'
+    },
+    error: {
+      main: '#FF1744'
+    },
+    background: {
+      default: '#F7F8F8'
+    }
+  },
+  typography
+})
+
+export const darkTheme: Theme = createTheme({
+  palette: {
+    mode: 'dark',
+    primary: {
+      main: '#615E9B'
+    },
+    secondary: {
+      main: '#36393F'
+    },
+    error: {
+      main: '#F04747'
+    },
+    background: {
+      default: '#0c1117',
+      paper: '#161B22'
+    },
+    text: {
+      primary: '#F0F6FC',
+      secondary: '#8B949E'
+    }
+  },
+  typography,
+  components: {
+    MuiAppBar: {
+      styleOverrides: {
+        root: {
+          backgroundColor: '#020408',
+          boxShadow: '0 1px 3px rgba(0,0,0,0.5)'
+        }
+      }
+    }
+  }
+})