From 8ed8e32a01973b2597a13847818ab75aaffbfdd5 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 2 Apr 2026 19:00:12 -0500 Subject: [PATCH] Add custom background image settings - Persist custom background image URL and opacity - Render the image behind the app via injected styles - Refresh wordmark styling to match the new textured background --- apps/web/src/components/Sidebar.tsx | 18 +++--- apps/web/src/index.css | 27 +++++++++ apps/web/src/lib/customTheme.ts | 79 +++++++++++++++++++++++++ apps/web/src/routes/_chat.settings.tsx | 81 ++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 759e4e812..136f0a8d2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -372,9 +372,7 @@ export default function Sidebar() { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSubPage = - pathname === "/settings" || - pathname === "/pr-review" || - pathname === "/merge-conflicts"; + pathname === "/settings" || pathname === "/pr-review" || pathname === "/merge-conflicts"; const { settings: appSettings, updateSettings } = useAppSettings(); const { resolvedTheme } = useTheme(); const { handleNewThread } = useHandleNewThread(); @@ -398,9 +396,9 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); - const [filesCollapsedByProject, setFilesCollapsedByProject] = useState< - ReadonlySet - >(() => new Set()); + const [filesCollapsedByProject, setFilesCollapsedByProject] = useState>( + () => new Set(), + ); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); @@ -1728,14 +1726,14 @@ export default function Sidebar() { }, []); const wordmark = ( -
+
- - +
+ + OK Code
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fa879be3f..d831843e8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -152,6 +152,7 @@ body::after { content: ""; position: fixed; inset: 0; + z-index: 0; pointer-events: none; opacity: 0.035; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); @@ -636,6 +637,32 @@ label:has(> select#reasoning-effort) select { } } +/* ─── Wordmark stitched-into-fabric effect ─── */ + +.wordmark-stitch { + position: relative; + background: + url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='6' height='6' fill='none'/%3E%3Ccircle cx='1' cy='1' r='0.6' fill='rgba(128,128,128,0.07)'/%3E%3Ccircle cx='4' cy='4' r='0.6' fill='rgba(128,128,128,0.07)'/%3E%3C/svg%3E"), + color-mix(in srgb, var(--muted) 50%, transparent); + border: 1.5px dashed color-mix(in srgb, var(--foreground) 22%, transparent); + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.12), + inset 0 -1px 1px rgba(255, 255, 255, 0.04), + 0 0 0 1.5px color-mix(in srgb, var(--background) 40%, transparent); + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.18); +} + +.dark .wordmark-stitch { + background: + url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='6' height='6' fill='none'/%3E%3Ccircle cx='1' cy='1' r='0.6' fill='rgba(255,255,255,0.04)'/%3E%3Ccircle cx='4' cy='4' r='0.6' fill='rgba(255,255,255,0.04)'/%3E%3C/svg%3E"), + color-mix(in srgb, var(--muted) 50%, transparent); + box-shadow: + inset 0 1px 3px rgba(0, 0, 0, 0.3), + inset 0 -1px 1px rgba(255, 255, 255, 0.03), + 0 0 0 1.5px color-mix(in srgb, var(--background) 30%, transparent); + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4); +} + /* ─── Accessibility ─── */ @media (prefers-reduced-motion: reduce) { diff --git a/apps/web/src/lib/customTheme.ts b/apps/web/src/lib/customTheme.ts index 9c134e236..426476f10 100644 --- a/apps/web/src/lib/customTheme.ts +++ b/apps/web/src/lib/customTheme.ts @@ -67,6 +67,9 @@ 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"; /** System-bundled fonts that don't need to be loaded from Google Fonts. */ const SYSTEM_FONTS = new Set([ @@ -545,6 +548,79 @@ 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) // --------------------------------------------------------------------------- @@ -566,4 +642,7 @@ 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 70a06a8cf..15b3a0678 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -50,13 +50,18 @@ import { } from "../lib/environmentVariablesReactQuery"; import { applyCustomTheme, + clearBackgroundImage, clearFontOverride, clearRadiusOverride, clearStoredCustomTheme, + getStoredBackgroundImage, + getStoredBackgroundOpacity, getStoredCustomTheme, getStoredFontOverride, getStoredRadiusOverride, removeCustomTheme, + setStoredBackgroundImage, + setStoredBackgroundOpacity, setStoredFontOverride, setStoredRadiusOverride, type CustomThemeData, @@ -230,6 +235,80 @@ 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(); + } + }, []); + + const handleOpacityChange = useCallback((value: number) => { + setBgOpacity(value); + setStoredBackgroundOpacity(value); + }, []); + + const handleReset = useCallback(() => { + setBgUrl(""); + setBgOpacity(0.15); + clearBackgroundImage(); + }, []); + + return ( + <> + + ) : null + } + control={ + handleUrlChange(e.target.value)} + placeholder="https://example.com/image.jpg" + className="w-full sm:w-56" + aria-label="Background image URL" + /> + } + /> + {hasBackground && ( + + { + const value = Number(e.target.value) / 100; + handleOpacityChange(value); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Background opacity" + /> + + {Math.round(bgOpacity * 100)}% + +
+ } + /> + )} + + ); +} + function SettingsRouteView() { const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme(); const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); @@ -835,6 +914,8 @@ function SettingsRouteView() { } /> + +