feat(desktop): remove theme selection from onboarding, default to Buzz + System (#1947)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-07-16 06:54:16 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 36c3c49949
commit 2c08271ba9
12 changed files with 88 additions and 525 deletions
+14 -8
View File
@@ -15,10 +15,10 @@
The inline script below reads the cached theme background (same
`buzz-theme-cache` entry ThemeProvider writes) and applies it synchronously
so the boot color matches the themed loading gate — no black flash on light
themes. On the first-ever launch (no cache yet) it seeds the `dark` class
synchronously so the setup gate reads the dark `:root` vars instead of the
light Catppuccin-Latte default, matching the dark `houston` theme
ThemeProvider applies moments later — no light flash before it loads.
themes. On the first-ever launch (no cache yet) it seeds the light/dark
class from the OS color scheme, matching the Buzz theme in System mode
that ThemeProvider applies moments later — no wrong-scheme flash before
it loads.
-->
<style>
html {
@@ -31,10 +31,16 @@
try {
cached = window.localStorage.getItem("buzz-theme-cache");
if (!cached) {
// No stored theme: the default theme (`houston`) is dark, so seed
// the `dark` class now. Otherwise the setup gate would paint from
// the light `:root` vars until ThemeProvider loads asynchronously.
document.documentElement.classList.add("dark");
// No stored theme: the default is Buzz following the OS scheme,
// so seed the matching class (and backdrop) now. Otherwise the
// setup gate would paint the wrong scheme until ThemeProvider
// loads asynchronously.
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.add("light");
document.documentElement.style.backgroundColor = "#fff";
}
return;
}
parsed = JSON.parse(cached);
@@ -12,19 +12,11 @@ import {
importIdentity,
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
import {
ACCENT_STORAGE_KEY,
NEUTRAL_ACCENT,
THEME_STORAGE_KEY,
useTheme,
} from "@/shared/theme/ThemeProvider";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { ONBOARDING_DEFAULT_THEME_NAME } from "@/shared/theme/theme-loader";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";
import { AvatarStep } from "./AvatarStep";
import { BackupStep } from "./BackupStep";
import { MembershipDenied } from "./MembershipDenied";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import { useCommunities } from "@/features/communities/useCommunities";
@@ -34,8 +26,6 @@ import {
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { ProfileStep } from "./ProfileStep";
import { SetupStep } from "./SetupStep";
import { ThemeStep, preloadThemePreviewVars } from "./ThemeStep";
import type {
OnboardingActions,
OnboardingPage,
@@ -190,36 +180,6 @@ export function OnboardingFlow({
const [transitionDirection, setTransitionDirection] =
React.useState<OnboardingTransitionDirection>("forward");
const systemColorScheme = useSystemColorScheme();
const { accentColor, setAccentColor, setTheme, themeName } = useTheme();
const ensureThemeStepDefaults = React.useCallback(() => {
const hasStoredTheme =
window.localStorage.getItem(THEME_STORAGE_KEY) !== null;
const hasStoredAccent =
window.localStorage.getItem(ACCENT_STORAGE_KEY) !== null;
if (!hasStoredTheme && themeName !== ONBOARDING_DEFAULT_THEME_NAME) {
setTheme(ONBOARDING_DEFAULT_THEME_NAME);
}
if (!hasStoredAccent && accentColor !== NEUTRAL_ACCENT) {
setAccentColor(NEUTRAL_ACCENT);
}
}, [accentColor, setAccentColor, setTheme, themeName]);
React.useEffect(() => {
if (
currentPage === "profile" ||
currentPage === "backup" ||
currentPage === "avatar"
) {
void preloadThemePreviewVars().catch(() => undefined);
}
if (currentPage === "avatar") {
ensureThemeStepDefaults();
}
}, [currentPage, ensureThemeStepDefaults]);
const resetProfileSaveError = React.useCallback(() => {
profileUpdateMutation.reset();
@@ -236,20 +196,6 @@ export function OnboardingFlow({
[resetProfileSaveError],
);
const showSetupPage = React.useCallback(() => {
setTransitionDirection("forward");
setCurrentPage("setup");
}, []);
const showThemePage = React.useCallback(
(direction: OnboardingTransitionDirection = "forward") => {
ensureThemeStepDefaults();
setTransitionDirection(direction);
setCurrentPage("theme");
},
[ensureThemeStepDefaults],
);
const showAvatarPage = React.useCallback(
(direction: OnboardingTransitionDirection = "forward") => {
setTransitionDirection(direction);
@@ -430,8 +376,7 @@ export function OnboardingFlow({
const pageIndex = activeSteps.indexOf(normalizedPage);
const currentStep = pageIndex >= 0 ? pageIndex + STEP_OFFSET : STEP_OFFSET;
const totalOnboardingSteps = activeSteps.length;
const hideFixedProgressOnCompact =
currentPage === "avatar" || currentPage === "theme";
const hideFixedProgressOnCompact = currentPage === "avatar";
// Swapping the identity changes the pubkey, which remounts this flow
// (keyed on pubkey in App.tsx) and re-runs the onboarding gate: the new
@@ -502,27 +447,14 @@ export function OnboardingFlow({
return (
<>
<div
className={`buzz-startup-shell flex items-start justify-center overflow-y-auto bg-background px-4 py-8 text-foreground ${
currentPage === "profile" ||
currentPage === "backup" ||
currentPage === "avatar" ||
currentPage === "key-import"
? "buzz-onboarding-neutral-theme"
: ""
}`}
className="buzz-onboarding-neutral-theme buzz-startup-shell flex items-start justify-center overflow-y-auto bg-background px-4 py-8 text-foreground"
data-testid="onboarding-gate"
data-system-color-scheme={systemColorScheme}
>
<StartupWindowDragRegion />
<div
className={`relative my-auto flex w-full flex-col items-center text-center ${
currentPage === "theme"
? "max-w-[1180px]"
: currentPage === "avatar"
? "max-w-[1080px]"
: currentPage === "setup"
? "max-w-[920px]"
: "max-w-[500px]"
currentPage === "avatar" ? "max-w-[1080px]" : "max-w-[500px]"
}`}
>
<OnboardingSlideTransition
@@ -643,15 +575,7 @@ export function OnboardingFlow({
onImport={importExistingKey}
/>
</OnboardingSlideTransition>
) : currentPage === "backup" ? (
<BackupStep
currentStep={currentStep}
direction={transitionDirection}
onBack={showProfilePage}
onNext={() => showAvatarPage()}
totalSteps={totalOnboardingSteps}
/>
) : currentPage === "avatar" ? (
) : (
<AvatarStep
actions={{
advanceWithoutSaving: complete,
@@ -669,22 +593,6 @@ export function OnboardingFlow({
state={avatarStepState}
totalSteps={totalOnboardingSteps}
/>
) : currentPage === "theme" ? (
<ThemeStep
actions={{
skip: showSetupPage,
submit: showSetupPage,
}}
direction={transitionDirection}
/>
) : (
<SetupStep
actions={{
back: () => showThemePage("backward"),
complete,
}}
direction={transitionDirection}
/>
)}
</div>
</div>
@@ -1,311 +0,0 @@
import { Check } from "lucide-react";
import * as React from "react";
import {
ACCENT_COLORS,
ACCENT_STORAGE_KEY,
NEUTRAL_ACCENT,
THEME_STORAGE_KEY,
useTheme,
} from "@/shared/theme/ThemeProvider";
import {
ONBOARDING_DEFAULT_THEME_NAME,
SYNTAX_THEMES,
type SyntaxThemeName,
} 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";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import type { ThemeStepActions } from "./types";
type ThemeStepProps = {
actions: ThemeStepActions;
direction: OnboardingTransitionDirection;
};
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;
const THEME_TILE_GAP = 12;
const THEME_VISIBLE_ROW_COUNT = 2;
const THEME_ROW_PEEK_HEIGHT = 48;
const THEME_SCROLL_MAX_HEIGHT =
THEME_TILE_HEIGHT * THEME_VISIBLE_ROW_COUNT +
THEME_TILE_GAP * THEME_VISIBLE_ROW_COUNT +
THEME_ROW_PEEK_HEIGHT;
function contrastColorForHex(hex: string) {
const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!match) {
return "#ffffff";
}
const r = Number.parseInt(match[1], 16);
const g = Number.parseInt(match[2], 16);
const b = Number.parseInt(match[3], 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
}
function formatThemeLabel(name: string): string {
return name
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function getOrderedThemes() {
return [
ONBOARDING_DEFAULT_THEME_NAME,
...SYNTAX_THEMES.filter((name) => name !== ONBOARDING_DEFAULT_THEME_NAME),
];
}
export { preloadThemePreviewVars } from "@/shared/theme/useThemePreviewVars";
function GradualBottomBlur() {
return (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 -bottom-4 z-10 h-28 overflow-hidden"
>
{GRADUAL_BLUR_LEVELS.map((blur, index) => {
const transparentStop = 96 - index * 11;
const solidStop = Math.max(0, transparentStop - 28);
const maskImage = `linear-gradient(to top, black 0%, black ${solidStop}%, transparent ${transparentStop}%)`;
return (
<div
className="absolute inset-0"
key={blur}
style={{
WebkitBackdropFilter: `blur(${blur}px)`,
WebkitMaskImage: maskImage,
backdropFilter: `blur(${blur}px)`,
maskImage,
}}
/>
);
})}
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(to top, hsl(var(--background)) 0%, hsl(var(--background) / 0.64) 30%, hsl(var(--background) / 0.18) 64%, transparent 100%)",
}}
/>
</div>
);
}
function ThemeTile({
isActive,
name,
onSelect,
vars,
}: {
isActive: boolean;
name: SyntaxThemeName;
onSelect: () => void;
vars: ThemePreviewVars | null;
}) {
return (
<button
aria-pressed={isActive}
className={cn(
"group flex w-[174px] min-w-0 flex-col rounded-lg border bg-background/70 p-2 text-left transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary text-foreground shadow-sm"
: "border-border/70 text-muted-foreground hover:border-border hover:bg-accent/70 hover:text-accent-foreground",
)}
data-testid={`onboarding-theme-option-${name}`}
onClick={onSelect}
type="button"
>
<ThemePreviewFrame vars={vars} />
<div className="mt-2 flex min-h-6 items-center gap-2 px-1">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{formatThemeLabel(name)}
</span>
{isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : null}
</div>
</button>
);
}
function AccentColorPicker({
accentColor,
onSelect,
}: {
accentColor: string;
onSelect: (value: string) => void;
}) {
return (
<div className="mx-auto mt-6 w-fit max-w-full rounded-xl bg-muted p-3">
<div className="flex flex-wrap items-center justify-center gap-2">
{ACCENT_COLORS.map((color) => {
const isNeutral = color.value === NEUTRAL_ACCENT;
const isSelected = accentColor === color.value;
const swatchBackground = isNeutral
? "hsl(var(--foreground))"
: color.value;
const selectedRingColor = isNeutral
? "hsl(var(--background))"
: contrastColorForHex(color.value);
return (
<button
aria-label={`Use ${color.name} accent color`}
aria-pressed={isSelected}
className="relative h-9 w-9 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.12] focus-visible:scale-[1.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`onboarding-accent-color-${color.name.toLowerCase()}`}
key={color.value}
onClick={() => onSelect(color.value)}
style={{ background: swatchBackground }}
title={color.name}
type="button"
>
{isSelected ? (
<span
className="absolute inset-1 rounded-full border-[3px]"
style={{
borderColor: selectedRingColor,
}}
/>
) : null}
</button>
);
})}
</div>
</div>
);
}
export function ThemeStep({ actions, direction }: ThemeStepProps) {
const { skip, submit } = actions;
const { accentColor, setAccentColor, setTheme, themeName } = useTheme();
const previewVarsByTheme = useThemePreviewVars();
const orderedThemes = React.useMemo(() => getOrderedThemes(), []);
React.useEffect(() => {
const hasStoredTheme =
window.localStorage.getItem(THEME_STORAGE_KEY) !== null;
const hasStoredAccent =
window.localStorage.getItem(ACCENT_STORAGE_KEY) !== null;
if (!hasStoredTheme && themeName !== ONBOARDING_DEFAULT_THEME_NAME) {
setTheme(ONBOARDING_DEFAULT_THEME_NAME);
}
if (!hasStoredAccent && accentColor !== NEUTRAL_ACCENT) {
setAccentColor(NEUTRAL_ACCENT);
}
}, [accentColor, setAccentColor, setTheme, themeName]);
return (
<OnboardingSlideTransition
className="flex w-full flex-col items-center pb-40 text-center lg:pb-0"
data-testid="onboarding-page-theme"
direction={direction}
transitionKey={`theme-${direction}`}
>
<div className="grid w-full max-w-[1180px] items-start gap-12 lg:grid-cols-[minmax(260px,320px)_minmax(0,760px)] lg:gap-14">
<div className="flex w-full flex-col items-center text-center lg:items-start lg:text-left">
<div className="w-full max-w-[360px]">
<h1 className="text-3xl font-semibold text-foreground">
Pick a theme
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Choose a look that makes Buzz feel like yours.
</p>
</div>
</div>
<div className="w-full">
<div className="relative w-full">
<div
className="overflow-y-auto pb-20 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{
maxHeight: `min(60dvh, ${THEME_SCROLL_MAX_HEIGHT}px)`,
}}
>
<div
className="grid justify-center gap-3"
style={{
gridTemplateColumns: `repeat(auto-fill, ${THEME_TILE_WIDTH}px)`,
}}
>
{orderedThemes.map((name) => {
const vars = withAccentPreviewVars(
previewVarsByTheme[name] ??
getThemeFallbackPreviewVars(name),
accentColor,
);
return (
<ThemeTile
isActive={themeName === name}
key={name}
name={name}
onSelect={() => setTheme(name)}
vars={vars}
/>
);
})}
</div>
</div>
<GradualBottomBlur />
</div>
<AccentColorPicker
accentColor={accentColor}
onSelect={setAccentColor}
/>
<div className="mt-10 flex w-full flex-col gap-3 lg:mx-auto lg:max-w-[500px] max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:z-40 max-lg:mt-0 max-lg:max-w-none max-lg:border-t max-lg:border-border max-lg:bg-background max-lg:p-4 max-lg:pb-[max(1rem,env(safe-area-inset-bottom))]">
<Button
className="h-10 w-full"
data-testid="onboarding-next"
onClick={submit}
type="button"
>
Next
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
data-testid="onboarding-skip"
onClick={skip}
type="button"
variant="ghost"
>
Skip
</Button>
<StepProgress
activeSegmentClassName="bg-primary"
className="mt-1 lg:hidden"
completeSegmentClassName="bg-primary/35"
currentStep={4}
inactiveSegmentClassName="bg-muted-foreground/25"
/>
</div>
</div>
</div>
</OnboardingSlideTransition>
);
}
@@ -1,109 +1,55 @@
/**
* Tests for the onboarding step-count and routing logic that was added for the
* key-backup feature. These are pure-logic tests — no React rendering needed.
* Tests for the onboarding step-count logic and the BackupStep gating helper
* (BackupStep now runs in the machine onboarding flow). These are pure-logic
* tests — no React rendering needed.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { backupNextDisabled } from "./BackupStep.tsx";
// Mirrors the FRESH_STEPS / IMPORT_STEPS arrays in OnboardingFlow.tsx.
// key-import is normalised to "profile" before the indexOf lookup.
const FRESH_STEPS = ["profile", "backup", "avatar", "theme", "setup"];
const IMPORT_STEPS = ["profile", "avatar", "theme", "setup"];
const STEP_OFFSET = 2;
// Mirrors the activeSteps array in OnboardingFlow.tsx. The relay-scoped flow
// owns only the community profile: profile → avatar. key-import is normalised
// to "profile" before the indexOf lookup.
const ACTIVE_STEPS = ["profile", "avatar"];
const STEP_OFFSET = 1;
/**
* Mirrors the currentStep derivation in OnboardingFlow.tsx.
* Fresh path: profile(2) → backup(3) → avatar(4) → theme(5) → setup(6)
* Imported path: profile(2) → avatar(3) → theme(4) → setup(5)
* Mirrors the currentStep derivation in OnboardingFlow.tsx:
* profile(1) → avatar(2).
*/
function computeCurrentStep(page, identityWasImported) {
const steps = identityWasImported ? IMPORT_STEPS : FRESH_STEPS;
function computeCurrentStep(page) {
const normalizedPage = page === "key-import" ? "profile" : page;
const idx = steps.indexOf(normalizedPage);
const idx = ACTIVE_STEPS.indexOf(normalizedPage);
return idx >= 0 ? idx + STEP_OFFSET : STEP_OFFSET;
}
function computeTotalSteps(identityWasImported) {
const steps = identityWasImported ? IMPORT_STEPS : FRESH_STEPS;
return steps.length + 1;
function computeTotalSteps() {
return ACTIVE_STEPS.length;
}
// ---------------------------------------------------------------------------
// Total step count
// Step count and numbering
// ---------------------------------------------------------------------------
test("totalSteps_is_5_when_identity_was_imported", () => {
assert.equal(computeTotalSteps(true), 5);
test("totalSteps_is_2", () => {
assert.equal(computeTotalSteps(), 2);
});
test("totalSteps_is_6_on_fresh_key_path", () => {
assert.equal(computeTotalSteps(false), 6);
test("currentStep_profile_is_1", () => {
assert.equal(computeCurrentStep("profile"), 1);
});
// ---------------------------------------------------------------------------
// Step numbers — fresh path (6 steps)
// ---------------------------------------------------------------------------
test("currentStep_profile_is_2_on_fresh_path", () => {
assert.equal(computeCurrentStep("profile", false), 2);
test("currentStep_key_import_is_1", () => {
assert.equal(computeCurrentStep("key-import"), 1);
});
test("currentStep_key_import_is_2_on_fresh_path", () => {
assert.equal(computeCurrentStep("key-import", false), 2);
test("currentStep_avatar_is_2", () => {
assert.equal(computeCurrentStep("avatar"), 2);
});
test("currentStep_backup_is_3_on_fresh_path", () => {
assert.equal(computeCurrentStep("backup", false), 3);
});
test("currentStep_avatar_is_4_on_fresh_path", () => {
assert.equal(computeCurrentStep("avatar", false), 4);
});
test("currentStep_theme_is_5_on_fresh_path", () => {
assert.equal(computeCurrentStep("theme", false), 5);
});
test("currentStep_setup_is_6_on_fresh_path", () => {
assert.equal(computeCurrentStep("setup", false), 6);
});
// ---------------------------------------------------------------------------
// Step numbers — imported key path (5 steps)
// ---------------------------------------------------------------------------
test("currentStep_profile_is_2_on_imported_path", () => {
assert.equal(computeCurrentStep("profile", true), 2);
});
test("currentStep_avatar_is_3_on_imported_path", () => {
assert.equal(computeCurrentStep("avatar", true), 3);
});
test("currentStep_theme_is_4_on_imported_path", () => {
assert.equal(computeCurrentStep("theme", true), 4);
});
test("currentStep_setup_is_5_on_imported_path", () => {
assert.equal(computeCurrentStep("setup", true), 5);
});
// ---------------------------------------------------------------------------
// Routing: profile submit goes to backup (fresh) or avatar (imported)
// ---------------------------------------------------------------------------
test("profile_submit_routes_to_backup_on_fresh_path", () => {
const identityWasImported = false;
const nextPage = identityWasImported ? "avatar" : "backup";
assert.equal(nextPage, "backup");
});
test("profile_submit_routes_to_avatar_on_imported_path", () => {
const identityWasImported = true;
const nextPage = identityWasImported ? "avatar" : "backup";
assert.equal(nextPage, "avatar");
test("currentStep_falls_back_to_1_for_pages_outside_the_step_list", () => {
assert.equal(computeCurrentStep("membership-denied"), 1);
});
// ---------------------------------------------------------------------------
@@ -3,10 +3,7 @@ import type { AcpRuntimeCatalogEntry, Profile } from "@/shared/api/types";
export type OnboardingPage =
| "profile"
| "key-import"
| "backup"
| "avatar"
| "theme"
| "setup"
| "membership-denied";
export type OnboardingActions = {
@@ -64,11 +61,6 @@ export type SetupStepActions = {
complete: () => void;
};
export type ThemeStepActions = {
skip: () => void;
submit: () => void;
};
export type SetupStepRuntimeState = {
errorMessage: string | null;
isChecking: boolean;
+1 -1
View File
@@ -74,7 +74,7 @@ function renderApp() {
<React.StrictMode>
<CommunitiesProvider>
<CommunityOnboardingProvider>
<ThemeProvider defaultTheme="houston">
<ThemeProvider defaultTheme="buzz">
<TooltipProvider delayDuration={300}>
<EmojiBurstProvider>
<PoofBurstProvider>
+7 -2
View File
@@ -452,7 +452,7 @@ async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
export function ThemeProvider({
children,
defaultTheme = "houston",
defaultTheme = "buzz",
}: ThemeProviderProps) {
// Apply cached vars synchronously before first render
const [selectedTheme, setSelectedTheme] = useState<string>(() => {
@@ -468,7 +468,12 @@ export function ThemeProvider({
return window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
});
const [followSystem, setFollowSystemState] = useState<boolean>(() => {
return window.localStorage.getItem(FOLLOW_SYSTEM_KEY) === "true";
const stored = window.localStorage.getItem(FOLLOW_SYSTEM_KEY);
if (stored !== null) return stored === "true";
// Fresh profiles (no saved theme) default to System mode so the Buzz
// default tracks the OS light/dark scheme. Profiles that picked a theme
// before this toggle existed keep their fixed theme until they opt in.
return window.localStorage.getItem(THEME_STORAGE_KEY) === null;
});
const [systemIsDark, setSystemIsDark] = useState<boolean>(() => {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
-10
View File
@@ -124,16 +124,6 @@ export const SYNTAX_THEMES = [
export type SyntaxThemeName = (typeof SYNTAX_THEMES)[number];
const ONBOARDING_THEME_FALLBACK: SyntaxThemeName = "github-light-default";
const ONBOARDING_THEME_PREFERENCES = [
"neutral",
ONBOARDING_THEME_FALLBACK,
] as const;
export const ONBOARDING_DEFAULT_THEME_NAME = (ONBOARDING_THEME_PREFERENCES.find(
(name) => (SYNTAX_THEMES as readonly string[]).includes(name),
) ?? ONBOARDING_THEME_FALLBACK) as SyntaxThemeName;
// 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([
+5 -3
View File
@@ -1072,8 +1072,10 @@ test("first-run onboarding shows setup loading until Welcome bootstrap completes
wash instanceof HTMLElement ? window.getComputedStyle(wash) : null;
return {
animateElementCount: element.querySelectorAll("animate").length,
// The document itself must not flash white before the gate mounts
// (inline <style> in index.html; black fallback when no cached theme).
// The document must match the OS scheme before the gate mounts (inline
// <style> + script in index.html; with no cached theme the first-launch
// default is Buzz following the OS scheme — white here because
// Playwright's default color scheme is light).
documentBackgroundColor: window.getComputedStyle(document.documentElement)
.backgroundColor,
grainientAnimation: washStyles?.animationName,
@@ -1088,7 +1090,7 @@ test("first-run onboarding shows setup loading until Welcome bootstrap completes
});
expect(gateTreatment).toEqual({
animateElementCount: 0,
documentBackgroundColor: "rgb(0, 0, 0)",
documentBackgroundColor: "rgb(255, 255, 255)",
grainientAnimation: "buzz-grainient-orbit",
grainientUsesRadialGradients: true,
markSvgsUseCurrentColor: true,
+15 -5
View File
@@ -296,6 +296,13 @@ test("shows profile save feedback as a toast", async ({ page }) => {
});
test("nests the avatar edit button in a clipped notch", async ({ page }) => {
// Under the Buzz default theme the settings nav overrides `--sidebar-active`
// (white pill on the gradient) while the avatar edit button deliberately
// keeps the root accent-driven token, so the shared-token comparison below
// only holds outside the Buzz theme.
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "github-light");
});
await page.goto("/");
await openSettings(page, "profile");
@@ -1350,15 +1357,18 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
).toBeVisible();
await page.getByTestId("settings-nav-appearance").click();
// Default theme is catppuccin-macchiato (dark)
// Default is Buzz in System mode; Playwright's default color scheme is
// light, so the app boots with the light Buzz theme.
await expect
.poll(() =>
page.evaluate(() => document.documentElement.classList.contains("dark")),
page.evaluate(() => document.documentElement.classList.contains("light")),
)
.toBe(true);
// Switch to Light mode tab to reveal light themes
await page.getByRole("button", { name: "Light" }).click();
// Switch to Light mode tab to reveal light themes. Target the testid — in
// the default System mode the "Light" paired-theme tile shares the same
// accessible name as the mode button.
await page.getByTestId("appearance-mode-light").click();
// Switch to a light theme — verifies dark→light transition
await page.getByTestId("theme-option-github-light").click();
@@ -1390,7 +1400,7 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
.toBe("github-light");
// Switch to Dark mode tab to reveal dark themes
await page.getByRole("button", { name: "Dark" }).click();
await page.getByTestId("appearance-mode-dark").click();
// Switch back to a dark theme — verifies light→dark transition
await page.getByTestId("theme-option-dracula").click();
+9 -1
View File
@@ -80,7 +80,15 @@ test("leaving a channel from the context menu never freezes the app", async ({
await expectAppClickable(page);
});
test("fades the pinned sidebar chrome edges", async ({ page }) => {
test("fades the pinned sidebar chrome edges outside the Buzz theme", async ({
page,
}) => {
// The Buzz default theme repaints the pinned header/footer with the
// sidebar gradient and drops the edge-fade pseudo-elements, so the fade
// treatment under test only exists on non-Buzz themes.
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "github-light");
});
await page.goto("/");
const pinnedHeader = page.getByTestId("sidebar-pinned-header");
+8 -1
View File
@@ -11,6 +11,10 @@ const CONSTRAINED_LANDSCAPE_VIDEO_SHA = "d".repeat(64);
const CONSTRAINED_LANDSCAPE_VIDEO_URL = `http://localhost:3000/media/${CONSTRAINED_LANDSCAPE_VIDEO_SHA}.mp4`;
const VIDEO_REVIEW_NEUTRAL_ACCENT = "neutral";
const VIDEO_REVIEW_LIGHT_THEME = "catppuccin-latte";
// The fresh-profile default is the Buzz theme, which pins the neutral accent
// regardless of the stored accent color. Accent-driven review foreground
// assertions must run on a non-Buzz theme for the seeded accent to apply.
const VIDEO_REVIEW_ACCENT_THEME = "houston";
const VIDEO_REVIEW_ACCENT = "#ec4899";
const VIDEO_REVIEW_ACCENT_FOREGROUND_RGB = "rgb(240, 115, 177)";
const VIDEO_REVIEW_INDIGO_ACCENT = "#6366f1";
@@ -211,7 +215,9 @@ async function openReviewWithPostedTimecode(
test("video upload previews use poster frames and inline videos open review mode", async ({
page,
}) => {
await installVideoReviewHarness(page);
await installVideoReviewHarness(page, {
themeName: VIDEO_REVIEW_ACCENT_THEME,
});
await page.goto("/");
await page.getByTestId("channel-general").click();
@@ -871,6 +877,7 @@ test("neutral accent uses the forced-dark review foreground", async ({
test("dark accent uses a contrast-safe review foreground", async ({ page }) => {
await installVideoReviewHarness(page, {
accentColor: VIDEO_REVIEW_INDIGO_ACCENT,
themeName: VIDEO_REVIEW_ACCENT_THEME,
});
const reviewDialog = await openReviewWithPostedTimecode(