diff --git a/website/index.html b/website/index.html index 636b250..fca90f9 100644 --- a/website/index.html +++ b/website/index.html @@ -3,8 +3,32 @@ - MouseTerm — Mouse-friendly. Multitasking. Terminal. + + MouseTerm — The multitasking terminal for mice + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/public/apple-touch-icon.png b/website/public/apple-touch-icon.png new file mode 100644 index 0000000..7bbde37 Binary files /dev/null and b/website/public/apple-touch-icon.png differ diff --git a/website/public/og-image.jpg b/website/public/og-image.jpg new file mode 100644 index 0000000..af09864 Binary files /dev/null and b/website/public/og-image.jpg differ diff --git a/website/src/assets/video-climb-blink-and-stare-end.webp b/website/src/assets/video-climb-blink-and-stare-end.webp new file mode 100644 index 0000000..b1a642a Binary files /dev/null and b/website/src/assets/video-climb-blink-and-stare-end.webp differ diff --git a/website/src/index.css b/website/src/index.css index 72d6989..15eb968 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -3,7 +3,7 @@ @theme { --font-display: "Ubuntu Sans Mono", ui-monospace, monospace; --font-body: "Ubuntu Mono", ui-monospace, monospace; - --color-bg: oklch(10% 0.01 60); + --color-bg: #000000; --color-surface: oklch(18% 0.015 60); --color-text: #dedede; --color-caramel: #b47624; diff --git a/website/src/pages/Dependencies.tsx b/website/src/pages/Dependencies.tsx index 1a69227..3814cbf 100644 --- a/website/src/pages/Dependencies.tsx +++ b/website/src/pages/Dependencies.tsx @@ -11,10 +11,12 @@ export function Component() {

Dependencies

-

- MouseTerm is built on {deps.length} open-source packages. Thank you to every author and contributor. +

+ MouseTerm (standalone app and VS Code plugin) has {deps.length} transitive dependencies. Thank you to every author and contributor. +

+

+ Thanks also to ascii-splash and react-router and their transitive dependencies, which we use for this marketing page but are not part of the end-user application.

- diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx index 1dad4d0..f21c882 100644 --- a/website/src/pages/Home.tsx +++ b/website/src/pages/Home.tsx @@ -1,6 +1,7 @@ import { AppleLogoIcon, CheckCircleIcon, + CircleNotchIcon, CubeIcon, DesktopIcon, DotsThreeOutlineIcon, @@ -20,11 +21,16 @@ import visualStudioIconUrl from "../assets/visual-studio-icon.svg"; import tinyIconUrl from "../assets/icon-tiny-dark.png"; import phoneMockupUrl from "../assets/phone-mockup.webp"; import standaloneLatest from "@standalone-latest"; +import { prefersReducedMotion } from "mouseterm-lib/lib/ui-geometry"; export { Home as Component }; +/** Multiplier on scroll required to drive the hero animation. + * 1 = baseline, 2 = half as sensitive, 0.5 = twice as sensitive. */ +const HERO_SLOMO_FACTOR = 2; + /** Scroll runway length in viewport heights. Larger = slower reveal. */ -const RUNWAY_VH = 300; +const RUNWAY_VH = 300 * HERO_SLOMO_FACTOR; /** Scroll thresholds within the pinned runway (0–1) */ const ICON_INITIAL_HIDE_FRAC = 0.67; // Fraction of icon's rendered height hidden at load — leaves top third visible @@ -38,6 +44,13 @@ const HEADER_REVEAL_LEAD = 0.04; const UNPIN_THRESHOLD = 0.8; const HERO_VIDEO_FPS = 120; +/** Critically-damped smoothing for the scroll value driving the hero animation. + * Half-life is the time for the displayed value to close half the gap to the + * scroll target — short enough to feel responsive, long enough to absorb the + * discrete jumps from clicky mouse wheels (Windows especially). */ +const HERO_SCROLL_HALFLIFE_S = 0.06; +const HERO_SCROLL_SETTLE_PX = 0.5; + /** Vertical padding applied to all content sections after the hero. */ const SECTION_PY = "py-8"; @@ -252,73 +265,63 @@ const EMAIL_REGEX = function NotifySignupForm() { const [email, setEmail] = useState(""); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); const [message, setMessage] = useState(""); + const [redirecting, setRedirecting] = useState(false); - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - if (loading) return; + const redirectUrl = `https://nedshed.dev/subscribe?email=${encodeURIComponent(email)}`; + function handleSubmit(e: FormEvent) { + e.preventDefault(); if (!EMAIL_REGEX.test(email)) { setMessage("Please enter a valid email"); return; } - - setLoading(true); - setMessage(""); - - try { - const response = await fetch("https://substackapi.com/api/subscribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email, - domain: "https://nedshed.dev/", - }), - }); - const data = await response.json(); - - if (data.errors) { - setMessage(data.errors[0].msg); - } else if (data.requires_confirmation) { - setSuccess(true); - } - } catch { - setMessage("Something went wrong. Please try again."); - } finally { - setLoading(false); - } + setRedirecting(true); + window.setTimeout(() => { + window.location.href = redirectUrl; + }, 3000); } - if (success) { + if (redirecting) { return ( -

- Thanks — check your email to confirm your subscription. -

+
+ +

+ Just one more click! Hit subscribe after{" "} + + the redirect + + ... +

+
); } return ( -
+ +
setEmail(e.target.value)} placeholder="you@example.com" required - disabled={loading} - aria-label="Email address" - className="min-h-12 w-full rounded-md border border-[var(--color-text)]/20 bg-[var(--color-bg)] px-4 py-3 text-base text-[var(--color-text)] placeholder:opacity-40 focus:border-[var(--color-caramel)] focus:outline-none disabled:opacity-50 sm:flex-1" + autoComplete="email" + className="min-h-12 w-full rounded-md border border-[var(--color-text)]/50 bg-[var(--color-bg)] px-4 py-3 text-base text-[var(--color-text)]/70 placeholder:opacity-50 focus:border-[var(--color-caramel)] focus:outline-none sm:flex-1" />
{message && ( @@ -342,6 +345,7 @@ function Home() { const headerRef = useRef(null); const headerBrandRef = useRef(null); const hookRef = useRef(null); + const contentRef = useRef(null); const [installGuide, setInstallGuide] = useState(null); const [heroVideoSrc, setHeroVideoSrc] = useState(); const [heroPosterReady, setHeroPosterReady] = useState(false); @@ -415,8 +419,8 @@ function Home() { }; const wordRefs = [word0Ref, word1Ref, word2Ref]; - let ticking = false; - let frameId = 0; + let smoothRafId = 0; + let lastSmoothTimestamp = 0; let handoffAnimationFrameId = 0; let handoffTimeoutId = 0; let videoFrameCallbackId = 0; @@ -427,6 +431,15 @@ function Home() { let disposed = false; let lastSeekFrame = -1; + // Mobile has native momentum scrolling; layering ours on top fights it + // (especially on iOS Safari where transforms during scroll are sensitive). + const isTouchDevice = typeof window.matchMedia === "function" + && window.matchMedia("(pointer: coarse)").matches; + const skipSmoothing = prefersReducedMotion() || isTouchDevice; + + const readRunwayScroll = () => -runway.getBoundingClientRect().top; + let smoothScroll = readRunwayScroll(); + function setPosterVisible(visible: boolean) { if (posterIsVisible === visible) return; posterIsVisible = visible; @@ -492,12 +505,9 @@ function Home() { }); } - function syncScrollState() { + function syncScrollState(runwayScroll: number, scrollLag: number) { if (disposed) return; - // How far through the scroll runway (0–1, clamped for animations) - const rect = runway.getBoundingClientRect(); - const runwayScroll = -rect.top; const runwayHeight = runway.offsetHeight - window.innerHeight; const fraction = runwayHeight > 0 ? clamp01(runwayScroll / runwayHeight) @@ -511,7 +521,9 @@ function Home() { const iconHeight = naturalAspect > containerAspect ? video.offsetWidth / naturalAspect // width-limited : video.offsetHeight; // height-limited - const initialOffset = iconHeight * ICON_INITIAL_HIDE_FRAC; + // Slomo stretches scroll-px without changing the at-rest pixel offset. + const iconHidePx = iconHeight * ICON_INITIAL_HIDE_FRAC; + const iconRiseScroll = iconHidePx * HERO_SLOMO_FACTOR; // Scrub video: hold frame 0 during icon rise, then scrub remaining range. // Quantize to source frames and skip duplicate frame requests. This avoids @@ -519,9 +531,9 @@ function Home() { let targetFrame = 0; if (video.duration && isFinite(video.duration)) { let target = 0; - if (runwayScroll >= initialOffset) { - const videoProgress = (runwayHeight - initialOffset) > 0 - ? clamp01((runwayScroll - initialOffset) / (runwayHeight - initialOffset)) + if (runwayScroll >= iconRiseScroll) { + const videoProgress = (runwayHeight - iconRiseScroll) > 0 + ? clamp01((runwayScroll - iconRiseScroll) / (runwayHeight - iconRiseScroll)) : 0; target = videoProgress * video.duration; } @@ -588,11 +600,8 @@ function Home() { const contentEnterScroll = runway.offsetHeight * UNPIN_THRESHOLD - window.innerHeight; const slideAmount = Math.max(0, runwayScroll - contentEnterScroll); - // Video transform combines two behaviors: - // 1. Icon-rise (runwayScroll 0 → initialOffset): translate down so only - // the top third is visible; scroll lifts it 1:1 until fully in view. - // 2. Unpin slide (fraction > UNPIN_THRESHOLD): translate up with content. - const iconCurrentOffset = Math.max(0, initialOffset - runwayScroll); + // Icon-rise (lifts at rate 1/SLOMO), then unpin slide takes over. + const iconCurrentOffset = Math.max(0, iconHidePx - runwayScroll / HERO_SLOMO_FACTOR); const videoTranslateY = iconCurrentOffset > 0 ? iconCurrentOffset : slideAmount > 0 ? -slideAmount : 0; @@ -618,16 +627,57 @@ function Home() { ? `translate3d(0, -${heroOffset.toFixed(3)}px, 0)` : ''; } + + // Counter-translate the (natively-scrolled) content section by the same + // lag the smoother is closing, so its top edge tracks the smoothed video. + // Done last to avoid forcing layout between the reads above. + if (contentRef.current) { + contentRef.current.style.transform = scrollLag !== 0 + ? `translate3d(0, ${scrollLag.toFixed(3)}px, 0)` + : ''; + } } - function scheduleScrollSync() { - if (ticking) return; - ticking = true; + function smoothFrame(now: number) { + if (disposed) { + smoothRafId = 0; + return; + } + // Clamp dt so a tab returning from background doesn't snap-jump. + const dt = Math.min(0.1, (now - lastSmoothTimestamp) / 1000); + lastSmoothTimestamp = now; - frameId = requestAnimationFrame(() => { - ticking = false; - syncScrollState(); - }); + const target = readRunwayScroll(); + + if (skipSmoothing) { + smoothScroll = target; + } else { + const decay = Math.exp(-Math.LN2 * dt / HERO_SCROLL_HALFLIFE_S); + smoothScroll = target - (target - smoothScroll) * decay; + } + + // Snap before paint so the final frame clears the lag transform exactly. + const settled = Math.abs(target - smoothScroll) <= HERO_SCROLL_SETTLE_PX; + if (settled) smoothScroll = target; + + syncScrollState(smoothScroll, target - smoothScroll); + + if (settled) { + smoothRafId = 0; + if (contentRef.current) contentRef.current.style.willChange = ''; + } else { + smoothRafId = requestAnimationFrame(smoothFrame); + } + } + + function scheduleScrollSync() { + if (smoothRafId) return; + // Promote the content layer only while smoothing is active. Toggling + // (vs. always-on) avoids holding a composited layer for the page + // lifetime when no animation is in flight. + if (contentRef.current) contentRef.current.style.willChange = 'transform'; + lastSmoothTimestamp = performance.now(); + smoothRafId = requestAnimationFrame(smoothFrame); } // Mobile unlock @@ -678,13 +728,13 @@ function Home() { } window.addEventListener("scroll", onScroll, { passive: true }); - syncScrollState(); // initial position, before first paint + syncScrollState(smoothScroll, 0); // initial position, before first paint setHeroLayoutReady(true); return () => { disposed = true; cancelPosterHandoff(); - cancelAnimationFrame(frameId); + if (smoothRafId) cancelAnimationFrame(smoothRafId); window.removeEventListener("scroll", onScroll); window.removeEventListener("touchstart", unlock); video.removeEventListener("canplaythrough", handleCanPlayThrough); @@ -758,7 +808,7 @@ function Home() { {/* ── Content sections — pulled up to appear as video starts scrolling ── */} -
+

Stop watching terminals spin

@@ -904,7 +954,7 @@ function Home() {

-
+
MouseTerm Playground running on a phone

- Take one for the road + Walk away. Keep going.

Coming next: Roam. Pair a @@ -920,22 +970,27 @@ function Home() { system will buzz you if there's anything to do. A hosted auto-pairing service comes later — just leave and keep working, no "I'm walking away" dance.

-

- Subscribe below to nedshed.dev — my dev log. Roam launches there first. When the hosted service is ready, we'll do discounts for early adopters, so don't miss out! +

+ Open source and free to self-host, or pay us a little bit and you can use ours. We'll discount for early adopters, so don't miss out!

+

+ This signs you up for my personal devlog nedshed.dev on Substack. The next post will be the launch post, you can unsubscribe any time. +