feat: scroll to post bubble from recommended card + back-to-top button
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s
Recommended cards already routed to /browse#post-<id>, but the stream had no logic to scroll to the target bubble — and the post might not be paged in yet. MessageStream now resolves the #post-<id> hash, auto-loads more pages until the bubble renders, scrolls to it, and gives it a brief gold highlight. Bubbles get scroll-mt so they clear the sticky header. Also adds a global floating back-to-top button (BackToTop) mounted in PublicLayout, shown after scrolling past 400px. Bundles related staging UI work already present in the working tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
src/motion/MotionProvider.tsx
Normal file
21
src/motion/MotionProvider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { LazyMotion, MotionConfig, domAnimation } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Wraps the app once at the root.
|
||||
*
|
||||
* - `LazyMotion` + `domAnimation` keeps the framer-motion bundle small
|
||||
* (~feature subset, lazily loaded). Components MUST use the `m` namespace
|
||||
* (e.g. `m.div`) rather than `motion.*`; `strict` enforces this so we never
|
||||
* accidentally pull in the full bundle.
|
||||
* - `MotionConfig reducedMotion="user"` makes every framer animation respect
|
||||
* the OS "reduce motion" setting automatically (transforms are dropped,
|
||||
* opacity is kept), so individual components don't each need to handle it.
|
||||
*/
|
||||
export function MotionProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<LazyMotion features={domAnimation} strict>
|
||||
<MotionConfig reducedMotion="user">{children}</MotionConfig>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
40
src/motion/Reveal.tsx
Normal file
40
src/motion/Reveal.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { m } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { fadeInUp } from "./variants";
|
||||
|
||||
type RevealProps = {
|
||||
children: ReactNode;
|
||||
/** Per-item delay in seconds (e.g. index * 0.06 for a staggered list). */
|
||||
delay?: number;
|
||||
className?: string;
|
||||
/** Only animate the first time it enters the viewport. */
|
||||
once?: boolean;
|
||||
/** Fraction of the element that must be visible to trigger (0-1). */
|
||||
amount?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fade + lift a block in as it scrolls into view. Uses framer's `whileInView`,
|
||||
* so it respects the global `reducedMotion="user"` config from MotionProvider
|
||||
* (no manual guard needed). Pass an incremental `delay` for simple stagger.
|
||||
*/
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
once = true,
|
||||
amount = 0.15,
|
||||
}: RevealProps) {
|
||||
return (
|
||||
<m.div
|
||||
className={className}
|
||||
variants={fadeInUp}
|
||||
custom={delay}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount }}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
12
src/motion/index.ts
Normal file
12
src/motion/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { MotionProvider } from "./MotionProvider";
|
||||
export { Reveal } from "./Reveal";
|
||||
export { useRevealOnScroll } from "./useRevealOnScroll";
|
||||
export {
|
||||
EASE_OUT,
|
||||
baseTransition,
|
||||
fadeInUp,
|
||||
scaleIn,
|
||||
staggerContainer,
|
||||
pageTransition,
|
||||
cardHover,
|
||||
} from "./variants";
|
||||
50
src/motion/useRevealOnScroll.ts
Normal file
50
src/motion/useRevealOnScroll.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type RevealOptions = {
|
||||
rootMargin?: string;
|
||||
threshold?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lightweight IntersectionObserver hook. Sets `inView` to true the first time
|
||||
* the element enters the viewport, then stops observing (fires once). Falls
|
||||
* back to immediately visible when IntersectionObserver is unavailable (SSR /
|
||||
* very old browsers) so content is never hidden.
|
||||
*/
|
||||
export function useRevealOnScroll<T extends HTMLElement = HTMLDivElement>(
|
||||
options?: RevealOptions,
|
||||
) {
|
||||
const ref = useRef<T>(null);
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
const rootMargin = options?.rootMargin ?? "0px 0px -10% 0px";
|
||||
const threshold = options?.threshold ?? 0.1;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setInView(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true);
|
||||
observer.disconnect();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin, threshold },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [rootMargin, threshold]);
|
||||
|
||||
return { ref, inView };
|
||||
}
|
||||
58
src/motion/variants.ts
Normal file
58
src/motion/variants.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Transition, Variants } from "framer-motion";
|
||||
|
||||
/** Premium ease-out curve shared by all motion. */
|
||||
export const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
/** Base transition for reveal-style animations. */
|
||||
export const baseTransition: Transition = {
|
||||
duration: 0.4,
|
||||
ease: EASE_OUT,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fade + lift in. `visible` is a function variant so callers can pass a
|
||||
* per-item delay via `custom` (defaults to 0, e.g. when driven by a
|
||||
* stagger container).
|
||||
*/
|
||||
export const fadeInUp: Variants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: (delay: number = 0) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { ...baseTransition, delay },
|
||||
}),
|
||||
};
|
||||
|
||||
/** Fade + subtle scale in (cards, popovers). */
|
||||
export const scaleIn: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.96 },
|
||||
visible: (delay: number = 0) => ({
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { ...baseTransition, delay },
|
||||
}),
|
||||
};
|
||||
|
||||
/** Parent container that staggers its children's `visible` state. */
|
||||
export const staggerContainer: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.06, delayChildren: 0.02 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Route enter/exit transition used with AnimatePresence. */
|
||||
export const pageTransition: Variants = {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
enter: { opacity: 1, y: 0, transition: { duration: 0.24, ease: EASE_OUT } },
|
||||
exit: { opacity: 0, y: -6, transition: { duration: 0.16, ease: EASE_OUT } },
|
||||
};
|
||||
|
||||
/** Springy hover lift for cards. Use rest/hover states on an `m` element. */
|
||||
export const cardHover: Variants = {
|
||||
rest: { y: 0 },
|
||||
hover: {
|
||||
y: -4,
|
||||
transition: { type: "spring", stiffness: 380, damping: 26 },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user