diff --git a/frontend/index.html b/frontend/index.html index 8b6de84..9732800 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,23 @@ OneAgent — every agent, one clear lane + +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ee708d3..4114f88 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }) { @@ -78,12 +79,14 @@ function WorkspaceRoutes() { export default function App() { return ( - - - - - - - + + + + + + + + + ); } diff --git a/frontend/src/components/NavigationSidebar.tsx b/frontend/src/components/NavigationSidebar.tsx index 40ceb9d..98b1c48 100644 --- a/frontend/src/components/NavigationSidebar.tsx +++ b/frontend/src/components/NavigationSidebar.tsx @@ -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 @@ -37,6 +38,10 @@ export function NavigationSidebar() { ))} + {/* First of the bottom group, so its margin-top: auto pushes appearance, + language and the task centre down together. */} + + - {/* Last child, so the language picker's margin-top: auto pushes both to - the bottom of the sidebar as one group. */} ); diff --git a/frontend/src/components/ThemePicker.tsx b/frontend/src/components/ThemePicker.tsx new file mode 100644 index 0000000..733a5b0 --- /dev/null +++ b/frontend/src/components/ThemePicker.tsx @@ -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 = { + 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 ( + + ); +} diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 7b418c2..4c65c2b 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -7,6 +7,10 @@ const english = { "激活环境": "Environment", "配置模板": "Profiles", "语言": "Language", + "外观": "Appearance", + "跟随系统": "System", + "浅色": "Light", + "深色": "Dark", "返回": "Back", "返回总览": "Back to overview", "继续": "Continue", diff --git a/frontend/src/state/ThemeContext.test.tsx b/frontend/src/state/ThemeContext.test.tsx new file mode 100644 index 0000000..5be4e11 --- /dev/null +++ b/frontend/src/state/ThemeContext.test.tsx @@ -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( + + + + + , + ); + 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("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/); + } + }); +}); diff --git a/frontend/src/state/ThemeContext.tsx b/frontend/src/state/ThemeContext.tsx new file mode 100644 index 0000000..0acaa4a --- /dev/null +++ b/frontend/src/state/ThemeContext.tsx @@ -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 ; 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 . + * + * "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(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(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 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(`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 {children}; +} + +export function useTheme(): ThemeContextValue { + const value = useContext(ThemeContext); + if (!value) { + throw new Error("useTheme must be used inside ThemeProvider"); + } + return value; +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 4a029eb..a543854 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -101,9 +101,16 @@ text-transform: uppercase; } +/* The appearance and language rows are one group. `margin-top: auto` sits on the + first of them so the pair is pushed to the bottom together; putting it on the + language row would let the theme row float up on its own. */ +.theme-picker { + margin-top: auto; +} + +.theme-picker, .language-picker { min-height: 38px; - margin-top: auto; padding: 0 8px; display: flex; align-items: center; @@ -112,6 +119,21 @@ font-size: 12px; } +.theme-picker span { + flex: 1; +} + +.theme-picker select { + width: 82px; + min-width: 0; + padding: 5px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--window-bg); + color: var(--text-primary); + font: inherit; +} + .language-picker span { flex: 1; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index f2adf26..4c035db 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -37,8 +37,20 @@ --footer-height: 68px; } +/* The dark palette is declared twice below, once per selector, because CSS has + no way to share a declaration list between a media query and a class. The two + blocks must stay in sync. + + `.theme-light` opts out of the media query, so forcing light keeps the light + palette on a dark desktop. + + Only colours belong here. The sizing tokens (--radius-*, --sidebar-width, + --footer-height) live in :root above and must stay there: duplicating them + would mean every future change had to be made twice, and forcing light mode + would drop whichever copy was missed. */ @media (prefers-color-scheme: dark) { - :root { + /* Skipped when the user forced light: the class wins over the desktop. */ + :root:not(.theme-light) { --page-bg: #151517; --window-bg: #1e1e20; --sidebar-bg: rgba(24, 24, 26, 0.94); @@ -68,3 +80,34 @@ --shadow-window: 0 22px 55px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35); } } + +/* An explicit choice, independent of the desktop setting. */ +:root.theme-dark { + --page-bg: #151517; + --window-bg: #1e1e20; + --sidebar-bg: rgba(24, 24, 26, 0.94); + --surface-subtle: #2c2c2e; + --surface-pressed: #3a3a3c; + --text-primary: #f5f5f7; + --text-secondary: #98989d; + --text-tertiary: #6e6e73; + --border: rgba(255, 255, 255, 0.14); + --border-strong: rgba(255, 255, 255, 0.22); + --border-faint: rgba(255, 255, 255, 0.08); + --icon-border: rgba(255, 255, 255, 0.12); + --icon-fg: #d1d1d6; + --footer-bg: rgba(28, 28, 30, 0.96); + --overlay-panel: rgba(0, 0, 0, 0.25); + --blue: #0a84ff; + --blue-hover: #409cff; + --blue-soft: rgba(10, 132, 255, 0.16); + --green: #30d158; + --green-soft: #1e3726; + --orange: #ff9f0a; + --orange-soft: #3a2c12; + --red: #ff453a; + --red-soft: #3a1213; + --info: #7cc4ff; + --info-soft: #12283a; + --shadow-window: 0 22px 55px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35); +}