feat(web): opt-in motion, native dialog for docs search, cross-document view transitions

Closed
WI
7d ago
+404-89

Motion on the site is opt-in, the docs search is a native modal <dialog> with CSS entry/exit transitions, and full-document navigations crossfade. Stacked on #191.

Opt-in motion (prefers-reduced-motion)

Movement (transforms, translating/scaling keyframes, loops) runs only under prefers-reduced-motion: no-preference. Opacity and color changes stay ungated. Existing complete reduce resets (landing, not-found, login, enterprise, flows, reflex, onboarding, flow-gate preview, .btn) are unchanged.

  • Hover lifts: 30 hover transforms across 12 files (agents, brand, plugins, file, message, pear, primitives, schedule, login, landing, blog, docs) sit in @media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference). Their color/border/shadow hover states stay in the plain hover block, so reduced-motion users still get hover feedback without movement.
  • Loops: the /file floating cards (5) and ping, the /auth connector pulse, the GitHub stars badge throb (site nav), and the /schedule clock second hand and tick pulses only animate under no-preference.
  • State transitions: the skip-link slide and the docs product-switcher chevron rotation transition only under no-preference. The landing tool-badge lift and tooltip rise are gated the same way, so reduced motion gets only the tooltip fade.
  • Entry: under reduced motion the waitlist success card fades in, with no scale pop.
  • relaycron.module.css .pricingCta transitions border-color, color, background (was all).
  • JS: FadeIn (used by 10 pages) checks matchMedia('(prefers-reduced-motion: reduce)'); under reduce it fades without the 30–40px slide. Every other live JS animation (SearchPreviewAnimation, DurableDeliveryTimeline, LostField, relay-field, ChannelMessagesPreview, SignupAtmosphere, FlowFileAnimation, FlowGatePreview) already checks matchMedia. FactoryBuilder uses scrollIntoView({ behavior: 'auto' }).

Docs search: native <dialog>

DocsSearch portals a <dialog> to document.body and opens it with showModal(). That gives top layer, inert page, focus trap, Esc to close, and focus restored to the opener. Cmd/Ctrl+K toggles it. Clicking the backdrop closes it (only when both the press and the release land on the backdrop). Arrow keys and Enter navigate results. The query resets on open, so the closing dialog keeps its content while it fades.

CSS: opacity 180ms with var(--ease-out-quint). Entry starts from @starting-style. Exit uses display/overlay with transition-behavior: allow-discrete, and ::backdrop has the same states. Under no-preference the dialog also rises 8px (translate); it never scales. :global(html):has(.dialog:modal) { overflow: hidden } locks page scroll while it's open; scrollbar-gutter: stable from #191 prevents layout shift. The overflow declarations in this PR are this scroll lock and overflow: hidden on the dialog box for its rounded corners. Neither touches the sibling overflow: clip sweep.

Cross-document view transitions

globals.css has @view-transition { navigation: auto; }. It crossfades same-origin full-document navigations between pages of this app:

  • Markdown links in docs and blog content. mdxComponents has no a override, so these render as plain <a>: about 264 internal links (250 to /docs).
  • Plain internal <a>s: the OpenClaw logo → /, AgentSignupJourney → /signup/agent/[product], and back/forward across those entries.

Next <Link> navigations (the nav, sidebar, search results, docs Card/BannerLink) are client-side and unaffected. Routes served by other apps through the router (/cloud*, /observer*) don't opt in, so they don't transition.

Verification

npm --workspace web run build ✅ · npm --workspace web run test ✅ 46 files, 555 passed / 1 skipped.

Dev server on :3247, checked in the in-app browser. Reduced motion was simulated by rewriting the loaded stylesheets' media lists (no-preference → not all, reduce → all):

Elementno-preferencereduce
/file .folderCardMain (loop)floatPrimary 7s infinitenone
/file .pulseOne (loop)ping 2.6s infinitenone
nav GitHub stars badge (loop)throb 3s infinitenone
/ hero marquee (existing gate)heroMarqueeSlide infinitenone
.skip-linktransition: transform 0.18sall 0s
/ .actionToolTooltip restingtranslateY(5px)none
/agents .pill:hover (real pointer hover)matrix(1,0,0,1,0,-1), color rgb(116,184,226)none, color rgb(116,184,226)
FadeIn offscreen wrapper (/agents/use-cases)transform: translateY(30px); transition: opacity …, transform …transform: none; transition: opacity …

Docs search on /docs/introduction:

  • Opens from the trigger button and from Cmd+K: dialog.open === true, :modal, focus on the input.
  • Settled open state: opacity 1, backdrop opacity 1, translate 0px. Entry runs opacity and translate transitions.
  • A wheel scroll over the backdrop leaves scrollY at 500; html overflow is hidden while open and visible after close.
  • ArrowDown ×2 → result index 2; Enter navigates client-side to /docs/cli-overview.
  • Esc closes and focus returns to the trigger (BUTTON[aria-haspopup=dialog]). A backdrop click closes; a click inside the panel does not.
  • Exit: right after close(), getAnimations() shows display, opacity, overlay and translate transitions, each 180ms and running. The dialog stays display: block; opacity at 90ms is 0.034 (quint ease-out).

View transition: the served CSS contains @view-transition{navigation:auto}. Clicking the in-article Markdown link /docs/cli-overview → /docs/observer is a full document load: the window marker was gone, and navigation.activation.navigationType === "push" from /docs/cli-overview. That makes it a same-origin cross-document navigation the rule applies to.

Left as-is

  • /message and /schedule redirect to /primitives, so relaycast.module.css and relaycron.module.css aren't rendered. They get the same gating for consistency. MessageRelayAnimation (rendered only by /message) keeps its rAF loop.
  • These components are imported nowhere: app/file/RelayfileAnimation.tsx, app/auth/RelayauthAnimation.tsx, components/RelayAnimation.tsx, components/NodeRelayAnimation.tsx. They're left untouched.
  • Hover transform states already neutralized by a reduce reset (enterprise's 0.01ms reset, .btn, the landing install visual) are unchanged.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


[!NOTE]
Low Risk
CSS and client UI accessibility improvements with no auth, data, or API changes; docs search modal behavior is the main functional touchpoint.

Overview
Motion is opt-in for users who have not requested reduced motion. Hover transform lifts are split from color/border/shadow so reduced-motion users still get visual hover feedback. Looping animations (floating file cards, connector pulse, GitHub stars throb, schedule clock ticks, etc.), skip-link slide, and several transitions only run under prefers-reduced-motion: no-preference. FadeIn keeps opacity fades but drops the slide when reduce is set; the waitlist success state uses a simple fade instead of a scale pop.

Docs search is rebuilt on a native <dialog> with showModal() (focus trap, inert backdrop, Esc, focus return). Styling uses opacity/translate entry and exit via @starting-style and discrete display/overlay transitions; motion-sensitive users get no rise on open.

Full-document navigations within the app get a crossfade from @view-transition { navigation: auto; } in globals.css (plain <a> / markdown links—not client <Link> routes).

Reviewed by Cursor Bugbot for commit 777295d934191197c96f1aebb107bdf9923ccd24. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Makes decorative movement opt-in, upgrades docs search to a native modal dialog, and crossfades same-origin full-document navigations.

  • Reduced-motion users keep color, border, and opacity feedback without slides, lifts, or looping animations.
  • Search supports Cmd/Ctrl+K, keyboard navigation, Escape and backdrop dismissal, and returns focus to its opener.
  • Client-side navigations remain unchanged.