@@ -36,7 +30,7 @@ function CustomItem({ icon: Icon, title, description, preview }: CustomItemProps
{preview}
-
+
);
}
@@ -91,7 +85,7 @@ function ModelsPreview() {
}, []);
return (
-
+
{MODELS.map((model, i) => {
const isActive = i === active;
return (
@@ -158,7 +152,7 @@ function ModelsPreview() {
function LengthPreview() {
return (
-
+
3-7 words
7-12 words
@@ -180,7 +174,7 @@ function LengthPreview() {
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
className="pointer-events-none absolute inset-y-0 left-0 w-full"
>
-
+
@@ -194,15 +188,15 @@ function LengthPreview() {
function PersonalizationPreview() {
const signals = ["writing style", "memory", "adapts over time"];
return (
-
+
-
+
coming soon
{signals.map((signal, i) => (
diff --git a/frontend/app/components/demo-gif.tsx b/frontend/app/components/demo-gif.tsx
new file mode 100644
index 0000000..820bca1
--- /dev/null
+++ b/frontend/app/components/demo-gif.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useReducedMotion } from "framer-motion";
+import Image from "next/image";
+import { useEffect, useRef, useState } from "react";
+
+type DemoGifProps = {
+ src: string;
+ width: number;
+ height: number;
+ alt: string;
+ icon: string;
+ iconPad?: boolean;
+ label: string;
+};
+
+/**
+ * Lazy, reduced-motion-aware GIF demo. The frame keeps the GIF's native aspect
+ * ratio (these clips are wide and short), so object-cover fills it with no crop.
+ * The (heavy) GIF is only fetched once it nears the viewport, and visitors who
+ * prefer reduced motion get a static placeholder instead of an animation they
+ * can't pause. A plain
is used on purpose: next/image would strip GIF
+ * animation.
+ */
+export function DemoGif({
+ src,
+ width,
+ height,
+ alt,
+ icon,
+ iconPad = false,
+ label,
+}: DemoGifProps) {
+ const prefersReducedMotion = useReducedMotion() ?? false;
+ const ref = useRef(null);
+ const [shouldLoad, setShouldLoad] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ if (prefersReducedMotion || shouldLoad) return;
+ const el = ref.current;
+ if (!el) return;
+ const io = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((e) => e.isIntersecting)) {
+ setShouldLoad(true);
+ io.disconnect();
+ }
+ },
+ { rootMargin: "250px" },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, [shouldLoad, prefersReducedMotion]);
+
+ const showPlaceholder = prefersReducedMotion || !shouldLoad || !loaded;
+
+ return (
+
+ {showPlaceholder && (
+
+
+
+
+
+ {prefersReducedMotion ? `${label} demo` : "loading demo…"}
+
+
+ )}
+ {shouldLoad && !prefersReducedMotion && (
+ // eslint-disable-next-line @next/next/no-img-element -- animated GIF
+
setLoaded(true)}
+ className={`h-full w-full object-cover transition-opacity duration-500 ${
+ loaded ? "opacity-100" : "opacity-0"
+ }`}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/app/components/demo-video-section.tsx b/frontend/app/components/demo-video-section.tsx
index b04c168..40a7a7e 100644
--- a/frontend/app/components/demo-video-section.tsx
+++ b/frontend/app/components/demo-video-section.tsx
@@ -21,7 +21,7 @@ export function DemoVideoSection() {
diff --git a/frontend/app/components/email-gate.tsx b/frontend/app/components/email-gate.tsx
index d29364f..315d9cd 100644
--- a/frontend/app/components/email-gate.tsx
+++ b/frontend/app/components/email-gate.tsx
@@ -135,7 +135,7 @@ export function EmailGateProvider({ children }: { children: ReactNode }) {
: { opacity: 0, scale: 0.96, y: 10 }
}
transition={{ duration: 0.32, ease: EASE }}
- className="relative w-full max-w-md rounded-3xl border-2 border-line bg-surface p-8 shadow-[0_13.4px_0_var(--line)]"
+ className="relative w-full max-w-md rounded-3xl border-2 border-line bg-surface p-8 shadow-[0_13.4px_0_var(--shadow-color)]"
>
@@ -194,15 +188,15 @@ function LengthPreview() { function PersonalizationPreview() { const signals = ["writing style", "memory", "adapts over time"]; return ( -