feat(desktop): redesign appearance settings with mode-first theme picker (#1528)

This commit is contained in:
klopez4212
2026-07-06 16:22:27 +01:00
committed by GitHub
parent d408370851
commit 9e773f103a
5 changed files with 1027 additions and 433 deletions
@@ -0,0 +1,538 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
export type ThemePreviewVars = Record<string, string>;
export const LIGHT_PREVIEW_VARS: ThemePreviewVars = {
"--background": "0 0% 100%",
"--border": "0 0% 89.8%",
"--foreground": "0 0% 9%",
"--muted": "0 0% 96.1%",
"--muted-foreground": "0 0% 45.1%",
"--primary": "0 0% 9%",
"--sidebar-background": "0 0% 98%",
"--sidebar-foreground": "0 0% 9%",
};
export const DARK_PREVIEW_VARS: ThemePreviewVars = {
"--background": "0 0% 3.9%",
"--border": "0 0% 14.9%",
"--foreground": "0 0% 98%",
"--muted": "0 0% 14.9%",
"--muted-foreground": "0 0% 63.9%",
"--primary": "0 0% 98%",
"--sidebar-background": "0 0% 0%",
"--sidebar-foreground": "0 0% 98%",
};
function hsl(vars: ThemePreviewVars | null, key: string) {
return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]})`;
}
function hslAlpha(vars: ThemePreviewVars | null, key: string, alpha: number) {
return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]} / ${alpha})`;
}
function ThemePreviewSvg({ vars }: { vars: ThemePreviewVars | null }) {
const clipId = React.useId().replace(/:/g, "");
const background = hsl(vars, "--background");
const border = hsl(vars, "--border");
const foreground = hsl(vars, "--foreground");
const mutedForeground = hsl(vars, "--muted-foreground");
const primary = hsl(vars, "--primary");
const primarySoft = hslAlpha(vars, "--primary", 0.68);
const sidebar = hsl(vars, "--sidebar-background");
const sidebarForeground = hslAlpha(vars, "--sidebar-foreground", 0.58);
return (
<svg
aria-hidden="true"
className="h-full w-full shrink-0"
fill="none"
preserveAspectRatio="xMinYMin slice"
viewBox="0 0 118 80"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath={`url(#${clipId})`}>
<rect fill={background} height="180" rx="3.6" width="288" />
<line stroke={border} x1="57" x2="117" y1="10.5" y2="10.5" />
<rect fill={sidebar} height="180" width="57.375" />
<rect
fill={sidebarForeground}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="15.9751"
/>
<rect
fill={sidebarForeground}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="21.375"
/>
<rect
fill={sidebarForeground}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="26.7749"
/>
<rect
fill={sidebarForeground}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="32.175"
/>
<rect
fill="#FF5F57"
height="2.7"
rx="1.35"
width="2.7"
x="3.5"
y="4.72485"
/>
<rect
height="2.5875"
rx="1.29375"
stroke="black"
strokeOpacity="0.2"
strokeWidth="0.1125"
width="2.5875"
x="3.55625"
y="4.7811"
/>
<rect
fill="#FEBC2E"
height="2.7"
rx="1.35"
width="2.7"
x="8"
y="4.72485"
/>
<rect
height="2.5875"
rx="1.29375"
stroke="black"
strokeOpacity="0.2"
strokeWidth="0.1125"
width="2.5875"
x="8.05625"
y="4.7811"
/>
<rect
fill="#28C840"
height="2.7"
rx="1.35"
width="2.7"
x="12.5"
y="4.72485"
/>
<rect
height="2.5875"
rx="1.29375"
stroke="black"
strokeOpacity="0.2"
strokeWidth="0.1125"
width="2.5875"
x="12.5563"
y="4.7811"
/>
<rect
fill={sidebarForeground}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="16.875"
/>
<rect
fill={sidebarForeground}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="22.2749"
/>
<rect
fill={sidebarForeground}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="27.675"
/>
<rect
fill={sidebarForeground}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="33.075"
/>
<rect
fill={mutedForeground}
height="1.8"
rx="0.225"
width="26.775"
x="3.60156"
y="43.875"
/>
<rect fill={foreground} height="2" rx="0.5" width="21" x="60" y="4" />
<rect fill={primary} height="4" rx="1" width="4" x="105" y="3" />
<rect fill={primarySoft} height="4" rx="1" width="4" x="111" y="3" />
</g>
<defs>
<clipPath id={clipId}>
<rect fill={background} height="180" rx="3.6" width="288" />
</clipPath>
</defs>
</svg>
);
}
/**
* Split preview SVG: light theme on top, dark theme on bottom.
* Matches the "System Preference" visual — one image showing both modes.
*/
function SystemPreferencePreviewSvg({
darkVars,
lightVars,
}: {
darkVars: ThemePreviewVars | null;
lightVars: ThemePreviewVars | null;
}) {
const clipBase = React.useId().replace(/:/g, "");
const clipDark = `${clipBase}-dark`;
const clipLight = `${clipBase}-light`;
const clipOuter = `${clipBase}-outer`;
// Dark half colors
const darkBg = hsl(darkVars, "--background");
const darkSidebar = hsl(darkVars, "--sidebar-background");
const darkSidebarFg = hslAlpha(darkVars, "--sidebar-foreground", 0.58);
const darkMutedFg = hsl(darkVars, "--muted-foreground");
// Light half colors
const lightBg = hsl(lightVars, "--background");
const lightBorder = hsl(lightVars, "--border");
const lightForeground = hsl(lightVars, "--foreground");
const lightPrimary = hsl(lightVars, "--primary");
const lightPrimarySoft = hslAlpha(lightVars, "--primary", 0.68);
const lightSidebar = hsl(lightVars, "--sidebar-background");
const lightSidebarFg = hslAlpha(lightVars, "--sidebar-foreground", 0.58);
const lightMutedFg = hsl(lightVars, "--muted-foreground");
return (
<svg
aria-hidden="true"
className="h-full w-full"
fill="none"
preserveAspectRatio="xMinYMin slice"
viewBox="0 0 118 80"
xmlns="http://www.w3.org/2000/svg"
>
{/* Light half (top) */}
<g clipPath={`url(#${clipLight})`}>
<rect fill={lightBg} height="180" rx="3.6" width="288" />
<rect fill={lightSidebar} height="180" width="57.375" />
<rect
fill={lightSidebarFg}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="15.9751"
/>
<rect
fill={lightSidebarFg}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="21.375"
/>
<rect
fill={lightSidebarFg}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="26.7749"
/>
<rect
fill={lightSidebarFg}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="32.175"
/>
<rect
fill="#FF5F57"
height="2.7"
rx="1.35"
width="2.7"
x="3.5"
y="4.72485"
/>
<rect
height="2.5875"
rx="1.29375"
stroke="black"
strokeOpacity="0.2"
strokeWidth="0.1125"
width="2.5875"
x="3.55625"
y="4.7811"
/>
<rect
fill="#FEBC2E"
height="2.7"
rx="1.35"
width="2.7"
x="8"
y="4.72485"
/>
<rect
height="2.5875"
rx="1.29375"
stroke="black"
strokeOpacity="0.2"
strokeWidth="0.1125"
width="2.5875"
x="8.05625"
y="4.7811"
/>
<rect
fill="#28C840"
height="2.7"
rx="1.35"
width="2.7"
x="12.5"
y="4.72485"
/>
<rect
height="2.5875"
rx="1.29375"
stroke="black"
strokeOpacity="0.2"
strokeWidth="0.1125"
width="2.5875"
x="12.5563"
y="4.7811"
/>
<rect
fill={lightSidebarFg}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="16.875"
/>
<rect
fill={lightSidebarFg}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="22.2749"
/>
<rect
fill={lightSidebarFg}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="27.675"
/>
<rect
fill={lightSidebarFg}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="33.075"
/>
<rect
fill={lightMutedFg}
height="1.8"
rx="0.225"
width="26.775"
x="3.60156"
y="43.875"
/>
<line stroke={lightBorder} x1="57" x2="118" y1="10.5" y2="10.5" />
<rect
fill={lightForeground}
height="2"
rx="0.5"
width="21"
x="60"
y="4"
/>
<rect fill={lightPrimary} height="4" rx="1" width="4" x="105" y="3" />
<rect
fill={lightPrimarySoft}
height="4"
rx="1"
width="4"
x="111"
y="3"
/>
</g>
{/* Dark half (bottom) — clipped to bottom portion */}
<g clipPath={`url(#${clipDark})`}>
<g clipPath={`url(#${clipOuter})`}>
<rect fill={darkBg} height="180" rx="3.6" width="288" y="22" />
<rect fill={darkSidebar} height="180" width="57.375" y="22" />
<rect
fill={darkSidebarFg}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="37.9751"
/>
<rect
fill={darkSidebarFg}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="43.375"
/>
<rect
fill={darkSidebarFg}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="48.7749"
/>
<rect
fill={darkSidebarFg}
height="3.6"
rx="0.9"
width="3.6"
x="3.60156"
y="54.175"
/>
<rect
fill={darkSidebarFg}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="38.875"
/>
<rect
fill={darkSidebarFg}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="44.2749"
/>
<rect
fill={darkSidebarFg}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="49.675"
/>
<rect
fill={darkSidebarFg}
height="1.8"
rx="0.225"
width="45.225"
x="9"
y="55.075"
/>
<rect
fill={darkMutedFg}
height="1.8"
rx="0.225"
width="26.775"
x="3.60156"
y="65.875"
/>
</g>
</g>
<defs>
<clipPath id={clipLight}>
<rect fill="white" height="180" rx="3.6" width="288" />
</clipPath>
<clipPath id={clipDark}>
<path d="M0 37H118V80H0V37Z" fill="white" />
</clipPath>
<clipPath id={clipOuter}>
<rect fill="white" height="180" rx="3.6" width="288" y="22" />
</clipPath>
</defs>
</svg>
);
}
export function ThemePreviewFrame({
className,
vars,
}: {
className?: string;
vars: ThemePreviewVars | null;
}) {
return (
<div
className={cn(
"relative aspect-[3/2] overflow-hidden rounded-2xl border",
className,
)}
style={{
backgroundColor: hsl(vars, "--muted"),
borderColor: hsl(vars, "--border"),
}}
>
<div className="absolute -bottom-1 -right-1 h-[90%] w-[90%]">
<ThemePreviewSvg vars={vars} />
</div>
</div>
);
}
/**
* System preference preview frame: shows light on top, dark on bottom
* in a single image to represent auto-switching themes.
*/
export function SystemPreferencePreviewFrame({
className,
darkVars,
lightVars,
}: {
className?: string;
darkVars: ThemePreviewVars | null;
lightVars: ThemePreviewVars | null;
}) {
return (
<div
className={cn(
"relative overflow-hidden rounded-2xl border border-border/70",
className,
)}
style={{
backgroundColor: "hsl(var(--muted))",
}}
>
<div className="absolute -bottom-1 -right-1 h-[90%] w-[90%]">
<SystemPreferencePreviewSvg darkVars={darkVars} lightVars={lightVars} />
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { createThemeVars } from "./adaptive-theme";
import {
SYNTAX_THEMES,
type SyntaxThemeName,
extractThemeInfo,
isLightTheme,
loadThemeData,
} from "./theme-loader";
import {
DARK_PREVIEW_VARS,
LIGHT_PREVIEW_VARS,
type ThemePreviewVars,
} from "./ThemePreviewFrame";
import { NEUTRAL_ACCENT } from "./ThemeProvider";
import { hexToHsl } from "./adaptive-theme";
export type ThemePreviewVarsByTheme = Partial<
Record<SyntaxThemeName, ThemePreviewVars>
>;
let themePreviewVarsCache: ThemePreviewVarsByTheme | null = null;
let themePreviewVarsPromise: Promise<ThemePreviewVarsByTheme> | null = null;
async function loadThemePreviewVars(name: SyntaxThemeName) {
const themeData = await loadThemeData(name);
const info = extractThemeInfo(name, themeData);
const { vars } = createThemeVars(info.bg, info.fg, info.comment, {
added: info.added,
deleted: info.deleted,
modified: info.modified,
});
return [name, vars] as const;
}
export function preloadThemePreviewVars() {
if (themePreviewVarsCache) {
return Promise.resolve(themePreviewVarsCache);
}
if (!themePreviewVarsPromise) {
themePreviewVarsPromise = Promise.all(
SYNTAX_THEMES.map((name) => loadThemePreviewVars(name)),
)
.then((entries) => {
const previewVars = Object.fromEntries(
entries,
) as ThemePreviewVarsByTheme;
themePreviewVarsCache = previewVars;
return previewVars;
})
.catch((error) => {
themePreviewVarsPromise = null;
throw error;
});
}
return themePreviewVarsPromise;
}
export function useThemePreviewVars() {
const [previewVarsByTheme, setPreviewVarsByTheme] =
useState<ThemePreviewVarsByTheme>(() => themePreviewVarsCache ?? {});
useEffect(() => {
let canceled = false;
void preloadThemePreviewVars()
.then((previewVars) => {
if (!canceled) {
setPreviewVarsByTheme(previewVars);
}
})
.catch(() => {
if (!canceled) {
setPreviewVarsByTheme({});
}
});
return () => {
canceled = true;
};
}, []);
return previewVarsByTheme;
}
export function getThemeFallbackPreviewVars(name: SyntaxThemeName) {
return isLightTheme(name) ? LIGHT_PREVIEW_VARS : DARK_PREVIEW_VARS;
}
export function withAccentPreviewVars(
vars: ThemePreviewVars | null,
accentColor: string,
): ThemePreviewVars | null {
if (!vars) {
return null;
}
if (accentColor === NEUTRAL_ACCENT) {
return {
...vars,
"--primary": vars["--foreground"],
"--primary-foreground": vars["--background"],
};
}
return {
...vars,
"--primary": hexToHsl(accentColor),
};
}