diff --git a/app/globals.css b/app/globals.css index 16f62d6..d3fd037 100644 --- a/app/globals.css +++ b/app/globals.css @@ -747,13 +747,6 @@ body::after { .landing-logo-track, .landing-logo-marquee, .motioncode-nav-shell::after, - .feature-code-snippet, - .feature-code-snippet::before, - .feature-code-cursor, - .motioncode-feature-card, - .motioncode-feature-card-bar, - .motioncode-feature-panel, - .motioncode-feature-panel-ghost, .motioncode-bridge-signal, .motioncode-bridge-pipeline-node, .motioncode-bridge-rail span, @@ -881,14 +874,6 @@ body::after { animation: inlineBadgeDotPulse 1.8s ease-in-out infinite; } -.motioncode-feature-status-line { - display: flex; - flex-wrap: wrap; - gap: 9px; - margin-bottom: 30px; -} - -.motioncode-feature-status-line .motioncode-inline-badge, .motioncode-final-cta-meta .motioncode-inline-badge { margin: 0; } @@ -1606,195 +1591,6 @@ body::after { } } -.motioncode-feature-card { - position: relative; - overflow: hidden; - padding: 24px 28px; - margin-bottom: 10px; - border: 1px solid var(--border); - border-left: 1px solid var(--border); - border-radius: 10px !important; - border-color: rgba(216, 207, 188, 0.26) !important; - background: - linear-gradient(135deg, rgba(255, 251, 244, 0.065), rgba(18, 20, 15, 0.82)) !important; - box-shadow: - 0 16px 34px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 251, 244, 0.035); - cursor: default; - backdrop-filter: blur(12px); - transform-origin: left center; - transition: - transform 420ms cubic-bezier(0.16, 1, 0.3, 1), - border-color 350ms ease, - background 350ms ease, - box-shadow 350ms ease, - opacity 350ms ease !important; - will-change: transform, opacity; -} - -/* Vertical accent rail that grows in on the active card — the main source of - "alive" without animating layout. transform/opacity only. */ -.motioncode-feature-card-bar { - position: absolute; - top: 0; - left: 0; - bottom: 0; - width: 3px; - background: linear-gradient(180deg, #00ff88, rgba(0, 255, 136, 0.25)); - transform: scaleY(0); - transform-origin: top center; - opacity: 0; - transition: - transform 480ms cubic-bezier(0.16, 1, 0.3, 1), - opacity 320ms ease; - pointer-events: none; -} - -.motioncode-feature-card[data-active="false"] { - opacity: 0.76 !important; - border-color: rgba(216, 207, 188, 0.24) !important; - background: - linear-gradient(135deg, rgba(216, 207, 188, 0.06), rgba(10, 12, 9, 0.88)) !important; -} - -.motioncode-feature-card:hover { - transform: translateX(8px); - opacity: 0.95 !important; - border-color: rgba(216, 207, 188, 0.64) !important; - background: - linear-gradient(135deg, rgba(216, 207, 188, 0.18), rgba(18, 20, 15, 0.82)) !important; - box-shadow: - 0 22px 48px rgba(0, 0, 0, 0.34), - 0 0 32px rgba(0, 255, 136, 0.055), - inset 0 1px 0 rgba(255, 251, 244, 0.08); -} - -.motioncode-feature-card[data-active="true"] { - opacity: 1 !important; - transform: translateX(10px); - border-color: rgba(0, 255, 136, 0.54) !important; - border-left: 1px solid var(--border) !important; - background: - linear-gradient(135deg, rgba(216, 207, 188, 0.18), rgba(0, 255, 136, 0.055) 64%, rgba(12, 14, 11, 0.9)) !important; - box-shadow: - 0 24px 52px rgba(0, 0, 0, 0.4), - 0 0 34px rgba(0, 255, 136, 0.08), - inset 0 1px 0 rgba(255, 251, 244, 0.1); -} - -.motioncode-feature-card[data-active="true"] .motioncode-feature-card-bar { - transform: scaleY(1); - opacity: 1; -} - -.motioncode-feature-card[data-active="true"] .card-num { - text-shadow: 0 0 12px rgba(0, 255, 136, 0.45); -} - -.card-num { - transition: text-shadow 320ms ease; -} - -.motioncode-feature-card-title { - color: rgb(255, 251, 244) !important; - text-wrap: balance; - letter-spacing: 0; -} - -.motioncode-feature-card-desc { - color: rgba(216, 207, 188, 0.82) !important; - font-family: var(--font-body) !important; -} - -.feature-code-snippet { - position: relative; - overflow: hidden; - border: 1px solid rgba(216, 207, 188, 0.18); - border-radius: 10px; - background: - linear-gradient(180deg, rgba(255, 251, 244, 0.055), rgba(0, 255, 136, 0.026)), - #070907; - color: rgb(255, 251, 244); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.38), - inset 0 1px 0 rgba(255, 251, 244, 0.06); - line-height: 1.8; - overflow-wrap: anywhere; - animation: terminalFrame 3.8s ease-in-out infinite; -} - -.feature-code-snippet::before { - content: ""; - position: absolute; - top: 0; - left: 12px; - right: 12px; - height: 1px; - background: linear-gradient(90deg, transparent, rgba(0, 255, 136, 0.72), rgba(255, 251, 244, 0.46), transparent); - pointer-events: none; - animation: terminalReadabilityPulse 3.4s ease-in-out infinite; -} - -.feature-code-snippet::selection { - background: rgba(0, 255, 136, 0.22); -} - -/* Each code line is its own element so it can be stagger-revealed by GSAP when - a panel activates. GSAP manages will-change during the tween, so we don't pin - a layer on every line permanently. */ -.feature-code-snippet > .feature-code-line { - display: block; - white-space: pre; -} - -.feature-code-cursor { - display: inline-block; - width: 7px; - height: 12px; - margin-top: 6px; - background: #00ff88; - box-shadow: 0 0 8px rgba(0, 255, 136, 0.55); - animation: terminalCursorBlink 1.05s steps(1, end) infinite; -} - -@keyframes terminalCursorBlink { - 0%, - 49% { - opacity: 1; - } - 50%, - 100% { - opacity: 0; - } -} - -/* Right-hand preview panels: crossfade with transform + opacity only. The old - version animated filter: blur(8px), which is expensive and ran during the - pinned scroll. Driven entirely by data-active now. */ -.motioncode-feature-panel { - opacity: 0; - transform: translateY(14px) scale(0.985); - pointer-events: none; - transition: - opacity 420ms cubic-bezier(0.16, 1, 0.3, 1), - transform 520ms cubic-bezier(0.16, 1, 0.3, 1); - will-change: transform, opacity; -} - -.motioncode-feature-panel[data-active="true"] { - opacity: 1; - transform: translateY(0) scale(1); - pointer-events: auto; -} - -.motioncode-feature-panel-ghost { - transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.motioncode-feature-panel[data-active="true"] .motioncode-feature-panel-ghost { - transform: scale(1.05); -} - .motioncode-process-section { position: relative; isolation: isolate; @@ -3172,31 +2968,7 @@ body::after { gap: 24px; } -/* Soft, inset column divider for the pinned features section. Replaces a hard - full-height border so the line never collides with the logo strip seam above; - it fades out top and bottom and reads as an intentional split. */ -.motioncode-feature-list::after { - content: ""; - position: absolute; - top: 14%; - bottom: 14%; - right: 0; - width: 1px; - background: linear-gradient( - 180deg, - transparent, - var(--border) 16%, - var(--border) 84%, - transparent - ); - pointer-events: none; -} - @media (max-width: 767px) { - .motioncode-feature-list::after { - display: none; - } - .motioncode-march-shell { overflow-x: hidden; } @@ -3207,37 +2979,6 @@ body::after { padding-inline: 10px !important; } - .motioncode-feature-section { - display: block !important; - height: auto !important; - min-height: 0 !important; - margin-bottom: 0 !important; - overflow: visible !important; - position: relative !important; - } - - .motioncode-feature-list { - border-right: 0 !important; - box-sizing: border-box; - padding: 72px 20px !important; - width: 100% !important; - } - - .motioncode-feature-list-inner { - gap: 12px; - justify-content: flex-start !important; - } - - .motioncode-feature-card { - margin-bottom: 0 !important; - opacity: 1 !important; - padding: 20px !important; - } - - .motioncode-feature-visual { - display: none !important; - } - .motioncode-process-rail { display: none; } diff --git a/app/page.tsx b/app/page.tsx index 9874102..44a07d0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -23,6 +23,13 @@ const Aurora = dynamic(() => import("@/components/react-bits/Aurora/Aurora"), { const HandoffBridge = dynamic( () => import("@/components/marketing/handoff-bridge"), ); + +// The capability timeline is a pinned, scroll-scrubbed motion-editor section +// with its own GSAP setup and React Bits DecryptedText titles. Below the fold, +// so it code-splits like the handoff diagram above. +const CapabilityTimeline = dynamic( + () => import("@/components/marketing/capability-timeline"), +); import { CheckoutButton } from "@/app/pricing/CheckoutButton"; import { MarketingAuthNavActions } from "@/components/marketing/auth-nav-actions"; import { ScrollSolutionBridge } from "@/components/marketing/scroll-solution-bridge"; @@ -68,56 +75,6 @@ function writeMotionPreference(next: boolean) { motionPreferenceListeners.forEach((listener) => listener()); } -const GLITCH_CHARS = "!<>-_\\/[]{}-=+*^?#________"; -const scrambleText = (element: HTMLElement | null, finalString: string, goingOut: boolean = false) => { - if (!element) return; - let iteration = 0; - const intervalId = element.getAttribute('data-interval-id'); - if (intervalId) clearInterval(parseInt(intervalId)); - - const stepTime = 20; - const totalSteps = 12; - const charsPerStep = Math.max(1, finalString.length / totalSteps); - - const interval = setInterval(() => { - // textContent (not innerText): innerText is layout-aware and forces a - // reflow on every write — and this runs every 20ms while a panel - // transition fires during the pinned scroll. textContent skips that. - element.textContent = finalString - .split("") - .map((letter, index) => { - if (!goingOut) { - if (index < iteration) return finalString[index]; - return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)]; - } else { - if (index < iteration) return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)]; - return finalString[index]; - } - }) - .join(""); - - if(iteration >= finalString.length) { - clearInterval(interval); - element.removeAttribute('data-interval-id'); - if (!goingOut) { - element.textContent = finalString; - } - } - - iteration += charsPerStep; - }, stepTime); - - element.setAttribute('data-interval-id', interval.toString()); -}; - -const FEATURES_DATA = [ - { num: "01", title: "Upload Any Format", desc: "Drag in MP4, GIF, WebM, or Lottie files. Our engine handles any animation source with frame-perfect accuracy.", code: "// 8 frames extracted\n> format: MP4 → JPEG\n> resolution: 1920×1080" }, - { num: "02", title: "AI Frame Analysis", desc: "Neural networks decompose motion into discrete keyframes, easing curves, and transform paths automatically.", code: "// motion analyzed\n> keyframes: 12\n> easing: cubic-bezier(...)" }, - { num: "03", title: "Multi-Framework Output", desc: "Get production-ready CSS, GSAP, and Framer Motion code. Copy, paste, ship.", code: "// output ready\n> CSS ✓\n> GSAP ✓\n> Framer ✓" }, - { num: "04", title: "Performance Scorer", desc: "Every generated animation is benchmarked for jank, repaints, and composite layer usage with a 0-100 score.", code: "// perf score\n> GPU layers: yes\n> score: 94/100" }, - { num: "05", title: "Accessibility Audit", desc: "Automatic prefers-reduced-motion fallbacks and WCAG compliance checks on every export.", code: "// a11y check\n> reduced-motion: ✓\n> WCAG AA: pass" } -]; - const FRAME_SAMPLES = ["00", "04", "08", "12", "16", "20", "24", "28"]; const CURVE_POINTS = [ { left: "8%", top: "72%" }, @@ -323,10 +280,6 @@ export default function LandingPage() { const heroTermLinesRef = useRef<(HTMLDivElement | null)[]>([]); const heroGlowRef = useRef(null); - const featuresSectionRef = useRef(null); - const leftCardsRef = useRef<(HTMLDivElement | null)[]>([]); - const rightPanelsRef = useRef<(HTMLDivElement | null)[]>([]); - const processSectionRef = useRef(null); const processStepsRef = useRef<(HTMLElement | null)[]>([]); @@ -719,215 +672,6 @@ export default function LandingPage() { } } - // Setup Features Section ScrollTrigger - const NUM_FEATURES = FEATURES_DATA.length; - let lastIndex = -1; - let featureTrigger: ReturnType | null = null; - let featureTimeline: ReturnType | null = null; - const compactFeaturesQuery = window.matchMedia("(max-width: 767px)"); - - // Drive the active feature purely through data-active attributes — the - // visual states live in CSS. onUpdate fires every frame while the section - // is pinned, so we bail out unless the index actually changed; that is the - // difference between a smooth pin and the per-frame style thrash that was - // here before. - const activateFeature = (index: number) => { - if (index === lastIndex) return; - const prevIndex = lastIndex; - lastIndex = index; - - leftCardsRef.current.forEach((card, i) => { - if (card) card.dataset.active = i === index ? "true" : "false"; - }); - - rightPanelsRef.current.forEach((panel, i) => { - if (!panel) return; - const isActive = i === index; - panel.dataset.active = isActive ? "true" : "false"; - - const titleEl = panel.querySelector(".feature-title") as HTMLElement | null; - const descEl = panel.querySelector(".feature-desc") as HTMLElement | null; - - if (isActive) { - const codeLines = panel.querySelectorAll(".feature-code-line"); - if (reduceMotion) { - if (titleEl) titleEl.textContent = FEATURES_DATA[i].title; - if (descEl) descEl.textContent = FEATURES_DATA[i].desc; - gsap.set(codeLines, { autoAlpha: 1, x: 0 }); - } else { - if (titleEl) scrambleText(titleEl, FEATURES_DATA[i].title, false); - if (descEl) scrambleText(descEl, FEATURES_DATA[i].desc, false); - gsap.fromTo( - codeLines, - { autoAlpha: 0, x: -10 }, - { - autoAlpha: 1, - x: 0, - duration: 0.34, - stagger: 0.05, - ease: "power2.out", - overwrite: true, - }, - ); - } - } else if (!reduceMotion && i === prevIndex) { - if (titleEl) scrambleText(titleEl, FEATURES_DATA[i].title, true); - if (descEl) scrambleText(descEl, FEATURES_DATA[i].desc, true); - } - }); - }; - - const resetFeatureCards = () => { - leftCardsRef.current.forEach((card) => { - if (card) card.dataset.active = "false"; - }); - rightPanelsRef.current.forEach((panel) => { - if (panel) panel.dataset.active = "false"; - }); - }; - - const clearFeatureTrigger = () => { - if (featureTimeline) { - // Killing the timeline kills its ScrollTrigger (and unpins) too. - featureTimeline.scrollTrigger?.kill(); - featureTimeline.kill(); - featureTimeline = null; - } - if (featureTrigger) { - featureTrigger.kill(); - featureTrigger = null; - } - // Hand the panels back to CSS: drop the inline transition lock and the - // opacity/visibility/transform the scrub timeline was driving, so a - // re-layout (e.g. resize into the compact breakpoint) starts clean. - rightPanelsRef.current.forEach((panel) => { - if (!panel) return; - panel.style.transition = ""; - gsap.set(panel, { clearProps: "opacity,visibility,transform" }); - }); - }; - - const applyFeatureLayout = () => { - clearFeatureTrigger(); - lastIndex = -1; - - if (!featuresSectionRef.current) return; - - if (compactFeaturesQuery.matches) { - resetFeatureCards(); - ScrollTrigger.refresh(); - return; - } - - // One viewport of scroll per *transition* between panels. With - // NUM_FEATURES panels there are NUM_FEATURES - 1 transitions, so the pin - // lasts that many viewports and progress maps linearly: panel i rests at - // progress i / lastFeatureIndex. Every panel gets an identical dwell. - // The old `* NUM_FEATURES + 0.35` mapping gave panel 1 only ~13% of the - // scroll and the last panel ~27% — a rushed first card, a draggy last. - const lastFeatureIndex = NUM_FEATURES - 1; - const pinConfig = { - trigger: featuresSectionRef.current, - start: "top top", - end: () => `+=${lastFeatureIndex * window.innerHeight}`, - pin: true, - // Let ScrollTrigger own the pin spacer instead of the old - // `pinSpacing: false` + manual marginBottom hack — that mismatch is - // what made the section snap/jump at the moment it pinned. - pinSpacing: true, - anticipatePin: 1, - invalidateOnRefresh: true, - } as const; - - if (reduceMotion) { - // Reduced motion: no scroll-scrubbed motion — instant, discrete swaps - // driven by the CSS `data-active` states. - featureTrigger = ScrollTrigger.create({ - ...pinConfig, - onUpdate: (self) => { - const index = Math.round(self.progress * lastFeatureIndex); - activateFeature(index); - }, - }); - activateFeature(0); - ScrollTrigger.refresh(); - return; - } - - // Motion on: drive the right-hand panel crossfade from a scroll-scrubbed - // timeline so the fade tracks scroll position 1:1 and stays smooth at any - // scroll speed — instead of firing a fixed-duration CSS transition on each - // discrete index flip (which can't keep up with a normal/fast scroll and - // reads as a stutter). GSAP owns opacity/visibility/transform here, so we - // disable the CSS transition that would otherwise lag the scrub. - // Keep index alignment with FEATURES_DATA (activateFeature indexes the - // same ref array), so no filtering — just guard nulls. - const panels = rightPanelsRef.current; - panels.forEach((panel, i) => { - if (!panel) return; - panel.style.transition = "none"; - gsap.set(panel, { - autoAlpha: i === 0 ? 1 : 0, - y: i === 0 ? 0 : 18, - scale: i === 0 ? 1 : 0.985, - }); - }); - - featureTimeline = gsap.timeline({ - // ease:"none" keeps the fade linear against scroll; `scrub` adds the - // brief catch-up that makes fast flings feel buttery instead of abrupt. - defaults: { ease: "none" }, - scrollTrigger: { - ...pinConfig, - scrub: 0.5, - onUpdate: (self) => { - const index = Math.round(self.progress * lastFeatureIndex); - activateFeature(index); - }, - }, - }); - - // Each segment [i, i+1] crossfades panel i out and panel i+1 in over one - // viewport of scroll. At every integer point exactly one panel rests fully - // visible, so the dwell per feature is identical. - for (let i = 0; i < lastFeatureIndex; i++) { - const outgoing = panels[i]; - const incoming = panels[i + 1]; - if (!outgoing || !incoming) continue; - featureTimeline - .to(outgoing, { autoAlpha: 0, y: -18, scale: 0.985 }, i) - .to(incoming, { autoAlpha: 1, y: 0, scale: 1 }, i); - } - - featureTrigger = featureTimeline.scrollTrigger ?? null; - - activateFeature(0); - ScrollTrigger.refresh(); - }; - - applyFeatureLayout(); - compactFeaturesQuery.addEventListener("change", applyFeatureLayout); - - // Entrance: the left step cards rise + fade in with a stagger the first - // time the section approaches the viewport, so they never read as static. - // clearProps hands control back to the CSS hover/active transforms. - if (!reduceMotion && featuresSectionRef.current) { - const cards = leftCardsRef.current.filter(Boolean) as HTMLElement[]; - gsap.from(cards, { - autoAlpha: 0, - y: 24, - duration: 0.55, - stagger: 0.08, - ease: "power3.out", - clearProps: "transform,opacity,visibility", - scrollTrigger: { - trigger: featuresSectionRef.current, - start: "top 78%", - once: true, - }, - }); - } - // Setup Process Steps Animation if (processSectionRef.current && processStepsRef.current.length > 0) { if (reduceMotion) { @@ -950,20 +694,6 @@ export default function LandingPage() { ); } } - - return () => { - compactFeaturesQuery.removeEventListener("change", applyFeatureLayout); - clearFeatureTrigger(); - // scrambleText runs on setInterval outside GSAP's control — clear any - // in-flight scrambles so they don't fire on a detached node. - rightPanelsRef.current.forEach((panel) => { - panel?.querySelectorAll("[data-interval-id]").forEach((el) => { - const id = el.getAttribute("data-interval-id"); - if (id) clearInterval(parseInt(id, 10)); - el.removeAttribute("data-interval-id"); - }); - }); - }; }); return () => { @@ -1537,70 +1267,8 @@ export default function LandingPage() { - {/* SECTION 5 - LOGO/FEATURES (STICKY SCROLL) */} -
- -
- 03 / -
- -
-
PRODUCT
-
Everything you need to ship motion.
-
- source locked - motion read - review pulse -
- -
- {FEATURES_DATA.map((ft, i) => ( -
{ leftCardsRef.current[i] = el; }} - data-testid="feature-card" - data-active={i === 0 ? "true" : "false"} - className="motioncode-feature-card" - style={{ "--card-index": i } as React.CSSProperties}> -
- ))} -
-
- -
- {FEATURES_DATA.map((ft, i) => ( -
{ rightPanelsRef.current[i] = el; }} - data-testid="feature-panel" - data-active={i === 0 ? "true" : "false"} - className="motioncode-feature-panel" - style={{ - position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "60px" - }}> -
{ft.num}
-
{ft.title}
-
{ft.desc}
-
- {ft.code.split("\n").map((line, li) => ( - {line || " "} - ))} -
-
- ))} -
- -
+ {/* SECTION 5 - CAPABILITIES (PINNED MOTION-TIMELINE SCRUBBER) */} + {/* SECTION 6 - HOW IT WORKS */}
640ms). Each has a `kind` that selects its signature + visualization. Copy names what the user gets, not how the engine works. */ +type CapabilityKind = "frames" | "curve" | "export" | "score" | "a11y"; + +type Capability = { + id: string; + ms: number; + frame: string; + title: string; + body: string; + kind: CapabilityKind; +}; + +const CAPABILITIES: Capability[] = [ + { + id: "decode", + ms: 0, + frame: "00ms · source", + title: "Decode the source", + body: "Drop an MP4, WebM, GIF, or Lottie file. MotionCode samples frames at the cadence the original played.", + kind: "frames", + }, + { + id: "curve", + ms: 160, + frame: "160ms · motion", + title: "Read the curve", + body: "Sampled frames resolve into keyframes, transform paths, and the exact easing the motion follows.", + kind: "curve", + }, + { + id: "export", + ms: 320, + frame: "320ms · export", + title: "Export to your stack", + body: "Copy CSS, GSAP, or Framer Motion that carries the same numbers, in the syntax you already write.", + kind: "export", + }, + { + id: "score", + ms: 480, + frame: "480ms · render", + title: "Score the render", + body: "Each export is measured for repaints and compositor layers, then scored so you ship transforms, not jank.", + kind: "score", + }, + { + id: "guard", + ms: 640, + frame: "640ms · access", + title: "Keep it accessible", + body: "Reduced-motion fallbacks and WCAG AA contrast checks ride along with every snippet you export.", + kind: "a11y", + }, +]; + +const TICKS = CAPABILITIES.map((c) => c.ms); +const SCORE_TARGET = 94; +// Per-framework coverage shown as bar fills (0-1). Distinct, not uniform. +const EXPORT_ROWS: Array<{ label: string; state: string; fill: number }> = [ + { label: "CSS", state: "ready", fill: 0.96 }, + { label: "GSAP", state: "spring", fill: 0.82 }, + { label: "Framer", state: "mapped", fill: 1 }, +]; +const FRAME_LABELS = ["00", "04", "08", "12", "16", "20", "24"]; +// Cubic-bezier easing trace; node coords lie on the path so the static (no-JS) +// render already shows nodes sitting on a fully drawn curve. +const CURVE_PATH = "M3,55 C 38,55 34,7 97,7"; +const CURVE_NODES = [ + { x: 3, y: 55 }, + { x: 28.7, y: 42.8 }, + { x: 51.8, y: 19.9 }, + { x: 97, y: 7 }, +]; + +const GAUGE_R = 40; +const GAUGE_CIRC = 2 * Math.PI * GAUGE_R; + +const pad = (n: number) => String(n).padStart(2, "0"); + +function Signature({ kind }: { kind: CapabilityKind }) { + if (kind === "frames") { + return ( +
+ +
+ 7 frames sampled · cadence locked +
+
+ ); + } + + if (kind === "curve") { + return ( +
+
+ +
+
+ ease + cubic-bezier(.34, 1.2, .64, 1) +
+
+ ); + } + + if (kind === "export") { + return ( +
+
+ {EXPORT_ROWS.map((row) => ( +
+ {row.label} + + + + {row.state} +
+ ))} +
+
+ ); + } + + if (kind === "score") { + return ( +
+
+
+ +
+ {SCORE_TARGET} +
+
+
+ + render score / 100 + + 0 dropped frames + compositor-only +
+
+
+ ); + } + + // a11y + return ( +
+
+
+ prefers-reduced-motion +
+
+
+
+
+
+
+ ); +} + +export default function CapabilityTimeline() { + const sectionRef = useRef(null); + const viewportRef = useRef(null); + const trackRef = useRef(null); + const cardsRef = useRef<(HTMLElement | null)[]>([]); + const ticksRef = useRef<(HTMLElement | null)[]>([]); + const dotsRef = useRef<(HTMLElement | null)[]>([]); + const frameReadoutRef = useRef(null); + const stepReadoutRef = useRef(null); + + useEffect(() => { + const section = sectionRef.current; + const viewport = viewportRef.current; + const track = trackRef.current; + if (!section || !viewport || !track) return; + + const n = CAPABILITIES.length; + const lastFeatureIndex = n - 1; + + const mm = gsap.matchMedia(); + + mm.add( + { + isDesktop: "(min-width: 768px)", + reduce: "(prefers-reduced-motion: reduce)", + }, + (ctx) => { + const conditions = ctx.conditions as { + isDesktop: boolean; + reduce: boolean; + }; + const cards = cardsRef.current.filter(Boolean) as HTMLElement[]; + + // Static layout (mobile or reduced motion): every card and viz stays in + // its finished, visible state. Nothing to drive. + if (!conditions.isDesktop || conditions.reduce) { + section.dataset.mode = "static"; + cards.forEach((card) => { + card.dataset.active = "true"; + }); + ticksRef.current.forEach((tick) => { + if (tick) tick.dataset.live = "true"; + }); + dotsRef.current.forEach((dot) => { + if (dot) dot.dataset.live = "true"; + }); + return; + } + + section.dataset.mode = "pinned"; + + // Play one card's signature visualization from zero up to its finished + // state. Always a fromTo, so it replays cleanly when scrolling back. + const playSignature = (card: HTMLElement, kind: CapabilityKind) => { + if (kind === "frames") { + gsap.fromTo( + card.querySelectorAll("[data-frame]"), + { autoAlpha: 0, y: 10 }, + { autoAlpha: 1, y: 0, duration: 0.34, stagger: 0.05, ease: "power2.out", overwrite: true }, + ); + } else if (kind === "curve") { + const trace = card.querySelector("[data-trace]"); + if (trace) { + const len = trace.getTotalLength(); + gsap.fromTo( + trace, + { strokeDasharray: len, strokeDashoffset: len }, + { strokeDashoffset: 0, duration: 0.7, ease: "power2.inOut", overwrite: true }, + ); + } + gsap.fromTo( + card.querySelectorAll("[data-node]"), + { scale: 0, transformOrigin: "center" }, + { scale: 1, duration: 0.3, stagger: 0.09, delay: 0.18, ease: "back.out(2)", overwrite: true }, + ); + } else if (kind === "export") { + card.querySelectorAll("[data-fill]").forEach((fill, i) => { + const target = parseFloat(fill.style.getPropertyValue("--mc-fill")) || 1; + gsap.fromTo( + fill, + { scaleX: 0 }, + { scaleX: target, duration: 0.5, delay: i * 0.1, ease: "power2.out", overwrite: true }, + ); + }); + } else if (kind === "score") { + const ring = card.querySelector("[data-gauge-ring]"); + const number = card.querySelector("[data-gauge-number]"); + if (ring) { + gsap.fromTo( + ring, + { strokeDashoffset: GAUGE_CIRC }, + { + strokeDashoffset: GAUGE_CIRC * (1 - SCORE_TARGET / 100), + duration: 0.9, + ease: "power2.out", + overwrite: true, + }, + ); + } + if (number) { + const counter = { value: 0 }; + gsap.to(counter, { + value: SCORE_TARGET, + duration: 0.9, + ease: "power2.out", + overwrite: true, + onUpdate: () => { + number.textContent = String(Math.round(counter.value)); + }, + }); + } + } + // a11y: switch + checks are driven by the CSS [data-active] state. + }; + + let lastIndex = -1; + const setActive = (index: number) => { + if (index === lastIndex) return; + lastIndex = index; + cards.forEach((card, i) => { + card.dataset.active = i === index ? "true" : "false"; + }); + ticksRef.current.forEach((tick, i) => { + if (tick) tick.dataset.live = i === index ? "true" : "false"; + }); + dotsRef.current.forEach((dot, i) => { + if (dot) dot.dataset.live = i === index ? "true" : "false"; + }); + if (stepReadoutRef.current) { + stepReadoutRef.current.textContent = `${pad(index + 1)} / ${pad(n)}`; + } + const active = cards[index]; + if (active) playSignature(active, CAPABILITIES[index].kind); + }; + + // Geometry: place card `index` centered under the read-head. Spacing is + // uniform, so position is linear in the (fractional) active index. + let cardW = 0; + let firstLeft = 0; + let step = 0; + let center = 0; + const measure = () => { + cardW = cards[0]?.offsetWidth ?? 0; + firstLeft = cards[0]?.offsetLeft ?? 0; + step = cards[1] ? cards[1].offsetLeft - cards[0].offsetLeft : 0; + center = viewport.clientWidth / 2; + }; + const place = (progress: number) => { + const activeFloat = progress * lastFeatureIndex; + const x = center - (firstLeft + activeFloat * step + cardW / 2); + gsap.set(track, { x }); + }; + + measure(); + place(0); + + const proxy = { progress: 0 }; + const tween = gsap.to(proxy, { + progress: 1, + ease: "none", + scrollTrigger: { + trigger: section, + start: "top top", + end: () => `+=${lastFeatureIndex * window.innerHeight}`, + pin: true, + pinSpacing: true, + anticipatePin: 1, + scrub: 0.5, + invalidateOnRefresh: true, + onRefresh: () => { + measure(); + place(proxy.progress); + }, + }, + onUpdate: () => { + const p = proxy.progress; + place(p); + section.style.setProperty("--mc-progress", `${(p * 100).toFixed(2)}%`); + if (frameReadoutRef.current) { + frameReadoutRef.current.textContent = `${Math.round(p * 640)}ms`; + } + setActive(Math.round(p * lastFeatureIndex)); + }, + }); + + setActive(0); + ScrollTrigger.refresh(); + + return () => { + tween.scrollTrigger?.kill(); + tween.kill(); + gsap.set(track, { clearProps: "transform" }); + }; + }, + section, + ); + + return () => mm.revert(); + }, []); + + return ( +
+
+
+
+ One analysis +

+ Everything one clip becomes, start to ship. +

+
+ +
+ + + +
+ + + +
+
+ ); +} diff --git a/components/react-bits/DecryptedText/DecryptedText.tsx b/components/react-bits/DecryptedText/DecryptedText.tsx new file mode 100644 index 0000000..df9c2b1 --- /dev/null +++ b/components/react-bits/DecryptedText/DecryptedText.tsx @@ -0,0 +1,413 @@ +"use client"; + +/* eslint-disable react-hooks/set-state-in-effect -- Vendored React Bits + component kept verbatim; its mount effects seed display state from props. + Rewriting that pattern would fork the upstream source we intentionally mirror. */ + +// Vendored from React Bits (DecryptedText, TS+CSS variant). +// Only change from upstream: import 'motion/react' -> 'framer-motion' +// (this project already depends on framer-motion v12), plus the +// "use client" directive above for the Next.js App Router. +import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import type { HTMLMotionProps } from 'framer-motion'; + +const styles = { + wrapper: { + display: 'inline-block', + whiteSpace: 'pre-wrap' + }, + srOnly: { + position: 'absolute' as const, + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0,0,0,0)', + border: 0 + } +}; + +interface DecryptedTextProps extends HTMLMotionProps<'span'> { + text: string; + speed?: number; + maxIterations?: number; + sequential?: boolean; + revealDirection?: 'start' | 'end' | 'center'; + useOriginalCharsOnly?: boolean; + characters?: string; + className?: string; + parentClassName?: string; + encryptedClassName?: string; + animateOn?: 'view' | 'hover' | 'inViewHover' | 'click'; + clickMode?: 'once' | 'toggle'; +} + +type Direction = 'forward' | 'reverse'; + +export default function DecryptedText({ + text, + speed = 50, + maxIterations = 10, + sequential = false, + revealDirection = 'start', + useOriginalCharsOnly = false, + characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+', + className = '', + parentClassName = '', + encryptedClassName = '', + animateOn = 'hover', + clickMode = 'once', + ...props +}: DecryptedTextProps) { + const [displayText, setDisplayText] = useState(text); + const [isAnimating, setIsAnimating] = useState(false); + const [revealedIndices, setRevealedIndices] = useState>(new Set()); + const [hasAnimated, setHasAnimated] = useState(false); + const [isDecrypted, setIsDecrypted] = useState(animateOn !== 'click'); + const [direction, setDirection] = useState('forward'); + + const containerRef = useRef(null); + const orderRef = useRef([]); + const pointerRef = useRef(0); + const intervalRef = useRef | null>(null); + + const availableChars = useMemo(() => { + return useOriginalCharsOnly + ? Array.from(new Set(text.split(''))).filter(char => char !== ' ') + : characters.split(''); + }, [useOriginalCharsOnly, text, characters]); + + const shuffleText = useCallback( + (originalText: string, currentRevealed: Set) => { + return originalText + .split('') + .map((char, i) => { + if (char === ' ') return ' '; + if (currentRevealed.has(i)) return originalText[i]; + return availableChars[Math.floor(Math.random() * availableChars.length)]; + }) + .join(''); + }, + [availableChars] + ); + + const computeOrder = useCallback( + (len: number): number[] => { + const order: number[] = []; + if (len <= 0) return order; + if (revealDirection === 'start') { + for (let i = 0; i < len; i++) order.push(i); + return order; + } + if (revealDirection === 'end') { + for (let i = len - 1; i >= 0; i--) order.push(i); + return order; + } + // center + const middle = Math.floor(len / 2); + let offset = 0; + while (order.length < len) { + if (offset % 2 === 0) { + const idx = middle + offset / 2; + if (idx >= 0 && idx < len) order.push(idx); + } else { + const idx = middle - Math.ceil(offset / 2); + if (idx >= 0 && idx < len) order.push(idx); + } + offset++; + } + return order.slice(0, len); + }, + [revealDirection] + ); + + const fillAllIndices = useCallback((): Set => { + const s = new Set(); + for (let i = 0; i < text.length; i++) s.add(i); + return s; + }, [text]); + + const removeRandomIndices = useCallback((set: Set, count: number): Set => { + const arr = Array.from(set); + for (let i = 0; i < count && arr.length > 0; i++) { + const idx = Math.floor(Math.random() * arr.length); + arr.splice(idx, 1); + } + return new Set(arr); + }, []); + + const encryptInstantly = useCallback(() => { + const emptySet = new Set(); + setRevealedIndices(emptySet); + setDisplayText(shuffleText(text, emptySet)); + setIsDecrypted(false); + }, [text, shuffleText]); + + const triggerDecrypt = useCallback(() => { + if (sequential) { + orderRef.current = computeOrder(text.length); + pointerRef.current = 0; + setRevealedIndices(new Set()); + } else { + setRevealedIndices(new Set()); + } + setDirection('forward'); + setIsAnimating(true); + }, [sequential, computeOrder, text.length]); + + const triggerReverse = useCallback(() => { + if (sequential) { + // compute forward order then reverse it: we'll remove indices in that order + orderRef.current = computeOrder(text.length).slice().reverse(); + pointerRef.current = 0; + setRevealedIndices(fillAllIndices()); // start fully revealed + setDisplayText(shuffleText(text, fillAllIndices())); + } else { + // non-seq: start from fully revealed as well + setRevealedIndices(fillAllIndices()); + setDisplayText(shuffleText(text, fillAllIndices())); + } + setDirection('reverse'); + setIsAnimating(true); + }, [sequential, computeOrder, fillAllIndices, shuffleText, text]); + + useEffect(() => { + if (!isAnimating) return; + + let currentIteration = 0; + + const getNextIndex = (revealedSet: Set): number => { + const textLength = text.length; + switch (revealDirection) { + case 'start': + return revealedSet.size; + case 'end': + return textLength - 1 - revealedSet.size; + case 'center': { + const middle = Math.floor(textLength / 2); + const offset = Math.floor(revealedSet.size / 2); + const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1; + + if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) { + return nextIndex; + } + + for (let i = 0; i < textLength; i++) { + if (!revealedSet.has(i)) return i; + } + return 0; + } + default: + return revealedSet.size; + } + }; + + intervalRef.current = setInterval(() => { + setRevealedIndices(prevRevealed => { + if (sequential) { + // Forward + if (direction === 'forward') { + if (prevRevealed.size < text.length) { + const nextIndex = getNextIndex(prevRevealed); + const newRevealed = new Set(prevRevealed); + newRevealed.add(nextIndex); + setDisplayText(shuffleText(text, newRevealed)); + return newRevealed; + } else { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setIsDecrypted(true); + return prevRevealed; + } + } + // Reverse + if (direction === 'reverse') { + if (pointerRef.current < orderRef.current.length) { + const idxToRemove = orderRef.current[pointerRef.current++]; + const newRevealed = new Set(prevRevealed); + newRevealed.delete(idxToRemove); + setDisplayText(shuffleText(text, newRevealed)); + if (newRevealed.size === 0) { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setIsDecrypted(false); + } + return newRevealed; + } else { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setIsDecrypted(false); + return prevRevealed; + } + } + } else { + // Non-Sequential + if (direction === 'forward') { + setDisplayText(shuffleText(text, prevRevealed)); + currentIteration++; + if (currentIteration >= maxIterations) { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setDisplayText(text); + setIsDecrypted(true); + } + return prevRevealed; + } + + // Non-Sequential Reverse + if (direction === 'reverse') { + let currentSet = prevRevealed; + if (currentSet.size === 0) { + currentSet = fillAllIndices(); + } + const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations))); + const nextSet = removeRandomIndices(currentSet, removeCount); + setDisplayText(shuffleText(text, nextSet)); + currentIteration++; + if (nextSet.size === 0 || currentIteration >= maxIterations) { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setIsDecrypted(false); + // ensure final scrambled state + setDisplayText(shuffleText(text, new Set())); + return new Set(); + } + return nextSet; + } + } + return prevRevealed; + }); + }, speed); + + return () => clearInterval(intervalRef.current ?? undefined); + }, [ + isAnimating, + text, + speed, + maxIterations, + sequential, + revealDirection, + shuffleText, + direction, + fillAllIndices, + removeRandomIndices, + characters, + useOriginalCharsOnly + ]); + + /* Click Behaviour */ + const handleClick = () => { + if (animateOn !== 'click') return; + + if (clickMode === 'once') { + if (isDecrypted) return; + setDirection('forward'); + triggerDecrypt(); + } + + if (clickMode === 'toggle') { + if (isDecrypted) { + triggerReverse(); + } else { + setDirection('forward'); + triggerDecrypt(); + } + } + }; + + /* Hover Behaviour */ + const triggerHoverDecrypt = useCallback(() => { + if (isAnimating) return; + + setRevealedIndices(new Set()); + setIsDecrypted(false); + setDisplayText(text); + setDirection('forward'); + setIsAnimating(true); + }, [isAnimating, text]); + + const resetToPlainText = useCallback(() => { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setRevealedIndices(new Set()); + setDisplayText(text); + setIsDecrypted(true); + setDirection('forward'); + }, [text]); + + /* View Observer */ + useEffect(() => { + if (animateOn !== 'view' && animateOn !== 'inViewHover') return; + + const observerCallback = (entries: IntersectionObserverEntry[]) => { + entries.forEach(entry => { + if (entry.isIntersecting && !hasAnimated) { + triggerDecrypt(); + setHasAnimated(true); + } + }); + }; + + const observerOptions = { + root: null, + rootMargin: '0px', + threshold: 0.1 + }; + + const observer = new IntersectionObserver(observerCallback, observerOptions); + const currentRef = containerRef.current; + if (currentRef) { + observer.observe(currentRef); + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef); + } + }; + }, [animateOn, hasAnimated, triggerDecrypt]); + + useEffect(() => { + if (animateOn === 'click') { + encryptInstantly(); + } else { + setDisplayText(text); + setIsDecrypted(true); + } + setRevealedIndices(new Set()); + setDirection('forward'); + }, [animateOn, text, encryptInstantly]); + + const animateProps = + animateOn === 'hover' || animateOn === 'inViewHover' + ? { + onMouseEnter: triggerHoverDecrypt, + onMouseLeave: resetToPlainText + } + : animateOn === 'click' + ? { + onClick: handleClick + } + : {}; + + return ( + + {displayText} + + + + ); +} + diff --git a/components/react-bits/index.ts b/components/react-bits/index.ts index 6404923..014d1ca 100644 --- a/components/react-bits/index.ts +++ b/components/react-bits/index.ts @@ -1,2 +1,3 @@ export { default as Aurora } from './Aurora/Aurora'; export { default as Magnet } from './Magnet/Magnet'; +export { default as DecryptedText } from './DecryptedText/DecryptedText'; diff --git a/tests/e2e/marketing.spec.ts b/tests/e2e/marketing.spec.ts index a3106d7..c4196b0 100644 --- a/tests/e2e/marketing.spec.ts +++ b/tests/e2e/marketing.spec.ts @@ -66,7 +66,7 @@ test.describe("marketing surface", () => { ).toHaveAttribute("href", "/app"); await expect( - page.locator("#features").getByText("Everything you need to ship motion."), + page.locator("#features").getByText("Everything one clip becomes"), ).toBeVisible(); await expect( page @@ -304,28 +304,26 @@ test.describe("marketing surface", () => { expect(overlaps(panelBoxes[1], panelBoxes[2])).toBe(false); }); - test("feature cards stay active, interactive, and readable", async ({ + test("capability timeline renders its cards and live read head", async ({ page, }) => { await page.goto("/"); - const cards = page.getByTestId("feature-card"); + const section = page.locator("#features"); + await expect(section).toBeVisible(); + + const cards = page.getByTestId("capability-card"); await expect(cards).toHaveCount(5); - await expect(cards.first()).toHaveAttribute("data-active", "true"); - await expect(cards.first()).toHaveCSS("border-radius", "10px"); - await expect( - cards.first().locator(".motioncode-feature-card-title"), - ).toHaveCSS("font-weight", "700"); - await cards.nth(1).scrollIntoViewIfNeeded(); - await cards.nth(1).hover(); - const hoverTransform = await cards.nth(1).evaluate( - (node) => getComputedStyle(node).transform, - ); - expect(hoverTransform).not.toBe("none"); + // The card under the read head is the live one on load. + await expect(cards.first()).toHaveAttribute("data-active", "true"); - const snippet = page.getByTestId("feature-code-snippet").first(); - await expect(snippet).toHaveCSS("color", "rgb(255, 251, 244)"); - await expect(snippet).toHaveCSS("animation-name", /terminal/i); + // Titles are bold, readable, and resolve to their real (decoded) text. + const firstTitle = cards.first().locator("h3"); + await expect(firstTitle).toHaveCSS("font-weight", "700"); + await expect(firstTitle).toHaveText("Decode the source"); + await expect( + section.getByText("Keep it accessible"), + ).toBeVisible(); }); });