diff --git a/apps/web/public/wallpapers/akane-bridge.jpg b/apps/web/public/wallpapers/akane-bridge.jpg new file mode 100644 index 00000000000..1b66d2b985c Binary files /dev/null and b/apps/web/public/wallpapers/akane-bridge.jpg differ diff --git a/apps/web/public/wallpapers/akane-cliff.jpg b/apps/web/public/wallpapers/akane-cliff.jpg new file mode 100644 index 00000000000..154b2c8cfe3 Binary files /dev/null and b/apps/web/public/wallpapers/akane-cliff.jpg differ diff --git a/apps/web/public/wallpapers/akane-mist.jpg b/apps/web/public/wallpapers/akane-mist.jpg new file mode 100644 index 00000000000..889e27680aa Binary files /dev/null and b/apps/web/public/wallpapers/akane-mist.jpg differ diff --git a/apps/web/public/wallpapers/akane-pagoda.jpg b/apps/web/public/wallpapers/akane-pagoda.jpg new file mode 100644 index 00000000000..f263ebe3475 Binary files /dev/null and b/apps/web/public/wallpapers/akane-pagoda.jpg differ diff --git a/apps/web/public/wallpapers/everforest.jpg b/apps/web/public/wallpapers/everforest.jpg new file mode 100644 index 00000000000..6d75259dbb8 Binary files /dev/null and b/apps/web/public/wallpapers/everforest.jpg differ diff --git a/apps/web/public/wallpapers/gruvbox-ferns.jpg b/apps/web/public/wallpapers/gruvbox-ferns.jpg new file mode 100644 index 00000000000..fd318699368 Binary files /dev/null and b/apps/web/public/wallpapers/gruvbox-ferns.jpg differ diff --git a/apps/web/public/wallpapers/pink-lakeside.png b/apps/web/public/wallpapers/pink-lakeside.png new file mode 100644 index 00000000000..d80672889f5 Binary files /dev/null and b/apps/web/public/wallpapers/pink-lakeside.png differ diff --git a/apps/web/public/wallpapers/ship-at-sea.jpg b/apps/web/public/wallpapers/ship-at-sea.jpg new file mode 100644 index 00000000000..4375ee2bdd0 Binary files /dev/null and b/apps/web/public/wallpapers/ship-at-sea.jpg differ diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb254..215913e9d39 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -24,6 +24,18 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + colorThemeId: Schema.String.pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + backgroundImage: Schema.String.pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + backgroundOpacity: Schema.Number.pipe( + Schema.withConstructorDefault(() => Option.some(0.85)), + ), + backgroundBlur: Schema.Number.pipe( + Schema.withConstructorDefault(() => Option.some(0)), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -71,6 +83,8 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + backgroundOpacity: Math.min(1.0, Math.max(0.1, settings.backgroundOpacity)), + backgroundBlur: Math.min(32, Math.max(0, settings.backgroundBlur)), }; } diff --git a/apps/web/src/components/BackgroundLayer.tsx b/apps/web/src/components/BackgroundLayer.tsx new file mode 100644 index 00000000000..9d23463148c --- /dev/null +++ b/apps/web/src/components/BackgroundLayer.tsx @@ -0,0 +1,68 @@ +import { useEffect } from "react"; + +import { useAppSettings } from "../appSettings"; +import { getInteractiveOpacityRate, interactiveOpacity, nestedOpacity } from "../lib/colorThemes"; +import { resolveBackgroundImageUrl } from "../lib/wallpapers"; + +export function BackgroundLayer() { + const { settings } = useAppSettings(); + const { backgroundImage, backgroundOpacity, backgroundBlur, colorThemeId } = settings; + + useEffect(() => { + const root = document.documentElement; + if (backgroundImage) { + root.setAttribute("data-has-wallpaper", "true"); + + const rate = getInteractiveOpacityRate(colorThemeId || undefined); + const overlayPct = `${Math.round(backgroundOpacity * 100)}%`; + const interactivePct = `${Math.round(interactiveOpacity(backgroundOpacity, rate * 0.4) * 100)}%`; + const nestedPct = `${Math.round(nestedOpacity(backgroundOpacity, rate) * 100)}%`; + + root.style.setProperty("--wp-overlay", overlayPct); + root.style.setProperty("--wp-interactive", interactivePct); + root.style.setProperty("--wp-nested", nestedPct); + } else { + root.removeAttribute("data-has-wallpaper"); + root.style.removeProperty("--wp-overlay"); + root.style.removeProperty("--wp-interactive"); + root.style.removeProperty("--wp-nested"); + } + return () => { + root.removeAttribute("data-has-wallpaper"); + root.style.removeProperty("--wp-overlay"); + root.style.removeProperty("--wp-interactive"); + root.style.removeProperty("--wp-nested"); + }; + }, [backgroundImage, backgroundOpacity, colorThemeId]); + + if (!backgroundImage) return null; + + const imageUrl = resolveBackgroundImageUrl(backgroundImage); + const overlayPercent = Math.round(backgroundOpacity * 100); + + return ( + <> + {/* Wallpaper image layer */} +
0 ? `blur(${backgroundBlur}px)` : undefined, + }} + /> + {/* Color overlay */} +
+ + ); +} diff --git a/apps/web/src/hooks/useColorTheme.ts b/apps/web/src/hooks/useColorTheme.ts new file mode 100644 index 00000000000..bd25a91f10f --- /dev/null +++ b/apps/web/src/hooks/useColorTheme.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +import { useAppSettings } from "../appSettings"; +import { applyColorTheme } from "../lib/colorThemes"; +import { useTheme } from "./useTheme"; + +export function useColorThemeEffect(): void { + const { resolvedTheme } = useTheme(); + const { settings } = useAppSettings(); + const { colorThemeId, backgroundImage } = settings; + + useEffect(() => { + if (resolvedTheme === "dark" && colorThemeId) { + applyColorTheme(colorThemeId, !!backgroundImage); + } else { + applyColorTheme(null); + } + }, [resolvedTheme, colorThemeId, backgroundImage]); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 22817916197..91a1fb53d82 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -120,6 +120,51 @@ } } +/* + * Wallpaper transparency system + * + * BackgroundLayer sets three CSS custom properties on :root: + * --wp-overlay raw opacity (e.g. "85%") — controls base overlay + * --wp-interactive interactiveOpacity result — sidebar, inputs (stays visible) + * --wp-nested nestedOpacity result — cards, headers (subtle tint) + * + * The overlay div (z-index: -10) uses --wp-overlay. UI panels reference the + * other two so interactive elements resist transparency changes. + */ + +/* Fully transparent — let the overlay show through */ +:root[data-has-wallpaper] body, +:root[data-has-wallpaper] .bg-background, +:root[data-has-wallpaper] .bg-sidebar, +:root[data-has-wallpaper] [data-slot="sidebar-inset"], +:root[data-has-wallpaper] [data-slot="sidebar-inset"] > div, +:root[data-has-wallpaper] [data-slot="sidebar-container"] { + background-color: transparent !important; +} + +/* Interactive tier — inputs stay highly visible */ +:root[data-has-wallpaper] [data-slot="input-control"] { + background-color: color-mix(in srgb, var(--background) var(--wp-interactive, 97%), transparent) !important; +} + +/* Chat header matches sidebar tint */ +:root[data-has-wallpaper] [data-slot="sidebar-inset"] > div > header { + background-color: color-mix(in srgb, var(--card) var(--wp-nested, 34%), transparent) !important; +} + +/* Nested tier — sidebar, cards, panels, code blocks get a subtle tint */ +:root[data-has-wallpaper] [data-slot="sidebar-inner"] { + background-color: color-mix(in srgb, var(--card) var(--wp-nested, 34%), transparent) !important; +} + +:root[data-has-wallpaper] .bg-card { + background-color: color-mix(in srgb, var(--card) var(--wp-nested, 34%), transparent) !important; +} + +:root[data-has-wallpaper] .bg-muted { + background-color: color-mix(in srgb, var(--muted) var(--wp-nested, 34%), transparent) !important; +} + body { font-family: "DM Sans", diff --git a/apps/web/src/lib/colorThemes.ts b/apps/web/src/lib/colorThemes.ts new file mode 100644 index 00000000000..a43980f71fd --- /dev/null +++ b/apps/web/src/lib/colorThemes.ts @@ -0,0 +1,347 @@ +import { getAppSettingsSnapshot } from "../appSettings"; + +export interface ThemeColors { + bg: string; + bg2: string; + border: string; + accent: string; + accent2: string; + text: string; + textMuted: string; + textDim: string; + danger: string; + green: string; + yellow: string; + magenta: string; +} + +export interface ColorTheme { + id: string; + name: string; + colors: ThemeColors; + interactiveOpacityRate?: number; +} + +export const COLOR_THEMES: readonly ColorTheme[] = [ + { + id: "cyberpunk", + name: "Cyberpunk", + colors: { + bg: "#080810", + bg2: "#0c1018", + border: "#1e2d40", + accent: "#c8ff00", + accent2: "#0fc5ed", + text: "#d0e0f0", + textMuted: "#7a9ab8", + textDim: "#4a6580", + danger: "#ff2e4a", + green: "#44ffb1", + yellow: "#ffe073", + magenta: "#a277ff", + }, + }, + { + id: "josean", + name: "Josean", + interactiveOpacityRate: 0.7, + colors: { + bg: "#011423", + bg2: "#01101c", + border: "#033259", + accent: "#47ff9c", + accent2: "#0fc5ed", + text: "#cbe0f0", + textMuted: "#7a9ab8", + textDim: "#4a6580", + danger: "#e52e2e", + green: "#44ffb1", + yellow: "#ffe073", + magenta: "#a277ff", + }, + }, + { + id: "tokyo-night", + name: "Tokyo Night", + colors: { + bg: "#1a1b26", + bg2: "#16161e", + border: "#292e42", + accent: "#7aa2f7", + accent2: "#2ac3de", + text: "#c0caf5", + textMuted: "#7982a8", + textDim: "#565d80", + danger: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + magenta: "#bb9af7", + }, + }, + { + id: "dracula", + name: "Dracula", + colors: { + bg: "#282a36", + bg2: "#21222c", + border: "#44475a", + accent: "#bd93f9", + accent2: "#8be9fd", + text: "#f8f8f2", + textMuted: "#8893b8", + textDim: "#626580", + danger: "#ff5555", + green: "#50fa7b", + yellow: "#f1fa8c", + magenta: "#ff79c6", + }, + }, + { + id: "catppuccin-mocha", + name: "Catppuccin Mocha", + colors: { + bg: "#1e1e2e", + bg2: "#181825", + border: "#313244", + accent: "#cba6f7", + accent2: "#89dceb", + text: "#cdd6f4", + textMuted: "#8f93a8", + textDim: "#626478", + danger: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + magenta: "#f5c2e7", + }, + }, + { + id: "gruvbox-dark", + name: "Gruvbox Dark", + colors: { + bg: "#282828", + bg2: "#1d2021", + border: "#3c3836", + accent: "#fabd2f", + accent2: "#83a598", + text: "#ebdbb2", + textMuted: "#a89984", + textDim: "#665c54", + danger: "#fb4934", + green: "#b8bb26", + yellow: "#fabd2f", + magenta: "#d3869b", + }, + }, + { + id: "nord", + name: "Nord", + colors: { + bg: "#2e3440", + bg2: "#272c36", + border: "#3b4252", + accent: "#88c0d0", + accent2: "#81a1c1", + text: "#eceff4", + textMuted: "#9aa5b4", + textDim: "#616e7c", + danger: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + magenta: "#b48ead", + }, + }, + { + id: "one-dark", + name: "One Dark", + colors: { + bg: "#282c34", + bg2: "#21252b", + border: "#3e4452", + accent: "#61afef", + accent2: "#56b6c2", + text: "#abb2bf", + textMuted: "#7f848e", + textDim: "#5c6370", + danger: "#e06c75", + green: "#98c379", + yellow: "#e5c07b", + magenta: "#c678dd", + }, + }, + { + id: "solarized-dark", + name: "Solarized Dark", + colors: { + bg: "#002b36", + bg2: "#00252f", + border: "#073642", + accent: "#b58900", + accent2: "#268bd2", + text: "#839496", + textMuted: "#657b83", + textDim: "#586e75", + danger: "#dc322f", + green: "#859900", + yellow: "#b58900", + magenta: "#d33682", + }, + }, + { + id: "rose-pine", + name: "Rose Pine", + colors: { + bg: "#191724", + bg2: "#1f1d2e", + border: "#26233a", + accent: "#ebbcba", + accent2: "#31748f", + text: "#e0def4", + textMuted: "#908caa", + textDim: "#6e6a86", + danger: "#eb6f92", + green: "#9ccfd8", + yellow: "#f6c177", + magenta: "#c4a7e7", + }, + }, + { + id: "kanagawa", + name: "Kanagawa", + colors: { + bg: "#1f1f28", + bg2: "#16161d", + border: "#2a2a37", + accent: "#dca561", + accent2: "#7e9cd8", + text: "#dcd7ba", + textMuted: "#9a9a8e", + textDim: "#727169", + danger: "#e82424", + green: "#98bb6c", + yellow: "#e6c384", + magenta: "#957fb8", + }, + }, +] as const; + +const CSS_VAR_KEYS = [ + "--background", + "--card", + "--popover", + "--border", + "--primary", + "--ring", + "--secondary", + "--muted", + "--accent", + "--foreground", + "--card-foreground", + "--popover-foreground", + "--primary-foreground", + "--muted-foreground", + "--secondary-foreground", + "--accent-foreground", + "--destructive", + "--destructive-foreground", + "--success", + "--success-foreground", + "--warning", + "--warning-foreground", + "--info", + "--info-foreground", + "--input", +] as const; + +function hexToAlpha(hex: string, alpha: number): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Mix two hex colors. ratio 0 = pure a, 1 = pure b. */ +function mixHex(a: string, b: string, ratio: number): string { + const ar = parseInt(a.slice(1, 3), 16); + const ag = parseInt(a.slice(3, 5), 16); + const ab = parseInt(a.slice(5, 7), 16); + const br = parseInt(b.slice(1, 3), 16); + const bg = parseInt(b.slice(3, 5), 16); + const bb = parseInt(b.slice(5, 7), 16); + const r = Math.round(ar + (br - ar) * ratio); + const g = Math.round(ag + (bg - ag) * ratio); + const bl = Math.round(ab + (bb - ab) * ratio); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bl.toString(16).padStart(2, "0")}`; +} + +export function getColorThemeById(id: string): ColorTheme | undefined { + return COLOR_THEMES.find((theme) => theme.id === id); +} + +export function applyColorTheme(themeId: string | null | undefined, wallpaperActive = false): void { + const root = document.documentElement; + + if (!themeId) { + for (const key of CSS_VAR_KEYS) { + root.style.removeProperty(key); + } + return; + } + + const theme = getColorThemeById(themeId); + if (!theme) return; + + const { colors } = theme; + + // When wallpaper is active, boost muted/dim text toward the main text color + const textMuted = wallpaperActive ? mixHex(colors.textMuted, colors.text, 0.3) : colors.textMuted; + const textDim = wallpaperActive ? mixHex(colors.textDim, colors.text, 0.3) : colors.textDim; + + root.style.setProperty("--background", colors.bg); + root.style.setProperty("--card", colors.bg2); + root.style.setProperty("--popover", colors.bg2); + root.style.setProperty("--border", colors.border); + root.style.setProperty("--primary", colors.accent); + root.style.setProperty("--ring", colors.accent); + root.style.setProperty("--secondary", hexToAlpha(colors.accent2, 0.12)); + root.style.setProperty("--muted", hexToAlpha(colors.accent2, 0.12)); + root.style.setProperty("--accent", hexToAlpha(colors.accent2, 0.12)); + root.style.setProperty("--foreground", colors.text); + root.style.setProperty("--card-foreground", colors.text); + root.style.setProperty("--popover-foreground", colors.text); + root.style.setProperty("--primary-foreground", colors.text); + root.style.setProperty("--muted-foreground", textMuted); + root.style.setProperty("--secondary-foreground", textDim); + root.style.setProperty("--accent-foreground", textDim); + root.style.setProperty("--destructive", colors.danger); + root.style.setProperty("--destructive-foreground", colors.danger); + root.style.setProperty("--success", colors.green); + root.style.setProperty("--success-foreground", colors.green); + root.style.setProperty("--warning", colors.yellow); + root.style.setProperty("--warning-foreground", colors.yellow); + root.style.setProperty("--info", colors.magenta); + root.style.setProperty("--info-foreground", colors.magenta); + root.style.setProperty("--input", hexToAlpha(colors.border, 0.8)); +} + +export function getInteractiveOpacityRate(themeId: string | undefined): number { + if (!themeId) return 0.55; + return getColorThemeById(themeId)?.interactiveOpacityRate ?? 0.55; +} + +/** Single-layer elements (sidebar) that sit directly over the wallpaper */ +export function interactiveOpacity(panelOpacity: number, rate = 0.55): number { + return 1 - (1 - panelOpacity) * rate; +} + +/** Elements nested inside the panel — subtle bg2 tint, not a full opaque layer */ +export function nestedOpacity(panelOpacity: number, rate = 0.55): number { + return panelOpacity * rate; +} + +// Eager application at module load: apply color theme if dark mode is active +{ + const settings = getAppSettingsSnapshot(); + if (settings.colorThemeId && document.documentElement.classList.contains("dark")) { + applyColorTheme(settings.colorThemeId, !!settings.backgroundImage); + } +} diff --git a/apps/web/src/lib/wallpapers.ts b/apps/web/src/lib/wallpapers.ts new file mode 100644 index 00000000000..aaa3f5cad79 --- /dev/null +++ b/apps/web/src/lib/wallpapers.ts @@ -0,0 +1,26 @@ +export interface WallpaperPreset { + id: string; + name: string; + file: string; +} + +export const WALLPAPER_PRESETS: readonly WallpaperPreset[] = [ + { id: "preset:ship-at-sea", name: "Ship at Sea", file: "ship-at-sea.jpg" }, + { id: "preset:akane-pagoda", name: "Akane Pagoda", file: "akane-pagoda.jpg" }, + { id: "preset:everforest", name: "Everforest", file: "everforest.jpg" }, + { id: "preset:gruvbox-ferns", name: "Gruvbox Ferns", file: "gruvbox-ferns.jpg" }, + { id: "preset:akane-cliff", name: "Akane Cliff", file: "akane-cliff.jpg" }, + { id: "preset:akane-bridge", name: "Akane Bridge", file: "akane-bridge.jpg" }, + { id: "preset:akane-mist", name: "Akane Mist", file: "akane-mist.jpg" }, + { id: "preset:pink-lakeside", name: "Pink Lakeside", file: "pink-lakeside.png" }, +] as const; + +export function getWallpaperUrl(preset: WallpaperPreset): string { + return `/wallpapers/${preset.file}`; +} + +export function resolveBackgroundImageUrl(value: string): string { + const preset = WALLPAPER_PRESETS.find((p) => p.id === value); + if (preset) return getWallpaperUrl(preset); + return value; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index eb3eca9cbd6..9b5bd42ba0d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -10,8 +10,10 @@ import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; +import { BackgroundLayer } from "../components/BackgroundLayer"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; +import { useColorThemeEffect } from "../hooks/useColorTheme"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -51,6 +53,8 @@ function RootRouteView() { + + @@ -296,6 +300,11 @@ function EventRouter() { return null; } +function ColorThemeManager() { + useColorThemeEffect(); + return null; +} + function DesktopProjectBootstrap() { // Desktop hydration runs through EventRouter project + orchestration sync. return null; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 8dfeb210f28..ebe41f07af8 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -7,6 +7,8 @@ import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; +import { COLOR_THEMES } from "../lib/colorThemes"; +import { WALLPAPER_PRESETS, getWallpaperUrl } from "../lib/wallpapers"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; @@ -93,6 +95,7 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const [customWallpaperUrl, setCustomWallpaperUrl] = useState(""); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; @@ -232,6 +235,218 @@ function SettingsRouteView() {

Active theme: {resolvedTheme}

+ + {/* Color Theme */} +
+
+

Color Theme

+

+ {resolvedTheme === "dark" + ? "Pick a color palette for dark mode." + : "Switch to dark mode to use color themes."} +

+
+ +
+ + {COLOR_THEMES.map((ct) => { + const selected = settings.colorThemeId === ct.id; + return ( + + ); + })} +
+
+ + {/* Background Wallpaper */} +
+
+

Background

+

+ Choose a wallpaper image displayed behind the app. +

+
+ +
+ + {WALLPAPER_PRESETS.map((preset) => { + const selected = settings.backgroundImage === preset.id; + return ( + + ); + })} +
+ +
+ setCustomWallpaperUrl(event.target.value)} + placeholder="https://example.com/wallpaper.jpg" + spellCheck={false} + className="flex-1 text-xs" + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + const url = customWallpaperUrl.trim(); + if (url) { + updateSettings({ backgroundImage: url }); + setCustomWallpaperUrl(""); + } + }} + /> + +
+
+ + {/* Opacity & Blur (only when wallpaper is active) */} + {settings.backgroundImage ? ( +
+
+

Overlay & Blur

+

+ Adjust the color overlay opacity and blur on the wallpaper. +

+
+ +
+ + + + +
+ +
+
+
+ ) : null}