diff --git a/README.md b/README.md
index 4ac959d..4edf2e8 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
--secret <...> --fleet <...> # every other machine
```
-That deploys a Worker **and** this web app into your own Cloudflare account,
-on the free plan. The same deploy is a card on the UI's Remote screen. One
-relay fronts every machine you own, and pairing a device covers the whole
-fleet rather than one machine.
+That deploys a Worker and this web app into your own Cloudflare account, on the
+free plan. Everything crossing it is end-to-end encrypted with Noise IK, with
+the daemon's key pinned when a browser pairs, so the Worker forwards ciphertext
+it holds no key for. There is no flue account, no flue server and no billing.
+flue.sh serves docs and downloads and is never part of the data path.
What it deploys and what it costs is at
[flue.sh/docs/relay](https://flue.sh/docs/relay), with the operator-grade
version in [docs/RELAY.md](docs/RELAY.md). What a hostile relay origin could do
-despite the end-to-end encryption, which is the honest version because the
-browser loads its JavaScript from that origin, is in the
-[FAQ](https://flue.sh/docs/faq) and at length in [docs/faq.md](docs/faq.md).
+despite the encryption, because the browser loads its JavaScript from that
+origin, is in the [FAQ](https://flue.sh/docs/faq) and at length in
+[docs/faq.md](docs/faq.md).
**For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rebuild flue.sh as one monospace document column, reposition the copy around session organisation, and make the README say the same thing.
+
+**Architecture:** A single primitives file `site/src/components/md.tsx` owns the markdown-in-a-browser look. The homepage, header, footer and `DocPage` compose those primitives. The three drawn mocks stay as they are, minus their shadows. The README is rewritten by hand to the spec's outline.
+
+**Tech Stack:** TanStack Start + React 19, Tailwind v4 (`@theme`, `@utility`), Geist Mono from Google Fonts, pnpm, wrangler. No test runner in `site/`; the checks are `pnpm run build` (vite, `check-pages`, `sitemap`, `check-prose`) and `pnpm run lint` (tsc), plus `reins` screenshots.
+
+**Spec:** `docs/superpowers/specs/2026-08-23-site-readme-overhaul-design.md`
+
+## Global Constraints
+
+- pnpm only. Never npm, npx or yarn. (`CLAUDE.md`)
+- Work on branch `worktree-site-readme-overhaul`; land via PR. (`CLAUDE.md`)
+- Prose: simple global English, no em-dashes or en-dashes anywhere, ranges use "to". `check-prose.mjs` enforces it on `site/`; check the README by hand with `grep -n '[—–]' README.md`.
+- Every feature claim must be verified in `web/src` or `internal/` before it is written. The spec's copy was verified on 2026-08-23; if the build shows a claim is false, drop the claim.
+- One font, Geist Mono. Colours stay zinc and teal. Radius 0 on site-owned elements.
+- `og.png`, `docs/hero-*.png`, `docs/architecture.png`, `web/` untouched.
+- Commit after every task with a plain-English message; commits and PR body are normal prose, not caveman.
+
+Run all commands from `site/` unless stated. Check cycle for every task:
+
+```bash
+pnpm run lint && pnpm run build
+```
+
+Expected tail of a green build:
+
+```
+check-pages: 6 routes, all prerendered
+sitemap: 6 pages -> https://flue.sh/sitemap.xml
+check-prose: N files, no em-dashes or en-dashes
+```
+
+Screenshots: `pnpm run dev` on port 3100 in the background, then
+`reins open http://localhost:3100/` and
+`reins screenshot --browser --tab --full --out `. Run
+`reins tabs` for the ids. Light and dark: press `d` on the page to toggle.
+
+---
+
+### Task 1: Tokens and font
+
+**Files:**
+- Modify: `site/src/styles.css`
+- Modify: `site/src/routes/__root.tsx` (links, title, description)
+
+**Produces:** `--font-sans` and `--font-mono` both resolve to Geist Mono. `--radius: 0`. Utilities `backdrop-scan` removed. `term-cursor` kept (the switcher mock uses it).
+
+- [ ] **Step 1: Replace the font and radius in `styles.css`**
+
+Remove the `rsms.me` import. Make both font families Geist Mono. Set `--radius: 0`. Delete the `@utility backdrop-scan { ... }` block and its comment. Keep the theme tokens, the `@custom-variant dark`, the border-color base layer, and `term-cursor`.
+
+```css
+@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400..700&display=swap');
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@custom-variant dark (&:where(.dark, .dark *));
+
+@theme {
+ /* One face. The site reads as a document, and a document is set in one
+ font. Sans is mono on purpose so that no component can drift back. */
+ --font-sans: 'Geist Mono', ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace;
+ --font-mono: 'Geist Mono', ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace;
+}
+```
+
+and in `:root`: `--radius: 0;`.
+
+- [ ] **Step 2: Update `__root.tsx`**
+
+Remove the `rsms.me` preconnect link. Set:
+
+```ts
+const TITLE = 'flue: your Claude Code sessions and terminals, in one place'
+const DESCRIPTION =
+ 'flue is a daemon that keeps terminal sessions alive, and a web app that lists every one of them across your machines. Claude Code, Codex, Pi, builds, SSH. Name them, tag them, pin them, find them, open them from any screen you own. One static Go binary, no hosted service.'
+```
+
+Keep `og:image:alt` as it is (it describes the picture, which is unchanged).
+
+- [ ] **Step 3: Check**
+
+`pnpm run lint && pnpm run build`. Green. The page will look wrong (Inter gone, blur utilities undefined are fine because Tailwind only emits what is used).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/styles.css src/routes/__root.tsx
+git commit -m "site: one font, no radius, new title"
+```
+
+---
+
+### Task 2: The primitives, `md.tsx`
+
+**Files:**
+- Create: `site/src/components/md.tsx`
+
+**Produces:** the exports below. Every later task imports from here.
+
+```ts
+export function Meta({ lines }: { lines: string[] }): JSX.Element
+export function H1({ children }: { children: ReactNode }): JSX.Element
+export function H2({ id, children }: { id?: string; children: ReactNode }): JSX.Element
+export function P({ children, muted }: { children: ReactNode; muted?: boolean }): JSX.Element
+export function Ul({ children }: { children: ReactNode }): JSX.Element // children are
+export function Ol({ children }: { children: ReactNode }): JSX.Element // children are
+export function A({ href, children }: { href: string; children: ReactNode }): JSX.Element
+export function Code({ children }: { children: ReactNode }): JSX.Element
+export function Kbd({ children }: { children: ReactNode }): JSX.Element
+export function Pre({ children, label, className }: { children: ReactNode; label?: string; className?: string }): JSX.Element
+export function Shell({ lines }: { lines: string[] }): JSX.Element // lines starting with '$' are prompts
+export function Table({ rows }: { rows: [string, ReactNode][] }): JSX.Element
+export function Rule(): JSX.Element
+export function Column({ children, className }: { children: ReactNode; className?: string }): JSX.Element
+```
+
+- [ ] **Step 1: Write the file**
+
+```tsx
+import type { ReactNode } from 'react'
+
+import { cn } from '@/lib/utils'
+
+/**
+ * The page is a document, and this file is its whole vocabulary.
+ *
+ * Everything flue.sh draws is one of these: a heading with its `#` marker
+ * showing, a paragraph capped at a measure, a `*` list, a `$` transcript,
+ * a two-column table, a rule. The mocks are the only thing on the site
+ * that is not in here, and they are pictures.
+ */
+
+/** The one column every page sits in. */
+export function Column({ children, className }: { children: ReactNode; className?: string }) {
+ return {children}
+}
+
+/** The block at the top of a page: who, what, when. */
+export function Meta({ lines }: { lines: string[] }) {
+ return (
+
+ {lines.map((line) => (
+
{line}
+ ))}
+
+ )
+}
+
+function Marker({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function H1({ children }: { children: ReactNode }) {
+ return (
+
+ #
+ {children}
+
+ )
+}
+
+export function H2({ id, children }: { id?: string; children: ReactNode }) {
+ return (
+
+ ##
+ {children}
+
+ )
+}
+
+export function P({ children, muted }: { children: ReactNode; muted?: boolean }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** `*` list. Children are ` `. The marker hangs in the gutter. */
+export function Ul({ children }: { children: ReactNode }) {
+ return (
+
+ )
+}
+
+/** `1.` list. Children are ` `. */
+export function Ol({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function A({ href, children }: { href: string; children: ReactNode }) {
+ const external = href.startsWith('http')
+ return (
+
+ {children}
+
+ )
+}
+
+export function Code({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+export function Kbd({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+/**
+ * A block of text as typed. `label` is read to a screen reader in place of
+ * the block, for the diagram drawn in box characters.
+ */
+export function Pre({
+ children,
+ label,
+ className,
+}: {
+ children: ReactNode
+ label?: string
+ className?: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** A transcript. Lines beginning with `$` are prompts; the rest is output. */
+export function Shell({ lines }: { lines: string[] }) {
+ return (
+
+ {lines.map((line, i) => (
+
+ {line.startsWith('$') ? (
+ <>
+ $
+ {line.slice(1)}
+ >
+ ) : (
+ {line}
+ )}
+
+ ))}
+
+ )
+}
+
+/** Two columns, key and value. A real table so it reflows and reads aloud. */
+export function Table({ rows }: { rows: [string, ReactNode][] }) {
+ return (
+
+
+ {rows.map(([k, v]) => (
+
+
+ {k}
+
+ {v}
+
+ ))}
+
+
+ )
+}
+
+export function Rule() {
+ return (
+
+ ---
+
+ )
+}
+```
+
+- [ ] **Step 2: Check**
+
+`pnpm run lint`. Green (the file is unused yet; tsc still type-checks it).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/components/md.tsx
+git commit -m "site: the markdown primitives every page is built from"
+```
+
+---
+
+### Task 3: Header, footer, copy button, wordmark
+
+**Files:**
+- Modify: `site/src/components/site-header.tsx`
+- Modify: `site/src/components/site-footer.tsx`
+- Modify: `site/src/components/copy-command.tsx`
+- Modify: `site/src/components/theme-toggle.tsx` (remove `Button` import only; keep behaviour)
+- Delete: `site/src/components/ui/button.tsx`
+
+**Consumes:** `Column`, `Rule`, `A` from Task 2.
+
+- [ ] **Step 1: Read `theme-toggle.tsx`** and note where it uses `Button`. Replace with a plain `` carrying the same `aria-label` and handler, classes `inline-flex size-8 items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`.
+
+- [ ] **Step 2: Rewrite `site-header.tsx`**
+
+```tsx
+import { GithubMark, Wordmark } from '@/components/wordmark'
+import { Column } from '@/components/md'
+import { ThemeToggle } from '@/components/theme-toggle'
+import { REPO_URL } from '@/lib/site'
+
+const NAV = [
+ { href: '/docs/setup', label: 'setup' },
+ { href: '/docs/how-it-works', label: 'how it works' },
+ { href: '/docs/relay', label: 'remote' },
+ { href: '/docs/faq', label: 'faq' },
+] as const
+
+/** One line at the top. Not sticky: a document does not follow you. */
+export function SiteHeader() {
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 3: Rewrite `site-footer.tsx`**
+
+```tsx
+import { A, Column, Rule } from '@/components/md'
+import { REPO_URL, X_URL } from '@/lib/site'
+
+const LINKS = [
+ { href: '/docs/setup', label: 'Setup' },
+ { href: '/docs/how-it-works', label: 'How it works' },
+ { href: '/docs/relay', label: 'Remote access' },
+ { href: '/docs/faq', label: 'FAQ' },
+ { href: REPO_URL, label: 'GitHub' },
+ { href: X_URL, label: 'X' },
+]
+
+export function SiteFooter() {
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 4: Restyle `copy-command.tsx`**
+
+Keep the component and its behaviour. Change the outer `div` classes to
+`'flex items-center gap-3 border border-border py-2 pr-2 pl-4 text-sm'`, the `$` span to `'text-primary'`, the code to `'min-w-0 flex-1 overflow-x-auto whitespace-nowrap'`, and the button classes to `'relative inline-flex size-8 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'`. Keep the lucide `Copy` and `Check` icons; they are controls, not prose.
+
+- [ ] **Step 5: Delete `ui/button.tsx`** and remove its import everywhere: `grep -rn "ui/button" src`. At this point `index.tsx` still imports it; leave `index.tsx` broken until Task 4 or stub the import out. Better: do Task 3 and Task 4 in one commit if the intermediate state will not type-check. Decide at the time; the plan prefers one commit per task, and a failing tsc between them is acceptable only if both land in the same push.
+
+- [ ] **Step 6: Check**
+
+`pnpm run lint` may fail on `index.tsx` and `doc-page.tsx` until Tasks 4 and 5. `pnpm run build` must still produce six routes.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A src/components
+git commit -m "site: header, footer and copy button as one line each"
+```
+
+---
+
+### Task 4: Homepage
+
+**Files:**
+- Rewrite: `site/src/routes/index.tsx`
+- Modify: `site/src/lib/site.ts` (add `VERSION`)
+- Modify: `site/src/components/mock/fleet.tsx`, `site/src/components/mock/switcher.tsx` (shadows)
+- Delete: `site/src/components/mock/terminal.tsx`, `site/src/components/mock/diagrams.tsx` if `grep -rn "mock/terminal\|mock/diagrams" src` shows no other user after the rewrite. `diagrams.tsx` is used by `docs.relay.tsx`; check before deleting.
+
+**Consumes:** every export of Task 2; `FleetWindow`, `PhoneFrame`, `SwitcherWindow`; `CopyCommand`.
+
+- [ ] **Step 1: Add the version**
+
+In `site.ts`:
+
+```ts
+/** Shown in the meta block on the homepage. Bumped by hand with each release. */
+export const VERSION = 'v0.5.1'
+```
+
+Verify the number: `git -C ../.. describe --tags --abbrev=0`.
+
+- [ ] **Step 2: Rewrite `index.tsx`** with the copy from the spec, section by section: `Meta`, `H1`, `P`, `CopyCommand` and brew line, the fleet and phone mocks, `H2` What it does with `Ul` of eight `li`, `SwitcherWindow`, `H2` Remote access with `P`, the `Pre` diagram with `label`, `P`, `Shell` with the existing `RELAY_LINES` as plain strings, a link, `H2` Facts with `Table`, `H2` Install with `Shell` and `P` and two links. Chords as `Kbd`. The `Pre` diagram:
+
+```
+your machine your Cloudflare account your phone
+daemon, holds the key relay, holds no key browser, pins the daemon's key
+127.0.0.1:7717 ────────► flue-relay.you.workers.dev ──► paired once, by QR
+```
+
+with `label="Your machine runs the daemon and holds the key. A relay Worker in your own Cloudflare account forwards ciphertext it holds no key for. Your phone pairs once by QR and pins the daemon's key."`.
+
+The mocks: wrap the fleet and phone in ``. The switcher in `
`.
+
+- [ ] **Step 3: Strip shadows from the mocks**
+
+In `fleet.tsx` `FleetWindow`: replace `shadow-2xl` with nothing; keep `ring-1`. In `switcher.tsx`: `grep -n shadow` and remove `shadow-2xl`/`shadow-xl` on the outer frame; keep inner elevation that draws the app's own dialog. `PhoneFrame`: same.
+
+- [ ] **Step 4: Check**
+
+`pnpm run lint && pnpm run build`. Green. `pnpm run dev` and screenshot `/` at 1280 and 390, light and dark. Confirm: no horizontal scroll, copy button copies, `⌘K` opens the switcher mock.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A src
+git commit -m "site: the homepage as one document
+
+One column, headings with their markers showing, eight bullets that say
+what flue does, a table of facts, and the install line. The mocks stay,
+without their glow. The positioning moves from remote access to
+organising sessions, which is the thing people use."
+```
+
+---
+
+### Task 5: Docs pages
+
+**Files:**
+- Rewrite: `site/src/components/doc-page.tsx`
+- Modify: `site/src/lib/docs.tsx` (`how-it-works` blurb)
+- Modify: `site/src/routes/docs.how-it-works.tsx` (lede), and any of the four routes that imports something removed.
+
+**Consumes:** Task 2 primitives. **Produces:** `DocPage`, `Section`, `P`, `Lead`, `Code`, `Link`, `Shell`, `Note`, `Steps`, `Step` with unchanged props.
+
+- [ ] **Step 1: Rewrite `doc-page.tsx`**
+
+```tsx
+import type { ReactNode } from 'react'
+
+import * as md from '@/components/md'
+import { SiteFooter } from '@/components/site-footer'
+import { SiteHeader } from '@/components/site-header'
+import { DOCS, type DocSlug } from '@/lib/docs'
+
+export function DocPage({ slug, title, blurb, children }: { slug: DocSlug; title: string; blurb: string; children: ReactNode }) {
+ const others = DOCS.filter((doc) => doc.slug !== slug)
+ return (
+ <>
+
+
+
+
+ ← flue.sh
+
+
+ {title}
+
+ {blurb}
+ {children}
+
+ Other pages
+
+ {others.map((doc) => (
+
+ {doc.title}
+ {doc.blurb}
+
+ ))}
+
+
+
+
+ >
+ )
+}
+
+export function Section({ title, id, children }: { title: string; id?: string; eyebrow?: string; children: ReactNode; className?: string }) {
+ return (
+
+ )
+}
+
+export const P = ({ children }: { children: ReactNode }) =>
{children}
+export const Lead = ({ children }: { children: ReactNode }) =>
{children}
+export const Code = md.Code
+export const Link = md.A
+export const Shell = md.Shell
+
+export function Note({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ )
+}
+
+export const Steps = md.Ol
+
+export function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ )
+}
+```
+
+`eyebrow` and `className` are accepted and ignored so the routes compile unchanged; remove the props from the routes in the same task and then from the signature.
+
+- [ ] **Step 2: Blurb and lede**
+
+`docs.tsx`: `how-it-works` blurb becomes `'A daemon keeps the sessions. A web app lists them. What that gives you, and what it costs.'`. In `docs.how-it-works.tsx`, read the first `Section` and remove any sentence that restates the walking story or "moves the view".
+
+- [ ] **Step 3: Check**
+
+`pnpm run lint && pnpm run build`. Screenshot each docs page at 1280 and 390. The walkthrough on `/docs/setup` still plays.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A src
+git commit -m "site: docs pages on the same primitives as the homepage"
+```
+
+---
+
+### Task 6: llms.txt
+
+**Files:**
+- Modify: `site/public/llms.txt`
+
+- [ ] **Step 1:** Replace the first heading and paragraph with the homepage headline and paragraph. Replace the feature bullets with the eight from the spec. Leave everything else.
+
+- [ ] **Step 2:** `pnpm run build` (check-prose scans `public/`? Check `scripts/check-prose.mjs`; if not, `grep -n '[—–]' public/llms.txt` must print nothing).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add public/llms.txt
+git commit -m "site: llms.txt says what the homepage says"
+```
+
+---
+
+### Task 7: README
+
+**Files:**
+- Rewrite: `README.md` (repo root)
+
+- [ ] **Step 1:** Rewrite to the spec's nine-part outline. The eight bullets verbatim from the homepage, chords in backticks. Sections kept verbatim: badges, links, hero picture, Install, The CLI, Building and developing, License.
+
+- [ ] **Step 2:** `grep -n '[—–]' README.md` prints nothing. `wc -l README.md` is near 120.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "readme: say what flue is, then what it does
+
+The same headline, paragraph and eight bullets as flue.sh, so the two
+cannot drift apart. The walking story goes; organising sessions across
+machines is what people use, and remote access is one line in that list."
+```
+
+---
+
+### Task 8: Verify and open the PR
+
+- [ ] **Step 1:** From `site/`: `pnpm run lint && pnpm run build`. From the repo root: `go test ./...` is not needed (no Go changed) but `make test` is cheap insurance if time allows.
+
+- [ ] **Step 2:** Screenshots, saved to the scratchpad: `/` light and dark at 1280, `/` at 390, each docs page at 1280. Check the list in the spec's Testing section.
+
+- [ ] **Step 3:** Push and open the PR with `gh pr create`. Title: "flue.sh and README as one plain document". Body: what changed, why (MIT side project, not a SaaS; organisation is the product), the before and after screenshots of `/`, and the checks run. End with the generated-with line. Hand the URL back.
diff --git a/docs/superpowers/specs/2026-08-23-site-readme-overhaul-design.md b/docs/superpowers/specs/2026-08-23-site-readme-overhaul-design.md
new file mode 100644
index 0000000..177b6cd
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-23-site-readme-overhaul-design.md
@@ -0,0 +1,247 @@
+# flue.sh and README overhaul: a README in a browser
+
+Date: 2026-08-23. Status: approved in conversation, implementing.
+
+## Goal
+
+flue.sh reads like a paid product. It is an MIT side project. The site
+becomes a plain document: one monospace column, headings, bullets, tables,
+commands. The reference is https://code.storage/. The README gets the same
+copy, so the two say one thing.
+
+The positioning also changes. The site sold "walk and read the answer on
+your phone". The thing people use is organisation: every Claude Code, Codex
+and terminal session across every machine, in one list, with the agent
+history beside it. Remote access is one feature in that list, not the moat.
+
+## Non-goals
+
+- No new features in the app. Every claim on the page is verified in
+ `web/src` and `internal/` before it is written.
+- No MDX pipeline. The page is React composed from a small set of
+ markdown-looking primitives.
+- `og.png`, `docs/hero-*.png` and `docs/architecture.png` stay as they are.
+ A new OG image is a follow-up.
+- The app (`web/`) is untouched.
+
+## Look
+
+One font, Geist Mono, already loaded from Google Fonts, on ``. Inter
+stays loaded for one reason: the drawn mocks are pictures of the app, and the
+app is set in Inter. They opt in with `font-sans`; nothing else does.
+
+- Body 14px, line height 1.75. Headings are the same size, bold, uppercase,
+ prefixed with a dim `#` or `##`. No display sizes anywhere.
+- One column, left aligned, `max-w-[52rem]`, page padding `px-6`. Prose is
+ capped at `68ch`. Mocks span the full column.
+- Colours stay zinc and teal, the tokens already in `site/src/styles.css`.
+ Teal is used for the `$` prompt, the heading markers, the copy
+ confirmation, and nothing else. Links are foreground and underlined.
+- No `rounded-*` on anything the site owns, and the `--radius` tokens are
+ gone from `styles.css`. The mocks keep Tailwind's default radii because
+ they draw the app, and the app has corners.
+- Light and dark both stay, with the existing toggle and pre-paint script.
+- Removed: `backdrop-scan`, `Rails`, `SectionRule`, the glow blur, the
+ eyebrow pill, lucide icons in prose, the `Button` component, the
+ `shadow-2xl` on the mocks (a hairline ring stays), the dashed card grid on
+ docs pages, and the sticky header.
+
+## Primitives: `site/src/components/md.tsx`
+
+One file owns the look. Every page composes these and nothing else.
+
+| Export | Renders |
+| ------- | ------------------------------------------------------------- |
+| `H1` | `# ` marker in muted, then bold uppercase text |
+| `H2` | `## ` marker in muted, then bold uppercase text |
+| `P` | paragraph, `max-w-[68ch]` |
+| `Ul` | `*` bulleted list, marker in muted, hanging indent |
+| `Ol` | `1.` numbered list, tabular numbers, hanging indent |
+| `A` | underlined link, foreground, external gets `target=_blank` |
+| `Code` | inline code, hairline background |
+| `Kbd` | inline keyboard chord, same look as `Code` |
+| `Pre` | block of preformatted text, hairline border, `overflow-x-auto` |
+| `Shell` | `Pre` with `$` lines coloured as prompts |
+| `Table` | two-column key/value table, hairlines, mono |
+| `Rule` | `---` in muted |
+| `Meta` | the top-of-page block: three short lines in muted |
+
+`Table` is a real `
` so it reflows and reads to a screen reader. It
+is drawn with hairlines, not ASCII pipes, which do not reflow.
+
+The docs helpers in `doc-page.tsx` (`Section`, `P`, `Lead`, `Code`, `Link`,
+`Shell`, `Note`, `Steps`, `Step`) keep their names and props and are
+re-implemented on top of `md.tsx`, so the four `docs.*.tsx` routes change
+little. `Note` becomes a block quote with a hairline left rule. `Steps`
+becomes `Ol`.
+
+## Homepage
+
+Top to bottom. Text is final unless the build turns up a claim that is not
+true, in which case the claim goes, not the code.
+
+```
+karnstack
+flue
+2026 · MIT
+
+# Your Claude Code sessions and terminals, in one place
+
+flue is a daemon that keeps terminal sessions alive, and a web app that
+lists every one of them across your machines. Claude Code, Codex, Pi,
+builds, SSH. Name them, tag them, pin them, find them, open them from
+any screen you own.
+
+$ curl -fsSL https://flue.sh/install.sh | sh [copy]
+ or brew install karnstack/tap/flue
+
+[ FleetWindow + PhoneFrame, full column width, no glow ]
+
+## What it does
+
+* Sessions outlive the tab. Close it, the agent keeps working. Reopen it,
+ the scrollback replays.
+* Sessions outlive flue. Each one runs in its own holder process. Update
+ or crash the daemon, nothing stops. After a reboot, each comes back with
+ its history and the command that resumes the conversation.
+* One list, every machine. Group by machine or by tag. Name, tag, pin,
+ search. Drag a row onto a group to retag it. Save a view.
+* One keystroke to any of them. ⌘K or Ctrl+Shift+K lists every session on
+ every machine, and the highlighted row shows its last 14 lines.
+ Ctrl+Shift+1 to 9 jumps to a pinned one.
+* Splits and tabs. ⌘D splits right, ⇧⌘D splits down, ⌥⌘T opens a tab.
+* Agent history. Every Claude Code, Codex and Pi conversation from every
+ machine. Search it, see tokens and cost per day, read the transcript,
+ resume it in a terminal.
+* Files. Select a path in a terminal, peek at the file, open it in a tab.
+* Phone and tablet. Pair once with a QR code. A key bar with Esc, Tab,
+ arrows and Ctrl. Two devices can mirror one session.
+
+[ SwitcherWindow, wired up, full column width ]
+
+## Remote access
+
+The daemon listens on loopback and nothing else. One command deploys a
+relay Worker and this web app into your own Cloudflare account, on the
+free plan. Every machine you own shares that relay.
+
+ your machine ──── your Cloudflare account ──── your phone
+ daemon, holds relay, holds no key browser, pins the
+ the key daemon's key
+ 127.0.0.1:7717 flue-relay.you.workers.dev paired once, by QR
+
+Everything crossing it is end-to-end encrypted with Noise IK. The middle
+box forwards bytes it cannot read. There is no flue account, no flue
+server and no billing. flue.sh serves docs and downloads and is never part
+of the data path.
+
+$ flue relay setup
+ (the existing RELAY_LINES transcript, as a Shell block)
+
+→ What it deploys, and what it costs (/docs/relay)
+
+## Facts
+
+| License | MIT |
+| Binary | One static Go binary |
+| Platforms | macOS, Linux, WSL |
+| Hosted service | None |
+| Account | None |
+| Remote access | Your Cloudflare account, free plan |
+| Encryption | Noise IK, end to end |
+| Version | 0.x, commands can still change |
+
+## Install
+
+$ curl -fsSL https://flue.sh/install.sh | sh
+$ flue enable
+
+flue enable installs a login service, starts the daemon and opens the UI.
+Everything after that happens in the browser. More than one machine: read
+the setup guide. There is a recording of the whole setup (8:51) on that
+page.
+
+→ Setup guide (/docs/setup)
+→ Source (github)
+
+---
+Setup | How it works | Remote access | FAQ | GitHub | X
+© 2026 karnstack. MIT.
+```
+
+The data-path diagram is a `Pre` so it reads as text, and is given an
+`aria-label` that says the same thing in one sentence. Below `sm` it is
+too wide for the column; it scrolls inside its own box, which is what
+`Pre` does.
+
+The chords are Mac-first. Where Linux and Windows differ the chord is
+listed after "or". The Linux chords for splits and tabs are
+`Ctrl+Shift+D`, `Ctrl+Alt+Shift+D`, `Ctrl+Alt+T`; the page says "on Mac"
+once and points at `⌘/` for the rest.
+
+There is no version number on the page. The README's hand-kept one said
+v0.5.1 while the latest tag was v0.9.4, and a number nobody bumps is worse
+than none. The meta block is `2026 · MIT`.
+
+## Header and footer
+
+- Header, not sticky: wordmark left; `[ setup ]` `[ how it works ]`
+ `[ remote ]` `[ faq ]` as bracketed links; theme toggle and GitHub mark
+ right. Below `md` the links wrap onto a second line.
+- Footer: `Rule`, then the links pipe-separated on one line, then the
+ copyright line.
+
+## Docs pages
+
+`DocPage` keeps its props (`slug`, `title`, `blurb`, `children`). It renders:
+`← flue.sh`, `H1`, lede as `P` in foreground, children, then `Rule` and
+"Other pages" as a `Ul` of links. Same column as the homepage.
+
+Content of the four routes is kept. Only the `how-it-works` lede changes,
+to match the new positioning, and any sentence that restates the
+walking story goes.
+
+## README
+
+Same skeleton as the homepage, in this order:
+
+1. Title, tagline "Your Claude Code sessions and terminals, in one place.",
+ badges, links, hero picture. Unchanged except the tagline.
+2. The hero paragraph, then the eight bullets, verbatim from the homepage.
+ The "Sessions outlive flue" section is now a bullet and is removed.
+3. One line: "One static Go binary. macOS, Linux, WSL."
+4. Install: unchanged.
+5. Recommended setup: the four steps and the one warning about running
+ `flue relay setup` once. The rest of the prose goes, the link to
+ `/docs/setup` stays.
+6. The CLI: unchanged.
+7. Remote access: the two commands, one paragraph, the two links, the
+ architecture picture.
+8. Status: four lines. Released, a link to the releases page instead of a
+ number, the 0.x caveat, a link to FOLLOW-UPS.
+9. Building and developing, License: unchanged.
+
+Target is around 120 lines from 172. `check-prose.mjs` does not scan the
+README, so the no-dashes rule is checked by hand before the PR.
+
+## Also touched
+
+- `site/src/routes/__root.tsx`: `TITLE` and `DESCRIPTION` match the new
+ headline and paragraph.
+- `site/public/llms.txt`: first lines match the new headline and paragraph.
+- `site/src/lib/docs.tsx`: `how-it-works` blurb.
+- `site/src/components/mock/fleet.tsx`, `switcher.tsx`: drop `shadow-2xl`,
+ keep the ring.
+- `site/src/components/walkthrough.tsx`: stays, used on `/docs/setup` only.
+- Deleted if unused after the rewrite: `site/src/components/ui/button.tsx`,
+ `site/src/components/mock/terminal.tsx`, `site/src/components/mock/diagrams.tsx`.
+
+## Testing
+
+- `cd site && pnpm run build` (vite, `check-pages`, `sitemap`, `check-prose`).
+- `cd site && pnpm run lint`.
+- Screenshots with `reins` at 390px and 1280px, light and dark, of `/`,
+ `/docs/setup`, `/docs/how-it-works`, `/docs/relay`, `/docs/faq`. Checked
+ for: nothing scrolls the page horizontally, the switcher still opens on
+ the chord, the copy button copies, the theme toggle works.
+- The PR carries a before and after screenshot of `/`.
diff --git a/site/public/llms.txt b/site/public/llms.txt
index de5f321..51a8358 100644
--- a/site/public/llms.txt
+++ b/site/public/llms.txt
@@ -1,6 +1,17 @@
# flue
-> flue lets you continue your Claude Code and Codex sessions on any screen: start on your laptop, check in from your phone or iPad, pick up again at your desk. A small Go daemon owns the shells and their scrollback, and a web app draws them, so the shell stays on the machine and flue only moves the view. Closing the tab does not kill a session. It only detaches it, so a build, an agent run or an SSH session keeps going, and reattaching replays what was missed. One keystroke opens every session on every machine at once.
+> flue is a daemon that keeps terminal sessions alive, and a web app that lists every one of them across your machines. Claude Code, Codex, Pi, builds, SSH. Name them, tag them, pin them, find them, open them from any screen you own. Closing the tab does not kill a session. It only detaches it, so a build, an agent run or an SSH session keeps going, and reattaching replays what was missed. Sessions outlive the daemon too, and come back after a reboot with their history and the command that resumes the conversation.
+
+What it does:
+
+- Sessions outlive the tab. Close it, the agent keeps working. Reopen it, the scrollback replays.
+- Sessions outlive flue. Each one runs in its own holder process. Update or crash the daemon, nothing stops. After a reboot, each comes back with its history and the command that resumes the conversation.
+- One list, every machine. Group by machine or by tag. Name, tag, pin, search. Drag a row onto a group to retag it. Save a view.
+- One keystroke to any of them. Cmd+K or Ctrl+Shift+K lists every session on every machine, and the highlighted row shows its last 14 lines. Ctrl+Shift+1 to 9 jumps to a pinned one.
+- Splits and tabs. Cmd+D splits right, Shift+Cmd+D splits down, Alt+Cmd+T opens a tab. Cmd+/ lists every chord, spelled for the keyboard you are on. Linux and Windows use Ctrl.
+- Agent history. Every Claude Code, Codex and Pi conversation from every machine. Search it, see tokens and cost per day, read the transcript, resume it in a terminal.
+- Files. Select a path in a terminal, peek at the file, open it in a tab.
+- Phone and tablet. Pair once with a QR code. A key bar with Esc, Tab, arrows and Ctrl. Two devices can mirror one session.
flue is open source (MIT), pre-1.0, and free. It installs as one static Go
binary on macOS, Linux and WSL, with no Node, Python or other toolchain.
diff --git a/site/public/og.png b/site/public/og.png
index 172b1fd..38d336d 100644
Binary files a/site/public/og.png and b/site/public/og.png differ
diff --git a/site/src/components/copy-command.tsx b/site/src/components/copy-command.tsx
index 17215fe..60a11ca 100644
--- a/site/src/components/copy-command.tsx
+++ b/site/src/components/copy-command.tsx
@@ -23,18 +23,11 @@ export function CopyCommand({ command, className }: { command: string; className
}
return (
-
-
+
+
$
-
- {command}
-
+ {command}
)
@@ -46,16 +39,16 @@ function Button({ copied, onCopy }: { copied: boolean; onCopy: () => void }) {
type="button"
onClick={onCopy}
aria-label={copied ? 'Copied' : 'Copy install command'}
- className="relative inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
+ className="relative inline-flex size-8 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{copied ? (
-
+
) : (
-
+
)}
)
diff --git a/site/src/components/doc-page.tsx b/site/src/components/doc-page.tsx
index e9b059c..310ac8c 100644
--- a/site/src/components/doc-page.tsx
+++ b/site/src/components/doc-page.tsx
@@ -1,12 +1,12 @@
-import { ArrowLeft, ArrowRight } from 'lucide-react'
import type { ReactNode } from 'react'
+import * as md from '@/components/md'
import { SiteFooter } from '@/components/site-footer'
import { SiteHeader } from '@/components/site-header'
import { DOCS, type DocSlug } from '@/lib/docs'
import { cn } from '@/lib/utils'
-/** The shell every document shares: rails, lede, content, and the way out. */
+/** The shell every document shares: the way back, the title, the way on. */
export function DocPage({
slug,
title,
@@ -23,51 +23,29 @@ export function DocPage({
return (
<>
-
-
-
-
+
+
>
@@ -77,118 +55,62 @@ export function DocPage({
/** A titled run of a document. Sections are the page's only structure. */
export function Section({
title,
- eyebrow,
+ id,
children,
- className,
}: {
title: string
- eyebrow?: string
+ id?: string
children: ReactNode
- className?: string
}) {
return (
-
- {eyebrow && (
- {eyebrow}
- )}
-
- {title}
-
- {children}
+
)
}
/** Body copy. One component so every paragraph on every page measures alike. */
export function P({ children }: { children: ReactNode }) {
- return {children}
+ return {children}
}
-/** The one sentence a reader should leave with. */
+/**
+ * The one sentence a reader should leave with. The same weight as the rest:
+ * a README does not raise its voice, it puts the sentence first.
+ */
export function Lead({ children }: { children: ReactNode }) {
- return {children}
+ return {children}
}
-export function Code({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- )
-}
-
-export function Link({ href, children }: { href: string; children: ReactNode }) {
- const external = href.startsWith('http')
- return (
-
- {children}
-
- )
-}
-
-/** A shell transcript. Dark in both themes, like every terminal on the site. */
-export function Shell({ lines }: { lines: string[] }) {
- return (
-
- {lines.map((line) => (
-
- {line.startsWith('$') ? (
- <>
- $
- {line.slice(1)}
- >
- ) : (
- {line}
- )}
-
- ))}
-
- )
-}
+export const Code = md.Code
+export const Link = md.A
+export const Shell = md.Shell
+export const Pre = md.Pre
+export const Table = md.Table
+export const Ul = md.Ul
/** A point worth pulling out of the flow, without being a warning. */
export function Note({ title, children }: { title: string; children: ReactNode }) {
return (
-
+
+ {title}
+ {children}
+
)
}
/** An ordered run of steps. The only numbered structure the docs have. */
export function Steps({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- )
+ return {children}
}
-/**
- * One step.
- *
- * The number is written rather than left to a marker, because it is set in
- * mono and tabular and has to line up with the ones above and below it however
- * many there are.
- */
+/** One step. The number is the list's own; the title is its first line. */
export function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
-
-
- {String(n).padStart(2, '0')}
-
-
+
+ {title}
+ {children}
)
}
diff --git a/site/src/components/md.tsx b/site/src/components/md.tsx
new file mode 100644
index 0000000..f07da15
--- /dev/null
+++ b/site/src/components/md.tsx
@@ -0,0 +1,217 @@
+import type { ReactNode } from 'react'
+
+import { cn } from '@/lib/utils'
+
+/**
+ * The page is a document, and this file is its whole vocabulary.
+ *
+ * Everything flue.sh draws is one of these: a heading with its `#` showing,
+ * a paragraph capped at a measure, a `*` list, a `$` transcript, a two-column
+ * table, a rule. The mocks are the only things on the site not in here, and
+ * they are pictures.
+ *
+ * One size of text, `text-base/7 sm:text-sm/7`, and it is the only size the
+ * page has apart from the markers. A README has one size too.
+ */
+
+/** The one column every page sits in. */
+export function Column({ children, className }: { children: ReactNode; className?: string }) {
+ return {children}
+}
+
+/** The text size everything uses. Named once so nothing drifts. */
+export const TEXT = 'text-base/7 sm:text-sm/7'
+
+/** The block at the top of a page: who, what, when. */
+export function Meta({ lines }: { lines: string[] }) {
+ return (
+
+ {lines.map((line) => (
+
{line}
+ ))}
+
+ )
+}
+
+/** A heading's `#`. Read by nobody; it is the one ornament on the page. */
+function Marker({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function H1({ children }: { children: ReactNode }) {
+ return (
+
+ #
+ {children}
+
+ )
+}
+
+export function H2({ id, children }: { id?: string; children: ReactNode }) {
+ return (
+
+ ##
+ {children}
+
+ )
+}
+
+export function P({ children, muted }: { children: ReactNode; muted?: boolean }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** `*` list. Children are ``. The marker hangs in the gutter. */
+export function Ul({ children }: { children: ReactNode }) {
+ return (
+ li]:mt-2 [&>li]:pl-1",
+ )}
+ >
+ {children}
+
+ )
+}
+
+/** `1.` list. Children are ` `. */
+export function Ol({ children }: { children: ReactNode }) {
+ return (
+ li]:mt-2 [&>li]:pl-1',
+ )}
+ >
+ {children}
+
+ )
+}
+
+export function A({ href, children }: { href: string; children: ReactNode }) {
+ const external = href.startsWith('http')
+ return (
+
+ {children}
+
+ )
+}
+
+export function Code({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+export function Kbd({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+/**
+ * A block of text as typed.
+ *
+ * `label` is read to a screen reader in place of the block, for the diagram
+ * drawn in box characters, which is a picture to a reader who cannot see it.
+ */
+export function Pre({
+ children,
+ label,
+ className,
+}: {
+ children: ReactNode
+ label?: string
+ className?: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** A transcript. Lines beginning with `$` are prompts; the rest is output. */
+export function Shell({ lines }: { lines: string[] }) {
+ return (
+
+ {lines.map((line, i) => (
+ // Lines repeat in a transcript (blank ones, at least), so the index
+ // is the only key that is honest.
+
+ {line === '' ? (
+ // An empty block has no height, so a blank line in the transcript
+ // would vanish. A space under `whitespace-pre` keeps the line.
+ ' '
+ ) : line.startsWith('$') ? (
+ <>
+ $
+ {line.slice(1)}
+ >
+ ) : (
+ {line}
+ )}
+
+ ))}
+
+ )
+}
+
+/** Two columns, key and value. A real table so it reflows and reads aloud. */
+export function Table({ rows }: { rows: [string, ReactNode][] }) {
+ return (
+
+
+ {rows.map(([k, v]) => (
+
+
+ {k}
+
+ {v}
+
+ ))}
+
+
+ )
+}
+
+/** `---`. A section boundary, and the only one the page draws. */
+export function Rule() {
+ return (
+
+ ---
+
+ )
+}
+
+/** `→ Somewhere`. A link that stands on its own line. */
+export function Arrow({ href, children }: { href: string; children: ReactNode }) {
+ return (
+
+
+ →
+
+ {children}
+
+ )
+}
diff --git a/site/src/components/mock/diagrams.tsx b/site/src/components/mock/diagrams.tsx
deleted file mode 100644
index 00bf22e..0000000
--- a/site/src/components/mock/diagrams.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { ArrowDown, ArrowUp, Cloud, HardDrive, Lock, Smartphone } from 'lucide-react'
-
-/**
- * The relay protocol in one picture.
- *
- * Vertical, deliberately. A three-node horizontal flow needs about a
- * thousand pixels before its socket labels stop colliding with the boxes
- * they sit between, and this lives in a reading column half that wide. Read
- * top to bottom, each hop gets a whole row to name itself in.
- *
- * The claim the drawing exists to make is that the middle box is *inside*
- * the encrypted run rather than at the end of it. That is what the teal rail
- * down the left is: it spans all three nodes, not the gap between two.
- */
-export function RelayProtocol() {
- return (
-
- {/* The channel is the wrapper's own left border, so it cannot end up
- shorter than the nodes it is meant to run past. */}
-
-
-
-
-
-
-
-
-
-
-
- Noise IK, end to end: browser initiator, daemon responder
-
-
- frame: [4B channel][payload] · inside: [1B kind][wire protocol bytes]
-
-
-
- )
-}
-
-function Node({
- icon: Icon,
- title,
- lines,
-}: {
- icon: typeof Cloud
- title: string
- lines: string[]
-}) {
- return (
-
-
-
-
{title}
-
- {lines.join(' · ')}
-
-
-
- )
-}
-
-/** The run between two nodes: which socket it is, and which way it dials. */
-function Hop({ label, dir }: { label: string; dir: 'down' | 'up' }) {
- const Arrow = dir === 'down' ? ArrowDown : ArrowUp
- return (
-
- )
-}
diff --git a/site/src/components/mock/fleet.tsx b/site/src/components/mock/fleet.tsx
index 0ece8b3..d55a619 100644
--- a/site/src/components/mock/fleet.tsx
+++ b/site/src/components/mock/fleet.tsx
@@ -42,7 +42,7 @@ export function FleetWindow({ className }: { className?: string }) {
sidebar, commands, paths — depends on the box it landed in rather
than on the size of the screen. */
className={cn(
- '@container overflow-hidden rounded-xl bg-zinc-50 shadow-2xl ring-1 ring-zinc-950/8 dark:bg-zinc-950 dark:shadow-none dark:ring-white/10',
+ '@container overflow-hidden rounded-xl bg-zinc-50 font-sans ring-1 ring-zinc-950/8 dark:bg-zinc-950 dark:ring-white/10',
className,
)}
>
@@ -255,9 +255,9 @@ export function PhoneFrame({ className }: { className?: string }) {
return (
diff --git a/site/src/components/mock/switcher.tsx b/site/src/components/mock/switcher.tsx
index 7f3b718..8f320cf 100644
--- a/site/src/components/mock/switcher.tsx
+++ b/site/src/components/mock/switcher.tsx
@@ -107,8 +107,8 @@ export function SwitcherWindow({ className }: { className?: string }) {
diff --git a/site/src/components/mock/terminal.tsx b/site/src/components/mock/terminal.tsx
deleted file mode 100644
index 19757b8..0000000
--- a/site/src/components/mock/terminal.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useEffect, useReducer } from 'react'
-
-import { cn } from '@/lib/utils'
-
-export type Line =
- | { kind: 'prompt'; text: string }
- | { kind: 'output'; text: string }
- | { kind: 'ok'; text: string }
-
-export const prompt = (text: string): Line => ({ kind: 'prompt', text })
-export const output = (text: string): Line => ({ kind: 'output', text })
-export const ok = (text: string): Line => ({ kind: 'ok', text })
-
-/** How long a prompt line takes to "type", per character. */
-const CHAR_MS = 34
-/** The beat between a command finishing and its output appearing. */
-const OUTPUT_MS = 260
-
-function cost(line: Line): number {
- return line.kind === 'prompt' ? line.text.length * CHAR_MS : OUTPUT_MS
-}
-
-/**
- * A terminal that types itself out and then holds.
- *
- * The whole transcript is in the DOM from the first render — lines that have
- * not been reached yet are `invisible`, not absent — so the panel never
- * changes height, the layout never reflows, and a reader with reduced motion
- * or no JS sees the finished transcript rather than an empty box.
- */
-export function MockTerminal({
- lines,
- title,
- startDelay = 300,
- className,
- bodyClassName,
-}: {
- lines: Line[]
- title?: string
- startDelay?: number
- className?: string
- bodyClassName?: string
-}) {
- const [shown, advance] = useReducer((n: number) => n + 1, 0)
-
- useEffect(() => {
- if (shown >= lines.length) return
- const delay = shown === 0 ? startDelay : cost(lines[shown - 1]!)
- const t = setTimeout(advance, delay)
- return () => clearTimeout(t)
- }, [shown, lines, startDelay])
-
- return (
-
- {title && (
-
- )}
-
- {lines.map((line, i) => (
-
- {line.kind === 'prompt' && (
- <>
- $
- {line.text}
- >
- )}
- {line.kind === 'ok' && (
- <>
- {' ✓ '}
- {line.text}
- >
- )}
- {line.kind === 'output' && {line.text} }
-
- ))}
-
- $
-
-
-
-
- )
-}
diff --git a/site/src/components/site-footer.tsx b/site/src/components/site-footer.tsx
index 5743904..521192c 100644
--- a/site/src/components/site-footer.tsx
+++ b/site/src/components/site-footer.tsx
@@ -1,70 +1,37 @@
-import { GithubMark, Wordmark, XMark } from '@/components/wordmark'
+import { A, Column, Rule, TEXT } from '@/components/md'
import { REPO_URL, X_URL } from '@/lib/site'
+import { cn } from '@/lib/utils'
+
+const LINKS = [
+ { href: '/docs/setup', label: 'Setup' },
+ { href: '/docs/how-it-works', label: 'How it works' },
+ { href: '/docs/relay', label: 'Remote access' },
+ { href: '/docs/faq', label: 'FAQ' },
+ { href: REPO_URL, label: 'GitHub' },
+ { href: X_URL, label: 'X' },
+]
export function SiteFooter() {
return (
-
-
-
-
-
- Made by{' '}
-
- karn
- {' '}
- and a few AI agents. MIT licensed. © 2026 karnstack.
-
-
-
-
-
+
)
}
diff --git a/site/src/components/site-header.tsx b/site/src/components/site-header.tsx
index ea845a2..28471af 100644
--- a/site/src/components/site-header.tsx
+++ b/site/src/components/site-header.tsx
@@ -1,47 +1,52 @@
-import { GithubMark, Wordmark } from '@/components/wordmark'
+import { Column, TEXT } from '@/components/md'
import { ThemeToggle } from '@/components/theme-toggle'
-import { Button } from '@/components/ui/button'
+import { GithubMark, Wordmark } from '@/components/wordmark'
import { REPO_URL } from '@/lib/site'
+import { cn } from '@/lib/utils'
-// Docs only. The homepage used to have an anchor here, back when it had seven
-// sections worth navigating between; it has four now, and scrolling is cheaper
-// than a link.
const NAV = [
- { href: '/docs/setup', label: 'Setup' },
- { href: '/docs/how-it-works', label: 'How it works' },
- { href: '/docs/relay', label: 'Remote' },
- { href: '/docs/faq', label: 'Docs' },
+ { href: '/docs/setup', label: 'setup' },
+ { href: '/docs/how-it-works', label: 'how it works' },
+ { href: '/docs/relay', label: 'remote' },
+ { href: '/docs/faq', label: 'faq' },
] as const
+/** One line at the top. Not sticky: a document does not follow you down. */
export function SiteHeader() {
return (
-
-
+
+
-
-
+ {/* Below `sm` the nav drops to its own line under the wordmark and
+ the icons, rather than splitting the three across three lines. */}
+
{NAV.map((item) => (
+ [
{item.label}
+ ]
))}
-
-
+
)
}
diff --git a/site/src/components/theme-toggle.tsx b/site/src/components/theme-toggle.tsx
index 1baa990..2b7b395 100644
--- a/site/src/components/theme-toggle.tsx
+++ b/site/src/components/theme-toggle.tsx
@@ -1,8 +1,6 @@
import { Moon, Sun } from 'lucide-react'
import { useEffect, useState } from 'react'
-import { Button } from '@/components/ui/button'
-
/* "system" until the visitor picks explicitly; toggling always lands on an
explicit light or dark, and that is what gets persisted. */
type Theme = 'light' | 'dark' | 'system'
@@ -67,17 +65,21 @@ export function ThemeToggle() {
}, [isDark])
return (
-
setTheme(isDark ? 'light' : 'dark')}
>
-
-
-
+
+
+
+
)
}
diff --git a/site/src/components/ui/button.tsx b/site/src/components/ui/button.tsx
deleted file mode 100644
index a12f647..0000000
--- a/site/src/components/ui/button.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { cva, type VariantProps } from 'class-variance-authority'
-import { Slot } from 'radix-ui'
-import type * as React from 'react'
-
-import { cn } from '@/lib/utils'
-
-const buttonVariants = cva(
- "inline-flex shrink-0 items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
- {
- variants: {
- variant: {
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
- outline: 'border border-border bg-background hover:bg-accent',
- ghost: 'hover:bg-accent hover:text-accent-foreground',
- link: 'text-primary underline-offset-4 hover:underline',
- },
- size: {
- default: 'h-9 py-2 text-base sm:h-8 sm:text-sm px-3',
- lg: 'h-11 px-4 text-base sm:h-10',
- icon: 'size-9 sm:size-8',
- },
- },
- defaultVariants: {
- variant: 'default',
- size: 'default',
- },
- },
-)
-
-function Button({
- className,
- variant = 'default',
- size = 'default',
- asChild = false,
- ...props
-}: React.ComponentProps<'button'> &
- VariantProps
& {
- asChild?: boolean
- }) {
- const Comp = asChild ? Slot.Root : 'button'
-
- return (
-
- )
-}
-
-export { Button, buttonVariants }
diff --git a/site/src/components/walkthrough.tsx b/site/src/components/walkthrough.tsx
index 4a77264..efecd38 100644
--- a/site/src/components/walkthrough.tsx
+++ b/site/src/components/walkthrough.tsx
@@ -8,6 +8,7 @@ import {
WALKTHROUGH_POSTER,
WALKTHROUGH_RUNTIME,
} from '@/lib/site'
+import { TEXT } from '@/components/md'
import { cn } from '@/lib/utils'
/**
@@ -31,18 +32,15 @@ import { cn } from '@/lib/utils'
* sign and no key to hold.
*/
export function Walkthrough({
- align = 'left',
className,
id,
}: {
- /** Follows the section it lands in, the same way InstallBlock does. */
- align?: 'left' | 'center'
className?: string
/** Set when something on the page links to the player. */
id?: string
}) {
return (
-
+
-
-
+
+
{WALKTHROUGH_RUNTIME}. Install, one relay in your own Cloudflare account, a phone paired from
a QR code, then a second machine.
diff --git a/site/src/lib/docs.tsx b/site/src/lib/docs.tsx
index 8669368..a753a72 100644
--- a/site/src/lib/docs.tsx
+++ b/site/src/lib/docs.tsx
@@ -4,9 +4,9 @@ import type { ReactNode } from 'react'
* The documents flue.sh publishes, written for the web rather than pulled
* from the repository's markdown.
*
- * These are the reader-facing versions: short answers, drawn diagrams, and
- * only the depth a visitor wants. The operator-grade detail stays in the
- * repository, and each page links to it. An agent reading the site is served
+ * These are the reader-facing versions: short answers, one picture where a
+ * picture is shorter, and only the depth a visitor wants. The operator-grade
+ * detail stays in the repository, and each page links to it. An agent reading the site is served
* by /llms.txt, which is why nothing here has to double as machine-readable
* plain text.
*/
diff --git a/site/src/routes/__root.tsx b/site/src/routes/__root.tsx
index 19e3756..050da2c 100644
--- a/site/src/routes/__root.tsx
+++ b/site/src/routes/__root.tsx
@@ -4,9 +4,9 @@ import type { ReactNode } from 'react'
import { SITE_URL } from '@/lib/site'
import appCss from '@/styles.css?url'
-const TITLE = 'flue: continue your Claude Code and Codex sessions on any screen'
+const TITLE = 'flue: your Claude Code sessions and terminals, in one place'
const DESCRIPTION =
- 'Start on your laptop, check in from your phone or iPad, pick up again at your desk. A small Go daemon holds your terminals, so closing the tab detaches a session instead of killing it. One static Go binary, no hosted service.'
+ 'flue is a daemon that keeps terminal sessions alive, and a web app that lists every one of them across your machines. Claude Code, Codex, Pi, builds, SSH. Name them, tag them, pin them, find them, open them from any screen you own. One static Go binary, no hosted service.'
export const Route = createRootRoute({
head: () => ({
@@ -25,7 +25,8 @@ export const Route = createRootRoute({
{ property: 'og:image:height', content: '630' },
{
property: 'og:image:alt',
- content: 'flue: your agent, build and SSH sessions on every screen you own',
+ content:
+ 'A dark card set in monospace. A heading reads: your Claude Code sessions and terminals, in one place. Under it, the install line: curl -fsSL https://flue.sh/install.sh | sh.',
},
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#ffffff' },
@@ -53,7 +54,7 @@ function RootDocument({ children }: { children: ReactNode }) {
-
+
{children}
diff --git a/site/src/routes/docs.faq.tsx b/site/src/routes/docs.faq.tsx
index fc74fbd..9020865 100644
--- a/site/src/routes/docs.faq.tsx
+++ b/site/src/routes/docs.faq.tsx
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import type { ReactNode } from 'react'
-import { Code, DocPage, Link, P } from '@/components/doc-page'
+import { Code, DocPage, Link, P, Section } from '@/components/doc-page'
import { docTitle, findDoc } from '@/lib/docs'
import { REPO_URL } from '@/lib/site'
@@ -182,24 +182,16 @@ const QUESTIONS: { q: string; verdict: string; body?: ReactNode }[] = [
function Faq() {
return (
-
- {QUESTIONS.map((item, i) => (
-
-
- {item.q}
-
-
- {item.verdict}
- {item.body}
-
-
- ))}
-
+ {QUESTIONS.map((item) => (
+
+ {/* The verdict is the answer, so it carries weight; the body is
+ for the reader who wants to know why. */}
+
+ {item.verdict}
+
+ {item.body}
+
+ ))}
)
}
diff --git a/site/src/routes/docs.how-it-works.tsx b/site/src/routes/docs.how-it-works.tsx
index 3daea42..f38bac4 100644
--- a/site/src/routes/docs.how-it-works.tsx
+++ b/site/src/routes/docs.how-it-works.tsx
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
-import { Code, DocPage, Lead, Link, Note, P, Section, Shell } from '@/components/doc-page'
+import { Code, DocPage, Lead, Link, Note, P, Section, Shell, Table } from '@/components/doc-page'
import { docTitle, findDoc } from '@/lib/docs'
import { REPO_URL } from '@/lib/site'
@@ -114,23 +114,7 @@ function HowItWorks() {
Everything after flue enable happens in the browser. The commands exist for
the things a browser cannot do.
-
- {COMMANDS.map((item, i) => (
-
-
{item.cmd}
-
- {item.what}
-
-
- ))}
-
+ [item.cmd, item.what])} />
diff --git a/site/src/routes/docs.relay.tsx b/site/src/routes/docs.relay.tsx
index 0789792..810b541 100644
--- a/site/src/routes/docs.relay.tsx
+++ b/site/src/routes/docs.relay.tsx
@@ -1,7 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
-import { Code, DocPage, Lead, Link, Note, P, Section, Shell } from '@/components/doc-page'
-import { RelayProtocol } from '@/components/mock/diagrams'
+import { Code, DocPage, Lead, Link, Note, P, Pre, Section, Shell, Ul } from '@/components/doc-page'
import { docTitle, findDoc } from '@/lib/docs'
import { REPO_URL } from '@/lib/site'
@@ -16,6 +15,31 @@ export const Route = createFileRoute('/docs/relay')({
const RELAY_DOC = `${REPO_URL}/blob/main/docs/RELAY.md`
+/*
+ * The relay protocol in one picture, read top to bottom. The claim it exists
+ * to make is that the middle box is inside the encrypted run rather than at
+ * the end of it, which is what the rail down the left side says.
+ */
+const PROTOCOL = `│ daemon on your machine
+│ dials out, never listens
+│ │
+│ │ wss /daemon/
+│ ▼
+│ Worker in your Cloudflare account
+│ one Durable Object per machine, one for the directory
+│ forwards frames, holds no key
+│ ▲
+│ │ wss /client/
+│ │
+│ browser any device you paired
+│ pins the daemon key
+│
+└─ Noise IK, end to end: browser initiator, daemon responder
+ frame: [4B channel][payload] inside: [1B kind][wire protocol bytes]`
+
+const PROTOCOL_LABEL =
+ 'The daemon and the browser each dial a Cloudflare Worker outbound. The Worker bridges the two sockets and forwards frames. A Noise IK channel runs end to end from the browser to the daemon, past the Worker, which holds no key for it.'
+
const SEES = [
{ can: false, text: 'What is in any session. Keystrokes, output and scrollback are Noise ciphertext, and the relay holds no key for it.' },
{ can: false, text: "Your daemon's private key, or any device's." },
@@ -39,7 +63,7 @@ function Relay() {
-
+ {PROTOCOL}
The Worker sits inside the encrypted run rather than at the end of it. It moves opaque
frames between two sockets. The Noise channel runs between your browser and your daemon,
@@ -92,33 +116,14 @@ function Relay() {
-
There is one more thing worth knowing, and it is not about the cryptography. The web app
is served by the relay origin, so you are trusting that origin to serve the published
diff --git a/site/src/routes/index.tsx b/site/src/routes/index.tsx
index be7d0ca..6ab7b43 100644
--- a/site/src/routes/index.tsx
+++ b/site/src/routes/index.tsx
@@ -1,495 +1,217 @@
-import { Fragment } from 'react'
import { createFileRoute } from '@tanstack/react-router'
-import { ArrowRight, Cloud, HardDrive, Lock, Play, Plus, Smartphone } from 'lucide-react'
import { CopyCommand } from '@/components/copy-command'
+import {
+ Arrow,
+ Code,
+ Column,
+ H1,
+ H2,
+ Kbd,
+ Meta,
+ P,
+ Pre,
+ Shell,
+ Table,
+ TEXT,
+ Ul,
+} from '@/components/md'
import { FleetWindow, PhoneFrame } from '@/components/mock/fleet'
import { SwitcherWindow } from '@/components/mock/switcher'
-import { MockTerminal, ok, output, prompt } from '@/components/mock/terminal'
import { SiteFooter } from '@/components/site-footer'
import { SiteHeader } from '@/components/site-header'
-import { Button } from '@/components/ui/button'
-import { Walkthrough } from '@/components/walkthrough'
-import { GithubMark } from '@/components/wordmark'
-import { BREW_CMD, INSTALL_CMD, REPO_URL, WALKTHROUGH_RUNTIME } from '@/lib/site'
+import { BREW_CMD, INSTALL_CMD, REPO_URL } from '@/lib/site'
+import { cn } from '@/lib/utils'
export const Route = createFileRoute('/')({
component: Home,
})
-const RELAY_LINES = [
- prompt('flue relay setup'),
- ok('token verified'),
- ok('worker deployed: flue-relay'),
- ok('web app uploaded'),
- ok('reachable at https://flue-relay.you.workers.dev'),
- ok('fleet key minted (Cloudflare never sees it)'),
- ok('this machine joined as laptop (laptop-9f3a-3f9a12cd)'),
- output('\n to add another machine, run this on it:\n'),
- output(' flue relay join wss://flue-relay.you.workers.dev --secret … --fleet …'),
-]
-
/**
- * Four sections, where there were seven.
+ * One document, top to bottom.
*
- * The seven were a problem/solution/trust/CTA run, which is the shape a SaaS
- * landing page takes and the wrong dress for a thing one person wrote for
- * themselves and gave away. What replaced the three-card problem grid is one
- * true story about why it exists, which is shorter and argues better.
+ * What it is, what it does, how it reaches your phone, the facts, the
+ * install line. No hero, no cards, no path of numbered boxes. The mocks are
+ * the only pictures, and they sit in the flow where a README would put a
+ * screenshot.
*/
function Home() {
return (
<>
-
-
-
-
-
-
-
-
-
-
-
- >
- )
-}
-
-/**
- * Two dashed rails at the container's edges, running the height of the page.
- *
- * They are the page's only structural line: every section's content already
- * aligns to them, so drawing them turns an invisible measure into something
- * the eye can follow, and the plus marks where a section rule crosses them
- * turn each boundary into a corner rather than a cut.
- */
-function Rails() {
- return (
-
- )
-}
-
-/** A section boundary: a dashed rule, crossed at both rails. */
-function SectionRule() {
- return (
-
- )
-}
-
-/* ---------------------------------------------------------------- hero --- */
+
+
+
-function Eyebrow() {
- return (
-
-
- MIT
-
- Open source, no hosted service
-
- )
-}
+
+
Your Claude Code sessions and terminals, in one place
+
+
+ flue is a daemon that keeps terminal sessions alive, and a web app that lists every
+ one of them across your machines. Claude Code, Codex, Pi, builds, SSH. Name them, tag
+ them, pin them, find them, open them from any screen you own.
+
+
+
+
+ or {BREW_CMD}
+
+
+ {/* The two halves of the claim in one picture: the list on a
+ machine, and the highlighted row of it open on a phone. */}
+
-function Headline() {
- return (
-
- Continue your Claude Code and Codex sessions on any screen.
-
- )
-}
+
-function Subline() {
- return (
-
- Start on your laptop. Check in from your phone or iPad. Pick up again at your desk. The shell
- stays on the machine; flue moves the view.
-
- )
-}
+
+
+
-function InstallBlock({ align = 'left' }: { align?: 'left' | 'center' }) {
- return (
-
-
-
- or {BREW_CMD}
-
-
- macOS, Linux, WSL. One static Go binary. No Node, no Python, no toolchain.
-
-
+
+
+
+
+
+
+ >
)
}
-/**
- * The hero's second action, and deliberately the quieter one.
- *
- * Copying the install line is the thing this page is for, so the command keeps
- * the loud treatment and this gets an outline. It points at the recording
- * further down the page rather than at /docs/setup: the reader who wants to
- * see it work before running a script into their shell is answered in place,
- * without losing the page they were on.
- */
-function WatchWalkthrough() {
- return (
-
-
-
- Watch the setup walkthrough
-
- {WALKTHROUGH_RUNTIME}
-
-
-
- )
-}
+/* ------------------------------------------------------------------------ */
/**
- * The two halves of the claim in one picture: the fleet on a machine, and the
- * ringed row of it open on a phone. They sit beside each other rather than
- * overlapping, since an overlap covers the rows the phone is meant to point
- * at, and the teal ring on the row is what pairs them. Below `lg` the phone
- * drops under the window, since neither fits beside the other at that width.
+ * Every line here is checked against web/src or internal/ before it is
+ * written. The chords are the Mac ones; the page says where the others are.
*/
-function ProofMock() {
- return (
-
- )
-}
-
-function Hero() {
+function WhatItDoes() {
return (
-
+ <>
+ What it does
+
+
+ Sessions outlive the tab. Close it, the agent keeps working. Reopen it, the scrollback
+ replays.
+
+
+ Sessions outlive flue. Each one runs in its own holder process. Update or crash the
+ daemon, nothing stops. After a reboot, each comes back with its history and the command
+ that resumes the conversation.
+
+
+ One list, every machine. Group by machine or by tag. Name, tag, pin, search. Drag a row
+ onto a group to retag it. Save a view.
+
+
+ One keystroke to any of them. ⌘K or Ctrl+Shift+K lists every
+ session on every machine, and the highlighted row shows its last 14 lines.{' '}
+ Ctrl+Shift+1 to 9 jumps to a pinned one.
+
+
+ Splits and tabs. ⌘D splits right, ⇧⌘D splits
+ down, ⌥⌘T opens a tab. ⌘/ lists every chord,
+ spelled for the keyboard you are on. Linux and Windows use Ctrl.
+
+
+ Agent history. Every Claude Code, Codex and Pi conversation from every machine. Search
+ it, see tokens and cost per day, read the transcript, resume it in a terminal.
+
+ Files. Select a path in a terminal, peek at the file, open it in a tab.
+
+ Phone and tablet. Pair once with a QR code. A key bar with Esc, Tab, arrows and Ctrl.
+ Two devices can mirror one session.
+
+
+ The switcher below is wired up. Press either chord and type.
+ >
)
}
-/* ------------------------------------------------------------- sessions --- */
-
-/**
- * Why it exists, what it does, and the palette you can actually use.
- *
- * One section for what used to be three. The story does the job the problem
- * grid was doing, at a fraction of its height and with the advantage of being
- * true, and the palette below it is the claim in the subline made testable:
- * the reader can press the chord on this page and watch the list arrive.
- */
-function Sessions() {
- return (
-
-
- {/* Why it exists, beside what it is. Same grid and gap as Remote, so
- the column edges of the two sections line up down the page. */}
-
-
-
Why it exists
-
-
- I wanted my 10,000 steps. Claude Code and Codex had other plans, and I am not buying
- a walking pad. So now I start the run on my machine, go for the walk, and read the
- answer on my phone.
-
-
- Karn, who wrote this instead of walking
-
-
-
+/* ------------------------------------------------------------------------ */
-
-
- One daemon owns the shells. Every screen is only a view.
-
-
- A small Go daemon holds the terminals and their scrollback. Closing the tab does not
- kill the session. It only detaches it: the agent keeps working, the build keeps
- running, the SSH session stays up. Reattach and the daemon replays what you missed.
-
-
- Two devices can attach to one session and mirror live. Size follows the view you used
- last, so it fits the phone in your hand, and the laptop when you go back to it.
-
-
-
-
- {/* The palette's heading group, with the heading holding the left
- column and its prose the right, so the run above the full-width
- drawing occupies the same measure the drawing does. */}
-
-
- One keystroke to any session on any machine.
-
-
-
- Press ⌘K on a Mac, or{' '}
- Ctrl+Shift+K on any platform
- including macOS, from any screen that can see a daemon. Pinned sessions first, with
- number keys on them, then the ones this browser has opened, then the rest.
-
-
- The highlighted row shows its last fourteen lines, so you can see which one is the
- build. Ctrl+Shift+1 to{' '}
- 9 jumps straight to a pinned session
- without opening the list. The one below is wired up: press either chord and type.
-
-
-
-
- {/* Full width rather than in a column: the real dialog is 56rem across
- and the preview pane is the first thing a half-row takes away, which
- is the part worth showing. */}
-
-
-
-
-
- How it is built
-
-
-
-
- )
-}
-
-/* --------------------------------------------------------------- remote --- */
+const RELAY_LINES = [
+ '$ flue relay setup',
+ ' token verified',
+ ' worker deployed: flue-relay',
+ ' web app uploaded',
+ ' reachable at https://flue-relay.you.workers.dev',
+ ' fleet key minted (Cloudflare never sees it)',
+ ' this machine joined as laptop (laptop-9f3a-3f9a12cd)',
+ '',
+ ' to add another machine, run this on it:',
+ ' flue relay join wss://flue-relay.you.workers.dev --secret … --fleet …',
+]
/*
- * The three places your bytes are, in order.
- *
- * The middle one is the point: it is the box you do not have to trust, and it
- * is drawn between the other two so that the dashed run passing through it
- * reads as one channel rather than two hops. The device box says the browser
- * pins the daemon's key, because the browser holds a long-lived key of its own
- * (web/src/crypto/keys.ts) and this panel used to claim the daemon held the
- * only one.
+ * The three places your bytes are, drawn as one line. The middle one is the
+ * point: it is the box you do not have to trust.
*/
-const PATH = [
- {
- title: 'Your machine',
- body: 'The daemon owns the terminals and holds the key your browser pins. It listens on loopback and nothing else.',
- mono: '127.0.0.1:7717',
- icon: HardDrive,
- },
- {
- title: 'Your Cloudflare account',
- body: 'A relay Worker you deployed forwards ciphertext between devices. It holds no key, so it can read nothing.',
- mono: 'flue-relay.you.workers.dev',
- icon: Cloud,
- },
- {
- title: 'Your other devices',
- body: 'Each browser pairs once from a QR code, and pins the daemon’s key from then on.',
- mono: 'phone · tablet · second laptop',
- icon: Smartphone,
- },
-]
+const PATH = `your machine your Cloudflare account your phone
+daemon, holds the key relay, holds no key browser, pins the key
+127.0.0.1:7717 ─────────► flue-relay.you.workers.dev ───► paired once, by QR`
+
+const PATH_LABEL =
+ 'Your machine runs the daemon and holds the key. A relay Worker in your own Cloudflare account forwards ciphertext it holds no key for. Your phone pairs once by QR code and pins the key.'
-/**
- * Remote access and the trust argument, together.
- *
- * They were two sections saying one thing twice: the prose and the path both
- * argued that the middle holds no key. Merged, the prose makes the claim and
- * the path shows it, which is the division of labour they should have had.
- */
function Remote() {
return (
-
-
-
-
-
-
Remote access
-
- Reachable from anywhere, with nobody in the middle.
-
-
- The daemon listens on loopback and nothing else. One command deploys a relay Worker
- and this web app into your own Cloudflare account, on the free plan, and every machine
- you own shares it.
-
-
- Everything crossing it is end-to-end encrypted with Noise IK. Your browser pins the
- daemon’s key when it pairs, so the Worker forwards ciphertext it holds no key
- for. It is a 0.x release, so commands and config can still change between versions.
-
-
- What it deploys, and what it costs
-
-
-
-
-
-
-
- There is no flue account, no flue server and no billing, because there is no company.
- There is me and a Cloudflare free plan. flue.sh serves docs and downloads, and is never
- part of the data path.
-
-
- {/* The three places, as a path rather than as three cards: dashed
- connectors carry the Noise IK channel from end to end, and the
- middle box is deliberately the one that holds no key. */}
-
- {PATH.map((step, i) => (
-
- {i > 0 && }
-
-
- {String(i + 1).padStart(2, '0')}
-
-
- {step.title}
-
- {step.body}
-
- {step.mono}
-
-
- ))}
-
-
-
-
- Noise IK, end to end. The middle box forwards bytes it cannot read.
-
-
-
-
+ <>
+ Remote access
+
+ The daemon listens on loopback and nothing else. One command deploys a relay Worker and
+ this web app into your own Cloudflare account, on the free plan. Every machine you own
+ shares that relay.
+
+ {PATH}
+
+ Everything crossing it is end-to-end encrypted with Noise IK. The middle box forwards
+ bytes it cannot read. There is no flue account, no flue server and no billing. flue.sh
+ serves docs and downloads and is never part of the data path.
+
+
+ What it deploys, and what it costs
+ >
)
}
-/**
- * The dashed run between two boxes in the path. Horizontal from `lg`, where
- * the boxes sit in a row; vertical below it, where they stack.
- */
-function Connector() {
+/* ------------------------------------------------------------------------ */
+
+function Facts() {
return (
-
-
-
-
-
-
-
-
-
+ <>
+ Facts
+
+ >
)
}
-/* -------------------------------------------------------------- install --- */
+/* ------------------------------------------------------------------------ */
-/**
- * The closer, and the one place on the page a recording belongs.
- *
- * Every other window here is drawn: the fleet, the switcher, the relay
- * transcript. Drawings argue well and prove nothing, and the objection at the
- * moment somebody is about to pipe a script into a shell is not "how does it
- * work", it is "does this actually work". So the walkthrough sits between the
- * claim and the command, where that question is live, rather than in the hero
- * where nobody has asked it yet and nine minutes is a wall.
- */
function Install() {
return (
-
-
-
-
- Install flue on the machine that runs the work.
-
-
- flue enable installs a login service,
- starts the daemon and opens the UI. Everything after that happens in the browser.
-
-
- Setting up more than one machine?{' '}
-
- Read the setup guide
-
- .
-
- {/* Full width, the way the switcher above sits full width in a
- section that is otherwise columns. The caption is centred with
- the rest of this section rather than left aligned, since it is
- the only line here that would have read off axis.
-
- The id is the hero button's target, and scroll-mt clears the
- sticky header so the jump lands on the player rather than under
- the bar. */}
-
-
-
-
-
-
- Read the source
-
-
-
-
+ <>
+ Install
+
+
+ flue enable installs a login service, starts the daemon and opens the UI.
+ Everything after that happens in the browser. More than one machine: read the setup guide.
+ There is a recording of the whole setup (8:51) on that page.
+
+ Setup guide
+ Source
+ >
)
}
diff --git a/site/src/routes/shot.tsx b/site/src/routes/shot.tsx
index 53daa20..1b54329 100644
--- a/site/src/routes/shot.tsx
+++ b/site/src/routes/shot.tsx
@@ -1,24 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
-import { FleetWindow, PhoneFrame } from '@/components/mock/fleet'
+import { Mark } from '@/components/wordmark'
+import { INSTALL_CMD } from '@/lib/site'
/**
- * The scene the README's screenshot is taken from.
- *
- * A crop of the landing page makes a bad README image: it lands as a bare
- * rectangle with the page's own edges cut through it, and GitHub gives it no
- * frame of its own. This composes the shot deliberately instead, with the
- * padding, corner radius, glow and shadow that a product image wants, so what
- * gets captured is a finished picture rather than an excerpt.
+ * The scene og.png is captured from, at exactly 1200 by 630.
*
* It is a development tool, not a page: nothing links here, so the prerender
* never crawls it and it does not exist on the deployed site. To regenerate
- * the README's images, run `make site-dev`, open /shot in each theme, and
- * capture the #shot element into docs/hero-light.png and docs/hero-dark.png.
+ * the image, run `make site-dev`, open /shot, and capture the top-left
+ * 1200 by 630 of the viewport (2400 by 1260 at 2x, then halve it) into
+ * site/public/og.png.
*
- * The page background is left transparent and the capture is taken with
- * alpha, so the rounded corners are really rounded in the PNG rather than
- * filled with whatever this page happened to be sitting on.
+ * The page is the homepage's first screen, reduced to what a social card
+ * can carry: the meta line, the headline with its marker showing, and the
+ * install line. Dark only, because a card is not a page and does not get a
+ * theme toggle, so the colours are the dark theme's literals rather than
+ * tokens.
*/
export const Route = createFileRoute('/shot')({
head: () => ({
@@ -29,28 +27,34 @@ export const Route = createFileRoute('/shot')({
function Shot() {
return (
-
- {/* The document itself has to be transparent as well, or the capture's
- alpha is filled in by the body before it reaches the corners. */}
-
-
- {/* The ground the scene sits on: the page's own canvas, plus the
- scanline texture and the teal bloom from the hero. */}
-
-
-
+
+
+
+
+ flue
+
+ karnstack · 2026 · MIT
+
-
+
+
+
+ #
+
+ Your Claude Code sessions and terminals, in one place
+
+
+
+ $
+
+ {INSTALL_CMD}
+
+
+
flue.sh
)
}
diff --git a/site/src/styles.css b/site/src/styles.css
index f48ce74..6192292 100644
--- a/site/src/styles.css
+++ b/site/src/styles.css
@@ -10,6 +10,12 @@
where the app has no toggle and `dark:` resolves through the media query. */
@custom-variant dark (&:where(.dark, .dark *));
+/*
+ * The page is set in mono, the way a README is. Inter is still here for one
+ * reason: the drawn mocks are pictures of the app, and the app is set in
+ * Inter. They opt in with `font-sans`; everything else inherits mono from
+ * .
+ */
@theme {
--font-sans: 'InterVariable', ui-sans-serif, system-ui, sans-serif;
--font-sans--font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11', 'ss03';
@@ -24,7 +30,6 @@
*/
:root {
color-scheme: light;
- --radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); /* zinc-950 */
@@ -81,10 +86,6 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
}
/* Tailwind v4 ships no default border colour; without this every bare
@@ -99,27 +100,6 @@
}
}
-/*
- * The hero's backdrop: horizontal rules at roughly terminal row pitch, masked
- * so they fade out before the copy below them. Decorative, and the only
- * texture on the page.
- */
-@utility backdrop-scan {
- --line: oklch(0.141 0.005 285.823 / 5%);
- background-image: repeating-linear-gradient(
- to bottom,
- var(--line) 0px,
- var(--line) 1px,
- transparent 1px,
- transparent 5px
- );
- mask-image: radial-gradient(ellipse 100% 65% at 50% 0%, black, transparent 80%);
-
- @variant dark {
- --line: oklch(1 0 0 / 6%);
- }
-}
-
/* The blinking block cursor used in every drawn terminal. */
@utility term-cursor {
display: inline-block;