Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
<script>
(function () {
var t = localStorage.getItem("floci-theme");
var theme = t === "system"
? (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: (t === "light" ? "light" : "dark");
document.documentElement.setAttribute(
"data-theme",
t === "light" ? "light" : "dark",
theme,
);
})();
</script>
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,9 @@ export function Layout() {
const location = useLocation()
const navigate = useNavigate()
const activeCloud = activeCloudFromPath(location.pathname)
const {theme} = useTheme()
const {resolvedTheme} = useTheme()
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
const isDark = theme === 'dark'
const isDark = resolvedTheme === 'dark'
const {data, isError} = useQuery({
queryKey: ['cloud-status', activeCloud],
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
Expand Down
7 changes: 7 additions & 0 deletions packages/frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -4503,3 +4503,10 @@ button.console-service-card {
color: var(--text-bright);
font-weight: 600;
}

@media (max-width: 600px) {
.settings-section .settings-row {
align-items: flex-start;
flex-direction: column;
}
}
31 changes: 25 additions & 6 deletions packages/frontend/src/lib/useTheme.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
import {useEffect} from 'react'
import {useEffect, useSyncExternalStore} from 'react'
import {create} from 'zustand'

type Theme = 'dark' | 'light'
type Theme = 'dark' | 'light' | 'system'

const SYSTEM_DARK_QUERY = '(prefers-color-scheme: dark)'

function systemPrefersDark(): boolean {
return window.matchMedia?.(SYSTEM_DARK_QUERY).matches ?? false
}

function subscribeToSystemTheme(onChange: () => void): () => void {
const query = window.matchMedia?.(SYSTEM_DARK_QUERY)
if (!query) return () => {}

query.addEventListener('change', onChange)
return () => query.removeEventListener('change', onChange)
}

const useThemeStore = create<{
theme: Theme
setTheme: (theme: Theme) => void
}>()((set) => ({
theme: (localStorage.getItem('floci-theme') === 'light' ? 'light' : 'dark') as Theme,
theme: (() => {
const stored = localStorage.getItem('floci-theme')
return stored === 'light' || stored === 'system' ? stored : 'dark'
Comment thread
greptile-apps[bot] marked this conversation as resolved.
})(),
setTheme: (theme) => set({theme}),
}))

/** Subscribe to theme changes and apply to DOM + localStorage. */
export function useTheme() {
const theme = useThemeStore((s) => s.theme)
const setTheme = useThemeStore((s) => s.setTheme)
const systemDark = useSyncExternalStore(subscribeToSystemTheme, systemPrefersDark)
const resolvedTheme = theme === 'system' ? (systemDark ? 'dark' : 'light') : theme

useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
document.documentElement.setAttribute('data-theme', resolvedTheme)
localStorage.setItem('floci-theme', theme)
}, [theme])
}, [resolvedTheme, theme])

return {theme, setTheme}
return {theme, resolvedTheme, setTheme}
}
11 changes: 10 additions & 1 deletion packages/frontend/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function SettingsPage() {
<div className="settings-row">
<div className="settings-row-text">
<span className="settings-label">Theme</span>
<span className="settings-description">Switch between light and dark mode</span>
<span className="settings-description">Choose light, dark, or your system preference</span>
</div>
<div className="settings-toggle-group" role="radiogroup" aria-label="Theme">
<button
Expand All @@ -42,6 +42,15 @@ export function SettingsPage() {
>
Light
</button>
<button
type="button"
className={`settings-toggle-btn${theme === 'system' ? ' active' : ''}`}
role="radio"
aria-checked={theme === 'system'}
onClick={() => setTheme('system')}
>
System
</button>
</div>
</div>
</div>
Expand Down
64 changes: 64 additions & 0 deletions packages/frontend/src/pages/SettingsTheme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {act, render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {afterEach, describe, expect, it, vi} from 'vitest'
import {useTheme} from '@/lib/useTheme'
import {SettingsPage} from './SettingsPage'

function ResolvedTheme() {
const {resolvedTheme} = useTheme()
return <output data-testid="resolved-theme">{resolvedTheme}</output>
}

afterEach(() => vi.unstubAllGlobals())

describe('Settings theme preference', () => {
it('follows system changes until the user chooses a fixed theme', async () => {
let dark = false
const listeners = new Set<() => void>()
vi.stubGlobal('matchMedia', vi.fn(() => ({
get matches() { return dark },
media: '(prefers-color-scheme: dark)',
addEventListener: (_event: string, listener: () => void) => listeners.add(listener),
removeEventListener: (_event: string, listener: () => void) => listeners.delete(listener),
})))
const user = userEvent.setup()

render(<><SettingsPage/><ResolvedTheme/></>)

await user.click(screen.getByRole('radio', {name: 'System'}))
expect(screen.getByRole('radio', {name: 'System'})).toHaveAttribute('aria-checked', 'true')
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('light')
expect(document.documentElement).toHaveAttribute('data-theme', 'light')
expect(localStorage.getItem('floci-theme')).toBe('system')
Comment thread
greptile-apps[bot] marked this conversation as resolved.

act(() => {
dark = true
listeners.forEach((listener) => listener())
})
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(localStorage.getItem('floci-theme')).toBe('system')

await user.click(screen.getByRole('radio', {name: 'Light'}))
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('light')
act(() => {
dark = false
listeners.forEach((listener) => listener())
})
act(() => {
dark = true
listeners.forEach((listener) => listener())
})
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('light')
expect(document.documentElement).toHaveAttribute('data-theme', 'light')
expect(localStorage.getItem('floci-theme')).toBe('light')

await user.click(screen.getByRole('radio', {name: 'Dark'}))
act(() => {
dark = false
listeners.forEach((listener) => listener())
})
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
})
})
18 changes: 18 additions & 0 deletions packages/frontend/tests/theme-preference.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {expect, test} from '@playwright/test'

for (const colorScheme of ['light', 'dark'] as const) {
test(`restores System theme before hydration in ${colorScheme} mode`, async ({page}) => {
await page.emulateMedia({colorScheme})
await page.addInitScript(() => localStorage.setItem('floci-theme', 'system'))

const entry = '**/src/main.tsx*'
await page.route(entry, (route) => route.abort())
await page.goto('/console/aws/settings', {waitUntil: 'domcontentloaded'})
await expect(page.locator('html')).toHaveAttribute('data-theme', colorScheme)

await page.unroute(entry)
await page.reload({waitUntil: 'domcontentloaded'})
await expect(page.getByRole('radio', {name: 'System'})).toHaveAttribute('aria-checked', 'true')
await expect(page.locator('html')).toHaveAttribute('data-theme', colorScheme)
})
}
Loading