fix(desktop): modal sign-in + branded communities onboarding page (#2147)

Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Bradley Axen
2026-07-20 09:59:40 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3
parent 1a94146181
commit 8a65d81fab
7 changed files with 619 additions and 250 deletions
+8 -5
View File
@@ -17,7 +17,10 @@ import { useReloadShortcut } from "@/app/useReloadShortcut";
import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys";
import { useAppOnboardingState } from "@/features/onboarding/hooks";
import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import {
type FirstCommunityPage,
useCommunityOnboarding,
} from "@/features/onboarding/communityOnboarding";
import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboardingFlow";
import {
MachineOnboardingFlow,
@@ -275,8 +278,8 @@ function CommunityApp({
const communityOnboarding = useCommunityOnboarding();
const connectingTransactionRef = useRef<string | null>(null);
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
const [resumeFirstCommunityJoin, setResumeFirstCommunityJoin] =
useState(false);
const [resumeFirstCommunityPage, setResumeFirstCommunityPage] =
useState<FirstCommunityPage | null>(null);
// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useCommunityInit so the listeners are registered
@@ -354,7 +357,7 @@ function CommunityApp({
}
if (communities.length === 1) {
if (transaction.source === "first-community") {
setResumeFirstCommunityJoin(true);
setResumeFirstCommunityPage(transaction.firstCommunityPage ?? "join");
}
clearCommunities();
return;
@@ -410,7 +413,7 @@ function CommunityApp({
// Show welcome setup for first-run users with no communities
appContent = (
<WelcomeSetup
initialPage={resumeFirstCommunityJoin ? "join" : undefined}
initialPage={resumeFirstCommunityPage ?? undefined}
onBack={onBackToMachineConfig}
/>
);
@@ -1,5 +1,6 @@
import * as React from "react";
import { AlertCircle, CheckCircle2, LoaderCircle } from "lucide-react";
import { AlertCircle, LoaderCircle } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
bindBuilderlabIdentity,
@@ -24,17 +25,47 @@ import { useCommunityOnboarding } from "@/features/onboarding/communityOnboardin
import { useIdentityQuery } from "@/shared/api/hooks";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";
import { OnboardingFooter } from "@/features/onboarding/ui/OnboardingFooter";
import {
ONBOARDING_INK_ICON_CLASS,
ONBOARDING_PRIMARY_CTA_CLASS,
} from "@/features/onboarding/ui/OnboardingChrome";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/shared/ui/dialog";
const FUZZY_SURFACE_CLASS =
"relative left-1/2 w-[min(calc(100%+12rem),calc(100vw-2rem))] max-w-[1040px] -translate-x-1/2 px-20 pb-14 pt-20 !text-[rgb(var(--buzz-hosted-community-surface-fg))] [--buzz-card-textured-min-height:224px]";
const COMMUNITY_LIST_CLASS = "mx-auto w-full max-w-[520px] text-left";
const COMMUNITY_ROW_CLASS =
"flex min-h-[5.75rem] items-center justify-between gap-8 py-4 text-sm";
const COMMUNITY_DIVIDER_CLASS =
"border-b-[0.5px] border-[rgb(var(--buzz-hosted-community-divider-border)/0.5)]";
const COMMUNITY_ACTION_CLASS =
"h-[2.375rem] min-w-32 shrink-0 rounded-full bg-[rgb(var(--buzz-hosted-community-action-bg))] px-6 text-sm text-foreground shadow-none hover:bg-[rgb(var(--buzz-hosted-community-action-bg-hover))]";
const PAGE_CTA_CLASS = `${ONBOARDING_PRIMARY_CTA_CLASS} w-36 shadow-none`;
const PAGE_BACK_CLASS =
"h-[2.375rem] w-36 rounded-full bg-foreground/10 px-6 shadow-none hover:bg-foreground/15";
const MODAL_PRIMARY_ACTION_CLASS = `${ONBOARDING_PRIMARY_CTA_CLASS} !text-[rgb(var(--buzz-hosted-community-modal-action-fg))]`;
const MODAL_BACK_ACTION_CLASS =
"h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15";
export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
const onboarding = useCommunityOnboarding();
const shouldReduceMotion = useReducedMotion();
const localPubkey = useIdentityQuery().data?.pubkey ?? null;
const [auth, setAuth] = React.useState<BuilderlabAuth | null>(null);
const [identity, setIdentity] = React.useState<HostedNostrIdentity | null>(
null,
);
const [communities, setCommunities] = React.useState<HostedCommunity[]>([]);
const [showCreate, setShowCreate] = React.useState(false);
const [name, setName] = React.useState("");
const [availability, setAvailability] = React.useState<boolean | null>(null);
const [checkingName, setCheckingName] = React.useState(false);
@@ -100,12 +131,14 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
});
};
const cancelSignIn = () => {
const cancelSignInAndGoBack = () => {
loginAttempt.current += 1;
setAction(null);
setError(null);
void cancelBuilderlabLogin().catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
onBack();
void cancelBuilderlabLogin().catch(() => {
// The modal has already closed and the login attempt is invalidated;
// cancellation is best-effort cleanup for the native/browser flow.
});
};
@@ -115,6 +148,7 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
setAuth(null);
setIdentity(null);
setCommunities([]);
setShowCreate(false);
setName("");
setAvailability(null);
});
@@ -188,6 +222,7 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
normalizedName.length <= 63 &&
VALID_HOSTED_COMMUNITY_NAME.test(normalizedName);
const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT;
const hasCommunities = activeCommunities.length > 0;
React.useEffect(() => {
if (!identity || identityMismatch || !normalizedName || !validName) {
@@ -228,6 +263,7 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
if (
!onboarding.start({
source: "first-community",
firstCommunityPage: "owned",
relayUrl,
communityName: community.name ?? community.slug,
})
@@ -268,231 +304,442 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
};
const busy = action !== null;
// The account is set up once we're signed in with a linked, matching
// identity. Until then the sign-in / link-identity modal drives the flow and
// the page behind it shows a blurred preview of where communities will land.
const ready = Boolean(auth && identity && !identityMismatch);
const modalOpen = !loading && !ready;
const errorBox = error ? (
<div
className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-left"
role="alert"
>
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-destructive/15 text-destructive">
<AlertCircle className="h-4 w-4" />
</span>
<span className="text-sm font-medium leading-5 text-destructive">
{error}
</span>
</div>
) : null;
const creationFeedback = atCommunityLimit
? `Youve reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.`
: name && !validName
? "Use lowercase letters, numbers, and single hyphens."
: checkingName
? "Checking availability…"
: availability === false
? "That address is already taken."
: availability === true
? "That address is available."
: null;
// The composed `<name>.<suffix>` line renders at text-4xl, but a valid name
// can be up to 63 chars and the suffix adds another 21 — far wider than the
// card at the 800px app minimum. Scale the font down to fit the container
// (container-query width) while capping at text-4xl (2.25rem) so short names
// keep the full-size treatment and long names stay fully visible instead of
// overflowing the surface.
const composedAddressLength =
(name ? name.length : "your-community".length) +
HOSTED_COMMUNITY_SUFFIX.length +
1; // leading dot before the suffix
// ~0.62em is the monospace glyph advance; 90cqw leaves a safety margin so the
// glyphs never touch the card edge.
const addressFontSize = `min(2.25rem, calc(90cqw / ${(
composedAddressLength * 0.62
).toFixed(2)}))`;
const creationInput = (inline: boolean) => (
<Input
aria-describedby={
creationFeedback ? "hosted-community-feedback" : undefined
}
aria-label="Community name"
autoComplete="off"
className={
inline
? "h-[2.375rem] w-[16.5rem] rounded-full border border-[color:var(--buzz-onboarding-backup-ink)]/25 bg-[rgb(var(--buzz-hosted-community-input-bg)/0.6)] px-6 text-center text-sm shadow-none placeholder:text-foreground/30 focus-visible:ring-1 focus-visible:ring-[color:var(--buzz-onboarding-backup-ink)]/40"
: "h-auto min-w-0 flex-none rounded-none border-0 bg-transparent p-0 text-right font-mono !text-[rgb(var(--buzz-hosted-community-surface-fg))] shadow-none placeholder:!text-[rgb(var(--buzz-hosted-community-surface-fg))] placeholder:opacity-20 focus-visible:ring-0"
}
disabled={busy || atCommunityLimit}
id="hosted-community-address"
data-testid={!inline ? "hosted-community-address-input" : undefined}
maxLength={63}
onChange={(event) => {
setName(event.target.value.toLowerCase());
setAvailability(null);
}}
placeholder={inline ? "Community name here" : "your-community"}
spellCheck={false}
style={
inline
? undefined
: {
width: `${name ? name.length : "your-community".length}ch`,
fontSize: addressFontSize,
}
}
value={name}
/>
);
const renderCreationForm = (inline: boolean) =>
inline ? (
<form
className="relative"
id="hosted-community-create-form"
onSubmit={create}
>
<div className={COMMUNITY_ROW_CLASS}>
<label className="text-sm" htmlFor="hosted-community-address">
Set new community name
</label>
{creationInput(true)}
</div>
</form>
) : (
<Card
asChild
className={`${FUZZY_SURFACE_CLASS} !py-10 [--buzz-card-textured-min-height:176px]`}
data-testid="hosted-community-create-surface"
variant="textured"
>
<form id="hosted-community-create-form" onSubmit={create}>
<div
className="mx-auto flex w-full max-w-[900px] items-center justify-center whitespace-nowrap"
data-testid="hosted-community-address-line"
style={{ containerType: "inline-size" }}
>
{creationInput(false)}
<span
className="shrink-0 font-mono !text-[rgb(var(--buzz-hosted-community-surface-fg))]"
id="hosted-community-suffix"
style={{ fontSize: addressFontSize }}
>
.{HOSTED_COMMUNITY_SUFFIX}
</span>
</div>
</form>
</Card>
);
return (
<div className="flex w-full max-w-[640px] flex-col items-center text-center">
<h1 className="text-title font-normal">Your communities</h1>
<p className="mx-auto mt-3 max-w-[480px] text-sm leading-6 text-foreground/80">
Sign in to connect a community you already own on this machine, or
create a new one.
<div className="flex min-h-[calc(100dvh-15.625rem)] w-full max-w-[920px] flex-col items-center text-center">
<h1 className="max-w-[620px] text-title font-normal leading-[1.18] tracking-[-0.025em]">
{hasCommunities ? "Choose a community" : "Create a community"}
</h1>
<p className="mx-auto mt-2 max-w-[560px] text-sm leading-6 text-foreground">
{hasCommunities
? "Connect one you own, or start something new."
: "Claim a Buzz address to get started."}
</p>
<div className="mt-10 w-full space-y-5 text-left">
{error ? (
<div
className="flex items-start gap-2 rounded-xl border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive"
role="alert"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
<div className="flex w-full flex-1 flex-col justify-center text-left">
{loading ? (
<div className="flex justify-center py-10" role="status">
<LoaderCircle className="h-6 w-6 animate-spin" />
<span className="sr-only">Checking Builderlab sign-in</span>
<span className="sr-only">Checking sign-in</span>
</div>
) : !auth ? (
<section className="rounded-xl bg-white/75 p-6 text-center">
<h2 className="font-medium">Sign in or create an account</h2>
<p className="mt-2 text-sm text-foreground/70">
Builderlab provides Block-hosted Buzz communities. Authentication
opens in your browser and returns here.
</p>
{action === "Signing in…" ? (
<div className="mt-5 flex flex-col items-center gap-3">
<div className="flex items-center gap-2 text-sm text-foreground/70">
<LoaderCircle className="h-4 w-4 animate-spin" />
Waiting for your browser
</div>
<Button onClick={cancelSignIn} variant="outline">
Cancel sign-in
</Button>
</div>
) : (
<Button className="mt-5 rounded-full px-6" onClick={signIn}>
Continue with Builderlab
</Button>
)}
</section>
) : !identity ? (
<section className="rounded-xl bg-white/75 p-6 text-center">
<h2 className="font-medium">Connect this Buzz identity</h2>
<p className="mt-2 text-sm text-foreground/70">
Link this devices Buzz key to{" "}
{auth.email ?? auth.name ?? "your Builderlab account"}. Buzz signs
a one-time challenge locally; your private key never leaves
Desktop.
</p>
<Button
className="mt-5 rounded-full px-6"
disabled={busy}
onClick={() => void connectIdentity()}
>
{busy ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
{action ?? "Connect this Buzz identity"}
</Button>
</section>
) : identityMismatch ? (
<section className="rounded-xl border border-amber-500/50 bg-amber-500/5 p-6">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div>
<h2 className="font-medium">
This account uses a different Buzz identity
</h2>
<p className="mt-2 text-sm text-foreground/70">
This Builderlab account is connected to another Buzz identity.
You can disconnect that identity and reconnect this device, or
sign out to use a different email.
) : ready ? (
<>
{errorBox}
{hasCommunities ? (
<>
<Card
className={`${FUZZY_SURFACE_CLASS} !max-w-[760px]`}
data-testid="hosted-community-list-surface"
variant="textured"
>
<section className={COMMUNITY_LIST_CLASS}>
<h2 className="text-center text-sm font-medium">
Your communities
</h2>
<ul className="mt-2">
{activeCommunities.map((community, index) => (
<li
className={`${COMMUNITY_ROW_CLASS} ${COMMUNITY_DIVIDER_CLASS}`}
key={
community.id ?? community.normalized_host ?? index
}
>
<div className="min-w-0">
<p className="truncate text-sm">
{community.name ??
community.slug ??
"Hosted community"}
</p>
<p className="mt-1 truncate text-sm text-foreground/55">
{community.normalized_host}
</p>
</div>
<Button
className={COMMUNITY_ACTION_CLASS}
disabled={busy}
onClick={() =>
void run("Connecting community…", async () => {
connect(community);
})
}
size="sm"
variant="ghost"
>
Connect
</Button>
</li>
))}
</ul>
<AnimatePresence initial={false} mode="wait">
{!showCreate ? (
<motion.div
animate={{ opacity: 1, transform: "translateX(0px)" }}
className={COMMUNITY_ROW_CLASS}
exit={
shouldReduceMotion
? { opacity: 0 }
: {
opacity: 0,
transform: "translateX(-12px)",
}
}
initial={false}
key="add-community-action"
transition={{
duration: shouldReduceMotion ? 0 : 0.18,
ease: "easeOut",
}}
>
<p className="text-sm">
Want to create a new community?
</p>
<Button
className={COMMUNITY_ACTION_CLASS}
disabled={busy || atCommunityLimit}
onClick={() => setShowCreate(true)}
size="sm"
type="button"
variant="ghost"
>
+ Add new
</Button>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1, transform: "translateX(0px)" }}
initial={
shouldReduceMotion
? { opacity: 1 }
: {
opacity: 0,
transform: "translateX(12px)",
}
}
key="add-community-input"
transition={{
duration: shouldReduceMotion ? 0 : 0.22,
ease: "easeOut",
}}
>
{renderCreationForm(true)}
</motion.div>
)}
</AnimatePresence>
</section>
</Card>
<p
aria-live="polite"
className={`relative z-10 mt-3 min-h-5 text-center text-sm ${
creationFeedback ? "visible" : "invisible"
} ${
availability === false || (name && !validName)
? "text-destructive"
: "text-[color:var(--buzz-onboarding-backup-ink)]"
}`}
id="hosted-community-feedback"
>
{creationFeedback ?? "Community address status"}
</p>
<p className="mt-3 break-all font-mono text-xs text-foreground/60">
</>
) : (
<>
{renderCreationForm(false)}
<p
aria-live="polite"
className={`relative z-10 mt-3 min-h-5 text-center text-sm ${
creationFeedback ? "visible" : "invisible"
} ${
availability === false || (name && !validName)
? "text-destructive"
: "text-[color:var(--buzz-onboarding-backup-ink)]"
}`}
id="hosted-community-feedback"
>
{creationFeedback ?? "Community address status"}
</p>
</>
)}
</>
) : (
<Card
aria-hidden
className={`${FUZZY_SURFACE_CLASS} opacity-70`}
variant="textured"
/>
)}
</div>
{!modalOpen ? (
<OnboardingFooter>
<Button
className={PAGE_CTA_CLASS}
disabled={
!validName ||
availability === false ||
checkingName ||
busy ||
atCommunityLimit
}
form="hosted-community-create-form"
type="submit"
>
{busy ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
{action ?? "Next"}
</Button>
<Button
className={PAGE_BACK_CLASS}
disabled={busy}
onClick={goBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
) : null}
<Dialog
open={modalOpen}
onOpenChange={(open) => {
if (open) return;
if (action === "Signing in…") {
cancelSignInAndGoBack();
return;
}
if (!busy) goBack();
}}
>
<DialogContent
className="buzz-onboarding-neutral-theme max-w-[560px] text-foreground [&_button]:shadow-none"
closeButtonClassName={ONBOARDING_INK_ICON_CLASS}
data-system-color-scheme="light"
overlayClassName="bg-[rgb(var(--buzz-hosted-community-modal-overlay-bg)/0.25)]"
surface="textured"
>
<div className="mx-auto flex w-full max-w-sm flex-col items-center py-2 text-center">
<BuzzMark className="mb-5 h-auto w-9 text-foreground" />
{!auth ? (
<>
<DialogTitle className="text-xl font-medium text-foreground">
Set up your community
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
Sign in to connect a community you already own or create a new
one. Well open Builderlab in your browser, then bring you
back to Buzz.
</DialogDescription>
{errorBox ? (
<div className="mt-5 w-full">{errorBox}</div>
) : null}
{action === "Signing in…" ? (
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
disabled
>
<LoaderCircle className="h-4 w-4 animate-spin" />
Waiting for your browser
</Button>
) : (
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
onClick={signIn}
>
Sign in to continue
</Button>
)}
{/* Quiet breadcrumb: Buzz itself is open source; this hosted
relay is the one account-backed piece of the flow. */}
<p className="mt-6 w-full border-t border-foreground/10 pt-4 text-xs leading-5 text-foreground/45">
Buzz is open source. Builderlab hosts the relay for this
account.
</p>
</>
) : !identity ? (
<>
<DialogTitle className="text-xl font-medium text-foreground">
Finish connecting Buzz
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
Your Builderlab account
{auth.email ? ` (${auth.email})` : ""} is ready. Connect this
devices Buzz identity to finish setup. Your private key stays
on this device.
</DialogDescription>
{errorBox ? (
<div className="mt-5 w-full">{errorBox}</div>
) : null}
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
disabled={busy}
onClick={() => void connectIdentity()}
>
{busy ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{busy ? action : "Connect and continue"}
</Button>
</>
) : (
<>
<DialogTitle className="text-xl font-medium text-foreground">
This account uses a different Buzz identity
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
This account is connected to another Buzz identity. Reconnect
this device, or sign out to use a different email.
</DialogDescription>
<p className="mt-4 w-full break-all rounded-xl bg-[rgb(var(--buzz-hosted-community-identity-bg)/0.5)] px-4 py-3 text-left font-mono text-xs text-foreground">
Account: {identity.npub ?? boundPubkey}
<br />
This device: {localNpub ?? localPubkey}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{errorBox ? (
<div className="mt-5 w-full">{errorBox}</div>
) : null}
<div className="mt-6 flex flex-col items-stretch gap-2">
<Button
className={MODAL_PRIMARY_ACTION_CLASS}
disabled={busy}
onClick={() => void switchToDeviceIdentity()}
>
{action ?? "Use this device's identity"}
{busy ? action : "Use this device's identity"}
</Button>
<Button
className={MODAL_BACK_ACTION_CLASS}
disabled={busy}
onClick={() => void signOut()}
variant="outline"
variant="ghost"
>
Sign in with a different email
</Button>
</div>
</div>
</div>
</section>
) : (
<>
<div className="flex items-center gap-2 rounded-xl bg-white/60 px-4 py-3 text-sm text-foreground/70">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Signed in{auth.email ? ` as ${auth.email}` : ""} with this Buzz
identity
</div>
{activeCommunities.length > 0 ? (
<section className="rounded-xl bg-white/75 p-5">
<h2 className="font-medium">Connect to one you own</h2>
<ul className="mt-3 space-y-2">
{activeCommunities.map((community, index) => (
<li
className="flex items-center justify-between gap-4 rounded-lg border border-foreground/10 bg-white/50 p-3"
key={community.id ?? community.normalized_host ?? index}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{community.name ??
community.slug ??
"Hosted community"}
</p>
<p className="truncate text-xs text-foreground/60">
{community.normalized_host}
</p>
</div>
<Button
className="rounded-full"
disabled={busy}
onClick={() =>
void run("Connecting community…", async () => {
connect(community);
})
}
size="sm"
variant="outline"
>
Connect
</Button>
</li>
))}
</ul>
</section>
) : null}
<form className="rounded-xl bg-white/75 p-5" onSubmit={create}>
<h2 className="font-medium">Create a new community</h2>
<p className="mt-1 text-sm text-foreground/70">
Choose the address your team will use to connect.
</p>
<div className="mt-4 flex items-center gap-2">
<Input
aria-label="Community address"
autoComplete="off"
disabled={busy || atCommunityLimit}
maxLength={63}
onChange={(event) => {
setName(event.target.value.toLowerCase());
setAvailability(null);
}}
placeholder="north-star"
spellCheck={false}
value={name}
/>
<span className="shrink-0 text-sm text-foreground/60">
.{HOSTED_COMMUNITY_SUFFIX}
</span>
</div>
{atCommunityLimit ? (
<p className="mt-2 text-sm text-foreground/60">
Youve reached the limit of {HOSTED_COMMUNITY_LIMIT} hosted
communities.
</p>
) : name && !validName ? (
<p className="mt-2 text-sm text-destructive">
Use lowercase letters, numbers, and single hyphens.
</p>
) : checkingName ? (
<p className="mt-2 text-sm text-foreground/60">
Checking availability
</p>
) : availability === false ? (
<p className="mt-2 text-sm text-destructive">
That address is already taken.
</p>
) : availability === true ? (
<p className="mt-2 text-sm text-emerald-600">
That address is available.
</p>
) : null}
<Button
className="mt-4 rounded-full px-6"
disabled={
!validName ||
availability === false ||
checkingName ||
busy ||
atCommunityLimit
}
type="submit"
>
{busy ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{action ?? "Create and connect"}
</Button>
</form>
</>
)}
</div>
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
disabled={busy}
onClick={goBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -98,6 +98,7 @@ export function WelcomeSetup({
setRelayUrlError(null);
communityOnboarding.start({
source: "first-community",
firstCommunityPage: "join",
relayUrl: normalizedRelayUrl,
});
},
@@ -108,6 +109,7 @@ export function WelcomeSetup({
(relayWsUrl: string, code: string, policyReceipt?: string) => {
communityOnboarding.start({
source: "first-community",
firstCommunityPage: "join",
relayUrl: relayWsUrl,
inviteCode: code,
policyReceipt,
@@ -26,9 +26,13 @@ export type CommunityOnboardingStage =
*/
| "entering";
export type FirstCommunityPage = "join" | "owned";
export type CommunityOnboardingTransaction = {
id: string;
source: CommunityOnboardingSource;
/** First-run screen that launched this transaction, restored on cancel. */
firstCommunityPage?: FirstCommunityPage;
stage: CommunityOnboardingStage;
relayUrl: string;
inviteCode?: string;
@@ -68,6 +72,7 @@ export type CommunityOnboardingTransactionPatch = Partial<
export type StartCommunityOnboardingInput = {
source: CommunityOnboardingSource;
firstCommunityPage?: FirstCommunityPage;
relayUrl: string;
inviteCode?: string;
communityName?: string;
@@ -150,6 +155,8 @@ export function startCommunityOnboarding(
if (existing?.relayUrl === relayUrl) {
const updated = {
...existing,
firstCommunityPage:
input.firstCommunityPage ?? existing.firstCommunityPage,
inviteCode: input.inviteCode?.trim() || existing.inviteCode,
communityName: input.communityName?.trim() || existing.communityName,
token: input.token?.trim() || existing.token,
@@ -169,6 +176,7 @@ export function startCommunityOnboarding(
const transaction: CommunityOnboardingTransaction = {
id: crypto.randomUUID(),
source: input.source,
firstCommunityPage: input.firstCommunityPage,
stage: input.inviteCode?.trim() ? "claiming" : "connecting",
relayUrl,
inviteCode: input.inviteCode?.trim() || undefined,
@@ -53,6 +53,16 @@
--buzz-gradient-dark-top: #4a4616;
--buzz-gradient-dark-bottom: #0a1423;
--buzz-content-dark: 0 0% 10.1960784314%;
/* Hosted-community onboarding intentionally keeps this light palette in
both app themes; semantic tokens keep component code color-agnostic. */
--buzz-hosted-community-surface-fg: 23 23 23;
--buzz-hosted-community-divider-border: 150 150 10;
--buzz-hosted-community-action-bg: 240 240 205;
--buzz-hosted-community-action-bg-hover: 232 232 188;
--buzz-hosted-community-input-bg: 255 255 255;
--buzz-hosted-community-modal-action-fg: 255 255 255;
--buzz-hosted-community-modal-overlay-bg: 0 0 0;
--buzz-hosted-community-identity-bg: 255 255 255;
}
.dark {
+7 -3
View File
@@ -45,6 +45,8 @@ type DialogContentProps = React.ComponentPropsWithoutRef<
> & {
/** Extra classes for the built-in close button (e.g. a themed icon color). */
closeButtonClassName?: string;
/** Extra classes for this dialog's backdrop. */
overlayClassName?: string;
overlayVariant?: "default" | "transparent";
showCloseButton?: boolean;
/**
@@ -66,6 +68,7 @@ const DialogContent = React.forwardRef<
className,
children,
closeButtonClassName,
overlayClassName,
overlayVariant = "default",
showCloseButton = true,
surface = "default",
@@ -76,11 +79,12 @@ const DialogContent = React.forwardRef<
<DialogPortal>
<DialogOverlay
data-testid="dialog-overlay"
className={
className={cn(
overlayVariant === "transparent"
? "bg-transparent backdrop-blur-none"
: undefined
}
: undefined,
overlayClassName,
)}
/>
<div
className={cn(
+133 -38
View File
@@ -616,13 +616,15 @@ test("first-community choices expose npub and invite input", async ({
await page
.getByRole("button", { name: "Create or connect to my own community" })
.click();
// Sign-in opens as a modal over the blurred hosted-community preview.
await expect(
page.getByRole("heading", { name: "Your communities" }),
page.getByRole("heading", { name: "Set up your community" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Continue with Builderlab" }),
page.getByRole("button", { name: "Sign in to continue" }),
).toBeVisible();
await page.getByRole("button", { name: "Back" }).click();
// The modal's close (X) dismisses back to the community picker.
await page.getByRole("button", { name: "Close" }).click();
await page.getByRole("button", { name: "Add me to a community" }).click();
await expect(page.getByTestId("welcome-join-npub")).toHaveText(
@@ -795,6 +797,21 @@ test("first-community owner can connect an existing hosted community", async ({
),
)
.toContain("wss://north-star.communities.buzz.xyz");
await page.getByTestId("community-profile-back").click();
await expect(
page.getByRole("heading", { name: "Choose a community" }),
).toBeVisible();
await expect(page.getByText("North Star")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Request access to community" }),
).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
window.localStorage.getItem("buzz-community-onboarding-transaction.v1"),
),
)
.toBeNull();
});
test("first-community owner can create and connect a hosted community", async ({
@@ -821,19 +838,46 @@ test("first-community owner can create and connect a hosted community", async ({
await page
.getByRole("button", { name: "Create or connect to my own community" })
.click();
await page.getByRole("button", { name: "Continue with Builderlab" }).click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(
page.getByRole("heading", { name: "Connect this Buzz identity" }),
page.getByRole("heading", { name: "Finish connecting Buzz" }),
).toBeVisible();
await page
.getByRole("button", { name: "Connect this Buzz identity" })
.click();
await expect(page.getByText("Signed in as owner@example.com")).toBeVisible();
await page
.getByRole("textbox", { name: "Community address" })
.fill("bee-lab");
await expect(page.getByText("That address is available.")).toBeVisible();
await page.getByRole("button", { name: "Create and connect" }).click();
await page.getByRole("button", { name: "Connect and continue" }).click();
const createSurface = page.getByTestId("hosted-community-create-surface");
const surfaceBoxBeforeFeedback = await createSurface.boundingBox();
const communityNameInput = page.getByTestId("hosted-community-address-input");
await communityNameInput.fill("bee-lab");
await expect(communityNameInput).toHaveAttribute("style", /width: 7ch;/);
const availabilityFeedback = page.getByText("That address is available.");
await expect(availabilityFeedback).toBeVisible();
const [feedbackBox, surfaceBox, inputBox, suffixBox] = await Promise.all([
availabilityFeedback.boundingBox(),
createSurface.boundingBox(),
page.getByTestId("hosted-community-address-input").boundingBox(),
page.locator("#hosted-community-suffix").boundingBox(),
]);
if (
!surfaceBoxBeforeFeedback ||
!feedbackBox ||
!surfaceBox ||
!inputBox ||
!suffixBox
) {
throw new Error("Could not measure hosted community creation layout");
}
expect(surfaceBox.y).toBe(surfaceBoxBeforeFeedback.y);
expect(surfaceBox.height).toBe(surfaceBoxBeforeFeedback.height);
const addressLeft = inputBox.x;
const addressRight = suffixBox.x + suffixBox.width;
expect(
Math.abs(
(addressLeft + addressRight) / 2 - (surfaceBox.x + surfaceBox.width / 2),
),
).toBeLessThanOrEqual(1);
expect(feedbackBox.y).toBeGreaterThanOrEqual(
surfaceBox.y + surfaceBox.height,
);
await page.getByRole("button", { name: "Next" }).click();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
@@ -846,6 +890,69 @@ test("first-community owner can create and connect a hosted community", async ({
.toContain("wss://bee-lab.communities.buzz.xyz");
});
test("hosted community address line stays within the card for a long name", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
// The 800px app minimum is the worst case for the full-width address line.
await page.setViewportSize({ width: 800, height: 720 });
await page.goto("/");
await page
.getByRole("button", { name: "Create or connect to my own community" })
.click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(
page.getByRole("heading", { name: "Finish connecting Buzz" }),
).toBeVisible();
await page.getByRole("button", { name: "Connect and continue" }).click();
const createSurface = page.getByTestId("hosted-community-create-surface");
const communityNameInput = page.getByTestId("hosted-community-address-input");
// A maximum-length (63 char) valid name — the overflow case Wes flagged; the
// 7-char check above cannot catch it.
const longName = "a".repeat(63);
await communityNameInput.fill(longName);
await expect(communityNameInput).toHaveValue(longName);
const [surfaceBox, inputBox, suffixBox] = await Promise.all([
createSurface.boundingBox(),
communityNameInput.boundingBox(),
page.locator("#hosted-community-suffix").boundingBox(),
]);
if (!surfaceBox || !inputBox || !suffixBox) {
throw new Error("Could not measure hosted community creation layout");
}
const addressLeft = inputBox.x;
const addressRight = suffixBox.x + suffixBox.width;
// The composed `<name>.<suffix>` line must stay within the card — no
// horizontal overflow past the surface or the 800px window.
expect(addressLeft).toBeGreaterThanOrEqual(surfaceBox.x);
expect(addressRight).toBeLessThanOrEqual(surfaceBox.x + surfaceBox.width);
expect(addressRight).toBeLessThanOrEqual(800);
// …and it stays centered within the card.
expect(
Math.abs(
(addressLeft + addressRight) / 2 - (surfaceBox.x + surfaceBox.width / 2),
),
).toBeLessThanOrEqual(2);
});
test("first-community reports a created community without a relay address", async ({
page,
}) => {
@@ -880,11 +987,9 @@ test("first-community reports a created community without a relay address", asyn
await page
.getByRole("button", { name: "Create or connect to my own community" })
.click();
await page
.getByRole("textbox", { name: "Community address" })
.fill("bee-lab");
await page.getByRole("textbox", { name: "Community name" }).fill("bee-lab");
await expect(page.getByText("That address is available.")).toBeVisible();
await page.getByRole("button", { name: "Create and connect" }).click();
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByRole("alert")).toContainText(
"The community was created, but Builderlab did not return its relay address.",
);
@@ -893,9 +998,7 @@ test("first-community reports a created community without a relay address", asyn
).toHaveCount(0);
});
test("first-community can cancel a closed-browser sign-in and retry", async ({
page,
}) => {
test("first-community X cancels a pending sign-in", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
@@ -917,24 +1020,18 @@ test("first-community can cancel a closed-browser sign-in and retry", async ({
await page
.getByRole("button", { name: "Create or connect to my own community" })
.click();
await page.getByRole("button", { name: "Continue with Builderlab" }).click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(page.getByText("Waiting for your browser…")).toBeVisible();
await page.getByRole("button", { name: "Cancel sign-in" }).click();
await expect(
page.getByRole("button", { name: "Continue with Builderlab" }),
page.getByRole("button", { name: "Cancel sign-in" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Close" }).click();
await expect(
page.getByRole("button", { name: "Create or connect to my own community" }),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
.toEqual(expect.arrayContaining(["cancel_builderlab_login"]));
await page.evaluate(() => {
if (window.__BUZZ_E2E_CONFIG__?.mock)
window.__BUZZ_E2E_CONFIG__.mock.builderlabLoginDelayMs = 0;
});
await page.getByRole("button", { name: "Continue with Builderlab" }).click();
await expect(
page.getByRole("heading", { name: "Connect this Buzz identity" }),
).toBeVisible();
});
test("first-community owner can replace a mismatched account identity", async ({
@@ -976,7 +1073,7 @@ test("first-community owner can replace a mismatched account identity", async ({
.getByRole("button", { name: "Use this device's identity" })
.click();
await expect(
page.getByText("Signed in as old-owner@example.com"),
page.getByRole("textbox", { name: "Community name" }),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
@@ -1028,7 +1125,7 @@ test("first-community explains when the local identity belongs to another accoun
),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Connect this Buzz identity" }),
page.getByRole("heading", { name: "Finish connecting Buzz" }),
).toBeVisible();
});
@@ -1066,9 +1163,7 @@ test("back clears Builderlab auth before returning to first-community choices",
await page
.getByRole("button", { name: "Create or connect to my own community" })
.click();
await expect(
page.getByRole("button", { name: "Continue with Builderlab" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Continue" })).toBeVisible();
});
test("first-community shows the scenario cards for localhost", async ({