feat(onboarding): extend V3 workshop preview

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-30 08:56:09 +01:00
parent 857e26e4e0
commit a5fcda05aa
13 changed files with 1680 additions and 676 deletions

View File

@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
ONBOARDING_PREVIEW_LANDING_ACTIONS,
ONBOARDING_PREVIEW_JOURNEYS,
resolveOnboardingPreviewJourney,
resolveOnboardingPreviewMode,
} from "./onboardingPreview.ts";
@@ -48,6 +50,16 @@ test("preview is available to the explicit E2E build", () => {
});
test("today preserves the complete workshop journey", () => {
assert.deepEqual(ONBOARDING_PREVIEW_LANDING_ACTIONS.today, {
primary: {
label: "Create a new identity key",
page: "identity-key",
},
secondary: {
label: "Use an existing key",
page: "sign-in-key",
},
});
assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.today, {
afterAccount: "setup",
afterCommunityEntry: "community-connecting",
@@ -61,8 +73,26 @@ test("today preserves the complete workshop journey", () => {
});
});
test("V3 skips technical setup and the transitional community screens", () => {
test("V3 uses the focused harness step and skips transitional community screens", () => {
assert.deepEqual(ONBOARDING_PREVIEW_LANDING_ACTIONS.v3, {
primary: { label: "Create an account", page: "email" },
secondary: { label: "Sign in", page: "sign-in" },
});
assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.v3, {
afterAccount: "harness-connection",
afterCommunityEntry: "community-profile",
afterProfile: "community-home",
communityChoiceBack: "harness-connection",
communityStep: 4,
finalStep: 5,
includeExistingCommunity: false,
profileStep: 5,
totalSteps: 5,
});
});
test("V3 can preview the same journey with harness connection deferred", () => {
assert.deepEqual(resolveOnboardingPreviewJourney("v3", false), {
afterAccount: "community-choice",
afterCommunityEntry: "community-profile",
afterProfile: "community-home",
@@ -73,4 +103,8 @@ test("V3 skips technical setup and the transitional community screens", () => {
profileStep: 4,
totalSteps: 4,
});
assert.equal(
resolveOnboardingPreviewJourney("today", false),
ONBOARDING_PREVIEW_JOURNEYS.today,
);
});

View File

@@ -18,6 +18,8 @@ export type OnboardingPreviewPage =
| "identity-key-help"
| "backup-password"
| "setup"
| "harness-connection"
| "harness-connection-detail"
| "config"
| "community-choice"
| "community-entry"
@@ -29,6 +31,35 @@ export type OnboardingPreviewPage =
export type OnboardingPreviewVariant = "today" | "v3";
export const ONBOARDING_PREVIEW_LANDING_ACTIONS = {
today: {
primary: {
label: "Create a new identity key",
page: "identity-key",
},
secondary: {
label: "Use an existing key",
page: "sign-in-key",
},
},
v3: {
primary: {
label: "Create an account",
page: "email",
},
secondary: {
label: "Sign in",
page: "sign-in",
},
},
} as const satisfies Record<
OnboardingPreviewVariant,
Record<
"primary" | "secondary",
{ label: string; page: OnboardingPreviewPage }
>
>;
type OnboardingPreviewJourney = {
afterAccount: OnboardingPreviewPage;
afterCommunityEntry: OnboardingPreviewPage;
@@ -58,17 +89,36 @@ export const ONBOARDING_PREVIEW_JOURNEYS: Record<
totalSteps: 7,
},
v3: {
afterAccount: "community-choice",
afterAccount: "harness-connection",
afterCommunityEntry: "community-profile",
afterProfile: "community-home",
communityChoiceBack: "harness-connection",
communityStep: 4,
finalStep: 5,
includeExistingCommunity: false,
profileStep: 5,
totalSteps: 5,
},
};
/** Resolve the optional V3 harness experiment without changing Today's flow. */
export function resolveOnboardingPreviewJourney(
variant: OnboardingPreviewVariant,
harnessConnectionInOnboarding: boolean,
): OnboardingPreviewJourney {
const journey = ONBOARDING_PREVIEW_JOURNEYS[variant];
if (variant !== "v3" || harnessConnectionInOnboarding) return journey;
return {
...journey,
afterAccount: "community-choice",
communityChoiceBack: null,
communityStep: 3,
finalStep: 4,
includeExistingCommunity: false,
profileStep: 4,
totalSteps: 4,
},
};
};
}
/** Resolve the explicit, non-production onboarding workshop route. */
export function resolveOnboardingPreviewMode({

View File

@@ -74,6 +74,8 @@ type BackupStepProps = {
previewMode?: boolean;
returningFromSecurity: boolean;
showPreviewBackupShortcut?: boolean;
/** Apply the experimental V3 copy and controls inside the safe preview. */
v3Presentation?: boolean;
};
/**
@@ -94,6 +96,7 @@ export function BackupStep({
previewMode = false,
returningFromSecurity,
showPreviewBackupShortcut = true,
v3Presentation = false,
}: BackupStepProps) {
const cardLayout = useOnboardingPreviewCardLayout();
const reduceMotion = useReducedMotion() ?? false;
@@ -105,8 +108,8 @@ export function BackupStep({
const [nsec, setNsec] = React.useState<string | null>(null);
const [isRevealed, setIsRevealed] = React.useState(false);
const canUseSpoilerParticles = React.useMemo(
() => previewMode && supportsSpoilerParticles(),
[previewMode],
() => v3Presentation && supportsSpoilerParticles(),
[v3Presentation],
);
const cancelledRef = React.useRef(false);
const copiedTimerRef = React.useRef<number | null>(null);
@@ -224,9 +227,11 @@ export function BackupStep({
)}
>
<h1 className="text-title font-normal text-foreground">
{previewMode ? "Create a private identity key" : "Backup options"}
{v3Presentation
? "Create a private identity key"
: "Backup options"}
</h1>
{previewMode ? (
{v3Presentation ? (
<div className={cardLayout ? "mt-2" : "mt-5"}>
<p className="text-sm leading-6 text-foreground/75">
This key will be how you log into Buzz. You can use it across
@@ -236,7 +241,8 @@ export function BackupStep({
<IdentityKeyHelpDialog
inline
onPreviewOpen={onOpenIdentityKeyHelp}
previewMode
previewMode={previewMode}
v3Presentation={v3Presentation}
/>
</div>
</div>
@@ -249,7 +255,7 @@ export function BackupStep({
)}
</div>
{previewMode ? (
{v3Presentation ? (
<div className="flex w-full max-w-[560px] flex-1 items-center py-10">
<div
className="w-full space-y-6"
@@ -383,7 +389,7 @@ export function BackupStep({
</div>
)}
{previewMode ? (
{v3Presentation ? (
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
@@ -421,7 +427,7 @@ export function BackupStep({
className={`text-title font-normal text-foreground ${REVEAL_ANIMATION_CLASS}`}
key={created ? "created" : "creating"}
>
{created && previewMode
{created && v3Presentation
? "Your private identity key"
: created
? "Your unique identity key has been created"
@@ -434,7 +440,7 @@ export function BackupStep({
REVEAL_ANIMATION_CLASS,
)}
>
{previewMode ? (
{v3Presentation ? (
"Don’t share this key. Anyone who has it can access your account."
) : (
<>
@@ -535,7 +541,7 @@ export function BackupStep({
</div>
</Card>
{previewMode ? (
{v3Presentation ? (
<div
className={cn(
"mt-8 flex flex-wrap items-center gap-3",
@@ -602,7 +608,7 @@ export function BackupStep({
</p>
) : null}
{!previewMode ? (
{!v3Presentation ? (
<p className="mx-auto mt-5 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
@@ -616,7 +622,7 @@ export function BackupStep({
)}
<OnboardingFooter className={REVEAL_ANIMATION_CLASS}>
{previewMode && created && showPreviewBackupShortcut ? (
{v3Presentation && created && showPreviewBackupShortcut ? (
<Button
className={cn(ONBOARDING_SECONDARY_CTA_CLASS, "gap-2")}
data-testid="backup-options-link"
@@ -635,7 +641,7 @@ export function BackupStep({
onClick={onNext}
type="button"
>
{previewMode ? "I’ve saved my key" : "Next"}
{v3Presentation ? "I’ve saved my key" : "Next"}
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>

View File

@@ -27,6 +27,8 @@ type DownloadKeyStepProps = {
onBack: () => void;
/** Use in-memory backup operations inside the dev-only workshop preview. */
previewMode?: boolean;
/** Apply the experimental V3 wording inside the safe preview. */
v3Presentation?: boolean;
};
/**
@@ -39,6 +41,7 @@ export function DownloadKeyStep({
session,
onBack,
previewMode = false,
v3Presentation = false,
}: DownloadKeyStepProps) {
const cardLayout = useOnboardingPreviewCardLayout();
const reduceMotion = useReducedMotion() ?? false;
@@ -48,7 +51,7 @@ export function DownloadKeyStep({
const hasVerifiedBackup = session.verified;
const hasSelectedBackup = session.test.stage === "password";
const showPreviewSaved =
previewMode && hasCreated && session.test.stage === "drop";
v3Presentation && hasCreated && session.test.stage === "drop";
const primaryActionClass = cardLayout
? ONBOARDING_PRIMARY_CTA_CLASS
: ONBOARDING_SECURITY_PRIMARY_CTA_CLASS;
@@ -157,6 +160,7 @@ export function DownloadKeyStep({
previewMode={previewMode}
session={session}
variant="spotlight"
v3Presentation={v3Presentation}
verifyButtonPortal={primaryActionSlot}
/>
</div>
@@ -205,7 +209,7 @@ export function DownloadKeyStep({
variant="ghost"
>
{hasVerifiedBackup
? previewMode
? v3Presentation
? "Continue"
: "Finish"
: "Skip for now"}

View File

@@ -277,6 +277,8 @@ type EncryptedBackupCreatorProps = {
onVerified?: () => void;
/** Replace native identity and file operations inside the dev-only preview. */
previewMode?: boolean;
/** Apply the experimental V3 wording inside the safe preview. */
v3Presentation?: boolean;
};
/**
@@ -481,6 +483,7 @@ export function EncryptedBackupCreator({
guidedTest = true,
onVerified,
previewMode = false,
v3Presentation = false,
}: EncryptedBackupCreatorProps) {
const cardLayout = useOnboardingPreviewCardLayout();
// Hosts without a longer-lived session get a private one (settings card).
@@ -889,7 +892,7 @@ export function EncryptedBackupCreator({
<PendingDownloadTicker />
) : state.savedPassword ? (
"Download backup again"
) : previewMode ? (
) : v3Presentation ? (
"Save backup"
) : (
"Backup key"

View File

@@ -0,0 +1,652 @@
import { ChevronRight } from "lucide-react";
import * as React from "react";
import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls";
import {
getProviderApiKeyLabel,
getPersonaModelOptions,
PERSONA_LLM_PROVIDER_OPTIONS,
runtimeSupportsLlmProviderSelection,
} from "@/features/agents/ui/agentConfigOptions";
import {
BUZZ_AGENT_THINKING_EFFORT_VALUES,
getProviderEffortConfig,
} from "@/features/agents/ui/buzzAgentConfig";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { SegmentedControl } from "@/shared/ui/segmented-control";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import { OnboardingPreviewInput } from "./OnboardingPreviewInput";
import {
OnboardingPreviewStep,
useOnboardingPreviewCardLayout,
} from "./OnboardingPreviewShell";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";
import { ONBOARDING_PREVIEW_CARD_INPUT_CLASS } from "./onboardingPreviewCardStyles";
export type HarnessConnectionMethod = "subscription" | "api";
export type HarnessConnectionOption = {
methods: readonly HarnessConnectionMethod[];
runtime: AcpRuntimeCatalogEntry;
};
const CONNECTION_METHOD_OPTIONS = [
{ label: "Subscription", value: "subscription" },
{ label: "API", value: "api" },
] as const;
function previewRuntime({
available = false,
id,
label,
source = "preset",
}: {
available?: boolean;
id: string;
label: string;
source?: AcpRuntimeCatalogEntry["source"];
}): AcpRuntimeCatalogEntry {
return {
authStatus: available
? { status: "not_applicable" }
: { status: "unknown" },
availability: available ? "available" : "not_installed",
avatarUrl: "",
binaryPath: available ? `/usr/local/bin/${id}` : null,
canAutoInstall: true,
command: available ? id : null,
contextLimitEnvVar: null,
defaultArgs: [],
id,
installHint: `Install ${label} to connect it to Buzz.`,
installInstructionsUrl: "https://github.com/block/buzz",
label,
loginHint: null,
maxRoundsEnvVar: null,
maxTokensEnvVar: null,
mcpCommand: null,
modelEnvVar: null,
nodeRequired: false,
providerEnvVar: null,
requiresExternalCli: id !== "buzz-agent",
source,
thinkingEnvVar: null,
underlyingCliPath: null,
};
}
/** Fixed workshop catalog. Availability changes only in React memory. */
export const HARNESS_CONNECTION_OPTIONS: readonly HarnessConnectionOption[] = [
{
methods: ["subscription", "api"],
runtime: previewRuntime({
available: true,
id: "claude",
label: "Claude Code",
source: "builtin",
}),
},
{
methods: ["subscription", "api"],
runtime: previewRuntime({ id: "codex", label: "Codex", source: "builtin" }),
},
{
methods: ["api"],
runtime: previewRuntime({
available: true,
id: "goose",
label: "Goose",
source: "builtin",
}),
},
{
methods: ["api"],
runtime: previewRuntime({
available: true,
id: "buzz-agent",
label: "Buzz Agent",
source: "builtin",
}),
},
{
methods: ["subscription"],
runtime: previewRuntime({ id: "cursor", label: "Cursor" }),
},
{
methods: ["subscription"],
runtime: previewRuntime({ id: "devin", label: "Devin" }),
},
{
methods: ["api"],
runtime: previewRuntime({ id: "omp", label: "Oh My Pi" }),
},
{
methods: ["api"],
runtime: previewRuntime({ id: "grok", label: "Grok Build" }),
},
{
methods: ["api"],
runtime: previewRuntime({ id: "opencode", label: "OpenCode" }),
},
{
methods: ["api"],
runtime: previewRuntime({ id: "kimi", label: "Kimi Code" }),
},
{
methods: ["subscription"],
runtime: previewRuntime({ id: "amp", label: "Amp" }),
},
{
methods: ["api"],
runtime: previewRuntime({ id: "hermes", label: "Hermes Agent" }),
},
{
methods: ["api"],
runtime: previewRuntime({ id: "openclaw", label: "OpenClaw" }),
},
];
/**
* Reusable row list for selecting or installing an AI client. The caller owns
* availability and side effects so onboarding can remain a session-only
* workshop while an in-app surface can wire the same presentation to Tauri.
*/
export function HarnessConnectionList({
installedIds,
onInstall,
onSelect,
options,
}: {
installedIds: ReadonlySet<string>;
onInstall: (option: HarnessConnectionOption) => void;
onSelect: (option: HarnessConnectionOption) => void;
options: readonly HarnessConnectionOption[];
}) {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [canScrollUp, setCanScrollUp] = React.useState(false);
const [canScrollDown, setCanScrollDown] = React.useState(false);
const orderedOptions = React.useMemo(() => {
const group = ({ runtime }: HarnessConnectionOption) => {
if (runtime.id === "buzz-agent") return 0;
if (runtime.id === "goose") return 1;
return installedIds.has(runtime.id) ? 2 : 3;
};
return [...options].sort((left, right) => group(left) - group(right));
}, [installedIds, options]);
const updateScrollEdges = React.useCallback(() => {
const element = scrollRef.current;
if (!element) return;
setCanScrollUp(element.scrollTop > 1);
setCanScrollDown(
element.scrollTop + element.clientHeight < element.scrollHeight - 1,
);
}, []);
React.useEffect(() => {
updateScrollEdges();
const element = scrollRef.current;
if (!element) return;
const observer = new ResizeObserver(updateScrollEdges);
observer.observe(element);
return () => observer.disconnect();
}, [updateScrollEdges]);
return (
<div className="relative -mx-2 min-h-0 w-[calc(100%+1rem)] flex-1">
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-10 h-4 transition-opacity duration-150 motion-reduce:transition-none",
canScrollUp ? "opacity-100" : "opacity-0",
)}
style={{
background: "linear-gradient(to bottom, white, rgb(255 255 255 / 0))",
}}
/>
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-4 transition-opacity duration-150 motion-reduce:transition-none",
canScrollDown ? "opacity-100" : "opacity-0",
)}
style={{
background: "linear-gradient(to top, white, rgb(255 255 255 / 0))",
}}
/>
<div
className="h-full min-h-0 space-y-1 overflow-y-auto overscroll-contain pr-1"
data-testid="onboarding-preview-harness-list"
onScroll={updateScrollEdges}
ref={scrollRef}
>
{orderedOptions.map((option) => {
const { runtime } = option;
const installed = installedIds.has(runtime.id);
const label = getRuntimeDisplayLabel(runtime);
const rowClassName =
"group flex min-h-14 w-full items-center gap-3 rounded-xl px-2 py-2 text-left text-sm font-medium text-foreground transition-colors duration-150 ease-out hover:bg-foreground/[0.04] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/20 motion-reduce:transition-none";
const contents = (
<>
<span className="flex size-10 shrink-0 items-center justify-center">
<RuntimeIcon className="size-9" runtime={runtime} />
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</>
);
if (installed) {
return (
<button
className={rowClassName}
data-testid={`onboarding-preview-harness-${runtime.id}`}
key={runtime.id}
onClick={() => onSelect(option)}
type="button"
>
{contents}
<span className="flex size-10 shrink-0 items-center justify-center">
<ChevronRight
aria-hidden="true"
className="size-4 text-muted-foreground transition-[color,transform] duration-150 ease-out group-hover:translate-x-0.5 group-hover:text-foreground motion-reduce:transition-none"
/>
</span>
</button>
);
}
return (
<div className={rowClassName} key={runtime.id}>
{contents}
<Button
aria-label={`Install ${label}`}
className="ml-auto h-7 shrink-0 rounded-full bg-foreground px-3 text-xs text-background shadow-none hover:bg-foreground/85"
data-testid={`onboarding-preview-harness-install-${runtime.id}`}
onClick={() => onInstall(option)}
size="xs"
type="button"
>
Install
</Button>
</div>
);
})}
</div>
</div>
);
}
export function HarnessConnectionPreview({
installedIds,
onBack,
onInstall,
onSelect,
total,
}: {
installedIds: ReadonlySet<string>;
onBack: () => void;
onInstall: (option: HarnessConnectionOption) => void;
onSelect: (option: HarnessConnectionOption) => void;
total: number;
}) {
return (
<OnboardingPreviewStep
current={3}
onBack={onBack}
testId="onboarding-preview-harness-connection"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-0 flex-1 flex-col"
containerClassName="min-h-0 flex-1"
transitionKey="preview-harness-connection"
>
<div className="shrink-0">
<h1 className="text-title font-normal text-foreground">
Connect your AI client
</h1>
<p className="mt-2 text-base leading-6 text-foreground/80">
Choose which AI client your agents will use. You can change this
anytime.
</p>
</div>
<div className="mt-6 flex min-h-0 flex-1 flex-col">
<HarnessConnectionList
installedIds={installedIds}
onInstall={onInstall}
onSelect={onSelect}
options={HARNESS_CONNECTION_OPTIONS}
/>
</div>
</OnboardingSlideTransition>
</OnboardingPreviewStep>
);
}
const API_PROVIDER_OPTIONS = PERSONA_LLM_PROVIDER_OPTIONS.map((provider) => ({
label: provider.label,
value: provider.id,
}));
const SUBSCRIPTION_NAMES: Record<string, string> = {
amp: "Amp subscription",
claude: "Claude subscription",
codex: "ChatGPT subscription",
cursor: "Cursor subscription",
devin: "Devin account",
};
function defaultApiProvider(runtimeId: string) {
if (runtimeId === "codex") return "openai";
return "anthropic";
}
function apiKeyLabel(provider: string) {
return (
getProviderApiKeyLabel(provider)?.replace("API Key", "API key") ?? "API key"
);
}
const DEFAULT_MODEL_OPTION = { label: "Default model", value: "" } as const;
function modelOptions(runtimeId: string, provider: string) {
const options = getPersonaModelOptions(runtimeId, provider).map((option) => ({
label: option.label,
value: option.id,
}));
return options.length > 0 ? options : [DEFAULT_MODEL_OPTION];
}
function ConnectedConfiguration({
effort,
model,
onEffortChange,
onModelChange,
provider,
runtimeId,
}: {
effort: string;
model: string;
onEffortChange: (value: string) => void;
onModelChange: (value: string) => void;
provider: string;
runtimeId: string;
}) {
const models = modelOptions(runtimeId, provider);
const effortConfig = getProviderEffortConfig(provider, model);
const efforts = BUZZ_AGENT_THINKING_EFFORT_VALUES.filter((value) =>
effortConfig.validValues.includes(value),
).map((value) => ({
label: value === effortConfig.defaultValue ? `${value} (default)` : value,
value,
}));
const availableEfforts =
efforts.length > 0
? efforts
: BUZZ_AGENT_THINKING_EFFORT_VALUES.map((value) => ({
label: value,
value,
}));
return (
<div
className="space-y-5"
data-testid="onboarding-preview-harness-connected"
>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor="onboarding-preview-harness-model"
>
Model
</label>
<AgentDropdownSelect
className={ONBOARDING_PREVIEW_CARD_INPUT_CLASS}
id="onboarding-preview-harness-model"
onValueChange={onModelChange}
options={models}
testId="onboarding-preview-harness-model"
value={model}
/>
</div>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor="onboarding-preview-harness-effort"
>
Effort
</label>
<AgentDropdownSelect
className={ONBOARDING_PREVIEW_CARD_INPUT_CLASS}
id="onboarding-preview-harness-effort"
onValueChange={onEffortChange}
options={availableEfforts}
testId="onboarding-preview-harness-effort"
value={effort}
/>
</div>
</div>
);
}
export function HarnessConnectionDetailPreview({
method,
onBack,
onContinue,
onMethodChange,
option,
total,
}: {
method: HarnessConnectionMethod;
onBack: () => void;
onContinue: () => void;
onMethodChange: (method: HarnessConnectionMethod) => void;
option: HarnessConnectionOption;
total: number;
}) {
const cardLayout = useOnboardingPreviewCardLayout();
const label = getRuntimeDisplayLabel(option.runtime);
const hasMethodChoice = option.methods.length > 1;
const [provider, setProvider] = React.useState(() =>
defaultApiProvider(option.runtime.id),
);
const [apiKey, setApiKey] = React.useState("workshop-preview-key");
const [connected, setConnected] = React.useState(false);
const [model, setModel] = React.useState("");
const [effort, setEffort] = React.useState("medium");
const canChooseProvider = runtimeSupportsLlmProviderSelection(
option.runtime.id,
);
const subscriptionName = SUBSCRIPTION_NAMES[option.runtime.id] ?? label;
const requiresApiKey = getProviderApiKeyLabel(provider) !== null;
const canConnect =
method === "subscription" || !requiresApiKey || apiKey.trim().length > 0;
React.useEffect(() => {
if (!option.methods.includes(method)) {
onMethodChange(option.methods[0] ?? "api");
}
}, [method, onMethodChange, option.methods]);
function handleMethodChange(nextMethod: HarnessConnectionMethod) {
setApiKey("workshop-preview-key");
setConnected(false);
setProvider(defaultApiProvider(option.runtime.id));
setModel("");
setEffort("medium");
onMethodChange(nextMethod);
}
function handleProviderChange(nextProvider: string) {
setProvider(nextProvider);
setApiKey("workshop-preview-key");
setConnected(false);
setModel("");
setEffort("medium");
}
function handlePrimaryAction() {
if (connected) {
onContinue();
return;
}
if (canConnect) setConnected(true);
}
return (
<OnboardingPreviewStep
current={3}
onBack={onBack}
testId="onboarding-preview-harness-connection-detail"
total={total}
>
<OnboardingSlideTransition
className={cn(
"flex min-h-0 w-full max-w-[500px] flex-1 flex-col",
cardLayout ? "items-stretch" : "items-center",
)}
containerClassName="min-h-0 flex-1"
transitionKey={`preview-harness-connection-${option.runtime.id}`}
>
<div className="flex items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center">
<RuntimeIcon className="size-9" runtime={option.runtime} />
</span>
<h1 className="text-title font-normal text-foreground">
Connect {label}
</h1>
</div>
<p className="mt-2 max-w-[440px] text-base leading-6 text-foreground/80">
{connected
? "Choose a model and effort level. You can change these anytime."
: hasMethodChoice
? "Choose how you want to connect. You can change this anytime."
: method === "subscription"
? `Sign in to connect ${label}. You can change this anytime.`
: "Add your connection details. You can change this anytime."}
</p>
<div className="mt-8 flex min-h-0 flex-1 flex-col gap-6">
{hasMethodChoice && !connected ? (
<SegmentedControl
appearance="onboarding-inline"
className="w-fit self-start"
legend={`Connection method for ${label}`}
onValueChange={handleMethodChange}
optionTestIdPrefix="onboarding-preview-harness-method"
options={CONNECTION_METHOD_OPTIONS.filter((item) =>
option.methods.includes(item.value),
)}
size="compact"
testId="onboarding-preview-harness-methods"
value={method}
/>
) : null}
{connected ? (
<ConnectedConfiguration
effort={effort}
model={model}
onEffortChange={setEffort}
onModelChange={setModel}
provider={provider}
runtimeId={option.runtime.id}
/>
) : method === "subscription" ? (
<div
className="flex items-center gap-3 rounded-xl bg-[#e2e2e2]/30 px-4 py-4"
data-testid="onboarding-preview-harness-subscription"
>
<RuntimeIcon className="size-9" runtime={option.runtime} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{subscriptionName}
</p>
<p className="mt-0.5 text-xs leading-5 text-foreground/70">
Buzz will open a sign-in window for {label}.
</p>
</div>
<Button
className="ml-auto h-7 shrink-0 rounded-full bg-foreground px-3 text-xs text-background shadow-none hover:bg-foreground/85"
data-testid="onboarding-preview-harness-subscription-sign-in"
onClick={() => setConnected(true)}
size="xs"
type="button"
>
Sign in
</Button>
</div>
) : (
<form
className="space-y-5"
data-testid="onboarding-preview-harness-api-form"
onSubmit={(event) => {
event.preventDefault();
handlePrimaryAction();
}}
>
{canChooseProvider ? (
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor="onboarding-preview-harness-provider"
>
Provider
</label>
<AgentDropdownSelect
className={ONBOARDING_PREVIEW_CARD_INPUT_CLASS}
id="onboarding-preview-harness-provider"
onValueChange={handleProviderChange}
options={API_PROVIDER_OPTIONS}
testId="onboarding-preview-harness-provider"
value={provider}
/>
</div>
) : null}
{requiresApiKey ? (
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor="onboarding-preview-harness-api-key"
>
{apiKeyLabel(provider)}
</label>
<OnboardingPreviewInput
autoComplete="off"
className={ONBOARDING_PREVIEW_CARD_INPUT_CLASS}
id="onboarding-preview-harness-api-key"
onChange={(event) => {
setApiKey(event.target.value);
setConnected(false);
}}
placeholder="Paste API key"
smooth
type="password"
value={apiKey}
/>
</div>
) : null}
</form>
)}
</div>
{method === "api" || connected ? (
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-preview-harness-continue"
disabled={!canConnect}
onClick={handlePrimaryAction}
type="button"
>
{connected ? "Continue" : "Connect"}
</Button>
</OnboardingFooter>
) : null}
</OnboardingSlideTransition>
</OnboardingPreviewStep>
);
}

View File

@@ -38,10 +38,13 @@ export function IdentityKeyHelpDialog({
inline = false,
onPreviewOpen,
previewMode = false,
v3Presentation = previewMode,
}: {
inline?: boolean;
onPreviewOpen?: () => void;
previewMode?: boolean;
/** Apply the experimental V3 label and link treatment in preview mode. */
v3Presentation?: boolean;
}) {
const [isVisible, setIsVisible] = React.useState(
previewMode ? true : hasSeenIdentityKeyHelp,
@@ -61,7 +64,7 @@ export function IdentityKeyHelpDialog({
const triggerButton = (
<Button
className={cn(
previewMode
v3Presentation
? "text-foreground underline decoration-foreground/45 underline-offset-4 hover:decoration-foreground"
: "text-foreground/70 hover:text-foreground",
"transition-opacity duration-300 motion-reduce:transition-none",
@@ -74,7 +77,9 @@ export function IdentityKeyHelpDialog({
type="button"
variant="link"
>
{previewMode ? "Learn how identity keys work" : "What’s an identity key?"}
{v3Presentation
? "Learn how identity keys work"
: "What’s an identity key?"}
</Button>
);

View File

@@ -1,5 +1,3 @@
import { Check } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import type { JoinPolicy } from "@/shared/api/invites";
@@ -8,18 +6,28 @@ import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import {
ONBOARDING_PREVIEW_JOURNEYS,
ONBOARDING_PREVIEW_LANDING_ACTIONS,
type OnboardingPreviewPage,
type OnboardingPreviewVariant,
resolveOnboardingPreviewJourney,
} from "../onboardingPreview";
import { BackupStep } from "./BackupStep";
import {
resetEncryptedBackupSession,
useEncryptedBackupSession,
} from "./EncryptedBackupCreator";
import {
HARNESS_CONNECTION_OPTIONS,
HarnessConnectionDetailPreview,
type HarnessConnectionMethod,
HarnessConnectionPreview,
} from "./HarnessConnectionStep";
import { JoinPolicyNotice } from "./JoinPolicyNotice";
import { KeySignInPreview } from "./KeySignInPreview";
import { IdentityKeyHelpPreviewContent } from "./IdentityKeyHelpDialog";
import {
IdentityKeyHelpDialog,
IdentityKeyHelpPreviewContent,
} from "./IdentityKeyHelpDialog";
import { LandingBees } from "./LandingBees";
import {
ONBOARDING_LANDING_CTA_CLASS,
@@ -27,7 +35,7 @@ import {
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
import { OnboardingPreviewInput } from "./OnboardingPreviewInput";
import { PreviewCredentialsFields } from "./OnboardingPreviewCredentials";
import {
BackupPasswordPreview,
CommunityChoicePreview,
@@ -50,10 +58,7 @@ import {
import { OnboardingPreviewControls } from "./OnboardingPreviewControls";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { SetupStepPreview } from "./SetupStepPreview";
import {
ONBOARDING_PREVIEW_CARD_INPUT_CLASS,
ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS,
} from "./onboardingPreviewCardStyles";
import { ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS } from "./onboardingPreviewCardStyles";
const EMAIL_SIGNUP_POLICY: JoinPolicy = {
ageAttestationRequired: true,
@@ -64,9 +69,14 @@ const EMAIL_SIGNUP_POLICY: JoinPolicy = {
function Landing({
onNavigate,
variant,
}: {
onNavigate: (page: OnboardingPreviewPage) => void;
variant: OnboardingPreviewVariant;
}) {
const isV3 = variant === "v3";
const landingActions = ONBOARDING_PREVIEW_LANDING_ACTIONS[variant];
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell buzz-onboarding-welcome flex max-h-dvh items-start justify-center overflow-hidden px-4 py-8 text-foreground"
@@ -92,31 +102,35 @@ function Landing({
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className={ONBOARDING_LANDING_CTA_CLASS}
onClick={() => onNavigate("email")}
onClick={() => onNavigate(landingActions.primary.page)}
type="button"
>
Create an account
{landingActions.primary.label}
</Button>
<Button
className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`}
onClick={() => onNavigate("sign-in")}
onClick={() => onNavigate(landingActions.secondary.page)}
type="button"
variant="ghost"
>
Sign in
{landingActions.secondary.label}
</Button>
</div>
<OnboardingFooter className="max-w-none">
<Button
className="h-auto text-foreground/70 hover:text-foreground"
data-testid="onboarding-preview-without-email"
onClick={() => onNavigate("backup-options")}
type="button"
variant="link"
>
Sign up without email
</Button>
</OnboardingFooter>
{isV3 ? (
<OnboardingFooter className="max-w-none">
<Button
className="h-auto text-foreground/70 hover:text-foreground"
data-testid="onboarding-preview-without-email"
onClick={() => onNavigate("backup-options")}
type="button"
variant="link"
>
Sign up without email
</Button>
</OnboardingFooter>
) : (
<IdentityKeyHelpDialog previewMode v3Presentation={false} />
)}
</OnboardingSlideTransition>
</div>
</OnboardingFooterProvider>
@@ -124,301 +138,6 @@ function Landing({
);
}
type PreviewPasswordStrength = "empty" | "weak" | "moderate" | "strong";
const strengthLabelPhraseVariants = {
animate: { transition: { staggerChildren: 0.008 } },
exit: { transition: { staggerChildren: 0.005 } },
initial: {},
};
const strengthLabelCharacterVariants = {
animate: {
filter: "blur(0)",
opacity: 1,
transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const },
y: 0,
},
exit: {
filter: "blur(0.25rem)",
opacity: 0,
transition: { duration: 0.16, ease: [0.64, 0, 0.78, 0] as const },
y: "-0.5rem",
},
initial: { filter: "blur(0.25rem)", opacity: 0, y: "0.5rem" },
};
function previewPasswordStrength(password: string): PreviewPasswordStrength {
if (password.length === 0) return "empty";
if (password.length < 8) return "weak";
if (password.length < 15) return "moderate";
return "strong";
}
function PasswordStrengthMeter({
descriptionId,
password,
}: {
descriptionId: string;
password: string;
}) {
const strength = previewPasswordStrength(password);
const reduceMotion = useReducedMotion() ?? false;
const activeCount =
strength === "strong"
? 3
: strength === "moderate"
? 2
: strength === "weak"
? 1
: 0;
const label =
strength === "empty"
? "Empty"
: strength === "weak"
? "Weak"
: strength === "moderate"
? "Moderate"
: "Strong";
const labelCharacters = React.useMemo(() => {
const occurrences = new Map<string, number>();
return [...label].map((character) => {
const occurrence = occurrences.get(character) ?? 0;
occurrences.set(character, occurrence + 1);
return { character, key: `${character}-${occurrence}` };
});
}, [label]);
return (
<div
className={cn(
"pointer-events-none absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-2 text-xs font-semibold transition-colors duration-150 motion-reduce:transition-none",
strength === "weak" && "text-red-700",
strength === "moderate" && "text-amber-700",
strength === "strong" && "text-green-700",
strength === "empty" && "text-transparent",
)}
data-strength={strength}
data-testid="onboarding-preview-password-strength"
>
<span className="relative flex h-5 w-[4.75rem] items-center justify-end overflow-visible text-right leading-none">
{reduceMotion ? (
<span>{strength === "empty" ? "" : label}</span>
) : (
<AnimatePresence initial={false} mode="wait">
{strength === "empty" ? null : (
<motion.span
animate="animate"
className="absolute inset-x-0 top-1/2 -translate-y-1/2 whitespace-nowrap"
exit="exit"
initial="initial"
key={label}
variants={strengthLabelPhraseVariants}
>
{labelCharacters.map(({ character, key }) => (
<motion.span
className="inline-block will-change-[transform,opacity,filter]"
key={`${label}-${key}`}
variants={strengthLabelCharacterVariants}
>
{character}
</motion.span>
))}
</motion.span>
)}
</AnimatePresence>
)}
</span>
<span
aria-hidden
className={cn(
"relative flex h-8 w-5 flex-col items-center justify-center gap-0.5",
strength === "empty" && "opacity-0",
)}
data-testid="onboarding-preview-password-strength-indicator"
>
{[3, 2, 1].map((level) => {
const isTopDot = level === 3;
const positionClass =
level === 3
? strength === "strong"
? "top-1/2 -translate-y-1/2"
: "top-1"
: level === 2
? "top-[13px]"
: "top-[22px]";
return (
<span
className={cn(
"absolute left-1/2 flex -translate-x-1/2 items-center justify-center overflow-hidden transition-[top,width,height,border-radius,background-color,opacity,transform] duration-150 ease-in-out motion-reduce:transition-none",
positionClass,
isTopDot && strength === "strong"
? "h-5 w-5 rounded-full bg-green-600"
: "h-1.5 w-1.5 rounded-full",
!isTopDot && strength === "strong"
? level === 2
? "translate-y-1 scale-0 opacity-0"
: "-translate-y-1.5 scale-0 opacity-0"
: "scale-100 opacity-100",
strength !== "strong" &&
strength !== "empty" &&
activeCount >= level &&
"bg-amber-500",
strength === "weak" && activeCount >= level && "bg-red-500",
strength !== "strong" &&
activeCount < level &&
"bg-foreground/20",
)}
data-level={level}
key={level}
>
{isTopDot ? (
<Check
className={cn(
"h-3.5 w-3.5 text-white transition-[opacity,transform] duration-120 ease-in-out motion-reduce:transition-none",
strength === "strong"
? "scale-100 opacity-100"
: "scale-75 opacity-0",
)}
data-testid="onboarding-preview-password-strength-check"
/>
) : null}
</span>
);
})}
</span>
<span
aria-atomic="true"
aria-live="polite"
className="sr-only"
id={descriptionId}
role="status"
>
Password strength: {label}
</span>
</div>
);
}
function PreviewCredentialsFields({
email,
emailId,
onEmailChange,
onPasswordChange,
onConfirmPasswordChange,
confirmPassword,
password,
passwordAutoComplete,
passwordHelp,
passwordId,
passwordPlaceholder,
showPasswordStrength = false,
}: {
email: string;
emailId: string;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onConfirmPasswordChange?: (value: string) => void;
confirmPassword?: string;
password: string;
passwordAutoComplete: "current-password" | "new-password";
passwordHelp?: React.ReactNode;
passwordId: string;
passwordPlaceholder: string;
showPasswordStrength?: boolean;
}) {
const cardLayout = useOnboardingPreviewCardLayout();
const passwordStrengthDescriptionId = React.useId();
return (
<>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={emailId}
>
Email
</label>
<OnboardingPreviewInput
autoComplete="email"
className={
cardLayout
? ONBOARDING_PREVIEW_CARD_INPUT_CLASS
: "h-12 rounded-2xl border-foreground/15 bg-white px-4"
}
id={emailId}
onChange={(event) => onEmailChange(event.target.value)}
placeholder="Enter your email address"
smooth={cardLayout}
type="email"
value={email}
/>
</div>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={passwordId}
>
Password
</label>
<div className="relative">
<OnboardingPreviewInput
aria-describedby={
showPasswordStrength ? passwordStrengthDescriptionId : undefined
}
autoComplete={passwordAutoComplete}
className={cn(
cardLayout
? ONBOARDING_PREVIEW_CARD_INPUT_CLASS
: "h-12 rounded-2xl border-foreground/15 bg-white px-4",
showPasswordStrength ? "pr-28" : "pr-4",
)}
id={passwordId}
onChange={(event) => onPasswordChange(event.target.value)}
placeholder={passwordPlaceholder}
smooth={cardLayout}
type="password"
value={password}
/>
{showPasswordStrength ? (
<PasswordStrengthMeter
descriptionId={passwordStrengthDescriptionId}
password={password}
/>
) : null}
</div>
{passwordHelp ? (
<div className="mt-2 text-left">{passwordHelp}</div>
) : null}
{onConfirmPasswordChange ? (
<div className="mt-5">
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={`${passwordId}-confirmation`}
>
Confirm password
</label>
<OnboardingPreviewInput
autoComplete="new-password"
className={
cardLayout
? ONBOARDING_PREVIEW_CARD_INPUT_CLASS
: "h-12 rounded-2xl border-foreground/15 bg-white px-4"
}
id={`${passwordId}-confirmation`}
onChange={(event) => onConfirmPasswordChange(event.target.value)}
placeholder="Confirm your password"
smooth={cardLayout}
type="password"
value={confirmPassword ?? ""}
/>
</div>
) : null}
</div>
</>
);
}
function EmailSignup({
password,
onBack,
@@ -666,6 +385,19 @@ export function OnboardingPreviewApp() {
const [signInEmail, setSignInEmail] = React.useState("");
const [signupPassword, setSignupPassword] = React.useState("");
const [passwordResetEmail, setPasswordResetEmail] = React.useState("");
const [installedHarnessIds, setInstalledHarnessIds] = React.useState(
() =>
new Set(
HARNESS_CONNECTION_OPTIONS.filter(
({ runtime }) => runtime.availability === "available",
).map(({ runtime }) => runtime.id),
),
);
const [selectedHarnessId, setSelectedHarnessId] = React.useState("claude");
const [harnessConnectionMethod, setHarnessConnectionMethod] =
React.useState<HarnessConnectionMethod>("subscription");
const [harnessConnectionInOnboarding, setHarnessConnectionInOnboarding] =
React.useState(true);
const [setupBackPage, setSetupBackPage] =
React.useState<OnboardingPreviewPage>("landing");
const [communityRoute, setCommunityRoute] =
@@ -674,7 +406,14 @@ export function OnboardingPreviewApp() {
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const backupSession = useEncryptedBackupSession();
const journey = ONBOARDING_PREVIEW_JOURNEYS[variant];
const journey = resolveOnboardingPreviewJourney(
variant,
harnessConnectionInOnboarding,
);
const selectedHarness =
HARNESS_CONNECTION_OPTIONS.find(
({ runtime }) => runtime.id === selectedHarnessId,
) ?? HARNESS_CONNECTION_OPTIONS[0];
React.useEffect(() => {
if (page === "landing") {
@@ -689,6 +428,15 @@ export function OnboardingPreviewApp() {
resetEncryptedBackupSession(backupSession);
setPage("landing");
setPasswordResetEmail("");
setInstalledHarnessIds(
new Set(
HARNESS_CONNECTION_OPTIONS.filter(
({ runtime }) => runtime.availability === "available",
).map(({ runtime }) => runtime.id),
),
);
setSelectedHarnessId("claude");
setHarnessConnectionMethod("subscription");
setRun((current) => current + 1);
setSignInEmail("");
setSignupPassword("");
@@ -716,7 +464,7 @@ export function OnboardingPreviewApp() {
let content: React.ReactNode;
if (page === "landing") {
content = <Landing key={run} onNavigate={setPage} />;
content = <Landing key={run} onNavigate={setPage} variant={variant} />;
} else if (page === "email") {
content = (
<EmailSignup
@@ -730,7 +478,7 @@ export function OnboardingPreviewApp() {
} else if (page === "identity-key") {
content = (
<OnboardingPreviewStep
onBack={() => setPage("backup-options")}
onBack={() => setPage(variant === "v3" ? "backup-options" : "landing")}
testId="onboarding-preview-identity-key"
total={journey.totalSteps}
>
@@ -747,13 +495,15 @@ export function OnboardingPreviewApp() {
previewMode
returningFromSecurity={false}
showPreviewBackupShortcut={false}
v3Presentation={variant === "v3"}
/>
</OnboardingPreviewStep>
);
} else if (page === "backup-options") {
content = (
<OnboardingPreviewStep
onBack={() => setPage("landing")}
onBack={() => setPage(variant === "v3" ? "landing" : "identity-key")}
security={variant === "today"}
testId="onboarding-preview-backup-options"
total={journey.totalSteps}
>
@@ -767,6 +517,7 @@ export function OnboardingPreviewApp() {
optionsExpanded
previewMode
returningFromSecurity={false}
v3Presentation={variant === "v3"}
/>
</OnboardingPreviewStep>
);
@@ -792,6 +543,7 @@ export function OnboardingPreviewApp() {
onDone={() => setPage("identity-key")}
session={backupSession}
total={journey.totalSteps}
v3Presentation={variant === "v3"}
/>
);
} else if (page === "sign-in") {
@@ -820,7 +572,7 @@ export function OnboardingPreviewApp() {
} else if (page === "sign-in-key") {
content = (
<KeySignInPreview
onBack={() => setPage("sign-in")}
onBack={() => setPage(variant === "v3" ? "sign-in" : "landing")}
onContinue={() => continueFromAccount("sign-in-key")}
total={journey.totalSteps}
/>
@@ -832,6 +584,38 @@ export function OnboardingPreviewApp() {
onNext={() => setPage("config")}
/>
);
} else if (page === "harness-connection") {
content = (
<HarnessConnectionPreview
installedIds={installedHarnessIds}
onBack={() => setPage(setupBackPage)}
onInstall={(option) => {
setInstalledHarnessIds((current) =>
new Set(current).add(option.runtime.id),
);
setSelectedHarnessId(option.runtime.id);
setHarnessConnectionMethod(option.methods[0] ?? "api");
setPage("harness-connection-detail");
}}
onSelect={(option) => {
setSelectedHarnessId(option.runtime.id);
setHarnessConnectionMethod(option.methods[0] ?? "api");
setPage("harness-connection-detail");
}}
total={journey.totalSteps}
/>
);
} else if (page === "harness-connection-detail") {
content = (
<HarnessConnectionDetailPreview
method={harnessConnectionMethod}
onBack={() => setPage("harness-connection")}
onContinue={() => setPage("community-choice")}
onMethodChange={setHarnessConnectionMethod}
option={selectedHarness}
total={journey.totalSteps}
/>
);
} else if (page === "config") {
content = (
<DefaultConfigPreview
@@ -926,6 +710,11 @@ export function OnboardingPreviewApp() {
return (
<>
<OnboardingPreviewControls
harnessConnectionInOnboarding={harnessConnectionInOnboarding}
onHarnessConnectionInOnboardingChange={(included) => {
setHarnessConnectionInOnboarding(included);
restart();
}}
onRestart={restart}
onVariantChange={changeVariant}
variant={variant}

View File

@@ -2,6 +2,7 @@ import { RotateCcw, ShieldCheck } from "lucide-react";
import type { OnboardingPreviewVariant } from "../onboardingPreview";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { SegmentedControl } from "@/shared/ui/segmented-control";
const PREVIEW_VARIANT_OPTIONS = [
@@ -10,10 +11,14 @@ const PREVIEW_VARIANT_OPTIONS = [
] as const;
export function OnboardingPreviewControls({
harnessConnectionInOnboarding,
onHarnessConnectionInOnboardingChange,
onRestart,
onVariantChange,
variant,
}: {
harnessConnectionInOnboarding: boolean;
onHarnessConnectionInOnboardingChange: (included: boolean) => void;
onRestart: () => void;
onVariantChange: (variant: OnboardingPreviewVariant) => void;
variant: OnboardingPreviewVariant;
@@ -56,6 +61,22 @@ export function OnboardingPreviewControls({
testId="onboarding-preview-variant"
value={variant}
/>
{variant === "v3" ? (
<label
className="flex cursor-pointer items-center gap-2 text-xs font-medium text-foreground"
htmlFor="onboarding-preview-harness-placement"
>
<Checkbox
checked={harnessConnectionInOnboarding}
className="border-foreground data-[state=checked]:bg-foreground data-[state=checked]:text-background"
id="onboarding-preview-harness-placement"
onCheckedChange={(checked) =>
onHarnessConnectionInOnboardingChange(checked === true)
}
/>
<span>Harness connection in onboarding</span>
</label>
) : null}
</aside>
);
}

View File

@@ -0,0 +1,303 @@
import { Check } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { OnboardingPreviewInput } from "./OnboardingPreviewInput";
import { useOnboardingPreviewCardLayout } from "./OnboardingPreviewShell";
import { ONBOARDING_PREVIEW_CARD_INPUT_CLASS } from "./onboardingPreviewCardStyles";
type PreviewPasswordStrength = "empty" | "weak" | "moderate" | "strong";
const strengthLabelPhraseVariants = {
animate: { transition: { staggerChildren: 0.008 } },
exit: { transition: { staggerChildren: 0.005 } },
initial: {},
};
const strengthLabelCharacterVariants = {
animate: {
filter: "blur(0)",
opacity: 1,
transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const },
y: 0,
},
exit: {
filter: "blur(0.25rem)",
opacity: 0,
transition: { duration: 0.16, ease: [0.64, 0, 0.78, 0] as const },
y: "-0.5rem",
},
initial: { filter: "blur(0.25rem)", opacity: 0, y: "0.5rem" },
};
function previewPasswordStrength(password: string): PreviewPasswordStrength {
if (password.length === 0) return "empty";
if (password.length < 8) return "weak";
if (password.length < 15) return "moderate";
return "strong";
}
function PasswordStrengthMeter({
descriptionId,
password,
}: {
descriptionId: string;
password: string;
}) {
const strength = previewPasswordStrength(password);
const reduceMotion = useReducedMotion() ?? false;
const activeCount =
strength === "strong"
? 3
: strength === "moderate"
? 2
: strength === "weak"
? 1
: 0;
const label =
strength === "empty"
? "Empty"
: strength === "weak"
? "Weak"
: strength === "moderate"
? "Moderate"
: "Strong";
const labelCharacters = React.useMemo(() => {
const occurrences = new Map<string, number>();
return [...label].map((character) => {
const occurrence = occurrences.get(character) ?? 0;
occurrences.set(character, occurrence + 1);
return { character, key: `${character}-${occurrence}` };
});
}, [label]);
return (
<div
className={cn(
"pointer-events-none absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-2 text-xs font-semibold transition-colors duration-150 motion-reduce:transition-none",
strength === "weak" && "text-red-700",
strength === "moderate" && "text-amber-700",
strength === "strong" && "text-green-700",
strength === "empty" && "text-transparent",
)}
data-strength={strength}
data-testid="onboarding-preview-password-strength"
>
<span className="relative flex h-5 w-[4.75rem] items-center justify-end overflow-visible text-right leading-none">
{reduceMotion ? (
<span>{strength === "empty" ? "" : label}</span>
) : (
<AnimatePresence initial={false} mode="wait">
{strength === "empty" ? null : (
<motion.span
animate="animate"
className="absolute inset-x-0 top-1/2 -translate-y-1/2 whitespace-nowrap"
exit="exit"
initial="initial"
key={label}
variants={strengthLabelPhraseVariants}
>
{labelCharacters.map(({ character, key }) => (
<motion.span
className="inline-block will-change-[transform,opacity,filter]"
key={`${label}-${key}`}
variants={strengthLabelCharacterVariants}
>
{character}
</motion.span>
))}
</motion.span>
)}
</AnimatePresence>
)}
</span>
<span
aria-hidden
className={cn(
"relative flex h-8 w-5 flex-col items-center justify-center gap-0.5",
strength === "empty" && "opacity-0",
)}
data-testid="onboarding-preview-password-strength-indicator"
>
{[3, 2, 1].map((level) => {
const isTopDot = level === 3;
const positionClass =
level === 3
? strength === "strong"
? "top-1/2 -translate-y-1/2"
: "top-1"
: level === 2
? "top-[13px]"
: "top-[22px]";
return (
<span
className={cn(
"absolute left-1/2 flex -translate-x-1/2 items-center justify-center overflow-hidden transition-[top,width,height,border-radius,background-color,opacity,transform] duration-150 ease-in-out motion-reduce:transition-none",
positionClass,
isTopDot && strength === "strong"
? "h-5 w-5 rounded-full bg-green-600"
: "h-1.5 w-1.5 rounded-full",
!isTopDot && strength === "strong"
? level === 2
? "translate-y-1 scale-0 opacity-0"
: "-translate-y-1.5 scale-0 opacity-0"
: "scale-100 opacity-100",
strength !== "strong" &&
strength !== "empty" &&
activeCount >= level &&
"bg-amber-500",
strength === "weak" && activeCount >= level && "bg-red-500",
strength !== "strong" &&
activeCount < level &&
"bg-foreground/20",
)}
data-level={level}
key={level}
>
{isTopDot ? (
<Check
className={cn(
"h-3.5 w-3.5 text-white transition-[opacity,transform] duration-120 ease-in-out motion-reduce:transition-none",
strength === "strong"
? "scale-100 opacity-100"
: "scale-75 opacity-0",
)}
data-testid="onboarding-preview-password-strength-check"
/>
) : null}
</span>
);
})}
</span>
<span
aria-atomic="true"
aria-live="polite"
className="sr-only"
id={descriptionId}
role="status"
>
Password strength: {label}
</span>
</div>
);
}
export function PreviewCredentialsFields({
email,
emailId,
onEmailChange,
onPasswordChange,
onConfirmPasswordChange,
confirmPassword,
password,
passwordAutoComplete,
passwordHelp,
passwordId,
passwordPlaceholder,
showPasswordStrength = false,
}: {
email: string;
emailId: string;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onConfirmPasswordChange?: (value: string) => void;
confirmPassword?: string;
password: string;
passwordAutoComplete: "current-password" | "new-password";
passwordHelp?: React.ReactNode;
passwordId: string;
passwordPlaceholder: string;
showPasswordStrength?: boolean;
}) {
const cardLayout = useOnboardingPreviewCardLayout();
const passwordStrengthDescriptionId = React.useId();
return (
<>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={emailId}
>
Email
</label>
<OnboardingPreviewInput
autoComplete="email"
className={
cardLayout
? ONBOARDING_PREVIEW_CARD_INPUT_CLASS
: "h-12 rounded-2xl border-foreground/15 bg-white px-4"
}
id={emailId}
onChange={(event) => onEmailChange(event.target.value)}
placeholder="Enter your email address"
smooth={cardLayout}
type="email"
value={email}
/>
</div>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={passwordId}
>
Password
</label>
<div className="relative">
<OnboardingPreviewInput
aria-describedby={
showPasswordStrength ? passwordStrengthDescriptionId : undefined
}
autoComplete={passwordAutoComplete}
className={cn(
cardLayout
? ONBOARDING_PREVIEW_CARD_INPUT_CLASS
: "h-12 rounded-2xl border-foreground/15 bg-white px-4",
showPasswordStrength ? "pr-28" : "pr-4",
)}
id={passwordId}
onChange={(event) => onPasswordChange(event.target.value)}
placeholder={passwordPlaceholder}
smooth={cardLayout}
type="password"
value={password}
/>
{showPasswordStrength ? (
<PasswordStrengthMeter
descriptionId={passwordStrengthDescriptionId}
password={password}
/>
) : null}
</div>
{passwordHelp ? (
<div className="mt-2 text-left">{passwordHelp}</div>
) : null}
{onConfirmPasswordChange ? (
<div className="mt-5">
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={`${passwordId}-confirmation`}
>
Confirm password
</label>
<OnboardingPreviewInput
autoComplete="new-password"
className={
cardLayout
? ONBOARDING_PREVIEW_CARD_INPUT_CLASS
: "h-12 rounded-2xl border-foreground/15 bg-white px-4"
}
id={`${passwordId}-confirmation`}
onChange={(event) => onConfirmPasswordChange(event.target.value)}
placeholder="Confirm your password"
smooth={cardLayout}
type="password"
value={confirmPassword ?? ""}
/>
</div>
) : null}
</div>
</>
);
}

View File

@@ -41,11 +41,13 @@ export function BackupPasswordPreview({
onDone,
session,
total,
v3Presentation = false,
}: {
onBack: () => void;
onDone: () => void;
session: EncryptedBackupSession;
total: number;
v3Presentation?: boolean;
}) {
return (
<OnboardingPreviewStep
@@ -59,6 +61,7 @@ export function BackupPasswordPreview({
onBack={onDone}
previewMode
session={session}
v3Presentation={v3Presentation}
/>
</OnboardingPreviewStep>
);
@@ -335,7 +338,7 @@ export function CommunityChoicePreview({
className={cn(
"flex w-full flex-1 flex-col",
cardLayout
? "items-stretch justify-start gap-2 py-6"
? "-mx-2 w-[calc(100%+1rem)] items-stretch justify-start gap-2 py-6"
: "translate-y-16 items-center justify-center gap-20 py-8",
)}
>

View File

@@ -1,36 +1,268 @@
import {
ArrowUp,
AtSign,
Bot,
ChevronLeft,
ChevronRight,
FolderKanban,
Hash,
Headphones,
Home,
Lock,
Mic,
MoreVertical,
Paperclip,
Plus,
Search,
Send,
Smile,
Sparkles,
TerminalSquare,
Users,
} from "lucide-react";
import * as React from "react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { ChannelIntroBlock } from "@/features/messages/ui/ChannelIntroBlock";
import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
const CHANNEL_ROW_CLASS =
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition-colors";
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition-colors hover:bg-black/[0.06]";
const WELCOME_TEAM = [
{ name: "Fizz", image: "/onboarding/starter-team/fizz.png" },
{ name: "Honey", image: "/onboarding/starter-team/honey.png" },
{ name: "Pollen", image: "/onboarding/starter-team/pollen.png" },
] as const;
const WELCOME_PREVIEW_MESSAGE =
"Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build.";
type PreviewThemeStyle = React.CSSProperties & Record<`--${string}`, string>;
const PREVIEW_APP_THEME: PreviewThemeStyle = {
colorScheme: "light",
"--accent": "0 0% 94%",
"--accent-foreground": "0 0% 10%",
"--background": "0 0% 100%",
"--border": "0 0% 87%",
"--card": "0 0% 100%",
"--card-foreground": "0 0% 10%",
"--foreground": "0 0% 10%",
"--input": "0 0% 84%",
"--muted": "0 0% 94%",
"--muted-foreground": "0 0% 42%",
"--popover": "0 0% 100%",
"--popover-foreground": "0 0% 10%",
"--primary": "0 0% 10%",
"--primary-foreground": "0 0% 100%",
"--ring": "0 0% 20%",
"--secondary": "0 0% 96%",
"--secondary-foreground": "0 0% 10%",
"--sidebar": "48 48% 88%",
"--sidebar-accent": "0 0% 100%",
"--sidebar-accent-foreground": "0 0% 10%",
"--sidebar-background": "48 48% 88%",
"--sidebar-border": "0 0% 60%",
"--sidebar-foreground": "0 0% 18%",
"--sidebar-ring": "0 0% 20%",
};
function PreviewAvatar({
avatarUrl,
label,
size = "small",
}: {
avatarUrl: string;
label: string;
size?: "small" | "message";
}) {
const className =
size === "message"
? "h-9 w-9 rounded-lg text-xs"
: "h-8 w-8 rounded-lg text-xs";
if (avatarUrl) {
return (
<ProfileAvatar
avatarUrl={avatarUrl}
className={className}
label={label}
/>
);
}
return (
<span
aria-label={label}
className={cn(
"flex shrink-0 items-center justify-center bg-black font-semibold text-white",
className,
)}
role="img"
>
{label.trim().charAt(0).toUpperCase() || "Y"}
</span>
);
}
function PreviewCommunityRail({ communityName }: { communityName: string }) {
return (
<nav
aria-label="Communities"
className="flex w-14 shrink-0 flex-col items-center gap-2.5 px-2.5 pb-5 pt-12 text-black"
>
<button
aria-label={communityName}
className="relative flex size-9 items-center justify-center rounded-xl bg-black text-[#f4ef82]"
type="button"
>
<span className="absolute -left-2.5 h-5 w-1 rounded-r-full bg-black" />
<BuzzMark className="h-5 w-7" />
</button>
{WELCOME_TEAM.map((agent) => (
<button
aria-label={`${agent.name} community`}
className="size-9 overflow-hidden rounded-xl bg-white/65"
key={agent.name}
type="button"
>
<img
alt=""
className="h-full w-full object-contain"
src={agent.image}
/>
</button>
))}
<button
aria-label="Add community"
className="flex size-9 items-center justify-center rounded-2xl bg-white/65 text-black/65 transition-[border-radius,background-color] hover:rounded-xl hover:bg-black hover:text-white"
type="button"
>
<Plus className="size-4" />
</button>
</nav>
);
}
function PreviewSidebar({
avatarUrl,
channelName,
communityName,
profileName,
onPreviewAction,
}: {
avatarUrl: string;
channelName: string;
communityName: string;
profileName: string;
onPreviewAction: (action: string) => void;
}) {
return (
<aside className="flex w-[344px] shrink-0 flex-col pb-2 pr-2 pt-10 text-black/80">
<div className="flex h-9 items-center gap-0.5 px-2">
<Button
aria-label="Go back"
className="size-7 rounded-md text-black/35"
disabled
size="icon"
variant="ghost"
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="Go forward"
className="size-7 rounded-md text-black/35"
disabled
size="icon"
variant="ghost"
>
<ChevronRight className="size-4" />
</Button>
</div>
<div className="px-2 pb-3 pt-1">
<button
className="flex h-9 w-full items-center gap-2 rounded-lg bg-white/25 px-3 text-sm text-black/45 transition-colors hover:bg-white/45"
onClick={() => onPreviewAction("Search")}
type="button"
>
<Search className="size-4" />
<span className="flex-1 text-left">Search everything</span>
<span className="text-xs">⌘K</span>
</button>
</div>
<nav aria-label="Workspace" className="space-y-0.5 px-2">
<button className={CHANNEL_ROW_CLASS} type="button">
<Home className="size-4 text-black/50" />
Home
</button>
<button className={CHANNEL_ROW_CLASS} type="button">
<FolderKanban className="size-4 text-black/50" />
Projects
</button>
<button className={CHANNEL_ROW_CLASS} type="button">
<Bot className="size-4 text-black/50" />
Agents
</button>
</nav>
<div className="mt-5 flex items-center justify-between px-4 text-xs font-medium text-black/45">
<span>Channels</span>
<Plus className="size-3.5" />
</div>
<div className="mt-1 space-y-0.5 px-2">
<button className={CHANNEL_ROW_CLASS} type="button">
<Hash className="size-4 text-black/45" />
general
</button>
<button className={CHANNEL_ROW_CLASS} type="button">
<Hash className="size-4 text-black/45" />
welcome-everyone
</button>
<button
className={cn(CHANNEL_ROW_CLASS, "bg-black/[0.07] font-semibold")}
type="button"
>
<Lock className="size-4 text-black/55" />
{channelName}
</button>
</div>
<div className="mt-5 px-4 text-xs font-medium text-black/45">
Direct messages
</div>
<div className="mt-1 space-y-0.5 px-2">
{WELCOME_TEAM.map((agent) => (
<button className={CHANNEL_ROW_CLASS} key={agent.name} type="button">
<span className="relative size-6 shrink-0">
<img
alt=""
className="size-6 rounded-lg object-contain"
src={agent.image}
/>
<span className="absolute -bottom-0.5 -right-0.5 size-2 rounded-full border border-[#dfe5cf] bg-emerald-500" />
</span>
{agent.name}
</button>
))}
</div>
<button
className="mt-auto flex items-center gap-2 rounded-xl px-2 py-2 text-left hover:bg-black/[0.05]"
onClick={() => onPreviewAction("Profile")}
type="button"
>
<PreviewAvatar avatarUrl={avatarUrl} label={profileName} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-black/85">
{profileName}
</p>
<p className="truncate text-xs text-black/45">🐝 {communityName}</p>
</div>
</button>
</aside>
);
}
export function WelcomeChannelAppPreview({
avatarUrl,
@@ -43,11 +275,10 @@ export function WelcomeChannelAppPreview({
}) {
const [draft, setDraft] = React.useState("");
const [messages, setMessages] = React.useState<string[]>([]);
const [channelName, setChannelName] = React.useState("Welcome");
const [showRenamePrompt, setShowRenamePrompt] = React.useState(true);
const [activePreviewAction, setActivePreviewAction] = React.useState<
string | null
>(null);
const channelName = "Welcome";
const profileName = displayName.trim() || "Your profile";
const submitDraft = (event: React.FormEvent) => {
event.preventDefault();
@@ -60,19 +291,19 @@ export function WelcomeChannelAppPreview({
const intro = {
actions: [
{
icon: <Hash aria-hidden className="h-4 w-4" />,
icon: <Hash aria-hidden className="size-4" />,
label: "Browse channels",
onClick: () => setActivePreviewAction("Browse channels"),
testId: "welcome-intro-action-browse-channels",
},
{
icon: <Plus aria-hidden className="h-4 w-4" />,
icon: <Plus aria-hidden className="size-4" />,
label: "Create a channel",
onClick: () => setActivePreviewAction("Create a channel"),
testId: "welcome-intro-action-create-channel",
},
{
icon: <Bot aria-hidden className="h-4 w-4" />,
icon: <Bot aria-hidden className="size-4" />,
label: "Create an agent",
onClick: () => setActivePreviewAction("Create an agent"),
testId: "welcome-intro-action-create-agent",
@@ -81,333 +312,181 @@ export function WelcomeChannelAppPreview({
channelKindLabel: "private welcome channel",
channelName,
description: null,
icon: <Sparkles aria-hidden className="h-7 w-7" />,
};
return (
<div
className="flex h-dvh w-full flex-col overflow-hidden bg-background text-foreground"
className="flex h-dvh w-full overflow-hidden bg-[linear-gradient(145deg,#f3ed8d_0%,#dce8bf_48%,#c8dde8_100%)] text-foreground"
data-testid="onboarding-preview-community-home"
style={PREVIEW_APP_THEME}
>
<StartupWindowDragRegion />
<div
className="flex h-12 shrink-0 items-center border-b border-sidebar-border bg-sidebar pl-20 pr-4 text-sidebar-foreground"
data-tauri-drag-region
>
<div className="flex items-center gap-0.5">
<Button
aria-label="Go back"
className="h-7 w-6 rounded text-sidebar-foreground/45"
disabled
size="icon"
variant="ghost"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
aria-label="Go forward"
className="h-7 w-6 rounded text-sidebar-foreground/45"
disabled
size="icon"
variant="ghost"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<button
className="mx-auto flex h-7 w-full max-w-[420px] items-center justify-center gap-2 rounded-md bg-sidebar-accent/65 px-3 text-xs text-sidebar-foreground/65"
onClick={() => setActivePreviewAction("Search")}
type="button"
<PreviewCommunityRail communityName={communityName} />
<PreviewSidebar
avatarUrl={avatarUrl}
channelName={channelName}
communityName={communityName}
onPreviewAction={setActivePreviewAction}
profileName={profileName}
/>
<main className="relative z-10 m-2 ml-0 flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-white text-black shadow-[-1px_0_0_0_rgb(0_0_0_/_0.08)]">
<header
className="relative z-30 shrink-0 cursor-default select-none bg-white px-5 py-2"
data-testid="chat-header"
data-tauri-drag-region
>
<Search className="h-3.5 w-3.5" aria-hidden />
Search {communityName}
</button>
<div className="w-[72px]" aria-hidden />
</div>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[300px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar p-2 text-sidebar-foreground">
<button
className="flex h-10 items-center gap-2 rounded-lg px-2 text-left hover:bg-sidebar-accent"
type="button"
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background">
B
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{communityName}
</span>
</button>
<nav className="mt-2 space-y-0.5" aria-label="Workspace">
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
type="button"
>
<Home
className="h-4 w-4 text-sidebar-foreground/65"
aria-hidden
/>
Home
</button>
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
type="button"
>
<FolderKanban
className="h-4 w-4 text-sidebar-foreground/65"
aria-hidden
/>
Projects
</button>
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
type="button"
>
<Bot className="h-4 w-4 text-sidebar-foreground/65" aria-hidden />
Agents
</button>
</nav>
<div className="mt-5 flex items-center justify-between px-2 text-xs font-medium text-sidebar-foreground/55">
<span>Channels</span>
<Plus className="h-3.5 w-3.5" aria-hidden />
</div>
<div className="mt-1 space-y-0.5">
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
data-testid="channel-general"
type="button"
>
<Hash
className="h-4 w-4 text-sidebar-foreground/55"
aria-hidden
/>
general
</button>
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
data-testid="channel-welcome-everyone"
type="button"
>
<Hash
className="h-4 w-4 text-sidebar-foreground/55"
aria-hidden
/>
welcome-everyone
</button>
<button
className={cn(
CHANNEL_ROW_CLASS,
"bg-sidebar-accent font-medium text-sidebar-accent-foreground",
)}
data-testid="channel-Welcome"
type="button"
>
<Lock className="h-4 w-4" aria-hidden />
{channelName}
</button>
</div>
<div className="mt-auto flex items-center gap-2 rounded-lg px-2 py-2 hover:bg-sidebar-accent">
<ProfileAvatar
avatarUrl={avatarUrl || null}
className="h-8 w-8 rounded-lg text-xs"
label={profileName}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{profileName}</p>
<p className="truncate text-xs text-sidebar-foreground/55">
Available
</p>
<div className="flex h-9 min-w-0 items-center gap-2.5">
<div className="flex min-w-0 flex-1 items-center gap-1">
<Lock className="size-4 text-black/45" />
<h1
className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight"
data-testid="chat-title"
>
{channelName}
</h1>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
aria-label="Open terminal"
className="size-9 rounded-xl"
size="icon"
variant="outline"
>
<TerminalSquare className="size-4" />
</Button>
<Button
aria-label="View members"
className="h-9 gap-2 rounded-xl px-3"
variant="outline"
>
<Users className="size-4" />
<span>4</span>
</Button>
<Button
aria-label="Start huddle"
className="size-9 rounded-xl"
size="icon"
variant="outline"
>
<Headphones className="size-4" />
</Button>
<Button
aria-label="More channel actions"
className="size-9 rounded-xl"
size="icon"
variant="outline"
>
<MoreVertical className="size-4" />
</Button>
</div>
</div>
</aside>
</header>
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
<header className="relative z-20 flex h-14 shrink-0 items-center border-b border-border/55 bg-background/80 px-5 backdrop-blur-md">
<Lock className="h-4 w-4 text-muted-foreground" aria-hidden />
<h1
className="ml-1 text-base font-semibold"
data-testid="chat-title"
>
{channelName}
</h1>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-48 pt-5">
<div className="mx-auto w-full max-w-[920px]">
<ChannelIntroBlock intro={intro} />
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-56 pt-4">
<div className="mx-auto w-full max-w-[920px]">
<ChannelIntroBlock intro={intro} />
<section
className="mx-3 mt-7 max-w-[680px] border-t border-border/60 pt-6 text-left"
data-testid="welcome-preview-community-message"
>
<p className="text-lg font-semibold">
Welcome to {communityName}
<div className="mt-8 flex items-start gap-3 px-3 text-left">
<img
alt="Fizz"
className="size-9 rounded-lg object-contain"
src="/onboarding/starter-team/fizz.png"
/>
<div className="min-w-0 max-w-[680px]">
<div className="flex items-baseline gap-2">
<p className="text-sm font-semibold">Fizz</p>
<p className="text-xs text-muted-foreground">Now</p>
</div>
<p className="mt-1 whitespace-pre-line text-sm leading-5">
{WELCOME_PREVIEW_MESSAGE}
</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Everyone starts with a private channel to get settled. This
one is yours. Use it to try out features, draft messages, or
work privately with agents.
</p>
</section>
{showRenamePrompt ? (
<section
className="mx-3 mt-6 flex max-w-[680px] items-center gap-4 rounded-2xl border border-border/70 bg-background/70 px-5 py-4 text-left"
data-testid="welcome-preview-rename-prompt"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Lock className="h-4 w-4" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
Make this space yours
</p>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
This private channel is yours to keep. Rename it for how
you want to use it.
</p>
</div>
<Button
className="h-9 shrink-0 rounded-full px-5"
onClick={() => {
setChannelName(
profileName === "Your profile"
? "My space"
: `${profileName}’s space`,
);
setShowRenamePrompt(false);
setActivePreviewAction("Channel details opened");
}}
type="button"
variant="outline"
>
Rename channel
</Button>
</section>
) : null}
<section
className="mx-3 mt-6 max-w-[680px] overflow-hidden rounded-2xl border border-border/70 bg-muted/35 text-left"
data-testid="welcome-preview-agent-activation"
>
<div className="flex items-center gap-4 p-5">
<div className="flex shrink-0 -space-x-3">
{WELCOME_TEAM.map((agent) => (
<img
alt={agent.name}
className="h-14 w-14 rounded-2xl border-2 border-background bg-background object-contain"
key={agent.name}
src={agent.image}
/>
))}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-base font-semibold">
Bring your starter team online
</p>
<span className="rounded-full bg-foreground/8 px-2 py-0.5 text-xs font-medium text-muted-foreground">
Not connected
</span>
</div>
<p className="mt-1.5 text-sm leading-5 text-muted-foreground">
Connect an AI provider to start Fizz, Honey, and Pollen.
They can help you learn Buzz and work through something
you’re building.
</p>
</div>
</div>
<div className="flex items-center justify-between border-t border-border/60 bg-background/45 px-5 py-3">
<p className="text-xs text-muted-foreground">
You can change providers or agents later.
</p>
<Button
className="h-9 rounded-full px-5"
onClick={() =>
setActivePreviewAction("Connect AI provider")
}
type="button"
>
Connect AI provider
</Button>
</div>
</section>
{messages.map((message, index) => (
<div
className="mt-5 flex items-start gap-3 px-3 text-left"
key={`${message}-${index.toString()}`}
>
<ProfileAvatar
avatarUrl={avatarUrl || null}
className="h-9 w-9 rounded-lg text-xs"
label={profileName}
/>
<div>
<p className="text-sm font-semibold">{profileName}</p>
<p className="mt-1 text-sm leading-5">{message}</p>
</div>
</div>
))}
</div>
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-40 isolate before:absolute before:inset-x-0 before:bottom-0 before:-z-10 before:h-24 before:bg-gradient-to-b before:from-transparent before:to-background before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:-z-10 after:h-12 after:bg-background after:content-['']">
<div className="composer-dock composer-overlay-corner-masks relative pointer-events-auto">
<ComposerDockBackdrop gutterClassName="inset-x-5" />
<form
className="relative z-10 mx-5 rounded-2xl border border-border/50 bg-background/80 px-4 pb-2 pt-3 shadow-none backdrop-blur-md"
data-testid="message-composer"
onSubmit={submitDraft}
{messages.map((message, index) => (
<div
className="mt-6 flex items-start gap-3 px-3 text-left"
key={`${message}-${index.toString()}`}
>
<textarea
aria-label={`Message ${channelName}`}
className="min-h-11 w-full resize-none bg-transparent text-sm leading-5 outline-none placeholder:text-muted-foreground/65"
data-testid="welcome-preview-composer-input"
onChange={(event) => setDraft(event.target.value)}
placeholder={`Message #${channelName}`}
rows={2}
value={draft}
<PreviewAvatar
avatarUrl={avatarUrl}
label={profileName}
size="message"
/>
<div className="flex items-center gap-1">
{[Paperclip, Smile, AtSign, Mic].map((Icon) => (
<Button
className="h-7 w-7 text-muted-foreground"
key={Icon.displayName ?? Icon.name}
size="icon"
type="button"
variant="ghost"
>
<Icon className="h-4 w-4" aria-hidden />
</Button>
))}
<Button
aria-label="Send message"
className="ml-auto h-7 w-7 rounded-lg"
disabled={!draft.trim()}
size="icon"
type="submit"
>
<Send className="h-4 w-4" aria-hidden />
</Button>
<div>
<div className="flex items-baseline gap-2">
<p className="text-sm font-semibold">{profileName}</p>
<p className="text-xs text-muted-foreground">Now</p>
</div>
<p className="mt-1 text-sm leading-5">{message}</p>
</div>
</form>
</div>
</div>
))}
</div>
</div>
{activePreviewAction ? (
<div
className="absolute right-5 top-16 z-50 rounded-lg border border-border bg-background px-3 py-2 text-xs shadow-lg"
role="status"
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-40 isolate before:absolute before:inset-x-0 before:bottom-0 before:-z-10 before:h-24 before:bg-gradient-to-b before:from-transparent before:to-white before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:-z-10 after:h-12 after:bg-white after:content-['']">
<div className="composer-dock composer-overlay-corner-masks relative pointer-events-auto">
<ComposerDockBackdrop gutterClassName="inset-x-4" />
<form
className="relative z-10 mx-4 mb-3 rounded-2xl border border-black/10 bg-white/95 px-4 pb-3 pt-3 backdrop-blur-md"
data-testid="message-composer"
onSubmit={submitDraft}
>
{activePreviewAction}
</div>
) : null}
</main>
</div>
<textarea
aria-label={`Message ${channelName}`}
className="min-h-11 w-full resize-none bg-transparent text-sm leading-5 outline-none placeholder:text-black/45"
data-testid="welcome-preview-composer-input"
onChange={(event) => setDraft(event.target.value)}
placeholder={`Message #${channelName}`}
rows={2}
value={draft}
/>
<div className="flex items-center gap-1">
{[AtSign, Paperclip, Smile].map((Icon) => (
<Button
className="size-7 text-black/55"
key={Icon.displayName ?? Icon.name}
size="icon"
type="button"
variant="ghost"
>
<Icon className="size-4" />
</Button>
))}
<Button
aria-label="Formatting"
className="size-7 text-xs font-semibold text-black/55"
size="icon"
type="button"
variant="ghost"
>
Aa
</Button>
<Button
aria-label="Send message"
className="ml-auto size-9 rounded-full"
disabled={!draft.trim()}
size="icon"
type="submit"
>
<ArrowUp className="size-4" />
</Button>
</div>
</form>
</div>
</div>
{activePreviewAction ? (
<div
className="absolute right-5 top-16 z-50 rounded-lg border border-black/10 bg-white px-3 py-2 text-xs shadow-lg"
role="status"
>
{activePreviewAction}
</div>
) : null}
</main>
</div>
);
}

View File

@@ -9,6 +9,7 @@ type SegmentOption<Value extends string> = {
};
type SegmentedControlSize = "compact" | "default" | "wide";
type SegmentedControlAppearance = "default" | "onboarding-inline";
const SIZE_CLASSES: Record<SegmentedControlSize, string> = {
compact: "w-48",
@@ -18,6 +19,7 @@ const SIZE_CLASSES: Record<SegmentedControlSize, string> = {
/** A mutually exclusive control with equal-width, optionally scrubbable options. */
export function SegmentedControl<Value extends string>({
appearance = "default",
className,
indicatorTestId,
legend,
@@ -29,6 +31,7 @@ export function SegmentedControl<Value extends string>({
testId,
value,
}: {
appearance?: SegmentedControlAppearance;
className?: string;
indicatorTestId?: string;
legend: string;
@@ -42,17 +45,45 @@ export function SegmentedControl<Value extends string>({
}) {
const [previewValue, setPreviewValue] = React.useState<Value | null>(null);
const controlRef = React.useRef<HTMLFieldSetElement | null>(null);
const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const [inlineIndicator, setInlineIndicator] = React.useState({
left: 3,
width: 0,
});
const activePointerIdRef = React.useRef<number | null>(null);
const pointerStartXRef = React.useRef<number | null>(null);
const pointerStartValueRef = React.useRef<Value | null>(null);
const scrubValueRef = React.useRef<Value | null>(null);
const skipPointerClickRef = React.useRef(false);
const displayedValue = previewValue ?? value;
const isOnboardingInline = appearance === "onboarding-inline";
const selectedIndex = Math.max(
0,
options.findIndex((option) => option.value === displayedValue),
);
React.useLayoutEffect(() => {
if (!isOnboardingInline) return;
const control = controlRef.current;
const selectedOption = optionRefs.current[selectedIndex];
if (!control || !selectedOption) return;
const updateIndicator = () => {
const controlBounds = control.getBoundingClientRect();
const optionBounds = selectedOption.getBoundingClientRect();
setInlineIndicator({
left: optionBounds.left - controlBounds.left,
width: optionBounds.width,
});
};
updateIndicator();
const observer = new ResizeObserver(updateIndicator);
observer.observe(control);
observer.observe(selectedOption);
return () => observer.disconnect();
}, [isOnboardingInline, selectedIndex]);
const getValueAtPointer = React.useCallback(
(element: HTMLFieldSetElement, clientX: number): Value => {
const bounds = element.getBoundingClientRect();
@@ -158,7 +189,10 @@ export function SegmentedControl<Value extends string>({
return (
<fieldset
className={cn(
"relative isolate h-8 max-w-full shrink-0 overflow-hidden rounded-md bg-muted/45 p-0.5",
"relative isolate max-w-full shrink-0 overflow-hidden",
isOnboardingInline
? "h-9 rounded-full border border-[#d4d4d4] bg-[#e2e2e2]/30 p-[3px] text-[#0f0f0f]"
: "h-8 rounded-md bg-muted/45 p-0.5",
SIZE_CLASSES[size],
onPreviewChange && "touch-none select-none cursor-ew-resize",
className,
@@ -176,26 +210,44 @@ export function SegmentedControl<Value extends string>({
<div
aria-hidden="true"
className={cn(
"absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none",
"absolute z-0 transition-[transform,width] duration-200 ease-in-out motion-reduce:transition-none",
isOnboardingInline
? "bottom-[3px] left-0 top-[3px] rounded-full bg-[#e2e2e2]"
: "bottom-0.5 left-0.5 top-0.5 rounded-md bg-background shadow-sm",
previewValue && "duration-0",
)}
data-testid={indicatorTestId ?? `${testId}-indicator`}
style={{
transform: `translateX(${selectedIndex * 100}%)`,
width: `calc((100% - 0.25rem) / ${options.length})`,
transform: isOnboardingInline
? `translateX(${inlineIndicator.left}px)`
: `translateX(${selectedIndex * 100}%)`,
width: isOnboardingInline
? inlineIndicator.width
: `calc((100% - 0.25rem) / ${options.length})`,
}}
/>
{/* Legends escape grid/flex layout on a fieldset, so the columns live
on an inner wrapper the legend is not part of. */}
<div className="grid h-full auto-cols-fr grid-flow-col">
{options.map(({ value: optionValue, label, Icon }) => (
<div
className={cn(
"h-full",
isOnboardingInline
? "flex w-fit gap-0.5"
: "grid auto-cols-fr grid-flow-col",
)}
>
{options.map(({ value: optionValue, label, Icon }, optionIndex) => (
<button
aria-pressed={value === optionValue}
className={cn(
"relative z-10 flex h-full items-center justify-center gap-1.5 rounded-md bg-transparent px-2.5 text-xs font-medium transition-colors duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
displayedValue === optionValue
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
"relative z-10 flex h-full items-center justify-center gap-1.5 bg-transparent font-medium transition-colors duration-150 ease-out focus-visible:outline-hidden motion-reduce:transition-none",
isOnboardingInline
? "rounded-full px-4 py-1 text-sm text-[#0f0f0f] focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#0f0f0f]"
: "rounded-md px-2.5 text-xs focus-visible:ring-2 focus-visible:ring-ring",
!isOnboardingInline &&
(displayedValue === optionValue
? "text-foreground"
: "text-muted-foreground hover:text-foreground"),
)}
data-testid={`${optionTestIdPrefix}-${optionValue}`}
key={optionValue}
@@ -206,6 +258,9 @@ export function SegmentedControl<Value extends string>({
}
onValueChange(optionValue);
}}
ref={(element) => {
optionRefs.current[optionIndex] = element;
}}
type="button"
>
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}