feat(desktop): redesign appearance settings with sectioned theme picker

Replaces the flat searchable theme list with three visual sections:

1. **Adapts to system** — Paired themes (light+dark) shown side by side
   with preview images. Selecting one auto-enables follow-system behavior.
2. **Light** — Light-only themes (no dark counterpart) with single preview.
3. **Dark** — Dark-only themes (no light counterpart) with single preview.

Uses the same ThemePreviewFrame SVG from onboarding to show a visual
representation of each theme. Removes the separate "Use system setting"
toggle since paired themes now implicitly enable it.

Refactors shared theme preview components out of ThemeStep into:
- shared/theme/ThemePreviewFrame.tsx — SVG preview component
- shared/theme/useThemePreviewVars.ts — preview vars loading hook

The accent color picker remains below the theme sections.
This commit is contained in:
klopez4212
2026-07-06 13:04:14 +01:00
parent 87f354ae07
commit ac00196c58
4 changed files with 597 additions and 436 deletions

View File

@@ -1,7 +1,6 @@
import { Check } from "lucide-react";
import * as React from "react";
import { createThemeVars, hexToHsl } from "@/shared/theme/adaptive-theme";
import {
ACCENT_COLORS,
ACCENT_STORAGE_KEY,
@@ -13,10 +12,16 @@ import {
ONBOARDING_DEFAULT_THEME_NAME,
SYNTAX_THEMES,
type SyntaxThemeName,
extractThemeInfo,
isLightTheme,
loadThemeData,
} from "@/shared/theme/theme-loader";
import {
ThemePreviewFrame,
type ThemePreviewVars,
} from "@/shared/theme/ThemePreviewFrame";
import {
getThemeFallbackPreviewVars,
useThemePreviewVars,
withAccentPreviewVars,
} from "@/shared/theme/useThemePreviewVars";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { StepProgress } from "@/shared/ui/step-progress";
@@ -31,30 +36,6 @@ type ThemeStepProps = {
direction: OnboardingTransitionDirection;
};
type ThemePreviewVars = Record<string, string>;
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%",
};
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%",
};
const GRADUAL_BLUR_LEVELS = [0.5, 1.25, 2.5, 4.5, 7, 10] as const;
const THEME_TILE_WIDTH = 174;
const THEME_TILE_HEIGHT = 160;
@@ -66,21 +47,6 @@ const THEME_SCROLL_MAX_HEIGHT =
THEME_TILE_GAP * THEME_VISIBLE_ROW_COUNT +
THEME_ROW_PEEK_HEIGHT;
type ThemePreviewVarsByTheme = Partial<
Record<SyntaxThemeName, ThemePreviewVars>
>;
let themePreviewVarsCache: ThemePreviewVarsByTheme | null = null;
let themePreviewVarsPromise: Promise<ThemePreviewVarsByTheme> | null = null;
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 contrastColorForHex(hex: string) {
const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!match) {
@@ -101,28 +67,6 @@ function formatThemeLabel(name: string): string {
.join(" ");
}
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),
};
}
function getOrderedThemes() {
return [
ONBOARDING_DEFAULT_THEME_NAME,
@@ -130,234 +74,7 @@ function getOrderedThemes() {
];
}
function getThemeFallbackPreviewVars(name: SyntaxThemeName) {
return isLightTheme(name) ? LIGHT_PREVIEW_VARS : DARK_PREVIEW_VARS;
}
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;
}
function useThemePreviewVars() {
const [previewVarsByTheme, setPreviewVarsByTheme] =
React.useState<ThemePreviewVarsByTheme>(() => themePreviewVarsCache ?? {});
React.useEffect(() => {
let canceled = false;
void preloadThemePreviewVars()
.then((previewVars) => {
if (!canceled) {
setPreviewVarsByTheme(previewVars);
}
})
.catch(() => {
if (!canceled) {
setPreviewVarsByTheme({});
}
});
return () => {
canceled = true;
};
}, []);
return previewVarsByTheme;
}
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-24 w-[142px] shrink-0 drop-shadow-sm"
fill="none"
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>
);
}
export { preloadThemePreviewVars } from "@/shared/theme/useThemePreviewVars";
function GradualBottomBlur() {
return (
@@ -394,31 +111,6 @@ function GradualBottomBlur() {
);
}
function ThemePreviewFrame({
className,
vars,
}: {
className?: string;
vars: ThemePreviewVars | null;
}) {
return (
<div
className={cn(
"relative h-28 w-[158px] overflow-hidden rounded-md border",
className,
)}
style={{
backgroundColor: hsl(vars, "--muted"),
borderColor: hsl(vars, "--border"),
}}
>
<div className="absolute bottom-0 right-0">
<ThemePreviewSvg vars={vars} />
</div>
</div>
);
}
function ThemeTile({
isActive,
name,

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useRef } from "react";
import { useMemo } from "react";
import {
BellRing,
Bot,
@@ -9,14 +9,10 @@ import {
Keyboard,
LayoutTemplate,
LockKeyhole,
Monitor,
MonitorCog,
Moon,
Search,
Smartphone,
Smile,
Stethoscope,
Sun,
UserRound,
type LucideIcon,
} from "lucide-react";
@@ -33,8 +29,21 @@ import {
NEUTRAL_ACCENT,
useTheme,
} from "@/shared/theme/ThemeProvider";
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
import { Switch } from "@/shared/ui/switch";
import {
LIGHT_THEMES,
SYNTAX_THEMES,
type SyntaxThemeName,
getThemePair,
} from "@/shared/theme/theme-loader";
import {
ThemePreviewFrame,
type ThemePreviewVars,
} from "@/shared/theme/ThemePreviewFrame";
import {
getThemeFallbackPreviewVars,
useThemePreviewVars,
withAccentPreviewVars,
} from "@/shared/theme/useThemePreviewVars";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
@@ -189,37 +198,171 @@ function formatThemeLabel(name: string): string {
.join(" ");
}
/**
* Categorize themes into three groups:
* 1. Paired — themes with both a light and dark variant (auto-switches with system)
* 2. Light-only — light themes with no dark counterpart
* 3. Dark-only — dark themes with no light counterpart
*
* For paired themes, we deduplicate by only keeping the light member
* (the dark member is shown alongside it as a preview).
*/
function useThemeCategories() {
return useMemo(() => {
const pairedLight: SyntaxThemeName[] = [];
const lightOnly: SyntaxThemeName[] = [];
const darkOnly: SyntaxThemeName[] = [];
// Track which themes are the "dark side" of a pair so we skip them
const darkPairMembers = new Set<string>();
for (const name of SYNTAX_THEMES) {
if (LIGHT_THEMES.has(name)) {
const pair = getThemePair(name);
if (pair) {
darkPairMembers.add(pair);
}
}
}
for (const name of SYNTAX_THEMES) {
// Skip dark members of pairs — they'll be shown alongside their light counterpart
if (darkPairMembers.has(name)) continue;
if (LIGHT_THEMES.has(name)) {
const pair = getThemePair(name);
if (pair) {
pairedLight.push(name);
} else {
lightOnly.push(name);
}
} else {
darkOnly.push(name);
}
}
return { pairedLight, lightOnly, darkOnly };
}, []);
}
function PairedThemeTile({
isActive,
lightName,
lightVars,
darkVars,
onSelect,
}: {
isActive: boolean;
lightName: SyntaxThemeName;
lightVars: ThemePreviewVars | null;
darkVars: ThemePreviewVars | null;
onSelect: () => void;
}) {
return (
<button
aria-pressed={isActive}
className={cn(
"group flex min-w-0 flex-col rounded-lg border bg-background/70 p-2 text-left transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary text-foreground shadow-sm"
: "border-border/70 text-muted-foreground hover:border-border hover:bg-accent/70 hover:text-accent-foreground",
)}
data-testid={`theme-pair-${lightName}`}
onClick={onSelect}
type="button"
>
<div className="flex gap-2">
<ThemePreviewFrame className="h-20 w-[120px]" vars={lightVars} />
<ThemePreviewFrame className="h-20 w-[120px]" vars={darkVars} />
</div>
<div className="mt-2 flex min-h-6 items-center gap-2 px-1">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{formatThemeLabel(
lightName.replace(/-(?:light|latte|dawn|lotus|ochin)$/, ""),
)}
</span>
{isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : null}
</div>
</button>
);
}
function SingleThemeTile({
isActive,
name,
vars,
onSelect,
}: {
isActive: boolean;
name: SyntaxThemeName;
vars: ThemePreviewVars | null;
onSelect: () => void;
}) {
return (
<button
aria-pressed={isActive}
className={cn(
"group flex w-[174px] min-w-0 flex-col rounded-lg border bg-background/70 p-2 text-left transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary text-foreground shadow-sm"
: "border-border/70 text-muted-foreground hover:border-border hover:bg-accent/70 hover:text-accent-foreground",
)}
data-testid={`theme-option-${name}`}
onClick={onSelect}
type="button"
>
<ThemePreviewFrame vars={vars} />
<div className="mt-2 flex min-h-6 items-center gap-2 px-1">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{formatThemeLabel(name)}
</span>
{isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : null}
</div>
</button>
);
}
function ThemeSettingsCard() {
const {
setTheme,
themeName,
selectedThemeName,
isDark,
accentColor,
setAccentColor,
followSystem,
setFollowSystem,
} = useTheme();
const [search, setSearch] = useState("");
const didScrollRef = useRef(false);
const activeRef = (node: HTMLButtonElement | null) => {
if (node && !didScrollRef.current) {
didScrollRef.current = true;
node.scrollIntoView({ block: "center" });
}
const previewVarsByTheme = useThemePreviewVars();
const { pairedLight, lightOnly, darkOnly } = useThemeCategories();
const getVars = (name: SyntaxThemeName) =>
withAccentPreviewVars(
previewVarsByTheme[name] ?? getThemeFallbackPreviewVars(name),
accentColor,
);
/** Check if a paired theme (by its light member) is the active selection */
const isPairActive = (lightName: SyntaxThemeName) => {
const darkName = getThemePair(lightName);
return selectedThemeName === lightName || selectedThemeName === darkName;
};
const selectedTheme = selectedThemeName;
const handleSelectPair = (lightName: SyntaxThemeName) => {
// Selecting a paired theme auto-enables follow-system
setTheme(lightName);
setFollowSystem(true);
};
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
if (!q) return SYNTAX_THEMES;
return SYNTAX_THEMES.filter((name) => name.includes(q));
}, [search]);
const handleSelectSingle = (name: SyntaxThemeName) => {
setTheme(name);
// Single themes don't follow system — they're always one mode
setFollowSystem(false);
};
return (
<section
className="flex min-h-0 flex-1 flex-col"
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
data-testid="settings-theme"
>
<SettingsSectionHeader
@@ -227,115 +370,108 @@ function ThemeSettingsCard() {
description="Choose a theme for Buzz."
/>
<div className="relative mb-3 shrink-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
autoCapitalize="none"
autoCorrect="off"
className="w-full rounded-lg border border-border/70 bg-background/70 py-2 pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search themes..."
spellCheck={false}
type="text"
value={search}
/>
{/* Section 1: Paired themes (follow system) */}
<div className="mb-6">
<h3 className="mb-1 text-sm font-medium text-foreground">
Adapts to system
</h3>
<p className="mb-3 text-2xs text-muted-foreground">
Automatically switches between light and dark with your system
preferences.
</p>
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-3">
{pairedLight.map((lightName) => {
const darkName = getThemePair(lightName);
if (!darkName) return null;
return (
<PairedThemeTile
darkVars={getVars(darkName)}
isActive={isPairActive(lightName)}
key={lightName}
lightName={lightName}
lightVars={getVars(lightName)}
onSelect={() => handleSelectPair(lightName)}
/>
);
})}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border/70 bg-background/70">
{filtered.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
No themes match your search.
{/* Section 2: Light-only themes */}
{lightOnly.length > 0 && (
<div className="mb-6">
<h3 className="mb-1 text-sm font-medium text-foreground">Light</h3>
<p className="mb-3 text-2xs text-muted-foreground">
Always uses a light appearance.
</p>
) : (
filtered.map((name) => {
const isActive = selectedTheme === name;
const isEffective = themeName === name;
const light = isLightTheme(name);
<div className="grid grid-cols-[repeat(auto-fill,174px)] gap-3">
{lightOnly.map((name) => (
<SingleThemeTile
isActive={selectedThemeName === name}
key={name}
name={name}
onSelect={() => handleSelectSingle(name)}
vars={getVars(name)}
/>
))}
</div>
</div>
)}
{/* Section 3: Dark-only themes */}
{darkOnly.length > 0 && (
<div className="mb-6">
<h3 className="mb-1 text-sm font-medium text-foreground">Dark</h3>
<p className="mb-3 text-2xs text-muted-foreground">
Always uses a dark appearance.
</p>
<div className="grid grid-cols-[repeat(auto-fill,174px)] gap-3">
{darkOnly.map((name) => (
<SingleThemeTile
isActive={selectedThemeName === name}
key={name}
name={name}
onSelect={() => handleSelectSingle(name)}
vars={getVars(name)}
/>
))}
</div>
</div>
)}
{/* Accent color picker */}
<div className="mt-2 shrink-0 pb-2">
<h3 className="mb-2 text-sm font-medium">Accent color</h3>
<div className="flex flex-wrap gap-2">
{ACCENT_COLORS.map((color) => {
const isNeutral = color.value === NEUTRAL_ACCENT;
const swatchColor = isNeutral
? "hsl(var(--foreground))"
: color.value;
const checkClassName =
isNeutral && isDark ? "text-black" : "text-white";
return (
<button
aria-pressed={isActive}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isActive
? "bg-primary/10 text-foreground"
: isEffective && followSystem
? "bg-primary/5 text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
"flex h-7 w-7 items-center justify-center rounded-full border border-border/50 transition-transform hover:scale-110",
accentColor === color.value &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
data-testid={`theme-option-${name}`}
key={name}
onClick={() => setTheme(name)}
ref={isActive ? activeRef : undefined}
data-testid={`accent-color-${color.name.toLowerCase()}`}
key={color.value}
onClick={() => setAccentColor(color.value)}
style={{ backgroundColor: swatchColor }}
title={color.name}
type="button"
>
{light ? (
<Sun className="h-4 w-4 shrink-0" />
) : (
<Moon className="h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate">
{formatThemeLabel(name)}
</span>
{isActive && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
{!isActive && isEffective && followSystem && (
<Monitor className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{accentColor === color.value && (
<Check className={cn("h-4 w-4", checkClassName)} />
)}
</button>
);
})
)}
</div>
<div className="mt-4 flex shrink-0 flex-col gap-3 pb-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="mb-2 text-sm font-medium">Accent color</h3>
<div className="flex flex-wrap gap-2">
{ACCENT_COLORS.map((color) => {
const isNeutral = color.value === NEUTRAL_ACCENT;
const swatchColor = isNeutral
? "hsl(var(--foreground))"
: color.value;
const checkClassName =
isNeutral && isDark ? "text-black" : "text-white";
return (
<button
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full border border-border/50 transition-transform hover:scale-110",
accentColor === color.value &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
data-testid={`accent-color-${color.name.toLowerCase()}`}
key={color.value}
onClick={() => setAccentColor(color.value)}
style={{ backgroundColor: swatchColor }}
title={color.name}
type="button"
>
{accentColor === color.value && (
<Check className={cn("h-4 w-4", checkClassName)} />
)}
</button>
);
})}
</div>
})}
</div>
<label
className="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground"
htmlFor="follow-system-switch"
>
<span className="min-w-0 truncate">Use system setting</span>
<Switch
checked={followSystem}
data-testid="follow-system-toggle"
id="follow-system-switch"
onCheckedChange={setFollowSystem}
/>
</label>
</div>
</section>
);

View File

@@ -0,0 +1,221 @@
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-24 w-[142px] shrink-0 drop-shadow-sm"
fill="none"
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>
);
}
export function ThemePreviewFrame({
className,
vars,
}: {
className?: string;
vars: ThemePreviewVars | null;
}) {
return (
<div
className={cn(
"relative h-28 w-[158px] overflow-hidden rounded-md border",
className,
)}
style={{
backgroundColor: hsl(vars, "--muted"),
borderColor: hsl(vars, "--border"),
}}
>
<div className="absolute bottom-0 right-0">
<ThemePreviewSvg vars={vars} />
</div>
</div>
);
}

View File

@@ -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),
};
}