mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
feat: add dark mode support to web UI (#1436)
* feat: add dark mode support to web UI Add a persisted, system-aware theme with a Light/Dark/System toggle in the sidebar user menu. An inline head script applies the stored theme before paint to avoid a flash, and the agents-ui surface gets dark overrides for its --ui-* variables. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: use static theme init script to satisfy CodeQL Build the no-FOUC theme script from a fully static string literal instead of interpolating THEME_STORAGE_KEY, so no value flows into code construction (CodeQL "Improper code sanitization"). Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: avoid theme flip on hydration for persisted preferences Only apply a theme after the stored preference is read, instead of eagerly applying the default "system" theme on first effect. This prevented a brief flip to the OS theme (then back) on load for users with a saved preference that differs from their OS setting. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
072c0158ff
commit
30d5d55f0f
4 changed files with 146 additions and 1 deletions
|
|
@ -1,10 +1,24 @@
|
|||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IoLogOutOutline, IoSettingsOutline } from "react-icons/io5";
|
||||
import {
|
||||
IoDesktopOutline,
|
||||
IoLogOutOutline,
|
||||
IoMoonOutline,
|
||||
IoSettingsOutline,
|
||||
IoSunnyOutline,
|
||||
} from "react-icons/io5";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { api, type SessionUser } from "@/lib/api";
|
||||
import { useTheme, type Theme } from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const THEME_OPTIONS: Array<{ value: Theme; label: string; icon: typeof IoSunnyOutline }> = [
|
||||
{ value: "light", label: "Light", icon: IoSunnyOutline },
|
||||
{ value: "dark", label: "Dark", icon: IoMoonOutline },
|
||||
{ value: "system", label: "System", icon: IoDesktopOutline },
|
||||
];
|
||||
|
||||
interface SidebarUserMenuProps {
|
||||
user: SessionUser;
|
||||
|
|
@ -14,6 +28,7 @@ interface SidebarUserMenuProps {
|
|||
export function SidebarUserMenu({ user, showSettingsLink = false }: SidebarUserMenuProps) {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
|
@ -67,6 +82,35 @@ export function SidebarUserMenu({ user, showSettingsLink = false }: SidebarUserM
|
|||
role="menu"
|
||||
className="absolute right-0 bottom-full left-0 mb-2 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Theme
|
||||
</span>
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1">
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = theme === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(option.value)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-sm border px-1 py-1.5 text-[10px] transition-colors",
|
||||
active
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-transparent text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
{showSettingsLink && (
|
||||
<Link
|
||||
to="/my-settings"
|
||||
|
|
|
|||
75
ui/src/lib/theme.ts
Normal file
75
ui/src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
export type ResolvedTheme = "light" | "dark"
|
||||
|
||||
export const THEME_STORAGE_KEY = "open-swe-theme"
|
||||
|
||||
function isTheme(value: string | null): value is Theme {
|
||||
return value === "light" || value === "dark" || value === "system"
|
||||
}
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
if (typeof window === "undefined") return "system"
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||
return isTheme(stored) ? stored : "system"
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
if (theme === "system") return systemPrefersDark() ? "dark" : "light"
|
||||
return theme
|
||||
}
|
||||
|
||||
function applyTheme(resolved: ResolvedTheme) {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
root.classList.toggle("dark", resolved === "dark")
|
||||
root.style.colorScheme = resolved
|
||||
}
|
||||
|
||||
/** Theme state with system detection, persistence, and `.dark` class syncing. */
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>("system")
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>("light")
|
||||
const themeRef = useRef<Theme>("system")
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredTheme()
|
||||
themeRef.current = stored
|
||||
setThemeState(stored)
|
||||
setResolvedTheme(resolveTheme(stored))
|
||||
applyTheme(resolveTheme(stored))
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const onChange = () => {
|
||||
if (themeRef.current !== "system") return
|
||||
const next = systemPrefersDark() ? "dark" : "light"
|
||||
setResolvedTheme(next)
|
||||
applyTheme(next)
|
||||
}
|
||||
media.addEventListener("change", onChange)
|
||||
return () => media.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, next)
|
||||
}
|
||||
themeRef.current = next
|
||||
const resolved = resolveTheme(next)
|
||||
setThemeState(next)
|
||||
setResolvedTheme(resolved)
|
||||
applyTheme(resolved)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setTheme])
|
||||
|
||||
return { theme, resolvedTheme, setTheme, toggleTheme }
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ import { useState } from "react"
|
|||
import appCss from "../styles.css?url"
|
||||
import { makeQueryClient } from "@/lib/query"
|
||||
|
||||
const themeInitScript = `(function(){try{var t=localStorage.getItem("open-swe-theme");var d=t==="dark"||((!t||t==="system")&&window.matchMedia("(prefers-color-scheme: dark)").matches);var r=document.documentElement;r.classList.toggle("dark",d);r.style.colorScheme=d?"dark":"light";}catch(e){}})();`
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
|
|
@ -47,6 +49,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,25 @@
|
|||
--ui-danger: oklch(0.577 0.245 27.325);
|
||||
}
|
||||
|
||||
.dark .agents-ui,
|
||||
.dark.agents-ui {
|
||||
--ui-bg: oklch(0.148 0.004 228.8);
|
||||
--ui-surface: oklch(0.218 0.008 223.9);
|
||||
--ui-panel: oklch(0.218 0.008 223.9);
|
||||
--ui-panel-2: oklch(0.274 0.006 286.033);
|
||||
--ui-border: oklch(1 0 0 / 10%);
|
||||
--ui-border-subtle: oklch(1 0 0 / 6%);
|
||||
--ui-text: oklch(0.987 0.002 197.1);
|
||||
--ui-text-muted: oklch(0.723 0.014 214.4);
|
||||
--ui-text-dim: oklch(0.56 0.021 213.5);
|
||||
--ui-accent: oklch(0.715 0.143 215.221);
|
||||
--ui-accent-bubble: oklch(0.274 0.04 223);
|
||||
--ui-sidebar: oklch(0.218 0.008 223.9);
|
||||
--ui-sidebar-hover: oklch(0.275 0.011 216.9);
|
||||
--ui-success: oklch(0.62 0.17 145);
|
||||
--ui-danger: oklch(0.704 0.191 22.216);
|
||||
}
|
||||
|
||||
.agents-ui {
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-bg);
|
||||
|
|
@ -28,6 +47,10 @@
|
|||
scrollbar-color: oklch(0.85 0.01 214) transparent;
|
||||
}
|
||||
|
||||
.dark .agents-ui * {
|
||||
scrollbar-color: oklch(0.4 0.01 214) transparent;
|
||||
}
|
||||
|
||||
.agents-ui .shimmer-text {
|
||||
background: linear-gradient(90deg, var(--ui-text-dim) 40%, var(--ui-accent) 50%, var(--ui-text-dim) 60%);
|
||||
background-size: 200% auto;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue