6a6d50abdb
- PopIn (Home Hero's brand dot) switched from whileInView to animate — its translate-based entrance could push the element off-screen on a narrow phone before the IntersectionObserver ever saw it as visible, leaving it stuck invisible permanently. - Hero image: no longer wrapped in Reveal below lg: — whileInView's margin meant it stayed at opacity:0 (a white gap above the fold) on short mobile viewports until scrolled. Reveal's fade-in kept from lg: up. - "→ Label" CTA links (Tools.tsx, Blog.tsx) now use a flex row with the arrow as its own span instead of a literal inline "→" character, which doesn't reliably align to the surrounding text's cap-height. - Replaced icon-arrow-connector.svg with a new shared StepArrow component (inline SVG) across all three step sections — the old asset's color couldn't be overridden from outside the SVG file, so it could never actually become brand-orange. Bigger and better-shaped below the structural breakpoint per feedback. - Added MobileSectionTOC (SectionTOC.tsx) — a <details> accordion shown below lg: on Impressum/Datenschutz/AGB/Widerruf/Versand, which previously had no on-page navigation aid at all below lg: (the sidebar TOC is `hidden` entirely there). - Updated the figma-to-nextjs skill with 8 new dated Gotchas from this mobile-responsive pass, and expanded Step 6's verification checklist. - README: new "Mobile responsive pass" section summarizing the above.
141 lines
5.4 KiB
TypeScript
141 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { motion, type Variants } from "motion/react";
|
|
import type { CSSProperties, ReactNode } from "react";
|
|
|
|
// Plain fade, no y-translate — fixed 2026-07-24. Used to animate opacity
|
|
// 0→1 *and* y 28→0 together ("fade up"), which read as the whole section
|
|
// visibly hopping/jumping into place on top of the fade — one motion cue
|
|
// too many. The fade alone is already a clear enough "this just appeared"
|
|
// signal without the extra jump.
|
|
const fadeIn: Variants = {
|
|
hidden: { opacity: 0 },
|
|
show: { opacity: 1, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
|
|
};
|
|
|
|
type RevealProps = {
|
|
children: ReactNode;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
/** Delay in seconds, e.g. for staggering siblings that aren't inside a RevealGroup. */
|
|
delay?: number;
|
|
};
|
|
|
|
/** Fades a section into view once, the first time it scrolls into view. */
|
|
export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
|
|
return (
|
|
<motion.div
|
|
className={className}
|
|
style={style}
|
|
initial="hidden"
|
|
whileInView="show"
|
|
viewport={{ once: true, margin: "-80px" }}
|
|
variants={fadeIn}
|
|
transition={{ delay }}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
const staggerContainer: Variants = {
|
|
hidden: {},
|
|
show: { transition: { staggerChildren: 0.12 } },
|
|
};
|
|
|
|
/** Wraps a grid/row of cards — reveals children one-by-one as it scrolls into view. */
|
|
export function RevealGroup({ children, className }: { children: ReactNode; className?: string }) {
|
|
return (
|
|
<motion.div
|
|
className={className}
|
|
initial="hidden"
|
|
whileInView="show"
|
|
viewport={{ once: true, margin: "-80px" }}
|
|
variants={staggerContainer}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/** Child item for use inside a RevealGroup — same fade motion, driven by the parent's stagger. */
|
|
export function RevealItem({ children, className }: { children: ReactNode; className?: string }) {
|
|
return (
|
|
<motion.div className={className} variants={fadeIn}>
|
|
{children}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
const popIn: Variants = {
|
|
hidden: { x: 140, y: -70, scale: 0.5, opacity: 0 },
|
|
show: {
|
|
// x is a single continuous glide (140 → 0), deliberately NOT
|
|
// keyframed in step with y's bounce schedule. It used to share y's
|
|
// per-segment alternating ease so it would decelerate to a dead stop
|
|
// at every bounce peak alongside y — physically correct for y (a
|
|
// ball's vertical speed really is zero at the top of each arc) but
|
|
// wrong for x, which has no reason to pause horizontally just because
|
|
// it's momentarily at peak height. That shared stop-start was read as
|
|
// "stops unnaturally in between". Giving x its own transition (below)
|
|
// decouples it completely, so it glides smoothly the whole time while
|
|
// y still bounces underneath it.
|
|
x: 0,
|
|
y: [-70, 0, -28, 0, -12, 0, -3, 0],
|
|
scale: [0.5, 1.05, 0.95, 1.03, 0.97, 1.02, 0.99, 1],
|
|
// Array, not a bare 1 — a scalar target here would fade opacity
|
|
// linearly across the *entire* 1s duration against the shared `times`
|
|
// grid below, so the dot would still be half-transparent through its
|
|
// first, fastest bounces. Snap to fully opaque at the first keyframe
|
|
// instead, matching how a real solid ball would look immediately.
|
|
opacity: [0, 1, 1, 1, 1, 1, 1, 1],
|
|
transition: {
|
|
// Applies to y/scale/opacity (anything without its own override
|
|
// below) — the actual bounce schedule.
|
|
default: {
|
|
duration: 1,
|
|
times: [0, 0.22, 0.4, 0.55, 0.68, 0.8, 0.9, 1],
|
|
// Per-segment, not a single ease for the whole animation — a real
|
|
// bounce accelerates while falling (gravity) and decelerates
|
|
// while rising toward each peak. Alternating fall (easeIn) / rise
|
|
// (easeOut), one entry per keyframe-to-keyframe transition.
|
|
ease: ["easeIn", "easeOut", "easeIn", "easeOut", "easeIn", "easeOut", "easeIn"],
|
|
},
|
|
// x's own transition — one smooth decelerating tween across the
|
|
// full duration, independent of the y bounce's stop-start rhythm.
|
|
x: { duration: 1, ease: "easeOut" },
|
|
},
|
|
},
|
|
};
|
|
|
|
/**
|
|
* One-shot "bouncing ball" drop-in for a small accent glyph (e.g. the
|
|
* brand's orange dot) — arrives from the top right and bounces to rest,
|
|
* not a looping pulse. `motion.span` needs a Client Component, same reason
|
|
* Reveal itself exists: keep "use client" isolated here so callers like
|
|
* Hero.tsx can stay Server Components.
|
|
*/
|
|
export function PopIn({ children, className, delay = 0 }: RevealProps) {
|
|
return (
|
|
<motion.span
|
|
className={className}
|
|
initial="hidden"
|
|
// animate, not whileInView — its only caller (Hero.tsx's brand dot)
|
|
// sits above the fold, already visible on load, so there's no real
|
|
// "scrolls into view" moment to gate on. whileInView's -80px viewport
|
|
// margin also broke on some phones: the popIn variant's own "hidden"
|
|
// state translates x:+140, and on a narrow mobile viewport that could
|
|
// push the dot's pre-animation bounding box past the right edge —
|
|
// IntersectionObserver then never reports it as visible, so
|
|
// whileInView never fires and the dot stays stuck off-screen
|
|
// (reported 2026-07-24: dot invisible on a real phone). Firing on
|
|
// mount sidesteps that geometry entirely.
|
|
animate="show"
|
|
variants={popIn}
|
|
transition={{ delay }}
|
|
>
|
|
{children}
|
|
</motion.span>
|
|
);
|
|
}
|