Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
type: changed
area: trade
pr: 1003
breaking: false
---
The header now hides on scroll-down and reveals on scroll-up on mobile with a fading hairline, landing sections reveal as they scroll into view, landing statistics count up the first time they are seen, and the wallet connect flow staggers its wallet list, pulses while connecting, and morphs into the account pill.
2 changes: 1 addition & 1 deletion apps/docs/content/reference/tokens.generated.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ status: stable

This page is generated from [`packages/ui/src/styles/globals.css`](https://github.com/SO4-Markets/interface/blob/main/packages/ui/src/styles/globals.css) — the `@theme inline` block plus the `:root` and `.dark` values it resolves to. It cannot drift from the source: `bun run --cwd apps/docs check:tokens:generated` (wired into CI) fails the build if this file is out of date. See [Design system](/developers/design-system) for what these roles mean and when to reach for each one; see [`DESIGN.md`](https://github.com/SO4-Markets/interface/blob/main/DESIGN.md) for the full written contract.

128 tokens, in both themes. A dashed swatch means one of two things, distinguished by the raw value column: the token is not defined in that theme at all (`—`), or it resolves through the excluded GMX palette (see below) and so cannot be rendered here — see [Design system](/developers/design-system#one-theme-so-far) for what that means for `surface-*` and `text-*` in light mode specifically.
129 tokens, in both themes. A dashed swatch means one of two things, distinguished by the raw value column: the token is not defined in that theme at all (`—`), or it resolves through the excluded GMX palette (see below) and so cannot be rendered here — see [Design system](/developers/design-system#one-theme-so-far) for what that means for `surface-*` and `text-*` in light mode specifically.

The `--color-gmx-*` palette and its landing-only typography/button utilities are excluded from this page. DESIGN.md documents them as source material for the GrantFox 3 landing theme, not general-purpose tokens — see [`docs/gf_3/001_theme_update.md`](https://github.com/SO4-Markets/interface/blob/main/docs/gf_3/001_theme_update.md).

Expand Down
72 changes: 56 additions & 16 deletions apps/web/src/features/wallet/components/ConnectButton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { cleanup, render, screen } from "@testing-library/react"
import { act, cleanup, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { useWalletStore } from "../store/wallet-store"
import { ConnectButton } from "./ConnectButton"
Expand Down Expand Up @@ -109,7 +109,9 @@ describe("ConnectButton - Disconnected State", () => {
const dialog = screen.getByRole("dialog")
expect(dialog).toBeInTheDocument()

const dialogTitle = screen.getByRole("heading", { name: "Connect Wallet" })
const dialogTitle = screen.getByRole("heading", {
name: "Connect Wallet",
})
expect(dialogTitle).toBeInTheDocument()
})

Expand All @@ -127,7 +129,7 @@ describe("ConnectButton - Disconnected State", () => {

// Dialog description should be present
expect(
screen.getByText("Choose a supported Stellar wallet to continue."),
screen.getByText("Choose a supported Stellar wallet to continue.")
).toBeInTheDocument()
})

Expand All @@ -141,9 +143,22 @@ describe("ConnectButton - Disconnected State", () => {
// Mobile wallet section should be visible
expect(screen.getByText("Use Mobile Wallet")).toBeInTheDocument()
expect(
screen.getByText(/Scan to connect from a mobile wallet/),
screen.getByText(/Scan to connect from a mobile wallet/)
).toBeInTheDocument()
})

it("should stagger the wallet list on open", async () => {
const user = userEvent.setup()
render(<ConnectButton />)

await user.click(screen.getByRole("button", { name: /Connect wallet/i }))

const rows = Array.from(document.querySelectorAll(".so4-wallet-row"))
expect(rows).toHaveLength(3)
expect(rows[0]).toHaveStyle({ "--wallet-stagger": "0" })
expect(rows[1]).toHaveStyle({ "--wallet-stagger": "1" })
expect(rows[2]).toHaveStyle({ "--wallet-stagger": "2" })
})
})

describe("Loading/Connecting State", () => {
Expand All @@ -166,15 +181,14 @@ describe("ConnectButton - Disconnected State", () => {
expect(button).toBeDisabled()
})

it("should show spinner when connecting", () => {
it("should show a pulsing wallet icon when connecting", () => {
useWalletStore.setState({ status: "connecting" })

render(<ConnectButton />)

// Spinner SVG should be rendered
const svg = screen.getByRole("button").querySelector("svg")
expect(svg).toBeInTheDocument()
expect(svg).toHaveClass("animate-spin")
const button = screen.getByRole("button", { name: /Connecting wallet/i })
expect(button.querySelector(".so4-wallet-pulse")).toBeInTheDocument()
expect(button).toHaveTextContent("Connecting")
})

it("should not open modal when connecting", async () => {
Expand Down Expand Up @@ -261,6 +275,26 @@ describe("ConnectButton - Disconnected State", () => {
// Account badge should be rendered instead
expect(screen.getByText(/GAAAAA/)).toBeInTheDocument()
})

it("should morph the connecting pill into the account badge", () => {
const { rerender } = render(<ConnectButton />)

act(() => {
useWalletStore.setState({ status: "connecting" })
})
rerender(<ConnectButton />)

act(() => {
useWalletStore.setState({
status: "connected",
address: fakeWalletAddress,
})
})
rerender(<ConnectButton />)

const pill = document.querySelector('[data-wallet-state="connected"]')
expect(pill).toHaveClass("so4-wallet-pill")
})
})

describe("Keyboard Shortcuts", () => {
Expand All @@ -277,7 +311,7 @@ describe("ConnectButton - Disconnected State", () => {
expect.objectContaining({
key: "k",
enabled: true,
}),
})
)
})

Expand All @@ -293,7 +327,7 @@ describe("ConnectButton - Disconnected State", () => {
expect(useKeyboardShortcut).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
}),
})
)
})

Expand All @@ -312,7 +346,7 @@ describe("ConnectButton - Disconnected State", () => {
expect(useKeyboardShortcut).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
}),
})
)
})
})
Expand All @@ -324,7 +358,9 @@ describe("ConnectButton - Disconnected State", () => {
expect(state.status).toBe("disconnected")

render(<ConnectButton />)
expect(screen.getByRole("button", { name: /Connect wallet/i })).toBeInTheDocument()
expect(
screen.getByRole("button", { name: /Connect wallet/i })
).toBeInTheDocument()
})

it("test B: should not have state from test A", () => {
Expand All @@ -334,14 +370,18 @@ describe("ConnectButton - Disconnected State", () => {
expect(state.status).toBe("disconnected")

render(<ConnectButton />)
expect(screen.getByRole("button", { name: /Connect wallet/i })).toBeInTheDocument()
expect(
screen.getByRole("button", { name: /Connect wallet/i })
).toBeInTheDocument()
})

it("should not render connected UI after render if store changes", () => {
const { rerender } = render(<ConnectButton />)

// Initially should show connect button
expect(screen.getByRole("button", { name: /Connect wallet/i })).toBeInTheDocument()
expect(
screen.getByRole("button", { name: /Connect wallet/i })
).toBeInTheDocument()

// Update store to connected state
useWalletStore.setState({
Expand All @@ -354,7 +394,7 @@ describe("ConnectButton - Disconnected State", () => {

// Now should show account badge instead
expect(
screen.queryByRole("button", { name: /Connect wallet/i }),
screen.queryByRole("button", { name: /Connect wallet/i })
).not.toBeInTheDocument()
expect(screen.getByText(/GAAAAA/)).toBeInTheDocument()
})
Expand Down
112 changes: 78 additions & 34 deletions apps/web/src/features/wallet/components/ConnectButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { cn } from "@workspace/ui/lib/utils"
import { createSep7ConnectUri, createSep7TransactionUri } from "../lib/sep7"
import { useBalance } from "../hooks/useBalance"
import { useWalletStore } from "../store/wallet-store"
import type { ComponentProps } from "react"
import type { CSSProperties, ComponentProps } from "react"
import { useKeyboardShortcut } from "@/shared/hooks/useKeyboardShortcut"
import { useWallet } from "@/app/providers"
import { NETWORK } from "@/app/config/network"
Expand Down Expand Up @@ -64,6 +64,19 @@ export function ConnectButton({ className, ...props }: ConnectButtonProps) {
const address = useWalletStore((state) => state.address)
const status = useWalletStore((state) => state.status)
const isConnecting = status === "connecting"
// #887: only the transition into `connected` should play the pill entrance,
// not every render of an already-connected wallet.
const wasConnecting = useRef(false)

useEffect(() => {
if (status === "connecting") {
wasConnecting.current = true
} else if (status !== "connected") {
wasConnecting.current = false
}
}, [status])

const justConnected = status === "connected" && wasConnecting.current

// Must be called unconditionally — before any early return
useKeyboardShortcut({
Expand All @@ -73,21 +86,51 @@ export function ConnectButton({ className, ...props }: ConnectButtonProps) {
})

if (status === "connected" && address) {
return <AccountBadge address={address} className={className as string | undefined} {...props} />
return (
<AccountBadge
address={address}
animateIn={justConnected}
className={className as string | undefined}
{...props}
/>
)
}

// #887: while the kit handshake is in flight the trigger becomes an
// aria-busy status with a single pulsing indicator, so the click has
// immediate feedback that never shifts the surrounding layout.
if (isConnecting) {
return (
<Button
{...props}
type="button"
aria-label="Connecting wallet"
aria-busy="true"
className={cn("w-full justify-center gap-2 sm:w-auto", className)}
disabled
>
<span
className="relative inline-flex size-3.5 items-center justify-center"
aria-hidden="true"
>
<span className="so4-wallet-pulse absolute inset-0 rounded-full bg-primary/20" />
<WalletIcon className="relative size-3.5" />
</span>
<span role="status">Connecting</span>
</Button>
)
}

return (
<>
<Button
{...props}
type="button"
aria-label={isConnecting ? "Connecting wallet" : "Connect wallet"}
aria-busy={isConnecting}
aria-label="Connect wallet"
className={cn("w-full sm:w-auto", className)}
disabled={isConnecting || props.disabled}
disabled={props.disabled}
onClick={() => setIsWalletModalOpen(true)}
>
{isConnecting && <Spinner />}
Connect Wallet
</Button>

Expand All @@ -96,13 +139,34 @@ export function ConnectButton({ className, ...props }: ConnectButtonProps) {
)
}

function WalletIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6.5A2.5 2.5 0 0 1 4 16.5v-9Z" />
<path d="M4 8h13" />
<path d="M16 12h4v4h-4a2 2 0 0 1 0-4Z" />
</svg>
)
}

function AccountBadge({
address,
className,
animateIn = false,
...props
}: {
address: string
className?: string
animateIn?: boolean
} & Omit<ConnectButtonProps, "className">) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
Expand All @@ -122,7 +186,11 @@ function AccountBadge({
}, [open])

return (
<div ref={ref} className="relative">
<div
ref={ref}
data-wallet-state="connected"
className={cn("relative", animateIn && "so4-wallet-pill")}
>
<Button
{...props}
type="button"
Expand Down Expand Up @@ -302,15 +370,16 @@ function WalletModal({
</DialogHeader>

<div className="space-y-2">
{sortedWalletOptions.map((wallet) => {
{sortedWalletOptions.map((wallet, index) => {
const isAvailable = availableWalletIds.has(wallet.id)

return (
<Button
key={wallet.id}
type="button"
variant={isAvailable ? "default" : "outline"}
className="w-full justify-between"
className="so4-wallet-row w-full justify-between"
style={{ "--wallet-stagger": index } as CSSProperties}
onClick={() => {
void connectWallet(wallet)
}}
Expand Down Expand Up @@ -376,31 +445,6 @@ function WalletModal({
)
}

function Spinner() {
return (
<svg
className="size-3.5 animate-spin"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z"
/>
</svg>
)
}

export function formatAddress(address: string) {
if (address.length <= 12) {
return address
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/lib/supports-view-timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* CSS scroll-driven animation support (#885).
*
* `animation-timeline: view()` is the preferred path: it runs off the main
* thread and needs no IntersectionObserver. Browsers without support keep the
* existing observer fallback.
*/
export function supportsViewTimeline(): boolean {
return (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("animation-timeline: view()")
)
}
Loading