From 9564cc8a38e0b05babbe016751bff664ba331103 Mon Sep 17 00:00:00 2001
From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
<8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 8 Jul 2026 12:19:19 +0100
Subject: [PATCH] feat(desktop): add Buzz light + dark themes with branded
sidebar gradient
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce a first-party "Buzz" theme pair (Buzz / Buzz Dark) that reuses the
GitHub Light / GitHub Dark base palettes for every base color and adds a
branded gradient painted across the sidebar/nav canvas in place of the flat
grey.
- theme-loader: register `buzz` + `buzz-dark`, pair them in THEME_PAIRS, and
slot them first so they lead the System / Light / Dark picker categories.
Both borrow the github-light / github-dark Shiki bundles for syntax colors.
- ThemeProvider: toggle a `data-buzz-sidebar` attribute on when a Buzz
theme is active (both the cached-FOUC path and the async apply path).
- theme.css: gradient fill for every `bg-sidebar` surface (header, sidebar
column, footer) anchored with `background-attachment: fixed` so they read as
one continuous ramp; white active pill (translucent tint on dark), and a
shared `--buzz-hover-surface` translucent-white token used by nav hover, the
search box, and the section "+" / "…" action buttons — replacing the GitHub
grey. All rules are scoped to `[data-buzz-sidebar]` so other themes are
untouched.
- Section action buttons pick up a `sidebar-section-action` marker class so the
Buzz hover override can target them without disturbing the shared Tailwind
class in other themes.
- ThemePreviewFrame: paint the gradient into the picker swatches (single and
System-preview tiles) via BUZZ_GRADIENT_STOPS so the tiles are honest.
Desktop-only (TS/React + CSS). `just ci` clean.
Signed-off-by: Fizz <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
.../features/settings/ui/SettingsPanels.tsx | 5 +
.../sidebar/ui/sidebarSectionStyles.ts | 2 +-
desktop/src/shared/styles/globals/theme.css | 182 ++++++++++++++++++
.../src/shared/theme/ThemePreviewFrame.tsx | 101 +++++++++-
desktop/src/shared/theme/ThemeProvider.tsx | 12 ++
desktop/src/shared/theme/theme-loader.ts | 41 +++-
6 files changed, 335 insertions(+), 8 deletions(-)
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index 4104d91050..3968ac4e65 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -41,6 +41,7 @@ import {
getThemePair,
} from "@/shared/theme/theme-loader";
import {
+ BUZZ_GRADIENT_STOPS,
SystemPreferencePreviewFrame,
ThemePreviewFrame,
type ThemePreviewVars,
@@ -292,6 +293,7 @@ function PairedThemeTile({
darkVars: ThemePreviewVars | null;
onSelect: () => void;
}) {
+ const darkName = getThemePair(lightName);
return (
svg]:size-4 [&>svg]:shrink-0";
+ "sidebar-section-action flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring [&>svg]:size-4 [&>svg]:shrink-0";
export const SECTION_ACTION_VISIBILITY_CLASS =
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css
index e31af02632..d7a4fd625d 100644
--- a/desktop/src/shared/styles/globals/theme.css
+++ b/desktop/src/shared/styles/globals/theme.css
@@ -163,3 +163,185 @@
cursor: default;
}
}
+
+/*
+ * Buzz theme — sidebar gradient.
+ *
+ * Buzz reuses the GitHub Light palette for every base color; its one
+ * distinguishing feature is this gradient painted across the sidebar/nav
+ * canvas, replacing GitHub Light's flat grey. ThemeProvider toggles the
+ * `data-buzz-sidebar` attribute on when the Buzz theme is active.
+ *
+ * These rules are intentionally UNLAYERED: the `bg-sidebar` background-color
+ * lives in Tailwind's `utilities` layer, and unlayered declarations win over
+ * any layered ones, so this reliably overrides it without an `!important`.
+ *
+ * The gradient is applied to every `bg-sidebar` canvas surface (the top
+ * chrome bar, the sidebar column, and the inset margin behind the white
+ * content card). `background-attachment: fixed` anchors the gradient to the
+ * viewport so these separate surfaces read as one continuous gradient rather
+ * than each restarting the ramp. `background-color` is cleared so the grey
+ * `--sidebar-background` never shows through.
+ *
+ * Item hover/active states use `bg-sidebar-accent` / `bg-sidebar-active`
+ * (distinct tokens) and are intentionally left untouched.
+ */
+:root[data-buzz-sidebar] {
+ --buzz-gradient-top: #e6e6b6;
+ --buzz-gradient-bottom: #c4d0da;
+}
+
+/*
+ * Dark variant (Buzz Dark). Reuses the GitHub Dark base palette; only the
+ * three Buzz tokens change, so every downstream rule (gradient fill, hover,
+ * search box, active pill) adapts automatically. The `.dark` class is set on
+ * the document root whenever a dark theme is active.
+ */
+:root[data-buzz-sidebar].dark {
+ --buzz-gradient-top: #2b2b18;
+ --buzz-gradient-bottom: #1b2530;
+}
+
+:root[data-buzz-sidebar] .bg-sidebar {
+ background-color: transparent;
+ background-image: linear-gradient(
+ to bottom,
+ var(--buzz-gradient-top),
+ var(--buzz-gradient-bottom)
+ );
+ background-attachment: fixed;
+ background-size: 100vw 100vh;
+ background-repeat: no-repeat;
+}
+
+/*
+ * The pinned header (search + nav) and the footer (profile card) are painted
+ * with an opaque `--sidebar-background` fill plus a soft edge-fade pseudo-
+ * element (see `components.css`). Under GitHub Light that fill is the flat
+ * grey we are replacing, so it reads as an opaque panel floating over the
+ * gradient. Repaint both regions with the SAME viewport-fixed gradient so
+ * they line up seamlessly with the surrounding sidebar canvas, and drop the
+ * edge-fade (there is no longer a distinct surface to fade into).
+ */
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-testid="sidebar-pinned-header"],
+:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-sidebar="footer"] {
+ background-color: transparent;
+ background-image: linear-gradient(
+ to bottom,
+ var(--buzz-gradient-top),
+ var(--buzz-gradient-bottom)
+ );
+ background-attachment: fixed;
+ background-size: 100vw 100vh;
+ background-repeat: no-repeat;
+}
+
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-testid="sidebar-pinned-header"]::before,
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-sidebar="footer"]::before {
+ background: none;
+}
+
+/*
+ * Active nav item. The accent system (`applyAccentColor` in ThemeProvider)
+ * sets `--sidebar-active` to the theme foreground as an INLINE style on
+ * :root, which renders the selected page as a solid black pill under GitHub
+ * Light. For the Buzz theme we want a white pill instead. Override the two
+ * active tokens here — `!important` is required because an important
+ * stylesheet declaration is what beats the non-important inline style set by
+ * the accent system. Text flips to the theme foreground so it stays legible
+ * on the white pill; the derived `bg-sidebar-active-foreground/20` badge on
+ * active items follows automatically.
+ */
+:root[data-buzz-sidebar] {
+ --sidebar-active: 0 0% 100% !important;
+ --sidebar-active-foreground: var(--foreground) !important;
+
+ /* Shared translucent-white fill for hover states and the search box. */
+ --buzz-hover-surface: rgb(255 255 255 / 0.6);
+}
+
+/*
+ * Dark variant: a solid white pill and 60%-white fills are too harsh on the
+ * dark gradient. Use a light translucent tint for the active pill (keeping
+ * the light theme foreground for legible text) and a lower-opacity white for
+ * hover / search surfaces.
+ */
+:root[data-buzz-sidebar].dark {
+ --sidebar-active: 0 0% 100% !important;
+ --sidebar-active-foreground: 0 0% 100% !important;
+
+ --buzz-hover-surface: rgb(255 255 255 / 0.12);
+}
+
+/*
+ * On dark the active pill is a translucent white tint rather than a solid
+ * fill, so it sits on the gradient without blowing out. The `!important`
+ * background overrides the `bg-sidebar-active` utility for this state only.
+ */
+:root[data-buzz-sidebar].dark [data-testid="app-sidebar"] [data-active="true"] {
+ background-color: rgb(255 255 255 / 0.16) !important;
+}
+
+/*
+ * Active nav pill: drop the drop shadow so the white tab sits flat on the
+ * 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"] {
+ box-shadow: none;
+}
+
+/*
+ * Hover on non-active nav items uses a translucent white fill (see
+ * `--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.
+ */
+: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 {
+ background-color: var(--buzz-hover-surface);
+}
+
+/*
+ * Search box: match the same translucent white fill (see
+ * `--buzz-hover-surface`) instead of the grey `bg-sidebar-border/35` fill, at
+ * rest and on hover/focus.
+ */
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-testid="open-search"],
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-testid="open-search"]:hover,
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-testid="open-search"]:focus-visible {
+ background-color: var(--buzz-hover-surface);
+}
+
+/*
+ * Section action buttons (the "+" and "…" at the right edge of each sidebar
+ * section header): match the same translucent white fill (see
+ * `--buzz-hover-surface`) on hover/focus instead of the grey
+ * `bg-sidebar-border/35` fill. Unlayered + `[data-buzz-sidebar]` prefix beats
+ * the Tailwind utility so it only applies under the Buzz theme.
+ */
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ .sidebar-section-action:hover,
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ .sidebar-section-action:focus-visible {
+ background-color: var(--buzz-hover-surface);
+}
diff --git a/desktop/src/shared/theme/ThemePreviewFrame.tsx b/desktop/src/shared/theme/ThemePreviewFrame.tsx
index 7a118e1058..8003b52fe7 100644
--- a/desktop/src/shared/theme/ThemePreviewFrame.tsx
+++ b/desktop/src/shared/theme/ThemePreviewFrame.tsx
@@ -3,6 +3,19 @@ import { cn } from "@/shared/lib/cn";
export type ThemePreviewVars = Record;
+/**
+ * Buzz sidebar-gradient stop colors, keyed by theme name. Single source of
+ * truth for the picker swatch — must stay in sync with the `--buzz-gradient-*`
+ * values in `shared/styles/globals/theme.css`.
+ */
+export const BUZZ_GRADIENT_STOPS: Record<
+ string,
+ { top: string; bottom: string }
+> = {
+ buzz: { top: "#e6e6b6", bottom: "#c4d0da" },
+ "buzz-dark": { top: "#2b2b18", bottom: "#1b2530" },
+};
+
export const LIGHT_PREVIEW_VARS: ThemePreviewVars = {
"--background": "0 0% 100%",
"--border": "0 0% 89.8%",
@@ -33,8 +46,15 @@ function hslAlpha(vars: ThemePreviewVars | null, key: string, alpha: number) {
return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]} / ${alpha})`;
}
-function ThemePreviewSvg({ vars }: { vars: ThemePreviewVars | null }) {
+function ThemePreviewSvg({
+ vars,
+ sidebarGradient,
+}: {
+ vars: ThemePreviewVars | null;
+ sidebarGradient?: { top: string; bottom: string };
+}) {
const clipId = React.useId().replace(/:/g, "");
+ const gradientId = `${clipId}-buzz`;
const background = hsl(vars, "--background");
const border = hsl(vars, "--border");
const foreground = hsl(vars, "--foreground");
@@ -56,7 +76,11 @@ function ThemePreviewSvg({ vars }: { vars: ThemePreviewVars | null }) {
-
+
+ {sidebarGradient ? (
+
+
+
+
+ ) : null}
);
@@ -203,14 +240,20 @@ function ThemePreviewSvg({ vars }: { vars: ThemePreviewVars | null }) {
function SystemPreferencePreviewSvg({
darkVars,
lightVars,
+ lightGradient,
+ darkGradient,
}: {
darkVars: ThemePreviewVars | null;
lightVars: ThemePreviewVars | null;
+ lightGradient?: { top: string; bottom: string };
+ darkGradient?: { top: string; bottom: string };
}) {
const clipBase = React.useId().replace(/:/g, "");
const clipDark = `${clipBase}-dark`;
const clipLight = `${clipBase}-light`;
const clipOuter = `${clipBase}-outer`;
+ const lightGradientId = `${clipBase}-buzz-light`;
+ const darkGradientId = `${clipBase}-buzz-dark`;
// Dark half colors
const darkBg = hsl(darkVars, "--background");
@@ -240,7 +283,11 @@ function SystemPreferencePreviewSvg({
{/* Light half (top) */}
-
+
-
+
+ {lightGradient ? (
+
+
+
+
+ ) : null}
+ {darkGradient ? (
+
+
+
+
+ ) : null}
);
@@ -485,9 +563,11 @@ function SystemPreferencePreviewSvg({
export function ThemePreviewFrame({
className,
vars,
+ sidebarGradient,
}: {
className?: string;
vars: ThemePreviewVars | null;
+ sidebarGradient?: { top: string; bottom: string };
}) {
return (