diff --git a/frontend/app/api/github-stars/route.ts b/frontend/app/api/github-stars/route.ts new file mode 100644 index 0000000..b8ea930 --- /dev/null +++ b/frontend/app/api/github-stars/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +const REPO = "fujacob/cotabby"; +const FALLBACK = 600; + +export const revalidate = 86400; + +export async function GET() { + const token = process.env.GITHUB_TOKEN; + const headers: HeadersInit = { Accept: "application/vnd.github+json" }; + if (token) headers.Authorization = `token ${token}`; + + let stars = FALLBACK; + try { + const res = await fetch(`https://api.github.com/repos/${REPO}`, { + headers, + next: { revalidate: 86400 }, + }); + if (res.ok) { + const data = (await res.json()) as { stargazers_count?: number }; + if (typeof data.stargazers_count === "number") { + stars = data.stargazers_count; + } + } + } catch { + // fall through to fallback + } + + const rounded = Math.max(FALLBACK, Math.floor(stars / 50) * 50); + return NextResponse.json( + { stars: rounded }, + { + headers: { + "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=86400", + }, + }, + ); +} diff --git a/frontend/app/components/alternating-feature-section.tsx b/frontend/app/components/alternating-feature-section.tsx index 720de33..eaaac57 100644 --- a/frontend/app/components/alternating-feature-section.tsx +++ b/frontend/app/components/alternating-feature-section.tsx @@ -4,6 +4,7 @@ import { AnimatePresence, m, useReducedMotion } from "framer-motion"; import Image from "next/image"; import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { FadeIn, WordReveal } from "./motion"; +import { DemoGif } from "./demo-gif"; const VIDEO_ID = "p3TIgxQFQGE"; @@ -126,7 +127,7 @@ function VideoBlock({ className = "", label, start, end }: VideoBlockProps) {
@@ -168,7 +169,7 @@ function SectionHeadline({ return (
-
+
) rendered without the aspect-video box. */ + media?: ReactNode; }; function FeatureRow({ @@ -205,6 +208,7 @@ function FeatureRow({ start, end, visual, + media, }: FeatureRowProps) { const textFromLeft = layout === "text-left"; @@ -232,10 +236,12 @@ function FeatureRow({ transition={{ delay: 0.12 }} className={textFromLeft ? "" : "md:order-1"} > - {visual !== undefined ? ( + {media !== undefined ? ( + media + ) : visual !== undefined ? (
{visual}
@@ -358,7 +364,7 @@ function EmojiAutocompleteVisual() { exit={{ opacity: 0, scale: 0.96, y: -4 }} transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }} style={{ transformOrigin: "top left" }} - className="absolute left-[5.2rem] top-[2.85rem] z-10 w-[13.5rem] overflow-hidden rounded-[0.85rem] border-2 border-line bg-surface-2 shadow-[0_5px_0_var(--line)] sm:left-[6rem] sm:top-[3.15rem] sm:w-[15rem]" + className="absolute left-[5.2rem] top-[2.85rem] z-10 w-[13.5rem] overflow-hidden rounded-[0.85rem] border-2 border-line bg-surface-2 shadow-[0_5px_0_var(--shadow-color)] sm:left-[6rem] sm:top-[3.15rem] sm:w-[15rem]" >
: @@ -435,16 +441,33 @@ export function AlternatingFeatureSection() { icon="/app-icons/slack.webp" iconPad label="slack" - start={33} - end={40} + media={ + + } /> + } /> formatRelative(RELEASE_DATE, new Date())); + const [relative, setRelative] = useState(() => + formatRelative(RELEASE_DATE, new Date()), + ); + const [dismissed, setDismissed] = useState(false); useEffect(() => { const update = () => setRelative(formatRelative(RELEASE_DATE, new Date())); update(); const id = setInterval(update, 60_000); + + // The init script may have already flagged this as dismissed (no-flash); + // mirror that into React state so the node is removed from the tree. + try { + if (localStorage.getItem(BANNER_DISMISS_KEY) === RELEASE.version) { + setDismissed(true); + } + } catch { + // ignore + } return () => clearInterval(id); }, []); + const dismiss = () => { + setDismissed(true); + try { + localStorage.setItem(BANNER_DISMISS_KEY, RELEASE.version); + } catch { + // ignore — still hidden for this session + } + const root = document.documentElement; + root.style.setProperty("--banner-height", "0px"); + root.classList.add("tabby-banner-dismissed"); + }; + + if (dismissed) return null; + return (
- v0.4.2-beta released {relative}. Send feedback at{" "} + {RELEASE.version} released {relative}. Send feedback at{" "} cotabby.app/feedback +
); } diff --git a/frontend/app/components/community-proof-section.tsx b/frontend/app/components/community-proof-section.tsx new file mode 100644 index 0000000..9d78c2d --- /dev/null +++ b/frontend/app/components/community-proof-section.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import { DISCORD_URL, GITHUB_URL } from "../lib/site"; +import { GithubStarLabel } from "./github-star-label"; +import { DiscordIcon, GithubIcon } from "./icons"; +import { FadeIn, ScaleIn, WordReveal } from "./motion"; + +const githubActionClass = + "tabby-button tabby-button-primary inline-flex h-12 items-center justify-center gap-2 rounded-2xl px-6 text-sm font-bold tracking-tight sm:h-13 sm:text-base"; + +const discordActionClass = + "tabby-button tabby-button-secondary inline-flex h-12 items-center justify-center gap-2 rounded-2xl px-6 text-sm font-bold tracking-tight sm:h-13 sm:text-base"; + +function StatTile({ + value, + label, +}: { + value: React.ReactNode; + label: string; +}) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} + +export function CommunityProofSection() { + return ( +
+
+
+ + +

+ No pitch decks or fake five-star quotes — just a public repo you can + read end to end, a license that keeps it free, and everything + running on your own machine. +

+
+
+ +
+ + } label="GitHub stars" /> + + + + + + + +
+ + +
+ + + Star on GitHub + + + + Join the Discord + +
+
+
+
+ ); +} diff --git a/frontend/app/components/comparison-section.tsx b/frontend/app/components/comparison-section.tsx new file mode 100644 index 0000000..3936da9 --- /dev/null +++ b/frontend/app/components/comparison-section.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { Check, X } from "lucide-react"; +import Image from "next/image"; +import { FadeIn, ScaleIn, WordReveal } from "./motion"; + +const COTABBY = [ + "Runs entirely on your Mac", + "Free & open source (AGPL-3.0)", + "No account, ever", + "Works fully offline", + "Your text never leaves the device", +] as const; + +const CLOUD = [ + "Keystrokes sent to a server", + "Monthly subscription", + "Account & sign-in required", + "Needs a connection", + "Telemetry & analytics", +] as const; + +const STRIKE_STYLE = { + textDecorationColor: "rgba(225, 29, 72, 0.8)", + textDecorationSkipInk: "none" as const, +}; + +export function ComparisonSection() { + return ( +
+
+ + +

+ Most AI writing tools quietly ship your keystrokes off to a server. + Cotabby does the opposite — here's the trade you're making. +

+
+
+ +
+ {/* Cotabby */} + +
+
+ + + Cotabby + +
+
    + {COTABBY.map((item) => ( +
  • + + + + + {item} + +
  • + ))} +
+
+
+ + {/* Cloud autocomplete */} + +
+
+ + + + + Cloud autocomplete + +
+
    + {CLOUD.map((item) => ( +
  • + + + + + {item} + +
  • + ))} +
+
+
+
+
+ ); +} diff --git a/frontend/app/components/customization-cards-section.tsx b/frontend/app/components/customization-cards-section.tsx index 9ad3720..d4184ae 100644 --- a/frontend/app/components/customization-cards-section.tsx +++ b/frontend/app/components/customization-cards-section.tsx @@ -3,14 +3,8 @@ import { useEffect, useState, type ReactNode } from "react"; import { m } from "framer-motion"; import { Cpu, SlidersHorizontal, Sparkles, type LucideIcon } from "lucide-react"; -import { - FadeIn, - HoverLift, - ScaleIn, - Stagger, - StaggerItem, - WordReveal, -} from "./motion"; +import { FadeIn, ScaleIn, Stagger, StaggerItem, WordReveal } from "./motion"; +import { TiltCard } from "./tilt-card"; type CustomItemProps = { icon: LucideIcon; @@ -21,10 +15,10 @@ type CustomItemProps = { function CustomItem({ icon: Icon, title, description, preview }: CustomItemProps) { return ( - +
-
+

@@ -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 + {alt} 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)]" > +
diff --git a/frontend/app/components/hero-typing-demo.tsx b/frontend/app/components/hero-typing-demo.tsx new file mode 100644 index 0000000..6364ac8 --- /dev/null +++ b/frontend/app/components/hero-typing-demo.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { m, useReducedMotion } from "framer-motion"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; + +const EASE = [0.22, 1, 0.36, 1] as const; +const GHOST_COLOR = "rgba(255, 130, 115, 0.62)"; // accent @ 62% + +type Scene = { + app: string; + icon: string; + iconPad?: boolean; + prefix: string; + suggestion: string; +}; + +// Real-feeling, concise continuations (the product's default 7–12 word range), +// each in a different everyday app to echo "works anywhere you type". +const SCENES: Scene[] = [ + { + app: "Gmail", + icon: "/app-icons/gmail.svg", + prefix: "Hey Sam — thanks for the notes.", + suggestion: " I'll fold them in and resend by end of day.", + }, + { + app: "Slack", + icon: "/app-icons/slack.webp", + iconPad: true, + prefix: "Fix is deployed,", + suggestion: " I'll keep an eye on the dashboards tonight.", + }, + { + app: "Notes", + icon: "/app-icons/apple-notes.svg", + prefix: "Weekend list:", + suggestion: " oat milk, matcha, and more cat treats.", + }, + { + app: "iMessage", + icon: "/app-icons/imessage.svg", + prefix: "running a few minutes late —", + suggestion: " grab us a table and I'll be right over.", + }, +]; + +type Phase = "typing" | "ghost" | "accepting" | "accepted"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const TRAFFIC = ["#ff5f57", "#febc2e", "#28c840"] as const; + +export function HeroTypingDemo({ className = "" }: { className?: string }) { + const prefersReducedMotion = useReducedMotion() ?? false; + const [sceneIndex, setSceneIndex] = useState(0); + const [typed, setTyped] = useState(0); + const [phase, setPhase] = useState("typing"); + const [inView, setInView] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + const node = rootRef.current; + if (!node) return; + const io = new IntersectionObserver( + ([entry]) => setInView(entry.isIntersecting), + { threshold: 0.4 }, + ); + io.observe(node); + return () => io.disconnect(); + }, []); + + useEffect(() => { + if (prefersReducedMotion || !inView) return; + let cancelled = false; + + async function run() { + // small loop guard so we always resume from a clean state + while (!cancelled) { + for (let s = 0; s < SCENES.length; s++) { + if (cancelled) return; + const scene = SCENES[s]; + setSceneIndex(s); + setPhase("typing"); + setTyped(0); + await sleep(320); + + for (let i = 1; i <= scene.prefix.length; i++) { + if (cancelled) return; + setTyped(i); + await sleep(26); + } + await sleep(360); + if (cancelled) return; + + setPhase("ghost"); + await sleep(1000); + if (cancelled) return; + + setPhase("accepting"); + await sleep(240); + if (cancelled) return; + + setPhase("accepted"); + await sleep(1600); + } + } + } + + void run(); + return () => { + cancelled = true; + }; + }, [prefersReducedMotion, inView]); + + const scene = SCENES[sceneIndex]; + const staticMode = prefersReducedMotion; + const showPrefix = staticMode ? scene.prefix : scene.prefix.slice(0, typed); + const showGhost = + staticMode || phase === "ghost" || phase === "accepting" || phase === "accepted"; + const accepted = staticMode || phase === "accepted"; + const pressing = phase === "accepting"; + + return ( +
+ {/* window chrome */} +
+ +
+ + + + + {scene.app} + +
+
+ + {/* text field */} +
+

+ {showPrefix} + {showGhost && ( + + {scene.suggestion} + + )} + {!staticMode && !accepted && ( + + )} +

+ +
+ + {accepted ? "accepted" : showGhost ? "suggestion" : "typing…"} + + + press + + Tab + + to accept + +
+
+
+ ); +} diff --git a/frontend/app/components/hero.tsx b/frontend/app/components/hero.tsx index a429321..83ba583 100644 --- a/frontend/app/components/hero.tsx +++ b/frontend/app/components/hero.tsx @@ -12,6 +12,7 @@ import { GITHUB_URL } from "../lib/site"; import { DownloadButton } from "./download-button"; import { GhostAcceptText } from "./ghost-accept-text"; import { GithubStarLabel } from "./github-star-label"; +import { HeroTypingDemo } from "./hero-typing-demo"; import { AppleIcon, GithubIcon } from "./icons"; import { TextAnimate } from "./text"; @@ -106,6 +107,9 @@ export function Hero() { delay={0.1} startOnView={false} once + // h1 already carries the full aria-label; skip the + // duplicate sr-only copy each rotation would otherwise add. + accessible={false} className="inline text-[3.15rem] sm:text-[4.8rem] lg:text-[6.2rem]" segmentClassName="will-change-transform" > @@ -148,6 +152,13 @@ export function Hero() { + + + + Support development & buy us a coffee = { idle: 500, dragging: 1100, @@ -47,38 +56,11 @@ const INSTALL_PHASE_DURATION_MS: Record = { reset: 350, }; -const INSTALL_NEXT_PHASE: Record = { - idle: "dragging", - dragging: "dropped", - dropped: "hidden", - hidden: "reset", - reset: "idle", -}; - function InstallVisual() { - const prefersReducedMotion = useReducedMotion() ?? false; - const containerRef = useRef(null); - const [isInView, setIsInView] = useState(false); - const [phase, setPhase] = useState("idle"); - - useEffect(() => { - const node = containerRef.current; - if (!node) return; - const observer = new IntersectionObserver( - ([entry]) => setIsInView(entry.isIntersecting), - { threshold: 0.45 }, - ); - observer.observe(node); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (prefersReducedMotion || !isInView) return; - const id = setTimeout(() => { - setPhase((p) => INSTALL_NEXT_PHASE[p]); - }, INSTALL_PHASE_DURATION_MS[phase]); - return () => clearTimeout(id); - }, [phase, isInView, prefersReducedMotion]); + const { phase, prefersReducedMotion, containerRef } = useSequencedPhases( + INSTALL_ORDER, + INSTALL_PHASE_DURATION_MS, + ); const atDestination = phase === "dragging" || phase === "dropped" || phase === "hidden"; const shrunk = phase === "dropped" || phase === "hidden"; @@ -109,7 +91,7 @@ function InstallVisual() { duration: INSTALL_PHASE_DURATION_MS[phase] / 1000, ease: phase === "dragging" ? EASE : "easeOut", }} - className="absolute top-1/2 z-10 h-9 w-9 -translate-y-1/2 overflow-hidden rounded-[0.55rem] border-2 border-line bg-surface shadow-[0_2px_0_var(--line)]" + className="absolute top-1/2 z-10 h-9 w-9 -translate-y-1/2 overflow-hidden rounded-[0.55rem] border-2 border-line bg-surface shadow-[0_2px_0_var(--shadow-color)]" > -
+
@@ -176,7 +158,7 @@ function TypeAnywhereVisual() { }} role="img" aria-label={app.name} - className="relative h-11 w-11 overflow-hidden rounded-[0.7rem] border-2 border-line bg-surface-2 shadow-[0_3.4px_0_var(--line)]" + className="relative h-11 w-11 overflow-hidden rounded-[0.7rem] border-2 border-line bg-surface-2 shadow-[0_3.4px_0_var(--shadow-color)]" > = { idle: 450, "ghost-1": 600, @@ -237,18 +231,6 @@ const TAB_PHASE_DURATION_MS: Record = { reset: 450, }; -const TAB_NEXT_PHASE: Record = { - idle: "ghost-1", - "ghost-1": "tap-1", - "tap-1": "ghost-2", - "ghost-2": "tap-2", - "tap-2": "ghost-3", - "ghost-3": "tap-3", - "tap-3": "done", - done: "reset", - reset: "idle", -}; - // For each fragment index i (0-2): visible from ghost-(i+1) onward; turns ink during tap-(i+1). const TAB_GHOST_PHASES: Record = { 0: ["ghost-1", "tap-1", "ghost-2", "tap-2", "ghost-3", "tap-3", "done"], @@ -263,29 +245,10 @@ const TAB_INK_PHASES: Record = { }; function TabVisual() { - const prefersReducedMotion = useReducedMotion() ?? false; - const containerRef = useRef(null); - const [isInView, setIsInView] = useState(false); - const [phase, setPhase] = useState("idle"); - - useEffect(() => { - const node = containerRef.current; - if (!node) return; - const observer = new IntersectionObserver( - ([entry]) => setIsInView(entry.isIntersecting), - { threshold: 0.45 }, - ); - observer.observe(node); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (prefersReducedMotion || !isInView) return; - const id = setTimeout(() => { - setPhase((p) => TAB_NEXT_PHASE[p]); - }, TAB_PHASE_DURATION_MS[phase]); - return () => clearTimeout(id); - }, [phase, isInView, prefersReducedMotion]); + const { phase, prefersReducedMotion, containerRef } = useSequencedPhases( + TAB_ORDER, + TAB_PHASE_DURATION_MS, + ); const isPressing = phase === "tap-1" || phase === "tap-2" || phase === "tap-3"; @@ -325,7 +288,7 @@ function TabVisual() { ? { duration: 0.32, ease: "easeOut", times: [0, 0.4, 1] } : { duration: 0.18, ease: "easeOut" } } - style={{ boxShadow: "0 3px 0 var(--line)" }} + style={{ boxShadow: "0 3px 0 var(--shadow-color)" }} className="inline-flex h-11 min-w-14 items-center justify-center rounded-[0.65rem] border-2 border-line bg-background px-3 text-base font-bold text-ink" > Tab diff --git a/frontend/app/components/legal-header.tsx b/frontend/app/components/legal-header.tsx index ca7a163..44a225e 100644 --- a/frontend/app/components/legal-header.tsx +++ b/frontend/app/components/legal-header.tsx @@ -42,7 +42,7 @@ export function LegalHeader({ current }: LegalHeaderProps) { width={56} height={56} sizes="56px" - className="h-14 w-14 rounded-2xl border-2 border-line bg-surface-2 shadow-[0_6.7px_0_var(--line)]" + className="h-14 w-14 rounded-2xl border-2 border-line bg-surface-2 shadow-[0_6.7px_0_var(--shadow-color)]" /> diff --git a/frontend/app/components/menu-bar-section.tsx b/frontend/app/components/menu-bar-section.tsx new file mode 100644 index 0000000..b0d918c --- /dev/null +++ b/frontend/app/components/menu-bar-section.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { + BatteryFull, + Pause, + Power, + Settings, + Wifi, +} from "lucide-react"; +import Image from "next/image"; +import { FadeIn, ScaleIn, WordReveal } from "./motion"; + +const HIGHLIGHTS = [ + "No dock icon, no extra window — just a quiet menu-bar tab.", + "Pause it for a meeting, flip it back on with one click.", + "Pick your engine and model without leaving the bar.", +] as const; + +function MenuRow({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +function MenuItem({ + icon: Icon, + label, + hint, + danger = false, +}: { + icon: typeof Pause; + label: string; + hint?: string; + danger?: boolean; +}) { + return ( +
+ + {label} + {hint && ( + {hint} + )} +
+ ); +} + +function MenuBarMockup() { + return ( +
+ {/* faux menu bar */} +
+
+ + {/* dropdown */} +
+
+ + + Cotabby + + + + On + +
+ +
+ + + +
+ +
+ + + +
+
+
+ ); +} + +export function MenuBarSection() { + return ( +
+
+
+ + +

+ Cotabby isn't another app to manage. It tucks into the menu + bar and stays out of the way until you start typing. +

+
+
    + {HIGHLIGHTS.map((item, i) => ( + +
  • + + {item} +
  • +
    + ))} +
+
+ + + + +
+
+ ); +} diff --git a/frontend/app/components/motion.tsx b/frontend/app/components/motion.tsx index 5b9d567..4b76e33 100644 --- a/frontend/app/components/motion.tsx +++ b/frontend/app/components/motion.tsx @@ -2,6 +2,7 @@ import { m, + useReducedMotion, useScroll, useSpring, useTransform, @@ -149,11 +150,13 @@ export function ParallaxY({ ...rest }: ParallaxYProps) { const localRef = useRef(null); + const prefersReducedMotion = useReducedMotion() ?? false; const { scrollYProgress } = useScroll({ target: targetRef ?? localRef, offset: ["start end", "end start"], }); - const y = useTransform(scrollYProgress, [0, 1], [strength, -strength]); + const range = prefersReducedMotion ? 0 : strength; + const y = useTransform(scrollYProgress, [0, 1], [range, -range]); const smooth = useSpring(y, { stiffness: 120, damping: 24, mass: 0.4 }); return ( @@ -333,9 +336,16 @@ export function ScrollProgressBar({ className }: ScrollProgressBarProps) {