mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Add onboarding design system showcase (#7511)
## Summary - add an Onboarding section with the four selected account, return, identity-key, and profile states - compose the flows from the shared Panel, Button, backdrop, typography, and color tokens - add a shared Base UI-backed TextField with `inset` and `soft` visual variants - give the soft variant a neutral fill, two-pixel neutral focus ring, matching error ring, and text-only disabled treatment - use tokenized error and disabled treatments, and keep profile artwork shapes unmasked ## Validation - desktop-new check and typecheck - desktop-new tests (44 passed) - verified the soft variant computed styles in light and dark themes, including exact `0 0 0 2px` focus and error rings - full pre-push suite: desktop, Tauri, and Rust checks passed - visually verified onboarding and text-field specimens in light and dark themes ## Review note The branch includes a merge of current `main`, required by the repository pre-push branch-skew guard. The onboarding foundation is commit `7cd451337`; the soft text-field comparison is commit `ec0fe9ba1`. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
12
desktop-new/public/onboarding/profile-green.svg
Normal file
12
desktop-new/public/onboarding/profile-green.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="109" height="109" viewBox="0 0 109 109" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame 2147234145">
|
||||
<g id="Union">
|
||||
<mask id="path-1-outside-1_0_17" maskUnits="userSpaceOnUse" x="3.35719" y="3.36094" width="102" height="102" fill="black">
|
||||
<rect fill="white" x="3.35719" y="3.36094" width="102" height="102"/>
|
||||
<path d="M54.4998 5.36094C81.6401 5.36094 103.642 19.0018 103.642 35.8287C103.642 42.8661 99.792 49.3445 93.3289 54.5025C99.7921 59.6607 103.641 66.1398 103.641 73.1773C103.641 90.0042 81.64 103.645 54.4998 103.645C27.3594 103.645 5.35719 90.0042 5.35719 73.1773C5.3572 66.1399 9.20656 59.6606 15.6697 54.5025C9.20706 49.3446 5.3583 42.8658 5.35817 35.8287C5.35817 19.0019 27.3595 5.36102 54.4998 5.36094Z"/>
|
||||
</mask>
|
||||
<path d="M54.4998 5.36094C81.6401 5.36094 103.642 19.0018 103.642 35.8287C103.642 42.8661 99.792 49.3445 93.3289 54.5025C99.7921 59.6607 103.641 66.1398 103.641 73.1773C103.641 90.0042 81.64 103.645 54.4998 103.645C27.3594 103.645 5.35719 90.0042 5.35719 73.1773C5.3572 66.1399 9.20656 59.6606 15.6697 54.5025C9.20706 49.3446 5.3583 42.8658 5.35817 35.8287C5.35817 19.0019 27.3595 5.36102 54.4998 5.36094Z" fill="#84CC16" style="fill:#84CC16;fill:color(display-p3 0.5176 0.8000 0.0863);fill-opacity:1;"/>
|
||||
<path d="M54.4998 5.36094V3.84388H54.4998L54.4998 5.36094ZM103.642 35.8287L105.159 35.8287V35.8287H103.642ZM93.3289 54.5025L92.3826 53.3168L90.8968 54.5025L92.3826 55.6883L93.3289 54.5025ZM103.641 73.1773H105.158V73.1773H103.641ZM54.4998 103.645V105.162H54.4998L54.4998 103.645ZM5.35719 73.1773H3.84013V73.1773H5.35719ZM15.6697 54.5025L16.616 55.6883L18.1017 54.5026L16.616 53.3168L15.6697 54.5025ZM5.35817 35.8287H3.84111V35.8287L5.35817 35.8287ZM54.4998 5.36094V6.878C67.8333 6.878 79.8325 10.2318 88.4493 15.5741C97.0891 20.9307 102.125 28.133 102.125 35.8287H103.642H105.159C105.159 26.6975 99.1945 18.666 90.0481 12.9954C80.8787 7.31051 68.3065 3.84388 54.4998 3.84388V5.36094ZM103.642 35.8287L102.125 35.8287C102.125 42.2701 98.6052 48.3507 92.3826 53.3168L93.3289 54.5025L94.2752 55.6883C100.979 50.3383 105.159 43.4621 105.159 35.8287L103.642 35.8287ZM93.3289 54.5025L92.3826 55.6883C98.6051 60.6543 102.124 66.7356 102.124 73.1773H103.641H105.158C105.158 65.544 100.979 58.667 94.2752 53.3168L93.3289 54.5025ZM103.641 73.1773H102.124C102.124 80.873 97.0883 88.0754 88.4487 93.4319C79.8322 98.7742 67.8333 102.128 54.4998 102.128L54.4998 103.645L54.4998 105.162C68.3065 105.162 80.8784 101.695 90.0475 96.0106C99.1937 90.34 105.158 82.3085 105.158 73.1773H103.641ZM54.4998 103.645V102.128C41.1662 102.128 29.1671 98.7743 20.5502 93.432C11.9104 88.0754 6.87425 80.873 6.87425 73.1773H5.35719H3.84013C3.84013 82.3086 9.80509 90.3401 18.9515 96.0107C28.1208 101.696 40.693 105.162 54.4998 105.162V103.645ZM5.35719 73.1773H6.87425C6.87426 66.7357 10.3936 60.6543 16.616 55.6883L15.6697 54.5025L14.7234 53.3168C8.01955 58.667 3.84013 65.5441 3.84013 73.1773H5.35719ZM15.6697 54.5025L16.616 53.3168C10.3941 48.351 6.87535 42.27 6.87523 35.8287L5.35817 35.8287L3.84111 35.8287C3.84125 43.4616 8.02 50.3382 14.7234 55.6883L15.6697 54.5025ZM5.35817 35.8287H6.87523C6.87523 28.133 11.9112 20.9307 20.5508 15.5741C29.1674 10.2319 41.1663 6.87804 54.4998 6.878L54.4998 5.36094L54.4998 3.84388C40.6931 3.84392 28.1211 7.31056 18.952 12.9954C9.80582 18.6661 3.84111 26.6975 3.84111 35.8287H5.35817Z" fill="white" fill-opacity="0.1" style="fill:white;fill-opacity:0.1;" mask="url(#path-1-outside-1_0_17)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
BIN
desktop-new/public/onboarding/profile-pink.png
Normal file
BIN
desktop-new/public/onboarding/profile-pink.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
BIN
desktop-new/public/onboarding/profile-purple.png
Normal file
BIN
desktop-new/public/onboarding/profile-purple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
8
desktop-new/public/onboarding/profile-sun.svg
Normal file
8
desktop-new/public/onboarding/profile-sun.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="109" height="109" viewBox="0 0 109 109" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame 2147234148">
|
||||
<g id="Star 1">
|
||||
<path d="M44.7219 10.2029C49.9734 4.45469 59.0266 4.45469 64.2781 10.2029V10.2029C67.2021 13.4035 71.5073 14.9704 75.8045 14.3982V14.3982C83.5223 13.3704 90.4575 19.1897 90.7855 26.9687V26.9687C90.9681 31.3 93.2589 35.2677 96.9186 37.5915V37.5915C103.491 41.7651 105.063 50.6807 100.314 56.8506V56.8506C97.6703 60.286 96.8747 64.7979 98.1845 68.9304V68.9304C100.537 76.3525 96.0102 84.1928 88.4063 85.8666V85.8666C84.1726 86.7986 80.6629 89.7436 79.0099 93.7512V93.7512C76.0411 100.949 67.5339 104.045 60.6331 100.44V100.44C56.7908 98.4323 52.2092 98.4323 48.3669 100.44V100.44C41.4661 104.045 32.9589 100.949 29.9901 93.7512V93.7512C28.3371 89.7436 24.8274 86.7986 20.5936 85.8666V85.8666C12.9898 84.1928 8.46319 76.3525 10.8155 68.9304V68.9304C12.1253 64.7979 11.3297 60.286 8.68553 56.8506V56.8506C3.93655 50.6807 5.50861 41.7651 12.0814 37.5915V37.5915C15.7411 35.2677 18.0319 31.3 18.2145 26.9687V26.9687C18.5425 19.1897 25.4777 13.3704 33.1955 14.3982V14.3982C37.4926 14.9704 41.7979 13.4035 44.7219 10.2029V10.2029Z" fill="#FEBC2E" style="fill:#FEBC2E;fill:color(display-p3 0.9961 0.7373 0.1804);fill-opacity:1;"/>
|
||||
<path d="M44.3232 9.83887C49.7887 3.85644 59.2113 3.85644 64.6768 9.83887C67.4816 12.909 71.6113 14.4122 75.7334 13.8633C83.7656 12.7937 90.9828 18.8504 91.3242 26.9463C91.4995 31.101 93.6975 34.9067 97.208 37.1357C104.049 41.4794 105.685 50.7584 100.742 57.1797C98.2057 60.4751 97.4428 64.8034 98.6992 68.7676C101.147 76.4919 96.436 84.6515 88.5225 86.3936C84.4612 87.2876 81.0945 90.1127 79.5088 93.957C76.419 101.448 67.5648 104.67 60.3828 100.918C56.6972 98.9925 52.3028 98.9925 48.6172 100.918C41.4352 104.67 32.581 101.448 29.4912 93.957C27.9055 90.1127 24.5388 87.2876 20.4775 86.3936C12.564 84.6515 7.85279 76.492 10.3008 68.7676C11.5572 64.8034 10.7943 60.4751 8.25781 57.1797C3.31545 50.7584 4.95144 41.4794 11.792 37.1357C15.3025 34.9067 17.5005 31.101 17.6758 26.9463C18.0172 18.8504 25.2344 12.7937 33.2666 13.8633C37.3887 14.4122 41.5184 12.909 44.3232 9.83887Z" stroke="white" stroke-opacity="0.4" style="stroke:white;stroke-opacity:0.4;" stroke-width="1.07921"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -20,9 +20,11 @@ import { useState } from "react";
|
||||
import { Switch } from "@/shared/ui/Switch";
|
||||
import { Accordion } from "@/shared/ui/Accordion";
|
||||
import { Select } from "@/shared/ui/Select";
|
||||
import { OnboardingShowcase } from "@/features/onboarding/ui/OnboardingShowcase";
|
||||
import { Avatar } from "@/shared/ui/Avatar";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
import { Button } from "@/shared/ui/Button";
|
||||
import { CodeInput } from "@/shared/ui/CodeInput";
|
||||
import { ConversationHeader } from "@/shared/ui/ConversationHeader";
|
||||
import { IconButton } from "@/shared/ui/IconButton";
|
||||
import { NavigationItem } from "@/shared/ui/NavigationItem";
|
||||
@@ -30,6 +32,7 @@ import { NavigationSection } from "@/shared/ui/NavigationSection";
|
||||
import { PanelHeader } from "@/shared/ui/PanelHeader";
|
||||
import { SearchField } from "@/shared/ui/SearchField";
|
||||
import { Tabs } from "@/shared/ui/Tabs";
|
||||
import { TextField } from "@/shared/ui/TextField";
|
||||
|
||||
import { ComponentAnatomy } from "./ComponentAnatomy";
|
||||
import {
|
||||
@@ -351,6 +354,75 @@ function PanelSpecimen() {
|
||||
);
|
||||
}
|
||||
|
||||
function TextFieldSpecimen() {
|
||||
return (
|
||||
<div className="component-specimen-stack">
|
||||
<SpecimenGroup label="Inset — content">
|
||||
<div className="component-specimen-stack w-full max-w-sm">
|
||||
<TextField
|
||||
label="Email address"
|
||||
placeholder="name@example.com"
|
||||
description="We’ll use this to help you sign in."
|
||||
type="email"
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
defaultValue="buzz-password"
|
||||
type="password"
|
||||
/>
|
||||
<CodeInput label="Verification code" defaultValue="28" autoFocus />
|
||||
</div>
|
||||
</SpecimenGroup>
|
||||
<SpecimenGroup label="Inset — states">
|
||||
<div className="component-specimen-stack w-full max-w-sm">
|
||||
<Specimen>
|
||||
<TextField label="Name" placeholder="Enter your name" />
|
||||
</Specimen>
|
||||
<Specimen prop='error="Enter a valid email address"'>
|
||||
<TextField
|
||||
label="Email address"
|
||||
defaultValue="not-an-email"
|
||||
error="Enter a valid email address"
|
||||
type="email"
|
||||
/>
|
||||
</Specimen>
|
||||
<Specimen prop="disabled">
|
||||
<TextField label="Workspace" defaultValue="Buzz" disabled />
|
||||
</Specimen>
|
||||
</div>
|
||||
</SpecimenGroup>
|
||||
<SpecimenGroup label="Soft — neutral fill and focus ring">
|
||||
<div className="component-specimen-stack w-full max-w-sm">
|
||||
<Specimen prop='variant="soft"'>
|
||||
<TextField
|
||||
label="Name"
|
||||
placeholder="Enter your name"
|
||||
variant="soft"
|
||||
/>
|
||||
</Specimen>
|
||||
<Specimen prop='variant="soft" error="Enter a valid email address"'>
|
||||
<TextField
|
||||
label="Email address"
|
||||
defaultValue="not-an-email"
|
||||
error="Enter a valid email address"
|
||||
type="email"
|
||||
variant="soft"
|
||||
/>
|
||||
</Specimen>
|
||||
<Specimen prop='variant="soft" disabled'>
|
||||
<TextField
|
||||
label="Workspace"
|
||||
defaultValue="Buzz"
|
||||
disabled
|
||||
variant="soft"
|
||||
/>
|
||||
</Specimen>
|
||||
</div>
|
||||
</SpecimenGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of Tabs, per variant.
|
||||
*
|
||||
@@ -936,10 +1008,12 @@ export const COMPONENT_SPECIMENS: Record<string, () => ReactNode> = {
|
||||
),
|
||||
"conversation-header": ConversationHeaderSpecimen,
|
||||
"panel-header": PanelHeaderSpecimen,
|
||||
"text-field": TextFieldSpecimen,
|
||||
"search-field": SearchFieldSpecimen,
|
||||
"navigation-section": NavigationSectionSpecimen,
|
||||
"navigation-item": NavigationItemSpecimen,
|
||||
composer: MessageComposerSpecimen,
|
||||
"composer-formatting-bar": FormattingBarSpecimen,
|
||||
"activity-rail": ActivityRailSpecimen,
|
||||
onboarding: OnboardingShowcase,
|
||||
};
|
||||
|
||||
293
desktop-new/src/features/onboarding/ui/OnboardingShowcase.tsx
Normal file
293
desktop-new/src/features/onboarding/ui/OnboardingShowcase.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconBan,
|
||||
IconDeviceFloppy,
|
||||
IconPlus,
|
||||
IconShieldCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/Button";
|
||||
import { CodeInput } from "@/shared/ui/CodeInput";
|
||||
import { IconButton } from "@/shared/ui/IconButton";
|
||||
import { Panel } from "@/shared/ui/Panel";
|
||||
import { TextField } from "@/shared/ui/TextField";
|
||||
|
||||
function OnboardingActions() {
|
||||
return (
|
||||
<div className="onboarding-actions">
|
||||
<IconButton
|
||||
aria-label="Go back"
|
||||
icon={<IconArrowLeft size={20} stroke={1.7} aria-hidden="true" />}
|
||||
variant="quiet"
|
||||
size="large"
|
||||
shape="round"
|
||||
/>
|
||||
<Button variant="primary">Continue</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalCopy() {
|
||||
return (
|
||||
<p className="onboarding-legal text-body-sm text-tertiary">
|
||||
By continuing, you confirm you are 18 years of age or older and agree to
|
||||
the Buzz <a href="#terms">Terms of Service</a> and{" "}
|
||||
<a href="#privacy">Privacy Policy</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingFrame({
|
||||
children,
|
||||
legal = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
legal?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="onboarding-scene">
|
||||
<div className="onboarding-window-controls" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<div className="onboarding-card-shell">
|
||||
<Panel>{children}</Panel>
|
||||
</div>
|
||||
{legal ? <LegalCopy /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountStage() {
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
return (
|
||||
<OnboardingFrame legal>
|
||||
<h2 className="text-title text-primary">Create a Buzz account</h2>
|
||||
<div className="onboarding-fields">
|
||||
<div className="onboarding-field-row">
|
||||
<TextField
|
||||
label="First name"
|
||||
placeholder="Your name"
|
||||
value={firstName}
|
||||
onValueChange={setFirstName}
|
||||
/>
|
||||
<TextField
|
||||
label="Last name"
|
||||
placeholder="Your last name"
|
||||
value={lastName}
|
||||
onValueChange={setLastName}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Email address"
|
||||
placeholder="Enter your email address"
|
||||
value={email}
|
||||
onValueChange={setEmail}
|
||||
type="email"
|
||||
/>
|
||||
<p className="text-body text-secondary">
|
||||
Or{" "}
|
||||
<button type="button" className="onboarding-text-action">
|
||||
Continue with private key
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<OnboardingActions />
|
||||
</OnboardingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeStage() {
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
return (
|
||||
<OnboardingFrame legal>
|
||||
<h2 className="text-title text-primary">Welcome back to Buzz</h2>
|
||||
<div className="onboarding-fields">
|
||||
<TextField
|
||||
label="Email address"
|
||||
placeholder="Enter your email address"
|
||||
value={email}
|
||||
onValueChange={setEmail}
|
||||
type="email"
|
||||
/>
|
||||
<p className="text-body text-secondary">
|
||||
Or{" "}
|
||||
<button type="button" className="onboarding-text-action">
|
||||
Sign in with private key
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<OnboardingActions />
|
||||
</OnboardingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckEmailStage() {
|
||||
return (
|
||||
<OnboardingFrame legal>
|
||||
<div className="onboarding-code-flow">
|
||||
<div className="onboarding-heading-lockup">
|
||||
<h2 className="text-title text-primary">Check your email</h2>
|
||||
<p className="text-body text-secondary">
|
||||
We sent a code to{" "}
|
||||
<strong className="text-primary font-semibold">
|
||||
name@example.com
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="onboarding-code-content">
|
||||
<CodeInput
|
||||
label="Verification code"
|
||||
labelHidden
|
||||
autoFocus
|
||||
defaultValue="28"
|
||||
/>
|
||||
<p className="text-body-sm text-tertiary">
|
||||
Didn’t get the code?{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="onboarding-resend-action text-body-sm text-primary font-semibold"
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="onboarding-actions">
|
||||
<IconButton
|
||||
aria-label="Go back"
|
||||
icon={<IconArrowLeft size={20} stroke={1.7} aria-hidden="true" />}
|
||||
variant="quiet"
|
||||
size="large"
|
||||
shape="round"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const IDENTITY_POINTS = [
|
||||
{
|
||||
label: "Stored securely on this device",
|
||||
icon: <IconShieldCheck size={20} stroke={1.7} aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
label: "Never share it—anyone with this key can sign in as you",
|
||||
icon: <IconBan size={20} stroke={1.7} aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
label: "Use a secure backup to recover your account",
|
||||
icon: <IconDeviceFloppy size={20} stroke={1.7} aria-hidden="true" />,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function IdentityStage() {
|
||||
return (
|
||||
<OnboardingFrame legal>
|
||||
<div className="onboarding-heading-lockup">
|
||||
<h2 className="text-title text-primary">
|
||||
Create a private identity key
|
||||
</h2>
|
||||
<p className="text-body-lg text-secondary">
|
||||
This key will be how you log into Buzz. You can use it across Buzz
|
||||
communities and other platforms.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="onboarding-heading-action onboarding-text-action text-body text-primary"
|
||||
>
|
||||
Learn how identity keys work
|
||||
</button>
|
||||
</div>
|
||||
<ul className="onboarding-identity-points">
|
||||
{IDENTITY_POINTS.map((point) => (
|
||||
<li key={point.label} className="text-body text-primary">
|
||||
<span className="onboarding-point-icon">{point.icon}</span>
|
||||
{point.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<OnboardingActions />
|
||||
</OnboardingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const PROFILE_CHOICES = [
|
||||
{ src: "/onboarding/profile-sun.svg", label: "Sun profile" },
|
||||
{ src: "/onboarding/profile-green.svg", label: "Green profile" },
|
||||
{ src: "/onboarding/profile-purple.png", label: "Purple profile" },
|
||||
{ src: "/onboarding/profile-pink.png", label: "Pink profile" },
|
||||
] as const;
|
||||
|
||||
function ProfileStage() {
|
||||
const [name, setName] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<OnboardingFrame>
|
||||
<h2 className="text-title text-primary">Build your profile</h2>
|
||||
<fieldset className="onboarding-profile-choices">
|
||||
<legend className="sr-only">Choose a profile</legend>
|
||||
<button
|
||||
type="button"
|
||||
className="onboarding-profile-add onboarding-profile-button"
|
||||
aria-label="Add your own profile picture"
|
||||
onClick={() => setSelected("custom")}
|
||||
data-selected={selected === "custom" || undefined}
|
||||
>
|
||||
<IconPlus size={24} stroke={1.7} aria-hidden="true" />
|
||||
</button>
|
||||
{PROFILE_CHOICES.map((choice) => (
|
||||
<button
|
||||
key={choice.label}
|
||||
type="button"
|
||||
className="onboarding-profile-button onboarding-profile-choice"
|
||||
aria-label={choice.label}
|
||||
onClick={() => setSelected(choice.label)}
|
||||
data-selected={selected === choice.label || undefined}
|
||||
>
|
||||
<img src={choice.src} alt="" />
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<TextField
|
||||
label="Name"
|
||||
placeholder="Enter your name"
|
||||
value={name}
|
||||
onValueChange={setName}
|
||||
/>
|
||||
<OnboardingActions />
|
||||
</OnboardingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const STAGES = [
|
||||
{ label: "Create a Buzz account", Component: AccountStage },
|
||||
{ label: "Welcome back to Buzz", Component: WelcomeStage },
|
||||
{ label: "Check your email", Component: CheckEmailStage },
|
||||
{ label: "Create a private identity key", Component: IdentityStage },
|
||||
{ label: "Build your profile", Component: ProfileStage },
|
||||
] as const;
|
||||
|
||||
export function OnboardingShowcase() {
|
||||
return (
|
||||
<div className="onboarding-showcase">
|
||||
<p className="onboarding-showcase-intro text-body text-secondary">
|
||||
Five selected onboarding moments from Figma, rebuilt with the current
|
||||
Buzz backdrop, surface, type, and control system.
|
||||
</p>
|
||||
{STAGES.map(({ label, Component }) => (
|
||||
<section className="onboarding-showcase-stage" key={label}>
|
||||
<h2 className="text-body-sm text-tertiary">{label}</h2>
|
||||
<Component />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -562,6 +562,210 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text-field {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.text-field-label,
|
||||
.text-field-description,
|
||||
.text-field-error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text-field-control {
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 0 var(--space-control-inset);
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color var(--duration-state) var(--easing-state),
|
||||
background-color var(--duration-state) var(--easing-state),
|
||||
box-shadow var(--duration-state) var(--easing-state);
|
||||
}
|
||||
|
||||
.text-field-control::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.text-field-control:hover:not(:disabled) {
|
||||
border-color: var(--neutral-7);
|
||||
}
|
||||
|
||||
html[data-keyboard-navigation] .text-field-control:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.text-field[data-invalid] .text-field-control {
|
||||
border-color: var(--red-9);
|
||||
background: var(--red-3);
|
||||
}
|
||||
|
||||
/* A quieter filled field for dense forms. Unlike the inset variant, focus is
|
||||
visible for pointer and keyboard entry because the ring is the proposed
|
||||
interaction treatment being compared in the design-system specimen. */
|
||||
.text-field[data-variant="soft"] .text-field-control {
|
||||
border: 0;
|
||||
background: var(--neutral-2);
|
||||
box-shadow: 0 0 0 1px var(--neutral-2);
|
||||
transition:
|
||||
opacity 0.1s ease,
|
||||
background 0.1s ease,
|
||||
box-shadow 0.1s ease;
|
||||
}
|
||||
|
||||
.text-field[data-variant="soft"] .text-field-control:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--neutral-7);
|
||||
}
|
||||
|
||||
.text-field[data-variant="soft"][data-invalid] .text-field-control {
|
||||
background: var(--neutral-2);
|
||||
box-shadow: 0 0 0 2px var(--red-9);
|
||||
}
|
||||
|
||||
.text-field-error {
|
||||
color: var(--red-11);
|
||||
}
|
||||
|
||||
.text-field[data-disabled] .text-field-control {
|
||||
background: var(--neutral-2);
|
||||
color: var(--text-disabled);
|
||||
-webkit-text-fill-color: var(--text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.text-field[data-disabled] .text-field-control::placeholder {
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.text-field[data-variant="soft"][data-disabled] .text-field-control {
|
||||
background: var(--neutral-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.code-input-label {
|
||||
margin: 0 0 var(--space-2);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.code-input-group {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.code-input-slot {
|
||||
position: relative;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.code-input-control {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 0;
|
||||
background: var(--bg-inset);
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color var(--duration-state) var(--easing-state),
|
||||
background-color var(--duration-state) var(--easing-state),
|
||||
box-shadow var(--duration-state) var(--easing-state);
|
||||
}
|
||||
|
||||
.code-input-control::selection {
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.code-input-control:focus {
|
||||
border-color: var(--neutral-7);
|
||||
}
|
||||
|
||||
html[data-keyboard-navigation] .code-input-control:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.code-input-control:disabled {
|
||||
background: var(--neutral-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.code-input-digit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.code-input-digit[data-motion="enter"] {
|
||||
animation: code-input-digit-enter var(--duration-state) ease-out both;
|
||||
}
|
||||
|
||||
.code-input-digit[data-motion="exit"] {
|
||||
animation: code-input-digit-exit var(--duration-state) ease-out both;
|
||||
}
|
||||
|
||||
.code-input:disabled .code-input-digit {
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
@keyframes code-input-digit-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes code-input-digit-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.code-input-digit[data-motion="enter"],
|
||||
.code-input-digit[data-motion="exit"] {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,4 +1,267 @@
|
||||
@layer components {
|
||||
.onboarding-showcase,
|
||||
.onboarding-showcase-stage {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.onboarding-showcase {
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.onboarding-showcase-intro {
|
||||
max-width: 38rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-showcase-stage {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.onboarding-showcase-stage > h2,
|
||||
.onboarding-card-shell h2,
|
||||
.onboarding-card-shell p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-scene {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 34rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.onboarding-window-controls {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
left: var(--space-4);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.onboarding-window-controls span {
|
||||
width: var(--space-3);
|
||||
height: var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.onboarding-window-controls span:nth-child(1) {
|
||||
background: var(--red-9);
|
||||
}
|
||||
|
||||
.onboarding-window-controls span:nth-child(2) {
|
||||
background: var(--amber-9);
|
||||
}
|
||||
|
||||
.onboarding-window-controls span:nth-child(3) {
|
||||
background: var(--green-9);
|
||||
}
|
||||
|
||||
.onboarding-card-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(35rem, calc(100% - 2 * var(--space-6)));
|
||||
margin: 4.5rem 0 6rem;
|
||||
}
|
||||
|
||||
.onboarding-card-shell > .panel {
|
||||
display: flex;
|
||||
height: auto;
|
||||
min-height: 23rem;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
justify-content: space-between;
|
||||
padding: 3rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.onboarding-fields,
|
||||
.onboarding-heading-lockup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.onboarding-fields {
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.onboarding-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
html[data-keyboard-navigation] .onboarding-profile-add:focus-visible,
|
||||
html[data-keyboard-navigation] .onboarding-resend-action:focus-visible,
|
||||
html[data-keyboard-navigation] .onboarding-text-action:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.onboarding-text-action {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-weight: var(--type-weight-semibold);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.onboarding-actions > .buzz-button[data-variant="primary"] {
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
.onboarding-code-flow,
|
||||
.onboarding-code-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.onboarding-code-flow {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.onboarding-code-content {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.onboarding-resend-action {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-weight: var(--type-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.onboarding-legal {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: var(--space-4);
|
||||
left: 50%;
|
||||
width: min(25rem, calc(100% - 2 * var(--space-6)));
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.onboarding-legal a {
|
||||
color: inherit;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
.onboarding-heading-lockup {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.onboarding-heading-action {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.onboarding-identity-points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.onboarding-identity-points li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.onboarding-point-icon {
|
||||
display: grid;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--neutral-2);
|
||||
}
|
||||
|
||||
.onboarding-profile-choices {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.onboarding-profile-button {
|
||||
display: grid;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.onboarding-profile-add {
|
||||
overflow: hidden;
|
||||
border: 2px dashed var(--border-primary);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.onboarding-profile-add:hover,
|
||||
.onboarding-profile-add[data-selected] {
|
||||
border-color: var(--purple-8);
|
||||
background: var(--purple-3);
|
||||
}
|
||||
|
||||
.onboarding-profile-choice img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: filter var(--duration-state) var(--easing-state);
|
||||
}
|
||||
|
||||
.onboarding-profile-choice:hover img,
|
||||
.onboarding-profile-choice[data-selected] img,
|
||||
html[data-keyboard-navigation] .onboarding-profile-choice:focus-visible img {
|
||||
filter: drop-shadow(0 0 0.2rem var(--purple-8));
|
||||
}
|
||||
|
||||
html[data-keyboard-navigation] .onboarding-profile-choice:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 47.999rem) {
|
||||
.onboarding-scene {
|
||||
min-height: 38rem;
|
||||
}
|
||||
|
||||
.onboarding-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.onboarding-card-shell > .panel {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
display: grid;
|
||||
min-height: 100svh;
|
||||
|
||||
205
desktop-new/src/shared/ui/CodeInput.tsx
Normal file
205
desktop-new/src/shared/ui/CodeInput.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent,
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type CodeInputProps = {
|
||||
label: string;
|
||||
labelHidden?: boolean;
|
||||
autoFocus?: boolean;
|
||||
defaultValue?: string;
|
||||
disabled?: boolean;
|
||||
onValueChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
const CODE_INPUT_LENGTH = 6;
|
||||
const CODE_INPUT_DIGIT_KEYS = ["1", "2", "3", "4", "5", "6"] as const;
|
||||
type DigitMotion = "idle" | "enter" | "exit";
|
||||
|
||||
function digitsFrom(value: string): string[] {
|
||||
const digits = value.replace(/\D/g, "").slice(0, CODE_INPUT_LENGTH).split("");
|
||||
return Array.from(
|
||||
{ length: CODE_INPUT_LENGTH },
|
||||
(_, index) => digits[index] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A segmented numeric code field with one-time-code autofill, paste
|
||||
* distribution, and keyboard movement between digits.
|
||||
*/
|
||||
export function CodeInput({
|
||||
label,
|
||||
labelHidden = false,
|
||||
autoFocus = false,
|
||||
defaultValue = "",
|
||||
disabled = false,
|
||||
onValueChange,
|
||||
}: CodeInputProps) {
|
||||
const [digits, setDigits] = useState(() => digitsFrom(defaultValue));
|
||||
const firstEmptyDigit = digits.findIndex((digit) => !digit);
|
||||
const initialFocusIndex = useRef(
|
||||
firstEmptyDigit === -1 ? CODE_INPUT_LENGTH - 1 : firstEmptyDigit,
|
||||
);
|
||||
const [renderedDigits, setRenderedDigits] = useState(() =>
|
||||
digitsFrom(defaultValue),
|
||||
);
|
||||
const [digitMotions, setDigitMotions] = useState<DigitMotion[]>(() =>
|
||||
Array.from({ length: CODE_INPUT_LENGTH }, () => "idle"),
|
||||
);
|
||||
const digitsRef = useRef(digits);
|
||||
const inputs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && !disabled) {
|
||||
inputs.current[initialFocusIndex.current]?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled]);
|
||||
|
||||
const update = (next: string[]) => {
|
||||
const previous = digitsRef.current;
|
||||
digitsRef.current = next;
|
||||
setDigits(next);
|
||||
setRenderedDigits((current) =>
|
||||
current.map((renderedDigit, index) => {
|
||||
if (next[index]) return next[index];
|
||||
if (previous[index]) return previous[index];
|
||||
return renderedDigit;
|
||||
}),
|
||||
);
|
||||
setDigitMotions((current) =>
|
||||
current.map((motion, index) => {
|
||||
if (previous[index] === next[index]) return motion;
|
||||
return next[index] ? "enter" : "exit";
|
||||
}),
|
||||
);
|
||||
onValueChange?.(next.join(""));
|
||||
};
|
||||
|
||||
const handleDigitAnimationEnd = (index: number, motion: DigitMotion) => {
|
||||
setDigitMotions((current) => {
|
||||
if (current[index] !== motion) return current;
|
||||
const next = [...current];
|
||||
next[index] = "idle";
|
||||
return next;
|
||||
});
|
||||
|
||||
if (motion === "exit" && !digitsRef.current[index]) {
|
||||
setRenderedDigits((current) => {
|
||||
const next = [...current];
|
||||
next[index] = "";
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const insert = (index: number, value: string) => {
|
||||
const incoming = value
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, CODE_INPUT_LENGTH - index);
|
||||
const next = [...digits];
|
||||
|
||||
if (!incoming) {
|
||||
next[index] = "";
|
||||
update(next);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [offset, digit] of [...incoming].entries()) {
|
||||
next[index + offset] = digit;
|
||||
}
|
||||
update(next);
|
||||
inputs.current[
|
||||
Math.min(index + incoming.length, CODE_INPUT_LENGTH - 1)
|
||||
]?.focus();
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
index: number,
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
insert(index, event.target.value);
|
||||
};
|
||||
|
||||
const handlePaste = (
|
||||
index: number,
|
||||
event: ClipboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const pasted = event.clipboardData.getData("text");
|
||||
if (!/\d/.test(pasted)) return;
|
||||
event.preventDefault();
|
||||
insert(index, pasted);
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
index: number,
|
||||
event: KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (event.key === "Backspace" && !digits[index] && index > 0) {
|
||||
event.preventDefault();
|
||||
const next = [...digits];
|
||||
next[index - 1] = "";
|
||||
update(next);
|
||||
inputs.current[index - 1]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" && index > 0) {
|
||||
event.preventDefault();
|
||||
inputs.current[index - 1]?.focus();
|
||||
} else if (event.key === "ArrowRight" && index < CODE_INPUT_LENGTH - 1) {
|
||||
event.preventDefault();
|
||||
inputs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className="code-input" disabled={disabled}>
|
||||
<legend
|
||||
className={`code-input-label text-body-sm text-primary font-semibold${labelHidden ? " sr-only" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</legend>
|
||||
<div className="code-input-group">
|
||||
{digits.map((digit, index) => (
|
||||
<div className="code-input-slot" key={CODE_INPUT_DIGIT_KEYS[index]}>
|
||||
<input
|
||||
aria-label={`${label} digit ${index + 1} of ${CODE_INPUT_LENGTH}`}
|
||||
autoComplete={index === 0 ? "one-time-code" : "off"}
|
||||
className="code-input-control text-body"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
onChange={(event) => handleChange(index, event)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onKeyDown={(event) => handleKeyDown(index, event)}
|
||||
onPaste={(event) => handlePaste(index, event)}
|
||||
pattern="[0-9]*"
|
||||
ref={(element) => {
|
||||
inputs.current[index] = element;
|
||||
}}
|
||||
type="text"
|
||||
value={digit}
|
||||
/>
|
||||
{renderedDigits[index] ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="code-input-digit text-body font-semibold"
|
||||
data-motion={digitMotions[index]}
|
||||
key={renderedDigits[index]}
|
||||
onAnimationEnd={() =>
|
||||
handleDigitAnimationEnd(index, digitMotions[index])
|
||||
}
|
||||
>
|
||||
{renderedDigits[index]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
56
desktop-new/src/shared/ui/TextField.tsx
Normal file
56
desktop-new/src/shared/ui/TextField.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Field } from "@base-ui/react/field";
|
||||
import { Input } from "@base-ui/react/input";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TextFieldProps = Omit<ComponentProps<typeof Input>, "className"> & {
|
||||
label: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
variant?: "inset" | "soft";
|
||||
};
|
||||
|
||||
/**
|
||||
* A labeled, single-line form control with Buzz appearance and Base UI
|
||||
* behavior. Search and other purpose-built controls remain separate
|
||||
* compositions because their icons and actions carry additional meaning.
|
||||
*/
|
||||
export function TextField({
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
disabled,
|
||||
name,
|
||||
type = "text",
|
||||
variant = "inset",
|
||||
...inputProps
|
||||
}: TextFieldProps) {
|
||||
return (
|
||||
<Field.Root
|
||||
className="text-field"
|
||||
disabled={disabled}
|
||||
invalid={Boolean(error)}
|
||||
name={name}
|
||||
data-variant={variant}
|
||||
>
|
||||
<Field.Label className="text-field-label text-body-sm text-primary font-semibold">
|
||||
{label}
|
||||
</Field.Label>
|
||||
<Input
|
||||
{...inputProps}
|
||||
className="text-field-control text-body"
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
/>
|
||||
{description ? (
|
||||
<Field.Description className="text-field-description text-body-sm text-tertiary">
|
||||
{description}
|
||||
</Field.Description>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Field.Error className="text-field-error text-body-sm" match>
|
||||
{error}
|
||||
</Field.Error>
|
||||
) : null}
|
||||
</Field.Root>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
* paths resolve the same way the app resolves them and no Node types are needed.
|
||||
*/
|
||||
const SOURCES = import.meta.glob(
|
||||
"/src/{shared/ui,features/composer/ui,features/agent-activity/ui,features/conversation/ui}/*.tsx",
|
||||
"/src/{shared/ui,features/composer/ui,features/agent-activity/ui,features/conversation/ui,features/onboarding/ui}/*.tsx",
|
||||
{
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
@@ -108,6 +108,16 @@ describe("component registry — Base UI backing", () => {
|
||||
"Button",
|
||||
]);
|
||||
|
||||
// Onboarding owns no primitives now: its form controls and actions all
|
||||
// arrive through shared Buzz components.
|
||||
const onboarding = resolveBaseUiBacking("onboarding");
|
||||
expect(onboarding.own).toEqual([]);
|
||||
expect(onboarding.inherited.map((entry) => entry.part.name)).toEqual([
|
||||
"Field",
|
||||
"Input",
|
||||
"Button",
|
||||
]);
|
||||
|
||||
// A component whose own import already covers a part does not also report
|
||||
// it as inherited.
|
||||
const button = resolveBaseUiBacking("button");
|
||||
|
||||
@@ -355,6 +355,29 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
baseUi: [],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "text-field",
|
||||
name: "TextField",
|
||||
purpose:
|
||||
"A labeled, single-line form control with optional supporting or error text.",
|
||||
behavior: "Base UI Field and Input",
|
||||
variants: [
|
||||
"inset",
|
||||
"soft",
|
||||
"text",
|
||||
"email",
|
||||
"password",
|
||||
"description",
|
||||
"error",
|
||||
"disabled",
|
||||
],
|
||||
status: "proposed",
|
||||
collection: "components",
|
||||
owner: "desktop-new Onboarding",
|
||||
source: "shared/ui/TextField.tsx",
|
||||
baseUi: [BASE_UI_PARTS.field, BASE_UI_PARTS.input],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "search-field",
|
||||
name: "SearchField",
|
||||
@@ -490,6 +513,25 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
baseUi: [],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "onboarding",
|
||||
name: "Onboarding",
|
||||
purpose:
|
||||
"The account, identity, and profile moments that introduce Buzz on the product backdrop.",
|
||||
behavior: "Composes shared Buzz fields, actions, and panels",
|
||||
variants: [
|
||||
"create account",
|
||||
"welcome back",
|
||||
"create identity key",
|
||||
"build profile",
|
||||
],
|
||||
status: "proposed",
|
||||
collection: "product-ui",
|
||||
owner: "desktop-new Onboarding",
|
||||
source: "features/onboarding/ui/OnboardingShowcase.tsx",
|
||||
baseUi: [],
|
||||
composes: ["text-field", "button", "icon-button", "panel"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user