mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
feat(desktop): mode-first appearance architecture
Redesigns the appearance settings with a two-level approach: 1. Top-level mode selector: System / Light / Dark - Each shows a mini preview thumbnail - System shows the split dark/light preview - Light/Dark show a single theme preview 2. Theme list below changes based on selected mode: - System → paired themes (with split preview tiles) - Light → all light-capable themes (paired light + light-only) - Dark → all dark-capable themes (paired dark + dark-only) Selecting a theme in System mode auto-enables follow-system. Selecting in Light/Dark mode disables follow-system.
This commit is contained in:
@@ -359,6 +359,8 @@ function HorizontalScrollWithFade({
|
||||
);
|
||||
}
|
||||
|
||||
type AppearanceMode = "system" | "light" | "dark";
|
||||
|
||||
function ThemeSettingsCard() {
|
||||
const {
|
||||
setTheme,
|
||||
@@ -366,44 +368,63 @@ function ThemeSettingsCard() {
|
||||
isDark,
|
||||
accentColor,
|
||||
setAccentColor,
|
||||
followSystem,
|
||||
setFollowSystem,
|
||||
} = useTheme();
|
||||
|
||||
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 [expanded, setExpanded] = useState(false);
|
||||
|
||||
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);
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
const handleSelectTheme = (name: SyntaxThemeName) => {
|
||||
setTheme(name);
|
||||
if (selectedMode === "system") {
|
||||
setFollowSystem(true);
|
||||
} else {
|
||||
setFollowSystem(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 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 handleSelectPair = (lightName: SyntaxThemeName) => {
|
||||
// Selecting a paired theme auto-enables follow-system
|
||||
setTheme(lightName);
|
||||
setFollowSystem(true);
|
||||
};
|
||||
|
||||
const handleSelectSingle = (name: SyntaxThemeName) => {
|
||||
setTheme(name);
|
||||
// Single themes don't follow system — they're always one mode
|
||||
setFollowSystem(false);
|
||||
};
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
||||
@@ -414,98 +435,112 @@ function ThemeSettingsCard() {
|
||||
description="Choose a theme for Buzz."
|
||||
/>
|
||||
|
||||
{/* Section 1: Paired themes (follow system) */}
|
||||
{/* Mode selector: System / Light / Dark */}
|
||||
<div className="mb-6 flex gap-3">
|
||||
{(
|
||||
[
|
||||
{ mode: "system" as const, label: "System" },
|
||||
{ mode: "light" as const, label: "Light" },
|
||||
{ mode: "dark" as const, label: "Dark" },
|
||||
] as const
|
||||
).map(({ mode, label }) => (
|
||||
<button
|
||||
aria-pressed={selectedMode === mode}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-2xl border px-5 py-3 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"
|
||||
>
|
||||
{mode === "system" && (
|
||||
<SystemPreferencePreviewFrame
|
||||
className="h-12 w-16"
|
||||
darkVars={getVars(
|
||||
pairedLight[0]
|
||||
? (getThemePair(pairedLight[0]) ?? pairedLight[0])
|
||||
: pairedLight[0],
|
||||
)}
|
||||
lightVars={getVars(pairedLight[0])}
|
||||
/>
|
||||
)}
|
||||
{mode === "light" && (
|
||||
<ThemePreviewFrame
|
||||
className="h-12 w-16"
|
||||
vars={getVars(allLightThemes[0])}
|
||||
/>
|
||||
)}
|
||||
{mode === "dark" && (
|
||||
<ThemePreviewFrame
|
||||
className="h-12 w-16"
|
||||
vars={getVars(allDarkThemes[0])}
|
||||
/>
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Theme list based on selected mode */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
Adapts to system
|
||||
</h3>
|
||||
{pairedLight.length > 4 && (
|
||||
<h3 className="text-sm font-medium text-foreground">Themes</h3>
|
||||
{((selectedMode === "system" && pairedLight.length > 4) ||
|
||||
(selectedMode === "light" && allLightThemes.length > 4) ||
|
||||
(selectedMode === "dark" && allDarkThemes.length > 4)) && (
|
||||
<button
|
||||
className="text-2xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleSection("paired")}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
{expandedSections.paired ? "Collapse" : "Show all"}
|
||||
{expanded ? "Collapse" : "Show all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<HorizontalScrollWithFade expanded={!!expandedSections.paired}>
|
||||
{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)}
|
||||
|
||||
<HorizontalScrollWithFade expanded={expanded}>
|
||||
{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)}
|
||||
/>
|
||||
))}
|
||||
</HorizontalScrollWithFade>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Light-only themes */}
|
||||
{lightOnly.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Light</h3>
|
||||
{lightOnly.length > 4 && (
|
||||
<button
|
||||
className="text-2xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleSection("light")}
|
||||
type="button"
|
||||
>
|
||||
{expandedSections.light ? "Collapse" : "Show all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<HorizontalScrollWithFade expanded={!!expandedSections.light}>
|
||||
{lightOnly.map((name) => (
|
||||
<SingleThemeTile
|
||||
isActive={selectedThemeName === name}
|
||||
key={name}
|
||||
name={name}
|
||||
onSelect={() => handleSelectSingle(name)}
|
||||
vars={getVars(name)}
|
||||
/>
|
||||
))}
|
||||
</HorizontalScrollWithFade>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 3: Dark-only themes */}
|
||||
{darkOnly.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Dark</h3>
|
||||
{darkOnly.length > 4 && (
|
||||
<button
|
||||
className="text-2xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleSection("dark")}
|
||||
type="button"
|
||||
>
|
||||
{expandedSections.dark ? "Collapse" : "Show all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<HorizontalScrollWithFade expanded={!!expandedSections.dark}>
|
||||
{darkOnly.map((name) => (
|
||||
<SingleThemeTile
|
||||
isActive={selectedThemeName === name}
|
||||
key={name}
|
||||
name={name}
|
||||
onSelect={() => handleSelectSingle(name)}
|
||||
vars={getVars(name)}
|
||||
/>
|
||||
))}
|
||||
</HorizontalScrollWithFade>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accent color picker */}
|
||||
<div className="mt-2 shrink-0 pb-2">
|
||||
<h3 className="mb-2 text-sm font-medium">Accent color</h3>
|
||||
|
||||
Reference in New Issue
Block a user