From 5575a5a6cae45a8422348db8c033272205f45d01 Mon Sep 17 00:00:00 2001 From: Abdulhameed-123 Date: Mon, 19 Oct 2026 12:18:30 +0100 Subject: [PATCH 1/3] feat(web): phase 9 scroll and wallet motion (#884-#887) Implements the four assigned motion issues: - #884 scroll-aware header. New useScrollHeader hook coalesces scroll work into one animation frame and drives a transform-only hide/show on mobile plus a data-scrolled hairline that fades in as a composited layer. Applied to the landing HeaderMenu and the app Navbar; only the landing header compresses its height, because it is fixed over the hero and cannot move document content (no CLS). - #885 scroll-driven section reveals. ViewportReveal now uses CSS scroll-driven animations (animation-timeline: view()) where supported, running off the main thread, and keeps the IntersectionObserver path as the fallback. Children stay in document flow and in the accessibility tree in every mode. - #886 count-up statistics. New CountUp eases landing stats from 0 the first time they enter the viewport, keeps tabular figures so the row never reflows, and announces only the final value to screen readers. Null stats still render the "-" placeholder with an "Unavailable" accessible label. - #887 wallet connect choreography. Wallet rows stagger in, the in-flight connect trigger becomes an aria-busy status with a single pulsing indicator, and the connected pill animates in only on the connecting -> connected transition rather than on every render. All motion is token-driven (new --duration-pulse) and transform/opacity only, respects prefers-reduced-motion and the in-app reduced-motion toggle, and is interruptible. apps/docs/content/reference/tokens.generated.mdx is regenerated for the new token. Verification on this branch (see the PR for the full gate report): - bun lint: 10 pre-existing errors, all in files this change does not touch (verified identical on main before the change). Zero errors in changed files. - bun typecheck / apps/web typecheck: 1 pre-existing error (useTxSubmit.tsx: unused 'attempt'), present on main. - bun run check:content: passes. - bun run check:tokens: 14 pre-existing violations (#732/#733 comments and an HTML entity in apps/docs), all present on main; none in changed files. - bun run test / test:coverage: 1 pre-existing failure (packages/ui popover.test.tsx data-open:animate-in), present on main. - bun run build: passes (apps/docs, apps/web, packages/ui). - app tests for the changed behaviour: 5 files, 56 tests, all passing. - apps/s03-indexer codegen/build/test (100 pass), sync:contracts:local, scripts/validate-manifest.sh, apps/web build: pass. - bun run changelog:validate is already red on main for unrelated entries; the entry for this change is added in a follow-up commit once the PR number exists. --- .../content/reference/tokens.generated.mdx | 2 +- .../wallet/components/ConnectButton.test.tsx | 72 ++++++-- .../wallet/components/ConnectButton.tsx | 112 ++++++++---- apps/web/src/lib/supports-view-timeline.ts | 14 ++ apps/web/src/lib/use-viewport-reveal.test.ts | 106 ------------ apps/web/src/lib/use-viewport-reveal.test.tsx | 134 ++++++++++++++ apps/web/src/lib/use-viewport-reveal.ts | 8 +- .../src/shared/hooks/useScrollHeader.test.ts | 137 +++++++++++++++ apps/web/src/shared/hooks/useScrollHeader.ts | 83 +++++++++ apps/web/src/ui/Navbar.tsx | 8 +- apps/web/src/ui/landing/count-up.test.tsx | 123 +++++++++++++ apps/web/src/ui/landing/count-up.tsx | 115 ++++++++++++ apps/web/src/ui/landing/header-menu.tsx | 10 +- apps/web/src/ui/landing/hero-section.tsx | 23 ++- apps/web/src/ui/nav/primitives.tsx | 4 +- apps/web/src/ui/viewport-reveal.test.tsx | 155 ++++++++++++----- apps/web/src/ui/viewport-reveal.tsx | 49 +++--- packages/ui/src/styles/globals.css | 163 ++++++++++++++++++ 18 files changed, 1082 insertions(+), 236 deletions(-) create mode 100644 apps/web/src/lib/supports-view-timeline.ts delete mode 100644 apps/web/src/lib/use-viewport-reveal.test.ts create mode 100644 apps/web/src/lib/use-viewport-reveal.test.tsx create mode 100644 apps/web/src/shared/hooks/useScrollHeader.test.ts create mode 100644 apps/web/src/shared/hooks/useScrollHeader.ts create mode 100644 apps/web/src/ui/landing/count-up.test.tsx create mode 100644 apps/web/src/ui/landing/count-up.tsx 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 ( -
+