mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
feat(onboarding): extend V3 workshop preview
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
652
desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx
Normal file
652
desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user