diff --git a/app/public/brand/arbitrum.svg b/app/public/brand/arbitrum.svg new file mode 100644 index 00000000..00f30b9d --- /dev/null +++ b/app/public/brand/arbitrum.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/public/brand/optimism.svg b/app/public/brand/optimism.svg new file mode 100644 index 00000000..c809fd13 --- /dev/null +++ b/app/public/brand/optimism.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/app/bridge/BridgePageLayout.tsx b/app/src/app/bridge/BridgePageLayout.tsx new file mode 100644 index 00000000..782973b2 --- /dev/null +++ b/app/src/app/bridge/BridgePageLayout.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; +import { ArrowDown, ArrowLeftRight } from "lucide-react"; +import SectionIntro from "@/components/sections/SectionIntro"; +import shell from "@/components/sections/SectionShell.module.css"; +import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS } from "@/lib/bridge/types"; +import styles from "./bridge.module.css"; + +const STEPS = [ + ["Pick a route and an amount", "A live quote appears with the fee, the gas and the least you will receive. Nothing is asked of your wallet yet."], + ["Approve, for tokens only", "USDC and USDG need an approval for the exact amount first. It is its own wallet step and moves nothing. ETH skips it."], + ["Confirm the deposit", "Slide the coin across or press the button, then confirm in your wallet. This is the one step that sends funds."], + ["Watch it arrive", "The board follows the transfer until the provider confirms delivery to the same address on the other network."], +] as const; + +export default function BridgePageLayout({ panel, history, controls, introActions }: { + panel: ReactNode; + history: ReactNode; + controls?: ReactNode; + introActions?: ReactNode; +}) { + return ( +
+ } + title="Move funds to where you launch" + description="Send ETH and dollars between Base, Robinhood Chain, Arc and Elysium, or bring them in from Ethereum, Arbitrum and Optimism. They arrive at the same wallet address. openlaunch charges nothing for it." + > + Your transfers + {introActions} + + {controls} +
+ {panel} +
+
+

Networks and tokens

+
+ {BRIDGE_DESTINATION_IDS.map((id) =>
{BRIDGE_CHAINS[id].name}
{BRIDGE_ASSETS[id].join(", ")}Send and receive
)} + {BRIDGE_FUNDING_IDS.map((id) =>
{BRIDGE_CHAINS[id].name}
{BRIDGE_ASSETS[id].join(", ")}Send only
)} +
+
+
+

How a transfer goes

+
    + {STEPS.map(([title, body]) =>
  1. {title}{body}
  2. )} +
+
+
+

What it costs

+
    +
  • openlaunch takes no fee on a transfer.
  • +
  • The bridge provider charges a fee that is shown before you confirm and is already taken out of the amount you receive.
  • +
  • Relay quotes every route. For USDC to USDC into Base or Arc, Circle is quoted too, and you get whichever delivers more. The quote names which one.
  • +
  • Gas on the network you send from is extra.
  • +
  • A quote that would cost more than 5% in fees, or lose more than 5% in conversion, is blocked.
  • +
+
+
+

Good to know

+
    +
  • On Elysium the bridge carries USDC only. Its gas token, HYPE, is not bridged, so keep some HYPE there to use or send what arrives.
  • +
  • Funds go from your wallet to Relay's deposit contract, or to Circle's transfer contract on a Circle route. They never pass through an openlaunch wallet or contract.
  • +
  • Relay and Circle are third parties. openlaunch operates neither and cannot reverse a transfer.
  • +
  • A transfer is saved in this browser while it is in progress. Reopen the bridge with the same wallet to pick it up.
  • +
  • Finished transfers stay on the board below. It never asks your wallet for anything.
  • +
+
+
+
+ {history} +
+ ); +} diff --git a/app/src/app/bridge/page.tsx b/app/src/app/bridge/page.tsx index 1112c28c..61eac368 100644 --- a/app/src/app/bridge/page.tsx +++ b/app/src/app/bridge/page.tsx @@ -1,13 +1,9 @@ import type { Metadata } from "next"; -import { ArrowLeftRight } from "lucide-react"; import { pageMetadata } from "@/lib/seo"; -import SectionIntro from "@/components/sections/SectionIntro"; -import shell from "@/components/sections/SectionShell.module.css"; import BridgePageSlot from "@/components/bridge/BridgePageSlot"; import BridgeHistory from "@/components/bridge/BridgeHistory"; import { parseBridgeLink } from "@/lib/bridge/link"; -import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS } from "@/lib/bridge/types"; -import styles from "./bridge.module.css"; +import BridgePageLayout from "./BridgePageLayout"; /** * The bridge as a page: the same panel the header opens, with room for the facts beside it. The panel is rendered by @@ -17,64 +13,15 @@ import styles from "./bridge.module.css"; export const metadata: Metadata = pageMetadata({ path: "/bridge", title: "Bridge", - description: "Move ETH and dollars between Base, Robinhood Chain and Arc, or fund them from Ethereum, Arbitrum and Optimism. Same wallet, no openlaunch fee.", + description: "Move ETH and dollars between Base, Robinhood Chain, Arc and Elysium, or fund them from Ethereum, Arbitrum and Optimism. Same wallet, no openlaunch fee.", }); -const STEPS = [ - ["Pick a route and an amount", "A live quote appears with the fee, the gas and the least you will receive. Nothing is asked of your wallet yet."], - ["Approve, for tokens only", "USDC and USDG need an approval for the exact amount first. It is its own wallet step and moves nothing. ETH skips it."], - ["Confirm the deposit", "Slide the coin across or press the button, then confirm in your wallet. This is the one step that sends funds."], - ["Watch it arrive", "The board follows the transfer until the provider confirms delivery to the same address on the other network."], -] as const; - export default async function BridgePage({ searchParams }: { searchParams: Promise> }) { const link = parseBridgeLink(await searchParams); return ( -
- } - title="Move funds to where you launch" - description="Send ETH and dollars between Base, Robinhood Chain and Arc, or bring them in from Ethereum, Arbitrum and Optimism. They arrive at the same wallet address. openlaunch charges nothing for it." - /> -
- -
-
-

Networks and tokens

-
- {BRIDGE_DESTINATION_IDS.map((id) =>
{BRIDGE_CHAINS[id].name}
{BRIDGE_ASSETS[id].join(", ")}Send and receive
)} - {BRIDGE_FUNDING_IDS.map((id) =>
{BRIDGE_CHAINS[id].name}
{BRIDGE_ASSETS[id].join(", ")}Send only
)} -
-
-
-

How a transfer goes

-
    - {STEPS.map(([title, body]) =>
  1. {title}{body}
  2. )} -
-
-
-

What it costs

-
    -
  • openlaunch takes no fee on a transfer.
  • -
  • The bridge provider charges a fee that is shown before you confirm and is already taken out of the amount you receive.
  • -
  • Relay quotes every route. For USDC to USDC into Base or Arc, Circle is quoted too, and you get whichever delivers more. The quote names which one.
  • -
  • Gas on the network you send from is extra.
  • -
  • A quote that would cost more than 5% in fees, or lose more than 5% in conversion, is blocked.
  • -
-
-
-

Good to know

-
    -
  • Funds go from your wallet to Relay's deposit contract, or to Circle's transfer contract on a Circle route. They never pass through an openlaunch wallet or contract.
  • -
  • Relay and Circle are third parties. openlaunch operates neither and cannot reverse a transfer.
  • -
  • A transfer is saved in this browser while it is in progress. Reopen the bridge with the same wallet to pick it up.
  • -
  • Finished transfers stay on the board below. It never asks your wallet for anything.
  • -
-
-
-
- -
+ } + history={} + /> ); } diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index ca1faee8..03927d07 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -1,9 +1,11 @@ "use client"; import { useEffect, useState } from "react"; -import { Dialog } from "@base-ui/react/dialog"; -import { X } from "lucide-react"; -import { BridgeForm, Transfer } from "@/components/bridge/BridgeDialog"; +import Link from "next/link"; +import { Select } from "@base-ui/react/select"; +import { ArrowDown, ArrowUpRight, Check, ChevronDown, PanelsTopLeft } from "lucide-react"; +import BridgePageLayout from "@/app/bridge/BridgePageLayout"; +import { BridgeFooter, BridgeForm, BridgeModal, Transfer } from "@/components/bridge/BridgeDialog"; import type useBridge from "@/components/bridge/useBridge"; import { formatUnits, parseEther, parseUnits, zeroAddress } from "viem"; import { canReverseBridgeRoute, changeBridgeRoute, parseBridgeAmount, type BridgeRouteChange, type BridgeRouteInputs } from "@/lib/bridge/client"; @@ -11,19 +13,42 @@ import { bridgeCurrency, bridgeFeePercent, defaultBridgeAsset, type BridgeQuote import type { TrackedApproval } from "@/lib/bridge/approval"; import { describePendingApproval } from "@/lib/bridge/approval-health"; import { BRIDGE_QUOTE_DEBOUNCE_MS } from "@/lib/bridge/quote-session"; -import styles from "@/components/bridge/BridgeDialog.module.css"; +import signal from "@/components/bridge/Signal.module.css"; +import shell from "@/components/sections/SectionShell.module.css"; import { HistoryBoard } from "@/components/bridge/BridgeHistory"; import type { BridgeHistoryEntry } from "@/lib/bridge/history"; import { FundCard } from "@/components/bridge/BridgeToBuy"; import type { BridgeHolding, FundStep } from "@/lib/bridge/fund"; import type { BridgeAsset, BridgeDestinationId } from "@/lib/bridge/types"; import { CCTP_TOKEN_MESSENGER, cctpRoute } from "@/lib/bridge/cctp"; +import styles from "./review.module.css"; const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const; const RELAY = "0x4cd00e387622c35bddb9b4c962c136462338bc31" as const; const requestId = `0x${"1".repeat(64)}` as const; -type Scene = "idle" | "disconnected" | "quote" | "quoting" | "high_fee" | "fee_boundary" | "impact_limit" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed" | "approval_queued" | "approval_missing" | "approval_fee"; -const scenes: Scene[] = ["disconnected", "quote", "quoting", "high_fee", "fee_boundary", "impact_limit", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed", "approval_queued", "approval_missing", "approval_fee"]; +const SCENE_LABELS = { + disconnected: "Wallet disconnected", + idle: "Enter an amount", + quoting: "Fetching a quote", + quote: "Review a quote", + high_fee: "High bridge fee", + fee_boundary: "Fee just over the limit", + impact_limit: "Conversion loss over the limit", + expired: "Quote expired", + error: "Quote unavailable", + approval_pending: "Approval submitted", + approval_uncertain: "Approval status unknown", + approval_confirmed: "Approval confirmed", + approval_queued: "Approval queued", + approval_missing: "Approval missing", + approval_fee: "Approval gas fee too low", + pending: "Transfer in progress", + success: "Transfer arrived", + uncertain: "Transfer status unknown", + refund: "Transfer refunded", +} as const; +type Scene = keyof typeof SCENE_LABELS; +const DEFAULT_ROUTE: BridgeRouteInputs = { originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }; const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as const; const minutesAgo = (minutes: number) => Date.UTC(2026, 10, 10, 12, 0) - minutes * 60_000; @@ -34,12 +59,14 @@ const HISTORY_ROWS: BridgeHistoryEntry[] = [ { requestId: hash(31), originChainId: 8453, destinationChainId: 4663, originAsset: "USDC", destinationAsset: "USDG", amountIn: "150000000", inDecimals: 6, amountOut: "149810000", outDecimals: 6, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 5), sourceHash: hash(32), destinationHashes: [hash(33)], recipient: null }, { requestId: hash(41), originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amountIn: "60000000", inDecimals: 6, amountOut: "59940000000000000000", outDecimals: 18, outKind: "quoted", status: "delayed", reverted: false, createdAt: minutesAgo(60 * 9), sourceHash: hash(42), destinationHashes: [], recipient: null }, { requestId: hash(51), originChainId: 1, destinationChainId: 5042, originAsset: "ETH", destinationAsset: "USDC", amountIn: "50000000000000000", inDecimals: 18, amountOut: "155300000000000000000", outDecimals: 18, outKind: "quoted", status: "refund", reverted: false, createdAt: minutesAgo(60 * 26), sourceHash: hash(52), destinationHashes: [], recipient: null }, + { requestId: hash(91), originChainId: 99901, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amountIn: "25000000", inDecimals: 6, amountOut: "24664192", outDecimals: 6, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 30), sourceHash: hash(92), destinationHashes: [hash(93)], recipient: null }, { requestId: hash(61), originChainId: 10, destinationChainId: 8453, originAsset: "ETH", destinationAsset: "ETH", amountIn: "8000000000000000", inDecimals: 18, amountOut: null, outDecimals: 18, outKind: null, status: "failure", reverted: true, createdAt: minutesAgo(60 * 50), sourceHash: hash(62), destinationHashes: [], recipient: null }, { requestId: hash(71), originChainId: 4663, destinationChainId: 8453, originAsset: "ETH", destinationAsset: "ETH", amountIn: "4000000000000000", inDecimals: 18, amountOut: "3991000000000000", outDecimals: 18, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 80), sourceHash: hash(72), destinationHashes: [hash(73)], recipient: "0x9f3a2bE0c1D4e5F6a7b8C9d0E1f2A3b4C5d6E7F8" }, { provider: "cctp", requestId: hash(81), originChainId: 42161, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amountIn: "40000000", inDecimals: 6, amountOut: "39983361000000000000", outDecimals: 18, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 96), sourceHash: hash(82), destinationHashes: [hash(83)], recipient: null }, ]; -type HistoryScene = "rows" | "empty" | "loading" | "no wallet" | "unsaved"; -const HISTORY_SCENES: HistoryScene[] = ["rows", "empty", "loading", "no wallet", "unsaved"]; +const HISTORY_LABELS = { rows: "Sample transfers", empty: "No transfers yet", loading: "Loading", "no wallet": "Wallet disconnected", unsaved: "Storage unavailable" } as const; +type HistoryScene = keyof typeof HISTORY_LABELS; +const PROVIDER_LABELS = { relay: "Relay", circle: "Circle CCTP" } as const; // "Bridge, then buy" in the trade panel, one card per step it can show. type FundScene = { name: string; step: FundStep; destination: BridgeDestinationId; asset: BridgeAsset; symbol: string; row: BridgeHistoryEntry | null; shortBy: string | null; suggestion: BridgeHolding | null; searching: boolean }; @@ -51,13 +78,42 @@ const FUND_SCENES: FundScene[] = [ { name: "delayed", step: "moving", destination: 5042, asset: "USDC", symbol: "ARCO", row: HISTORY_ROWS[3], shortBy: null, suggestion: null, searching: false }, { name: "arrived", step: "arrived", destination: 8453, asset: "USDC", symbol: "BASED", row: HISTORY_ROWS[1], shortBy: null, suggestion: null, searching: false }, { name: "refunded", step: "attention", destination: 5042, asset: "USDC", symbol: "ARCO", row: HISTORY_ROWS[4], shortBy: null, suggestion: null, searching: false }, + { name: "Elysium, source found", step: "short", destination: 99901, asset: "USDC", symbol: "KINO", row: null, shortBy: "18.5 USDC", suggestion: { chainId: 42161, asset: "USDC", amount: 64_200_000n, decimals: 6 }, searching: false }, ]; +function ReviewSelect({ label, options, value, onChange }: { label: string; options: Record; value: Value; onChange: (value: Value) => void }) { + const items = (Object.keys(options) as Value[]).map((key) => ({ value: key, label: options[key] })); + return ( + { if (next !== null) onChange(next); }}> +
+ {label} + + + +
+ + + + + {items.map((item) => ( + + {item.label} + + + ))} + + + + +
+ ); +} + export default function BridgeReview() { const [historyScene, setHistoryScene] = useState("rows"); const [open, setOpen] = useState(false); const [scene, setScene] = useState("disconnected"); - const [route, setRoute] = useState({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }); + const [route, setRoute] = useState(DEFAULT_ROUTE); const [approved, setApproved] = useState(false); // Which provider the synthetic quote comes from. Circle only carries USDC to USDC into Base or Arc. const [circle, setCircle] = useState(false); @@ -74,13 +130,16 @@ export default function BridgeReview() { // Synthetic fixed conversion, not a market quote. This page never calls Relay. const inputAmount = parseBridgeAmount(amount, inputCurrency.decimals) ?? 0n; useEffect(() => { - if (!open || (scene !== "idle" && scene !== "approval_confirmed") || !inputAmount) return; + if ((scene !== "idle" && scene !== "approval_confirmed") || !inputAmount) return; const timer = setTimeout(() => setScene("quote"), BRIDGE_QUOTE_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [open, scene, inputAmount, amount, origin, destination, originAsset, destinationAsset]); + }, [scene, inputAmount, amount, origin, destination, originAsset, destinationAsset]); const normalizedInput = inputAmount * 10n ** BigInt(18 - inputCurrency.decimals); const netAmount = normalizedInput * 9975n / 10000n; - const converted = originAsset === destinationAsset ? netAmount : originAsset === "USDC" ? netAmount / 2400n : netAmount * 2400n; + const converted = originAsset === destinationAsset ? netAmount + : originAsset === "ETH" ? netAmount * 2400n + : destinationAsset === "ETH" ? netAmount / 2400n + : netAmount; // Circle: one synthetic fixed fee (the size of the live Fast plus forward fee), delivered exactly const circleFee = destination === 5042 ? 16_577n : 54_447n; const outputAmount = viaCircle ? (inputAmount > circleFee ? inputAmount - circleFee : 0n) * 10n ** BigInt(outputCurrency.decimals - 6) : converted / 10n ** BigInt(18 - outputCurrency.decimals); @@ -91,7 +150,7 @@ export default function BridgeReview() { transaction: { to: wallet, data: "0x", value: "0", chainId: origin }, // deliberately non-executable fixture ...(erc20Input ? { approval: { token: inputCurrency.address, spender: viaCircle ? CCTP_TOKEN_MESSENGER : RELAY, amount: inputAmount.toString() } } : {}), }; - const approval: TrackedApproval | null = erc20Input && (origin === 5042 || origin === 8453) && (scene.startsWith("approval_") || approved) ? { version: 1, chainId: origin, address: wallet, token: inputCurrency.address, spender: viaCircle ? CCTP_TOKEN_MESSENGER.toLowerCase() as typeof RELAY : RELAY, amount: inputAmount.toString(), createdAt: clock, status: scene === "approval_uncertain" ? "uncertain" : scene === "approval_confirmed" || approved ? "confirmed" : "pending", ...(scene === "approval_uncertain" ? {} : { approvalHash: requestId }) } : null; + const approval: TrackedApproval | null = erc20Input && (origin === 5042 || origin === 8453 || origin === 99901) && (scene.startsWith("approval_") || approved) ? { version: 1, chainId: origin, address: wallet, token: inputCurrency.address, spender: viaCircle ? CCTP_TOKEN_MESSENGER.toLowerCase() as typeof RELAY : RELAY, amount: inputAmount.toString(), createdAt: clock, status: scene === "approval_uncertain" ? "uncertain" : scene === "approval_confirmed" || approved ? "confirmed" : "pending", ...(scene === "approval_uncertain" ? {} : { approvalHash: requestId }) } : null; const tracking = ["pending", "success", "uncertain", "refund"].includes(scene); const feeWarning = ["high_fee", "fee_boundary", "impact_limit"].includes(scene); const rejectedFee = scene === "fee_boundary" ? inputAmount / 20n + 1n : scene === "impact_limit" ? inputAmount / 400n : inputAmount * 55548n / 1_000_000n; @@ -99,7 +158,7 @@ export default function BridgeReview() { address: scene === "disconnected" ? undefined : wallet, walletChainId: origin, originChainId: origin, destinationChainId: destination, setOriginChainId: (chainId) => changeRoute({ side: "origin", chainId }), setDestinationChainId: (chainId) => changeRoute({ side: "destination", chainId }), reverseRoute: () => changeRoute({ side: "reverse" }), canReverse: canReverseBridgeRoute({ originChainId: origin }), amount, setAmount, originAsset, destinationAsset, setOriginAsset: (asset) => changeRoute({ side: "origin-asset", asset }), setDestinationAsset: (asset) => changeRoute({ side: "destination-asset", asset }), - balance: parseUnits(originAsset === "USDC" ? "125" : "0.05", inputCurrency.decimals), nativeBalance: parseEther(origin === 5042 ? "125" : "0.05"), balanceLoading: false, balanceError: null, + balance: parseUnits(originAsset === "ETH" ? "0.05" : "125", inputCurrency.decimals), nativeBalance: parseEther(origin === 5042 ? "125" : "0.05"), balanceLoading: false, balanceError: null, quote: scene === "quote" || scene === "expired" ? quote : null, phase: tracking ? scene as "pending" | "success" | "uncertain" | "refund" : scene === "quote" || scene === "expired" ? "review" : "idle", error: null, quoteError: scene === "error" ? "Relay is temporarily unavailable. Try requesting a quote again." : feeWarning ? "This quote exceeds the 5% safety limit." : null, @@ -115,32 +174,82 @@ export default function BridgeReview() { approvalCanBeDiscarded: scene === "approval_uncertain", discardApproval: async () => { setApproved(false); setScene("idle"); }, canDiscard: scene === "uncertain", discard: async () => setScene("idle"), discarding: false, }; + + function selectScene(value: Scene) { + if (value === "idle") { + setRoute((current) => ({ ...current, amount: "" })); + } else if (value.startsWith("approval_") && (!erc20Input || (origin !== 5042 && origin !== 8453 && origin !== 99901))) { + setRoute(DEFAULT_ROUTE); + } else if (!inputAmount) { + setRoute((current) => ({ ...current, amount: originAsset === "ETH" ? "0.01" : "25" })); + } + setApproved(value === "approval_confirmed"); + setScene(value); + } + + function selectProvider(value: keyof typeof PROVIDER_LABELS) { + const useCircle = value === "circle"; + if (useCircle && !cctpRoute({ ...route, originAsset, destinationAsset })) setRoute(DEFAULT_ROUTE); + setCircle(useCircle); + setApproved(false); + } + + const content = tracking ? : selectScene("quote")} idPrefix="bridge-preview" />; + const footerUsesCircle = (bridge.tracked?.provider ?? bridge.quote?.provider) === "cctp"; return ( <> -
-

Bridge visual review

-

Development-only synthetic data. No wallet requests, API calls, or funds.

-

Base offers ETH and USDC. Arc uses USDC. Robinhood offers ETH; its USDC routes currently fail our safety checks. USDC sends use an exact-amount approval.

- -
{scenes.map((value) => )}
- - -
Bridge
- Preview only · {scene} · no funds move - {tracking ? : setScene("quote")} />} -
{viaCircle ? "Powered by Circle CCTP" : "Powered by Relay"}0 Openlaunch fee
-
-
-
{HISTORY_SCENES.map((value) => )}
-
-
- {}} /> -

Bridge, then buy

-

The trade panel card, at trade panel width. Buttons do nothing here.

-
- {FUND_SCENES.map(({ name, ...scene }) =>
{name}
{}} onOpen={() => {}} onDismiss={() => {}} />
)} -
-
+ + Trade panel + Live bridge + } + controls={ +
+
+

Development preview

+

Sample data. No wallet requests, API calls, or funds.

+
+
+ + + +
+
+ } + panel={ +
+ {open ?

The bridge preview is open in a dialog.

: content} + +
+ } + history={<> +
+ + {}} /> +
+
+

Bridge, then buy

+

Preview how the funding card looks inside the token trade panel.

+
+ {FUND_SCENES.map(({ name, ...fixture }) => ( +
+
{name}
+ {}} onOpen={() => {}} onDismiss={() => {}} /> +
+ ))} +
+
+ } + /> + setOpen(false)} />} + > + {open ? content : null} + ); } diff --git a/app/src/app/ui-review-bridge/page.tsx b/app/src/app/ui-review-bridge/page.tsx index 631c9a80..7ab8f5b0 100644 --- a/app/src/app/ui-review-bridge/page.tsx +++ b/app/src/app/ui-review-bridge/page.tsx @@ -1,7 +1,9 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; import BridgeReview from "./BridgeReview"; export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Bridge review" }; /** Local visual fixture. Never exposes a mock wallet or transfer on production. */ export default function BridgeReviewPage() { diff --git a/app/src/app/ui-review-bridge/review.module.css b/app/src/app/ui-review-bridge/review.module.css new file mode 100644 index 00000000..d1394667 --- /dev/null +++ b/app/src/app/ui-review-bridge/review.module.css @@ -0,0 +1,78 @@ +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 16px 24px; + margin-bottom: 24px; + padding: 16px 18px; + border: 1px solid var(--color-line); + border-radius: 16px; + background: var(--color-card); +} +.toolbar h2, .trade h2 { margin: 0; color: var(--color-ink); font-weight: 650; } +.toolbar h2 { font-size: 14px; } +.toolbarCopy p, .trade > p { margin: 4px 0 0; color: var(--color-muted); font-size: 13px; line-height: 1.5; } +.toolbarActions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px; min-width: 0; } +.field { display: grid; min-width: 0; gap: 4px; } +.label { color: var(--color-muted); font-size: 12px; font-weight: 500; } +.select { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 44px; + padding: 0 12px; + border: 1px solid var(--color-line); + border-radius: 10px; + background: var(--color-paper); + color: var(--color-ink); + font-size: 13px; + font-weight: 550; + text-align: left; + cursor: pointer; +} +.select:hover, .select[data-popup-open] { border-color: var(--color-line-strong); } +.select svg { flex-shrink: 0; color: var(--color-muted); } +.positioner { z-index: 90; } +.menu { + max-width: var(--available-width); + min-width: var(--anchor-width); + max-height: var(--available-height); + overflow-y: auto; + overscroll-behavior: contain; + padding: 5px; + border: 1px solid var(--color-line-strong); + border-radius: 12px; + background: var(--color-raised); + box-shadow: var(--shadow-card-hover); +} +.option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-height: 36px; + padding: 7px 9px; + border-radius: 7px; + color: var(--color-body); + font-size: 13px; + cursor: pointer; +} +.option[data-highlighted] { background: var(--color-brand-soft); color: var(--color-ink); } +.option svg { flex-shrink: 0; color: var(--color-brand); } +.dialogNotice { min-height: 320px; margin: 0; color: var(--color-muted); font-size: 13px; } +.historyPreview { display: grid; justify-items: stretch; gap: 14px; margin-top: 64px; } +.historyPreview > .field { justify-self: end; } +.historyPreview > section { margin-top: 0; scroll-margin-top: 180px; } +.trade { margin-top: 56px; scroll-margin-top: 104px; } +.trade h2 { font-size: 17px; } +.tradeGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: 24px; margin-top: 18px; } +.tradeGrid figure { display: grid; min-width: 0; align-content: start; gap: 8px; margin: 0; } +.tradeGrid figcaption { color: var(--color-muted); font-size: 12px; } +@media (max-width: 600px) { + .toolbar { padding: 14px; } + .toolbarActions { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .toolbarActions > button { width: 100%; padding-inline: 10px; } + .toolbarActions .field:first-child { grid-column: 1 / -1; } +} diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index aabd1476..21fc5aa3 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -6,20 +6,68 @@ .triggerHint { margin-left: auto; font-size: 12px; color: var(--color-muted); } .backdrop { position: fixed; inset: 0; z-index: 80; background: color-mix(in srgb, var(--color-scrim) 45%, transparent); opacity: 1; transition: opacity 180ms ease-out; } .backdrop[data-starting-style], .backdrop[data-ending-style] { opacity: 0; } -.panel { position: fixed; z-index: 81; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(480px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; padding: 26px 28px 20px; border: 1px solid var(--color-line); border-radius: 24px; background: var(--color-raised); color: var(--color-ink); box-shadow: var(--shadow-dialog); outline: none; scrollbar-width: thin; scrollbar-color: var(--color-line-strong) transparent; transition: opacity 180ms ease-out, transform 220ms cubic-bezier(.16, 1, .3, 1); } +.panel { + --dialog-gutter: 22px; + position: fixed; + z-index: 81; + top: 50%; + left: 50%; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(496px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + overflow: hidden; + border: 1px solid var(--color-line); + border-radius: 24px; + background: var(--color-raised); + color: var(--color-ink); + box-shadow: var(--shadow-dialog); + outline: none; + transform: translate(-50%, -50%); + transition: opacity 180ms ease-out, transform 220ms cubic-bezier(.16, 1, .3, 1); +} .panel[data-starting-style], .panel[data-ending-style] { opacity: 0; transform: translate(-50%, calc(-50% + 8px)) scale(.98); } .panel ::selection { background: var(--color-brand-soft); color: var(--color-brand); } -.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.title { margin: 0; font-size: 24px; line-height: 1.25; letter-spacing: -.035em; font-weight: 650; } -.close { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; margin: -4px -8px -4px 0; border-radius: 50%; color: var(--color-muted); cursor: pointer; } -.description { margin: 6px 0 26px; color: var(--color-muted); font-size: 14px; line-height: 1.5; } -.route { display: grid; grid-template-columns: minmax(0, 1fr) 36px minmax(0, 1fr); align-items: center; gap: 8px; padding-bottom: 20px; border-bottom: 1px solid var(--color-line); } +.title { margin: 0; font-size: 19px; line-height: 1.2; letter-spacing: -.025em; font-weight: 650; } +.close { display: inline-flex; flex: 0 0 36px; align-items: center; justify-content: center; height: 36px; margin-right: -4px; border-radius: 50%; color: var(--color-muted); cursor: pointer; } +.description { margin: 3px 0 0; color: var(--color-muted); font-size: 12px; line-height: 1.5; } +.dialogHeader { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; + padding: 14px var(--dialog-gutter); + border-bottom: 1px solid var(--color-line); +} +.headerMark { + display: grid; + flex: 0 0 38px; + height: 38px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-brand) 15%, var(--color-line)); + border-radius: 11px; + background: linear-gradient(155deg, var(--color-card), var(--color-brand-soft)); + color: var(--color-brand); +} +.headingCopy { flex: 1; min-width: 0; } +.dialogBody { + min-height: 0; + min-width: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + padding: 14px var(--dialog-gutter) 12px; + scroll-padding-block: 12px; + scrollbar-width: thin; + scrollbar-color: var(--color-line-strong) transparent; +} +.dialogBody:has(.formActions[data-pin]) { scroll-padding-bottom: 66px; } +.route { display: grid; grid-template-columns: minmax(0, 1fr) 34px minmax(0, 1fr); align-items: end; gap: 8px; } .network { min-width: 0; } .smallLabel { display: block; font-size: 12px; font-weight: 500; color: var(--color-muted); line-height: 1.5; } -.networkTrigger { display: grid; grid-template-columns: 30px minmax(0, 1fr) 13px; align-items: center; gap: 8px; width: calc(100% + 16px); min-height: 58px; margin: 5px -8px 0; padding: 8px 7px; border: 1px solid transparent; border-radius: 12px; color: var(--color-ink); text-align: left; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } -.networkTrigger[data-popup-open] { background: var(--color-paper); border-color: var(--color-line-strong); } +.networkTrigger { display: grid; grid-template-columns: 30px minmax(0, 1fr) 13px; align-items: center; gap: 8px; width: 100%; min-height: 52px; margin-top: 4px; padding: 6px 9px; border: 1px solid var(--color-line); border-radius: 12px; background: var(--color-paper); color: var(--color-ink); text-align: left; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } +.networkTrigger[data-popup-open] { background: var(--color-card); border-color: var(--color-line-strong); } .networkTrigger[data-disabled] { cursor: not-allowed; opacity: .55; } -.networkCopy { display: grid; min-width: 0; gap: 3px; } +.networkCopy { display: grid; min-width: 0; gap: 2px; } .networkName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; line-height: 1.4; font-weight: 600; } .networkChevron { display: inline-flex; color: var(--color-muted); } .networkTrigger[data-popup-open] .networkChevron { transform: rotate(180deg); } @@ -32,7 +80,6 @@ .networkMenuTitle { margin: 0; padding: 8px 10px 10px; color: var(--color-body); font-size: 12px; font-weight: 500; } .networkList { display: grid; gap: 3px; } .networkGroupLabel { margin: 0; padding: 12px 10px 6px; border-top: 1px solid var(--color-line); color: var(--color-muted); font-size: 12px; font-weight: 500; } -.letterMark { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--mark-fill); box-shadow: 0 0 0 1.5px var(--mark-ring); color: #fff; font-size: 9px; font-weight: 700; letter-spacing: .02em; } .walletHint { display: block; width: 100%; margin-top: 12px; padding: 9px 12px; border: 1px dashed var(--color-line-strong); border-radius: 10px; color: var(--color-body); font-size: 12px; text-align: left; cursor: pointer; transition: color 160ms, background 160ms; } .walletHint:not(:disabled):hover { color: var(--color-ink); background: var(--color-paper); } .walletHint:disabled { cursor: default; opacity: .6; } @@ -47,28 +94,55 @@ .networkMark { display: inline-flex; flex: 0 0 30px; align-items: center; justify-content: center; height: 30px; width: 30px; } .networkMark img { display: block; flex-shrink: 0; object-fit: contain; } .arcMark { background: var(--color-scrim); border-radius: 7px; } +.arbitrumMark { background: #f1f5ff; border-radius: 7px; } +.elysiumMark img { border-radius: 7px; } .networkMark .darkLogo { display: none; } :global(.dark) .networkMark .lightLogo { display: none; } :global(.dark) .networkMark .darkLogo { display: block; } -.reverse { display: inline-flex; align-items: center; justify-content: center; height: 36px; width: 36px; margin-top: 4px; color: var(--color-muted); border: 1px solid var(--color-line); border-radius: 50%; cursor: pointer; transition: transform 180ms cubic-bezier(.16, 1, .3, 1), background 160ms, color 160ms; } -.amountSection { padding: 23px 0 20px; } -.amountRow { display: flex; align-items: center; gap: 16px; margin-top: 4px; } -.amountRow input { display: block; min-width: 0; width: 100%; border: 0; border-radius: 4px; background: transparent; color: var(--color-ink); caret-color: var(--color-brand); font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 40px; line-height: 1.3; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 800; } +.reverse { display: inline-flex; align-items: center; justify-content: center; height: 34px; width: 34px; margin-bottom: 9px; color: var(--color-muted); border: 1px solid var(--color-line); border-radius: 50%; cursor: pointer; transition: transform 180ms cubic-bezier(.16, 1, .3, 1), background 160ms, color 160ms; } +.amountStack { + min-width: 0; + margin-top: 12px; + border: 1px solid var(--color-line); + border-radius: 15px; + background: var(--color-paper); +} +.amountSection { padding: 12px 13px 10px; } +.amountRow { display: flex; align-items: center; gap: 10px; margin-top: 3px; } +.amountRow input { display: block; min-width: 0; width: 100%; border: 0; border-radius: 4px; background: transparent; color: var(--color-ink); caret-color: var(--color-brand); font-family: var(--font-mono); font-size: 36px; line-height: 1.2; letter-spacing: -.05em; font-variant-numeric: tabular-nums; font-weight: 600; } .amountRow input::placeholder { color: var(--color-muted); } .currency { display: inline-flex; flex-shrink: 0; align-items: center; gap: 5px; font-size: 18px; font-weight: 550; } -.assetTrigger { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; gap: 7px; min-height: 44px; padding: 6px 9px; border: 1px solid var(--color-line-strong); border-radius: 12px; background: var(--color-paper); color: var(--color-ink); font-size: 16px; font-weight: 550; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } +.fixedAsset { display: grid; flex-shrink: 0; justify-items: end; gap: 3px; } +.assetRequirement { color: var(--color-body); font-size: 12px; line-height: 1.5; } +.assetTrigger { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; gap: 6px; min-height: 40px; padding: 5px 8px; border: 1px solid var(--color-line-strong); border-radius: 10px; background: var(--color-card); color: var(--color-ink); font-size: 14px; font-weight: 600; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } .assetTrigger[data-popup-open] { border-color: var(--color-brand); } .assetTrigger[data-popup-open] .networkChevron { transform: rotate(180deg); } .assetTrigger:not(:disabled):active:not(:focus-visible) { transform: scale(.98); } .assetTrigger:focus-visible { transition: none; } -.destinationAsset { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 64px; padding-block: 10px; border-top: 1px solid var(--color-line); } +.destinationAsset { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 52px; padding: 6px 13px; border-top: 1px solid var(--color-line); } .destinationAsset .currency { font-size: 16px; } -.routeNotice { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.6; } -.balance { margin-top: 8px; color: var(--color-muted); font-size: 12px; font-variant-numeric: tabular-nums; } -.previewNote { display: flex; align-items: flex-start; gap: 10px; padding: 17px 0; border-top: 1px solid var(--color-line); color: var(--color-body); font-size: 12px; line-height: 1.65; } -.previewNote svg { flex-shrink: 0; margin-top: 2px; color: var(--color-brand); } -.previewNote span { color: var(--color-muted); } -.feeLimit { padding-top: 20px; border-top: 1px solid var(--color-line); font-size: 12px; font-weight: 400; line-height: 1.5; } +.receiveCopy { min-width: 0; } +.quoteHint { display: flex; align-items: flex-start; gap: 5px; color: var(--color-body); font-size: 11px; line-height: 1.4; } +.quoteHint:not(:empty) { margin-top: 2px; } +.quoteHint svg { flex-shrink: 0; margin-top: 2px; color: var(--color-brand); } +.routeAvailability { margin-top: 6px; color: var(--color-muted); font-size: 12px; line-height: 1.5; } +.routeAvailability summary { + display: flex; + align-items: center; + gap: 6px; + min-height: 30px; + border-radius: 6px; + color: var(--color-body); + list-style: none; + cursor: pointer; +} +.routeAvailability summary::-webkit-details-marker { display: none; } +.routeAvailability summary svg { flex-shrink: 0; color: var(--color-muted); } +.routeAvailability summary svg:last-child { margin-left: auto; } +.routeAvailability[open] summary svg:last-child { transform: rotate(180deg); } +.routeAvailability p { margin: 2px 0 5px; overflow-wrap: anywhere; } +.balance { margin-top: 5px; color: var(--color-muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.feeLimit { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); font-size: 12px; font-weight: 400; line-height: 1.5; } .feeLimitHeading { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: start; gap: 10px; } .feeLimitIcon { margin-top: 3px; color: var(--color-down-ink); } .feeLimitCopy h3 { margin: 0; color: var(--color-ink); font-size: 14px; font-weight: 600; line-height: 1.5; text-wrap: balance; } @@ -90,10 +164,10 @@ .feeLimitReason[open] summary svg { transform: rotate(180deg); } .feeLimitReason p { margin: 0 0 12px; color: var(--color-body); font-size: 12px; font-weight: 400; line-height: 1.65; overflow-wrap: anywhere; } .feeLimit .feeLimitHint { margin: 0; color: var(--color-muted); font-size: 11px; font-weight: 400; line-height: 1.65; text-wrap: pretty; } -.quote { padding: 18px 0 0; border-top: 1px solid var(--color-line); } +.quote { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); } .receiveLabel { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 12px; color: var(--color-body); } .estimate { font-size: 11px; color: var(--color-muted); } -.receiveAmount { margin: 6px 0 18px; font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 30px; font-weight: 800; line-height: 1.3; letter-spacing: 0; font-variant-numeric: tabular-nums; } +.receiveAmount { margin: 6px 0 16px; font-family: var(--font-mono); font-size: 30px; font-weight: 600; line-height: 1.3; letter-spacing: -.04em; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .receiveAmount span { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; vertical-align: middle; font-family: var(--font-sans); font-size: 16px; color: var(--color-muted); font-weight: 500; } .fees { display: grid; gap: 9px; font-size: 12px; } .fees > div, .transferDetails > div { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } @@ -113,15 +187,102 @@ .recipient[open] summary > svg { transform: rotate(180deg); } .fullAddress { display: block; padding: 0 0 16px; color: var(--color-ink); font-family: var(--font-mono); font-size: 11px; line-height: 1.7; overflow-wrap: anywhere; user-select: all; } .transfer .recipient { margin: 0; border-top: 0; border-bottom: 1px solid var(--color-line); } +.safety { margin-top: 5px; color: var(--color-body); font-size: 12px; line-height: 1.5; } +.safety summary { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 4px 12px; + min-height: 40px; + padding-block: 6px; + border-radius: 6px; + list-style: none; + cursor: pointer; +} +.safety summary::-webkit-details-marker { display: none; } +.gasLabel, .safetyToggle { display: inline-flex; align-items: center; gap: 6px; } +.gasLabel svg, .safetyToggle { flex-shrink: 0; color: var(--color-muted); } +.safetyToggle { margin-left: auto; font-weight: 500; } +.safety[open] .safetyToggle svg { transform: rotate(180deg); } +.safetyCopy { display: grid; gap: 6px; padding: 2px 0 6px 11px; border-left: 1px solid var(--color-line-strong); } +.safetyCopy p { margin: 0; } +.formActions { margin-top: 8px; } +/* Keep the entry action in reach. Review and confirmation remain after the quote details. */ +.dialogBody .formActions[data-pin] { + position: sticky; + z-index: 1; + bottom: -12px; + margin-inline: calc(-1 * var(--dialog-gutter)); + padding: 6px var(--dialog-gutter) 14px; + background: var(--color-raised); +} .primary { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; width: 100%; padding: 12px 16px; border: 1px solid transparent; border-radius: 12px; background: var(--color-brand); color: var(--color-inverse); font-size: 14px; font-weight: 600; cursor: pointer; transition: background 160ms, transform 160ms cubic-bezier(.16, 1, .3, 1); } +.primary[data-connect] { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 18px; + min-height: 52px; + padding: 8px 12px 8px 10px; + text-align: left; +} +.primary[data-connect]:not(:disabled) { + border-color: color-mix(in srgb, var(--color-brand-strong) 72%, var(--color-brand)); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 19%, transparent), 0 2px 4px color-mix(in srgb, var(--color-brand) 15%, transparent); +} +.actionMark { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 7px; + background: color-mix(in srgb, currentColor 13%, transparent); +} +.actionArrow { justify-self: end; opacity: .84; } .panel button:disabled { opacity: .55; cursor: not-allowed; } .primary:disabled { opacity: 1; background: var(--color-paper); border-color: var(--color-line); color: var(--color-muted); } /* beside the slide-to-confirm rail the ordinary button steps back, but stays a full-size, plainly labelled control */ .secondary { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 44px; width: 100%; margin-top: 8px; padding: 10px 16px; border: 1px solid var(--color-line-strong); border-radius: 12px; background: transparent; color: var(--color-ink); font-size: 13px; font-weight: 600; cursor: pointer; transition: background 160ms ease, transform 160ms cubic-bezier(.16, 1, .3, 1); } .secondary:disabled { color: var(--color-muted); cursor: default; } .disclaimer { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.65; } -.footer { display: flex; justify-content: space-between; gap: 12px; padding-top: 16px; margin-top: 20px; border-top: 1px solid var(--color-line); font-size: 11px; color: var(--color-muted); } -.footer a, .explorerLinks a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } +.footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 2px 10px; + margin-top: 16px; + padding-top: 6px; + border-top: 1px solid var(--color-line); + font-size: 12px; + line-height: 1.4; +} +.panel > .footer { + margin: 0; + padding: 4px var(--dialog-gutter); + background: var(--color-card); +} +.footerMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 0 9px; } +.providerLink, .historyLink { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 40px; + border-radius: 7px; + color: var(--color-body); + font-weight: 500; + text-underline-offset: 3px; +} +.providerLink svg, .historyLink svg { flex-shrink: 0; color: var(--color-muted); } +.feeLabel { color: var(--color-muted); font-size: 11px; } +.feeLabel strong { color: var(--color-body); font-weight: 600; } +.historyLink { + margin-left: auto; + padding-inline: 9px; + margin-inline-end: -9px; + color: var(--color-ink); + transition: background 160ms ease; +} +.explorerLinks a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } .error { display: flex; align-items: flex-start; gap: 8px; min-width: 0; margin: 12px 0; padding: 12px; color: var(--color-down-ink); background: var(--color-down-soft); border-radius: 8px; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } .error > span { flex: 1; min-width: 0; } .error > svg { flex-shrink: 0; margin-top: 2px; } @@ -149,21 +310,43 @@ .spinner { animation: bridge-spin 1s linear infinite; } @keyframes bridge-spin { to { transform: rotate(360deg); } } .trigger:focus-visible, .panel :is(button, a, input, select, summary):focus-visible { outline: 2px solid var(--color-brand); outline-offset: 4px; } +.safety summary:focus-visible, .footer a:focus-visible { outline: 2px solid var(--color-brand); outline-offset: -2px; } @media (hover: hover) and (pointer: fine) { - .networkTrigger:not(:disabled):hover { background: var(--color-paper); border-color: var(--color-line); } + .networkTrigger:not(:disabled):hover { background: var(--color-card); border-color: var(--color-line-strong); } .assetTrigger:not(:disabled):hover { border-color: var(--color-brand); } .close:hover, .reverse:hover, .externalAction:hover { color: var(--color-ink); background: var(--color-paper); } .trigger:hover { color: var(--color-ink); background: var(--color-line); } .primary:not(:disabled):hover { background: var(--color-brand-strong); } - .footer a:hover, .explorerLinks a:hover { text-decoration: underline; } + .safety summary:hover .safetyToggle, .providerLink:hover, .explorerLinks a:hover { text-decoration: underline; } + .historyLink:hover { background: var(--color-paper); } } .primary:not(:disabled):active, .reverse:not(:disabled):active, .trigger:active { transform: scale(.97); } +.primary[data-connect]:not(:disabled):active { transform: scale(.985); } .networkTrigger:not(:disabled):active:not(:focus-visible) { transform: scale(.98); } .networkTrigger:focus-visible { transition: none; } +@media (min-width: 521px) and (max-height: 700px) { + .panel { max-height: calc(100dvh - 20px); } + .dialogHeader { padding-block: 10px; } + .dialogBody { padding-top: 8px; } + .dialogBody .routeAvailability { margin-top: 2px; } + .dialogBody .amountStack { margin-top: 8px; } + .dialogBody .amountSection { padding-block: 9px 8px; } + .dialogBody .safety { margin-top: 2px; } + .dialogBody .safety summary { min-height: 36px; } + .panel > .footer { padding-block: 2px; } +} @media (max-width: 520px) { - .panel { width: 100%; top: auto; bottom: 0; left: 0; max-height: calc(100dvh - 16px); transform: none; padding: 24px 22px calc(20px + env(safe-area-inset-bottom)); border-radius: 22px 22px 0 0; border-bottom: 0; box-shadow: var(--shadow-sheet); } + .panel { --dialog-gutter: 17px; width: 100%; top: auto; bottom: 0; left: 0; max-height: calc(100dvh - 16px); transform: none; border-radius: 22px 22px 0 0; border-bottom: 0; box-shadow: var(--shadow-sheet); } .panel[data-starting-style], .panel[data-ending-style] { transform: translateY(16px); } - .amountRow input { font-size: 34px; } + .dialogHeader { padding-block: 12px; } + .dialogBody { padding-top: 12px; } + .panel > .footer { padding-bottom: calc(4px + env(safe-area-inset-bottom)); } + .route { grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr); gap: 6px; } + .networkTrigger { grid-template-columns: 24px minmax(0, 1fr) 11px; gap: 5px; padding-inline: 7px; } + .networkTrigger .networkMark { width: 24px; height: 24px; } + .networkTrigger .networkMark img { max-width: 24px; max-height: 24px; } + .networkTrigger .networkName { font-size: 13px; } + .amountRow input { font-size: 32px; } } -@media (max-width: 360px) { .networkTrigger { grid-template-columns: 24px minmax(0, 1fr) 13px; gap: 5px; padding-inline: 4px; } .networkTrigger .networkMark { width: 24px; height: 24px; } .networkTrigger .networkMark img { max-width: 24px; max-height: 24px; } .networkTrigger .networkName { font-size: 13px; } } -@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary, .secondary, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger { transition: none; } .spinner { animation: none; } } +@media (max-width: 360px) { .panel { --dialog-gutter: 13px; } .networkTrigger { padding-inline: 5px; } } +@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary, .secondary, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger, .historyLink { transition: none; } .primary[data-connect]:not(:disabled):active { transform: none; } .spinner { animation: none; } } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index 25207826..9c1d38fa 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -1,12 +1,12 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import Image from "next/image"; import Link from "next/link"; import { Dialog } from "@base-ui/react/dialog"; import { Select } from "@base-ui/react/select"; -import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, CircleDollarSign, Clock3, LoaderCircle, Wallet, X } from "lucide-react"; +import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, CircleDollarSign, Clock3, Fuel, History, Info, LoaderCircle, Wallet, X } from "lucide-react"; import { formatEther, formatUnits, zeroAddress } from "viem"; import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeProviderName, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeFundingChain, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; import { DISCARD_AFTER_MS } from "@/lib/bridge/client"; @@ -28,11 +28,6 @@ type Bridge = ReturnType; // Money leaves from any network, funding ones included, but arrives only on a launch chain. const SOURCE_CHAINS: readonly BridgeChainId[] = BRIDGE_CHAIN_IDS; const DESTINATION_CHAINS: readonly BridgeChainId[] = BRIDGE_DESTINATION_IDS; -// Funding networks without a bundled logo get a lettermark in the network's own colours. -const LETTERMARKS: Partial> = { - 42161: { text: "ARB", fill: "#213147", ring: "#12aaff" }, - 10: { text: "OP", fill: "#ff0420", ring: "#ff0420" }, -}; function nativeAmount(value: string | bigint): string { const text = typeof value === "bigint" ? formatEther(value) : value; @@ -42,9 +37,11 @@ function nativeAmount(value: string | bigint): string { } function NetworkMark({ chain }: { chain: BridgeChainId }) { - const letters = LETTERMARKS[chain]; - if (letters) return {letters.text}; + if (chain === 42161) return ; + if (chain === 10) return ; if (chain === 1) return ; + // a black disc on white, its own tile on both themes (ChainLogo.tsx) + if (chain === 99901) return ; return {chain === 8453 ? : chain === 5042 ? @@ -63,7 +60,12 @@ function AssetMark({ asset, size = 24 }: { asset: BridgeAsset; size?: number }) function AssetSelect({ label, chain, asset, disabled, onChange }: { label: "Send" | "Receive"; chain: BridgeChainId; asset: BridgeAsset; disabled: boolean; onChange: (asset: BridgeAsset) => void }) { const [instant, setInstant] = useState(false); const choices = BRIDGE_ASSETS[chain]; - if (choices.length === 1) return {asset}; + if (choices.length === 1) return ( + + {asset} + {chain === 99901 ? USDC only · Gas in HYPE : null} + + ); return ({ value, label: value }))} disabled={disabled} onValueChange={(value) => { if (isBridgeAssetSupported(chain, value)) onChange(value); }} onOpenChange={(_, details) => setInstant(details.event.type.startsWith("key"))}> @@ -140,7 +142,7 @@ function Recipient({ address }: { address: string }) { ); } -function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: BridgeQuoteRejection; inputSymbol: BridgeAsset; gasSymbol: BridgeAsset }) { +function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: BridgeQuoteRejection; inputSymbol: BridgeAsset; gasSymbol: string }) { const feeTooHigh = rejection.reason === "relay-fee"; const exactPercent = feeTooHigh ? rejection.relayFeePercent : rejection.totalImpactPercent.replace(/^-/, ""); return
@@ -183,7 +185,6 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = }) { const onPage = slot !== null; const bridge = useBridge(open || onPage); - const popup = useRef(null); const [connecting, setConnecting] = useState(false); const transferring = bridge.tracked !== null; const linkKey = link ? `${linkCount}:${bridgeLinkKey(link)}` : ""; @@ -206,41 +207,82 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = } const circle = (bridge.tracked?.provider ?? bridge.quote?.provider) === "cctp"; - const footer = ( -
- {circle - ? Powered by Circle CCTP - : Powered by Relay } - {onPage ? null : onOpenChange(false)}>Your transfers} - 0 Openlaunch fee -
- ); + const footer = onOpenChange(false)} />; const content = transferring ? : ; return ( <> {onPage ? createPortal(
{content}{footer}
, slot) : null} - - - - { if (event.key === "Escape") event.stopPropagation(); }}> -
- Bridge - -
- {transferring ? "Your transfer, from departure to arrival." : "Same wallet. A new network."} - {content} - {footer} -
-
-
+ + {content} + {connecting ? { setConnecting(false); if (!onPage) onOpenChange(true); }} /> : null} ); } -export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () => void }) { +export function BridgeModal({ open, onOpenChange, description, children, footer, finalFocus, closeLabel = "Close bridge" }: { + open: boolean; + onOpenChange: (open: boolean) => void; + description: string; + children: ReactNode; + footer: ReactNode; + finalFocus?: false | (() => HTMLElement | null); + closeLabel?: string; +}) { + const popup = useRef(null); + return ( + + + + { if (event.key === "Escape") event.stopPropagation(); }}> +
+ +
+ Bridge + {description} +
+ +
+
{children}
+ {footer} +
+
+
+ ); +} + +export function BridgeFooter({ circle, hideHistory = false, historyHref = "/bridge#transfers", onHistory }: { + circle: boolean; + hideHistory?: boolean; + historyHref?: string; + onHistory?: () => void; +}) { + const provider = circle ? "Circle CCTP" : "Relay"; + return ( +
+
+ + Via {provider} + + 0% app fee +
+ {hideHistory ? null : Your transfers} +
+ ); +} + +export function BridgeForm({ bridge: b, connect, idPrefix = "bridge" }: { bridge: Bridge; connect: () => void; idPrefix?: string }) { if (b.approval && (b.approval.status === "pending" || b.approval.status === "uncertain")) return ; + const amountId = `${idPrefix}-amount`; + const balanceId = `${idPrefix}-balance`; + const gasNoteId = `${idPrefix}-gas-note`; const locked = b.busy || b.approvalBusy; const loading = locked || b.quoteLoading; const reviewing = !!b.quote && !b.quoteExpired; @@ -263,19 +305,26 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () : reviewing && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} ${inputCurrency.symbol}` : reviewing ? `Bridge to ${destination.name}` : b.quoteError ? "Retry quote" : !b.canQuote ? "Enter an amount" : "Get quote"; const error = (!b.quoteRejection && b.quoteError) || b.error || b.storageError || b.approvalError; + const quoteHint = b.quoteLoading ? "Getting quote…" : b.quoteError ? "Quote unavailable" + : b.address ? "Enter amount for a quote" : "Connect to see a quote"; const mode = signalMode({ phase: b.phase, tracked: false, approvalActive: b.approvalBusy, quoteLoading: b.quoteLoading, reviewing, quoteExpired: needsRefresh, blocked: !!b.quoteRejection || !!b.quoteError }); // the deposit, and only the deposit, can also be confirmed by sliding: never an approval, a quote or a refresh const slideReady = !!b.address && reviewing && !b.approvalRequired && !loading && !b.allowanceLoading; + const actionDisabled = loading || b.allowanceLoading || (!!b.address && !b.canQuote); + const quoteAction = !!b.address && !reviewing; - // the button and the slide share this one path, with the same guards - function submit() { + function runWalletAction() { + if (actionDisabled) return; if (!b.address) { connect(); return; } - if (loading || b.allowanceLoading || !b.canQuote) return; - void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); + if (reviewing) void (b.approvalRequired ? b.approve() : b.confirm()); } return ( -
{ event.preventDefault(); submit(); }}> + { + event.preventDefault(); + // Enter from the amount field can fetch a quote. Wallet actions use the button or slider. + if (quoteAction && !actionDisabled) void b.requestQuote(); + }}>
@@ -284,21 +333,38 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
{walletFunding !== null ? : null} {convertsAsset ?

{inputCurrency.symbol} {outputCurrency.symbol}Converted by Relay

: null} - {b.originChainId === 4663 || b.destinationChainId === 4663 ?

USDC on Robinhood is unavailable: its routes do not pass our current safety checks. ETH and USDG are available.

: null} + {b.originChainId === 4663 || b.destinationChainId === 4663 ? ( +
+ Robinhood supports ETH and USDG +

USDC routes on Robinhood are unavailable because they don’t pass our current safety checks.

+
+ ) : null} {erc20Input && b.approval?.chainId === b.originChainId && b.approval.status === "confirmed" && !b.quote ?

{inputCurrency.symbol} approval confirmed. Review a fresh quote before bridging. No funds have been bridged yet.

: null} -
- -
- b.setAmount(event.target.value)} disabled={locked} aria-describedby="bridge-balance bridge-gas-note" /> - +
+
+ +
+ b.setAmount(event.target.value)} disabled={locked} aria-describedby={`${balanceId} ${gasNoteId}`} /> + +
+

{!b.address ? `${inputCurrency.symbol} on ${origin.name}` : b.balanceLoading ? "Checking your balance…" : b.balance !== undefined ? `Available: ${nativeAmount(formatUnits(b.balance, inputCurrency.decimals))} ${inputCurrency.symbol}` : b.balanceError ?? "Balance unavailable"}

+ {b.address && erc20Input && b.originChainId !== 5042 && b.nativeBalance !== undefined ?

For gas: {nativeAmount(b.nativeBalance)} {origin.symbol}

: null} +
+
+
+ Receive on {destination.name} + + {!b.quote && !b.quoteRejection ? <> + {b.quoteLoading ? : null} + {quoteHint} + : null} + +
+
-

{!b.address ? `${inputCurrency.symbol} on ${origin.name}` : b.balanceLoading ? "Checking your balance…" : b.balance !== undefined ? `Available: ${nativeAmount(formatUnits(b.balance, inputCurrency.decimals))} ${inputCurrency.symbol}` : b.balanceError ?? "Balance unavailable"}

- {b.address && erc20Input && b.originChainId !== 5042 && b.nativeBalance !== undefined ?

For gas: {nativeAmount(b.nativeBalance)} {origin.symbol}

: null}
-
Receive on {destination.name}
- {b.quote ? (
Estimated output{needsRefresh ? "Quote expired" : "Live quote"}
@@ -314,21 +380,40 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()

{needsRefresh ? "This quote expired. Refresh and review the new amounts." : circle ? "Circle takes one fixed fee, so this is the exact amount you receive. Arrival time and gas can change." : "0.5% slippage limit. Arrival time and gas can change."}

{b.approvalRequired ?

First, approve only {nativeAmount(b.amount)} {inputCurrency.symbol} for {circle ? "Circle’s transfer contract" : "Relay’s deposit contract"}. Then review a fresh quote and confirm the bridge separately. Approval alone does not move your funds and uses additional {origin.symbol} for gas.

: null}
- ) : b.quoteRejection ? :
- {b.quoteLoading ? : } -

{b.quoteLoading ? "Updating your quote…" : b.quoteError ? "No quote available yet." : b.address ? "Enter an amount. We’ll find your route." : "See your route before you commit."}
- {b.quoteLoading ? "Keep typing. We’ll use your latest amount." : "Quotes update automatically. No wallet request until you confirm."}

-
} + ) : b.quoteRejection ? : null} {b.address ? : null} {error ?

{error}

: null} - {slideReady ? } back={convertsAsset ? : null} onConfirm={submit} /> : null} - -

Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}Bridging uses {provider}, a third-party protocol, and carries risk. Openlaunch does not operate {provider}, holds no funds in transit, and is not responsible for delays, refunds or losses. {erc20Input ? `An unspent ${inputCurrency.symbol} approval remains until used or revoked.` : "No token approvals required."}

+
+ + Gas in {origin.symbol} on {origin.name} + Details + +
+

Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}{erc20Input ? `An unused ${inputCurrency.symbol} approval stays active until you use or revoke it.` : "No token approvals required."}

+

Bridging uses {provider}, a third-party protocol, and carries risk. Openlaunch does not operate {provider}, holds no funds in transit, and is not responsible for delays, refunds or losses.

+
+
+
+ {slideReady ? } back={convertsAsset ? : null} onConfirm={runWalletAction} /> : null} + +
); } diff --git a/app/src/components/bridge/BridgeHistory.tsx b/app/src/components/bridge/BridgeHistory.tsx index f3a0565b..03c40899 100644 --- a/app/src/components/bridge/BridgeHistory.tsx +++ b/app/src/components/bridge/BridgeHistory.tsx @@ -72,7 +72,7 @@ export default function BridgeHistory() { /** The board itself, from plain props, so the development fixture can show every state without a wallet. */ export function HistoryBoard({ wallet, rows, loading, note, unsaved, onRefresh }: { wallet: string; rows: readonly BridgeHistoryEntry[]; loading: boolean; note: string; unsaved: boolean; onRefresh: () => void }) { return ( -
+

Your transfers

diff --git a/app/src/components/bridge/Signal.module.css b/app/src/components/bridge/Signal.module.css index de2da574..cf55dc15 100644 --- a/app/src/components/bridge/Signal.module.css +++ b/app/src/components/bridge/Signal.module.css @@ -2,14 +2,14 @@ The board is a dark object in both themes (an LED strip is not a light-mode surface), so its plate and lights are literal colours; everything around it follows the site tokens. */ -.board { margin-bottom: 18px; padding: 10px 10px 8px; border: 1px solid #1c1f27; border-radius: 14px; background: #050608; } +.board { margin-bottom: 14px; padding: 8px 10px 7px; border: 1px solid #232832; border-radius: 14px; background: linear-gradient(165deg, #111722, #050608 62%); box-shadow: inset 0 1px 0 rgb(255 255 255 / .07); } .boardRuler { display: block; height: 0; } .boardCanvas { display: block; width: 100%; } -.caption { margin: 6px 2px 0; min-height: 20px; overflow: hidden; color: #6d9dff; font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 13px; font-weight: 800; line-height: 20px; text-align: center; white-space: nowrap; } +.caption { margin: 3px 2px 0; min-height: 18px; overflow: hidden; color: #6d9dff; font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 12px; font-weight: 800; line-height: 18px; text-align: center; white-space: nowrap; } .caption[data-mode="idle"], .caption[data-mode="expired"] { color: #7b8494; } .caption[data-mode="arrived"] { color: #3ddc97; } .caption[data-mode="refund"], .caption[data-mode="attention"], .caption[data-mode="blocked"] { color: #fbbf24; } -.caption span { display: inline-block; animation: roll 360ms cubic-bezier(.16, 1, .3, 1) both; } +.caption span { display: inline-block; animation: roll 220ms cubic-bezier(.16, 1, .3, 1) both; } @keyframes roll { from { opacity: 0; transform: translateY(105%); } to { opacity: 1; transform: none; } } .slide { margin-top: 4px; } diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index a92b63f1..0d4e56ba 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -9,7 +9,7 @@ const header = read("../HeaderNav.tsx"); const css = read("./BridgeDialog.module.css"); test("bridge uses bundled official asset marks rather than letter placeholders", () => { - for (const asset of ["base.svg", "robinhood-black.svg", "robinhood-white.svg", "ethereum.svg", "arc.svg", "usdc.svg"]) { + for (const asset of ["base.svg", "robinhood-black.svg", "robinhood-white.svg", "ethereum.svg", "arc.svg", "arbitrum.svg", "optimism.svg", "usdc.svg"]) { assert.ok(panel.includes(`/brand/${asset}`), asset); const svg = read(`../../../public/brand/${asset}`); assert.match(svg, /\{content\}\{footer\}<\/section>, slot\) : null/); - assert.match(panel, /\("#bridge-amount"\)\?\.focus/); // a link preselects through the pickers' guarded setters, once, and carries no amount @@ -70,9 +70,13 @@ test("the signal board is decorative and sliding confirms the deposit only", () assert.equal((panel.match(/ : null\}/); - assert.match(panel, /onSubmit=\{\(event\) => \{ event\.preventDefault\(\); submit\(\); \}\}/); - assert.match(panel, /