From 79627797ff826447f44489f8a64abf1c9535867a Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 10 Nov 2026 17:30:58 +0530 Subject: [PATCH] feat(bridge): signal board, slide to confirm and a /bridge page A redesign of the bridge panel that leaves the transfer logic alone: useBridge, the validators and every wallet guard are unchanged. - signal board: a dot-matrix strip spells the two networks and runs lights along the route. What it shows is a pure function of the transfer state (lib/bridge/signal.ts); packets move only for a tracked pending transfer. Decorative (aria-hidden), canvas, still under reduced motion, a dark plate in both themes - dot numerals (Doto, OFL) for the amount and the estimated output, loaded with the lazy panel only - slide to confirm: for the deposit step alone, a coin can be dragged across a rail. Only a release or Enter at the far end confirms (lib/bridge/slide.ts); the slide and the button share one guarded submit path and the button never goes away - /bridge: the same panel as a page with the networks, steps, costs and limits beside it. The page registers a slot and the header's one controller renders into it, so no second bridge hook runs - shareable links: /bridge?from=&to=&asset=&toAsset= preselects a route through the pickers' guarded setters; no amount, ignored whole if any value is off (lib/bridge/link.ts) - sitemap and footer list /bridge; docs/bridge.md section; tests for the board states, drag rules, links, the single controller and the shapes --- app/src/app/bridge/bridge.module.css | 27 +++ app/src/app/bridge/page.tsx | 76 +++++++ app/src/app/metadata.test.ts | 1 + app/src/components/Footer.tsx | 1 + .../components/bridge/BridgeDialog.module.css | 11 +- app/src/components/bridge/BridgeDialog.tsx | 88 ++++++-- app/src/components/bridge/BridgePageSlot.tsx | 16 ++ app/src/components/bridge/BridgeProvider.tsx | 16 +- app/src/components/bridge/Signal.module.css | 46 +++++ app/src/components/bridge/SignalBoard.tsx | 101 ++++++++++ app/src/components/bridge/SlideConfirm.tsx | 105 ++++++++++ app/src/components/bridge/bridge-slot.ts | 25 +++ app/src/components/bridge/bridge-ui.test.ts | 60 +++++- app/src/components/bridge/dot-font.ts | 7 + app/src/lib/bridge/link.ts | 43 ++++ app/src/lib/bridge/signal.test.ts | 144 ++++++++++++++ app/src/lib/bridge/signal.ts | 188 ++++++++++++++++++ app/src/lib/bridge/slide.ts | 39 ++++ app/src/lib/seo.test.ts | 2 +- app/src/lib/seo.ts | 1 + docs/bridge.md | 13 +- 21 files changed, 979 insertions(+), 31 deletions(-) create mode 100644 app/src/app/bridge/bridge.module.css create mode 100644 app/src/app/bridge/page.tsx create mode 100644 app/src/components/bridge/BridgePageSlot.tsx create mode 100644 app/src/components/bridge/Signal.module.css create mode 100644 app/src/components/bridge/SignalBoard.tsx create mode 100644 app/src/components/bridge/SlideConfirm.tsx create mode 100644 app/src/components/bridge/bridge-slot.ts create mode 100644 app/src/components/bridge/dot-font.ts create mode 100644 app/src/lib/bridge/link.ts create mode 100644 app/src/lib/bridge/signal.test.ts create mode 100644 app/src/lib/bridge/signal.ts create mode 100644 app/src/lib/bridge/slide.ts diff --git a/app/src/app/bridge/bridge.module.css b/app/src/app/bridge/bridge.module.css new file mode 100644 index 00000000..c9dee357 --- /dev/null +++ b/app/src/app/bridge/bridge.module.css @@ -0,0 +1,27 @@ +/* /bridge: the panel on the left at a comfortable reading width, the facts beside it; one column on small screens with the panel first. */ +.layout { display: grid; grid-template-columns: minmax(0, 520px) minmax(0, 1fr); align-items: start; gap: 40px 56px; } +.facts { display: grid; gap: 32px; min-width: 0; padding-top: 6px; } +.facts h2 { margin: 0 0 12px; color: var(--color-ink); font-size: 17px; font-weight: 650; letter-spacing: -.02em; line-height: 1.3; } + +.networks { display: grid; margin: 0; } +.networks div { display: grid; grid-template-columns: minmax(96px, 132px) minmax(0, 1fr); gap: 12px; padding: 10px 0; border-top: 1px solid var(--color-line); font-size: 14px; line-height: 1.5; } +.networks div:last-child { border-bottom: 1px solid var(--color-line); } +.networks dt { color: var(--color-ink); font-weight: 600; } +.networks dd { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin: 0; color: var(--color-body); } +.networks dd span { color: var(--color-muted); font-size: 13px; } + +/* a real sequence: each step happens after the one before it */ +.steps { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; counter-reset: step; } +.steps li { display: grid; grid-template-columns: 26px minmax(0, 1fr); column-gap: 12px; counter-increment: step; font-size: 14px; line-height: 1.6; } +.steps li::before { content: counter(step); grid-row: span 2; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--color-line-strong); border-radius: 50%; color: var(--color-ink); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; } +.steps strong { color: var(--color-ink); font-weight: 600; } +.steps span { color: var(--color-body); } + +.points { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; color: var(--color-body); font-size: 14px; line-height: 1.6; } +.points li { position: relative; padding-left: 16px; } +.points li::before { content: ""; position: absolute; left: 0; top: .68em; width: 5px; height: 5px; border-radius: 50%; background: var(--color-line-strong); } + +@media (max-width: 960px) { + .layout { grid-template-columns: minmax(0, 1fr); } + .layout > :first-child { max-width: 560px; } +} diff --git a/app/src/app/bridge/page.tsx b/app/src/app/bridge/page.tsx new file mode 100644 index 00000000..f93d02df --- /dev/null +++ b/app/src/app/bridge/page.tsx @@ -0,0 +1,76 @@ +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 { 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"; + +/** + * The bridge as a page: the same panel the header opens, with room for the facts beside it. The panel is rendered by + * the header's one bridge controller into the slot below (components/bridge/bridge-slot.ts), so this page starts no + * second copy. `?from=…&to=…&asset=…&toAsset=…` preselects a route (lib/bridge/link.ts) and nothing more. + */ +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.", +}); + +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, Relay, charges a fee that is shown before you confirm and is already taken out of the amount you receive.
  • +
  • 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. They never pass through an openlaunch wallet or contract.
  • +
  • Relay is a third-party protocol. openlaunch does not operate it 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.
  • +
+
+
+
+
+ ); +} diff --git a/app/src/app/metadata.test.ts b/app/src/app/metadata.test.ts index 55231705..b02a4180 100644 --- a/app/src/app/metadata.test.ts +++ b/app/src/app/metadata.test.ts @@ -26,6 +26,7 @@ test("every indexable page declares its own canonical path and share card", () = ["./feed/page.tsx", "/feed"], ["./agents/page.tsx", "/agents"], ["./about/page.tsx", "/about"], + ["./bridge/page.tsx", "/bridge"], ]; for (const [file, path] of pages) { const src = read(file); diff --git a/app/src/components/Footer.tsx b/app/src/components/Footer.tsx index 901fe117..8f12e4f4 100644 --- a/app/src/components/Footer.tsx +++ b/app/src/components/Footer.tsx @@ -10,6 +10,7 @@ import styles from "./Footer.module.css"; const EXPLORE = [ { href: "/", label: "Launchpad" }, { href: "/rules", label: "How it works" }, + { href: "/bridge", label: "Bridge" }, { href: "/about", label: "About openlaunch" }, { href: "/feed", label: "Community posts" }, { href: "/me", label: "Your dashboard" }, diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index 24f2156d..aabd1476 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -53,7 +53,7 @@ .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-size: 38px; line-height: 1.35; letter-spacing: -.035em; font-variant-numeric: tabular-nums; font-weight: 500; } +.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; } .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; } @@ -93,8 +93,8 @@ .quote { padding: 18px 0 0; 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-size: 27px; font-weight: 600; line-height: 1.3; letter-spacing: -.025em; font-variant-numeric: tabular-nums; } -.receiveAmount span { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; vertical-align: middle; font-size: 16px; color: var(--color-muted); font-weight: 500; } +.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 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; } .fees dt, .transferDetails dt { color: var(--color-body); } @@ -116,6 +116,9 @@ .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); } .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; } @@ -163,4 +166,4 @@ .amountRow input { font-size: 34px; } } @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, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger { transition: none; } .spinner { animation: none; } } +@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary, .secondary, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger { transition: none; } .spinner { animation: none; } } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index aa742a77..e12872ee 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -1,19 +1,27 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } 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 { formatEther, formatUnits, zeroAddress } from "viem"; import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeFundingChain, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; import { DISCARD_AFTER_MS } from "@/lib/bridge/client"; +import { bridgeLinkKey, type BridgeLink } from "@/lib/bridge/link"; +import { signalCaption, signalMode } from "@/lib/bridge/signal"; import { formatFeeWarningPercent } from "@/lib/bridge/fee-display"; import { shortAddr } from "@/lib/chainPublic"; import WalletPicker from "../WalletPicker"; import WalletAvatar from "../WalletAvatar"; import useBridge from "./useBridge"; +import SignalBoard from "./SignalBoard"; +import SlideConfirm from "./SlideConfirm"; +import { dotFont } from "./dot-font"; import styles from "./BridgeDialog.module.css"; +import signal from "./Signal.module.css"; type Bridge = ReturnType; // Money leaves from any network, funding ones included, but arrives only on a launch chain. @@ -158,24 +166,55 @@ function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: Brid ; } -export default function BridgeDialog({ open, onOpenChange, restoreFocus }: { +/** + * The bridge's one controller. It shows the panel as the header dialog, or, when the /bridge page has registered its + * slot (bridge-slot.ts), inside that page instead: the same hook either way, so a transfer is quoted, sent and tracked + * once. `link` is the page's shareable route (lib/bridge/link.ts); it only preselects networks and tokens. + */ +export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = null, link = null }: { open: boolean; onOpenChange: (open: boolean) => void; restoreFocus: () => HTMLElement | null; + slot?: HTMLElement | null; + link?: BridgeLink | null; }) { - const bridge = useBridge(open); + 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 = bridgeLinkKey(link); + const appliedLink = useRef(""); + + // Each link is applied once, through the same guarded setters the pickers use: they refuse while an approval or a + // transfer is in progress, so a link can never move a route out from under one. + useEffect(() => { + if (!link || appliedLink.current === linkKey) return; + appliedLink.current = linkKey; + bridge.setOriginChainId(link.originChainId); + bridge.setDestinationChainId(link.destinationChainId); + if (link.originAsset) bridge.setOriginAsset(link.originAsset); + if (link.destinationAsset) bridge.setDestinationAsset(link.destinationAsset); + }, [bridge, link, linkKey]); function connect() { - onOpenChange(false); + if (!onPage) onOpenChange(false); setConnecting(true); } + const footer = ( +
+ Powered by Relay + {onPage ? null : onOpenChange(false)}>Open full page} + 0 Openlaunch fee +
+ ); + const content = transferring ? : ; + return ( <> - + {onPage ? createPortal(
{content}{footer}
, slot) : null} + { if (event.key === "Escape") event.stopPropagation(); }}> @@ -184,15 +223,12 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus }: { {transferring ? "Your transfer, from departure to arrival." : "Same wallet. A new network."} - {transferring ? : } -
- Powered by Relay - 0 Openlaunch fee -
+ {content} + {footer}
- {connecting ? { setConnecting(false); onOpenChange(true); }} /> : null} + {connecting ? { setConnecting(false); if (!onPage) onOpenChange(true); }} /> : null} ); } @@ -219,14 +255,20 @@ 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 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; + + // the button and the slide share this one path, with the same guards + function submit() { + if (!b.address) { connect(); return; } + if (loading || b.allowanceLoading || !b.canQuote) return; + void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); + } return ( -
{ - event.preventDefault(); - if (!b.address) { connect(); return; } - if (loading || b.allowanceLoading || !b.canQuote) return; - void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); - }}> + { event.preventDefault(); submit(); }}> +
@@ -271,7 +313,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.address ? : null} {error ?

{error}

: null} -