diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index f3f947345..00165f3d7 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -37,6 +37,13 @@ describe("AppSettingsSchema", () => { ), ).not.toHaveProperty("windowOpacity"); }); + + it("defaults background image settings to disabled with a lightweight overlay", () => { + const settings = Schema.decodeUnknownSync(AppSettingsSchema)({}); + + expect(settings.backgroundImageUrl).toBe(""); + expect(settings.backgroundImageOpacity).toBe(0.15); + }); }); describe("normalizeCustomModelSlugs", () => { @@ -276,6 +283,8 @@ describe("AppSettingsSchema", () => { claudeBinaryPath: "", codexBinaryPath: "/usr/local/bin/codex", codexHomePath: "", + backgroundImageUrl: "", + backgroundImageOpacity: 0.15, defaultThreadEnvMode: "worktree", confirmThreadDelete: false, enableAssistantStreaming: false, diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 9d47561f3..051b12539 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { Option, Schema } from "effect"; import { TrimmedNonEmptyString, @@ -18,6 +18,8 @@ import { EnvMode } from "./components/BranchToolbar.logic"; const APP_SETTINGS_STORAGE_KEY = "okcode:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; +const BACKGROUND_IMAGE_KEY = "okcode:background-image"; +const BACKGROUND_OPACITY_KEY = "okcode:background-opacity"; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; @@ -64,6 +66,8 @@ export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + backgroundImageUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + backgroundImageOpacity: Schema.Number.pipe(withDefaults(() => 0.15)), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "worktree" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), autoDeleteMergedThreads: Schema.Boolean.pipe(withDefaults(() => false)), @@ -154,9 +158,15 @@ function clampOpacity(value: number): number { return Math.max(0.3, Math.min(1, value)); } +function clampBackgroundOpacity(value: number): number { + return Math.max(0.05, Math.min(1, value)); +} + function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, + backgroundImageUrl: settings.backgroundImageUrl.trim(), + backgroundImageOpacity: clampBackgroundOpacity(settings.backgroundImageOpacity), sidebarOpacity: clampOpacity(settings.sidebarOpacity), customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), @@ -298,6 +308,35 @@ export function useAppSettings() { [setSettings], ); + useEffect(() => { + if (typeof window === "undefined" || settings.backgroundImageUrl.trim().length > 0) { + return; + } + + const legacyBackgroundImageUrl = + window.localStorage.getItem(BACKGROUND_IMAGE_KEY)?.trim() ?? ""; + if (legacyBackgroundImageUrl.length === 0) { + return; + } + + const legacyBackgroundOpacityRaw = window.localStorage.getItem(BACKGROUND_OPACITY_KEY); + const legacyBackgroundOpacity = + legacyBackgroundOpacityRaw === null ? null : Number.parseFloat(legacyBackgroundOpacityRaw); + + setSettings((prev) => + normalizeAppSettings({ + ...prev, + backgroundImageUrl: legacyBackgroundImageUrl, + backgroundImageOpacity: + typeof legacyBackgroundOpacity === "number" && Number.isFinite(legacyBackgroundOpacity) + ? legacyBackgroundOpacity + : prev.backgroundImageOpacity, + }), + ); + window.localStorage.removeItem(BACKGROUND_IMAGE_KEY); + window.localStorage.removeItem(BACKGROUND_OPACITY_KEY); + }, [setSettings, settings.backgroundImageUrl]); + const resetSettings = useCallback(() => { setSettings(DEFAULT_APP_SETTINGS); }, [setSettings]); diff --git a/apps/web/src/lib/customTheme.ts b/apps/web/src/lib/customTheme.ts index 035ad4796..3aa9a0b0d 100644 --- a/apps/web/src/lib/customTheme.ts +++ b/apps/web/src/lib/customTheme.ts @@ -71,9 +71,7 @@ const CUSTOM_THEME_STYLE_ID = "okcode-custom-theme-style"; const CUSTOM_THEME_FONT_LINK_ID = "okcode-custom-theme-fonts"; const RADIUS_OVERRIDE_KEY = "okcode:radius-override"; const FONT_OVERRIDE_KEY = "okcode:font-override"; -const BACKGROUND_IMAGE_KEY = "okcode:background-image"; -const BACKGROUND_OPACITY_KEY = "okcode:background-opacity"; -const BACKGROUND_STYLE_ID = "okcode-background-image-style"; +const LEGACY_BACKGROUND_STYLE_ID = "okcode-background-image-style"; /** System-bundled fonts that don't need to be loaded from Google Fonts. */ const SYSTEM_FONTS = new Set([ @@ -570,79 +568,6 @@ export function applyFontOverride(): void { } } -// --------------------------------------------------------------------------- -// Background Image Override -// --------------------------------------------------------------------------- - -export function getStoredBackgroundImage(): string | null { - return localStorage.getItem(BACKGROUND_IMAGE_KEY) || null; -} - -export function getStoredBackgroundOpacity(): number | null { - const raw = localStorage.getItem(BACKGROUND_OPACITY_KEY); - if (raw === null) return null; - const num = Number.parseFloat(raw); - return Number.isFinite(num) ? num : null; -} - -export function setStoredBackgroundImage(url: string): void { - localStorage.setItem(BACKGROUND_IMAGE_KEY, url); - applyBackgroundImage(); -} - -export function setStoredBackgroundOpacity(opacity: number): void { - localStorage.setItem(BACKGROUND_OPACITY_KEY, String(opacity)); - applyBackgroundImage(); -} - -export function clearBackgroundImage(): void { - localStorage.removeItem(BACKGROUND_IMAGE_KEY); - localStorage.removeItem(BACKGROUND_OPACITY_KEY); - if (hasDom()) { - document.getElementById(BACKGROUND_STYLE_ID)?.remove(); - } -} - -export function applyBackgroundImage(): void { - if (!hasDom()) return; - const url = getStoredBackgroundImage(); - if (!url) { - document.getElementById(BACKGROUND_STYLE_ID)?.remove(); - return; - } - - const opacity = getStoredBackgroundOpacity() ?? 0.15; - - let styleEl = document.getElementById(BACKGROUND_STYLE_ID) as HTMLStyleElement | null; - if (!styleEl) { - styleEl = document.createElement("style"); - styleEl.id = BACKGROUND_STYLE_ID; - document.head.appendChild(styleEl); - } - - // Use a ::before pseudo-element on #root so it layers behind content but - // above the body background color, with controllable opacity. - const escapedUrl = url.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - styleEl.textContent = ` - body::before { - content: ""; - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - background-image: url("${escapedUrl}"); - background-size: cover; - background-position: center; - background-repeat: no-repeat; - opacity: ${opacity}; - } - #root { - position: relative; - z-index: 1; - } - `; -} - // --------------------------------------------------------------------------- // Initialization (called on module load) // --------------------------------------------------------------------------- @@ -650,6 +575,7 @@ export function applyBackgroundImage(): void { /** Restore persisted custom theme + overrides on app boot. */ export function initCustomTheme(): void { if (!hasDom() || typeof localStorage === "undefined") return; + document.getElementById(LEGACY_BACKGROUND_STYLE_ID)?.remove(); // If a custom theme is stored and selected, apply it const colorTheme = localStorage.getItem("okcode:color-theme"); if (colorTheme === "custom") { @@ -664,7 +590,4 @@ export function initCustomTheme(): void { // Always apply font override if set applyFontOverride(); - - // Always apply background image if set - applyBackgroundImage(); } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 5cebc4906..eeb95ca9b 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -50,18 +50,13 @@ import { } from "../lib/environmentVariablesReactQuery"; import { applyCustomTheme, - clearBackgroundImage, clearFontOverride, clearRadiusOverride, clearStoredCustomTheme, - getStoredBackgroundImage, - getStoredBackgroundOpacity, getStoredCustomTheme, getStoredFontOverride, getStoredRadiusOverride, removeCustomTheme, - setStoredBackgroundImage, - setStoredBackgroundOpacity, setStoredFontOverride, setStoredRadiusOverride, type CustomThemeData, @@ -235,30 +230,43 @@ function getErrorMessage(error: unknown): string { return "Unknown error"; } -function BackgroundImageSettings() { - const [bgUrl, setBgUrl] = useState(() => getStoredBackgroundImage() ?? ""); - const [bgOpacity, setBgOpacity] = useState(() => getStoredBackgroundOpacity() ?? 0.15); - const hasBackground = bgUrl.trim().length > 0; - - const handleUrlChange = useCallback((value: string) => { - setBgUrl(value); - if (value.trim().length > 0) { - setStoredBackgroundImage(value.trim()); - } else { - clearBackgroundImage(); - } - }, []); +function BackgroundImageSettings({ + backgroundImageUrl, + backgroundImageOpacity, + defaultBackgroundImageUrl, + defaultBackgroundImageOpacity, + updateSettings, +}: { + backgroundImageUrl: string; + backgroundImageOpacity: number; + defaultBackgroundImageUrl: string; + defaultBackgroundImageOpacity: number; + updateSettings: (patch: { backgroundImageOpacity?: number; backgroundImageUrl?: string }) => void; +}) { + const hasBackground = backgroundImageUrl.trim().length > 0; + + const handleUrlChange = useCallback( + (value: string) => { + updateSettings({ + backgroundImageUrl: value, + }); + }, + [updateSettings], + ); - const handleOpacityChange = useCallback((value: number) => { - setBgOpacity(value); - setStoredBackgroundOpacity(value); - }, []); + const handleOpacityChange = useCallback( + (value: number) => { + updateSettings({ backgroundImageOpacity: value }); + }, + [updateSettings], + ); const handleReset = useCallback(() => { - setBgUrl(""); - setBgOpacity(0.15); - clearBackgroundImage(); - }, []); + updateSettings({ + backgroundImageUrl: defaultBackgroundImageUrl, + backgroundImageOpacity: defaultBackgroundImageOpacity, + }); + }, [defaultBackgroundImageOpacity, defaultBackgroundImageUrl, updateSettings]); return ( <> @@ -272,7 +280,7 @@ function BackgroundImageSettings() { } control={ handleUrlChange(e.target.value)} placeholder="https://example.com/image.jpg" className="w-full sm:w-56" @@ -290,7 +298,7 @@ function BackgroundImageSettings() { type="range" min={5} max={100} - value={Math.round(bgOpacity * 100)} + value={Math.round(backgroundImageOpacity * 100)} onChange={(e) => { const value = Number(e.target.value) / 100; handleOpacityChange(value); @@ -299,7 +307,7 @@ function BackgroundImageSettings() { aria-label="Background opacity" /> - {Math.round(bgOpacity * 100)}% + {Math.round(backgroundImageOpacity * 100)}% } @@ -437,6 +445,10 @@ function SettingsRouteView() { ? ["Custom models"] : []), ...(isInstallSettingsDirty ? ["Provider installs"] : []), + ...(settings.backgroundImageUrl !== defaults.backgroundImageUrl ? ["Background image"] : []), + ...(settings.backgroundImageOpacity !== defaults.backgroundImageOpacity + ? ["Background opacity"] + : []), ...(radiusOverride !== null ? ["Border radius"] : []), ...(fontOverride ? ["Font family"] : []), ]; @@ -878,7 +890,13 @@ function SettingsRouteView() { } /> - +