diff --git a/app/components/ui/Drawer.tsx b/app/components/ui/Drawer.tsx new file mode 100644 index 0000000..9807a66 --- /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..555978a 100644 --- a/app/components/ui/Modal.tsx +++ b/app/components/ui/Modal.tsx @@ -31,10 +31,10 @@ export function Modal({ open, onClose, title, children, headerAction, footer, cl }} > - + diff --git a/app/globals.css b/app/globals.css index 0a90254..27bb0d7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -387,6 +387,11 @@ body { } body { + /* Containing block for iOS 26+ absolute dialog/drawer backdrops. Without + this, `position: absolute; inset: 0` still sizes to the visual viewport + and leaves gaps under the browser chrome. + https://base-ui.com/react/overview/quick-start#ios-26-safari */ + position: relative; font-family: var(--font-google-sans-flex), -apple-system, 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} - + ); } 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, diff --git a/tailwind.config.ts b/tailwind.config.ts index e8f7488..7df2182 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,5 @@ import type { Config } from 'tailwindcss'; +import plugin from 'tailwindcss/plugin'; // BDS palette. The CSS variables (--bds--) 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;