mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Fable 5
parent
36c3c49949
commit
2c08271ba9
+14
-8
@@ -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;
|
||||
|
||||
@@ -74,7 +74,7 @@ function renderApp() {
|
||||
<React.StrictMode>
|
||||
<CommunitiesProvider>
|
||||
<CommunityOnboardingProvider>
|
||||
<ThemeProvider defaultTheme="houston">
|
||||
<ThemeProvider defaultTheme="buzz">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<EmojiBurstProvider>
|
||||
<PoofBurstProvider>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user