From b30a207082f47e26fca948afa9153bac047ee841 Mon Sep 17 00:00:00 2001 From: JW-Rami <106039941+JW-Rami@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:57:11 +0200 Subject: [PATCH] refactor: extract reusable transfer components --- README.md | 33 +- docs/adr/0003-reusable-transfer-ui.md | 35 + docs/repo-actions.md | 2 +- src/App.test.tsx | 41 ++ src/App.tsx | 258 ++----- src/invisible/README.md | 35 +- .../components/PayoutWindowPicker.tsx | 32 + .../components/PrivateTransferWidget.tsx | 193 +++++ src/invisible/components/TransferActivity.tsx | 63 ++ src/invisible/components/TransferDetails.tsx | 234 ++++++ src/invisible/components/TransferForm.tsx | 152 ++++ src/invisible/components/TransferReview.tsx | 56 ++ src/invisible/index.ts | 1 + src/invisible/invisible.test.tsx | 50 +- src/invisible/presentation.ts | 18 + src/invisible/styles.css | 676 ++++++++++++++++++ src/invisible/ui.ts | 13 + src/invisible/ui/CopyValue.tsx | 35 + src/invisible/ui/Dialog.tsx | 36 + src/invisible/ui/ErrorMessage.tsx | 12 + src/invisible/ui/Icons.tsx | 40 ++ src/invisible/usePrivateTransferController.ts | 108 +++ src/style.css | 276 +++---- 23 files changed, 2014 insertions(+), 385 deletions(-) create mode 100644 docs/adr/0003-reusable-transfer-ui.md create mode 100644 src/invisible/components/PayoutWindowPicker.tsx create mode 100644 src/invisible/components/PrivateTransferWidget.tsx create mode 100644 src/invisible/components/TransferActivity.tsx create mode 100644 src/invisible/components/TransferDetails.tsx create mode 100644 src/invisible/components/TransferForm.tsx create mode 100644 src/invisible/components/TransferReview.tsx create mode 100644 src/invisible/presentation.ts create mode 100644 src/invisible/styles.css create mode 100644 src/invisible/ui.ts create mode 100644 src/invisible/ui/CopyValue.tsx create mode 100644 src/invisible/ui/Dialog.tsx create mode 100644 src/invisible/ui/ErrorMessage.tsx create mode 100644 src/invisible/ui/Icons.tsx create mode 100644 src/invisible/usePrivateTransferController.ts diff --git a/README.md b/README.md index cf61261..0cc36da 100644 --- a/README.md +++ b/README.md @@ -40,36 +40,29 @@ npm install @invisible-labs/sdk@0.9.0-devnet The folder depends only on React and the public SDK. It imports no app components, routing, design system, Vite environment variables or private workspace packages. ```tsx +import { useState } from "react"; import { invisibleDevnet } from "@invisible-labs/sdk/presets"; -import { useInvisibleSession, usePrivateTransfers, useTransferStatus } from "./invisible"; - -const coordinatorUrl = invisibleDevnet(); +import { usePrivateTransferController } from "./invisible"; +import { PrivateTransferWidget, type TransferTab } from "./invisible/ui"; +import "./invisible/styles.css"; function PrivateTransfer() { - const connection = useInvisibleSession(coordinatorUrl); - const transfers = usePrivateTransfers(connection.session, coordinatorUrl); - const status = useTransferStatus(connection.session, transfers.selected, connection.fail); - - return <> - - -

{status.view?.state}

-

{connection.error?.message ?? transfers.error?.message ?? status.error?.message}

- ; + const controller = usePrivateTransferController(invisibleDevnet()); + const [tab, setTab] = useState("transfer"); + return ; } ``` -See [`src/App.tsx`](src/App.tsx) for the complete deposit, saved-transfer, Recovery Code and refund UI. See the [folder guide](src/invisible/README.md) for lifecycle and integration details. +The controller opens one attested session and coordinates the existing hooks. The widget provides address input, review, deposit instructions, activity and refunds. It never opens another session. The parent controls the tab so navigation can be shared with its own header. + +For your own design, import the controller or individual hooks from `./invisible`; import only the display modules you need from `./invisible/ui`. UI styles are scoped to `.invisible-widget`. Wrap standalone display modules in that class, and customize `--invisible-*` variables on the wrapper. Do not copy the demo's global `src/style.css`. + +See the [folder guide](src/invisible/README.md) for module interfaces, lifecycle and safety rules. [`src/App.tsx`](src/App.tsx) only supplies the demo route, header and navigation. ## Ownership and safety - **SDK:** WebSocket transport, Noise, attestation, DKG/delegation, policy validation, recovery codes, typed errors and local transfer records. -- **Hooks:** React cleanup, command concurrency, selected transfer, polling, refresh subscriptions and readable errors. +- **Hooks/controller:** React cleanup, command concurrency, selected transfer, polling, refresh subscriptions, deposit/refund guards and readable errors. - **Application:** form/UI, external wallet deposit and your storage/privacy policy. - `SyncRequired` messages carry refresh hints. Only `sync` provides lifecycle truth. - Polling never overlaps; retries apply to reads. A failed attestation stops the session. Disconnect closes it; reconnect creates and attests a fresh one. Select a saved transfer to resume reads. diff --git a/docs/adr/0003-reusable-transfer-ui.md b/docs/adr/0003-reusable-transfer-ui.md new file mode 100644 index 0000000..6bf109b --- /dev/null +++ b/docs/adr/0003-reusable-transfer-ui.md @@ -0,0 +1,35 @@ +# ADR-0003: Reusable transfer interface + +Status: Accepted +Date: 2026-10-08 + +## Problem + +The SDK hooks were copyable, but the demo App mixed session orchestration, financial eligibility and all display modules. Integrators could not copy the complete interface independently of the demo layout. + +## Options considered + +- Split JSX alone: financial guards remain tied to the demo. +- Add a global store/provider: introduces another lifecycle and dependency surface. +- Compose the existing hooks in one controller and export separate display modules. + +## Decision + +Use one headless controller per integration and pass it to display modules. Keep everything reusable inside `src/invisible/`, with separate headless/UI exports and scoped CSS. App owns demo configuration, layout and navigation. Keep draft input in the form and SDK protocol behavior in the existing hooks/package. + +## Why + +A consumer can copy one folder, use the complete widget or replace its presentation, while retaining the same command guards and authoritative status handling. + +## Risks + +A consumer composing individual modules must retain eligibility and review confirmation. Scoped CSS requires native nesting; dialogs/popovers/Web Locks retain their existing browser requirements. Widgets on the same origin share mutation markers. + +## Follow-up + +Verify the copied folder in an independent host, multiple instances, keyboard/mobile behavior and financial failure guards. Revisit dependencies only for a measured requirement. + +## References + +- [Integration interfaces](../../src/invisible/README.md) +- [Original integration decision](0001-copyable-public-sdk-hooks.md) diff --git a/docs/repo-actions.md b/docs/repo-actions.md index 6a04996..9435163 100644 --- a/docs/repo-actions.md +++ b/docs/repo-actions.md @@ -5,7 +5,7 @@ | `npm ci` | Install the pinned public dependencies | Lockfile installed; no GitHub token required | | `npm run dev` | Start Vite locally | URL printed; SDK session opens on page load; no financial mutation until review confirmation | | `npm run typecheck` | Validate TypeScript and public SDK types | Exit 0 | -| `npm test` | Run adapter behavior tests | All tests pass | +| `npm test` | Run hook/controller and reusable UI behavior tests | All tests pass | | `npm run build` | Typecheck and produce static assets | `dist/` created | | `npm run preview` | Serve the production bundle locally | Preview URL printed | | `npm run check` | Run tests and production build | Exit 0 | diff --git a/src/App.test.tsx b/src/App.test.tsx index f7c776a..c07656a 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,8 +1,11 @@ +import { createRef, useState, type FormEvent } from "react"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { createSession, type Session, TransportError } from "@invisible-labs/sdk"; import { contractRequest, listPersistedTransfers, requestRefund, sync, type ContractRequestResult, type PersistedTransfer, SUPPORTED_TOTAL_DEADLINE_MS } from "@invisible-labs/sdk/user"; import App from "./App"; +import { usePrivateTransferController } from "./invisible"; +import { PrivateTransferWidget, TransferReview, type TransferTab } from "./invisible/ui"; vi.mock("@invisible-labs/sdk", async importOriginal => { const actual = await importOriginal(); @@ -123,3 +126,41 @@ it("never displays expired or terminal deposit instructions or terminal bearer m expect(screen.queryByText("Recovery Code")).toBeNull(); expect(screen.queryByRole("button", { name: "Request refund" })).toBeNull(); }); + +it("two copied widgets keep independent inputs, payout choices and accessible identifiers", async () => { + const COORDINATOR_URL = "wss://coordinator.example/ws-noise"; + function Integration({ name }: { name: string }) { + const controller = usePrivateTransferController(COORDINATOR_URL); + const [tab, setTab] = useState("transfer"); + return
; + } + render(<>); + await waitFor(() => expect(createSession).toHaveBeenCalledTimes(2)); + const first = screen.getByRole("region", { name: "First widget" }); + const second = screen.getByRole("region", { name: "Second widget" }); + fireEvent.change(within(first).getByLabelText("Destination address"), { target: { value: DESTINATION } }); + expect((within(second).getByLabelText("Destination address") as HTMLInputElement).value).toBe(""); + const firstChoices = within(first).getAllByRole("radio", { hidden: true }); + const secondChoices = within(second).getAllByRole("radio", { hidden: true }); + fireEvent.click(firstChoices[1]); + expect((firstChoices[1] as HTMLInputElement).checked).toBe(true); + expect((secondChoices[0] as HTMLInputElement).checked).toBe(true); + const ids = [...document.querySelectorAll("[id]")].map(node => node.id); + expect(new Set(ids).size).toBe(ids.length); + expect(within(first).getByRole("button", { name: "Transfer settings" }).getAttribute("popovertarget")) + .not.toBe(within(second).getByRole("button", { name: "Transfer settings" }).getAttribute("popovertarget")); + expect(contractRequest).not.toHaveBeenCalled(); + expect(requestRefund).not.toHaveBeenCalled(); +}); + +it("the reusable review confirms without submitting its host form", async () => { + const ref = createRef(); + const onConfirm = vi.fn(async () => {}); + const onSubmit = vi.fn((event: FormEvent) => event.preventDefault()); + render(
); + act(() => ref.current!.showModal()); + fireEvent.click(screen.getByRole("button", { name: "Create private transfer" })); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + expect(contractRequest).not.toHaveBeenCalled(); +}); diff --git a/src/App.tsx b/src/App.tsx index 0366faa..9075d4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,207 +1,61 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; -import { publicKey } from "@invisible-labs/sdk"; +import { useState } from "react"; import { invisibleDevnet } from "@invisible-labs/sdk/presets"; -import { - INSTANT_PAYOUT_WINDOW_MS, MIN_ENTRY_AMOUNT_LAMPORTS, MAX_ENTRY_AMOUNT_LAMPORTS, - SUPPORTED_TOTAL_DEADLINE_MS, recoveryCodeToHex, -} from "@invisible-labs/sdk/user"; -import { formatSol, isTerminalState, parseSolAmount, toUiError, useInvisibleSession, usePrivateTransfers, useTransferStatus, type InvisibleUiError } from "./invisible"; - -const COORDINATOR_URL = import.meta.env.VITE_INVISIBLE_COORDINATOR_URL?.trim() || invisibleDevnet(); -const MILLISECONDS_PER_MINUTE = 60_000; -const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE; -const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR; -const ADDRESS_PREVIEW_LENGTH = 6; -const SOURCE_URL = "https://github.com/Invisible-Labs/invisible-sdk-react-example"; +import { usePrivateTransferController } from "./invisible"; +import { PrivateTransferWidget, type TransferTab } from "./invisible/ui"; +import "./invisible/styles.css"; + +const COORDINATOR_URL = + import.meta.env.VITE_INVISIBLE_COORDINATOR_URL?.trim() || invisibleDevnet(); +const SOURCE_URL = + "https://github.com/Invisible-Labs/invisible-sdk-react-example"; const DOCS_URL = "https://docs.invisible.exchange/docs/sdk/"; -const SETTINGS_ID = "transfer-settings"; -const HELP_ID = "integration-help"; -const shortAddress = (address: string) => `${address.slice(0, ADDRESS_PREVIEW_LENGTH)}...${address.slice(-ADDRESS_PREVIEW_LENGTH)}`; -function windowLabel(windowMs: number) { - if (windowMs === INSTANT_PAYOUT_WINDOW_MS) return "Instant"; - const [duration, unit] = windowMs >= MILLISECONDS_PER_DAY ? [windowMs / MILLISECONDS_PER_DAY, "day"] : windowMs >= MILLISECONDS_PER_HOUR ? [windowMs / MILLISECONDS_PER_HOUR, "hour"] : [windowMs / MILLISECONDS_PER_MINUTE, "minute"]; - return `${duration} ${unit}${duration === 1 ? "" : "s"}`; -} -const PHASE_LABELS: Record = { - awaiting_deposit: "Waiting for deposit", activating: "Activating transfer", waiting_for_liquidity: "Waiting for liquidity", - retry_delayed: "Settlement delayed", recovery_required: "Recovery needed", matched_scheduled: "Payout scheduled", - locked_pending_payout: "Preparing payout", submitted: "Payout submitted", confirming: "Confirming payout", - completed: "Transfer complete", refund_pending: "Refund pending", refunded: "Refund complete", expired: "Transfer expired", -}; -const ICON_PATHS = { - arrow: "M12 4v16m-7-7 7 7 7-7", chevron: "m6 9 6 6 6-6", close: "m6 6 12 12M6 18 18 6", - clock: "M12 7v5l3 2", check: "m5 12 4 4L19 6", copy: "M9 9h11v11H9zM5 15H3V3h12v2", - help: "M9.5 8a2.5 2.5 0 1 1 4 2c-1 .6-1.5 1-1.5 2m0 4h.01", - settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1z", -} as const; -function Icon({ name }: { name: keyof typeof ICON_PATHS }) { - return ; -} -function SolIcon() { - return ; -} -function ErrorMessage({ error }: { error: InvisibleUiError | null }) { - return error ?

{error.message}{error.remoteAccepted ? " This request was already accepted. Check its saved status before submitting again." : ""}

: null; -} -function CopyValue({ label, value }: { label: string; value: string }) { - const [error, setError] = useState(null); - const [copied, setCopied] = useState(false); - async function copy() { - try { await navigator.clipboard.writeText(value); setCopied(true); setError(null); } - catch (error) { setError(toUiError(error)); } - } - return
{label}
{value}
; -} export default function App() { - const connection = useInvisibleSession(COORDINATOR_URL); - const transfers = usePrivateTransfers(connection.session, COORDINATOR_URL); - const status = useTransferStatus(connection.session, transfers.selected, connection.fail); - const [tab, setTab] = useState<"transfer" | "activity">("transfer"); - const [amountSol, setAmountSol] = useState(""); - const [destination, setDestination] = useState(""); - const [deadlineMs, setDeadlineMs] = useState(INSTANT_PAYOUT_WINDOW_MS); - const [formError, setFormError] = useState(null); - const reviewDialog = useRef(null); - const transfer = transfers.selected; - const snapshot = status.view?.actor_sync?.snapshot; - const isTerminal = status.view ? isTerminalState(status.view.state) : transfer?.stage === "terminal-cleaned"; - const ready = connection.phase === "connected"; - const canCreate = ready && transfers.loaded && !transfers.busy && !transfers.creationBlocked; - const canRefund = ready && status.fresh && snapshot?.refundability === "available_now" && !transfers.refundBlocked && !transfers.busy; - const depositExpiry = transfer?.depositExpiresAtMs; - const coordinatorNow = Date.now() + ((transfer?.coordinatorTimestampMs ?? transfer?.updatedAtMs ?? Date.now()) - (transfer?.updatedAtMs ?? Date.now())); - const depositExpired = depositExpiry !== undefined && coordinatorNow >= depositExpiry; - const canDeposit = !depositExpired && ready && transfer?.stage === "ready" && transfer.status === "awaiting_deposit" && - !isTerminal && (status.view === null || status.view.state === "awaiting_deposit"); - - // Session setup is read-only. Financial commands remain explicit user actions. - useEffect(() => { void connection.connect(); }, [connection.connect]); - useEffect(() => { - if (status.fresh && status.view) { - transfers.reconcileRefund(status.view); - if (isTerminalState(status.view.state)) void transfers.reload().catch(connection.fail); - } - }, [status.fresh, status.view, transfers.reconcileRefund, transfers.reload, connection.fail]); - - function review(event: FormEvent) { - event.preventDefault(); - if (!canCreate) return; - try { - parseSolAmount(amountSol); - const address = destination.trim(); - publicKey(address); - setDestination(address); - setFormError(null); - reviewDialog.current?.showModal(); - } catch (error) { setFormError(toUiError(error)); } - } - async function create() { - if (!canCreate) return; - reviewDialog.current?.close(); - await transfers.createTransfer({ amountSol, destination, deadlineMs }); - setTab("activity"); - } - function reconnect() { connection.disconnect(); void connection.connect(); } - - return <> -
- Invisible - - Solana devnet -
- -
-
-
{ - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = event.key === "Home" ? "transfer" : event.key === "End" ? "activity" : tab === "transfer" ? "activity" : "transfer"; - setTab(next); - event.currentTarget.querySelector(`#${next}-tab`)?.focus(); - }}> - - -
-
- - -
-
-
-

Transfer settings

-
Payout window{SUPPORTED_TOTAL_DEADLINE_MS.map(windowMs => )}
-
NetworkSolana devnet
-

Scheduled payouts follow the accepted coordinator policy. Only devnet SOL is supported.

-
- - - - - {transfers.creationBlocked &&

An incomplete or uncertain request is saved. before creating another transfer.

} - -
- -
-

Review transfer

-
{amountSol || "0"} SOL

Private transfer on Solana devnet

-
Recipient
{destination}
Payout window
{windowLabel(deadlineMs)}
-

Create the deposit address, save your Recovery Code, then send the exact deposit from your own wallet. The accepted policy determines fees and the final payout.

- -
- -

Invisible example

Devnet beta. Your Recovery Code is stored in this browser. Browser storage is not an encrypted vault.

SDK documentation ↗Source and integration guide ↗
- ; -} -function RefundForm({ hasRecoveryCode, canRefund, blocked, onRefund }: { hasRecoveryCode: boolean; canRefund: boolean; blocked: boolean; onRefund: (code: string) => Promise }) { - const [code, setCode] = useState(""); - return
{ event.preventDefault(); if (canRefund) void onRefund(code).then(() => setCode("")); }}> - {!hasRecoveryCode && } -

Refunds return to the original deposit wallet. Acceptance is pending until status confirms the outcome.

- {blocked &&

A refund is pending or uncertain. Refresh status before submitting again.

} -
; + const controller = usePrivateTransferController(COORDINATOR_URL); + const [tab, setTab] = useState("transfer"); + const { connection } = controller; + return ( + <> +
+ + + Invisible + + + + + Solana devnet + +
+ +
+ +
+ + ); } diff --git a/src/invisible/README.md b/src/invisible/README.md index b31b382..f213b58 100644 --- a/src/invisible/README.md +++ b/src/invisible/README.md @@ -2,6 +2,12 @@ Imports: `react`, `@invisible-labs/sdk`, `/user` and `/events`. The root app selects its route through the SDK's `/presets` entrypoint. Copy every module here; tests are optional in the consuming application. +## Entrypoints + +- `index.ts`: hooks and controller, without UI or CSS imports. +- `ui.ts`: reusable display modules. Import `styles.css` separately. +- `usePrivateTransferController(url)`: opens one session, composes the hooks, reconciles fresh refund status and exposes guarded actions and display eligibility. Terminal or unrelated snapshots cannot authorize a refund. Create one controller per integration and pass it to its display modules. + ## Hooks | Hook | Owns | Main outputs | @@ -61,8 +67,33 @@ SDK automatic persistence does not contain the material required for arbitrary p ## Customize -Keep the hooks and replace `App.tsx` with your UI. Use the SDK's amount limits and supported payout windows, and its `publicKey`/Recovery Code constructors. Keep `busy`, `creationBlocked`, fresh refund eligibility and cleanup guards when replacing buttons. +Keep the hooks/controller and replace the display modules with your UI. Use the SDK's amount limits and supported payout windows, and its `publicKey`/Recovery Code constructors. Keep `busy`, `creationBlocked`, fresh refund eligibility and cleanup guards when replacing buttons. -If you add refund UI, call `reconcileRefund(status.view)` after a fresh sync and refresh after requesting a refund, as `App.tsx` does. An acceptance receipt must never render as a completed refund. +The controller already calls `reconcileRefund(status.view)` after a fresh sync and refreshes after requesting a refund. When composing only the individual hooks, retain that same ordering. An acceptance receipt must never render as a completed refund. Wallet connection and deposit submission belong to your existing wallet layer. Do not automatically send or repeat a wallet transaction from a polling effect. + +## Display modules + +| Export from `ui.ts` | Interface | +| --- | --- | +| `PrivateTransferWidget` | `controller`, controlled `tab`, `onTabChange` | +| `TransferForm` | Amount/address state and review; supplied deadline, eligibility, phase and callbacks | +| `PayoutWindowPicker` | Controlled SDK-supported `value` and `onChange` | +| `TransferReview` | Validated input, dialog ref, eligibility and explicit confirmation callback | +| `TransferActivity` / `TransferDetails` | Shared controller for saved transfers, deposit/recovery display and refunds | +| `Dialog`, `CopyValue`, `ErrorMessage`, icons | Shared display behavior, without opening SDK sessions | + +Use `PrivateTransferWidget` for a complete interface, or compose these modules under a `.invisible-widget` wrapper. Every instance creates its own input/dialog/popover identifiers and radio-group name. The controller owns protocol-related eligibility; the form owns draft values; the parent/widget owns navigation and payout selection. + +`styles.css` uses scoped native CSS nesting. It changes no body, root or sibling styles. Customize tokens on your wrapper, for example: + +```css +.invisible-widget { + --invisible-accent: #8b5cf6; + --invisible-accent-hover: #a78bfa; + --invisible-accent-tint: #8b5cf614; +} +``` + +Reuse native dialog/popover behavior and retain the keyboard focus styles. Two controllers using the same coordinator and origin share the existing mutation-marker protection; separate widgets do not create a new financial authority. diff --git a/src/invisible/components/PayoutWindowPicker.tsx b/src/invisible/components/PayoutWindowPicker.tsx new file mode 100644 index 0000000..ac91caf --- /dev/null +++ b/src/invisible/components/PayoutWindowPicker.tsx @@ -0,0 +1,32 @@ +import { useId } from "react"; +import { SUPPORTED_TOTAL_DEADLINE_MS } from "@invisible-labs/sdk/user"; +import { windowLabel } from "../presentation"; +import { Icon } from "../ui/Icons"; + +export function PayoutWindowPicker({ + value, + onChange, +}: { + value: number; + onChange: (windowMs: number) => void; +}) { + const name = useId(); + return ( +
+ Payout window + {SUPPORTED_TOTAL_DEADLINE_MS.map((windowMs) => ( + + ))} +
+ ); +} diff --git a/src/invisible/components/PrivateTransferWidget.tsx b/src/invisible/components/PrivateTransferWidget.tsx new file mode 100644 index 0000000..ac378ae --- /dev/null +++ b/src/invisible/components/PrivateTransferWidget.tsx @@ -0,0 +1,193 @@ +import { useId, useState } from "react"; +import { INSTANT_PAYOUT_WINDOW_MS } from "@invisible-labs/sdk/user"; +import { PayoutWindowPicker } from "./PayoutWindowPicker"; +import type { PrivateTransferController } from "../usePrivateTransferController"; +import { Icon } from "../ui/Icons"; +import { ErrorMessage } from "../ui/ErrorMessage"; +import { TransferForm } from "./TransferForm"; +import { TransferActivity } from "./TransferActivity"; + +export type TransferTab = "transfer" | "activity"; +const SOURCE_URL = + "https://github.com/Invisible-Labs/invisible-sdk-react-example"; +const DOCS_URL = "https://docs.invisible.exchange/docs/sdk/"; + +export function PrivateTransferWidget({ + controller, + tab, + onTabChange, +}: { + controller: PrivateTransferController; + tab: TransferTab; + onTabChange: (tab: TransferTab) => void; +}) { + const settingsId = useId(); + const [deadlineMs, setDeadlineMs] = useState(INSTANT_PAYOUT_WINDOW_MS); + const helpId = useId(); + const transferTabId = useId(); + const activityTabId = useId(); + const transferPanelId = useId(); + const activityPanelId = useId(); + const { transfers, connection, canCreate } = controller; + return ( +
+
+
{ + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) + return; + event.preventDefault(); + const next = + event.key === "Home" + ? "transfer" + : event.key === "End" + ? "activity" + : tab === "transfer" + ? "activity" + : "transfer"; + onTabChange(next); + event.currentTarget + .querySelector( + `[id="${next === "transfer" ? transferTabId : activityTabId}"]`, + ) + ?.focus(); + }} + > + + +
+
+ + + +
+
+
+

Transfer settings

+ +
+ Network + Solana devnet +
+

+ Scheduled payouts follow the accepted coordinator policy. Only devnet + SOL is supported. +

+
+ + + + {transfers.creationBlocked && ( +

+ An incomplete or uncertain request is saved.{" "} + {" "} + before creating another transfer. +

+ )} + + +
+

Invisible example

+

+ Devnet beta. Your Recovery Code is stored in this browser. Browser + storage is not an encrypted vault. +

+ + SDK documentation ↗ + + + Source and integration guide ↗ + + +
+
+ ); +} diff --git a/src/invisible/components/TransferActivity.tsx b/src/invisible/components/TransferActivity.tsx new file mode 100644 index 0000000..c5b0c23 --- /dev/null +++ b/src/invisible/components/TransferActivity.tsx @@ -0,0 +1,63 @@ +import { formatSol } from "../amount"; +import { shortAddress } from "../presentation"; +import type { PrivateTransferController } from "../usePrivateTransferController"; +import { Icon, SolIcon } from "../ui/Icons"; +import { TransferDetails } from "./TransferDetails"; + +export function TransferActivity({ + controller, + onCreate, +}: { + controller: PrivateTransferController; + onCreate: () => void; +}) { + const { transfers, connection } = controller; + return ( + <> + {transfers.transfers.length === 0 ? ( +
+ +

No transfers yet

+

Your saved transfers will appear here.

+ +
+ ) : ( + <> +
+ {transfers.transfers.map((record) => ( + + ))} +
+ + + )} + {connection.phase === "error" && ( + + )} + + ); +} diff --git a/src/invisible/components/TransferDetails.tsx b/src/invisible/components/TransferDetails.tsx new file mode 100644 index 0000000..c99356e --- /dev/null +++ b/src/invisible/components/TransferDetails.tsx @@ -0,0 +1,234 @@ +import { useState } from "react"; +import { recoveryCodeToHex } from "@invisible-labs/sdk/user"; +import { formatSol } from "../amount"; +import { shortAddress } from "../presentation"; +import type { PrivateTransferController } from "../usePrivateTransferController"; +import { Icon } from "../ui/Icons"; +import { ErrorMessage } from "../ui/ErrorMessage"; +import { CopyValue } from "../ui/CopyValue"; + +const PHASE_LABELS: Record = { + awaiting_deposit: "Waiting for deposit", + activating: "Activating transfer", + waiting_for_liquidity: "Waiting for liquidity", + retry_delayed: "Settlement delayed", + recovery_required: "Recovery needed", + matched_scheduled: "Payout scheduled", + locked_pending_payout: "Preparing payout", + submitted: "Payout submitted", + confirming: "Confirming payout", + completed: "Transfer complete", + refund_pending: "Refund pending", + refunded: "Refund complete", + expired: "Transfer expired", +}; +export function TransferDetails({ + controller, +}: { + controller: PrivateTransferController; +}) { + const { + transfer, + status, + snapshot, + ready, + isTerminal, + canDeposit, + depositExpiry, + depositExpired, + canRefund, + transfers, + requestRefund, + } = controller; + if (!transfer) return null; + return ( +
+
+

+ {snapshot + ? (PHASE_LABELS[snapshot.settlement_phase] ?? + snapshot.settlement_phase) + : (status.view?.state ?? transfer.status)} +

+ +
+ {!status.fresh && ( +

Last known state. Reconnect to refresh.

+ )} + {canDeposit && ( + <> +

+ Save your Recovery Code, then send exactly{" "} + {formatSol(transfer.amountLamports)} devnet SOL to + this deposit address. +

+ + {depositExpiry && ( +

+ Deposit before{" "} + + . Do not fund an expired address. +

+ )} + + )} + {depositExpired && transfer.stage === "ready" && !isTerminal && ( +

+ The deposit window expired. Do not send funds; refresh status. +

+ )} + {transfer.stage === "allocation" && ( +

+ Initialization did not finish. Do not send funds. Check the saved + transfer before trying again. +

+ )} + {transfer.recoveryCode && !isTerminal && ( +
+ + Recovery Code Keep private + + +
+ )} + {snapshot && ( +
+
+
Net payout
+
+ {formatSol(transfer.policySnapshot.net_payout_lamports)} SOL +
+
+
+
Settled
+
{formatSol(snapshot.settled_amount_lamports)} SOL
+
+
+
Refund availability
+
{snapshot.refundability.replaceAll("_", " ")}
+
+
+
Refund outcome
+
{snapshot.refund_status ?? "None"}
+
+
+ )} + {status.view && ( +
    + {status.view.fragments_executed.map((payout) => ( +
  • + + {formatSol(payout.amount_lamports)} SOL to{" "} + {shortAddress(payout.destination_address)} + + + View payout ↗ + +
  • + ))} + {status.view.withdraw_execution && ( +
  • + + {formatSol(status.view.withdraw_execution.amount_lamports)} SOL + refunded to{" "} + {shortAddress( + status.view.withdraw_execution.destination_address, + )} + + + View refund ↗ + +
  • + )} +
+ )} + + {!isTerminal && ( + + )} +
+ ); +} +function RefundForm({ + hasRecoveryCode, + canRefund, + blocked, + onRefund, +}: { + hasRecoveryCode: boolean; + canRefund: boolean; + blocked: boolean; + onRefund: (code: string) => Promise; +}) { + const [code, setCode] = useState(""); + return ( +
{ + event.preventDefault(); + if (canRefund) void onRefund(code).then(() => setCode("")); + }} + > + {!hasRecoveryCode && ( + + )} +

+ Refunds return to the original deposit wallet. Acceptance is pending + until status confirms the outcome. +

+ + {blocked && ( +

+ A refund is pending or uncertain. Refresh status before submitting + again. +

+ )} +
+ ); +} diff --git a/src/invisible/components/TransferForm.tsx b/src/invisible/components/TransferForm.tsx new file mode 100644 index 0000000..dcb12ad --- /dev/null +++ b/src/invisible/components/TransferForm.tsx @@ -0,0 +1,152 @@ +import { useId, useRef, useState, type FormEvent } from "react"; +import { publicKey } from "@invisible-labs/sdk"; +import { + MIN_ENTRY_AMOUNT_LAMPORTS, + MAX_ENTRY_AMOUNT_LAMPORTS, +} from "@invisible-labs/sdk/user"; +import { formatSol, parseSolAmount } from "../amount"; +import { toUiError, type InvisibleUiError } from "../errors"; +import type { PrivateTransferController } from "../usePrivateTransferController"; +import { windowLabel } from "../presentation"; +import { Icon, SolIcon } from "../ui/Icons"; +import { ErrorMessage } from "../ui/ErrorMessage"; +import { TransferReview } from "./TransferReview"; + +export function TransferForm({ + deadlineMs, + canCreate, + busy, + phase, + onCreate, + onReconnect, +}: { + deadlineMs: number; + canCreate: boolean; + busy: boolean; + phase: PrivateTransferController["connection"]["phase"]; + onCreate: PrivateTransferController["createTransfer"]; + onReconnect: () => void; +}) { + const amountId = useId(); + const destinationId = useId(); + const [amountSol, setAmountSol] = useState(""); + const [destination, setDestination] = useState(""); + const [formError, setFormError] = useState(null); + const reviewDialog = useRef(null); + const ready = phase === "connected"; + function review(event: FormEvent) { + event.preventDefault(); + if (!canCreate) return; + try { + parseSolAmount(amountSol); + const address = destination.trim(); + publicKey(address); + setDestination(address); + setFormError(null); + reviewDialog.current?.showModal(); + } catch (error) { + setFormError(toUiError(error)); + } + } + async function create() { + if (!canCreate) return; + reviewDialog.current?.close(); + await onCreate({ amountSol, destination, deadlineMs }); + } + + return ( + <> +
+
+ +
+ setAmountSol(event.target.value)} + /> + + + SOL + +
+
+ Solana devnet + + {formatSol(MIN_ENTRY_AMOUNT_LAMPORTS)} -{" "} + {formatSol(MAX_ENTRY_AMOUNT_LAMPORTS)} SOL + +
+
+ +
+ +
+ + setDestination(event.target.value)} + /> +
+
+ Solana devnet + {windowLabel(deadlineMs)} +
+
+ + {phase === "error" && ( + + )} +
+

+ No wallet connection needed. Fund the deposit from your own wallet. +

+ + + + ); +} diff --git a/src/invisible/components/TransferReview.tsx b/src/invisible/components/TransferReview.tsx new file mode 100644 index 0000000..5400a32 --- /dev/null +++ b/src/invisible/components/TransferReview.tsx @@ -0,0 +1,56 @@ +import type { Ref } from "react"; +import type { PrivateTransferInput } from "../usePrivateTransferController"; +import { windowLabel } from "../presentation"; +import { SolIcon } from "../ui/Icons"; +import { Dialog } from "../ui/Dialog"; + +export function TransferReview({ + input, + canCreate, + onConfirm, + ref, +}: { + input: PrivateTransferInput; + canCreate: boolean; + onConfirm: () => Promise; + ref: Ref; +}) { + const { amountSol, destination, deadlineMs } = input; + return ( + +
+ + {amountSol || "0"} SOL +
+

Private transfer on Solana devnet

+
+
+
Recipient
+
{destination}
+
+
+
Payout window
+
{windowLabel(deadlineMs)}
+
+
+

+ Create the deposit address, save your Recovery Code, then send the exact + deposit from your own wallet. The accepted policy determines fees and + the final payout. +

+ +
+ ); +} diff --git a/src/invisible/index.ts b/src/invisible/index.ts index 417d47c..3863fc5 100644 --- a/src/invisible/index.ts +++ b/src/invisible/index.ts @@ -3,3 +3,4 @@ export { usePrivateTransfers } from "./usePrivateTransfers"; export { useTransferStatus, isTerminalState } from "./useTransferStatus"; export { parseSolAmount, formatSol } from "./amount"; export { toUiError, type InvisibleUiError } from "./errors"; +export { usePrivateTransferController, type PrivateTransferController, type PrivateTransferInput } from "./usePrivateTransferController"; diff --git a/src/invisible/invisible.test.tsx b/src/invisible/invisible.test.tsx index 73259cf..62600f4 100644 --- a/src/invisible/invisible.test.tsx +++ b/src/invisible/invisible.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, renderHook } from "@testing-library/react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AttestationError, CommandError, StorageError, TransportError, closeSession, createSession, type Session } from "@invisible-labs/sdk"; import { contractRequest, listPersistedTransfers, requestRefund, type ContractRequestResult, type PersistedTransfer, type SyncUserResponse } from "@invisible-labs/sdk/user"; @@ -8,6 +8,7 @@ import { toUiError } from "./errors"; import { hasPendingMutation, mutationKey, runMutation } from "./mutations"; import { useInvisibleSession } from "./useInvisibleSession"; import { usePrivateTransfers } from "./usePrivateTransfers"; +import { usePrivateTransferController } from "./usePrivateTransferController"; import { useTransferStatus } from "./useTransferStatus"; import { sync } from "@invisible-labs/sdk/user"; @@ -262,3 +263,50 @@ describe("sync and WebSocket hints", () => { expect(requestRefund).not.toHaveBeenCalled(); }); }); + +it("the composed controller restores records, gates commands and keeps an uncertain refund blocked after reconnect", async () => { + vi.mocked(createSession).mockResolvedValue(session); + vi.mocked(requestRefund).mockRejectedValue(new TransportError("CONNECTION_LOST", "Unknown refund")); + const { result } = renderHook(() => usePrivateTransferController(URL)); + await act(async () => { + await result.current.createTransfer({ amountSol: "1.5", destination: DESTINATION, deadlineMs: 0 }); + await result.current.requestRefund(""); + }); + expect(contractRequest).not.toHaveBeenCalled(); + expect(requestRefund).not.toHaveBeenCalled(); + await waitFor(() => expect(result.current.canRefund).toBe(true)); + expect(createSession).toHaveBeenCalledTimes(1); + await act(async () => { await result.current.requestRefund(""); }); + expect(requestRefund).toHaveBeenCalledExactlyOnceWith(session, { swapId: transfer.swapId, syncSecret: transfer.syncSecret, recoveryCode: transfer.recoveryCode }); + expect(result.current.canRefund).toBe(false); + await act(async () => { result.current.reconnect(); }); + await waitFor(() => expect(createSession).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.transfers.loaded).toBe(true)); + await act(async () => { await result.current.requestRefund(""); }); + expect(requestRefund).toHaveBeenCalledTimes(1); + expect(result.current.transfers.refundBlocked).toBe(true); +}); + +it.each(["completed", "refunded", "failed"] as const)("the headless controller refuses a refund in terminal state %s even with stale eligibility", async state => { + vi.mocked(createSession).mockResolvedValue(session); + vi.mocked(sync).mockResolvedValue({ ...view(), state }); + const { result } = renderHook(() => usePrivateTransferController(URL)); + await waitFor(() => expect(result.current.status.fresh).toBe(true)); + expect(result.current.isTerminal).toBe(true); + expect(result.current.canRefund).toBe(false); + expect(result.current.canDeposit).toBe(false); + await act(async () => { await result.current.requestRefund(""); }); + expect(requestRefund).not.toHaveBeenCalled(); +}); + +it("the headless controller refuses eligibility belonging to another transfer", async () => { + vi.mocked(createSession).mockResolvedValue(session); + vi.mocked(sync).mockResolvedValue(view("other-transfer")); + const { result } = renderHook(() => usePrivateTransferController(URL)); + await waitFor(() => expect(result.current.status.error).not.toBeNull()); + expect(result.current.status.fresh).toBe(false); + expect(result.current.status.view).toBeNull(); + expect(result.current.canRefund).toBe(false); + await act(async () => { await result.current.requestRefund(""); }); + expect(requestRefund).not.toHaveBeenCalled(); +}); diff --git a/src/invisible/presentation.ts b/src/invisible/presentation.ts new file mode 100644 index 0000000..3b53459 --- /dev/null +++ b/src/invisible/presentation.ts @@ -0,0 +1,18 @@ +import { INSTANT_PAYOUT_WINDOW_MS } from "@invisible-labs/sdk/user"; + +const MILLISECONDS_PER_MINUTE = 60_000; +const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE; +const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR; +const ADDRESS_PREVIEW_LENGTH = 6; +export const shortAddress = (address: string) => + `${address.slice(0, ADDRESS_PREVIEW_LENGTH)}...${address.slice(-ADDRESS_PREVIEW_LENGTH)}`; +export function windowLabel(windowMs: number) { + if (windowMs === INSTANT_PAYOUT_WINDOW_MS) return "Instant"; + const [duration, unit] = + windowMs >= MILLISECONDS_PER_DAY + ? [windowMs / MILLISECONDS_PER_DAY, "day"] + : windowMs >= MILLISECONDS_PER_HOUR + ? [windowMs / MILLISECONDS_PER_HOUR, "hour"] + : [windowMs / MILLISECONDS_PER_MINUTE, "minute"]; + return `${duration} ${unit}${duration === 1 ? "" : "s"}`; +} diff --git a/src/invisible/styles.css b/src/invisible/styles.css new file mode 100644 index 0000000..b131fb6 --- /dev/null +++ b/src/invisible/styles.css @@ -0,0 +1,676 @@ +.invisible-widget { + position: relative; + anchor-scope: all; + box-sizing: border-box; + color-scheme: dark; + --invisible-background: #131313; + --invisible-surface: #1f1f1f; + --invisible-surface-hover: #292929; + --invisible-selected: #303030; + --invisible-text: #ffffff; + --invisible-muted: #9b9b9b; + --invisible-quiet: #6b6b6b; + --invisible-accent: #ff37c7; + --invisible-accent-hover: #ff5ed1; + --invisible-accent-tint: #ff37c714; + --invisible-danger: #ff9898; + --invisible-border: #333333; + --invisible-overlay: #00000066; + --invisible-solana: #b89cfa; + --invisible-radius-card: 24px; + --invisible-radius-button: 20px; + --invisible-radius-dialog: 16px; + --invisible-radius-pill: 999px; + --invisible-card-width: 480px; + --invisible-dialog-width: 400px; + --invisible-popover-width: 320px; + --invisible-font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: var(--invisible-font); + color: var(--invisible-text); + background: var(--invisible-background); + line-height: 1.4; + font-synthesis: none; + -webkit-font-smoothing: antialiased; + + * { + box-sizing: border-box; + } + button, + input { + font: inherit; + color: inherit; + } + button, + a, + summary { + -webkit-tap-highlight-color: transparent; + } + button { + border: 0; + cursor: pointer; + background: none; + } + button:disabled { + cursor: not-allowed; + } + a { + color: inherit; + text-decoration: none; + } + a:hover { + color: var(--invisible-text); + } + :focus-visible { + outline: 2px solid var(--invisible-accent); + outline-offset: 3px; + } + button, + a { + transition: + color 100ms, + background-color 100ms, + opacity 100ms; + } + button:not(:disabled):active { + transform: scale(0.98); + } + h2, + p { + margin: 0; + } + h2 { + font-size: 18px; + font-weight: 500; + } + [hidden] { + display: none !important; + } + .muted { + color: var(--invisible-muted); + } + .toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 5px; + margin-bottom: 5px; + height: 36px; + } + .tabs { + display: flex; + gap: 12px; + } + .tabs button { + display: flex; + gap: 6px; + align-items: center; + padding: 8px 12px; + border-radius: var(--invisible-radius-pill); + color: var(--invisible-muted); + font-size: 16px; + font-weight: 500; + } + .tabs button[aria-selected="true"] { + background: var(--invisible-selected); + color: var(--invisible-text); + } + .tabs button:hover { + color: var(--invisible-text); + } + .count { + font-size: 11px; + background: var(--invisible-surface); + padding: 1px 5px; + border-radius: var(--invisible-radius-pill); + } + .toolbar-tools { + display: flex; + gap: 8px; + } + .icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 5px; + border-radius: var(--invisible-radius-pill); + color: var(--invisible-muted); + flex-shrink: 0; + } + .icon-button:hover { + background: var(--invisible-surface-hover); + color: var(--invisible-text); + } + .icon-button:disabled { + opacity: 0.4; + } + .amount-card, + .recipient-card { + height: 136px; + padding: 16px; + border-radius: var(--invisible-radius-card); + } + .amount-card { + border: 1px solid var(--invisible-border); + } + .recipient-card { + background: var(--invisible-surface); + margin-top: 4px; + height: auto; + } + .amount-card > label, + .recipient-card > label { + font-size: 16px; + } + .amount-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 6px 0 8px; + height: 44px; + } + .amount-row input { + font-size: 36px; + font-weight: 400; + line-height: 1.2; + min-width: 0; + } + .amount-row input { + background: none; + border: 0; + padding: 0; + width: 100%; + } + .amount-row input::placeholder { + color: var(--invisible-quiet); + opacity: 1; + } + .card-caption { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 14px; + color: var(--invisible-muted); + } + .token-pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-shrink: 0; + padding: 4px 10px 4px 4px; + height: 38px; + border-radius: var(--invisible-radius-pill); + font-weight: 600; + } + .token-pill { + border: 1px solid var(--invisible-border); + } + .sol-icon { + display: inline-flex; + width: 28px; + height: 28px; + border-radius: var(--invisible-radius-pill); + align-items: center; + justify-content: center; + background: var(--invisible-background); + flex-shrink: 0; + } + .sol-icon svg { + width: 21px; + height: 21px; + fill: var(--invisible-solana); + } + .flow-arrow { + width: 40px; + height: 40px; + border: 4px solid var(--invisible-background); + border-radius: var(--invisible-radius-dialog); + background: var(--invisible-surface); + display: flex; + align-items: center; + justify-content: center; + margin: -18px auto -22px; + position: relative; + z-index: 1; + color: var(--invisible-text); + } + .flow-arrow svg { + width: 24px; + height: 24px; + } + .primary-action, + .secondary-action { + width: 100%; + padding: 16px 20px; + border-radius: var(--invisible-radius-button); + font-size: 18px; + font-weight: 600; + margin-top: 4px; + background: var(--invisible-accent); + min-height: 55px; + } + .primary-action:hover:not(:disabled) { + background: var(--invisible-accent-hover); + } + .primary-action:disabled { + background: var(--invisible-surface); + color: var(--invisible-muted); + } + .secondary-action { + background: var(--invisible-accent-tint); + color: var(--invisible-accent); + font-size: 16px; + } + .secondary-action:disabled { + color: var(--invisible-quiet); + background: var(--invisible-surface); + } + .form-footnote { + color: var(--invisible-quiet); + text-align: center; + font-size: 12px; + margin: 16px 12px; + } + .text-button { + color: var(--invisible-accent); + padding: 0; + font-size: inherit; + } + .text-button:hover { + color: var(--invisible-accent-hover); + } + .text-button:disabled { + opacity: 0.5; + } + .retry-button { + display: block; + margin: 12px auto; + } + .hint { + font-size: 13px; + color: var(--invisible-muted); + line-height: 1.5; + } + .error, + .notice { + font-size: 14px; + line-height: 1.5; + margin-top: 12px; + overflow-wrap: anywhere; + } + .error { + color: var(--invisible-danger); + } + .notice { + color: var(--invisible-muted); + padding: 12px; + background: var(--invisible-surface); + border-radius: var(--invisible-radius-dialog); + } + [popover], + dialog { + color: var(--invisible-text); + background: var(--invisible-background); + border: 1px solid var(--invisible-border); + border-radius: var(--invisible-radius-dialog); + padding: 0; + } + [popover]::backdrop { + background: transparent; + } + .settings-trigger { + anchor-name: --settings; + } + .help-trigger { + anchor-name: --help; + } + .settings-popover { + width: min(var(--invisible-popover-width), calc(100vw - 24px)); + padding: 16px; + margin: 0; + inset: 184px auto auto + max( + 12px, + calc( + 50% + var(--invisible-card-width) / 2 - var(--invisible-popover-width) + ) + ); + } + .settings-popover h2 { + margin-bottom: 16px; + } + .payout-options { + border: 0; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + .payout-options legend { + padding: 0; + margin-bottom: 12px; + font-size: 14px; + } + .payout-options label { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px; + background: var(--invisible-surface); + border: 1px solid var(--invisible-border); + border-radius: var(--invisible-radius-dialog); + font-size: 14px; + cursor: pointer; + } + .payout-options label:hover { + background: var(--invisible-surface-hover); + } + .payout-options input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + } + .payout-options label:has(:checked) { + border-color: var(--invisible-accent); + color: var(--invisible-accent); + background: var(--invisible-accent-tint); + } + .payout-options label:has(:focus-visible) { + outline: 2px solid var(--invisible-muted); + outline-offset: 3px; + } + .payout-options label > svg { + opacity: 0; + flex-shrink: 0; + } + .payout-options label:has(:checked) > svg { + opacity: 1; + } + .settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 16px 0; + font-size: 14px; + } + dialog { + width: min(var(--invisible-dialog-width), calc(100% - 24px)); + max-height: calc(100dvh - 32px); + overflow: auto; + } + dialog::backdrop { + background: var(--invisible-overlay); + } + .dialog-content { + padding: 20px; + } + .dialog-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 20px; + } + .address-field { + margin: 12px 0; + display: flex; + gap: 8px; + align-items: center; + border-radius: var(--invisible-radius-button); + background: var(--invisible-background); + padding: 12px; + } + .address-field:focus-within { + outline: 2px solid var(--invisible-muted); + outline-offset: 3px; + } + .address-field input { + min-width: 0; + width: 100%; + border: 0; + background: none; + font-size: 16px; + padding: 4px 0; + } + .address-field input:focus-visible { + outline: none; + } + .review-amount { + display: flex; + align-items: center; + gap: 12px; + font-size: 32px; + margin-bottom: 8px; + } + .review-amount strong { + font-weight: 500; + } + .review-dialog .primary-action { + margin-top: 20px; + } + .full-address { + overflow-wrap: anywhere; + max-width: 65%; + } + .empty-state { + padding: 48px 16px; + text-align: center; + border: 1px solid var(--invisible-border); + border-radius: var(--invisible-radius-card); + color: var(--invisible-muted); + } + .empty-state > svg { + width: 40px; + height: 40px; + color: var(--invisible-quiet); + margin-bottom: 16px; + } + .empty-state h2 { + color: var(--invisible-text); + } + .empty-state p { + font-size: 14px; + margin: 8px 0 20px; + } + .transfer-list { + border: 1px solid var(--invisible-border); + border-radius: var(--invisible-radius-card); + padding: 8px; + margin-bottom: 8px; + } + .transfer-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 8px; + text-align: left; + border-radius: var(--invisible-radius-dialog); + } + .transfer-item:hover, + .transfer-item.selected { + background: var(--invisible-surface); + } + .transfer-item > span:nth-child(2) { + flex: 1; + } + .transfer-item strong, + .transfer-item small { + display: block; + } + .transfer-item strong { + font-size: 16px; + font-weight: 500; + } + .transfer-item small { + font-size: 12px; + color: var(--invisible-muted); + margin-top: 2px; + } + .transfer-item > .muted { + font-size: 12px; + text-align: right; + max-width: 35%; + } + .status-card { + background: var(--invisible-surface); + padding: 20px; + border-radius: var(--invisible-radius-card); + font-size: 14px; + } + .status-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + } + .status-card p { + margin-bottom: 12px; + } + .copy-value { + margin: 16px 0; + font-size: 12px; + } + .copy-value > div { + display: flex; + align-items: center; + gap: 12px; + background: var(--invisible-background); + border-radius: var(--invisible-radius-dialog); + padding: 12px; + margin-top: 6px; + } + .copy-value code { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; + font-size: 13px; + } + .recovery { + padding: 12px 0; + border-top: 1px solid var(--invisible-border); + border-bottom: 1px solid var(--invisible-border); + } + .recovery summary { + cursor: pointer; + } + .recovery summary .muted { + font-size: 12px; + float: right; + } + .status-facts { + margin: 20px 0; + font-size: 14px; + } + .status-facts > div { + display: flex; + justify-content: space-between; + align-items: start; + gap: 16px; + margin: 12px 0; + } + .status-facts dt { + color: var(--invisible-muted); + } + .status-facts dd { + margin: 0; + text-align: right; + } + .payouts { + list-style: none; + padding: 0; + font-size: 13px; + } + .payouts li { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + overflow-wrap: anywhere; + } + .payouts a { + color: var(--invisible-accent); + } + .refund-form { + border-top: 1px solid var(--invisible-border); + padding-top: 16px; + margin-top: 16px; + } + .refund-form label { + display: block; + } + .refund-form input { + width: 100%; + margin: 8px 0; + padding: 12px; + background: var(--invisible-background); + border: 1px solid var(--invisible-border); + border-radius: var(--invisible-radius-dialog); + } + .help-popover { + width: min(var(--invisible-popover-width), calc(100% - 24px)); + padding: 20px; + margin: 0; + inset: auto auto 52px 12px; + } + .help-popover h2 { + margin-bottom: 12px; + } + .help-popover a, + .help-popover .text-button { + display: block; + font-size: 14px; + margin-top: 16px; + } + @supports (position-anchor: --settings) { + .settings-popover, + .help-popover { + inset: auto; + top: anchor(bottom); + right: anchor(right); + margin-top: 8px; + position-try-fallbacks: flip-block, flip-inline; + } + .settings-popover { + position-anchor: --settings; + } + .help-popover { + position-anchor: --help; + } + } + @media (max-width: 600px) { + .tabs { + gap: 8px; + } + .tabs button { + font-size: 14px; + } + .amount-row input { + font-size: 34px; + } + .card-caption { + font-size: 12px; + } + .primary-action { + font-size: 16px; + } + } + @media (prefers-reduced-motion: reduce) { + button, + a { + transition: none; + } + button:not(:disabled):active { + transform: none; + } + } +} diff --git a/src/invisible/ui.ts b/src/invisible/ui.ts new file mode 100644 index 0000000..26d3e43 --- /dev/null +++ b/src/invisible/ui.ts @@ -0,0 +1,13 @@ +export { + PrivateTransferWidget, + type TransferTab, +} from "./components/PrivateTransferWidget"; +export { TransferForm } from "./components/TransferForm"; +export { PayoutWindowPicker } from "./components/PayoutWindowPicker"; +export { TransferReview } from "./components/TransferReview"; +export { TransferActivity } from "./components/TransferActivity"; +export { TransferDetails } from "./components/TransferDetails"; +export { Dialog } from "./ui/Dialog"; +export { CopyValue } from "./ui/CopyValue"; +export { ErrorMessage } from "./ui/ErrorMessage"; +export { Icon, SolIcon } from "./ui/Icons"; diff --git a/src/invisible/ui/CopyValue.tsx b/src/invisible/ui/CopyValue.tsx new file mode 100644 index 0000000..5695541 --- /dev/null +++ b/src/invisible/ui/CopyValue.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { toUiError, type InvisibleUiError } from "../errors"; +import { Icon } from "./Icons"; +import { ErrorMessage } from "./ErrorMessage"; + +export function CopyValue({ label, value }: { label: string; value: string }) { + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setError(null); + } catch (error) { + setError(toUiError(error)); + } + } + return ( +
+ {label} +
+ {value} + +
+ +
+ ); +} diff --git a/src/invisible/ui/Dialog.tsx b/src/invisible/ui/Dialog.tsx new file mode 100644 index 0000000..9cde18c --- /dev/null +++ b/src/invisible/ui/Dialog.tsx @@ -0,0 +1,36 @@ +import { useId, type ReactNode, type Ref } from "react"; +import { Icon } from "./Icons"; + +export function Dialog({ + title, + closeLabel, + ref, + children, + className = "", +}: { + title: string; + closeLabel: string; + ref: Ref; + children: ReactNode; + className?: string; +}) { + const titleId = useId(); + return ( + +
+
+

{title}

+ +
+ {children} +
+
+ ); +} diff --git a/src/invisible/ui/ErrorMessage.tsx b/src/invisible/ui/ErrorMessage.tsx new file mode 100644 index 0000000..a932638 --- /dev/null +++ b/src/invisible/ui/ErrorMessage.tsx @@ -0,0 +1,12 @@ +import type { InvisibleUiError } from "../errors"; + +export function ErrorMessage({ error }: { error: InvisibleUiError | null }) { + return error ? ( +

+ {error.message} + {error.remoteAccepted + ? " This request was already accepted. Check its saved status before submitting again." + : ""} +

+ ) : null; +} diff --git a/src/invisible/ui/Icons.tsx b/src/invisible/ui/Icons.tsx new file mode 100644 index 0000000..49f0715 --- /dev/null +++ b/src/invisible/ui/Icons.tsx @@ -0,0 +1,40 @@ +const ICON_PATHS = { + arrow: "M12 4v16m-7-7 7 7 7-7", + chevron: "m6 9 6 6 6-6", + close: "m6 6 12 12M6 18 18 6", + clock: "M12 7v5l3 2", + check: "m5 12 4 4L19 6", + copy: "M9 9h11v11H9zM5 15H3V3h12v2", + help: "M9.5 8a2.5 2.5 0 1 1 4 2c-1 .6-1.5 1-1.5 2m0 4h.01", + settings: + "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1z", +} as const; +export function Icon({ name }: { name: keyof typeof ICON_PATHS }) { + return ( + + ); +} +export function SolIcon() { + return ( + + ); +} diff --git a/src/invisible/usePrivateTransferController.ts b/src/invisible/usePrivateTransferController.ts new file mode 100644 index 0000000..81361d1 --- /dev/null +++ b/src/invisible/usePrivateTransferController.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect } from "react"; +import { useInvisibleSession } from "./useInvisibleSession"; +import { usePrivateTransfers } from "./usePrivateTransfers"; +import { useTransferStatus, isTerminalState } from "./useTransferStatus"; + +export type PrivateTransferInput = Parameters< + ReturnType["createTransfer"] +>[0]; + +/** One session owner; presentation consumers share this controller. */ +export function usePrivateTransferController(coordinatorUrl: string) { + const connection = useInvisibleSession(coordinatorUrl); + const transfers = usePrivateTransfers(connection.session, coordinatorUrl); + const status = useTransferStatus( + connection.session, + transfers.selected, + connection.fail, + ); + const transfer = transfers.selected; + const snapshot = status.view?.actor_sync?.snapshot; + const isTerminal = status.view + ? isTerminalState(status.view.state) + : transfer?.stage === "terminal-cleaned"; + const ready = connection.phase === "connected"; + const canCreate = + ready && transfers.loaded && !transfers.busy && !transfers.creationBlocked; + const canRefund = + ready && + !isTerminal && + snapshot?.contract_id === transfer?.swapId && + status.fresh && + snapshot?.refundability === "available_now" && + !transfers.refundBlocked && + !transfers.busy; + const depositExpiry = transfer?.depositExpiresAtMs; + const coordinatorNow = + Date.now() + + ((transfer?.coordinatorTimestampMs ?? transfer?.updatedAtMs ?? Date.now()) - + (transfer?.updatedAtMs ?? Date.now())); + const depositExpired = + depositExpiry !== undefined && coordinatorNow >= depositExpiry; + const canDeposit = + !depositExpired && + ready && + transfer?.stage === "ready" && + transfer.status === "awaiting_deposit" && + !isTerminal && + (status.view === null || status.view.state === "awaiting_deposit"); + + // Session setup is read-only. Financial commands remain explicit user actions. + useEffect(() => { + void connection.connect(); + }, [connection.connect]); + useEffect(() => { + if (status.fresh && status.view) { + transfers.reconcileRefund(status.view); + if (isTerminalState(status.view.state)) + void transfers.reload().catch(connection.fail); + } + }, [ + status.fresh, + status.view, + transfers.reconcileRefund, + transfers.reload, + connection.fail, + ]); + + const reconnect = useCallback(() => { + connection.disconnect(); + void connection.connect(); + }, [connection.disconnect, connection.connect]); + const createTransfer = useCallback( + async (input: PrivateTransferInput) => { + if (canCreate) await transfers.createTransfer(input); + }, + [canCreate, transfers.createTransfer], + ); + const requestRefund = useCallback( + async (recoveryInput: string) => { + if (transfer && status.view && canRefund) { + await transfers.refund(transfer, status.view, recoveryInput); + status.refresh(); + } + }, + [transfer, status.view, canRefund, transfers.refund, status.refresh], + ); + + return { + connection, + transfers, + status, + transfer, + snapshot, + isTerminal, + ready, + canCreate, + canRefund, + depositExpiry, + depositExpired, + canDeposit, + reconnect, + createTransfer, + requestRefund, + }; +} +export type PrivateTransferController = ReturnType< + typeof usePrivateTransferController +>; diff --git a/src/style.css b/src/style.css index 7ad05de..de978f3 100644 --- a/src/style.css +++ b/src/style.css @@ -1,27 +1,15 @@ :root { color-scheme: dark; --background: #131313; - --surface: #1f1f1f; - --surface-hover: #292929; - --selected: #303030; --text: #ffffff; --muted: #9b9b9b; --quiet: #6b6b6b; --accent: #ff37c7; --accent-hover: #ff5ed1; - --accent-tint: #ff37c714; --danger: #ff9898; - --border: #333333; - --overlay: #00000066; --positive: #40c9a1; - --solana: #b89cfa; - --radius-card: 24px; - --radius-button: 20px; - --radius-dialog: 16px; --radius-pill: 999px; --card-width: 480px; - --dialog-width: 400px; - --popover-width: 320px; --header-height: 72px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--font); @@ -31,151 +19,121 @@ font-synthesis: none; -webkit-font-smoothing: antialiased; } -* { box-sizing: border-box; } -body { margin: 0; } -button, input { font: inherit; color: inherit; } -button, a, summary { -webkit-tap-highlight-color: transparent; } -button { border: 0; cursor: pointer; background: none; } -button:disabled { cursor: not-allowed; } -a { color: inherit; text-decoration: none; } -a:hover { color: var(--text); } -:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } -button, a { transition: color 100ms, background-color 100ms, opacity 100ms; } -button:not(:disabled):active { transform: scale(.98); } -h2, p { margin: 0; } -h2 { font-size: 18px; font-weight: 500; } -[hidden] { display: none !important; } -.muted { color: var(--muted); } -.app-header { height: var(--header-height); display: flex; align-items: center; padding: 12px 20px; gap: 32px; } -.brand { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); font-size: 22px; font-weight: 500; } -.brand:hover { color: var(--accent-hover); } -.diamond { font-size: 34px; line-height: 1; } -.app-header nav { display: flex; align-items: center; gap: 28px; } -.app-header nav button, .app-header nav a { display: inline-flex; align-items: center; gap: 4px; padding: 8px 0; font-size: 20px; color: var(--muted); } -.app-header nav button:hover, .app-header nav .nav-active { color: var(--text); } -.app-header nav svg { width: 14px; transform: rotate(-45deg); } -.network { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: 14px; color: var(--muted); white-space: nowrap; } -.connection-dot { width: 7px; height: 7px; border-radius: var(--radius-pill); background: var(--quiet); } -.connection-dot.phase-connected { background: var(--positive); } -.connection-dot.phase-connecting { background: var(--accent); } -.connection-dot.phase-error { background: var(--danger); } -.app-main { width: min(var(--card-width), calc(100% - 16px)); margin: 66px auto 80px; } -.toolbar { display: flex; align-items: center; justify-content: space-between; padding: 0 5px; margin-bottom: 5px; height: 36px; } -.tabs { display: flex; gap: 12px; } -.tabs button { display: flex; gap: 6px; align-items: center; padding: 8px 12px; border-radius: var(--radius-pill); color: var(--muted); font-size: 16px; font-weight: 500; } -.tabs button[aria-selected="true"] { background: var(--selected); color: var(--text); } -.tabs button:hover { color: var(--text); } -.count { font-size: 11px; background: var(--surface); padding: 1px 5px; border-radius: var(--radius-pill); } -.toolbar-tools { display: flex; gap: 8px; } -.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 5px; border-radius: var(--radius-pill); color: var(--muted); flex-shrink: 0; } -.icon-button:hover { background: var(--surface-hover); color: var(--text); } -.icon-button:disabled { opacity: .4; } -.amount-card, .recipient-card { height: 136px; padding: 16px; border-radius: var(--radius-card); } -.amount-card { border: 1px solid var(--border); } -.recipient-card { background: var(--surface); margin-top: 4px; height: auto; } -.amount-card > label, .recipient-card > label { font-size: 16px; } -.amount-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 6px 0 8px; height: 44px; } -.amount-row input { font-size: 36px; font-weight: 400; line-height: 1.2; min-width: 0; } -.amount-row input { background: none; border: 0; padding: 0; width: 100%; } -.amount-row input::placeholder { color: var(--quiet); opacity: 1; } -.card-caption { display: flex; justify-content: space-between; gap: 8px; font-size: 14px; color: var(--muted); } -.token-pill { display: inline-flex; align-items: center; justify-content: center; gap: 8px; flex-shrink: 0; padding: 4px 10px 4px 4px; height: 38px; border-radius: var(--radius-pill); font-weight: 600; } -.token-pill { border: 1px solid var(--border); } -.sol-icon { display: inline-flex; width: 28px; height: 28px; border-radius: var(--radius-pill); align-items: center; justify-content: center; background: var(--background); flex-shrink: 0; } -.sol-icon svg { width: 21px; height: 21px; fill: var(--solana); } -.flow-arrow { width: 40px; height: 40px; border: 4px solid var(--background); border-radius: var(--radius-dialog); background: var(--surface); display: flex; align-items: center; justify-content: center; margin: -18px auto -22px; position: relative; z-index: 1; color: var(--text); } -.flow-arrow svg { width: 24px; height: 24px; } -.primary-action, .secondary-action { width: 100%; padding: 16px 20px; border-radius: var(--radius-button); font-size: 18px; font-weight: 600; margin-top: 4px; background: var(--accent); min-height: 55px; } -.primary-action:hover:not(:disabled) { background: var(--accent-hover); } -.primary-action:disabled { background: var(--surface); color: var(--muted); } -.secondary-action { background: var(--accent-tint); color: var(--accent); font-size: 16px; } -.secondary-action:disabled { color: var(--quiet); background: var(--surface); } -.form-footnote { color: var(--quiet); text-align: center; font-size: 12px; margin: 16px 12px; } -.text-button { color: var(--accent); padding: 0; font-size: inherit; } -.text-button:hover { color: var(--accent-hover); } -.text-button:disabled { opacity: .5; } -.retry-button { display: block; margin: 12px auto; } -.hint { font-size: 13px; color: var(--muted); line-height: 1.5; } -.error, .notice { font-size: 14px; line-height: 1.5; margin-top: 12px; overflow-wrap: anywhere; } -.error { color: var(--danger); } -.notice { color: var(--muted); padding: 12px; background: var(--surface); border-radius: var(--radius-dialog); } -[popover], dialog { color: var(--text); background: var(--background); border: 1px solid var(--border); border-radius: var(--radius-dialog); padding: 0; } -[popover]::backdrop { background: transparent; } -.settings-trigger { anchor-name: --settings; } -.settings-popover { width: min(var(--popover-width), calc(100vw - 24px)); padding: 16px; margin: 0; inset: 184px auto auto max(12px, calc(50% + var(--card-width) / 2 - var(--popover-width))); } -@supports (position-anchor: --settings) { .settings-popover { position-anchor: --settings; inset: auto; top: anchor(bottom); right: anchor(right); margin-top: 8px; } } -.settings-popover h2 { margin-bottom: 16px; } -.payout-options { border: 0; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } -.payout-options legend { padding: 0; margin-bottom: 12px; font-size: 14px; } -.payout-options label { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-dialog); font-size: 14px; cursor: pointer; } -.payout-options label:hover { background: var(--surface-hover); } -.payout-options input { position: absolute; opacity: 0; width: 1px; height: 1px; } -.payout-options label:has(:checked) { border-color: var(--accent); color: var(--accent); background: var(--accent-tint); } -.payout-options label:has(:focus-visible) { outline: 2px solid var(--muted); outline-offset: 3px; } -.payout-options label > svg { opacity: 0; flex-shrink: 0; } -.payout-options label:has(:checked) > svg { opacity: 1; } -.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 16px 0; font-size: 14px; } -dialog { width: min(var(--dialog-width), calc(100% - 24px)); max-height: calc(100dvh - 32px); overflow: auto; } -dialog::backdrop { background: var(--overlay); } -.dialog-content { padding: 20px; } -.dialog-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; } -.address-field { margin: 12px 0; display: flex; gap: 8px; align-items: center; border-radius: var(--radius-button); background: var(--background); padding: 12px; } -.address-field:focus-within { outline: 2px solid var(--muted); outline-offset: 3px; } -.address-field input { min-width: 0; width: 100%; border: 0; background: none; font-size: 16px; padding: 4px 0; } -.address-field input:focus-visible { outline: none; } -.review-amount { display: flex; align-items: center; gap: 12px; font-size: 32px; margin-bottom: 8px; } -.review-amount strong { font-weight: 500; } -.review-dialog .primary-action { margin-top: 20px; } -.full-address { overflow-wrap: anywhere; max-width: 65%; } -.empty-state { padding: 48px 16px; text-align: center; border: 1px solid var(--border); border-radius: var(--radius-card); color: var(--muted); } -.empty-state > svg { width: 40px; height: 40px; color: var(--quiet); margin-bottom: 16px; } -.empty-state h2 { color: var(--text); } -.empty-state p { font-size: 14px; margin: 8px 0 20px; } -.transfer-list { border: 1px solid var(--border); border-radius: var(--radius-card); padding: 8px; margin-bottom: 8px; } -.transfer-item { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 8px; text-align: left; border-radius: var(--radius-dialog); } -.transfer-item:hover, .transfer-item.selected { background: var(--surface); } -.transfer-item > span:nth-child(2) { flex: 1; } -.transfer-item strong, .transfer-item small { display: block; } -.transfer-item strong { font-size: 16px; font-weight: 500; } -.transfer-item small { font-size: 12px; color: var(--muted); margin-top: 2px; } -.transfer-item > .muted { font-size: 12px; text-align: right; max-width: 35%; } -.status-card { background: var(--surface); padding: 20px; border-radius: var(--radius-card); font-size: 14px; } -.status-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } -.status-card p { margin-bottom: 12px; } -.copy-value { margin: 16px 0; font-size: 12px; } -.copy-value > div { display: flex; align-items: center; gap: 12px; background: var(--background); border-radius: var(--radius-dialog); padding: 12px; margin-top: 6px; } -.copy-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 13px; } -.recovery { padding: 12px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } -.recovery summary { cursor: pointer; } -.recovery summary .muted { font-size: 12px; float: right; } -.status-facts { margin: 20px 0; font-size: 14px; } -.status-facts > div { display: flex; justify-content: space-between; align-items: start; gap: 16px; margin: 12px 0; } -.status-facts dt { color: var(--muted); } -.status-facts dd { margin: 0; text-align: right; } -.payouts { list-style: none; padding: 0; font-size: 13px; } -.payouts li { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; overflow-wrap: anywhere; } -.payouts a { color: var(--accent); } -.refund-form { border-top: 1px solid var(--border); padding-top: 16px; margin-top: 16px; } -.refund-form label { display: block; } -.refund-form input { width: 100%; margin: 8px 0; padding: 12px; background: var(--background); border: 1px solid var(--border); border-radius: var(--radius-dialog); } -.help-button { position: fixed; bottom: 12px; left: 12px; color: var(--text); anchor-name: --help; } -.help-popover { width: min(var(--popover-width), calc(100% - 24px)); padding: 20px; margin: 0; inset: auto auto 52px 12px; } -.help-popover h2 { margin-bottom: 12px; } -.help-popover a, .help-popover .text-button { display: block; font-size: 14px; margin-top: 16px; } +body { + margin: 0; +} +.app-header { + :where(button, a) { + font: inherit; + color: inherit; + background: none; + border: 0; + cursor: pointer; + text-decoration: none; + } + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + } +} +.app-header { + box-sizing: border-box; + height: var(--header-height); + display: flex; + align-items: center; + padding: 12px 20px; + gap: 32px; +} +.brand { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--accent); + font-size: 22px; + font-weight: 500; +} +.brand:hover { + color: var(--accent-hover); +} +.diamond { + font-size: 34px; + line-height: 1; +} +.app-header nav { + display: flex; + align-items: center; + gap: 28px; +} +.app-header nav button, +.app-header nav a { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 8px 0; + font-size: 20px; + color: var(--muted); +} +.app-header nav button:hover, +.app-header nav .nav-active { + color: var(--text); +} +.app-header nav svg { + width: 14px; + transform: rotate(-45deg); +} +.network { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + font-size: 14px; + color: var(--muted); + white-space: nowrap; +} +.connection-dot { + width: 7px; + height: 7px; + border-radius: var(--radius-pill); + background: var(--quiet); +} +.connection-dot.phase-connected { + background: var(--positive); +} +.connection-dot.phase-connecting { + background: var(--accent); +} +.connection-dot.phase-error { + background: var(--danger); +} +.app-main { + width: min(var(--card-width), calc(100% - 16px)); + margin: 66px auto 80px; +} @media (max-width: 600px) { - .app-header { gap: 16px; padding: 12px 16px; } - .brand { font-size: 18px; gap: 4px; } - .diamond { font-size: 28px; } - .app-header nav { gap: 16px; } - .app-header nav button { display: none; } - .app-header nav a { font-size: 14px; } - .network { font-size: 12px; } - .app-main { margin-top: 26px; } - .tabs { gap: 8px; } - .tabs button { font-size: 14px; } - .settings-popover { top: 140px; right: 12px; left: auto; } - .amount-row input { font-size: 34px; } - .card-caption { font-size: 12px; } - .primary-action { font-size: 16px; } -} -@media (prefers-reduced-motion: reduce) { button, a { transition: none; } button:not(:disabled):active { transform: none; } } + .app-header { + gap: 16px; + padding: 12px 16px; + } + .brand { + font-size: 18px; + gap: 4px; + } + .diamond { + font-size: 28px; + } + .app-header nav { + gap: 16px; + } + .app-header nav button { + display: none; + } + .app-header nav a { + font-size: 14px; + } + .network { + font-size: 12px; + } + .app-main { + margin-top: 26px; + } +}