feat(desktop): add Buzz light + dark themes with branded sidebar gradient

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 <html> 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>
This commit is contained in:
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
2026-07-08 12:19:19 +01:00
parent 5dce80aff5
commit 9564cc8a38
6 changed files with 335 additions and 8 deletions

View File

@@ -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 (
<button
aria-pressed={isActive}
@@ -307,7 +309,9 @@ function PairedThemeTile({
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "group-hover:ring-2 group-hover:ring-border",
)}
darkGradient={darkName ? BUZZ_GRADIENT_STOPS[darkName] : undefined}
darkVars={darkVars}
lightGradient={BUZZ_GRADIENT_STOPS[lightName]}
lightVars={lightVars}
/>
<span
@@ -348,6 +352,7 @@ function SingleThemeTile({
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "group-hover:ring-2 group-hover:ring-border",
)}
sidebarGradient={BUZZ_GRADIENT_STOPS[name]}
vars={vars}
/>
<span

View File

@@ -1,5 +1,5 @@
export const SECTION_ICON_BUTTON_CLASS =
"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";
"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";

View File

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

View File

@@ -3,6 +3,19 @@ import { cn } from "@/shared/lib/cn";
export type ThemePreviewVars = Record<string, string>;
/**
* 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 }) {
<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={sidebarGradient ? `url(#${gradientId})` : sidebar}
height="180"
width="57.375"
/>
<rect
fill={sidebarForeground}
height="3.6"
@@ -191,6 +215,19 @@ function ThemePreviewSvg({ vars }: { vars: ThemePreviewVars | null }) {
<clipPath id={clipId}>
<rect fill={background} height="180" rx="3.6" width="288" />
</clipPath>
{sidebarGradient ? (
<linearGradient
id={gradientId}
x1="0"
x2="0"
y1="0"
y2="180"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor={sidebarGradient.top} />
<stop offset="1" stopColor={sidebarGradient.bottom} />
</linearGradient>
) : null}
</defs>
</svg>
);
@@ -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) */}
<g clipPath={`url(#${clipLight})`}>
<rect fill={lightBg} height="180" rx="3.6" width="288" />
<rect fill={lightSidebar} height="180" width="57.375" />
<rect
fill={lightGradient ? `url(#${lightGradientId})` : lightSidebar}
height="180"
width="57.375"
/>
<rect
fill={lightSidebarFg}
height="3.6"
@@ -391,7 +438,12 @@ function SystemPreferencePreviewSvg({
<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={darkGradient ? `url(#${darkGradientId})` : darkSidebar}
height="180"
width="57.375"
y="22"
/>
<rect
fill={darkSidebarFg}
height="3.6"
@@ -477,6 +529,32 @@ function SystemPreferencePreviewSvg({
<clipPath id={clipOuter}>
<rect fill="white" height="180" rx="3.6" width="288" y="22" />
</clipPath>
{lightGradient ? (
<linearGradient
gradientUnits="userSpaceOnUse"
id={lightGradientId}
x1="0"
x2="0"
y1="0"
y2="80"
>
<stop offset="0" stopColor={lightGradient.top} />
<stop offset="1" stopColor={lightGradient.bottom} />
</linearGradient>
) : null}
{darkGradient ? (
<linearGradient
gradientUnits="userSpaceOnUse"
id={darkGradientId}
x1="0"
x2="0"
y1="22"
y2="80"
>
<stop offset="0" stopColor={darkGradient.top} />
<stop offset="1" stopColor={darkGradient.bottom} />
</linearGradient>
) : null}
</defs>
</svg>
);
@@ -485,9 +563,11 @@ function SystemPreferencePreviewSvg({
export function ThemePreviewFrame({
className,
vars,
sidebarGradient,
}: {
className?: string;
vars: ThemePreviewVars | null;
sidebarGradient?: { top: string; bottom: string };
}) {
return (
<div
@@ -501,7 +581,7 @@ export function ThemePreviewFrame({
}}
>
<div className="absolute -bottom-1 -right-1 h-[90%] w-[90%]">
<ThemePreviewSvg vars={vars} />
<ThemePreviewSvg vars={vars} sidebarGradient={sidebarGradient} />
</div>
</div>
);
@@ -515,10 +595,14 @@ export function SystemPreferencePreviewFrame({
className,
darkVars,
lightVars,
lightGradient,
darkGradient,
}: {
className?: string;
darkVars: ThemePreviewVars | null;
lightVars: ThemePreviewVars | null;
lightGradient?: { top: string; bottom: string };
darkGradient?: { top: string; bottom: string };
}) {
return (
<div
@@ -531,7 +615,12 @@ export function SystemPreferencePreviewFrame({
}}
>
<div className="absolute -bottom-1 -right-1 h-[90%] w-[90%]">
<SystemPreferencePreviewSvg darkVars={darkVars} lightVars={lightVars} />
<SystemPreferencePreviewSvg
darkGradient={darkGradient}
darkVars={darkVars}
lightGradient={lightGradient}
lightVars={lightVars}
/>
</div>
</div>
);

View File

@@ -206,6 +206,16 @@ function applyAccentColor(value: string) {
root.style.setProperty("--sidebar-active-foreground", fgHsl);
}
/** Toggle the Buzz sidebar-gradient marker on the document root. */
function applyBuzzSidebar(themeName: string) {
const root = document.documentElement;
if (themeName === "buzz" || themeName === "buzz-dark") {
root.setAttribute("data-buzz-sidebar", "");
} else {
root.removeAttribute("data-buzz-sidebar");
}
}
/** Apply cached CSS vars synchronously to prevent FOUC. */
function applyCachedVars(): string | null {
try {
@@ -218,6 +228,7 @@ function applyCachedVars(): string | null {
}
root.classList.remove("light", "dark");
root.classList.add(isDark ? "dark" : "light");
applyBuzzSidebar(themeName);
const accent =
window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
@@ -246,6 +257,7 @@ async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
root.classList.remove("light", "dark");
root.classList.add(isDark ? "dark" : "light");
applyBuzzSidebar(name);
// Cache for FOUC prevention
try {

View File

@@ -7,8 +7,39 @@
import type { ThemeRegistrationRaw } from "shiki";
// Available syntax themes (all Shiki bundled themes, alphabetically sorted)
/**
* Buzz theme name. Buzz is a first-party light theme that reuses GitHub
* Light for every base color (backgrounds, text, borders, code) — the
* message area and containers are indistinguishable from GitHub Light. Its
* one distinguishing feature is a branded gradient painted across the
* sidebar/nav canvas, replacing GitHub Light's flat grey. The gradient is
* applied by {@link ThemeProvider} toggling a `data-buzz-sidebar` attribute
* on the document root; the CSS lives in `shared/styles/globals/theme.css`.
*/
export const BUZZ_THEME_NAME = "buzz";
/**
* Buzz Dark theme name. The dark-mode counterpart to {@link BUZZ_THEME_NAME}:
* reuses the GitHub Dark palette for every base color, with the same branded
* sidebar gradient (dark-tuned colors, see `shared/styles/globals/theme.css`).
* {@link ThemeProvider} toggles the shared `data-buzz-sidebar` attribute for
* this theme too; the `.dark` root class selects the dark gradient values.
*
* Buzz and Buzz Dark are paired in {@link THEME_PAIRS}, so the picker shows a
* combined "Buzz" tile under System mode (follow-OS) plus a single "Buzz" tile
* under Light and a "Buzz Dark" tile under Dark.
*/
export const BUZZ_DARK_THEME_NAME = "buzz-dark";
/** The Shiki bundle Buzz borrows its base palette from. */
export const BUZZ_BASE_THEME: SyntaxThemeName = "github-light";
// Available themes. "buzz" is a Buzz-branded theme that reuses the
// github-light palette plus a sidebar gradient; the rest are the Shiki
// bundled syntax themes, alphabetically sorted.
export const SYNTAX_THEMES = [
"buzz",
"buzz-dark",
"andromeeda",
"aurora-x",
"ayu-dark",
@@ -86,6 +117,7 @@ export const ONBOARDING_DEFAULT_THEME_NAME = (ONBOARDING_THEME_PREFERENCES.find(
// Known light themes — used by the theme picker to show sun/moon icons
// for themes that haven't been loaded yet.
export const LIGHT_THEMES: ReadonlySet<SyntaxThemeName> = new Set([
"buzz",
"catppuccin-latte",
"everforest-light",
"github-light",
@@ -111,6 +143,10 @@ const themeImports: Record<
SyntaxThemeName,
() => Promise<{ default: ThemeRegistrationRaw }>
> = {
// Buzz reuses the github-light palette; its gradient is applied separately.
buzz: () => import("shiki/themes/github-light.mjs"),
// Buzz Dark reuses the github-dark palette; dark gradient applied separately.
"buzz-dark": () => import("shiki/themes/github-dark.mjs"),
andromeeda: () => import("shiki/themes/andromeeda.mjs"),
"aurora-x": () => import("shiki/themes/aurora-x.mjs"),
"ayu-dark": () => import("shiki/themes/ayu-dark.mjs"),
@@ -189,6 +225,8 @@ export function isLightTheme(name: string): boolean {
export const THEME_PAIRS: ReadonlyMap<SyntaxThemeName, SyntaxThemeName> =
new Map([
// Light → Dark
// Buzz is the first-party pair; keep it first so it leads every category.
["buzz", "buzz-dark"],
["catppuccin-latte", "catppuccin-mocha"],
["everforest-light", "everforest-dark"],
["github-light", "github-dark"],
@@ -207,6 +245,7 @@ export const THEME_PAIRS: ReadonlyMap<SyntaxThemeName, SyntaxThemeName> =
["solarized-light", "solarized-dark"],
["vitesse-light", "vitesse-dark"],
// Dark → Light (reverse mappings)
["buzz-dark", "buzz"],
["catppuccin-mocha", "catppuccin-latte"],
["everforest-dark", "everforest-light"],
["github-dark", "github-light"],