/* Loops — small reusable primitives */
const Container = ({ children, className = "" }) =>
{children}
;
const Section = ({ id, children, className = "", as = "section", aria, style }) => {
const Tag = as;
return (
{children}
);
};
const Kicker = ({ children }) =>
{children};
const SectionHeading = ({ kicker, title, body, align = "left", titleClassName = "", className = "" }) =>
{kicker ?
{kicker}
: null}
{title}
{body ?
{body}
: null}
;
/* Buttons — primary, secondary, ghost */
const Button = React.forwardRef(({
as: Tag = "a",
variant = "primary",
size = "md",
children,
className = "",
...props
}, ref) => {
const base = "inline-flex items-center justify-center gap-2 font-semibold rounded-pill transition-all focus-ring select-none whitespace-nowrap";
const sizes = {
sm: "h-10 px-5 text-[14px]",
md: "h-12 px-6 text-[15px]",
lg: "h-[52px] px-7 text-[15px]"
};
const variants = {
primary: "bg-brand text-white shadow-cta hover:bg-[#3f45c9] active:bg-[#363bb5]",
secondary: "bg-soft text-ink border border-line hover:bg-muted/60",
ghost: "bg-transparent text-brand border-[1.5px] border-brand hover:bg-brand-soft",
dark: "bg-ink text-white hover:bg-[#2a1f47]",
white: "bg-white text-ink border border-line hover:bg-soft"
};
return (
{children}
);
});
/* Soft pill / chip */
const Chip = ({ children, tone = "indigo", className = "" }) => {
const tones = {
indigo: "bg-brand-soft text-brand-deep",
rose: "bg-rose-soft text-rose",
amber: "bg-amber-soft text-[#b96e10]",
neutral: "bg-soft text-ink-2 border border-line"
};
return (
{children}
);
};
/* App store buttons — illustrative, never real Apple/Google trade dress */
const AppStoreBadge = ({ store = "apple", className = "" }) => {
const apple =
Download on
App Store
;
const google =
Get it on
Google Play
;
return store === "apple" ? apple : google;
};
/* Animated brand keyline — subtle, just a thin moving line */
const Keyline = ({ className = "" }) =>
;
/* A simple visual "season glyph" — abstract calm circle motif */
const SeasonGlyph = ({ variant = 0, className = "" }) => {
const variants = [
// career — concentric rising arc
,
// money — soft coin stack
,
// exercise — pulse
,
// mood — gentle waves
,
// relationships — two overlapping circles
,
// exams — three rising bars
,
// parenting — small + big circle
,
// big questions — single dot with halo
];
return (
);
};
/* ----------------------------------------------------------------
Hover micro-interaction helper.
Plays a looping WAAPI animation while `card` is hovered/focused.
On hover-in: eases playback up to full speed.
On hover-out: instead of freezing mid-motion, it smoothly carries
the animation FORWARD to its next resting frame (an iteration
boundary, where the element sits at its default state) and only
then pauses — so every hover animation resets cleanly.
Respects prefers-reduced-motion (stays at the resting frame).
Returns a cleanup function.
------------------------------------------------------------------- */
function attachHoverLoop(el, card, keyframes, timing) {
if (!el) return () => {};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const D = timing.duration;
const anim = el.animate(keyframes, { ...timing, iterations: Infinity, easing: "linear" });
anim.pause();
if (reduce || !card) return () => anim.cancel();
let raf = null,last = null;
let mode = "idle"; // 'in' | 'out' | 'idle'
let settleFrom = 0,settleTarget = 0,settleElapsed = 0,settleDur = 1;
const easeOut = (p) => 1 - Math.pow(1 - p, 3);
const loop = (t) => {
if (last == null) last = t;
const dt = (t - last) / 1000;
last = t;
if (mode === "in") {
let r = anim.playbackRate;
r += (1 - r) * Math.min(1, dt * 4.5); // ease up to speed
if (1 - r < 0.012) r = 1;
anim.playbackRate = r;
if (anim.playState !== "running") anim.play();
raf = requestAnimationFrame(loop);
} else if (mode === "out") {
// drive currentTime manually toward the next resting frame
settleElapsed += dt * 1000;
const p = Math.min(1, settleElapsed / settleDur);
anim.currentTime = settleFrom + (settleTarget - settleFrom) * easeOut(p);
if (p >= 1) {anim.currentTime = settleTarget;anim.pause();mode = "idle";raf = null;last = null;return;}
raf = requestAnimationFrame(loop);
} else {
raf = null;last = null;
}
};
const kick = () => {if (!raf) {last = null;raf = requestAnimationFrame(loop);}};
const start = () => {mode = "in";if (anim.playState !== "running") anim.play();kick();};
const stop = () => {
const c = anim.currentTime || 0;
settleFrom = c;
settleTarget = Math.ceil((c + 0.001) / D) * D; // next iteration boundary = resting frame
settleElapsed = 0;
settleDur = Math.min(650, settleTarget - c + 120); // proportional, capped, snappy
anim.pause(); // we drive currentTime ourselves now
anim.playbackRate = 1;
mode = "out";
kick();
};
card.addEventListener("mouseenter", start);
card.addEventListener("mouseleave", stop);
card.addEventListener("focusin", start);
card.addEventListener("focusout", stop);
return () => {
card.removeEventListener("mouseenter", start);
card.removeEventListener("mouseleave", stop);
card.removeEventListener("focusin", start);
card.removeEventListener("focusout", stop);
if (raf) cancelAnimationFrame(raf);
anim.cancel();
};
}
Object.assign(window, { Container, Section, SectionHeading, Kicker, Button, Chip, AppStoreBadge, Keyline, SeasonGlyph, attachHoverLoop });