diff --git a/README.md b/README.md index 4ac959d..4edf2e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

flue

-

Continue your Claude Code and Codex sessions on any screen.

+

Your Claude Code sessions and terminals, in one place.

CI status @@ -26,28 +26,29 @@

-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. - -A small Go daemon holds the shells and their scrollback, and a web app draws -them. Closing the tab does not kill a session. It only detaches it: the agent -keeps working, the build keeps running, the SSH session stays up, and -reattaching replays what you missed. - -- **Sessions outlive the tab.** Close it and the build keeps running. -- **One list, every machine.** Name, tag, pin, group and search the whole - fleet from one place. Hover a session to see what it is doing. -- **One keystroke to any of them.** `⌘K`, or `Ctrl+Shift+K` anywhere, opens - every session on every machine. The highlighted row shows its own last - fourteen lines, so you can see which one is the build. `Ctrl+Shift+1` to `9` - jumps to a pinned session. -- **Reachable from anything you own.** Pair a phone with a QR code, once for - the whole fleet. Two devices on one session mirror live, and the size follows - whichever view you are using. -- **No hosted service.** Remote access runs through a relay you deploy into - your own Cloudflare account, end-to-end encrypted, with the daemon's key - pinned at pairing. flue.sh is a landing page and is never part of the data - path. +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. + +- **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. One static Go binary. macOS, Linux, WSL. No Node, no Python, no toolchain. @@ -64,33 +65,21 @@ survive your last logout. If lingering cannot be turned on, which happens in some containers, `flue enable` warns you and names the command to run. Everything after that happens in the browser. -## Recommended setup +## More than one machine One relay, every machine joined to it, every device paired once. -1. Install flue and run `flue enable` on every machine that runs work: the - laptop, the desktop, the Pi, the VPS. +1. Install flue and run `flue enable` on every machine that runs work. 2. Run `flue relay setup` **once**, on one machine. Running it again does not - add a relay, it replaces the one you have: every machine then has to re-join - with the newly printed line, and every device has to pair again. + add a relay, it replaces the one you have, and every machine and device + has to join and pair again. 3. Run the `flue relay join` line it prints on every other machine. 4. Pair each phone or tablet once, from a QR code. That pairing covers the - whole fleet, so there is no second ceremony per machine. It is per browser, - so Safari and Chrome on one iPad pair separately. + whole fleet. It is per browser, so Safari and Chrome on one iPad pair + separately. -Long jobs belong on a machine that stays on. A sleeping laptop's sessions are -not lost, but nothing runs until it wakes. - -The full version is at [flue.sh/docs/setup](https://flue.sh/docs/setup). - -## Sessions outlive flue - -Every session runs in its own small holder process, not inside the daemon. -Updating flue, restarting it, even the daemon crashing outright: the shells -and agents keep running, and the next daemon picks them back up with their -scrollback where you left it. A machine reboot is the one thing that ends a -session, and even then flue brings it back with its history, a fresh shell, -and the command that resumes the agent conversation it was in. +The full version, with a recording of the whole thing, is at +[flue.sh/docs/setup](https://flue.sh/docs/setup). ## The CLI @@ -112,7 +101,7 @@ flue version # print the version (also --version, -v) ## Remote access -The daemon listens on loopback and nothing else, so reaching it from somewhere +The daemon listens on loopback and nothing else. Reaching it from somewhere else is opt-in and takes one command: ```sh @@ -120,17 +109,18 @@ flue relay setup # machine 1: pa flue relay join wss:// --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 ( + + ) +} + +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 ` + + + - + ) } 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 ( - +