cmux/web/app/typing.tsx
Lawrence Chen d25f14a79f Add landing page (web/) with Next.js + Tailwind
- Minimal centered layout with Geist sans font
- Typing animation cycling through agent names
- Light/dark mode toggle via next-themes
- Download for Mac button linking to latest DMG
- Feature list, GitHub + Docs footer links
- Meta tags and Open Graph for SEO
- CI: add web typecheck job (tsc --noEmit)
2026-02-09 20:51:27 -08:00

102 lines
2.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
const phrases = [
"coding agents",
"multitasking",
"Claude Code",
"Codex",
"Opencode",
"Gemini",
];
export function TypingTagline() {
const [phraseIndex, setPhraseIndex] = useState(0);
const [charIndex, setCharIndex] = useState(0);
const [deleting, setDeleting] = useState(false);
const [showControls, setShowControls] = useState(false);
const [topOffset, setTopOffset] = useState(0);
const [blink, setBlink] = useState(true);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "." && e.metaKey) {
e.preventDefault();
setShowControls((s) => !s);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
useEffect(() => {
const phrase = phrases[phraseIndex];
if (!deleting && charIndex === phrase.length) {
const timeout = setTimeout(() => setDeleting(true), 2000);
return () => clearTimeout(timeout);
}
if (deleting && charIndex === 0) {
setDeleting(false);
setPhraseIndex((i) => (i + 1) % phrases.length);
return;
}
const speed = deleting ? 30 : 60;
const timeout = setTimeout(() => {
setCharIndex((c) => c + (deleting ? -1 : 1));
}, speed);
return () => clearTimeout(timeout);
}, [charIndex, deleting, phraseIndex]);
const phrase = phrases[phraseIndex];
const displayed = phrase.slice(0, charIndex);
const tailwindClass =
topOffset > 0
? `-top-[${topOffset}px]`
: topOffset < 0
? `top-[${Math.abs(topOffset)}px]`
: "";
return (
<span>
{displayed}
<span
className={`inline-block w-[2px] h-[1.1em] bg-foreground/70 ml-[1px] ${blink ? "animate-blink" : ""}`}
style={{ position: "relative", top: `${-topOffset}px` }}
onDoubleClick={() => setShowControls((s) => !s)}
/>
{showControls && (
<span className="fixed bottom-5 right-5 z-50 flex w-[420px] items-center gap-3 rounded-xl bg-[#222] px-4 py-3 font-mono text-xs text-white shadow-lg">
<label className="flex items-center gap-2">
top:
<input
type="range"
min={-5}
max={5}
step={0.5}
value={topOffset}
onChange={(e) => setTopOffset(parseFloat(e.target.value))}
className="w-24"
/>
<span className="w-12">{topOffset}px</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={blink}
onChange={(e) => setBlink(e.target.checked)}
/>
blink
</label>
<code className="select-all cursor-pointer rounded bg-[#333] px-2 py-0.5">
{tailwindClass || "0px"}
</code>
</span>
)}
</span>
);
}