Skip to content

Commit e83da57

Browse files
authored
feat(account): streamline create-account modal UX (#90)
1 parent 54a87b8 commit e83da57

3 files changed

Lines changed: 306 additions & 143 deletions

File tree

app/vibenet/demos/account/components/CreateAccountModal.tsx

Lines changed: 170 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
'use client';
22

3-
// The account-creation modal: EOA vs Smart, key minting/picking, salt. Shared
4-
// by every demo that lets a user create a local EIP-8130 account — driven off
5-
// the create-modal slice of `useAccountEngine`'s return value.
3+
// The account-creation modal. "Account Type" is the top-level choice:
4+
// Default — one-click EOA off your first unused key (mints one if none)
5+
// Passkey — one-click smart account owned by your first unused passkey
6+
// Advanced — hand-pick smart/EOA + initial keys + salt
7+
// Default/Passkey stay minimal (name only); Advanced reveals the full controls.
8+
// Driven off the create-modal slice of `useAccountEngine`'s return value.
69

710
import { Button } from '../../../../components/ui/Button';
811
import { cn } from '../../../../components/ui/cn';
912
import { Text } from '../../../../components/ui/Text';
1013
import { Modal } from '../../../../components/ui/Modal';
11-
import { KIND_LABEL, short, signerIdentity, type WalletSigner } from '../shared';
14+
import { KIND_LABEL, short, signerIdentity, type CreateMode, type WalletSigner } from '../shared';
1215
import { CheckIcon, KindBadge, TrashIcon } from '../../_shared/primitives';
1316
import type { SignerKind } from '../library/model';
1417
import type { AccountEngine } from '../useAccountEngine';
@@ -17,10 +20,19 @@ type CreateAccountModalProps = {
1720
engine: AccountEngine;
1821
};
1922

23+
const MODES: ReadonlyArray<readonly [CreateMode, string, string]> = [
24+
['default', 'Default', 'Simplest account setup'],
25+
['passkey', 'Passkey', 'Smart account · passkey'],
26+
['advanced', 'Advanced', 'Pick type, keys & salt'],
27+
];
28+
2029
export function CreateAccountModal({ engine }: CreateAccountModalProps) {
2130
const {
2231
modalOpen,
2332
setModalOpen,
33+
createMode,
34+
setCreateMode,
35+
suggestedName,
2436
modalType,
2537
setModalType,
2638
modalLabel,
@@ -43,6 +55,13 @@ export function CreateAccountModal({ engine }: CreateAccountModalProps) {
4355
randomizeCreateSalt,
4456
} = engine;
4557

58+
const busyCreating = busy !== null;
59+
const canCreate = createMode === 'advanced' ? Boolean(modalAddress) : true;
60+
61+
const submit = () => {
62+
if (canCreate && !busyCreating) void createAccount();
63+
};
64+
4665
return (
4766
<Modal
4867
open={modalOpen}
@@ -56,41 +75,44 @@ export function CreateAccountModal({ engine }: CreateAccountModalProps) {
5675
<Button
5776
variant="primary"
5877
size="sm"
59-
onClick={createAccount}
60-
disabled={!modalAddress}
78+
onClick={submit}
79+
disabled={!canCreate || busyCreating}
6180
className="disabled:cursor-not-allowed disabled:opacity-50"
6281
>
63-
Create Account
82+
{busyCreating ? 'Creating…' : 'Create Account'}
6483
</Button>
6584
</>
6685
}
6786
>
6887
<label className="flex flex-col gap-2 text-[14px] font-normal">
69-
Label
88+
Name
7089
<input
7190
value={modalLabel}
72-
placeholder="E.g. Main account"
91+
placeholder={suggestedName}
7392
onChange={(e) => setModalLabel(e.target.value)}
93+
onKeyDown={(e) => {
94+
if (e.key === 'Enter') {
95+
e.preventDefault();
96+
submit();
97+
}
98+
}}
7499
className="w-full rounded-lg border border-bds-gray-10 bg-bds-gray-0 px-3.5 py-2.5 text-[14px] font-normal outline-none transition-colors placeholder:text-bds-gray-40 focus:border-foreground dark:border-white/10 dark:bg-white/5 dark:focus:border-bds-blue-40"
75100
/>
76101
</label>
77102

78103
<div className="flex flex-col gap-2 text-[14px] font-normal">
79104
Account type
80-
<div className="grid grid-cols-2 gap-2">
81-
{(
82-
[
83-
['smart', 'Smart Account', 'Counterfactual · keys + salt → address'],
84-
['eoa', 'EOA', 'Your EOA · delegates to DefaultAccount'],
85-
] as const
86-
).map(([type, title, hint]) => (
105+
<div role="radiogroup" aria-label="Account type" className="grid grid-cols-3 gap-2">
106+
{MODES.map(([mode, title, hint]) => (
87107
<button
88-
key={type}
108+
key={mode}
89109
type="button"
90-
onClick={() => setModalType(type)}
110+
role="radio"
111+
aria-checked={createMode === mode}
112+
onClick={() => setCreateMode(mode)}
91113
className={cn(
92114
'flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors',
93-
modalType === type
115+
createMode === mode
94116
? 'border-foreground'
95117
: 'border-bds-gray-10 hover:border-foreground dark:border-white/10 dark:hover:border-white',
96118
)}
@@ -102,77 +124,110 @@ export function CreateAccountModal({ engine }: CreateAccountModalProps) {
102124
</div>
103125
</div>
104126

105-
{modalType === 'smart' ? (
127+
{createMode === 'advanced' ? (
106128
<>
107-
<KeyPicker
108-
heading="Initial keys"
109-
empty="Mint a key above to add it as an initial owner."
110-
signers={signers}
111-
busy={busy}
112-
mintKinds={['k1', 'p256', 'passkey']}
113-
canDelete={(s) => !usedSignerIds.has(s.id)}
114-
onDelete={deleteSigner}
115-
isOn={(s) => modalIds.includes(s.id)}
116-
onToggle={(s) =>
117-
setModalIds((prev) => (prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id]))
118-
}
119-
onMint={async (kind) => {
120-
const s = await createSigner(kind);
121-
if (s) setModalIds((prev) => [...prev, s.id]);
122-
}}
123-
/>
124-
<label className="flex flex-col gap-2 text-[14px] font-normal">
125-
Salt
126-
<div className="flex items-center gap-2 rounded-lg border border-bds-gray-10 bg-bds-gray-0 transition-colors focus-within:border-foreground dark:border-white/10 dark:bg-white/5 dark:focus-within:border-bds-blue-40">
127-
<input
128-
value={modalSalt}
129-
spellCheck={false}
130-
onChange={(e) => setModalSalt(e.target.value)}
131-
placeholder="0x… (32 bytes) or any phrase"
132-
className="min-w-0 flex-1 bg-transparent px-3.5 py-2.5 font-sans text-[13px] font-normal outline-none placeholder:text-bds-gray-40"
133-
/>
134-
<Button
135-
size="sm"
136-
variant="secondary"
137-
onClick={randomizeCreateSalt}
138-
className="mr-1.5 shrink-0"
139-
>
140-
Randomize
141-
</Button>
129+
<div className="flex flex-col gap-2 text-[14px] font-normal">
130+
Account model
131+
<div role="radiogroup" aria-label="Account model" className="grid grid-cols-2 gap-2">
132+
{(
133+
[
134+
['smart', 'Smart Account', 'Counterfactual · keys + salt → address'],
135+
['eoa', 'EOA', 'Your EOA · delegates to DefaultAccount'],
136+
] as const
137+
).map(([type, title, hint]) => (
138+
<button
139+
key={type}
140+
type="button"
141+
role="radio"
142+
aria-checked={modalType === type}
143+
onClick={() => setModalType(type)}
144+
className={cn(
145+
'flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors',
146+
modalType === type
147+
? 'border-foreground'
148+
: 'border-bds-gray-10 hover:border-foreground dark:border-white/10 dark:hover:border-white',
149+
)}
150+
>
151+
<span className="text-[14px] font-normal">{title}</span>
152+
<span className="text-[12px] font-normal text-bds-gray-60 dark:text-bds-gray-40">{hint}</span>
153+
</button>
154+
))}
142155
</div>
143-
</label>
144-
</>
145-
) : (
146-
<KeyPicker
147-
heading="EOA key"
148-
empty="Mint a K1 key — its address becomes the account."
149-
hint="Your EOA is the account. Its key stays a full owner and it delegates to DefaultAccount on first use. Runs on Vibenet (native 8130)."
150-
signers={eoaSigners}
151-
busy={busy}
152-
mintKinds={['k1']}
153-
canDelete={(s) => !usedSignerIds.has(s.id)}
154-
onDelete={deleteSigner}
155-
isOn={(s) => modalEoaId === s.id}
156-
onToggle={(s) => setModalEoaId(modalEoaId === s.id ? null : s.id)}
157-
onMint={async (kind) => {
158-
const s = await createSigner(kind);
159-
if (s) setModalEoaId(s.id);
160-
}}
161-
/>
162-
)}
156+
</div>
163157

164-
<div className="flex flex-col gap-1">
165-
<span className="text-[11px] tracking-[0.6px] text-bds-gray-60 dark:text-bds-gray-40">Address</span>
166-
{modalAddress ? (
167-
<span className="break-all font-sans text-[13px] text-foreground">{modalAddress}</span>
168-
) : modalType === 'eoa' ? (
169-
<span className="text-[13px] text-bds-gray-60 dark:text-bds-gray-40">Pick a K1 key</span>
170-
) : modalSigners.length === 0 ? (
171-
<span className="text-[13px] text-bds-gray-60 dark:text-bds-gray-40">Select at least one key</span>
172-
) : (
173-
<span className="text-[13px] text-bds-red-60">duplicate key — pick distinct actors</span>
174-
)}
175-
</div>
158+
{modalType === 'smart' ? (
159+
<>
160+
<KeyPicker
161+
heading="Initial keys"
162+
empty="Add a key below to include it as an initial owner."
163+
signers={signers}
164+
busy={busy}
165+
mintKinds={['k1', 'p256', 'passkey']}
166+
canDelete={(s) => !usedSignerIds.has(s.id)}
167+
onDelete={deleteSigner}
168+
isOn={(s) => modalIds.includes(s.id)}
169+
onToggle={(s) =>
170+
setModalIds((prev) => (prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id]))
171+
}
172+
onMint={async (kind) => {
173+
const s = await createSigner(kind);
174+
if (s) setModalIds((prev) => [...prev, s.id]);
175+
}}
176+
/>
177+
<label className="flex flex-col gap-2 text-[14px] font-normal">
178+
Salt
179+
<div className="flex items-center gap-2 rounded-lg border border-bds-gray-10 bg-bds-gray-0 transition-colors focus-within:border-foreground dark:border-white/10 dark:bg-white/5 dark:focus-within:border-bds-blue-40">
180+
<input
181+
value={modalSalt}
182+
spellCheck={false}
183+
onChange={(e) => setModalSalt(e.target.value)}
184+
placeholder="0x… (32 bytes) or any phrase"
185+
className="min-w-0 flex-1 bg-transparent px-3.5 py-2.5 font-sans text-[13px] font-normal outline-none placeholder:text-bds-gray-40"
186+
/>
187+
<Button
188+
size="sm"
189+
variant="secondary"
190+
onClick={randomizeCreateSalt}
191+
className="mr-1.5 shrink-0"
192+
>
193+
Randomize
194+
</Button>
195+
</div>
196+
</label>
197+
</>
198+
) : (
199+
<KeyPicker
200+
heading="EOA key"
201+
empty="Add a K1 key — its address becomes the account."
202+
hint="Your EOA is the account. Its key stays a full owner and it delegates to DefaultAccount on first use. Runs on Vibenet (native 8130)."
203+
signers={eoaSigners}
204+
busy={busy}
205+
mintKinds={['k1']}
206+
canDelete={(s) => !usedSignerIds.has(s.id)}
207+
onDelete={deleteSigner}
208+
isOn={(s) => modalEoaId === s.id}
209+
onToggle={(s) => setModalEoaId(modalEoaId === s.id ? null : s.id)}
210+
onMint={async (kind) => {
211+
const s = await createSigner(kind);
212+
if (s) setModalEoaId(s.id);
213+
}}
214+
/>
215+
)}
216+
217+
<div className="flex flex-col gap-1">
218+
<span className="text-[11px] tracking-[0.6px] text-bds-gray-60 dark:text-bds-gray-40">Address</span>
219+
{modalAddress ? (
220+
<span className="break-all font-sans text-[13px] text-foreground">{modalAddress}</span>
221+
) : modalType === 'eoa' ? (
222+
<span className="text-[13px] text-bds-gray-60 dark:text-bds-gray-40">Pick a K1 key</span>
223+
) : modalSigners.length === 0 ? (
224+
<span className="text-[13px] text-bds-gray-60 dark:text-bds-gray-40">Select at least one key</span>
225+
) : (
226+
<span className="text-[13px] text-bds-red-60">duplicate key — pick distinct actors</span>
227+
)}
228+
</div>
229+
</>
230+
) : null}
176231
</Modal>
177232
);
178233
}
@@ -207,27 +262,20 @@ function KeyPicker({
207262
}: KeyPickerProps) {
208263
return (
209264
<div className="flex flex-col gap-2">
210-
<div className="flex items-center justify-between gap-2">
211-
<Text variant="label" className="font-normal">
212-
{heading}
213-
</Text>
214-
<div className="flex gap-2">
215-
{mintKinds.map((kind) => (
216-
<Button key={kind} variant="outline" size="sm" onClick={() => onMint(kind)} disabled={busy !== null}>
217-
+ {KIND_LABEL[kind]}
218-
</Button>
219-
))}
220-
</div>
221-
</div>
265+
<Text variant="label" className="font-normal">
266+
{heading}
267+
</Text>
222268
{signers.length === 0 ? (
223-
<Text variant="footnote" tone="muted" className="py-2">
269+
<Text variant="footnote" tone="muted" className="py-1">
224270
{empty}
225271
</Text>
226272
) : (
227273
<ul className="flex flex-col gap-1.5">
228274
{signers.map((s) => {
229275
const on = isOn(s);
230276
const deletable = Boolean(canDelete?.(s) && onDelete);
277+
// A key that can't be deleted is bound to an existing account.
278+
const inUse = Boolean(canDelete && !canDelete(s));
231279
return (
232280
<li key={s.id} className="flex items-stretch gap-1.5">
233281
<button
@@ -259,12 +307,34 @@ function KeyPicker({
259307
>
260308
<TrashIcon size={15} />
261309
</button>
310+
) : inUse ? (
311+
<span
312+
title="Bound to an account — can't be deleted"
313+
className="flex shrink-0 items-center rounded-lg border border-bds-gray-10 px-2 text-[11px] text-bds-gray-50 dark:border-white/10 dark:text-bds-gray-40"
314+
>
315+
in use
316+
</span>
262317
) : null}
263318
</li>
264319
);
265320
})}
266321
</ul>
267322
)}
323+
{/* Mint controls sit at the foot of the list, styled like the "+ New
324+
Account" affordance — one dashed button per available key kind. */}
325+
<div className="flex gap-2">
326+
{mintKinds.map((kind) => (
327+
<button
328+
key={kind}
329+
type="button"
330+
onClick={() => onMint(kind)}
331+
disabled={busy !== null}
332+
className="flex flex-1 items-center justify-center gap-1 rounded-lg border border-dashed border-bds-gray-15 px-3 py-2.5 text-[13px] text-bds-gray-60 transition-colors hover:border-base-blue hover:text-base-blue disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/15 dark:text-bds-gray-40 dark:hover:border-bds-blue-60"
333+
>
334+
+ {KIND_LABEL[kind]}
335+
</button>
336+
))}
337+
</div>
268338
{hint ? (
269339
<Text variant="footnote" tone="muted">
270340
{hint}

app/vibenet/demos/account/shared.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ export type Persisted = {
3232
genesisHash?: string;
3333
};
3434

35+
/**
36+
* Top-level choice in the create-account modal's "Account Type" selector.
37+
* `default`/`passkey` are one-click flows (auto-pick or mint a key); `advanced`
38+
* exposes the full smart-vs-EOA + keys + salt controls.
39+
*/
40+
export type CreateMode = 'default' | 'passkey' | 'advanced';
41+
3542
export const KIND_LABEL: Record<SignerKind, string> = {
3643
k1: 'K1',
3744
p256: 'P-256',

0 commit comments

Comments
 (0)