diff --git a/.changelog/unreleased/1003-phase-9-scroll-and-wallet-motion.md b/.changelog/unreleased/1003-phase-9-scroll-and-wallet-motion.md
new file mode 100644
index 00000000..3721d6a1
--- /dev/null
+++ b/.changelog/unreleased/1003-phase-9-scroll-and-wallet-motion.md
@@ -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.
diff --git a/apps/docs/content/reference/tokens.generated.mdx b/apps/docs/content/reference/tokens.generated.mdx
index 9a564c14..770a394e 100644
--- a/apps/docs/content/reference/tokens.generated.mdx
+++ b/apps/docs/content/reference/tokens.generated.mdx
@@ -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).
diff --git a/apps/web/src/features/wallet/components/ConnectButton.test.tsx b/apps/web/src/features/wallet/components/ConnectButton.test.tsx
index af98b588..6a7e03c0 100644
--- a/apps/web/src/features/wallet/components/ConnectButton.test.tsx
+++ b/apps/web/src/features/wallet/components/ConnectButton.test.tsx
@@ -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"
@@ -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()
})
@@ -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()
})
@@ -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()
+
+ 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", () => {
@@ -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()
- // 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 () => {
@@ -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()
+
+ act(() => {
+ useWalletStore.setState({ status: "connecting" })
+ })
+ rerender()
+
+ act(() => {
+ useWalletStore.setState({
+ status: "connected",
+ address: fakeWalletAddress,
+ })
+ })
+ rerender()
+
+ const pill = document.querySelector('[data-wallet-state="connected"]')
+ expect(pill).toHaveClass("so4-wallet-pill")
+ })
})
describe("Keyboard Shortcuts", () => {
@@ -277,7 +311,7 @@ describe("ConnectButton - Disconnected State", () => {
expect.objectContaining({
key: "k",
enabled: true,
- }),
+ })
)
})
@@ -293,7 +327,7 @@ describe("ConnectButton - Disconnected State", () => {
expect(useKeyboardShortcut).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
- }),
+ })
)
})
@@ -312,7 +346,7 @@ describe("ConnectButton - Disconnected State", () => {
expect(useKeyboardShortcut).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
- }),
+ })
)
})
})
@@ -324,7 +358,9 @@ describe("ConnectButton - Disconnected State", () => {
expect(state.status).toBe("disconnected")
render()
- 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", () => {
@@ -334,14 +370,18 @@ describe("ConnectButton - Disconnected State", () => {
expect(state.status).toBe("disconnected")
render()
- 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()
// 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({
@@ -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()
})
diff --git a/apps/web/src/features/wallet/components/ConnectButton.tsx b/apps/web/src/features/wallet/components/ConnectButton.tsx
index 7a5073b0..81dfe26c 100644
--- a/apps/web/src/features/wallet/components/ConnectButton.tsx
+++ b/apps/web/src/features/wallet/components/ConnectButton.tsx
@@ -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"
@@ -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({
@@ -73,7 +86,39 @@ export function ConnectButton({ className, ...props }: ConnectButtonProps) {
})
if (status === "connected" && address) {
- return
+ return (
+
+ )
+ }
+
+ // #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 (
+
+ )
}
return (
@@ -81,13 +126,11 @@ export function ConnectButton({ className, ...props }: ConnectButtonProps) {
@@ -96,13 +139,34 @@ export function ConnectButton({ className, ...props }: ConnectButtonProps) {
)
}
+function WalletIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
function AccountBadge({
address,
className,
+ animateIn = false,
...props
}: {
address: string
className?: string
+ animateIn?: boolean
} & Omit) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
@@ -122,7 +186,11 @@ function AccountBadge({
}, [open])
return (
-