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
4 changes: 2 additions & 2 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import type { Preview } from "@storybook/react-vite";
import { ThemeProvider, type ThemeMode } from "../src/browser/contexts/ThemeContext";
import { ThemeProvider, type ResolvedThemeMode } from "../src/browser/contexts/ThemeContext";
import "../src/browser/styles/globals.css";
import {
TUTORIAL_STATE_KEY,
Expand Down Expand Up @@ -91,7 +91,7 @@ const preview: Preview = {
// Theme provider
(Story, context) => {
// Default to dark if mode not set (e.g., Chromatic headless browser defaults to light)
const mode = (context.globals.theme as ThemeMode | undefined) ?? "dark";
const mode = (context.globals.theme as ResolvedThemeMode | undefined) ?? "dark";

// Apply theme synchronously before React renders - critical for Chromatic snapshots
if (typeof document !== "undefined") {
Expand Down
36 changes: 13 additions & 23 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@
<script>
(function () {
const THEME_KEY = "uiTheme";
const VALID_THEMES = new Set([
const VALID_PREFERENCES = new Set([
"system",
"light",
"dark",
"flexoki-light",
"flexoki-dark",
]);

const THEME_COLORS = {
dark: "#1e1e1e",
light: "#f5f6f8",
Expand All @@ -121,7 +121,7 @@
};

function getColorScheme(theme) {
return theme === "light" || theme.endsWith("-light") ? "light" : "dark";
return theme === "light" || theme === "flexoki-light" ? "light" : "dark";
}

try {
Expand All @@ -137,34 +137,24 @@
}
}

const prefersLight = window.matchMedia
? window.matchMedia("(prefers-color-scheme: light)").matches
: false;

const fallbackTheme = prefersLight ? "light" : "dark";
let theme = fallbackTheme;

if (typeof parsed === "string") {
if (VALID_THEMES.has(parsed)) {
theme = parsed;
} else if (parsed.endsWith("-light")) {
theme = "light";
} else if (parsed.endsWith("-dark")) {
theme = "dark";
}
}
// Normalize preference (unknown → "system")
const preference =
typeof parsed === "string" && VALID_PREFERENCES.has(parsed) ? parsed : "system";

// Resolve system preference to actual theme
const systemTheme =
window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
const theme = preference === "system" ? systemTheme : preference;
const colorScheme = getColorScheme(theme);

document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = colorScheme;

const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute(
"content",
THEME_COLORS[theme] ?? THEME_COLORS[colorScheme] ?? "#1e1e1e"
);
metaThemeColor.setAttribute("content", THEME_COLORS[theme] ?? "#1e1e1e");
}
} catch (error) {
console.warn("Failed to apply preferred theme early", error);
Expand Down
10 changes: 2 additions & 8 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { useStableReference, compareMaps } from "./hooks/useStableReference";
import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext";
import { useOpenTerminal } from "./hooks/useOpenTerminal";
import type { CommandAction } from "./contexts/CommandRegistryContext";
import { useTheme, type ThemeMode } from "./contexts/ThemeContext";
import { useTheme } from "./contexts/ThemeContext";
import { CommandPalette } from "./components/CommandPalette";
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";

Expand Down Expand Up @@ -77,12 +77,6 @@ function AppInner() {
} = useWorkspaceContext();
const { theme, setTheme, toggleTheme } = useTheme();
const { open: openSettings } = useSettings();
const setThemePreference = useCallback(
(nextTheme: ThemeMode) => {
setTheme(nextTheme);
},
[setTheme]
);
const { api, status, error, authenticate } = useAPI();

const {
Expand Down Expand Up @@ -455,7 +449,7 @@ function AppInner() {
});
},
onToggleTheme: toggleTheme,
onSetTheme: setThemePreference,
onSetTheme: setTheme,
onOpenSettings: openSettings,
onClearTimingStats: (workspaceId: string) => workspaceStore.clearTimingStats(workspaceId),
api,
Expand Down
6 changes: 3 additions & 3 deletions src/browser/components/Messages/MarkdownComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ interface CodeBlockProps {
*/
const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
const { theme: themeMode } = useTheme();
const { resolvedTheme } = useTheme();

// Split code into lines, removing trailing empty line
const plainLines = code
Expand All @@ -51,7 +51,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {

useEffect(() => {
let cancelled = false;
const isLight = themeMode === "light" || themeMode.endsWith("-light");
const isLight = resolvedTheme === "light" || resolvedTheme.endsWith("-light");
const theme = isLight ? "light" : "dark";

setHighlightedLines(null);
Expand All @@ -78,7 +78,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
return () => {
cancelled = true;
};
}, [code, language, themeMode]);
}, [code, language, resolvedTheme]);

const lines = highlightedLines ?? plainLines;
const isSingleLine = lines.length === 1;
Expand Down
5 changes: 3 additions & 2 deletions src/browser/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,9 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
} = useProjectContext();

// Theme for logo variant
const { theme } = useTheme();
const MuxLogo = theme === "dark" || theme.endsWith("-dark") ? MuxLogoDark : MuxLogoLight;
const { resolvedTheme } = useTheme();
const MuxLogo =
resolvedTheme === "dark" || resolvedTheme.endsWith("-dark") ? MuxLogoDark : MuxLogoLight;

// Mobile breakpoint for auto-closing sidebar
const MOBILE_BREAKPOINT = 768;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ function buildUnifiedView(content: string, diffText: string): UnifiedLine[] | nu
}

export const TextFileViewer: React.FC<TextFileViewerProps> = (props) => {
const { theme: themeMode } = useTheme();
const { resolvedTheme } = useTheme();
const language = getLanguageFromPath(props.filePath);
const languageDisplayName = getLanguageDisplayName(language);

Expand All @@ -160,7 +160,7 @@ export const TextFileViewer: React.FC<TextFileViewerProps> = (props) => {
? unifiedLines.map((l) => l.content)
: fileLines.filter((l, i, arr) => i < arr.length - 1 || l !== "");

const theme = themeMode === "light" || themeMode.endsWith("-light") ? "light" : "dark";
const theme = resolvedTheme === "light" || resolvedTheme.endsWith("-light") ? "light" : "dark";

let cancelled = false;

Expand All @@ -181,7 +181,7 @@ export const TextFileViewer: React.FC<TextFileViewerProps> = (props) => {
return () => {
cancelled = true;
};
}, [unifiedLines, fileLines, language, themeMode]);
}, [unifiedLines, fileLines, language, resolvedTheme]);

const addedCount = unifiedLines?.filter((l) => l.type === "added").length ?? 0;
const removedCount = unifiedLines?.filter((l) => l.type === "removed").length ?? 0;
Expand Down
4 changes: 2 additions & 2 deletions src/browser/components/ThemeToggleButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { useTheme } from "@/browser/contexts/ThemeContext";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";

export function ThemeToggleButton() {
const { theme, toggleTheme } = useTheme();
const isLightTheme = theme === "light" || theme.endsWith("-light");
const { resolvedTheme, toggleTheme } = useTheme();
const isLightTheme = resolvedTheme === "light" || resolvedTheme === "flexoki-light";
const label = isLightTheme ? "Switch to dark theme" : "Switch to light theme";
const Icon = isLightTheme ? MoonStar : SunMedium;

Expand Down
14 changes: 10 additions & 4 deletions src/browser/components/shared/DiffRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,13 +500,19 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
className,
}) => {
// Detect language for syntax highlighting (memoized to prevent repeated detection)
const { theme } = useTheme();
const { resolvedTheme } = useTheme();
const language = React.useMemo(
() => (filePath ? getLanguageFromPath(filePath) : "text"),
[filePath]
);

const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart, theme);
const highlightedChunks = useHighlightedDiff(
content,
language,
oldStart,
newStart,
resolvedTheme
);

const lineNumberWidths = React.useMemo(() => {
if (!showLineNumbers || !highlightedChunks) {
Expand Down Expand Up @@ -866,7 +872,7 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
window.removeEventListener("blur", stopDragging);
};
}, []);
const { theme } = useTheme();
const { resolvedTheme } = useTheme();
const [selection, setSelection] = React.useState<LineSelection | null>(null);

// Notify parent when composition state changes
Expand Down Expand Up @@ -895,7 +901,7 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
enableHighlighting ? language : "text",
oldStart,
newStart,
theme
resolvedTheme
);

// Parse raw lines once for use in lineData
Expand Down
6 changes: 3 additions & 3 deletions src/browser/components/tools/shared/HighlightedCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({
startLineNumber = 1,
}) => {
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
const { theme: themeMode } = useTheme();
const { resolvedTheme } = useTheme();

const plainLines = code.split("\n").filter((line, i, arr) => i < arr.length - 1 || line !== "");

useEffect(() => {
let cancelled = false;
const theme = themeMode === "light" || themeMode.endsWith("-light") ? "light" : "dark";
const theme = resolvedTheme === "light" || resolvedTheme.endsWith("-light") ? "light" : "dark";

setHighlightedLines(null);

Expand All @@ -53,7 +53,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({
return () => {
cancelled = true;
};
}, [code, language, themeMode]);
}, [code, language, resolvedTheme]);

const lines = highlightedLines ?? plainLines;

Expand Down
43 changes: 12 additions & 31 deletions src/browser/contexts/ThemeContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,12 @@ import { UI_THEME_KEY } from "@/common/constants/storage";

// Helper to access internals
const TestComponent = () => {
const { theme, toggleTheme } = useTheme();
const { theme, resolvedTheme, toggleTheme } = useTheme();
return (
<div>
<span data-testid="theme-value">{theme}</span>
<button onClick={toggleTheme} data-testid="toggle-btn">
Toggle
</button>
<span data-testid="theme">{theme}</span>
<span data-testid="resolved">{resolvedTheme}</span>
<button onClick={toggleTheme}>Toggle</button>
</div>
);
};
Expand Down Expand Up @@ -66,44 +65,26 @@ describe("ThemeContext", () => {
}
});

test("uses persisted state by default", () => {
test("defaults to system theme", () => {
const { getByTestId } = render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
// If matchMedia matches is false (default mock), resolveSystemTheme returns 'dark' (since it checks prefers-color-scheme: light)
// resolveSystemTheme logic: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
expect(getByTestId("theme-value").textContent).toBe("dark");
// Default is "system", which resolves to "dark" when matchMedia returns false for prefers-color-scheme: light
expect(getByTestId("theme").textContent).toBe("system");
expect(getByTestId("resolved").textContent).toBe("dark");
});

test("respects forcedTheme prop", () => {
const { getByTestId, rerender } = render(
<ThemeProvider forcedTheme="light">
<TestComponent />
</ThemeProvider>
);
expect(getByTestId("theme-value").textContent).toBe("light");

rerender(
<ThemeProvider forcedTheme="dark">
<TestComponent />
</ThemeProvider>
);
expect(getByTestId("theme-value").textContent).toBe("dark");
});

test("forcedTheme overrides persisted state", () => {
test("forcedTheme overrides resolved theme without changing preference", () => {
window.localStorage.setItem(UI_THEME_KEY, JSON.stringify("light"));

const { getByTestId } = render(
<ThemeProvider forcedTheme="dark">
<TestComponent />
</ThemeProvider>
);
expect(getByTestId("theme-value").textContent).toBe("dark");

// Check that localStorage is still light (since forcedTheme doesn't write to storage by itself)
expect(JSON.parse(window.localStorage.getItem(UI_THEME_KEY)!)).toBe("light");
// Preference unchanged, but resolved theme is forced
expect(getByTestId("theme").textContent).toBe("light");
expect(getByTestId("resolved").textContent).toBe("dark");
});
});
Loading
Loading