Prototype Buzz onboarding V3 workshop flow

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-26 18:26:10 +01:00
parent e51106eba9
commit 55af8df0da
15 changed files with 1802 additions and 340 deletions

View File

@@ -569,6 +569,41 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs
echo "Starting standalone desktop on Vite port ${BUZZ_VITE_PORT}; no relay services were started"
pnpm exec tauri dev --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
# Launch a development-only onboarding workshop. The frontend uses React-only
# state and fixed mock data; native setup stops before migrations, identity
# persistence, keychain access, relay sync, or agent restore.
onboarding-preview *ARGS: _ensure-sidecar-stubs
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
cd {{desktop_dir}}
[[ -d node_modules ]] || pnpm install
unset BUZZ_AUTH_TAG BUZZ_RESET_WEBVIEW_STATE BUZZ_SHARE_IDENTITY
export BUZZ_ONBOARDING_PREVIEW=1
export BUZZ_RELAY_URL="ws://127.0.0.1:9"
# Fixed public test fixture: build_app_state never generates an identity,
# while the preview UI never receives or exposes this value.
export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03"
source ../scripts/instance-env.sh
export BUZZ_PREVIEW_VITE_PORT=$((20001 + (BUZZ_VITE_PORT % 40000)))
BUZZ_TAURI_CONFIG=$(node -e '
const config = JSON.parse(process.env.BUZZ_TAURI_CONFIG);
const url = new URL(config.build.devUrl);
url.port = process.env.BUZZ_PREVIEW_VITE_PORT;
url.searchParams.set("onboardingPreview", "1");
config.build.devUrl = url.toString();
config.build.beforeDevCommand = `exec ./node_modules/.bin/vite --port ${process.env.BUZZ_PREVIEW_VITE_PORT} --strictPort`;
config.identifier = `xyz.block.buzz.onboarding-preview.p${process.env.BUZZ_PREVIEW_VITE_PORT}`;
config.productName = "Buzz Onboarding Preview";
process.stdout.write(JSON.stringify(config));
')
export BUZZ_TAURI_CONFIG
export BUZZ_DEV_KEYRING_SERVICE="buzz-desktop-onboarding-preview.${BUZZ_PREVIEW_VITE_PORT}"
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
echo "Starting non-persistent onboarding preview on Vite port ${BUZZ_PREVIEW_VITE_PORT}; external relay access is disabled"
pnpm exec tauri dev --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
# Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
staging *ARGS: bootstrap _ensure-sidecar-stubs
#!/usr/bin/env bash

View File

@@ -176,18 +176,18 @@ pub fn build_media_fetch_client() -> reqwest::Result<reqwest::Client> {
}
pub fn build_app_state() -> AppState {
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
// in setup() will replace the ephemeral placeholder with a persisted key.
let (keys, identity_storage) = match identity_from_env() {
Some(keys) => {
eprintln!(
"buzz-desktop: configured identity pubkey {}",
keys.public_key().to_hex()
);
(keys, IdentityStorage::Environment)
}
None => (Keys::generate(), IdentityStorage::Ephemeral),
};
// Env var takes precedence; setup() otherwise resolves a persisted identity.
let (keys, identity_storage) =
match crate::onboarding_preview::require_fixed_identity(identity_from_env()) {
Some(keys) => {
eprintln!(
"buzz-desktop: configured identity pubkey {}",
keys.public_key().to_hex()
);
(keys, IdentityStorage::Environment)
}
None => (Keys::generate(), IdentityStorage::Ephemeral),
};
AppState {
keys: Mutex::new(keys),

View File

@@ -33,6 +33,7 @@ mod native_websocket_batch;
mod nostr_bind;
pub mod nostr_convert;
mod observed_unread;
mod onboarding_preview;
mod persona_catalog;
mod prevent_sleep;
mod ptt_shortcut;
@@ -161,6 +162,10 @@ pub fn run() {
// commit the startup surface before revealing it.
let window = webview.window();
if onboarding_preview::reveal_window(&window) {
return;
}
#[cfg(target_os = "macos")]
{
set_initial_window_backing(&window);
@@ -238,6 +243,9 @@ pub fn run() {
.manage(channel_head_cache::ChannelHeadCacheStore::default())
.setup(move |app| {
let app_handle = app.handle().clone();
if onboarding_preview::skip_native_setup() {
return Ok(());
}
#[cfg(target_os = "macos")]
{
tray_menu::init(&app_handle)?;

View File

@@ -0,0 +1,55 @@
const ONBOARDING_PREVIEW_ENV: &str = "BUZZ_ONBOARDING_PREVIEW";
fn enabled_for(debug_build: bool, value: Option<&str>) -> bool {
debug_build && value == Some("1")
}
/// Whether the development-only native onboarding workshop is active.
pub(crate) fn enabled() -> bool {
enabled_for(
cfg!(debug_assertions),
std::env::var(ONBOARDING_PREVIEW_ENV).ok().as_deref(),
)
}
/// Fail closed before app state could generate a placeholder identity.
pub(crate) fn require_fixed_identity<T>(identity: Option<T>) -> Option<T> {
assert!(
identity.is_some() || !enabled(),
"BUZZ_ONBOARDING_PREVIEW requires fixed mock identity data"
);
identity
}
/// Stop native setup before migrations, persistence, networking, or agents.
pub(crate) fn skip_native_setup() -> bool {
if !enabled() {
return false;
}
eprintln!(
"buzz-desktop: onboarding preview safety mode; skipped migrations, identity persistence, relay sync, and agent restore"
);
true
}
/// Preview does not wait for the normal app's first-render event.
pub(crate) fn reveal_window<R: tauri::Runtime>(window: &tauri::Window<R>) -> bool {
if !enabled() {
return false;
}
crate::initial_window::reveal_initial_window(window);
true
}
#[cfg(test)]
mod tests {
use super::enabled_for;
#[test]
fn preview_is_debug_only_and_requires_exact_opt_in() {
assert!(enabled_for(true, Some("1")));
assert!(!enabled_for(true, Some("true")));
assert!(!enabled_for(true, None));
assert!(!enabled_for(false, Some("1")));
}
}

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveOnboardingPreviewMode } from "./onboardingPreview.ts";
test("preview requires an exact development opt-in", () => {
assert.equal(
resolveOnboardingPreviewMode({
dev: true,
mode: "development",
search: "?onboardingPreview=1",
}),
true,
);
assert.equal(
resolveOnboardingPreviewMode({
dev: true,
mode: "development",
search: "?onboardingPreview=true",
}),
false,
);
});
test("preview is unavailable in production", () => {
assert.equal(
resolveOnboardingPreviewMode({
dev: false,
mode: "production",
search: "?onboardingPreview=1",
}),
false,
);
});
test("preview is available to the explicit E2E build", () => {
assert.equal(
resolveOnboardingPreviewMode({
dev: false,
mode: "e2e",
search: "?onboardingPreview=1",
}),
true,
);
});

View File

@@ -0,0 +1,31 @@
const ONBOARDING_PREVIEW_PARAM = "onboardingPreview";
const ONBOARDING_PREVIEW_VALUE = "1";
type OnboardingPreviewEnvironment = {
dev: boolean;
mode: string;
search: string;
};
/** Resolve the explicit, non-production onboarding workshop route. */
export function resolveOnboardingPreviewMode({
dev,
mode,
search,
}: OnboardingPreviewEnvironment) {
if (!dev && mode !== "e2e") return false;
return (
new URLSearchParams(search).get(ONBOARDING_PREVIEW_PARAM) ===
ONBOARDING_PREVIEW_VALUE
);
}
/** True only for a development or explicit E2E preview boot. */
export function onboardingPreviewRequested() {
if (typeof window === "undefined") return false;
return resolveOnboardingPreviewMode({
dev: import.meta.env.DEV,
mode: import.meta.env.MODE,
search: window.location.search,
});
}

View File

@@ -27,6 +27,7 @@ import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
* pause sells the creation moment.
*/
const INTRO_HOLD_MS = 1400;
const PREVIEW_KEY_TEXT = `nsec1${"q".repeat(58)}`;
/**
* The creation moment should only be sold once per app session. Module-level
@@ -53,6 +54,7 @@ type BackupStepProps = {
onOpenPasswordBackup: () => void;
onShowOptions: () => void;
optionsExpanded: boolean;
previewMode?: boolean;
returningFromSecurity: boolean;
};
@@ -69,6 +71,7 @@ export function BackupStep({
onOpenPasswordBackup,
onShowOptions,
optionsExpanded,
previewMode = false,
returningFromSecurity,
}: BackupStepProps) {
const reduceMotion = useReducedMotion() ?? false;
@@ -112,7 +115,7 @@ export function BackupStep({
setCopyState("copying");
setCopyError(null);
try {
const value = nsec ?? (await getNsec());
const value = nsec ?? (previewMode ? PREVIEW_KEY_TEXT : await getNsec());
await writeTextToClipboard(value);
if (cancelledRef.current) return;
setCopyState("copied");
@@ -128,7 +131,7 @@ export function BackupStep({
err instanceof Error ? err.message : "Failed to retrieve private key.",
);
}
}, [nsec]);
}, [nsec, previewMode]);
const toggleReveal = React.useCallback(async () => {
if (isRevealed) {
@@ -138,7 +141,7 @@ export function BackupStep({
setCopyError(null);
try {
// The raw key enters the DOM only after this explicit reveal action.
const value = nsec ?? (await getNsec());
const value = nsec ?? (previewMode ? PREVIEW_KEY_TEXT : await getNsec());
if (cancelledRef.current) return;
setNsec(value);
setIsRevealed(true);
@@ -148,7 +151,7 @@ export function BackupStep({
err instanceof Error ? err.message : "Failed to retrieve private key.",
);
}
}, [isRevealed, nsec]);
}, [isRevealed, nsec, previewMode]);
// Fixed-length decorative mask (nsec keys are 63 chars) so no key material
// is fetched just to render the blurred row. Bullets are joined with a

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
@@ -13,25 +14,31 @@ type JoinPolicyNoticeProps = {
policy: JoinPolicy;
/** Relay hosting the policy documents the links below point at. */
relayWsUrl: string;
/** Optional safe host for surfaces that must not open external documents. */
onOpenDocument?: (document: "terms" | "privacy") => void;
/** Use primary page text where a surface needs stronger consent contrast. */
textTone?: "muted" | "foreground";
};
/**
* Join-policy consent block shown on every join surface.
*
* The Terms/Privacy links open the relay-hosted document pages
* (`/api/join-policy/terms|privacy`) in the system browser via the OS
* opener. They must NOT navigate or render in-app: these surfaces exist
* before onboarding completes, where the router (required by the message
* Markdown component) is not mounted — an in-app render tears down the
* whole React tree.
* The Terms/Privacy links normally open the relay-hosted document pages
* (`/api/join-policy/terms|privacy`) in the system browser via the OS opener.
* A non-networked host may intercept those actions. They must NOT navigate or
* render in-app: these surfaces exist before onboarding completes, where the
* router (required by the message Markdown component) is not mounted — an
* in-app render tears down the whole React tree.
*/
export function JoinPolicyNotice({
ageConfirmed,
agreementConfirmed,
onAgeConfirmedChange,
onAgreementConfirmedChange,
onOpenDocument,
policy,
relayWsUrl,
textTone = "muted",
}: JoinPolicyNoticeProps) {
const ageConfirmationId = React.useId();
const agreementConfirmationId = React.useId();
@@ -49,7 +56,12 @@ export function JoinPolicyNotice({
}
/>
<label
className="cursor-pointer text-xs leading-5 text-muted-foreground"
className={cn(
"cursor-pointer text-xs leading-5",
textTone === "foreground"
? "text-foreground"
: "text-muted-foreground",
)}
htmlFor={ageConfirmationId}
>
I am 18 years of age or older.
@@ -68,16 +80,30 @@ export function JoinPolicyNotice({
}
/>
<label
className="cursor-pointer text-xs leading-5 text-muted-foreground"
className={cn(
"cursor-pointer text-xs leading-5",
textTone === "foreground"
? "text-foreground"
: "text-muted-foreground",
)}
htmlFor={agreementConfirmationId}
>
I agree to the Buzz{" "}
{policy.termsMarkdown ? (
<Button
className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
className={cn(
"h-auto p-0 align-baseline text-xs",
textTone === "foreground"
? "font-medium text-foreground underline hover:underline focus-visible:underline"
: "no-underline hover:underline focus-visible:no-underline",
)}
onClick={(event) => {
event.preventDefault();
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"));
if (onOpenDocument) {
onOpenDocument("terms");
} else {
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"));
}
}}
type="button"
variant="link"
@@ -88,10 +114,19 @@ export function JoinPolicyNotice({
{policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
{policy.privacyMarkdown ? (
<Button
className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
className={cn(
"h-auto p-0 align-baseline text-xs",
textTone === "foreground"
? "font-medium text-foreground underline hover:underline focus-visible:underline"
: "no-underline hover:underline focus-visible:no-underline",
)}
onClick={(event) => {
event.preventDefault();
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"));
if (onOpenDocument) {
onOpenDocument("privacy");
} else {
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"));
}
}}
type="button"
variant="link"

View File

@@ -45,6 +45,8 @@ type NostrKeyImportFormProps = {
footerMode?: "onboarding" | "inline";
/** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */
variant?: "default" | "spotlight";
/** Use fixed in-memory presentation data and bypass credential parsing. */
previewMode?: boolean;
};
/**
@@ -67,6 +69,7 @@ export function NostrKeyImportForm({
mode = "key",
footerMode = "onboarding",
variant = "default",
previewMode = false,
}: NostrKeyImportFormProps) {
const [nsecInput, setNsecInput] = React.useState("");
const [passphrase, setPassphrase] = React.useState("");
@@ -101,7 +104,7 @@ export function NostrKeyImportForm({
setPassphrase("");
}
}, [isPasswordStage]);
const isValid = keyImportSubmitEnabled(nsecInput, passphrase);
const isValid = previewMode || keyImportSubmitEnabled(nsecInput, passphrase);
const isInteractionDisabled = disabled || isImporting;
const showInvalidHint =
hasInput &&
@@ -217,7 +220,10 @@ export function NostrKeyImportForm({
setImportError(null);
try {
await onImport(trimmedInput, isPasswordStage ? passphrase : undefined);
await onImport(
previewMode ? "" : trimmedInput,
isPasswordStage ? passphrase : undefined,
);
} catch (error) {
setImportError(
error instanceof Error ? error.message : "Couldn't import this key.",
@@ -235,6 +241,7 @@ export function NostrKeyImportForm({
onImport,
onImportingChange,
passphrase,
previewMode,
trimmedInput,
]);
@@ -302,14 +309,16 @@ export function NostrKeyImportForm({
data-testid="nostr-import-nsec-input"
id="nostr-private-key"
onChange={(event) => {
if (previewMode) return;
setNsecInput(event.target.value);
setImportError(null);
}}
placeholder="Enter your key here"
readOnly={previewMode}
ref={inputRef}
spellCheck={false}
type={isRevealed ? "text" : "password"}
value={nsecInput}
value={previewMode ? `nsec1${"•".repeat(24)}` : nsecInput}
/>
{/* Absolutely positioned so appearing/disappearing never resizes
the input or shifts its centered text; fades with hasInput. */}

View File

@@ -0,0 +1,839 @@
import { Check, RotateCcw, ShieldCheck } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import type { JoinPolicy } from "@/shared/api/invites";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
import { JoinPolicyNotice } from "./JoinPolicyNotice";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import {
ONBOARDING_LANDING_CTA_CLASS,
ONBOARDING_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
OnboardingChrome,
} from "./OnboardingChrome";
import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { SetupStepPreview } from "./SetupStepPreview";
type PreviewPage =
| "landing"
| "email"
| "identity-key"
| "sign-in"
| "sign-in-key"
| "forgot-password"
| "setup";
const EMAIL_SIGNUP_POLICY: JoinPolicy = {
ageAttestationRequired: true,
privacyMarkdown: "available",
termsMarkdown: "available",
version: "email-signup-workshop",
};
function PreviewBanner({ onRestart }: { onRestart: () => void }) {
return (
<aside
className="fixed right-4 top-4 z-[100] flex max-w-sm items-center gap-3 rounded-2xl border border-foreground/15 bg-background/95 px-4 py-3 text-left shadow-lg backdrop-blur"
data-testid="onboarding-preview-banner"
>
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Workshop preview</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
Changes in this workshop aren’t saved.
</p>
</div>
<Button
aria-label="Restart onboarding preview"
className="h-8 w-8 shrink-0"
data-testid="onboarding-preview-restart"
onClick={onRestart}
size="icon"
type="button"
variant="ghost"
>
<RotateCcw className="h-4 w-4" aria-hidden />
</Button>
</aside>
);
}
function Landing({ onNavigate }: { onNavigate: (page: PreviewPage) => void }) {
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"
data-testid="onboarding-preview-landing"
>
<StartupWindowDragRegion />
<LandingBees />
<OnboardingFooterProvider>
<div className="relative my-auto flex w-full max-w-[720px] flex-col items-center text-center">
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
transitionKey="preview-landing"
>
<img
alt="Buzz"
className="w-full max-w-[600px]"
src="/landing/buzz-wordmark.png"
/>
<p className="mt-2 max-w-[560px] text-center text-2xl font-normal leading-none text-foreground">
Your people, your agents, your projects —<br />
all in one place.
</p>
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className={ONBOARDING_LANDING_CTA_CLASS}
onClick={() => onNavigate("email")}
type="button"
>
Create an account
</Button>
<Button
className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`}
onClick={() => onNavigate("sign-in")}
type="button"
variant="ghost"
>
Sign in
</Button>
</div>
<OnboardingFooter className="max-w-none">
<Button
className="text-foreground/70 hover:text-foreground"
data-testid="onboarding-preview-without-email"
onClick={() => onNavigate("identity-key")}
type="button"
variant="link"
>
Sign up without email
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
</div>
</OnboardingFooterProvider>
</div>
);
}
function PreviewStep({
children,
current = 2,
onBack,
testId,
}: {
children: React.ReactNode;
current?: number;
onBack: () => void;
testId: string;
}) {
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-x-hidden overflow-y-auto px-4 pb-28 pt-[106px] text-foreground"
data-testid={testId}
>
<StartupWindowDragRegion />
<OnboardingChrome current={current} />
<OnboardingFooterProvider backAction={{ onClick: onBack }}>
<div className="buzz-onboarding-step-frame relative flex w-full max-w-[1040px] flex-col items-center text-center">
{children}
</div>
</OnboardingFooterProvider>
</div>
);
}
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 passwordStrengthDescriptionId = React.useId();
return (
<>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={emailId}
>
Email
</label>
<Input
autoComplete="email"
className="h-12 rounded-2xl border-foreground/15 bg-white px-4"
id={emailId}
onChange={(event) => onEmailChange(event.target.value)}
placeholder="Enter your email address"
type="email"
value={email}
/>
</div>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={passwordId}
>
Password
</label>
<div className="relative">
<Input
aria-describedby={
showPasswordStrength ? passwordStrengthDescriptionId : undefined
}
autoComplete={passwordAutoComplete}
className={cn(
"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}
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>
<Input
autoComplete="new-password"
className="h-12 rounded-2xl border-foreground/15 bg-white px-4"
id={`${passwordId}-confirmation`}
onChange={(event) => onConfirmPasswordChange(event.target.value)}
placeholder="Confirm your password"
type="password"
value={confirmPassword ?? ""}
/>
</div>
) : null}
</div>
</>
);
}
function EmailSignup({
password,
onBack,
onContinue,
onPasswordChange,
}: {
password: string;
onBack: () => void;
onContinue: (email: string) => void;
onPasswordChange: (password: string) => void;
}) {
const [email, setEmail] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
// This workshop never creates an account, so it deliberately permits the
// next preview step without collecting real credentials or consent.
const canContinue = true;
return (
<PreviewStep onBack={onBack} testId="onboarding-preview-email">
<OnboardingSlideTransition
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
transitionKey="preview-email"
>
<h1 className="text-title font-normal text-foreground">
Create an account
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
Enter your email address to get started.
</p>
<form
className="mt-8 w-full space-y-5 text-left"
onSubmit={(event) => {
event.preventDefault();
if (canContinue) onContinue(email);
}}
>
<PreviewCredentialsFields
email={email}
emailId="onboarding-preview-email-address"
onEmailChange={setEmail}
onPasswordChange={onPasswordChange}
confirmPassword={confirmPassword}
onConfirmPasswordChange={setConfirmPassword}
password={password}
passwordAutoComplete="new-password"
passwordId="onboarding-preview-password"
passwordPlaceholder="Choose your password"
showPasswordStrength
/>
<JoinPolicyNotice
ageConfirmed={ageConfirmed}
agreementConfirmed={agreementConfirmed}
onAgeConfirmedChange={setAgeConfirmed}
onAgreementConfirmedChange={setAgreementConfirmed}
onOpenDocument={() => undefined}
policy={EMAIL_SIGNUP_POLICY}
relayWsUrl="wss://preview.invalid"
textTone="foreground"
/>
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-preview-email-continue"
disabled={!canContinue}
onClick={() => onContinue(email)}
type="button"
>
Continue
</Button>
</OnboardingFooter>
</form>
</OnboardingSlideTransition>
</PreviewStep>
);
}
function AgentHarnessStep({
onBack,
onNext,
}: {
onBack: () => void;
onNext: () => void;
}) {
return (
<PreviewStep current={3} onBack={onBack} testId="onboarding-preview-setup">
<SetupStepPreview onBack={onBack} onNext={onNext} />
</PreviewStep>
);
}
function EmailSignIn({
email,
onBack,
onContinue,
onEmailChange,
onForgotPassword,
onSignInWithKey,
}: {
email: string;
onBack: () => void;
onContinue: () => void;
onEmailChange: (email: string) => void;
onForgotPassword: () => void;
onSignInWithKey: () => void;
}) {
const [password, setPassword] = React.useState("");
const canContinue = email.trim().length > 0 && password.length > 0;
return (
<PreviewStep onBack={onBack} testId="onboarding-preview-sign-in">
<OnboardingSlideTransition
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
transitionKey="preview-sign-in"
>
<h1 className="text-title font-normal text-foreground">Sign in</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
Enter your email address and password to sign in.
</p>
<form
className="mt-8 w-full space-y-5 text-left"
onSubmit={(event) => {
event.preventDefault();
if (canContinue) onContinue();
}}
>
<PreviewCredentialsFields
email={email}
emailId="onboarding-preview-sign-in-email"
onEmailChange={onEmailChange}
onPasswordChange={setPassword}
password={password}
passwordAutoComplete="current-password"
passwordHelp={
<Button
className="h-auto justify-start p-0 text-xs font-medium text-foreground/70 no-underline hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:underline"
onClick={onForgotPassword}
type="button"
variant="link"
>
Forgot password?
</Button>
}
passwordId="onboarding-preview-sign-in-password"
passwordPlaceholder="Enter your password"
/>
<div className="flex w-full flex-col items-center gap-3 pt-1">
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-preview-sign-in-submit"
disabled={!canContinue}
onClick={onContinue}
type="button"
>
Sign in
</Button>
<div
aria-hidden
className="flex w-full max-w-64 items-center gap-3 text-xs text-foreground/70"
>
<span className="h-px flex-1 bg-foreground/20" />
<span>or</span>
<span className="h-px flex-1 bg-foreground/20" />
</div>
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
onClick={onSignInWithKey}
type="button"
variant="ghost"
>
Sign in with a key
</Button>
</div>
</form>
</OnboardingSlideTransition>
</PreviewStep>
);
}
function PasswordReset({
initialEmail,
onBack,
}: {
initialEmail: string;
onBack: () => void;
}) {
const [email, setEmail] = React.useState(initialEmail);
const [submitted, setSubmitted] = React.useState(false);
if (submitted) {
return (
<PreviewStep
onBack={onBack}
testId="onboarding-preview-password-reset-sent"
>
<OnboardingSlideTransition
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
transitionKey="preview-password-reset-sent"
>
<h1 className="text-title font-normal text-foreground">
Check your email
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
Use the link we sent to {email.trim()} to reset your password.
</p>
</OnboardingSlideTransition>
</PreviewStep>
);
}
return (
<PreviewStep onBack={onBack} testId="onboarding-preview-password-reset">
<OnboardingSlideTransition
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
transitionKey="preview-password-reset"
>
<h1 className="text-title font-normal text-foreground">
Reset your password
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
Enter your email address and we'll send you a link to reset your
password.
</p>
<form
className="mt-8 w-full text-left"
onSubmit={(event) => {
event.preventDefault();
if (email.trim()) setSubmitted(true);
}}
>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor="onboarding-preview-password-reset-email"
>
Email
</label>
<Input
autoComplete="email"
className="h-12 rounded-2xl border-foreground/15 bg-white px-4"
id="onboarding-preview-password-reset-email"
onChange={(event) => setEmail(event.target.value)}
placeholder="Enter your email address"
type="email"
value={email}
/>
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
disabled={!email.trim()}
onClick={() => setSubmitted(true)}
type="button"
>
Send reset link
</Button>
</OnboardingFooter>
</form>
</OnboardingSlideTransition>
</PreviewStep>
);
}
function KeySignIn({
onBack,
onContinue,
}: {
onBack: () => void;
onContinue: () => void;
}) {
return (
<PreviewStep onBack={onBack} testId="onboarding-preview-sign-in-key">
<OnboardingSlideTransition
className="flex min-h-[calc(100dvh-13.25rem)] w-full max-w-[560px] flex-col items-center justify-center"
transitionKey="preview-sign-in-key"
>
<h1 className="text-title font-normal text-foreground">
Enter your private key
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
Paste your private key to sign in to Buzz. You can also use a backup
file, or recover from your phone.
</p>
<div className="buzz-onboarding-key-import-position w-full">
<NostrKeyImportForm
onBack={onBack}
onImport={async () => onContinue()}
previewMode
showBack={false}
variant="spotlight"
/>
</div>
</OnboardingSlideTransition>
</PreviewStep>
);
}
export function OnboardingPreviewApp() {
const [run, setRun] = React.useState(0);
const [page, setPage] = React.useState<PreviewPage>("landing");
const [signInEmail, setSignInEmail] = React.useState("");
const [signupPassword, setSignupPassword] = React.useState("");
const [passwordResetEmail, setPasswordResetEmail] = React.useState("");
const [setupBackPage, setSetupBackPage] =
React.useState<PreviewPage>("landing");
React.useEffect(() => {
if (page === "landing") {
setPasswordResetEmail("");
setSignInEmail("");
setSignupPassword("");
}
}, [page]);
const restart = React.useCallback(() => {
setPage("landing");
setPasswordResetEmail("");
setRun((current) => current + 1);
setSignInEmail("");
setSignupPassword("");
}, []);
let content: React.ReactNode;
if (page === "landing") {
content = <Landing key={run} onNavigate={setPage} />;
} else if (page === "email") {
content = (
<EmailSignup
password={signupPassword}
onBack={() => setPage("landing")}
onContinue={() => {
setSetupBackPage("email");
setPage("setup");
}}
onPasswordChange={setSignupPassword}
/>
);
} else if (page === "identity-key") {
content = (
<PreviewStep
onBack={() => setPage("landing")}
testId="onboarding-preview-identity-key"
>
<BackupStep
direction="forward"
onNext={() => {
setSetupBackPage("identity-key");
setPage("setup");
}}
onOpenPasswordBackup={() => undefined}
onShowOptions={() => undefined}
optionsExpanded={false}
previewMode
returningFromSecurity={false}
/>
</PreviewStep>
);
} else if (page === "sign-in") {
content = (
<EmailSignIn
email={signInEmail}
onBack={() => setPage("landing")}
onContinue={() => {
setSetupBackPage("sign-in");
setPage("setup");
}}
onEmailChange={setSignInEmail}
onForgotPassword={() => {
setPasswordResetEmail(signInEmail);
setPage("forgot-password");
}}
onSignInWithKey={() => setPage("sign-in-key")}
/>
);
} else if (page === "forgot-password") {
content = (
<PasswordReset
initialEmail={passwordResetEmail}
onBack={() => setPage("sign-in")}
/>
);
} else if (page === "sign-in-key") {
content = (
<KeySignIn
onBack={() => setPage("sign-in")}
onContinue={() => {
setSetupBackPage("sign-in-key");
setPage("setup");
}}
/>
);
} else {
content = (
<AgentHarnessStep
onBack={() => setPage(setupBackPage)}
onNext={() => undefined}
/>
);
}
return (
<>
<PreviewBanner onRestart={restart} />
{content}
</>
);
}

View File

@@ -0,0 +1,301 @@
import { Check } from "lucide-react";
import type * as React from "react";
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Spinner } from "@/shared/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { runtimeIsReadyForOnboarding } from "./onboardingRuntimeSelection";
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";
function RuntimeReadinessIndicator({
runtime,
ready,
}: {
runtime: AcpRuntimeCatalogEntry;
ready: boolean;
}) {
// Checkmark temporarily hidden; flip to true to restore it.
const showReadinessCheckmark = false;
if (!ready || !showReadinessCheckmark) return null;
return (
<span
aria-hidden="true"
className="pointer-events-none absolute right-8 top-8 flex h-8 w-8 items-center justify-center rounded-full border border-[var(--buzz-welcome-chartreuse)] bg-[var(--buzz-welcome-chartreuse)]"
data-testid={`onboarding-runtime-check-${runtime.id}`}
>
<Check
className="h-4 w-4 text-foreground"
data-testid={`onboarding-runtime-checkmark-${runtime.id}`}
strokeWidth={3}
/>
</span>
);
}
function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (
runtime.availability === "available" &&
runtime.command &&
runtime.binaryPath
) {
const description = describeResolvedCommand(
runtime.command,
runtime.binaryPath,
);
return (
<>
<p className="text-xs leading-4 text-white">
{description.charAt(0).toUpperCase() + description.slice(1)}
</p>
{runtime.defaultArgs.length > 0 ? (
<p className="mt-1 text-xs leading-4 text-white">
Args:{" "}
<code className="font-mono">{runtime.defaultArgs.join(", ")}</code>
</p>
) : null}
</>
);
}
if (runtime.availability === "adapter_missing") {
return (
<>
<p className="text-xs leading-4 text-white">
CLI detected; ACP adapter missing.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
if (runtime.availability === "adapter_outdated") {
return (
<>
<p className="text-xs leading-4 text-white">
ACP adapter detected but outdated — reinstall required.
</p>
<p className="mt-1 text-xs leading-4 text-white">
This updates the machine-global{" "}
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
codex-acp
</code>{" "}
adapter. Older Buzz releases using the legacy adapter contract may
lose community access until{" "}
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
@zed-industries/codex-acp@0.16.0
</code>{" "}
is restored.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
if (runtime.availability === "cli_missing") {
return (
<>
<p className="text-xs leading-4 text-white">
ACP adapter detected; CLI missing.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
return (
<>
<p className="text-xs leading-4 text-white">Not installed yet.</p>
<p className="mt-1 text-xs leading-4 text-white">{runtime.installHint}</p>
</>
);
}
function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
if (
runtime.availability === "available" &&
runtime.command &&
runtime.binaryPath
) {
const description = describeResolvedCommand(
runtime.command,
runtime.binaryPath,
);
return description.charAt(0).toUpperCase() + description.slice(1);
}
if (runtime.availability === "adapter_missing") {
return "CLI detected; ACP adapter missing.";
}
if (runtime.availability === "adapter_outdated") {
return "ACP adapter detected but outdated — reinstall required.";
}
if (
runtime.availability === "cli_missing" ||
runtime.availability === "not_installed"
) {
return "CLI not detected.";
}
return "";
}
export function SetupRuntimeAction({
ariaLabel,
children,
disabled,
onClick,
testId,
}: {
ariaLabel: string;
children: React.ReactNode;
disabled?: boolean;
onClick: () => void;
testId?: string;
}) {
return (
<Button
aria-label={ariaLabel}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-testid={testId}
disabled={disabled}
onClick={onClick}
type="button"
variant="ghost"
>
{children}
</Button>
);
}
export function SetupRuntimeInstallingStatus({
runtime,
}: {
runtime: AcpRuntimeCatalogEntry;
}) {
return (
<div
aria-label={`Installing ${runtime.label}`}
className="flex h-5 items-center gap-2 rounded-full bg-white/60 px-2.5 font-mono text-badge font-normal uppercase text-foreground"
role="status"
>
<Spinner className="h-3 w-3 border-2 text-foreground" />
INSTALLING
</div>
);
}
export function SetupRuntimeCheckingStatus({
runtime,
}: {
runtime: AcpRuntimeCatalogEntry;
}) {
return (
<div
aria-label={`Rechecking ${runtime.label}`}
className="flex h-5 items-center gap-2 rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
data-testid={`onboarding-runtime-rechecking-${runtime.id}`}
role="status"
>
<Spinner className="h-3 w-3 border-2 text-foreground" />
CHECKING…
</div>
);
}
export function SetupRuntimeReadyStatus({
runtime,
}: {
runtime: AcpRuntimeCatalogEntry;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex h-5 cursor-default items-center rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
data-testid={`onboarding-runtime-ready-${runtime.id}`}
>
READY
</span>
</TooltipTrigger>
<TooltipContent
className="max-w-80 bg-black text-left text-xs text-white shadow-sm"
side="top"
>
<RuntimeDetails runtime={runtime} />
</TooltipContent>
</Tooltip>
);
}
export function SetupRuntimeCardPresentation({
footer,
installError,
installOutputLine,
isInstalling,
runtime,
status,
}: {
footer?: React.ReactNode;
installError: string | null;
installOutputLine?: string | null;
isInstalling: boolean;
runtime: AcpRuntimeCatalogEntry;
status: React.ReactNode;
}) {
const isAvailable = runtime.availability === "available";
const isReady = runtimeIsReadyForOnboarding(runtime);
return (
<Card
className={cn(
"group h-[224px] w-full max-w-[288px] select-none items-center px-3 py-1.5 text-center",
installError && "ring-1 ring-destructive/40",
isReady && "brightness-[0.98]",
)}
data-ready={isReady ? "true" : "false"}
data-testid={`onboarding-runtime-${runtime.id}`}
variant="textured"
>
<RuntimeReadinessIndicator ready={isReady} runtime={runtime} />
<div className="flex min-w-0 flex-col items-center gap-2.5">
<div className="flex min-w-0 items-center justify-center gap-3">
<RuntimeIcon className="h-7 w-7" runtime={runtime} />
<h2 className="truncate text-sm font-normal leading-5 text-foreground">
{getRuntimeDisplayLabel(runtime)}
</h2>
</div>
{status}
{isInstalling && installOutputLine ? (
<p
aria-live="polite"
className="max-w-[13rem] truncate font-mono text-2xs leading-4 text-muted-foreground"
data-testid={`onboarding-runtime-install-output-${runtime.id}`}
>
{installOutputLine}
</p>
) : !isAvailable && runtimeDetailText(runtime) ? (
<p
aria-hidden={installError ? "true" : undefined}
className={cn(
"max-w-[13rem] text-2xs leading-4 text-muted-foreground",
installError && "invisible",
)}
>
{runtimeDetailText(runtime)}
</p>
) : null}
</div>
{footer}
</Card>
);
}

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { Check, Info } from "lucide-react";
import { Info } from "lucide-react";
import {
useAcpAuthMethodsQuery,
@@ -9,15 +9,10 @@ import {
useInstallAcpRuntimeMutation,
} from "@/features/agents/hooks";
import { useInstallOutputLine } from "@/features/agents/lib/useInstallOutputLine";
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { getInstallErrorMessage } from "@/shared/lib/installError";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee";
import { Spinner } from "@/shared/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
getReadyOnboardingRuntimes,
getVisibleOnboardingRuntimes,
@@ -26,7 +21,13 @@ import {
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { RuntimeErrorTooltip } from "./RuntimeErrorTooltip";
import { OnboardingFooter } from "./OnboardingFooter";
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";
import {
SetupRuntimeAction,
SetupRuntimeCardPresentation,
SetupRuntimeCheckingStatus,
SetupRuntimeInstallingStatus,
SetupRuntimeReadyStatus,
} from "./SetupRuntimeCard";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -40,6 +41,7 @@ type SetupStepProps = {
};
type SetupStepContentProps = SetupStepProps & {
renderRuntimeCard?: (runtime: AcpRuntimeCatalogEntry) => React.ReactNode;
state: SetupStepState;
};
@@ -66,32 +68,6 @@ function useSetupStepState(): SetupStepState {
};
}
function RuntimeReadinessIndicator({
runtime,
ready,
}: {
runtime: AcpRuntimeCatalogEntry;
ready: boolean;
}) {
// Checkmark temporarily hidden; flip to true to restore it.
const showReadinessCheckmark = false;
if (!ready || !showReadinessCheckmark) return null;
return (
<span
aria-hidden="true"
className="pointer-events-none absolute right-8 top-8 flex h-8 w-8 items-center justify-center rounded-full border border-[var(--buzz-welcome-chartreuse)] bg-[var(--buzz-welcome-chartreuse)]"
data-testid={`onboarding-runtime-check-${runtime.id}`}
>
<Check
className="h-4 w-4 text-foreground"
data-testid={`onboarding-runtime-checkmark-${runtime.id}`}
strokeWidth={3}
/>
</span>
);
}
function RuntimeStatus({
installError,
isInstalling,
@@ -153,10 +129,8 @@ function RuntimeStatus({
if (shouldSignIn) {
return (
<div className="flex flex-col items-center gap-1.5">
<Button
aria-label={`Sign in to ${runtime.label}`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-testid={`onboarding-runtime-instructions-${runtime.id}`}
<SetupRuntimeAction
ariaLabel={`Sign in to ${runtime.label}`}
onClick={() => {
if (didSignInCheckTimeOut) {
setDidSignInCheckTimeOut(false);
@@ -178,15 +152,14 @@ function RuntimeStatus({
},
);
}}
type="button"
variant="ghost"
testId={`onboarding-runtime-instructions-${runtime.id}`}
>
{isWaitingForSignIn
? "CHECKING…"
: didSignInCheckTimeOut
? "CHECK AGAIN"
: "SIGN IN"}
</Button>
</SetupRuntimeAction>
{methodsQuery.error instanceof Error ? (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
@@ -206,16 +179,7 @@ function RuntimeStatus({
}
if (isInstalling) {
return (
<div
aria-label={`Installing ${runtime.label}`}
className="flex h-5 items-center gap-2 rounded-full bg-white/60 px-2.5 font-mono text-badge font-normal uppercase text-foreground"
role="status"
>
<Spinner className="h-3 w-3 border-2 text-foreground" />
INSTALLING
</div>
);
return <SetupRuntimeInstallingStatus runtime={runtime} />;
}
if (runtimeIsReadyForOnboarding(runtime)) {
@@ -227,50 +191,20 @@ function RuntimeStatus({
// both clear and READY returns. Next stays gated by isChecking/errorMessage
// in SetupStepContent, so this only governs the per-card claim.
if (runtimesQuery.isFetching) {
return (
<div
aria-label={`Rechecking ${runtime.label}`}
className="flex h-5 items-center gap-2 rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
data-testid={`onboarding-runtime-rechecking-${runtime.id}`}
role="status"
>
<Spinner className="h-3 w-3 border-2 text-foreground" />
CHECKING…
</div>
);
return <SetupRuntimeCheckingStatus runtime={runtime} />;
}
if (runtimesQuery.isError) {
return (
<Button
aria-label={`Check ${runtime.label} again`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-testid={`onboarding-runtime-recheck-${runtime.id}`}
<SetupRuntimeAction
ariaLabel={`Check ${runtime.label} again`}
onClick={() => void runtimesQuery.forceRefresh()}
type="button"
variant="ghost"
testId={`onboarding-runtime-recheck-${runtime.id}`}
>
CHECK AGAIN
</Button>
</SetupRuntimeAction>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex h-5 cursor-default items-center rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
data-testid={`onboarding-runtime-ready-${runtime.id}`}
>
READY
</span>
</TooltipTrigger>
<TooltipContent
className="max-w-80 bg-black text-left text-xs text-white shadow-sm"
side="top"
>
<RuntimeDetails runtime={runtime} />
</TooltipContent>
</Tooltip>
);
return <SetupRuntimeReadyStatus runtime={runtime} />;
}
if (
@@ -278,160 +212,40 @@ function RuntimeStatus({
runtime.authStatus.status === "unknown"
) {
return (
<Button
aria-label={`Check ${runtime.label} again`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
<SetupRuntimeAction
ariaLabel={`Check ${runtime.label} again`}
disabled={runtimesQuery.isFetching}
onClick={() => void runtimesQuery.forceRefresh()}
type="button"
variant="ghost"
>
{runtimesQuery.isFetching ? "CHECKING…" : "CHECK AGAIN"}
</Button>
</SetupRuntimeAction>
);
}
const installLabel = installError ? "RETRY INSTALL" : "INSTALL";
if (runtime.canAutoInstall) {
return (
<Button
aria-label={`${installError ? "Retry installing" : "Install"} ${runtime.label}`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-testid={`onboarding-runtime-install-${runtime.id}`}
<SetupRuntimeAction
ariaLabel={`${installError ? "Retry installing" : "Install"} ${runtime.label}`}
onClick={onInstall}
type="button"
variant="ghost"
testId={`onboarding-runtime-install-${runtime.id}`}
>
{installLabel}
</Button>
</SetupRuntimeAction>
);
}
return (
<Button
aria-label={`View ${runtime.label} install instructions`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-testid={`onboarding-runtime-instructions-${runtime.id}`}
<SetupRuntimeAction
ariaLabel={`View ${runtime.label} install instructions`}
onClick={() => void openUrl(runtime.installInstructionsUrl)}
type="button"
variant="ghost"
testId={`onboarding-runtime-instructions-${runtime.id}`}
>
INSTALL
</Button>
</SetupRuntimeAction>
);
}
function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (
runtime.availability === "available" &&
runtime.command &&
runtime.binaryPath
) {
const description = describeResolvedCommand(
runtime.command,
runtime.binaryPath,
);
return (
<>
<p className="text-xs leading-4 text-white">
{description.charAt(0).toUpperCase() + description.slice(1)}
</p>
{runtime.defaultArgs.length > 0 ? (
<p className="mt-1 text-xs leading-4 text-white">
Args:{" "}
<code className="font-mono">{runtime.defaultArgs.join(", ")}</code>
</p>
) : null}
</>
);
}
if (runtime.availability === "adapter_missing") {
return (
<>
<p className="text-xs leading-4 text-white">
CLI detected; ACP adapter missing.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
if (runtime.availability === "adapter_outdated") {
return (
<>
<p className="text-xs leading-4 text-white">
ACP adapter detected but outdated — reinstall required.
</p>
<p className="mt-1 text-xs leading-4 text-white">
This updates the machine-global{" "}
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
codex-acp
</code>{" "}
adapter. Older Buzz releases using the legacy adapter contract may
lose community access until{" "}
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
@zed-industries/codex-acp@0.16.0
</code>{" "}
is restored.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
if (runtime.availability === "cli_missing") {
return (
<>
<p className="text-xs leading-4 text-white">
ACP adapter detected; CLI missing.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
return (
<>
<p className="text-xs leading-4 text-white">Not installed yet.</p>
<p className="mt-1 text-xs leading-4 text-white">{runtime.installHint}</p>
</>
);
}
function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
if (
runtime.availability === "available" &&
runtime.command &&
runtime.binaryPath
) {
const description = describeResolvedCommand(
runtime.command,
runtime.binaryPath,
);
return description.charAt(0).toUpperCase() + description.slice(1);
}
if (runtime.availability === "adapter_missing") {
return "CLI detected; ACP adapter missing.";
}
if (runtime.availability === "adapter_outdated") {
return "ACP adapter detected but outdated — reinstall required.";
}
if (
runtime.availability === "cli_missing" ||
runtime.availability === "not_installed"
) {
return "CLI not detected.";
}
return "";
}
function isSupportedOnboardingAuthMethod(
runtime: AcpRuntimeCatalogEntry,
method: AcpAuthMethod,
@@ -523,8 +337,6 @@ function RuntimeCard({
const installError = installResults[runtime.id]?.error ?? null;
const isInstalling = installMutation.isPending;
const installOutputLine = useInstallOutputLine(runtime.id, isInstalling);
const isAvailable = runtime.availability === "available";
const isReady = runtimeIsReadyForOnboarding(runtime);
function handleInstall() {
onInstallResultsChange((current) => ({
@@ -557,66 +369,33 @@ function RuntimeCard({
}
return (
<Card
className={cn(
"group h-[224px] w-full max-w-[288px] select-none items-center px-3 py-1.5 text-center",
installError && "ring-1 ring-destructive/40",
isReady && "brightness-[0.98]",
)}
data-ready={isReady ? "true" : "false"}
data-testid={`onboarding-runtime-${runtime.id}`}
variant="textured"
>
<RuntimeReadinessIndicator ready={isReady} runtime={runtime} />
<div className="flex min-w-0 flex-col items-center gap-2.5">
<div className="flex min-w-0 items-center justify-center gap-3">
<RuntimeIcon className="h-7 w-7" runtime={runtime} />
<h2 className="truncate text-sm font-normal leading-5 text-foreground">
{getRuntimeDisplayLabel(runtime)}
</h2>
</div>
<SetupRuntimeCardPresentation
footer={
installError ? (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
detail={installError}
label="Installation failed"
showIcon
testId={`onboarding-runtime-error-${runtime.id}`}
/>
) : (
<RuntimeAuthError runtime={runtime} />
)
}
installError={installError}
installOutputLine={installOutputLine}
isInstalling={isInstalling}
runtime={runtime}
status={
<RuntimeStatus
installError={installError}
isInstalling={isInstalling}
onInstall={handleInstall}
runtime={runtime}
/>
{isInstalling && installOutputLine ? (
// Takes the detail text's slot rather than adding a row: the card is
// fixed-height, and during an install the live line is the more
// useful of the two.
<p
aria-live="polite"
className="max-w-[13rem] truncate font-mono text-2xs leading-4 text-muted-foreground"
data-testid={`onboarding-runtime-install-output-${runtime.id}`}
>
{installOutputLine}
</p>
) : !isAvailable && runtimeDetailText(runtime) ? (
<p
aria-hidden={installError ? "true" : undefined}
className={cn(
"max-w-[13rem] text-2xs leading-4 text-muted-foreground",
installError && "invisible",
)}
>
{runtimeDetailText(runtime)}
</p>
) : null}
</div>
{installError ? (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
detail={installError}
label="Installation failed"
showIcon
testId={`onboarding-runtime-error-${runtime.id}`}
/>
) : (
<RuntimeAuthError runtime={runtime} />
)}
</Card>
}
/>
);
}
@@ -642,6 +421,7 @@ function RuntimeProvidersSection({
installResults,
navigateToAgentSettings,
onInstallResultsChange,
renderRuntimeCard,
runtimeProviders,
}: {
installResults: InstallResultsState;
@@ -649,6 +429,7 @@ function RuntimeProvidersSection({
onInstallResultsChange: React.Dispatch<
React.SetStateAction<InstallResultsState>
>;
renderRuntimeCard?: (runtime: AcpRuntimeCatalogEntry) => React.ReactNode;
runtimeProviders: SetupStepState["runtimeProviders"];
}) {
const { errorMessage, isChecking, items } = runtimeProviders;
@@ -669,14 +450,20 @@ function RuntimeProvidersSection({
<div className="flex w-full flex-1 flex-col items-center justify-center gap-8 py-10">
{orderedItems.length > 0 ? (
<div className="grid min-w-0 w-full max-w-[1200px] grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{orderedItems.map((runtime) => (
<RuntimeCard
installResults={installResults}
key={runtime.id}
onInstallResultsChange={onInstallResultsChange}
runtime={runtime}
/>
))}
{orderedItems.map((runtime) =>
renderRuntimeCard ? (
<React.Fragment key={runtime.id}>
{renderRuntimeCard(runtime)}
</React.Fragment>
) : (
<RuntimeCard
installResults={installResults}
key={runtime.id}
onInstallResultsChange={onInstallResultsChange}
runtime={runtime}
/>
),
)}
</div>
) : isChecking ? (
<RuntimeProvidersLoadingState />
@@ -723,10 +510,11 @@ function RuntimeProvidersSection({
);
}
function SetupStepContent({
export function SetupStepContent({
actions,
direction,
onReadyRuntimeIdsChange,
renderRuntimeCard,
state,
}: SetupStepContentProps) {
const { runtimeProviders } = state;
@@ -758,6 +546,7 @@ function SetupStepContent({
installResults={installResults}
navigateToAgentSettings={actions.navigateToAgentSettings}
onInstallResultsChange={setInstallResults}
renderRuntimeCard={renderRuntimeCard}
runtimeProviders={runtimeProviders}
/>

View File

@@ -0,0 +1,272 @@
import * as React from "react";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { TooltipProvider } from "@/shared/ui/tooltip";
import {
SetupRuntimeAction,
SetupRuntimeCardPresentation,
SetupRuntimeInstallingStatus,
SetupRuntimeReadyStatus,
} from "./SetupRuntimeCard";
import { SetupStepContent } from "./SetupStep";
const PREVIEW_RUNTIMES: AcpRuntimeCatalogEntry[] = [
{
id: "claude",
label: "Claude Code",
avatarUrl: "",
availability: "available",
command: "claude-agent-acp",
binaryPath: "/usr/local/bin/claude-agent-acp",
defaultArgs: [],
mcpCommand: null,
modelEnvVar: null,
providerEnvVar: null,
thinkingEnvVar: null,
maxTokensEnvVar: null,
contextLimitEnvVar: null,
maxRoundsEnvVar: null,
installHint: "Install the Claude Code ACP adapter via npm.",
installInstructionsUrl:
"https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
canAutoInstall: true,
requiresExternalCli: true,
underlyingCliPath: "/usr/local/bin/claude",
nodeRequired: false,
authStatus: { status: "logged_out" },
loginHint: "Sign in with your Claude subscription.",
source: "builtin",
},
{
id: "codex",
label: "Codex",
avatarUrl: "",
availability: "adapter_missing",
command: null,
binaryPath: null,
defaultArgs: [],
mcpCommand: null,
modelEnvVar: null,
providerEnvVar: null,
thinkingEnvVar: null,
maxTokensEnvVar: null,
contextLimitEnvVar: null,
maxRoundsEnvVar: null,
installHint: "Install the Codex ACP adapter.",
installInstructionsUrl: "https://github.com/openai/codex",
canAutoInstall: true,
requiresExternalCli: true,
underlyingCliPath: "/usr/local/bin/codex",
nodeRequired: false,
authStatus: { status: "unknown" },
loginHint: "Sign in with your ChatGPT subscription.",
source: "builtin",
},
{
id: "goose",
label: "Goose",
avatarUrl: "",
availability: "available",
command: "goose",
binaryPath: "/usr/local/bin/goose",
defaultArgs: ["acp"],
mcpCommand: null,
modelEnvVar: null,
providerEnvVar: null,
thinkingEnvVar: null,
maxTokensEnvVar: null,
contextLimitEnvVar: null,
maxRoundsEnvVar: null,
installHint: "Install Goose via the official install script.",
installInstructionsUrl: "https://block.github.io/goose/",
canAutoInstall: true,
requiresExternalCli: true,
underlyingCliPath: null,
nodeRequired: false,
authStatus: { status: "not_applicable" },
loginHint: null,
source: "builtin",
},
{
id: "buzz-agent",
label: "Buzz Agent",
avatarUrl: "",
availability: "available",
command: "buzz-agent",
binaryPath: "/Applications/Buzz.app/Contents/MacOS/buzz-agent",
defaultArgs: [],
mcpCommand: "buzz-dev-mcp",
modelEnvVar: null,
providerEnvVar: null,
thinkingEnvVar: null,
maxTokensEnvVar: null,
contextLimitEnvVar: null,
maxRoundsEnvVar: null,
installHint: "Ships with the Buzz desktop app.",
installInstructionsUrl: "https://github.com/block/buzz",
canAutoInstall: false,
requiresExternalCli: false,
underlyingCliPath: null,
nodeRequired: false,
authStatus: { status: "not_applicable" },
loginHint: null,
source: "builtin",
},
];
type PreviewActivity = "checking" | "idle" | "installing";
function PreviewRuntimeCard({
onRuntimeChange,
runtime,
}: {
onRuntimeChange: (
runtimeId: string,
update: (runtime: AcpRuntimeCatalogEntry) => AcpRuntimeCatalogEntry,
) => void;
runtime: AcpRuntimeCatalogEntry;
}) {
const [activity, setActivity] = React.useState<PreviewActivity>("idle");
const timeoutRef = React.useRef<number | null>(null);
React.useEffect(
() => () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
},
[],
);
function finishAfter(
activityValue: Exclude<PreviewActivity, "idle">,
update: (current: AcpRuntimeCatalogEntry) => AcpRuntimeCatalogEntry,
) {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
setActivity(activityValue);
timeoutRef.current = window.setTimeout(() => {
onRuntimeChange(runtime.id, update);
setActivity("idle");
timeoutRef.current = null;
}, 700);
}
let status: React.ReactNode;
if (activity === "installing") {
status = <SetupRuntimeInstallingStatus runtime={runtime} />;
} else if (activity === "checking") {
status = (
<SetupRuntimeAction
ariaLabel={`Sign in to ${runtime.label}`}
disabled
onClick={() => undefined}
testId={`onboarding-runtime-instructions-${runtime.id}`}
>
CHECKING…
</SetupRuntimeAction>
);
} else if (
runtime.availability === "available" &&
(runtime.authStatus.status === "logged_in" ||
runtime.authStatus.status === "not_applicable")
) {
status = <SetupRuntimeReadyStatus runtime={runtime} />;
} else if (
runtime.availability === "available" &&
runtime.authStatus.status === "logged_out"
) {
status = (
<SetupRuntimeAction
ariaLabel={`Sign in to ${runtime.label}`}
onClick={() =>
finishAfter("checking", (current) => ({
...current,
authStatus: { status: "logged_in" },
}))
}
testId={`onboarding-runtime-instructions-${runtime.id}`}
>
SIGN IN
</SetupRuntimeAction>
);
} else {
status = (
<SetupRuntimeAction
ariaLabel={`Install ${runtime.label}`}
onClick={() =>
finishAfter("installing", (current) => ({
...current,
availability: "available",
authStatus: { status: "logged_out" },
binaryPath: `/usr/local/bin/${current.id}-acp`,
command: `${current.id}-acp`,
}))
}
testId={`onboarding-runtime-install-${runtime.id}`}
>
INSTALL
</SetupRuntimeAction>
);
}
return (
<SetupRuntimeCardPresentation
installError={null}
isInstalling={activity === "installing"}
runtime={runtime}
status={status}
/>
);
}
export function SetupStepPreview({
onBack,
onNext,
}: {
onBack: () => void;
onNext: () => void;
}) {
const [runtimes, setRuntimes] = React.useState(PREVIEW_RUNTIMES);
const updateRuntime = React.useCallback(
(
runtimeId: string,
update: (runtime: AcpRuntimeCatalogEntry) => AcpRuntimeCatalogEntry,
) => {
setRuntimes((current) =>
current.map((runtime) =>
runtime.id === runtimeId ? update(runtime) : runtime,
),
);
},
[],
);
return (
<TooltipProvider>
<SetupStepContent
actions={{
back: onBack,
next: onNext,
navigateToAgentSettings: onNext,
}}
direction="forward"
onReadyRuntimeIdsChange={() => undefined}
renderRuntimeCard={(runtime) => (
<PreviewRuntimeCard
onRuntimeChange={updateRuntime}
runtime={runtime}
/>
)}
state={{
runtimeProviders: {
errorMessage: null,
isChecking: false,
items: runtimes,
},
}}
/>
</TooltipProvider>
);
}

View File

@@ -13,6 +13,8 @@ import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communitie
import { CommunitiesProvider } from "@/features/communities/useCommunities";
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding";
import { onboardingPreviewRequested } from "@/features/onboarding/onboardingPreview";
import { OnboardingPreviewApp } from "@/features/onboarding/ui/OnboardingPreviewApp";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
@@ -108,6 +110,16 @@ function renderApp() {
);
}
function renderOnboardingPreview() {
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RootErrorBoundary>
<OnboardingPreviewApp />
</RootErrorBoundary>
</React.StrictMode>,
);
}
async function installE2eBridgeIfConfigured() {
// The mock bridge is compiled only into dev and explicit E2E builds. A
// pre-bootstrap global alone must never activate mock IPC in production.
@@ -123,6 +135,13 @@ async function installE2eBridgeIfConfigured() {
}
async function bootstrap() {
if (onboardingPreviewRequested()) {
// Preview mounts before storage migration, community state, the normal app,
// or any native-backed onboarding action. Its state lives only in React.
renderOnboardingPreview();
return;
}
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();

View File

@@ -4,12 +4,56 @@ import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/shared/lib/cn";
type AnimatedCheckmarkProps = React.ComponentPropsWithoutRef<"svg"> & {
visible?: boolean;
};
function AnimatedCheckmark({
className,
visible = true,
...props
}: AnimatedCheckmarkProps) {
const shouldReduceMotion = useReducedMotion();
return (
<svg
aria-hidden="true"
className={cn("h-4 w-4", className)}
fill="none"
viewBox="0 0 24 24"
{...props}
>
<motion.path
animate={
visible
? { opacity: 1, pathLength: 1 }
: { opacity: 0, pathLength: 0 }
}
d="m5 12 4 4L19 6"
initial={
shouldReduceMotion || !visible ? false : { opacity: 0, pathLength: 0 }
}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
}
}
/>
</svg>
);
}
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => {
const shouldReduceMotion = useReducedMotion();
return (
<CheckboxPrimitive.Root
ref={ref}
@@ -20,34 +64,11 @@ const Checkbox = React.forwardRef<
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<svg
aria-hidden="true"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<motion.path
animate={{ opacity: 1, pathLength: 1 }}
d="m5 12 4 4L19 6"
initial={shouldReduceMotion ? false : { opacity: 0, pathLength: 0 }}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
}
}
/>
</svg>
<AnimatedCheckmark />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
export { AnimatedCheckmark, Checkbox };