site.jsx

  1const { useState, useEffect } = React;
  2
  3// --- Global hooks ---
  4
  5function useScrollReveal() {
  6  useEffect(() => {
  7    const els = document.querySelectorAll(".reveal");
  8    if (!els.length) return;
  9    if (!("IntersectionObserver" in window)) {
 10      els.forEach((el) => el.classList.add("visible"));
 11      return;
 12    }
 13    const obs = new IntersectionObserver(
 14      (entries) => {
 15        entries.forEach((e) => {
 16          if (e.isIntersecting) {
 17            e.target.classList.add("visible");
 18            obs.unobserve(e.target);
 19          }
 20        });
 21      },
 22      { threshold: 0.08, rootMargin: "0px 0px -40px 0px" },
 23    );
 24    els.forEach((el) => obs.observe(el));
 25    return () => obs.disconnect();
 26  }, []);
 27}
 28
 29function useMouseGlow() {
 30  useEffect(() => {
 31    const move = (e) => {
 32      document.documentElement.style.setProperty("--mx", e.clientX + "px");
 33      document.documentElement.style.setProperty("--my", e.clientY + "px");
 34    };
 35    window.addEventListener("mousemove", move, { passive: true });
 36    return () => window.removeEventListener("mousemove", move);
 37  }, []);
 38}
 39
 40// --- Components ---
 41
 42function FloatpaneMark({ size = 20 }) {
 43  return (
 44    <img
 45      src="../assets/floatpane.png"
 46      alt="Floatpane logo"
 47      className="wm-logo"
 48      height={size}
 49      width={size}
 50    />
 51  );
 52}
 53
 54function MatchaWordmark() {
 55  const [version, setVersion] = useState("v0.8.2");
 56  useEffect(() => {
 57    fetch("https://api.github.com/repos/floatpane/matcha/releases/latest")
 58      .then((r) => (r.ok ? r.json() : null))
 59      .then((d) => {
 60        if (d && d.tag_name)
 61          setVersion(
 62            d.tag_name.startsWith("v") ? d.tag_name : "v" + d.tag_name,
 63          );
 64      })
 65      .catch(() => {});
 66  }, []);
 67  return (
 68    <div className="wordmark">
 69      <img
 70        src="../assets/logo-transparent.png"
 71        alt="Matcha logo"
 72        className="wm-logo"
 73        height={24}
 74        width={24}
 75      />
 76      <span className="wm-name">matcha</span>
 77      <span className="wm-dim"> {version}</span>
 78    </div>
 79  );
 80}
 81
 82function TopNav() {
 83  const [stars, setStars] = useState(null);
 84  useEffect(() => {
 85    fetch("https://api.github.com/repos/floatpane/matcha")
 86      .then((r) => (r.ok ? r.json() : null))
 87      .then((d) => {
 88        if (d && typeof d.stargazers_count === "number") {
 89          const n = d.stargazers_count;
 90          setStars(n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n));
 91        }
 92      })
 93      .catch(() => {});
 94  }, []);
 95  return (
 96    <header className="nav">
 97      <a href="#top" className="nav-brand">
 98        <MatchaWordmark />
 99      </a>
100      <nav className="nav-links">
101        <a href="#features">Features</a>
102        <a href="#install">Install</a>
103        <a href="https://docs.matcha.email">Docs </a>
104        <a href="https://github.com/floatpane/matcha" className="nav-github">
105          <span>GitHub</span>
106          {stars && <span className="nav-star"> {stars}</span>}
107        </a>
108      </nav>
109      <div className="nav-right">
110        <a href="#install" className="btn btn-ghost">
111          install
112        </a>
113      </div>
114    </header>
115  );
116}
117
118function QuickStart() {
119  const CMD1 = "brew install floatpane/matcha/matcha";
120  const CMD2 = "matcha";
121  const [l1, setL1] = useState("");
122  const [l2, setL2] = useState("");
123  const [phase, setPhase] = useState("pre"); // pre → t1 → pause → t2 → done
124
125  useEffect(() => {
126    let t;
127    if (phase === "pre") {
128      t = setTimeout(() => setPhase("t1"), 1400);
129    } else if (phase === "t1") {
130      if (l1.length < CMD1.length) {
131        t = setTimeout(() => setL1(CMD1.slice(0, l1.length + 1)), 36);
132      } else {
133        t = setTimeout(() => setPhase("pause"), 420);
134      }
135    } else if (phase === "pause") {
136      t = setTimeout(() => setPhase("t2"), 320);
137    } else if (phase === "t2") {
138      if (l2.length < CMD2.length) {
139        t = setTimeout(() => setL2(CMD2.slice(0, l2.length + 1)), 90);
140      } else {
141        setPhase("done");
142      }
143    }
144    return () => clearTimeout(t);
145  }, [phase, l1, l2]);
146
147  const caret1 = phase === "pre" || phase === "t1" || phase === "pause";
148  const showL2 = phase === "t2" || phase === "done";
149
150  return (
151    <div className="quickstart">
152      <div className="qs-bar">
153        <span className="qs-dot qs-r" />
154        <span className="qs-dot qs-y" />
155        <span className="qs-dot qs-g" />
156        <span className="qs-bar-label">terminal</span>
157      </div>
158      <pre className="qs-code">
159        <span className="qs-prompt">$ </span>
160        {l1}
161        {caret1 && <span className="qs-caret" />}
162        {showL2 && (
163          <>
164            {"\n"}
165            <span className="qs-prompt">$ </span>
166            {l2}
167            <span className="qs-caret" />
168          </>
169        )}
170      </pre>
171    </div>
172  );
173}
174
175function Hero() {
176  return (
177    <section className="hero" id="top">
178      <div className="hero-copy">
179        <div className="hero-eyebrow">
180          <span className="dot-live" />
181          <span>by floatpane · local-first · secure · no telemetry</span>
182        </div>
183        <h1 className="hero-h1">
184          Email for people who
185          <br />
186          live in the <em>terminal.</em>
187        </h1>
188        <p className="hero-sub">
189          Matcha is a keyboard-native email client built for the shell.
190          Multi-account IMAP, PGP encryption, markdown composing, and a CLI
191          that pipes. One static binary. No cloud. No trackers.
192        </p>
193        <div className="hero-cta">
194          <a href="#install" className="btn btn-primary btn-lg">
195            Install now
196          </a>
197          <a href="https://docs.matcha.email" className="btn btn-ghost btn-lg">
198            Read the docs <span className="btn-k"></span>
199          </a>
200        </div>
201        <QuickStart />
202        <div className="hero-meta">
203          <div>
204            <span className="dim">license</span> MIT
205          </div>
206          <div>
207            <span className="dim">runtime</span> single static binary
208          </div>
209          <div>
210            <span className="dim">platforms</span> macOS · Linux · Windows
211          </div>
212        </div>
213      </div>
214    </section>
215  );
216}
217
218const FEATURES = [
219  {
220    k: "01",
221    title: "Keyboard-native",
222    body: "Read, reply, delete, archive — all from the keyboard. Navigate messages, switch accounts, and jump between folders without touching the mouse.",
223    mono: "j  k  r  d  a  ↵  esc",
224  },
225  {
226    k: "02",
227    title: "Visual mode batch ops",
228    body: "Enter visual mode to select a range of messages, then delete, archive, or move them all as a single IMAP command.",
229    mono: "v  j j j  d\n→ deleted 4 messages",
230  },
231  {
232    k: "03",
233    title: "Compose in markdown",
234    body: "Write in the syntax you already know. Headings, lists, fenced code, and tables render cleanly on the other side.",
235    mono: "# subject\n- bullet\n`inline`",
236  },
237  {
238    k: "04",
239    title: "Multi-account, tabbed",
240    body: "IMAP, Gmail, Fastmail, Proton Bridge — all in one window. Switch between them instantly so you never reply from the wrong address.",
241    mono: "← me@andrinoff\n→ drew@floatpane",
242  },
243  {
244    k: "05",
245    title: "Fuzzy filter",
246    body: "Filter across senders, subjects, and bodies in the active view. Results stream in as you type.",
247    mono: "/lena  →  3 hits",
248  },
249  {
250    k: "06",
251    title: "Local-first drafts",
252    body: "Every keystroke hits disk before it hits the wire. Close the laptop, open it anywhere, pick up mid-sentence.",
253    mono: "~/.cache/matcha/drafts",
254  },
255  {
256    k: "07",
257    title: "CLI that composes",
258    body: "Pipe errors into apologies. Send from scripts, CI, or cron. `matcha send` does one thing well.",
259    mono: "$ matcha send --to …",
260  },
261  {
262    k: "08",
263    title: "Inline image rendering",
264    body: "Images render inline via iTerm2 or kitty graphics where supported. Toggle with a key. Off by default, always.",
265    mono: "→ ◧ images on",
266  },
267  {
268    k: "09",
269    title: "Full-disk encryption",
270    body: "Encrypt all local data with a password that is never stored — not on disk, not in the keyring. Matcha shows a lock screen on startup. Forget the password and there is no reset.",
271    mono: "matcha is locked\n> ••••••••\nenter: unlock",
272  },
273];
274
275function Features() {
276  return (
277    <section className="features" id="features">
278      <div className="section-head reveal">
279        <div className="section-head-l">
280          <div className="section-eyebrow">§ features</div>
281          <h2 className="section-h2">
282            Everything you'd expect.
283            <br />
284            <span className="dim">And nothing you wouldn't.</span>
285          </h2>
286        </div>
287        <p className="section-head-r">
288          Matcha is opinionated. It won't follow you around the web, won't
289          upsell you on credits, and won't sync your signatures to a SaaS. It
290          reads mail. It writes mail. It stays out of the way.
291        </p>
292      </div>
293      <div className="feature-grid">
294        {FEATURES.map((f, i) => (
295          <article
296            key={f.k}
297            className="feature reveal"
298            style={{ transitionDelay: `${i * 55}ms` }}
299          >
300            <div className="feature-head">
301              <span className="feature-k">{f.k}</span>
302              <span className="feature-dash"></span>
303            </div>
304            <h3 className="feature-title">{f.title}</h3>
305            <p className="feature-body">{f.body}</p>
306            <pre className="feature-mono">{f.mono}</pre>
307          </article>
308        ))}
309      </div>
310    </section>
311  );
312}
313
314const INSTALL_TABS = {
315  brew: {
316    plat: "macOS · Linux",
317    cmd: "$ brew install floatpane/matcha/matcha\n$ matcha",
318  },
319  winget: {
320    plat: "Windows 10 / 11",
321    cmd: "$ winget install --id=floatpane.matcha\n$ matcha",
322  },
323  scoop: {
324    plat: "Windows",
325    cmd: "$ scoop install matcha\n$ matcha",
326  },
327  snap: { plat: "Ubuntu · Linux", cmd: "$ sudo snap install matcha\n$ matcha" },
328  flatpak: {
329    plat: "Linux",
330    cmd: "$ flatpak install https://matcha.email/matcha.flatpakref\n$ matcha",
331  },
332  aur: { plat: "Arch Linux", cmd: "$ yay -S matcha-client-bin\n$ matcha" },
333  nix: {
334    plat: "NixOS · any Nix",
335    cmd: "$ nix profile install github:floatpane/nix-matcha\n$ matcha",
336  },
337  nixpkgs: {
338    plat: "NixOS · nixpkgs",
339    cmd: "$ nix profile install nixpkgs#matcha\n$ matcha",
340  },
341};
342
343function Install() {
344  const [tab, setTab] = useState("brew");
345  const [meta, setMeta] = useState({
346    version: "0.8.2",
347    date: "apr 23, 2026",
348    size: null,
349  });
350  useEffect(() => {
351    fetch("https://api.github.com/repos/floatpane/matcha/releases/latest")
352      .then((r) => (r.ok ? r.json() : null))
353      .then((d) => {
354        if (!d) return;
355        const version = (d.tag_name || "").replace(/^v/, "") || "0.8.2";
356        const date = d.published_at
357          ? new Date(d.published_at)
358              .toLocaleDateString("en-US", {
359                month: "short",
360                day: "numeric",
361                year: "numeric",
362              })
363              .toLowerCase()
364          : "apr 23, 2026";
365        const asset = (d.assets || []).find((a) => a.size) || null;
366        const size = asset
367          ? (asset.size / (1024 * 1024)).toFixed(1) + " MB"
368          : null;
369        setMeta({ version, date, size });
370      })
371      .catch(() => {});
372  }, []);
373  const t = INSTALL_TABS[tab];
374  return (
375    <section className="install" id="install">
376      <div className="section-head reveal">
377        <div className="section-head-l">
378          <div className="section-eyebrow">§ install</div>
379          <h2 className="section-h2">
380            One binary.
381            <br />
382            <span className="dim">Pick your package manager.</span>
383          </h2>
384        </div>
385        <p className="section-head-r">
386          No runtime. Ships natively for macOS, Linux, Windows. Source and
387          issues at{" "}
388          <a
389            href="https://github.com/floatpane/matcha"
390            className="underline-link"
391          >
392            github.com/floatpane/matcha
393          </a>
394          .
395        </p>
396      </div>
397      <div className="install-card reveal" style={{ transitionDelay: "0.15s" }}>
398        <div className="install-tabs">
399          {Object.keys(INSTALL_TABS).map((k) => (
400            <button
401              key={k}
402              onClick={() => setTab(k)}
403              className={"install-tab " + (tab === k ? "active" : "")}
404            >
405              {k}
406            </button>
407          ))}
408          <div className="install-tabs-spacer" />
409          <span className="install-plat">{t.plat}</span>
410        </div>
411        <pre className="install-code">{t.cmd}</pre>
412        <div className="install-foot">
413          <div>
414            <span className="dim">latest</span> {meta.version} · {meta.date}
415          </div>
416          <div>
417            <span className="dim">source</span> github.com/floatpane/matcha
418          </div>
419        </div>
420      </div>
421    </section>
422  );
423}
424
425function CTA() {
426  return (
427    <section className="cta">
428      <div className="cta-inner">
429        <div className="cta-pre reveal">$ _</div>
430        <h2
431          className="cta-h2 reveal"
432          style={{ transitionDelay: "0.12s" }}
433        >
434          Your inbox is waiting
435          <br />
436          in the terminal.
437        </h2>
438        <div
439          className="cta-row reveal"
440          style={{ transitionDelay: "0.24s" }}
441        >
442          <a href="#install" className="btn btn-primary btn-lg">
443            install matcha
444          </a>
445          <a href="https://docs.matcha.email" className="btn btn-ghost btn-lg">
446            read the docs 
447          </a>
448        </div>
449      </div>
450    </section>
451  );
452}
453
454function Footer() {
455  return (
456    <footer className="footer">
457      <div className="footer-top">
458        <div className="footer-brand">
459          <MatchaWordmark />
460          <p className="footer-tag">
461            a keyboard-native email client.
462            <br />
463            made with care by floatpane.
464          </p>
465        </div>
466        <div className="footer-cols">
467          <div>
468            <div className="footer-h">product</div>
469            <a href="#features">features</a>
470            <a href="#install">install</a>
471            <a href="https://github.com/floatpane/matcha/releases">releases</a>
472          </div>
473          <div>
474            <div className="footer-h">resources</div>
475            <a href="https://docs.matcha.email">docs</a>
476            <a href="https://docs.matcha.email/Configuration">config</a>
477            <a href="https://docs.matcha.email/Features/CLI">cli</a>
478            <a href="https://github.com/floatpane/matcha/blob/master/SECURITY.md">
479              security
480            </a>
481          </div>
482          <div>
483            <div className="footer-h">community</div>
484            <a href="https://github.com/floatpane/matcha">github</a>
485            <a href="https://discord.gg/RxNrJgfatk">discord</a>
486            <a href="https://fosstodon.org/@floatpane">mastodon</a>
487          </div>
488          <div>
489            <div className="footer-h">floatpane</div>
490            <a href="https://floatpane.com">website</a>
491            <a href="mailto:us@floatpane.com">contact</a>
492            <a href="mailto:support@floatpane.com">support</a>
493          </div>
494        </div>
495      </div>
496      <div className="footer-bot">
497        <div className="footer-copy">
498          <FloatpaneMark size={16} />
499          <span>
500            © {new Date().getFullYear()} floatpane · MIT licensed · no trackers
501            on this page
502          </span>
503        </div>
504      </div>
505    </footer>
506  );
507}
508
509function App() {
510  useScrollReveal();
511  useMouseGlow();
512  return (
513    <div className="site">
514      <TopNav />
515      <Hero />
516      <Features />
517      <Install />
518      <CTA />
519      <Footer />
520    </div>
521  );
522}
523
524window.MatchaApp = App;