feat(web): add theme selection UI in Settings > Appearance (#495)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #495.
This commit is contained in:
2026-02-23 14:18:16 +00:00
committed by jason.woltje
parent 79286e98c6
commit 90c3bbccdf
3 changed files with 376 additions and 27 deletions

View File

@@ -0,0 +1,324 @@
"use client";
import { useState, useCallback } from "react";
import type { ReactElement } from "react";
import Link from "next/link";
import { useTheme } from "@/providers/ThemeProvider";
import { getAllThemes, type ThemeDefinition } from "@/themes";
import { apiPatch } from "@/lib/api/client";
function ThemeCard({
theme,
isActive,
onSelect,
}: {
theme: ThemeDefinition;
isActive: boolean;
onSelect: () => void;
}): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onSelect}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
style={{
display: "flex",
flexDirection: "column",
gap: 12,
padding: 16,
borderRadius: "var(--r-lg)",
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
border: isActive
? "2px solid var(--primary)"
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
cursor: "pointer",
textAlign: "left",
transition: "all 0.15s ease",
position: "relative",
width: "100%",
}}
aria-label={`Select ${theme.name} theme`}
aria-pressed={isActive}
>
{/* Color preview swatches */}
<div
style={{
display: "flex",
gap: 0,
borderRadius: "var(--r)",
overflow: "hidden",
height: 48,
width: "100%",
border: "1px solid rgba(128, 128, 128, 0.15)",
}}
>
{theme.colorPreview.map((color, i) => (
<div
key={i}
style={{
flex: 1,
background: color,
}}
/>
))}
</div>
{/* Theme info */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontSize: "0.9rem",
fontWeight: 600,
color: "var(--text)",
}}
>
{theme.name}
</span>
{isActive && (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="var(--primary)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="13 4 6 12 3 9" />
</svg>
)}
<span
style={{
fontSize: "0.7rem",
padding: "2px 6px",
borderRadius: "var(--r-sm)",
background: theme.isDark ? "rgba(128,128,128,0.15)" : "rgba(245,158,11,0.12)",
color: theme.isDark ? "var(--muted)" : "var(--warn)",
fontWeight: 500,
marginLeft: "auto",
}}
>
{theme.isDark ? "Dark" : "Light"}
</span>
</div>
<p
style={{
fontSize: "0.78rem",
color: "var(--muted)",
margin: 0,
lineHeight: 1.4,
}}
>
{theme.description}
</p>
</button>
);
}
function SystemThemeCard({
isActive,
onSelect,
}: {
isActive: boolean;
onSelect: () => void;
}): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onSelect}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
style={{
display: "flex",
flexDirection: "column",
gap: 12,
padding: 16,
borderRadius: "var(--r-lg)",
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
border: isActive
? "2px solid var(--primary)"
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
cursor: "pointer",
textAlign: "left",
transition: "all 0.15s ease",
width: "100%",
}}
aria-label="Use system theme preference"
aria-pressed={isActive}
>
{/* Split preview (dark | light) */}
<div
style={{
display: "flex",
gap: 0,
borderRadius: "var(--r)",
overflow: "hidden",
height: 48,
width: "100%",
border: "1px solid rgba(128, 128, 128, 0.15)",
}}
>
<div style={{ flex: 1, background: "#0f141d" }} />
<div style={{ flex: 1, background: "#1b2331" }} />
<div style={{ flex: 1, background: "#f0f4fc" }} />
<div style={{ flex: 1, background: "#dde4f2" }} />
<div
style={{
flex: 1,
background: "linear-gradient(135deg, #2f80ff 50%, #8b5cf6 50%)",
}}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--text)" }}>System</span>
{isActive && (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="var(--primary)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="13 4 6 12 3 9" />
</svg>
)}
<span
style={{
fontSize: "0.7rem",
padding: "2px 6px",
borderRadius: "var(--r-sm)",
background: "rgba(47, 128, 255, 0.12)",
color: "var(--primary-l)",
fontWeight: 500,
marginLeft: "auto",
}}
>
Auto
</span>
</div>
<p
style={{
fontSize: "0.78rem",
color: "var(--muted)",
margin: 0,
lineHeight: 1.4,
}}
>
Follows your operating system appearance preference
</p>
</button>
);
}
export default function AppearanceSettingsPage(): ReactElement {
const { theme: preference, setTheme: setLocalTheme } = useTheme();
const [saving, setSaving] = useState(false);
const allThemes = getAllThemes();
const handleThemeSelect = useCallback(
async (themeId: string) => {
setLocalTheme(themeId);
setSaving(true);
try {
await apiPatch("/users/me/preferences", { theme: themeId });
} catch {
// Theme is still applied locally even if API save fails
} finally {
setSaving(false);
}
},
[setLocalTheme]
);
return (
<div className="max-w-4xl mx-auto p-6">
{/* Breadcrumb */}
<div style={{ marginBottom: 8 }}>
<Link
href="/settings"
style={{
fontSize: "0.83rem",
color: "var(--muted)",
textDecoration: "none",
}}
>
Settings
</Link>
<span style={{ fontSize: "0.83rem", color: "var(--muted)", margin: "0 6px" }}>/</span>
<span style={{ fontSize: "0.83rem", color: "var(--text-2)" }}>Appearance</span>
</div>
{/* Page header */}
<div style={{ marginBottom: 32 }}>
<h1
style={{
fontSize: "1.875rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
}}
>
Appearance
</h1>
<p
style={{
fontSize: "0.9rem",
color: "var(--muted)",
margin: "8px 0 0 0",
}}
>
Choose a theme for the Mosaic interface
{saving && (
<span style={{ marginLeft: 12, color: "var(--primary-l)", fontStyle: "italic" }}>
Saving...
</span>
)}
</p>
</div>
{/* Theme grid */}
<div
className="grid gap-3"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
}}
>
{/* System option first */}
<SystemThemeCard
isActive={preference === "system"}
onSelect={() => void handleThemeSelect("system")}
/>
{/* All registered themes */}
{allThemes.map((t) => (
<ThemeCard
key={t.id}
theme={t}
isActive={preference === t.id}
onSelect={() => void handleThemeSelect(t.id)}
/>
))}
</div>
</div>
);
}

View File

@@ -95,6 +95,31 @@ function SettingsCategoryCard({ category }: SettingsCategoryCardProps): ReactEle
}
const categories: CategoryConfig[] = [
{
title: "Appearance",
description:
"Choose a theme for the interface. Switch between Dark, Light, Nord, Dracula, and more.",
href: "/settings/appearance",
accent: "var(--ms-pink-500)",
iconBg: "rgba(236, 72, 153, 0.12)",
icon: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="10" cy="10" r="7.5" />
<path d="M10 2.5v15" />
<path d="M10 2.5a7.5 7.5 0 0 1 0 15" fill="currentColor" opacity="0.15" />
</svg>
),
},
{
title: "Credentials",
description: