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")} />}
+
Powered by Relay0 Openlaunch fee
+
+
+
+ );
+}
diff --git a/app/src/app/ui-review-bridge/page.tsx b/app/src/app/ui-review-bridge/page.tsx
new file mode 100644
index 00000000..631c9a80
--- /dev/null
+++ b/app/src/app/ui-review-bridge/page.tsx
@@ -0,0 +1,10 @@
+import { notFound } from "next/navigation";
+import BridgeReview from "./BridgeReview";
+
+export const dynamic = "force-dynamic";
+
+/** Local visual fixture. Never exposes a mock wallet or transfer on production. */
+export default function BridgeReviewPage() {
+ if (process.env.NODE_ENV !== "development") notFound();
+ return ;
+}
diff --git a/app/src/components/HeaderNav.tsx b/app/src/components/HeaderNav.tsx
index 8c6cb8aa..6b07a44a 100644
--- a/app/src/components/HeaderNav.tsx
+++ b/app/src/components/HeaderNav.tsx
@@ -13,6 +13,7 @@ import ConnectButton from "./ConnectButton";
import ThemeToggle from "./ThemeToggle";
import NotificationSettings from "./NotificationSettings";
import LivePulse from "./launchpad/LivePulse";
+import { BridgeButton, BridgeProvider } from "./bridge/BridgeProvider";
const NAV = [
{ href: "/", label: "Launchpad" },
@@ -38,12 +39,12 @@ export default function HeaderNav({ pulse }: { pulse: Pulse }) {
const heroCtaOnScreen = useHeroCtaOnScreen(pathname);
return (
-
+
-
+
);
}
@@ -99,6 +100,7 @@ function Desktop({ visible = false, pulse, isActive, quietCta }: { visible?: boo
{switching || (localBusy && localBusy !== "disconnect") ? "Confirm the network in your wallet…" : key ? `Connected to ${CHAIN_LABELS[key]}` : "A network switch needs wallet approval."}
+
{switching || (localBusy && localBusy !== "disconnect") ? "Confirm the network in your wallet…" : key ? `Connected to ${CHAIN_LABELS[key]}` : bridgeNetwork ? `Connected to ${network}. Use Bridge to move funds, or choose a launch network above.` : "A network switch needs wallet approval."}
{verifying ? "Verifying transaction" : b.approvalBusy ? "Check your wallet" : uncertain ? "Check your approval" : "Approval submitted"}
+
{verifying ? `Checking the transaction on ${approvalChain.name}. No wallet request will be made.` : b.approvalBusy ? "Approve the exact USDC amount in your wallet. This is not the bridge deposit." : uncertain ? "The wallet response was interrupted. Check your wallet’s activity and verify the transaction hash below. Do not approve again." : `Waiting for ${approvalChain.name} to confirm. You’ll review a fresh bridge quote next.`}
No bridge deposit has been requested. An approval permits Relay’s deposit contract to use up to this amount; it does not bridge it.
+ {b.approvalError || b.storageError ?
{b.approvalError || b.storageError}
: null}
+ {approval.approvalHash ? View approval on {approvalChain.name} : null}
+ {uncertain && !approval.approvalHash && !b.approvalBusy ?
+ Have the approval transaction hash?
+
Copy it from your wallet’s activity on {approvalChain.name}. We’ll verify the wallet, token, spender and exact amount before resuming.
+
+ : null}
+ {approval.approvalHash ? : null}
+ {b.approvalCanBeDiscarded ?
+ Still not confirmed after {DISCARD_AFTER_MS / 60_000} minutes?
+
{approvalChain.name} has no record of this approval. If your wallet shows it as dropped or cancelled, you can discard it and start over. If it confirms later it only grants this exact allowance; the next deposit re-checks it.
+
+ : null}
+
You can close this panel. Reopen Bridge with this wallet to resume. If you stop after approval, the unspent allowance remains until used or revoked.
+
;
+}
+
+export function Transfer({ bridge: b }: { bridge: Bridge }) {
+ const transfer = b.tracked!;
+ const success = b.phase === "success";
+ const refund = b.phase === "refund";
+ const failed = b.phase === "failure";
+ const reverted = transfer.failureReason === "source-reverted";
+ const uncertain = b.phase === "uncertain";
+ const waitingForWallet = b.phase === "confirming" || b.phase === "switching";
+ const terminal = success || refund || failed;
+ const origin = BRIDGE_CHAINS[transfer.originChainId];
+ const destination = BRIDGE_CHAINS[transfer.destinationChainId];
+ const inputCurrency = bridgeTransferInputCurrency(transfer);
+ const outputCurrency = bridgeCurrency(transfer.destinationChainId, transfer.destinationAsset, "output");
+ const title = success ? `Arrived on ${destination.name}` : refund ? "Relay reports a refund" : reverted ? "The deposit reverted" : failed ? "Transfer needs attention" : uncertain ? "Checking your transfer" : waitingForWallet ? "Check your wallet" : "Your transfer is on its way";
+ const detail = success ? `Relay has confirmed ${outputCurrency.symbol} delivery to your receiving wallet.` : refund ? "Check your wallets on both networks and Relay’s transfer details before trying again." : reverted ? `The source network rejected the deposit. Your ${inputCurrency.symbol} was not bridged; network gas was still charged.` : failed ? "Relay couldn’t complete this transfer. Check the transfer details for recovery before sending again." : uncertain ? "The wallet response was interrupted. Don’t send again while we check whether your deposit was submitted." : waitingForWallet ? "Review the network, amount and transaction in your wallet. Nothing moves without your confirmation." : "You can close this panel. Reopen Bridge with this wallet to check its status.";
+
+ return (
+
Deposit on {origin.name}{reverted ? "Reverted. Deposit not made." : transfer.sourceHash || success || refund ? "Submitted to the source network" : waitingForWallet ? "Awaiting wallet confirmation" : "Checking for your deposit"}
+
{success ? : "2"}
{refund ? "Refund reported by Relay" : "Relay processes the transfer"}{transfer.status === "delayed" ? "Taking longer than expected. Tracking continues." : failed ? "Open transfer details for help" : success ? "Transfer complete" : "Live status from the bridge provider"}
+
{success ? : "3"}
Receive {outputCurrency.symbol} on {destination.name}{success ? "Delivered to the same wallet address" : "Delivery will be confirmed here"}
+ {b.canReset ? : null}
+ {b.canDiscard ?
+ No deposit after {DISCARD_AFTER_MS / 60_000} minutes?
+
Relay has not seen a deposit for this transfer and nothing is confirmed on {origin.name}. If your wallet shows the transaction as dropped or cancelled, you can discard this record and start over. Keep the Transfer ID below in case you need Relay support.
+
+ : null}
+
Transfer ID {transfer.requestId}
+
+ );
+}
diff --git a/app/src/components/bridge/BridgeProvider.tsx b/app/src/components/bridge/BridgeProvider.tsx
new file mode 100644
index 00000000..9f110bc7
--- /dev/null
+++ b/app/src/components/bridge/BridgeProvider.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { createContext, useCallback, useContext, useRef, useState } from "react";
+import { ArrowLeftRight } from "lucide-react";
+import styles from "./BridgeDialog.module.css";
+
+const BridgeDialog = dynamic(() => import("./BridgeDialog"), { ssr: false });
+const BridgeContext = createContext<(() => void) | null>(null);
+
+/** Mounted once for both navigation variants; closing the panel does not stop tracking. */
+export function BridgeProvider({ children }: { children: React.ReactNode }) {
+ const [activated, setActivated] = useState(false);
+ const [open, setOpen] = useState(false);
+ const trigger = useRef(null);
+ const show = useCallback(() => {
+ trigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ setActivated(true);
+ setOpen(true);
+ }, []);
+ const restoreFocus = useCallback(() => trigger.current?.isConnected
+ ? trigger.current
+ : document.querySelector('[aria-controls="mobile-menu"]'), []);
+
+ return (
+
+ {children}
+ {activated ? : null}
+
+ );
+}
+
+export function BridgeButton({ block = false, onOpen }: { block?: boolean; onOpen?: () => void }) {
+ const show = useContext(BridgeContext);
+ return (
+
+ );
+}
diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts
new file mode 100644
index 00000000..b3f8278b
--- /dev/null
+++ b/app/src/components/bridge/bridge-ui.test.ts
@@ -0,0 +1,142 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
+const panel = read("./BridgeDialog.tsx");
+const provider = read("./BridgeProvider.tsx");
+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"]) {
+ assert.ok(panel.includes(`/brand/${asset}`), asset);
+ const svg = read(`../../../public/brand/${asset}`);
+ assert.match(svg, //);
+ assert.match(panel, //);
+});
+
+test("bridge keeps one lazily loaded controller across desktop/mobile and dismissal", () => {
+ assert.match(header, //);
+ assert.match(header, //);
+ assert.match(header, / setOpen\(false\)\} \/>/);
+ assert.match(provider, /dynamic\(\(\) => import\("\.\/BridgeDialog"\)/);
+ assert.match(provider, /activated \? {
+ for (const content of ["Minimum received", "Source gas", "Receiving wallet", "0.5% slippage", "carries risk", "This quote expired", "Refresh quote", "0 Openlaunch fee"]) assert.ok(panel.includes(content), content);
+ assert.match(panel, /reviewing \? b.approvalRequired \? b.approve\(\) : b.confirm\(\) : b.requestQuote\(\)/);
+ assert.doesNotMatch(panel, /dangerouslySetInnerHTML|setInterval|sendTransaction/);
+});
+
+test("bridge dialog has accessible focus, mobile layout and reduced motion", () => {
+ assert.match(panel, /Dialog.Popup[^>]+initialFocus=\{popup\}/);
+ assert.match(panel, /finalFocus=\{connecting \? false : restoreFocus\}/);
+ assert.match(panel, /Dialog.Close[^>]+aria-label="Close bridge"/);
+ assert.match(panel, /htmlFor="bridge-amount"/);
+ assert.match(css, /prefers-reduced-motion: reduce/);
+ assert.match(css, /max-height: calc\(100dvh - 16px\)/);
+ assert.match(css, /:focus-visible/);
+});
+
+test("bridge alerts wrap long provider reasons without discarding actionable errors", () => {
+ assert.match(css, /\.error \{[^}]*min-width: 0[^}]*overflow-wrap: anywhere/);
+ assert.match(css, /\.error > span \{[^}]*min-width: 0/);
+ assert.match(read("./useBridge.ts"), /const messageOf = bridgeErrorMessage;/);
+});
+
+test("both quote and transfer recipients expose the full address without hover", () => {
+ assert.match(panel, /[\s\S]*[\s\S]*\{address\}<\/code>/);
+ assert.match(panel, //);
+ assert.match(panel, //);
+ assert.match(css, /input, select, summary\):focus-visible/);
+});
+
+test("bridge selectors expose three networks and label cross-asset conversion and gas", () => {
+ assert.match(panel, /BRIDGE_CHAIN_IDS.map/);
+ assert.match(panel, /Select.Trigger[^>]+aria-label=\{`\$\{label\} network`\}/);
+ assert.match(panel, /onChange=\{b.setDestinationChainId\}/);
+ assert.match(panel, /onClick=\{b.reverseRoute\}/);
+ assert.match(panel, /Gas in \{BRIDGE_CHAINS\[chain\].symbol\}/);
+ assert.match(panel, /Converted by Relay/);
+ assert.match(panel, /Value change/);
+ assert.doesNotMatch(panel + provider, /Bridge ETH|relayFeeEth|sourceGasEth|Your ETH is on its way/);
+});
+
+test("network pickers use themed Base UI lists without native browser menus", () => {
+ assert.match(panel, /import \{ Select \} from "@base-ui\/react\/select"/);
+ assert.doesNotMatch(panel, /