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
20 changes: 9 additions & 11 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -398,9 +396,9 @@ export default function Sidebar() {
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
ReadonlySet<ProjectId>
>(() => new Set());
const [filesCollapsedByProject, setFilesCollapsedByProject] = useState<
ReadonlySet<ProjectId>
>(() => new Set());
const [filesCollapsedByProject, setFilesCollapsedByProject] = useState<ReadonlySet<ProjectId>>(
() => new Set(),
);
const dragInProgressRef = useRef(false);
const suppressProjectClickAfterDragRef = useRef(false);
const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
Expand Down Expand Up @@ -1732,14 +1730,14 @@ export default function Sidebar() {
}, []);

const wordmark = (
<div className="flex items-center gap-2">
<SidebarTrigger className="shrink-0" />
<div className="flex items-center gap-2 w-full">
<SidebarTrigger className="shrink-0 md:hidden" />
<Tooltip>
<TooltipTrigger
render={
<div className="flex min-w-0 flex-1 items-center gap-1 ml-1 cursor-pointer">
<OkCodeMark className="size-5 text-foreground" />
<span className="truncate text-sm font-medium tracking-tight text-foreground">
<div className="wordmark-stitch flex min-w-0 flex-1 items-center justify-center gap-1.5 cursor-pointer mx-auto px-3 py-1.5 rounded-lg">
<OkCodeMark className="size-6 text-foreground drop-shadow-[0_1px_0_rgba(255,255,255,0.15)]" />
<span className="truncate text-base font-semibold tracking-wide text-foreground uppercase drop-shadow-[0_1px_0_rgba(255,255,255,0.15)]">
OK Code
</span>
</div>
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/VoodooStitches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,22 @@ function generateStitches(w: number, h: number): { stitches: Stitch[]; total: nu
const topCount = Math.max(0, Math.floor((w - EDGE_INSET * 2) / STITCH_SPACING));
const topOffset = (w - EDGE_INSET * 2 - (topCount - 1) * STITCH_SPACING) / 2;
for (let i = 0; i < topCount; i++) {
stitches.push({ cx: EDGE_INSET + topOffset + i * STITCH_SPACING, cy: EDGE_INSET, index: index++ });
stitches.push({
cx: EDGE_INSET + topOffset + i * STITCH_SPACING,
cy: EDGE_INSET,
index: index++,
});
}

// Right edge: top → bottom
const rightCount = Math.max(0, Math.floor((h - EDGE_INSET * 2) / STITCH_SPACING));
const rightOffset = (h - EDGE_INSET * 2 - (rightCount - 1) * STITCH_SPACING) / 2;
for (let i = 0; i < rightCount; i++) {
stitches.push({ cx: w - EDGE_INSET, cy: EDGE_INSET + rightOffset + i * STITCH_SPACING, index: index++ });
stitches.push({
cx: w - EDGE_INSET,
cy: EDGE_INSET + rightOffset + i * STITCH_SPACING,
index: index++,
});
}

// Bottom edge: right → left
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}

/* ─── Voodoo stitch border pulse ─── */
@keyframes voodoo-stitch-pulse {
0%,
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/lib/customTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,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([
Expand Down Expand Up @@ -569,6 +572,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)
// ---------------------------------------------------------------------------
Expand All @@ -590,4 +666,7 @@ export function initCustomTheme(): void {

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

// Always apply background image if set
applyBackgroundImage();
}
81 changes: 81 additions & 0 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -230,6 +235,80 @@ 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();
}
}, []);

const handleOpacityChange = useCallback((value: number) => {
setBgOpacity(value);
setStoredBackgroundOpacity(value);
}, []);

const handleReset = useCallback(() => {
setBgUrl("");
setBgOpacity(0.15);
clearBackgroundImage();
}, []);

return (
<>
<SettingsRow
title="Background image"
description="Set a custom background image URL. Supports any image URL."
resetAction={
hasBackground ? (
<SettingResetButton label="background image" onClick={handleReset} />
) : null
}
control={
<Input
value={bgUrl}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder="https://example.com/image.jpg"
className="w-full sm:w-56"
aria-label="Background image URL"
/>
}
/>
{hasBackground && (
<SettingsRow
title="Background opacity"
description="Adjust the visibility of the custom background image."
control={
<div className="flex items-center gap-2">
<input
type="range"
min={5}
max={100}
value={Math.round(bgOpacity * 100)}
onChange={(e) => {
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"
/>
<span className="w-9 text-right text-xs tabular-nums text-muted-foreground">
{Math.round(bgOpacity * 100)}%
</span>
</div>
}
/>
)}
</>
);
}

function SettingsRouteView() {
const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme();
const { settings, defaults, updateSettings, resetSettings } = useAppSettings();
Expand Down Expand Up @@ -835,6 +914,8 @@ function SettingsRouteView() {
}
/>

<BackgroundImageSettings />

<SettingsRow
title="Accent project names"
description="Use the theme's accent color for project names in the sidebar."
Expand Down
Loading