mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): redesign appearance settings with mode-first theme picker (#1528)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
BellRing,
|
||||
Bot,
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
Keyboard,
|
||||
LayoutTemplate,
|
||||
LockKeyhole,
|
||||
Monitor,
|
||||
MonitorCog,
|
||||
Moon,
|
||||
Search,
|
||||
Smartphone,
|
||||
Smile,
|
||||
Stethoscope,
|
||||
Sun,
|
||||
SunMoon,
|
||||
UserRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -33,8 +32,22 @@ 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 {
|
||||
SystemPreferencePreviewFrame,
|
||||
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,10 +202,162 @@ function formatThemeLabel(name: string): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a display label for a paired theme from its light variant name.
|
||||
* Strips mode-specific tokens (light, latte, dawn, lotus, ochin, lighter, plus)
|
||||
* from any position, handling names like "github-light-default", "light-plus",
|
||||
* "material-theme-lighter", and "gruvbox-light-soft".
|
||||
*/
|
||||
function pairedThemeLabel(lightName: string): string {
|
||||
const modeTokens = new Set([
|
||||
"light",
|
||||
"latte",
|
||||
"dawn",
|
||||
"lotus",
|
||||
"ochin",
|
||||
"lighter",
|
||||
"plus",
|
||||
]);
|
||||
const parts = lightName.split("-").filter((t) => !modeTokens.has(t));
|
||||
// If stripping removed everything (e.g. "light-plus"), fall back to the raw name
|
||||
const base = parts.length > 0 ? parts.join("-") : lightName;
|
||||
return formatThemeLabel(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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="group flex w-[168px] shrink-0 flex-col items-center text-center focus-visible:outline-hidden"
|
||||
data-testid={`theme-pair-${lightName}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<SystemPreferencePreviewFrame
|
||||
className={cn(
|
||||
"h-[112px] w-[168px] transition-shadow",
|
||||
isActive
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "group-hover:ring-2 group-hover:ring-border",
|
||||
)}
|
||||
darkVars={darkVars}
|
||||
lightVars={lightVars}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 w-full truncate text-xs",
|
||||
isActive ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{pairedThemeLabel(lightName)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleThemeTile({
|
||||
isActive,
|
||||
name,
|
||||
vars,
|
||||
onSelect,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
name: SyntaxThemeName;
|
||||
vars: ThemePreviewVars | null;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className="group flex w-[168px] shrink-0 flex-col items-center text-center focus-visible:outline-hidden"
|
||||
data-testid={`theme-option-${name}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreviewFrame
|
||||
className={cn(
|
||||
"h-[112px] w-[168px] transition-shadow",
|
||||
isActive
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "group-hover:ring-2 group-hover:ring-border",
|
||||
)}
|
||||
vars={vars}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 w-full truncate text-xs",
|
||||
isActive ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatThemeLabel(name)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type AppearanceMode = "system" | "light" | "dark";
|
||||
|
||||
function ThemeSettingsCard() {
|
||||
const {
|
||||
setTheme,
|
||||
themeName,
|
||||
selectedThemeName,
|
||||
isDark,
|
||||
accentColor,
|
||||
@@ -200,26 +365,94 @@ function ThemeSettingsCard() {
|
||||
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();
|
||||
|
||||
// Determine the active mode from current state
|
||||
const activeMode: AppearanceMode = followSystem
|
||||
? "system"
|
||||
: isDark
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const [selectedMode, setSelectedMode] = useState<AppearanceMode>(activeMode);
|
||||
|
||||
const getVars = (name: SyntaxThemeName) =>
|
||||
withAccentPreviewVars(
|
||||
previewVarsByTheme[name] ?? getThemeFallbackPreviewVars(name),
|
||||
accentColor,
|
||||
);
|
||||
|
||||
// All light themes (paired light + light-only)
|
||||
const allLightThemes = useMemo(
|
||||
() => [...pairedLight, ...lightOnly],
|
||||
[pairedLight, lightOnly],
|
||||
);
|
||||
|
||||
// All dark themes (paired dark + dark-only)
|
||||
const allDarkThemes = useMemo(() => {
|
||||
const pairedDark = pairedLight
|
||||
.map((l) => getThemePair(l))
|
||||
.filter(Boolean) as SyntaxThemeName[];
|
||||
return [...pairedDark, ...darkOnly];
|
||||
}, [pairedLight, darkOnly]);
|
||||
|
||||
const handleModeSelect = (mode: AppearanceMode) => {
|
||||
setSelectedMode(mode);
|
||||
if (mode === "system") {
|
||||
setFollowSystem(true);
|
||||
// If the current theme is unpaired, resolveSystemTheme can't switch it
|
||||
// with the OS. Fall back to the first paired theme so System mode works.
|
||||
const pair = getThemePair(selectedThemeName as SyntaxThemeName);
|
||||
if (!pair && pairedLight.length > 0) {
|
||||
setTheme(pairedLight[0]);
|
||||
}
|
||||
} else {
|
||||
setFollowSystem(false);
|
||||
// Switch to the counterpart theme when the current theme doesn't match
|
||||
// the selected mode. E.g. if the stored theme is light and the user
|
||||
// clicks Dark, apply the dark pair so the app immediately reflects the
|
||||
// chosen mode. For unpaired themes (no counterpart), fall back to the
|
||||
// first available theme in the target mode's list.
|
||||
const currentIsLight = LIGHT_THEMES.has(
|
||||
selectedThemeName as SyntaxThemeName,
|
||||
);
|
||||
const needsDark = mode === "dark" && currentIsLight;
|
||||
const needsLight = mode === "light" && !currentIsLight;
|
||||
if (needsDark || needsLight) {
|
||||
const pair = getThemePair(selectedThemeName as SyntaxThemeName);
|
||||
if (pair) {
|
||||
setTheme(pair);
|
||||
} else {
|
||||
// Unpaired theme — pick the first theme from the target mode
|
||||
const fallback = needsDark ? allDarkThemes[0] : allLightThemes[0];
|
||||
if (fallback) {
|
||||
setTheme(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTheme = selectedThemeName;
|
||||
const handleSelectTheme = (name: SyntaxThemeName) => {
|
||||
setTheme(name);
|
||||
if (selectedMode === "system") {
|
||||
setFollowSystem(true);
|
||||
} else {
|
||||
setFollowSystem(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return SYNTAX_THEMES;
|
||||
return SYNTAX_THEMES.filter((name) => name.includes(q));
|
||||
}, [search]);
|
||||
/** 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;
|
||||
};
|
||||
|
||||
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 +460,128 @@ 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}
|
||||
/>
|
||||
{/* Mode selector: System / Light / Dark */}
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ mode: "system" as const, label: "System", Icon: SunMoon },
|
||||
{ mode: "light" as const, label: "Light", Icon: Sun },
|
||||
{ mode: "dark" as const, label: "Dark", Icon: Moon },
|
||||
] as const
|
||||
).map(({ mode, label, Icon }) => (
|
||||
<button
|
||||
aria-pressed={selectedMode === mode}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedMode === mode
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border/70 text-muted-foreground hover:border-border hover:text-foreground",
|
||||
)}
|
||||
data-testid={`appearance-mode-${mode}`}
|
||||
key={mode}
|
||||
onClick={() => handleModeSelect(mode)}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</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.
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((name) => {
|
||||
const isActive = selectedTheme === name;
|
||||
const isEffective = themeName === name;
|
||||
const light = isLightTheme(name);
|
||||
{/* Theme grid — constrained to ~3 rows, scrolls internally */}
|
||||
<div className="relative mb-6">
|
||||
{/* Top fade */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-3"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, hsl(var(--background)), hsl(var(--background) / 0))",
|
||||
}}
|
||||
/>
|
||||
{/* Bottom fade */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-3"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, hsl(var(--background)), hsl(var(--background) / 0))",
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-[430px] overflow-y-auto rounded-lg pt-2">
|
||||
<div className="flex flex-wrap gap-4 p-1">
|
||||
{selectedMode === "system" &&
|
||||
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={() => handleSelectTheme(lightName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{selectedMode === "light" &&
|
||||
allLightThemes.map((name) => (
|
||||
<SingleThemeTile
|
||||
isActive={selectedThemeName === name}
|
||||
key={name}
|
||||
name={name}
|
||||
onSelect={() => handleSelectTheme(name)}
|
||||
vars={getVars(name)}
|
||||
/>
|
||||
))}
|
||||
{selectedMode === "dark" &&
|
||||
allDarkThemes.map((name) => (
|
||||
<SingleThemeTile
|
||||
isActive={selectedThemeName === name}
|
||||
key={name}
|
||||
name={name}
|
||||
onSelect={() => handleSelectTheme(name)}
|
||||
vars={getVars(name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent color picker */}
|
||||
<div className="shrink-0 px-1 pb-2">
|
||||
<h3 className="mb-2 text-sm font-medium">Accent color</h3>
|
||||
<div className="flex flex-wrap gap-2 p-1">
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -1274,6 +1274,9 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Switch to Light mode tab to reveal light themes
|
||||
await page.getByRole("button", { name: "Light" }).click();
|
||||
|
||||
// Switch to a light theme — verifies dark→light transition
|
||||
await page.getByTestId("theme-option-github-light").click();
|
||||
|
||||
@@ -1303,6 +1306,9 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
.poll(() => page.evaluate(() => localStorage.getItem("buzz-theme")))
|
||||
.toBe("github-light");
|
||||
|
||||
// Switch to Dark mode tab to reveal dark themes
|
||||
await page.getByRole("button", { name: "Dark" }).click();
|
||||
|
||||
// Switch back to a dark theme — verifies light→dark transition
|
||||
await page.getByTestId("theme-option-dracula").click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user