site.jsx

  1const { useState, useEffect } = React;
  2
  3function FloatpaneMark({ size = 20 }) {
  4  return (
  5    <img
  6      src="../assets/floatpane.png"
  7      alt="Floatpane logo"
  8      className="wm-logo"
  9      height={size}
 10      width={size}
 11    />
 12  );
 13}
 14
 15function MatchaWordmark() {
 16  const [version, setVersion] = useState("v0.8.2");
 17  useEffect(() => {
 18    // floatpane/matcha repo — fetch latest release tag
 19    fetch("https://api.github.com/repos/floatpane/matcha/releases/latest")
 20      .then((r) => (r.ok ? r.json() : null))
 21      .then((d) => {
 22        if (d && d.tag_name)
 23          setVersion(
 24            d.tag_name.startsWith("v") ? d.tag_name : "v" + d.tag_name,
 25          );
 26      })
 27      .catch(() => {});
 28  }, []);
 29  return (
 30    <div className="wordmark">
 31      <img
 32        src="../assets/logo-transparent.png"
 33        alt="Matcha logo"
 34        className="wm-logo"
 35        height={24}
 36        width={24}
 37      />
 38      <span className="wm-name">matcha</span>
 39      <span className="wm-dim">— {version}</span>
 40    </div>
 41  );
 42}
 43
 44function TopNav() {
 45  const [stars, setStars] = useState(null);
 46  useEffect(() => {
 47    fetch("https://api.github.com/repos/floatpane/matcha")
 48      .then((r) => (r.ok ? r.json() : null))
 49      .then((d) => {
 50        if (d && typeof d.stargazers_count === "number") {
 51          const n = d.stargazers_count;
 52          setStars(n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n));
 53        }
 54      })
 55      .catch(() => {});
 56  }, []);
 57  return (
 58    <header className="nav">
 59      <a href="#top" className="nav-brand">
 60        <MatchaWordmark />
 61      </a>
 62      <nav className="nav-links">
 63        <a href="#features">Features</a>
 64        <a href="#keys">Keybinds</a>
 65        <a href="#install">Install</a>
 66        <a href="https://docs.matcha.email">Docs ↗</a>
 67        <a href="https://github.com/floatpane/matcha" className="nav-github">
 68          <span>GitHub</span>
 69          {stars && <span className="nav-star">ā˜… {stars}</span>}
 70        </a>
 71      </nav>
 72      <div className="nav-right">
 73        <a href="#install" className="btn btn-ghost">
 74          install
 75        </a>
 76      </div>
 77    </header>
 78  );
 79}
 80
 81function Hero({ datasetKey, setDatasetKey }) {
 82  const [pressed, setPressed] = useState(false);
 83  const TUI = window.MatchaTUI;
 84  return (
 85    <section className="hero" id="top">
 86      <div className="hero-copy">
 87        <div className="hero-eyebrow">
 88          <span className="dot-live" />
 89          <span>by floatpane Ā· local-first Ā· secure Ā· no telemetry</span>
 90        </div>
 91        <h1 className="hero-h1">
 92          A powerful, feature-rich
 93          <br />
 94          email client <em>for your terminal.</em>
 95        </h1>
 96        <p className="hero-sub">
 97          Matcha is a modern TUI email client for people who live in the shell.
 98          Vim keybindings, PGP, IMAP multi-account, markdown composing,
 99          visual-mode batch ops, and a CLI that speaks your language.
100        </p>
101        <div className="hero-cta">
102          <a href="#install" className="btn btn-primary">
103            <span>Install</span>
104            <span className="btn-k">↵</span>
105          </a>
106          <a href="https://docs.matcha.email" className="btn btn-ghost">
107            <span>Read the docs</span>
108            <span className="btn-k">→</span>
109          </a>
110        </div>
111        <div className="hero-meta">
112          <div>
113            <span className="dim">license</span> MIT
114          </div>
115          <div>
116            <span className="dim">runtime</span> single static binary
117          </div>
118          <div>
119            <span className="dim">platforms</span> macOS Ā· Linux Ā· Windows
120          </div>
121        </div>
122      </div>
123
124      <div className="hero-demo">
125        <div className="hero-demo-chrome">
126          <div className="hero-demo-label">
127            <span className="dim">demo Ā·</span>
128            <button
129              className={"demo-swap " + (datasetKey === "default" ? "on" : "")}
130              onClick={() => setDatasetKey("default")}
131            >
132              drew's inbox
133            </button>
134            <button
135              className={"demo-swap " + (datasetKey === "dev" ? "on" : "")}
136              onClick={() => setDatasetKey("dev")}
137            >
138              floatpane dev
139            </button>
140            <button
141              className={"demo-swap " + (datasetKey === "personal" ? "on" : "")}
142              onClick={() => setDatasetKey("personal")}
143            >
144              personal
145            </button>
146          </div>
147          <div className="hero-demo-hint">
148            {pressed ? (
149              <span>āœ“ keyboard live</span>
150            ) : (
151              <span className="dim">
152                click Ā· <kbd>j</kbd>
153                <kbd>k</kbd> Ā· <kbd>tab</kbd> Ā· <kbd>↵</kbd>
154              </span>
155            )}
156          </div>
157        </div>
158        {TUI && (
159          <TUI datasetKey={datasetKey} onKeyPressed={() => setPressed(true)} />
160        )}
161      </div>
162    </section>
163  );
164}
165
166const FEATURES = [
167  {
168    k: "01",
169    title: "Email, the way you move",
170    body: "Read, reply, delete, archive — all from the home row. j/k to move, h/l between accounts, tab between folders. No mouse, no modals.",
171    mono: "j  k  h  l  r  d  a  ↵",
172  },
173  {
174    k: "02",
175    title: "Visual mode, for real",
176    body: "Press v to enter Vim-style multi-select. Expand with j/k, then d, a, or m to run batch ops as a single IMAP command.",
177    mono: "v  j j j  d\n→ deleted 4 messages",
178  },
179  {
180    k: "03",
181    title: "Compose in markdown",
182    body: "Write in the syntax you already know. Headings, lists, fenced code, and tables render cleanly on the other side.",
183    mono: "# subject\n- bullet\n`inline`",
184  },
185  {
186    k: "04",
187    title: "Multi-account, tabbed",
188    body: "IMAP, Gmail, Fastmail, Proton Bridge — all tabbed in one window. h and l switch between them so you never reply from the wrong address.",
189    mono: "← me@andrinoff\n→ drew@floatpane",
190  },
191  {
192    k: "05",
193    title: "Fuzzy filter",
194    body: "Press / to fuzzy-filter across senders, subjects, and bodies in the active view. Results stream in as you type.",
195    mono: "/lena  →  3 hits",
196  },
197  {
198    k: "06",
199    title: "Local-first drafts",
200    body: "Every keystroke hits disk before it hits the wire. Close the laptop, open it anywhere, pick up mid-sentence. Esc saves.",
201    mono: "~/.cache/matcha/drafts",
202  },
203  {
204    k: "07",
205    title: "CLI that composes",
206    body: "Pipe errors into apologies. Send from scripts, CI, or cron. `matcha send` does one thing well.",
207    mono: "$ matcha send --to …",
208  },
209  {
210    k: "08",
211    title: "AI, on your terms",
212    body: "Rewrite drafts with the model of your choice. Let agents send on your behalf — with strict scopes and an audit log.",
213    mono: "alt + r: make it more formal",
214  },
215  {
216    k: "09",
217    title: "Smart image rendering",
218    body: "Images render inline via iterm2 or kitty-graphics where supported. Toggle with i. Off by default, always.",
219    mono: "i  →  ā—§ images on",
220  },
221];
222
223function Features() {
224  return (
225    <section className="features" id="features">
226      <div className="section-head">
227        <div className="section-head-l">
228          <div className="section-eyebrow">§ features</div>
229          <h2 className="section-h2">
230            Everything you'd expect.
231            <br />
232            <span className="dim">And nothing you wouldn't.</span>
233          </h2>
234        </div>
235        <p className="section-head-r">
236          Matcha is opinionated. It won't follow you around the web, won't
237          upsell you on AI credits, and won't sync your signatures to a SaaS. It
238          reads mail. It writes mail. It stays out of the way.
239        </p>
240      </div>
241      <div className="feature-grid">
242        {FEATURES.map((f) => (
243          <article key={f.k} className="feature">
244            <div className="feature-head">
245              <span className="feature-k">{f.k}</span>
246              <span className="feature-dash">——</span>
247            </div>
248            <h3 className="feature-title">{f.title}</h3>
249            <p className="feature-body">{f.body}</p>
250            <pre className="feature-mono">{f.mono}</pre>
251          </article>
252        ))}
253      </div>
254    </section>
255  );
256}
257
258function Keybinds() {
259  const rows = [
260    {
261      g: "motion",
262      items: [
263        ["j / k", "next / prev message"],
264        ["↑ / ↓", "next / prev message"],
265        ["h / l", "prev / next account"],
266        ["← / →", "prev / next account"],
267        ["tab", "next folder"],
268        ["shift-tab", "prev folder"],
269      ],
270    },
271    {
272      g: "inbox",
273      items: [
274        ["↵", "open email"],
275        ["r", "refresh"],
276        ["d", "delete"],
277        ["a", "archive"],
278        ["/", "filter"],
279        ["v", "visual mode"],
280        ["esc", "back / main menu"],
281      ],
282    },
283    {
284      g: "visual mode",
285      items: [
286        ["v", "enter visual mode"],
287        ["j / k", "expand selection"],
288        ["d", "delete all selected"],
289        ["a", "archive all selected"],
290        ["m", "move to folder"],
291        ["v / esc", "exit visual mode"],
292      ],
293    },
294    {
295      g: "email view",
296      items: [
297        ["j / k", "scroll body"],
298        ["r", "reply"],
299        ["d", "delete"],
300        ["a", "archive"],
301        ["tab", "focus attachments"],
302        ["i", "toggle images"],
303        ["esc", "back to inbox"],
304      ],
305    },
306    {
307      g: "attachments",
308      items: [
309        ["j / k", "navigate"],
310        ["↵", "download & open"],
311        ["tab / esc", "back to body"],
312      ],
313    },
314    {
315      g: "composer",
316      items: [
317        ["tab / shift-tab", "navigate fields"],
318        ["↵ on From", "select account"],
319        ["↵ on Attachment", "open file picker"],
320        ["↵ on Send", "send email"],
321        ["↑ / ↓", "contact suggestions"],
322        ["esc", "save draft & exit"],
323      ],
324    },
325  ];
326  return (
327    <section className="keybinds" id="keys">
328      <div className="section-head">
329        <div className="section-head-l">
330          <div className="section-eyebrow">§ keybinds</div>
331          <h2 className="section-h2">
332            Vim-native.
333            <br />
334            <span className="dim">Home row to inbox zero.</span>
335          </h2>
336        </div>
337        <p className="section-head-r">
338          Every binding is documented at{" "}
339          <a href="https://docs.matcha.email" className="underline-link">
340            docs.matcha.email
341          </a>
342          . Muscle-memory for vimmers, learnable for everyone else.
343        </p>
344      </div>
345      <div className="keybinds-grid">
346        {rows.map((row) => (
347          <div key={row.g} className="keybinds-col">
348            <div className="keybinds-col-head">── {row.g} ──</div>
349            {row.items.map(([k, label]) => (
350              <div key={k} className="keybind-row">
351                <span className="keybind-k">
352                  <kbd>{k}</kbd>
353                </span>
354                <span className="keybind-dots">{"Ā·".repeat(26)}</span>
355                <span className="keybind-label">{label}</span>
356              </div>
357            ))}
358          </div>
359        ))}
360      </div>
361    </section>
362  );
363}
364
365const INSTALL_TABS = {
366  brew: {
367    plat: "macOS Ā· Linux",
368    cmd: "$ brew install floatpane/matcha/matcha\n$ matcha",
369  },
370  winget: {
371    plat: "Windows 10 / 11",
372    cmd: "$ winget install --id=floatpane.matcha\n$ matcha",
373  },
374  snap: { plat: "Ubuntu Ā· Linux", cmd: "$ sudo snap install matcha\n$ matcha" },
375  flatpak: {
376    plat: "Linux",
377    cmd: "$ flatpak install https://matcha.email/matcha.flatpakref\n$ matcha",
378  },
379  aur: { plat: "Arch Linux", cmd: "$ yay -S matcha-client-bin\n$ matcha" },
380  nix: {
381    plat: "NixOS Ā· any Nix",
382    cmd: "$ nix profile install github:floatpane/matcha\n$ matcha",
383  },
384};
385
386function Install() {
387  const [tab, setTab] = useState("brew");
388  const [meta, setMeta] = useState({
389    version: "0.8.2",
390    date: "apr 23, 2026",
391    size: null,
392  });
393  useEffect(() => {
394    fetch("https://api.github.com/repos/floatpane/matcha/releases/latest")
395      .then((r) => (r.ok ? r.json() : null))
396      .then((d) => {
397        if (!d) return;
398        const version = (d.tag_name || "").replace(/^v/, "") || "0.8.2";
399        const date = d.published_at
400          ? new Date(d.published_at)
401              .toLocaleDateString("en-US", {
402                month: "short",
403                day: "numeric",
404                year: "numeric",
405              })
406              .toLowerCase()
407          : "apr 23, 2026";
408        const asset = (d.assets || []).find((a) => a.size) || null;
409        const size = asset
410          ? (asset.size / (1024 * 1024)).toFixed(1) + " MB"
411          : null;
412        setMeta({ version, date, size });
413      })
414      .catch(() => {});
415  }, []);
416  const t = INSTALL_TABS[tab];
417  return (
418    <section className="install" id="install">
419      <div className="section-head">
420        <div className="section-head-l">
421          <div className="section-eyebrow">§ install</div>
422          <h2 className="section-h2">
423            One binary.
424            <br />
425            <span className="dim">Pick your package manager.</span>
426          </h2>
427        </div>
428        <p className="section-head-r">
429          No runtime. Ships natively for macOS, Linux, Windows. Source and
430          issues at{" "}
431          <a
432            href="https://github.com/floatpane/matcha"
433            className="underline-link"
434          >
435            github.com/floatpane/matcha
436          </a>
437          .
438        </p>
439      </div>
440      <div className="install-card">
441        <div className="install-tabs">
442          {Object.keys(INSTALL_TABS).map((k) => (
443            <button
444              key={k}
445              onClick={() => setTab(k)}
446              className={"install-tab " + (tab === k ? "active" : "")}
447            >
448              {k}
449            </button>
450          ))}
451          <div className="install-tabs-spacer" />
452          <span className="install-plat">{t.plat}</span>
453        </div>
454        <pre className="install-code">{t.cmd}</pre>
455        <div className="install-foot">
456          <div>
457            <span className="dim">latest</span> {meta.version} Ā· {meta.date}
458          </div>
459          <div>
460            <span className="dim">source</span> github.com/floatpane/matcha
461          </div>
462        </div>
463      </div>
464    </section>
465  );
466}
467
468function CTA() {
469  return (
470    <section className="cta">
471      <div className="cta-inner">
472        <div className="cta-pre">$ _</div>
473        <h2 className="cta-h2">
474          Your inbox is waiting
475          <br />
476          in the terminal.
477        </h2>
478        <div className="cta-row">
479          <a href="#install" className="btn btn-primary btn-lg">
480            make your emails secure
481          </a>
482          <a href="https://docs.matcha.email" className="btn btn-ghost btn-lg">
483            read the docs →
484          </a>
485        </div>
486      </div>
487    </section>
488  );
489}
490
491function Footer() {
492  return (
493    <footer className="footer">
494      <div className="footer-top">
495        <div className="footer-brand">
496          <MatchaWordmark />
497          <p className="footer-tag">
498            a modern TUI email client.
499            <br />
500            made with care by floatpane.
501          </p>
502        </div>
503        <div className="footer-cols">
504          <div>
505            <div className="footer-h">product</div>
506            <a href="#features">features</a>
507            <a href="#keys">keybinds</a>
508            <a href="#install">install</a>
509            <a href="https://github.com/floatpane/matcha/releases">releases</a>
510          </div>
511          <div>
512            <div className="footer-h">resources</div>
513            <a href="https://docs.matcha.email">docs</a>
514            <a href="https://docs.matcha.email/Configuration">config</a>
515            <a href="https://docs.matcha.email/Features/CLI">cli</a>
516            <a href="https://github.com/floatpane/matcha/blob/master/SECURITY.md">
517              security
518            </a>
519          </div>
520          <div>
521            <div className="footer-h">community</div>
522            <a href="https://github.com/floatpane/matcha">github</a>
523            <a href="https://discord.gg/RxNrJgfatk">discord</a>
524            <a href="https://fosstodon.org/@floatpane">mastodon</a>
525          </div>
526          <div>
527            <div className="footer-h">floatpane</div>
528            <a href="https://floatpane.com">website</a>
529            <a href="mailto:us@floatpane.com">contact</a>
530            <a href="mailto:support@floatpane.com">support</a>
531          </div>
532        </div>
533      </div>
534      <div className="footer-bot">
535        <div className="footer-copy">
536          <FloatpaneMark size={16} />
537          <span>
538            Ā© {new Date().getFullYear()} floatpane Ā· MIT licensed Ā· no trackers
539            on this page
540          </span>
541        </div>
542      </div>
543    </footer>
544  );
545}
546
547// ---------- Tweaks ----------
548function Tweaks({ datasetKey, setDatasetKey, visible }) {
549  if (!visible) return null;
550  return (
551    <div className="tweaks">
552      <div className="tweaks-head">Tweaks</div>
553      <div className="tweaks-sub">Demo content</div>
554      {Object.entries({
555        default: "drew's inbox",
556        dev: "floatpane dev",
557        personal: "personal",
558      }).map(([k, label]) => (
559        <button
560          key={k}
561          onClick={() => setDatasetKey(k)}
562          className={"tweaks-opt " + (datasetKey === k ? "on" : "")}
563        >
564          <span className="tweaks-dot">{datasetKey === k ? "ā—" : "ā—‹"}</span>
565          <span>{label}</span>
566        </button>
567      ))}
568    </div>
569  );
570}
571
572function App() {
573  const [datasetKey, setDatasetKey] = useState(() => {
574    try {
575      return localStorage.getItem("matcha-dataset") || "default";
576    } catch {
577      return "default";
578    }
579  });
580  const [tweaksVisible, setTweaksVisible] = useState(false);
581
582  useEffect(() => {
583    try {
584      localStorage.setItem("matcha-dataset", datasetKey);
585    } catch {}
586  }, [datasetKey]);
587
588  useEffect(() => {
589    const onMsg = (e) => {
590      const d = e.data || {};
591      if (d.type === "__activate_edit_mode") setTweaksVisible(true);
592      if (d.type === "__deactivate_edit_mode") setTweaksVisible(false);
593    };
594    window.addEventListener("message", onMsg);
595    window.parent.postMessage({ type: "__edit_mode_available" }, "*");
596    return () => window.removeEventListener("message", onMsg);
597  }, []);
598
599  useEffect(() => {
600    window.parent.postMessage(
601      { type: "__edit_mode_set_keys", edits: { datasetKey } },
602      "*",
603    );
604  }, [datasetKey]);
605
606  return (
607    <div className="site">
608      <TopNav />
609      <Hero datasetKey={datasetKey} setDatasetKey={setDatasetKey} />
610      <Features />
611      <Keybinds />
612      <Install />
613      <CTA />
614      <Footer />
615      <Tweaks
616        datasetKey={datasetKey}
617        setDatasetKey={setDatasetKey}
618        visible={tweaksVisible}
619      />
620    </div>
621  );
622}
623
624window.MatchaApp = App;