Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -276,6 +283,8 @@ describe("AppSettingsSchema", () => {
claudeBinaryPath: "",
codexBinaryPath: "/usr/local/bin/codex",
codexHomePath: "",
backgroundImageUrl: "",
backgroundImageOpacity: 0.15,
defaultThreadEnvMode: "worktree",
confirmThreadDelete: false,
enableAssistantStreaming: false,
Expand Down
41 changes: 40 additions & 1 deletion apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { Option, Schema } from "effect";
import {
TrimmedNonEmptyString,
Expand All @@ -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;
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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]);
Expand Down
81 changes: 2 additions & 79 deletions apps/web/src/lib/customTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -570,86 +568,14 @@ 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)
// ---------------------------------------------------------------------------

/** 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") {
Expand All @@ -664,7 +590,4 @@ export function initCustomTheme(): void {

// Always apply font override if set
applyFontOverride();

// Always apply background image if set
applyBackgroundImage();
}
78 changes: 48 additions & 30 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -235,30 +230,43 @@ function getErrorMessage(error: unknown): string {
return "Unknown error";
}

function BackgroundImageSettings() {
const [bgUrl, setBgUrl] = useState<string>(() => getStoredBackgroundImage() ?? "");
const [bgOpacity, setBgOpacity] = useState<number>(() => 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 (
<>
Expand All @@ -272,7 +280,7 @@ function BackgroundImageSettings() {
}
control={
<Input
value={bgUrl}
value={backgroundImageUrl}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder="https://example.com/image.jpg"
className="w-full sm:w-56"
Expand All @@ -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);
Expand All @@ -299,7 +307,7 @@ function BackgroundImageSettings() {
aria-label="Background opacity"
/>
<span className="w-9 text-right text-xs tabular-nums text-muted-foreground">
{Math.round(bgOpacity * 100)}%
{Math.round(backgroundImageOpacity * 100)}%
</span>
</div>
}
Expand Down Expand Up @@ -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"] : []),
];
Expand Down Expand Up @@ -878,7 +890,13 @@ function SettingsRouteView() {
}
/>

<BackgroundImageSettings />
<BackgroundImageSettings
backgroundImageOpacity={settings.backgroundImageOpacity}
backgroundImageUrl={settings.backgroundImageUrl}
defaultBackgroundImageOpacity={defaults.backgroundImageOpacity}
defaultBackgroundImageUrl={defaults.backgroundImageUrl}
updateSettings={updateSettings}
/>

<SettingsRow
title="Accent project names"
Expand Down
Loading
Loading