diff --git a/packages/k8s-ui/src/components/logs/LogCore.theme.test.tsx b/packages/k8s-ui/src/components/logs/LogCore.theme.test.tsx new file mode 100644 index 0000000000..2affc30e14 --- /dev/null +++ b/packages/k8s-ui/src/components/logs/LogCore.theme.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { LogCore } from './LogCore' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +let root: Root +let element: HTMLDivElement +beforeEach(() => { + localStorage.clear() + element = document.createElement('div') + document.body.appendChild(element) + root = createRoot(element) +}) +afterEach(async () => { + await act(async () => root.unmount()) + element.remove() +}) + +const noop = () => {} +async function render(props: { forceDark?: boolean; defaultDark?: boolean }) { + await act(async () => + root.render( + , + ), + ) +} +const toggle = () => element.querySelector('button[aria-label^="Switch log viewer"]') + +describe('LogCore theme', () => { + it('defaults to dark when no theme hint is given', async () => { + await render({}) + expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to light mode') + }) + + it('follows defaultDark and keeps the toggle available', async () => { + await render({ defaultDark: false }) + expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to dark mode') + await render({ defaultDark: true }) + expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to light mode') + }) + + it('prefers the saved toggle choice over defaultDark', async () => { + localStorage.setItem('radar-logs-dark', 'true') + await render({ defaultDark: false }) + expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to light mode') + }) + + it('returns to the saved choice when a forceDark pin is lifted', async () => { + localStorage.setItem('radar-logs-dark', 'false') + await render({ forceDark: true, defaultDark: false }) + expect(toggle()).toBeNull() + await render({ defaultDark: false }) + expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to dark mode') + }) + + it('ignores a saved value that is not a palette choice', async () => { + localStorage.setItem('radar-logs-dark', 'garbage') + await render({ defaultDark: false }) + expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to dark mode') + }) + + it('hides the toggle when forceDark is set', async () => { + await render({ forceDark: true, defaultDark: false }) + expect(toggle()).toBeNull() + }) +}) diff --git a/packages/k8s-ui/src/components/logs/LogCore.tsx b/packages/k8s-ui/src/components/logs/LogCore.tsx index e1a6d2113f..ad7ce13efd 100644 --- a/packages/k8s-ui/src/components/logs/LogCore.tsx +++ b/packages/k8s-ui/src/components/logs/LogCore.tsx @@ -66,6 +66,12 @@ interface LogCoreProps { * the viewer manages its own palette via localStorage and the Sun/Moon button. */ forceDark?: boolean + /** + * Palette to use when the user hasn't picked one with the Sun/Moon toggle + * (e.g. the host app's theme). Unlike `forceDark`, the toggle stays visible. + * Defaults to dark. + */ + defaultDark?: boolean } interface LevelOption { @@ -152,12 +158,14 @@ export function LogCore({ emptyCommand, errorMessage, forceDark, + defaultDark = true, }: LogCoreProps) { const virtuosoRef = useRef(null) const [atBottom, setAtBottom] = useState(true) const themeLocked = typeof forceDark === 'boolean' // Seed isDark: forceDark prop wins; else localStorage['radar-logs-dark']; - // else default dark. See log-palette.ts for why the viewer is palette-driven + // else defaultDark (the host app's theme, dark if not given). See + // log-palette.ts for why the viewer is palette-driven // instead of theme-token-driven. const [isDark, setIsDark] = useState(() => { if (typeof forceDark === 'boolean') return forceDark @@ -166,13 +174,26 @@ export function LogCore({ if (v === 'false') return false if (v === 'true') return true } catch {} - return true + return defaultDark }) useEffect(() => { if (typeof forceDark === 'boolean') { setIsDark(forceDark) } }, [forceDark]) + // Re-resolve whenever the host's hint changes, including when a forceDark pin + // is lifted: the user's saved pick wins, otherwise the host default. + useEffect(() => { + if (typeof forceDark === 'boolean') return + try { + const v = localStorage.getItem('radar-logs-dark') + if (v === 'true' || v === 'false') { + setIsDark(v === 'true') + return + } + } catch {} + setIsDark(defaultDark) + }, [defaultDark, forceDark]) const palette = useMemo(() => getLogPalette(isDark), [isDark]) const toggleDark = useCallback(() => { if (themeLocked) return diff --git a/packages/k8s-ui/src/components/logs/LogsViewer.tsx b/packages/k8s-ui/src/components/logs/LogsViewer.tsx index 6e0368ac5b..c6ac52fdcb 100644 --- a/packages/k8s-ui/src/components/logs/LogsViewer.tsx +++ b/packages/k8s-ui/src/components/logs/LogsViewer.tsx @@ -28,8 +28,10 @@ export interface LogsViewerProps { createStream?: (params: Omit) => EventSource /** Override the download mechanism (e.g. for desktop apps where blob URLs fail). */ overrideDownload?: (content: string, mime: string, filename: string) => void - /** Force dark mode on the logs container (default: true) */ + /** Pin the logs container to dark (true) or light (false) and hide the toggle */ forceDark?: boolean + /** Palette used until the user toggles it, e.g. the app theme (default: true) */ + defaultDark?: boolean /** * Open the stream automatically on mount (and on container switch) instead of * loading a static snapshot. The user can still Stop, and a manual Stop is not @@ -47,6 +49,7 @@ export function LogsViewer({ createStream, overrideDownload, forceDark, + defaultDark, autoStream = false, }: LogsViewerProps) { const [selectedContainer, setSelectedContainer] = useState(initialContainer || containers[0] || '') @@ -190,6 +193,7 @@ export function LogsViewer({ onClear={clear} toolbarExtra={renderToolbarExtra} forceDark={forceDark} + defaultDark={defaultDark} /> ) } diff --git a/packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx b/packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx index 2f8fe643b9..44dce73e23 100644 --- a/packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx +++ b/packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx @@ -53,8 +53,10 @@ export interface WorkloadLogsViewerProps { createStream?: (params: WorkloadLogsFetchParams) => EventSource /** Override the download mechanism (e.g. for desktop apps where blob URLs fail). */ overrideDownload?: (content: string, mime: string, filename: string) => void - /** Force dark mode on the logs container (default: true) */ + /** Pin the logs container to dark (true) or light (false) and hide the toggle */ forceDark?: boolean + /** Palette used until the user toggles it, e.g. the app theme (default: true) */ + defaultDark?: boolean /** * Open the stream automatically on mount (and on container switch) instead of * loading a static snapshot. The user can still Stop, and a manual Stop is not @@ -63,7 +65,7 @@ export interface WorkloadLogsViewerProps { autoStream?: boolean } -export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, autoStream = false }: WorkloadLogsViewerProps) { +export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false }: WorkloadLogsViewerProps) { const [selectedContainer, setSelectedContainer] = useState('') const [pods, setPods] = useState([]) const [selectedPods, setSelectedPods] = useState>(new Set()) @@ -400,6 +402,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl emptyCommand={emptyCommand} errorMessage={entries.length === 0 ? fetchError || streamError : null} forceDark={forceDark} + defaultDark={defaultDark} /> ) } diff --git a/web/src/components/logs/LogsViewer.tsx b/web/src/components/logs/LogsViewer.tsx index a7ecc290ed..82e2835bfe 100644 --- a/web/src/components/logs/LogsViewer.tsx +++ b/web/src/components/logs/LogsViewer.tsx @@ -43,7 +43,10 @@ export function LogsViewer({ namespace, podName, containers, initialContainer, a fetchLogs={fetchLogs} createStream={makeStream} overrideDownload={desktopDownload} + // Light logs on a dark app are never wanted: dark theme pins the palette, + // light theme only sets where it starts and leaves the toggle available. forceDark={theme === 'dark' ? true : undefined} + defaultDark={false} autoStream={autoStream} /> ) diff --git a/web/src/components/logs/WorkloadLogsViewer.tsx b/web/src/components/logs/WorkloadLogsViewer.tsx index 72bd2477f2..9ba745a031 100644 --- a/web/src/components/logs/WorkloadLogsViewer.tsx +++ b/web/src/components/logs/WorkloadLogsViewer.tsx @@ -42,7 +42,10 @@ export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true, s fetchAll={fetchAll} createStream={snapshotOnly ? undefined : makeStream} overrideDownload={desktopDownload} + // Light logs on a dark app are never wanted: dark theme pins the palette, + // light theme only sets where it starts and leaves the toggle available. forceDark={theme === 'dark' ? true : undefined} + defaultDark={false} autoStream={autoStream} /> )