Skip to content
Merged
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
17 changes: 17 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
<meta name="theme-color" content="#151517" media="(prefers-color-scheme: dark)" />
<meta name="description" content="OneAgent is the calm control plane for coding agents, providers, models, and profiles." />
<title>OneAgent — every agent, one clear lane</title>
<!--
Runs before the first paint so a forced theme does not flash the other
palette while React mounts. Kept inline and dependency-free for that
reason; ThemeContext applies the same two classes once it takes over.
-->
<script>
(function () {
try {
var stored = window.localStorage.getItem("oneagent.theme");
if (stored === "dark" || stored === "light") {
document.documentElement.classList.add("theme-" + stored);
}
} catch (error) {
/* Storage barred: the media query in tokens.css still applies. */
}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
17 changes: 10 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ProvidersPage } from "./pages/ProvidersPage";
import { ReviewPage } from "./pages/ReviewPage";
import { I18nProvider, useI18n } from "./i18n";
import { TaskCenterProvider } from "./state/TaskCenterContext";
import { ThemeProvider } from "./state/ThemeContext";
import { WizardProvider, useWizard } from "./state/WizardContext";

function SetupGuard({ stage, children }: { stage: "provider" | "model" | "review" | "activation"; children: React.ReactNode }) {
Expand Down Expand Up @@ -78,12 +79,14 @@ function WorkspaceRoutes() {

export default function App() {
return (
<I18nProvider>
<TaskCenterProvider>
<WizardProvider>
<WorkspaceRoutes />
</WizardProvider>
</TaskCenterProvider>
</I18nProvider>
<ThemeProvider>
<I18nProvider>
<TaskCenterProvider>
<WizardProvider>
<WorkspaceRoutes />
</WizardProvider>
</TaskCenterProvider>
</I18nProvider>
</ThemeProvider>
);
}
7 changes: 5 additions & 2 deletions frontend/src/components/NavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NavLink } from "react-router-dom";

import { type TranslationKey, useI18n } from "../i18n";
import { TaskCenter } from "./TaskCenter";
import { ThemePicker } from "./ThemePicker";

// Only real destinations belong here. /setup/* are wizard steps behind
// SetupGuard: listing them made the sidebar look broken, because clicking one
Expand Down Expand Up @@ -37,6 +38,10 @@ export function NavigationSidebar() {
))}
</nav>

{/* First of the bottom group, so its margin-top: auto pushes appearance,
language and the task centre down together. */}
<ThemePicker />

<label className="language-picker">
<Languages size={16} aria-hidden="true" />
<span>{t("语言")}</span>
Expand All @@ -50,8 +55,6 @@ export function NavigationSidebar() {
</select>
</label>

{/* Last child, so the language picker's margin-top: auto pushes both to
the bottom of the sidebar as one group. */}
<TaskCenter />
</aside>
);
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/components/ThemePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Moon, Sun, SunMoon } from "lucide-react";

import { useI18n } from "../i18n";
import { type ThemePreference, useTheme } from "../state/ThemeContext";

const icons: Record<ThemePreference, typeof Sun> = {
system: SunMoon,
light: Sun,
dark: Moon,
};

/**
* The appearance setting, shaped like the language picker beside it.
*
* A select rather than a two-state switch: "system" is one of three real
* choices, and a toggle cannot express it. The icon reflects what is in force,
* so "system" shows the sun or the moon once resolved rather than staying
* ambiguous.
*/
export function ThemePicker() {
const { t } = useI18n();
const { preference, setPreference, resolved } = useTheme();
const Icon = preference === "system" ? icons[resolved] : icons[preference];

return (
<label className="theme-picker">
<Icon size={16} aria-hidden="true" />
<span>{t("外观")}</span>
<select
value={preference}
onChange={(event) => setPreference(event.target.value as ThemePreference)}
aria-label={t("外观")}
>
<option value="system">{t("跟随系统")}</option>
<option value="light">{t("浅色")}</option>
<option value="dark">{t("深色")}</option>
</select>
</label>
);
}
4 changes: 4 additions & 0 deletions frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const english = {
"激活环境": "Environment",
"配置模板": "Profiles",
"语言": "Language",
"外观": "Appearance",
"跟随系统": "System",
"浅色": "Light",
"深色": "Dark",
"返回": "Back",
"返回总览": "Back to overview",
"继续": "Continue",
Expand Down
140 changes: 140 additions & 0 deletions frontend/src/state/ThemeContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { I18nProvider, LOCALE_STORAGE_KEY } from "../i18n";
import { ThemePicker } from "../components/ThemePicker";
import { THEME_STORAGE_KEY, ThemeProvider, storedPreference } from "./ThemeContext";

/** matchMedia is absent in jsdom; each test declares what the desktop reports. */
function stubSystem(dark: boolean) {
const listeners = new Set<(event: MediaQueryListEvent) => void>();
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: dark,
addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener),
})),
);
return (next: boolean) => listeners.forEach((listener) => listener({ matches: next } as MediaQueryListEvent));
}

function mount() {
render(
<ThemeProvider>
<I18nProvider>
<ThemePicker />
</I18nProvider>
</ThemeProvider>,
);
return screen.getByRole("combobox", { name: "外观" });
}

const classes = () => document.documentElement.className;

beforeEach(() => {
localStorage.clear();
// jsdom reports navigator.language as en-US, so pin the locale rather than
// assert against whichever one the host implies.
localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN");
document.documentElement.className = "";
vi.unstubAllGlobals();
});

describe("ThemeProvider", () => {
it("carries no class while following the system", () => {
// No class is what lets the media query in tokens.css decide. A class for
// "system" would pin the palette and defeat the whole point.
stubSystem(true);
mount();
expect(classes()).toBe("");
});

it("forces a palette independently of the desktop", async () => {
stubSystem(true);
const select = mount();
await userEvent.selectOptions(select, "light");
// theme-light on a dark desktop is the case that needs the :not() in the
// media query, otherwise the dark block still wins.
expect(document.documentElement.classList.contains("theme-light")).toBe(true);
expect(document.documentElement.classList.contains("theme-dark")).toBe(false);
});

it("persists the choice", async () => {
stubSystem(false);
await userEvent.selectOptions(mount(), "dark");
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
expect(storedPreference()).toBe("dark");
});

it("follows a live system change only while set to system", () => {
const flip = stubSystem(false);
mount();
flip(true);
// Still no class: "system" delegates to CSS rather than mirroring the state.
expect(classes()).toBe("");
});

it("keeps an explicit choice when the desktop flips", async () => {
const flip = stubSystem(false);
await userEvent.selectOptions(mount(), "light");
flip(true);
expect(document.documentElement.classList.contains("theme-light")).toBe(true);
});

it("returns to the system palette when asked", async () => {
stubSystem(true);
const select = mount();
await userEvent.selectOptions(select, "dark");
await userEvent.selectOptions(select, "system");
expect(classes()).toBe("");
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe("system");
});

it("overrides the media-driven window colour", async () => {
// index.html's two theme-color tags are media-driven and cannot see a forced
// palette, so the window chrome would keep the desktop's colour.
stubSystem(false);
await userEvent.selectOptions(mount(), "dark");
expect(document.head.querySelector<HTMLMetaElement>("meta#theme-color-resolved")?.content).toBe("#151517");
});

it("treats a barred storage as no stored choice", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("denied");
});
// Hardened webviews reject storage; the app must still render.
expect(storedPreference()).toBe("system");
vi.restoreAllMocks();
});

it("ignores a stored value that is not a preference", () => {
localStorage.setItem(THEME_STORAGE_KEY, "chartreuse");
expect(storedPreference()).toBe("system");
});
});

describe("tokens.css", () => {
it("declares the same dark variables for the media query and the class", () => {
// CSS cannot share a declaration list between a media query and a class, so
// the palette is written twice. This is the guard against the two drifting.
const css = readFileSync(join(process.cwd(), "src/styles/tokens.css"), "utf8");
const blocks = [...css.matchAll(/:root(?::not\(\.theme-light\)|\.theme-dark)\s*\{([^}]*)\}/g)];
expect(blocks).toHaveLength(2);
const variables = blocks.map((block) =>
[...block[1].matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((match) => `${match[1]}:${match[2].trim()}`).sort(),
);
expect(variables[0]).toEqual(variables[1]);
});

it("keeps sizing tokens out of the dark blocks", () => {
// Repeating them there would drop them whenever light is forced.
const css = readFileSync(join(process.cwd(), "src/styles/tokens.css"), "utf8");
for (const block of css.matchAll(/:root(?::not\(\.theme-light\)|\.theme-dark)\s*\{([^}]*)\}/g)) {
expect(block[1]).not.toMatch(/--radius-|--sidebar-width|--footer-height/);
}
});
});
114 changes: 114 additions & 0 deletions frontend/src/state/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from "react";

/**
* The appearance setting.
*
* Three states, not two: "system" is a real choice, not the absence of one. A
* two-state toggle would have to guess what "off" means the moment the desktop
* flips, and a user who wants to track their desktop has no way to say so.
*
* The resolved palette lives in CSS (styles/tokens.css). This only decides which
* class sits on <html>; no colour values belong here.
*/
export type ThemePreference = "system" | "light" | "dark";

export const THEME_STORAGE_KEY = "oneagent.theme";

function isPreference(value: unknown): value is ThemePreference {
return value === "system" || value === "light" || value === "dark";
}

/** The stored choice, or "system" when nothing was stored or storage is barred. */
export function storedPreference(): ThemePreference {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
if (isPreference(saved)) return saved;
} catch {
// Storage can be unavailable in hardened webviews; the system preference
// still applies, it just cannot be overridden across restarts.
}
return "system";
}

/**
* Put the preference on <html>.
*
* "system" carries no class at all, which is what lets the media query in
* tokens.css decide. Exported so the pre-hydration script and the provider apply
* it exactly the same way.
*/
export function applyPreference(preference: ThemePreference): void {
const root = document.documentElement;
root.classList.toggle("theme-dark", preference === "dark");
root.classList.toggle("theme-light", preference === "light");
}

interface ThemeContextValue {
preference: ThemePreference;
setPreference: (preference: ThemePreference) => void;
/** What the preference resolves to right now, for anything that needs the answer rather than the setting. */
resolved: "light" | "dark";
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

function systemPrefersDark(): boolean {
return typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches === true;
}

export function ThemeProvider({ children }: PropsWithChildren) {
const [preference, setPreferenceState] = useState<ThemePreference>(storedPreference);
const [systemDark, setSystemDark] = useState(systemPrefersDark);

// A desktop change still moves the app while the preference is "system". The
// listener runs regardless of the current preference so switching back to
// "system" already has the right answer.
useEffect(() => {
const query = window.matchMedia?.("(prefers-color-scheme: dark)");
if (!query) return;
const onChange = (event: MediaQueryListEvent) => setSystemDark(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);

const resolved: "light" | "dark" = preference === "system" ? (systemDark ? "dark" : "light") : preference;

useEffect(() => {
applyPreference(preference);
}, [preference]);

// The two media-driven <meta name="theme-color"> tags in index.html cannot see
// a forced palette, so the window chrome would keep the desktop's colour while
// the content switched. One resolved tag overrides both.
useEffect(() => {
const id = "theme-color-resolved";
let tag = document.head.querySelector<HTMLMetaElement>(`meta#${id}`);
if (!tag) {
tag = document.createElement("meta");
tag.id = id;
tag.name = "theme-color";
document.head.append(tag);
}
tag.content = resolved === "dark" ? "#151517" : "#f5f5f7";
}, [resolved]);

const setPreference = useCallback((next: ThemePreference) => {
setPreferenceState(next);
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// In-memory only, as with the locale: the choice holds for this session.
}
}, []);

const value = useMemo(() => ({ preference, setPreference, resolved }), [preference, setPreference, resolved]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme(): ThemeContextValue {
const value = useContext(ThemeContext);
if (!value) {
throw new Error("useTheme must be used inside ThemeProvider");
}
return value;
}
Loading