From 3d2a9b09882cabf2780b58272fa458fdbcb58677 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Tue, 1 Sep 2026 13:47:10 -0700 Subject: [PATCH 1/3] feat: Drawer for create account modal --- app/components/ui/Drawer.tsx | 70 +++++++++++++++++++ app/components/ui/Modal.tsx | 2 +- .../demos/_components/AccountDemoShell.tsx | 2 +- .../account/components/CreateAccountModal.tsx | 10 +-- 4 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 app/components/ui/Drawer.tsx diff --git a/app/components/ui/Drawer.tsx b/app/components/ui/Drawer.tsx new file mode 100644 index 0000000..e77da9c --- /dev/null +++ b/app/components/ui/Drawer.tsx @@ -0,0 +1,70 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Drawer as BaseDrawer } from '@base-ui/react/drawer'; + +import { cn } from './cn'; +import { CloseIcon } from './icons'; +import { textVariantClasses } from './Text'; + +type DrawerProps = { + open: boolean; + onClose: () => void; + title: ReactNode; + children: ReactNode; + // Rendered in a pinned footer row (e.g. Cancel / Confirm actions). + footer?: ReactNode; + // Widen/narrow the panel; defaults to a form-width sheet (narrower than Modal). + className?: string; +}; + +// Right-side drawer on Base UI Drawer: swipe-to-dismiss, focus trap, Escape, +// and document scroll lock come from the library. Chrome matches Modal so +// account/create flows can swap one for the other without a visual rewrite. +export function Drawer({ open, onClose, title, children, footer, className }: DrawerProps) { + return ( + { + if (!nextOpen) onClose(); + }} + > + + + + +
+ + {title} + +
+ + + +
+
+ + + {children} + + + {footer ? ( +
+ {footer} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/app/components/ui/Modal.tsx b/app/components/ui/Modal.tsx index e64c632..5c5fe33 100644 --- a/app/components/ui/Modal.tsx +++ b/app/components/ui/Modal.tsx @@ -34,7 +34,7 @@ export function Modal({ open, onClose, title, children, headerAction, footer, cl diff --git a/app/vibenet/demos/_components/AccountDemoShell.tsx b/app/vibenet/demos/_components/AccountDemoShell.tsx index ee6f4f5..60fb7e3 100644 --- a/app/vibenet/demos/_components/AccountDemoShell.tsx +++ b/app/vibenet/demos/_components/AccountDemoShell.tsx @@ -46,7 +46,7 @@ export function AccountDemoShell({ }: AccountDemoShellProps) { const engine = useAccountEngine(); const [topbarSlot, setTopbarSlot] = useState(null); - // The switcher and the empty-state gate both open the create-account modal. + // The switcher and the empty-state gate both open the create-account drawer. const [createOpen, setCreateOpen] = useState(false); const onCreate = () => setCreateOpen(true); useEffect(() => { diff --git a/app/vibenet/demos/account/components/CreateAccountModal.tsx b/app/vibenet/demos/account/components/CreateAccountModal.tsx index 8800040..4de10ac 100644 --- a/app/vibenet/demos/account/components/CreateAccountModal.tsx +++ b/app/vibenet/demos/account/components/CreateAccountModal.tsx @@ -1,6 +1,6 @@ 'use client'; -// The account-creation modal. "Account Type" is the top-level choice: +// The account-creation drawer. "Account Type" is the top-level choice: // Default — one-click EOA off your first unused key (mints one if none) // Passkey — one-click smart account owned by your first unused passkey // Advanced — hand-pick smart/EOA + initial keys + salt @@ -15,7 +15,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Button } from '../../../../components/ui/Button'; import { cn } from '../../../../components/ui/cn'; import { Text } from '../../../../components/ui/Text'; -import { Modal } from '../../../../components/ui/Modal'; +import { Drawer } from '../../../../components/ui/Drawer'; import { KIND_LABEL, short, signerIdentity, type CreateMode, type WalletSigner } from '../shared'; import { CheckIcon, KindBadge, TrashIcon } from '../../_shared/primitives'; import { actorPairs, normalizeSalt, randomHex32, sortActors, toStoredActor } from '../library/derive'; @@ -55,7 +55,7 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { const [modalIds, setModalIds] = useState([]); const [modalEoaId, setModalEoaId] = useState(null); - // Reset to the one-click default each time the modal opens. + // Reset to the one-click default each time the drawer opens. useEffect(() => { if (!open) return; setCreateMode('default'); @@ -218,7 +218,7 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { }; return ( - ) : null} - + ); } From e7fd67e16031a8961feb9802cecc71c0362619d5 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Tue, 1 Sep 2026 13:56:23 -0700 Subject: [PATCH 2/3] fix iOS 26 modal/drawer backdrop --- app/components/ui/Drawer.tsx | 2 +- app/components/ui/Modal.tsx | 2 +- app/globals.css | 5 +++++ tailwind.config.ts | 9 ++++++++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/components/ui/Drawer.tsx b/app/components/ui/Drawer.tsx index e77da9c..9807a66 100644 --- a/app/components/ui/Drawer.tsx +++ b/app/components/ui/Drawer.tsx @@ -31,7 +31,7 @@ export function Drawer({ open, onClose, title, children, footer, className }: Dr }} > - + - + -) are defined in // globals.css, so Tailwind color utilities resolve straight onto them. This @@ -75,7 +76,13 @@ const config: Config = { }, }, }, - plugins: [], + plugins: [ + // iOS/WebKit only. Same @supports gate Base UI uses for iOS 26 backdrop + // positioning (`-webkit-touch-callout` is implemented on iOS Safari/Chrome). + plugin(({ addVariant }) => { + addVariant('ios', '@supports (-webkit-touch-callout: none)'); + }), + ], }; export default config; From fcdb2ebb0b7a5cbda66bb0e9702481f93c6df723 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Tue, 1 Sep 2026 14:27:18 -0700 Subject: [PATCH 3/3] create policy drawer --- app/vibenet/demos/b20/B20Demo.tsx | 67 ++++++++----------- .../demos/b20/components/CreatePolicy.tsx | 37 ++++++++-- .../demos/b20/components/PolicyList.tsx | 2 +- 3 files changed, 60 insertions(+), 46 deletions(-) diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx index 3cfe1cf..2d789ab 100644 --- a/app/vibenet/demos/b20/B20Demo.tsx +++ b/app/vibenet/demos/b20/B20Demo.tsx @@ -8,7 +8,6 @@ import type { Address, Hex } from 'viem'; import { trackB20Action, trackB20ModuleSelect } from '../../../analytics/events'; import { Button } from '../../../components/ui/Button'; import { Card } from '../../../components/ui/Card'; -import { Modal } from '../../../components/ui/Modal'; import { Text } from '../../../components/ui/Text'; import { FeatureCard } from '../../components/FeatureCard'; import { walletErrorMessage } from '../../library/wallet'; @@ -173,11 +172,11 @@ function B20DemoInner() { // A policy assignment chosen from the Policies list, pending confirmation in // the transaction popup. const [pendingAssign, setPendingAssign] = useState(null); - // The scope whose "+ Policy" opened the Create Policy dialog. When set, the + // The scope whose "+ Policy" opened the Create Policy drawer. When set, the // newly created policy is auto-assigned to this scope once creation lands. const [pendingCreateScope, setPendingCreateScope] = useState<{ scope: string; label: string } | null>(null); // True while CreatePolicy runs its own preflight reads / broadcast, before the - // parent-level `busy` is set. Blocks closing the modal so an aborted dialog + // parent-level `busy` is set. Blocks closing the drawer so an aborted dialog // can't still sign and broadcast a policy transaction. const [policyPreflight, setPolicyPreflight] = useState(false); @@ -632,46 +631,38 @@ function B20DemoInner() { onFirstPayment={startFirstPayment} /> - {/* Create Policy modal */} - { if (policyPreflight) return; setPendingCreateScope(null); closeModal(); }} - title="Create Policy" - className="max-w-3xl" - > - {token ? ( - { - if (wallet) setRecentPolicies(writeRecentPolicy(wallet, policy)); - }} - onComplete={(policy) => { - setOpenModal(null); - // Created from a scope's "+ Policy" — carry straight into - // assigning the new policy to that scope. - const target = pendingCreateScope; - setPendingCreateScope(null); - if (target) { - setPendingAssign({ - scope: target.scope, - scopeLabel: target.label, - policyId: policy.id, - policyLabel: policy.label || `Policy ${policy.id.toString()}`, - }); - } - }} - onBusyChange={setPolicyPreflight} - busy={busy} - /> - ) : null} - + wallet={wallet} + recentPolicies={recentPolicies} + addressBook={addressBook} + onSend={send} + onPolicyCreated={(policy) => { + if (wallet) setRecentPolicies(writeRecentPolicy(wallet, policy)); + }} + onComplete={(policy) => { + setOpenModal(null); + // Created from a scope's "+ Policy" — carry straight into + // assigning the new policy to that scope. + const target = pendingCreateScope; + setPendingCreateScope(null); + if (target) { + setPendingAssign({ + scope: target.scope, + scopeLabel: target.label, + policyId: policy.id, + policyLabel: policy.label || `Policy ${policy.id.toString()}`, + }); + } + }} + onBusyChange={setPolicyPreflight} + busy={busy} + /> {/* Assign-policy confirmation (owns the shared transaction dialog) */} void; wallet: Address | null; recentPolicies: RecentPolicy[]; addressBook: AddressBookEntry[]; onSend: (label: string, to: Address, data: Hex, action: string) => Promise; onPolicyCreated: (policy: CreatedPolicy) => void; onComplete: (policy: CreatedPolicy) => void; - /** Reports the local preflight/broadcast state so the modal can block close. */ + /** Reports the local preflight/broadcast state so the parent can block close. */ onBusyChange?: (busy: boolean) => void; busy: string | null; }) { @@ -135,9 +140,31 @@ export function CreatePolicy({ }; const pending = !!busy || finalizing; const compositeReady = children.length >= 2 && children.every(Boolean); + const canCreate = Boolean(wallet && label.trim() && (mode !== 'composite' || compositeReady)); return ( -
+ + + + + } + > +
setLabel(event.target.value)} placeholder="KYC allowlist" /> @@ -245,11 +272,7 @@ export function CreatePolicy({ )} -
-
-
+ ); } diff --git a/app/vibenet/demos/b20/components/PolicyList.tsx b/app/vibenet/demos/b20/components/PolicyList.tsx index 32257c3..05cf2cc 100644 --- a/app/vibenet/demos/b20/components/PolicyList.tsx +++ b/app/vibenet/demos/b20/components/PolicyList.tsx @@ -17,7 +17,7 @@ const READ_RETRY_MS = [0, 2_500, 6_000]; // Inline policy assignment: one row per token feature (scope), each with a // dropdown of the account's named policies. Selecting one hands the choice up to // the transaction popup flow (it does not assign in place); "+ Policy" opens the -// Create Policy dialog for that scope, then assigns the new policy to it. +// Create Policy drawer for that scope, then assigns the new policy to it. export function PolicyList({ token, adminStatus,