Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 113 additions & 43 deletions desktop/src/features/settings/ui/SettingsPanels.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
Archive,
BellRing,
Expand Down Expand Up @@ -32,6 +33,7 @@ import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchi
import { cn } from "@/shared/lib/cn";
import {
ACCENT_COLORS,
isBuzzTheme,
NEUTRAL_ACCENT,
useTheme,
} from "@/shared/theme/ThemeProvider";
Expand Down Expand Up @@ -378,17 +380,36 @@ function SingleThemeTile({

type AppearanceMode = "system" | "light" | "dark";

// Reveal/hide motion for the accent picker: a small translate + opacity fade.
// The picker sits below the theme grid and reads as tucking up behind it, so
// it enters from above (slides *down* into place when a non-Buzz theme reveals
// it) and exits upward (slides up behind the grid when Buzz hides it). No
// height/scale — height collapse clipped the swatches behind the grid's bottom
// fade (the "white bar"). Snappier than the modal 0.2s since this is a small
// settings control, sharing the modal/ProfileSettingsCard easing curve.
const ACCENT_PICKER_TRANSITION = {
duration: 0.16,
ease: [0.23, 1, 0.32, 1] as const,
};

function ThemeSettingsCard() {
const {
setTheme,
selectedThemeName,
themeName,
isDark,
accentColor,
setAccentColor,
followSystem,
setFollowSystem,
} = useTheme();

// Buzz themes pin a neutral accent (GitHub black in light, white in dark),
// so the accent picker is hidden while a Buzz theme is active. `themeName` is
// the effective theme, so this also covers System mode resolving to Buzz.
const accentPickerHidden = isBuzzTheme(themeName);
const shouldReduceMotion = useReducedMotion();

const previewVarsByTheme = useThemePreviewVars();
const { pairedLight, lightOnly, darkOnly } = useThemeCategories();

Expand Down Expand Up @@ -522,15 +543,19 @@ function ThemeSettingsCard() {
"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))",
}}
/>
{/* Bottom fade — hidden while the accent picker is visible so its
near-white gradient (Buzz light) can't mask the swatches below it
(the "white bar"). Kept only when the picker is hidden. */}
{accentPickerHidden ? (
<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))",
}}
/>
) : null}
<div className="max-h-[430px] overflow-y-auto rounded-lg pt-2">
<div className="flex flex-wrap gap-4 p-1">
{selectedMode === "system" &&
Expand Down Expand Up @@ -572,44 +597,89 @@ function ThemeSettingsCard() {
</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
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>
{/* Accent color picker — hidden for Buzz themes (pinned neutral accent).
Reveal/hide with the translate-up + opacity fade defined by
ACCENT_PICKER_TRANSITION above. Reduced motion skips the transition
and just renders/unrenders. */}
{shouldReduceMotion ? (
accentPickerHidden ? null : (
<AccentPickerContent
accentColor={accentColor}
isDark={isDark}
setAccentColor={setAccentColor}
/>
)
) : (
<AnimatePresence initial={false}>
{accentPickerHidden ? null : (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="will-change-[opacity,transform]"
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key="accent-picker"
transition={ACCENT_PICKER_TRANSITION}
>
<AccentPickerContent
accentColor={accentColor}
isDark={isDark}
setAccentColor={setAccentColor}
/>
</motion.div>
)}
</AnimatePresence>
)}
</section>
);
}

/** Accent swatch grid — shared by the animated and reduced-motion reveal paths. */
function AccentPickerContent({
accentColor,
isDark,
setAccentColor,
}: {
accentColor: string;
isDark: boolean;
setAccentColor: (value: string) => void;
}) {
return (
<div className="shrink-0 px-1 pb-2 pt-1">
<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
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>
);
}

export function renderSettingsSection(
section: SettingsSection,
props: SettingsPanelProps,
Expand Down
22 changes: 17 additions & 5 deletions desktop/src/shared/styles/globals/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,8 @@
* turned those white (white-on-white under Buzz Dark); scoping keeps them on
* the normal accent-driven active colors.
*/
:root[data-buzz-sidebar] [data-testid="app-sidebar"] {
:root[data-buzz-sidebar] [data-testid="app-sidebar"],
:root[data-buzz-sidebar] [data-testid="settings-sidebar"] {
--sidebar-active: var(--buzz-active-fill);
--sidebar-active-foreground: var(--buzz-active-foreground);
}
Expand All @@ -317,7 +318,8 @@
* Dark variant: keep the active-pill foreground white on the dark gradient.
* (The pill itself is repainted to a translucent tint by the rule below.)
*/
:root[data-buzz-sidebar].dark [data-testid="app-sidebar"] {
:root[data-buzz-sidebar].dark [data-testid="app-sidebar"],
:root[data-buzz-sidebar].dark [data-testid="settings-sidebar"] {
--sidebar-active: var(--buzz-active-fill);
--sidebar-active-foreground: var(--buzz-active-foreground);
}
Expand All @@ -327,15 +329,19 @@
* gradient (Buzz-only; other themes keep `shadow-xs`). Higher specificity
* than the `data-[active=true]:shadow-xs` utility and unlayered, so it wins.
*/
:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-active="true"] {
:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-active="true"],
:root[data-buzz-sidebar] [data-testid="settings-sidebar"] [data-active="true"] {
box-shadow: none;
}

/*
* On dark the active pill is a translucent white tint rather than a solid
* fill, so it sits on the gradient without blowing out.
*/
:root[data-buzz-sidebar].dark [data-testid="app-sidebar"] [data-active="true"] {
:root[data-buzz-sidebar].dark [data-testid="app-sidebar"] [data-active="true"],
:root[data-buzz-sidebar].dark
[data-testid="settings-sidebar"]
[data-active="true"] {
background-color: color-mix(
in srgb,
hsl(var(--buzz-active-fill)) 16%,
Expand All @@ -348,13 +354,19 @@
* `--buzz-hover-surface`) instead of the grey `--sidebar-accent`. Active
* items are excluded - they stay solid on hover (their
* `data-[active=true]:hover:bg-sidebar-active` rule). Covers both top-level
* menu buttons and channel sub-buttons.
* menu buttons and channel sub-buttons, in the app nav and the settings nav.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
[data-sidebar="menu-button"]:not([data-active="true"]):hover,
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
[data-sidebar="menu-sub-button"]:not([data-active="true"]):hover,
:root[data-buzz-sidebar]
[data-testid="settings-sidebar"]
[data-sidebar="menu-button"]:not([data-active="true"]):hover,
:root[data-buzz-sidebar]
[data-testid="settings-sidebar"]
[data-sidebar="menu-sub-button"]:not([data-active="true"]):hover {
background-color: var(--buzz-hover-surface);
}
Expand Down
Loading
Loading