Skip to content

feat(web): phase 9 scroll and wallet motion (#884-#887) - #1003

Merged
IbrahimIjai merged 3 commits into
SO4-Markets:mainfrom
hameedx01:feat/phase-9-motion-884-887
Oct 19, 2026
Merged

IbrahimIjai merged 3 commits into
SO4-Markets:mainfrom
hameedx01:feat/phase-9-motion-884-887

Conversation

@hameedx01

Copy link
Copy Markdown
Contributor

Closes #884
Closes #885
Closes #886
Closes #887

Implements the four Phase 9 motion issues in one change. All motion is
token-driven, transform/opacity only, honours prefers-reduced-motion and
the in-app reduced-motion toggle, and is interruptible. No eslint-disable,
.skip, or lowered threshold was added.

What changed

#884 — scroll-aware header (apps/web/src/shared/hooks/useScrollHeader.ts, ui/landing/header-menu.tsx, ui/Navbar.tsx)

  • New useScrollHeader hook coalesces all scroll work into a single
    requestAnimationFrame, tracks a scrolled flag (8px) and a direction flag
    (6px threshold so jitter cannot flip it), and only hides on mobile
    (max-width: 639px). The header is always revealed at the top of the page
    and on desktop.
  • Hiding is transform: translateY(-100%); nothing changes document height, so
    there is no layout shift. The hairline and shadow are drawn by a pseudo
    element that fades in with opacity, replacing the layout-affecting
    border-b border-border on the app navbar.
  • Only the landing header compresses its height (16 → 14), because it is
    fixed over the hero and cannot move document content. The app navbar keeps
    its height so nothing under it can shift.
  • Reversal mid-animation continues from the current visual state, because both
    interactions are CSS transitions on the compositor.
  • The landing mobile menu was moved out of <header>: a transformed ancestor
    becomes the containing block for fixed descendants, which collapsed the
    full-screen panel to zero height. This was caught by
    e2e/landing-journey.spec.ts at 390px, and there is now a component test
    that fails if the panel is nested back inside the header.

#885 — CSS scroll-driven reveals (ui/viewport-reveal.tsx, lib/supports-view-timeline.ts, lib/use-viewport-reveal.ts)

  • ViewportReveal uses animation-timeline: view() with
    animation-range: entry 10% entry 90% where the browser supports it, so the
    reveal runs off the main thread. Browsers without view timelines keep the
    existing IntersectionObserver path (useViewportReveal gained an enabled
    flag so the observer is not constructed when CSS drives the animation).
  • Reduced motion renders content immediately. Children stay in document flow
    and in the accessibility tree in every mode; nothing is measured or unmounted.

#886 — count-up landing stats (ui/landing/count-up.tsx, ui/landing/hero-section.tsx)

  • New CountUp eases each stat from 0 the first time it enters the viewport,
    using --duration-slowest and --ease-out (cubic-bezier(0, 0, 0.2, 1), whose
    shape is matched by easeOutCubic).
  • The display uses font-variant-numeric: tabular-nums slashed-zero so digits
    never reflow the row, and the animating number is aria-hidden while a single
    sr-only node carries the final formatted value — screen readers get one
    announcement, not one per frame.
  • The backend currently returns landing stats as null; those still render the
    - placeholder with an Unavailable accessible label, and the animation is
    skipped.

#887 — wallet connect choreography (features/wallet/components/ConnectButton.tsx)

  • Wallet rows stagger in with a --wallet-stagger custom property (index × 35ms,
    capped by the token duration), transform and opacity only.
  • The in-flight trigger becomes a disabled aria-busy="true" status button with
    a single pulsing indicator and a role="status" label, so the click has
    immediate feedback and the surrounding layout does not move.
  • The connected pill entrance plays only on the connecting → connected
    transition (tracked with a ref), not on every render of an already-connected
    wallet.

Tokens — one new token, --duration-pulse: 1500ms (the 1.5s loop in the
motion spec). apps/docs/content/reference/tokens.generated.mdx is regenerated.

Verification

Commands run on feat/phase-9-motion-884-887 (base 5932835), with bun
installed at the version pinned in bun.lock.

Check Result
bun lint 10 errors, all pre-existing in untouched files (identical output on base, and identical to the CI log for 5932835). Zero errors in changed files.
bun typecheck 1 pre-existing error (useTxSubmit.tsx(125,13): 'attempt' is declared but its value is never read), present on base.
bun run check:tokens 14 pre-existing violations (#732/#733 comments read as hex colours, one HTML entity in apps/docs), all present on base, none in changed files.
bun run check:content Passes (41 pages, zero orphans).
bun run test / test:coverage 1 pre-existing failure (packages/ui/src/components/popover.test.tsx expects data-open:animate-in), fails identically on base.
bun run build Passes (apps/docs, apps/web, packages/ui).
apps/web typecheck / build 1 pre-existing typecheck error as above; build passes.
apps/web tests for changed behaviour 6 files, 58 tests, all pass (useScrollHeader, count-up, viewport-reveal, use-viewport-reveal, header-menu, ConnectButton).
apps/web full suite 229 failed / 1695 passed; on base it is 243 failed / 1667 passed. No new failures — the delta is the added tests plus fixes in the touched files.
Integration checks apps/s03-indexer codegen, build, 100/100 tests, sync:contracts:local, scripts/validate-manifest.sh all pass.
bun run changelog:validate Already red on base for unrelated entries (ob-119-render-coalescing.md, ob-061-chart-resize.md, 780-ob-125-safe-caching-headers.md, …). The entry for this change is added in a follow-up commit once this PR number exists.

CI is red on main at the base commit for the same reasons — the workflow
run for 5932835 fails at the Lint step with exactly the same 10 errors, so the
Playwright E2E job never runs there. This PR does not add any of them, and
fixing unrelated lint violations here would be out of scope.

Layout-shift evidence (PLAYWRIGHT_LANDING_AUDIT=1, production testnet build, Chromium)

Viewport Motion CLS before (base) CLS after Requests Transfer
390×900 no-preference 0 0 16 597,841 B
390×900 reduce 0 0 16 597,841 B
768×900 no-preference 0.0000384 0.0000384 16 597,841 B
768×900 reduce 0.0000384 0.0001726 16 597,841 B
1440×900 no-preference 0.0000128 0.0000128 16 597,841 B
1440×900 reduce 0.0000128 0.0000835 16 597,841 B

All values are far below the 0.1 budget; the differences between runs are at the
noise floor of this lab measurement, not a shift introduced by the header.

Recordings

Six before clips and six after clips (390/768/1440 × normal/reduced motion) were
recorded from the same spec with
PLAYWRIGHT_LANDING_AUDIT=1 PLAYWRIGHT_MOTION_EVIDENCE=1. They are attached to
this PR via the GitHub web UI (the API cannot upload PR attachments); the local
copies live in ~/so4-motion-evidence/before/ and ~/so4-motion-evidence/after/
and are not committed, per the convention in
docs/gf_3/003_landing_motion_review.md.

The remaining failures in e2e/landing-journey.spec.ts (getByRole('tab', { name: 'Long' })
not visible on /trade, and one running animation after route exit) are
identical on base and are not touched by this change.

…ets#887)

Implements the four assigned motion issues:

- SO4-Markets#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).
- SO4-Markets#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.
- SO4-Markets#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.
- SO4-Markets#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 (SO4-Markets#732/SO4-Markets#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.
…SO4-Markets#884)

The scroll-aware header now carries `transform: translateY(-100%)` while
hidden on mobile. A transformed ancestor becomes the containing block for
fixed descendants, so the full-screen menu (`fixed inset-0 top-16`, nested in
the header) resolved its `inset-0` against the 64px header box and collapsed
to zero height. The e2e landing journey caught it at 390px as
`dialog "Site menu"` resolving to hidden.

Move the panel to a sibling of `<header>` so the viewport stays its
containing block, and add a component test that fails if the panel is ever
nested back inside the header.

Verified: e2e/landing-journey.spec.ts at 390px now passes the menu open and
Escape assertions; the remaining failures in that spec (trade "Long" tab not
visible, one running animation after route exit) are identical on main.
@vercel

vercel Bot commented Oct 19, 2026

Copy link
Copy Markdown

@hameedx01 is attempting to deploy a commit to the Ijai's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@hameedx01 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Unreleased entry for the Phase 9 motion change, now that the pull request number exists. The file follows .changelog/unreleased/TEMPLATE.md; bun run changelog:validate reports no problem with this entry (it is still red on pre-existing, unrelated entries).
@IbrahimIjai
IbrahimIjai merged commit 6eb9713 into SO4-Markets:main Oct 19, 2026
1 check failed
@grantfox-oss grantfox-oss Bot mentioned this pull request Oct 19, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants