mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 13:53:27 +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 { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { api, type SessionUser } from "@/lib/api";
|
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 {
|
interface SidebarUserMenuProps {
|
||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
|
|
@ -14,6 +28,7 @@ interface SidebarUserMenuProps {
|
||||||
export function SidebarUserMenu({ user, showSettingsLink = false }: SidebarUserMenuProps) {
|
export function SidebarUserMenu({ user, showSettingsLink = false }: SidebarUserMenuProps) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
|
@ -67,6 +82,35 @@ export function SidebarUserMenu({ user, showSettingsLink = false }: SidebarUserM
|
||||||
role="menu"
|
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"
|
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 && (
|
{showSettingsLink && (
|
||||||
<Link
|
<Link
|
||||||
to="/my-settings"
|
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 appCss from "../styles.css?url"
|
||||||
import { makeQueryClient } from "@/lib/query"
|
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({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
|
|
@ -47,6 +49,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,25 @@
|
||||||
--ui-danger: oklch(0.577 0.245 27.325);
|
--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 {
|
.agents-ui {
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
background: var(--ui-bg);
|
background: var(--ui-bg);
|
||||||
|
|
@ -28,6 +47,10 @@
|
||||||
scrollbar-color: oklch(0.85 0.01 214) transparent;
|
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 {
|
.agents-ui .shimmer-text {
|
||||||
background: linear-gradient(90deg, var(--ui-text-dim) 40%, var(--ui-accent) 50%, var(--ui-text-dim) 60%);
|
background: linear-gradient(90deg, var(--ui-text-dim) 40%, var(--ui-accent) 50%, var(--ui-text-dim) 60%);
|
||||||
background-size: 200% auto;
|
background-size: 200% auto;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue