diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg new file mode 100644 index 0000000..f4fbc38 --- /dev/null +++ b/docs/assets/favicon.svg @@ -0,0 +1 @@ +torrnado diff --git a/docs/assets/tui.png b/docs/assets/tui.png new file mode 100644 index 0000000..171d58f Binary files /dev/null and b/docs/assets/tui.png differ diff --git a/docs/guide/tui.md b/docs/guide/tui.md index 5f6b0ae..5792dd6 100644 --- a/docs/guide/tui.md +++ b/docs/guide/tui.md @@ -9,10 +9,10 @@ Three panes plus a status line. The focused pane is the one with the highlighted border, and it's where `j`/`k` go: ``` -┌ sidebar ─┐┌ torrent list ─────────────────────────────────┐ -│ torrnado ││ Name Size Status ↓ Speed ETA │ -│ ││> ubuntu-24.04.iso 5.9GiB downl… ↓ 21M/s 3m56│ -│ Status ││ ━━━━━━━━━━━─────────── │ +┌ sidebar ─┐┌ torrent list ──────────────────────────────────┐ +│ torrnado ││ Name Progress Size Status ETA │ +│ ││> ubuntu-24.04… ━━━━━──── 62% 5.9GiB downl… 3m56│ +│ Status ││ │ │ All ││ │ │ Downl… │└────────────────────────────────────────────────┘ │ Seeding │┌ detail ────────────────────────────────────────┐ @@ -24,8 +24,10 @@ highlighted border, and it's where `j`/`k` go: - **Sidebar** filters the list by status. It intersects with `/` search rather than replacing it. -- **List** shows one torrent per two lines: the data columns, and a thin - progress underline beneath the name (absent once complete). +- **List** shows one torrent per line. Progress is a column - a bar + followed by its percentage - rather than an underline beneath the name. + A wide enough pane also shows size, status, both speeds and the ETA; a + narrow one drops those and keeps the name and progress. - **Detail pane** always tracks the cursor torrent - there is no separate full-screen detail view. Its three tabs are the piece completion map, the connected-peer table, and the file list. @@ -58,7 +60,8 @@ mode, just the movement/action idioms. | `q` | quit the TUI (the daemon keeps running) | With the detail pane focused on its Files tab, `j`/`k` move between files -and `+`/`-` raise/lower the selected file's priority. On the other tabs +and `+`/`-` raise/lower the selected file's priority (`=` and `_` do the +same, so neither needs shift). On the other tabs `j`/`k` scroll. Actions (`p`, `r`, `x`, `D`, `:`) work from any pane and always apply to the list's selection or cursor row. @@ -69,7 +72,7 @@ always apply to the list's selection or cursor row. | Command | Effect | |-------------------------------------------------------|--------------------------------------------| | `:add ...` | add one or more torrents (see Batch add) | -| `:remove` / `:remove!` | remove (without/with data); acts on the selection, or the cursor row | +| `:remove` / `:remove!` (`:rm` / `:rm!`) | remove (without/with data); acts on the selection, or the cursor row | | `:purge` | delete the data, keep the torrent; selection or cursor row | | `:pause` / `:resume` | absolute pause/resume; selection or cursor row | | `:recheck` | force recheck on selection or cursor row | @@ -79,6 +82,12 @@ always apply to the list's selection or cursor row. | `:theme [name]` | open the theme picker, or switch straight to a named theme | | `:q` / `:quit` | quit the TUI | +Arguments may be quoted with `'` or `"`, which is what makes an argument +containing a space possible (`:move '/media/big disk'`). Quoting a magnet +is unnecessary here - the palette is not a shell, so nothing expands - +but harmless, which matters because quoting one *is* necessary in zsh and +the habit follows you into the palette. + ### Batch add `:add` (and `torrnado add` on the CLI) accepts any mix of: @@ -102,32 +111,3 @@ torrnado add ~/torrents/*.torrent torrnado add ~/torrents/ # every .torrent file in the directory torrnado add magnets.txt # one magnet uri per line ``` - -## Command palette - -`:`-prefixed, vim ex-mode style: - -| Command | Effect | -|-------------------------------------------------------|--------------------------------------------| -| `:add ...` | add one or more torrents (see Batch add) | -| `:remove` / `:remove!` | remove (without/with data); acts on the selection, or the cursor row | -| `:purge` | delete the data, keep the torrent; selection or cursor row | -| `:pause` / `:resume` | absolute pause/resume; selection or cursor row | -| `:recheck` | force recheck on selection or cursor row | -| `:limit-up ` / `:limit-down ` | set the *global* rate limit (`500k`, `2M`, `unlimited`) | -| `:move ` | move the cursor row's data to a new directory | -| `:sort name\|size\|progress\|ratio\|eta\|added\|down\|up [desc]` | change list sort order | -| `:theme [name]` | open the theme picker, or switch straight to a named theme | -| `:q` / `:quit` | quit the TUI | - -Arguments may be quoted with `'` or `"`, which is what makes an argument -containing a space possible (`:move '/media/big disk'`). Quoting a magnet -is unnecessary here - the palette is not a shell, so nothing expands - -but harmless, which matters because quoting one *is* necessary in zsh and -the habit follows you into the palette. - -Arguments may be quoted with `'` or `"`, which is what makes an argument -containing a space possible (`:move '/media/big disk'`). Quoting a magnet -is unnecessary here - the palette is not a shell, so nothing expands - -but harmless, which matters because quoting one *is* necessary in zsh and -the habit follows you into the palette. diff --git a/docs/index.md b/docs/index.md index 5fc60d3..20aee98 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,22 +1,39 @@ -# torrnado - -A terminal BitTorrent client: a vim-like TUI on top of a torrent engine -that runs as a background daemon. - -``` -┌──────────────────┐┌────────────────────────────────────────────────────────────────────┐ -│ torrnado ││ Name Progress Size Status │ -│ ││ > archlinux-2026.08.01… ━━━━━━━────── 42% 1.5GiB downloading │ -│ Status ││ Fedora-KDE-Desktop-L… ━━━━━━━━━━━━ 100% 3.1GiB seeding │ -│ All ││ * ubuntu-26.04-desktop… ━━────────── 11% 6.1GiB paused │ -│ Downloading ││ │ -│ Seeding │└────────────────────────────────────────────────────────────────────┘ -│ Completed │┌────────────────────────────────────────────────────────────────────┐ -│ Stopped ││ ─ [Pieces] Peers Files │ -│ ││ 1950/24208 pieces verified × 256.0KiB │ -└──────────────────┘└────────────────────────────────────────────────────────────────────┘ - ↓ 21.4MiB/s ↑ 0B/s │ 3 torrents added 1 torrent(s) -``` +--- +hide: + - navigation + - toc +--- + +
+ +

torrnado

+ +

+A terminal BitTorrent client with a vim-like TUI.
+The engine runs as a daemon, so closing the terminal doesn't stop the download. +

+ +
+[get started](getting-started/installation.md){ .tn-btn .tn-btn--primary } +[view on github](https://github.com/lestex/torrnado){ .tn-btn .tn-btn--ghost } +
+ +
+ +
+The torrnado TUI: a sidebar filtering by status, four torrents listed with
+          progress, size, state and speed, one downloading at 19% and 35.5MiB/s, and
+          a docked Pieces tab below showing the verified piece map. +
+ +
+ macOS + Linux + MIT License + Go 1.25+ + v0.1.0 +
## The idea @@ -29,6 +46,89 @@ the CLI are both thin clients that talk to the engine over a local Unix socket, so neither of them owns the torrents; quitting either one is not an event the daemon notices. +

// features

+ +
+ +
+### runs detached +One daemon; the TUI and the CLI attach and detach freely. Quitting a +client is not something the engine notices. +
+ +
+### survives restarts +The torrent list, paused state, save paths, rate limits and per-file +priorities are written to disk and restored on start. +
+ +
+### streams while downloading +Press ++v++ on a video and it opens in your player at once, seeking +included - the read position drives which pieces are fetched. +
+ +
+### three panes, vim keys +A status sidebar, the torrent list and a docked Pieces/Peers/Files pane. +++colon++ opens a command palette; ++h++ lists every key. +
+ +
+### fully scriptable +Every action is a subcommand, so `torrnado add`, `torrnado list` and +friends work in a shell script or a cron job. +
+ +
+### waits for your VPN +Optionally holds every transfer until the system's traffic leaves through +a tunnel, and lets them go again when it reconnects. +
+ +
+ +

$ install torrnado

+ +
+ +=== "Released binary" + + ```sh + tar xzf torrnado_0.1.0_linux_amd64.tar.gz + ./torrnado version + ``` + + Archives for Linux and macOS on both architectures, plus + `checksums.txt`, are on the [releases + page](https://github.com/lestex/torrnado/releases). + +=== "From source" + + ```sh + go build -o torrnado ./cmd/torrnado + ``` + + Requires Go 1.25+. `make build` instead stamps the version, commit and + date in, so `torrnado version` says more than "dev". + +=== "Docker" + + ```sh + docker build -t torrnado . + docker run --rm -v "$PWD/downloads:/downloads" torrnado version + ``` + + For leaving it running on a box - see [Docker](server/docker.md). + +
+ +

+then run `torrnado` to start - [quick start →](getting-started/quick-start.md) +

+ +## Where to go next +
- __Start here__ @@ -67,26 +167,8 @@ an event the daemon notices.
-## What it does - -- **Runs detached.** One daemon; the TUI and CLI attach and detach freely. -- **Survives restarts.** The torrent list, paused state, save paths, rate - limits and per-file priorities are written to disk and restored. -- **Streams while downloading.** Press ++v++ on a video and it opens in - your player at once, seeking included - the read position drives which - pieces are fetched. -- **Scripts.** Every action is a subcommand, so `torrnado add`, - `torrnado list` and friends work in a shell script or a cron job. -- **Stays out of the way.** Vim-like keys, no mouse, no configuration - required to start. - ## What it does not do No remote control protocol, no web UI, no Windows. The socket is local by construction and SSH already solves the remote problem properly - see [non-goals](reference/caveats.md) for the full list and the reasoning. - -## Requirements - -Go 1.25+ to build, a POSIX system to run (Linux and macOS are tested), and -a media player if you want the streaming preview. diff --git a/docs/reference/development.md b/docs/reference/development.md index c93d205..8c5af1b 100644 --- a/docs/reference/development.md +++ b/docs/reference/development.md @@ -120,8 +120,17 @@ The tag is the version; nothing is committed anywhere to forget to bump. make check && make e2e make changelog TAG=v0.1.0 # files the pending commits under that version git commit -am "chore: changelog for v0.1.0" + +# main is protected: the changelog commit lands through a pull request. +git checkout -b release-v0.1.0 && git push -u origin release-v0.1.0 +gh pr create --base main --fill && gh pr merge --merge + +# Tag the commit that actually landed, not the local one you wrote - +# otherwise the archives and `torrnado version` name a commit that is not +# on the branch. Tags are not protected and push directly. +git checkout main && git pull git tag -a v0.1.0 -m "v0.1.0" -git push --follow-tags +git push origin v0.1.0 ``` The tag triggers `.github/workflows/release.yml`: it reruns `make check` diff --git a/docs/stylesheets/torrnado.css b/docs/stylesheets/torrnado.css new file mode 100644 index 0000000..0e44bd9 --- /dev/null +++ b/docs/stylesheets/torrnado.css @@ -0,0 +1,594 @@ +/* torrnado docs theme. + * + * Two jobs: repaint Material in the TUI's own palette, and provide the + * landing-page components (hero, terminal frame, stat bar, feature grid). + * + * The colors are dracula, which is what `theme` defaults to in + * internal/config, so the site and the screenshot on it are the same + * scheme rather than two that merely coexist. They are copied from + * internal/theme/builtin.go rather than sampled off the screenshot: a + * capture carries the display's color profile, and every value read back + * a few points adrift. + * + * Changing the site's colors means editing the two blocks below and + * nothing else - every rule in this file derives from these tokens. + */ + +/* ---------------------------------------------------------------- palette */ + +[data-md-color-scheme="slate"] { + /* dracula, the TUI's default theme, taken from internal/theme/builtin.go + * rather than sampled off a screenshot - a capture carries the display + * profile with it, and every value came out a few points adrift. */ + --tn-canvas: #282a36; /* Background */ + --tn-canvas-subtle: #31333f; /* between Background and Border */ + --tn-canvas-inset: #21222c; + --tn-border: #44475a; /* Border / SelectedBg */ + --tn-border-muted: #383a4a; + --tn-fg: #f8f8f2; /* Foreground */ + /* dracula's own Muted is #6272a4, which scores 3.03 against the + * background: fine for short terminal labels, not for paragraphs of + * muted prose. Lightened until it passes at 4.72. */ + --tn-fg-muted: #8b93b8; + --tn-accent: #bd93f9; /* Accent */ + --tn-success: #50fa7b; /* Success */ + --tn-warning: #f1fa8c; /* Warning */ + --tn-btn: #bd93f9; + --tn-btn-hover: #caa9fa; + /* The accent is light, so the button takes dark text rather than white. */ + --tn-btn-fg: #282a36; + + --md-primary-fg-color: var(--tn-canvas-subtle); + --md-primary-bg-color: var(--tn-fg); + --md-accent-fg-color: var(--tn-accent); + + --md-typeset-a-color: var(--tn-accent); + --md-code-bg-color: var(--tn-canvas-subtle); + --md-code-fg-color: var(--tn-fg); + --md-footer-bg-color: var(--tn-canvas-inset); + --md-footer-bg-color--dark: var(--tn-canvas-inset); +} + +[data-md-color-scheme="default"] { + /* dracula has no light counterpart, so this keeps its purple and puts it + * on a neutral ground, darkened until it carries body text on white. */ + --tn-canvas: #ffffff; + --tn-canvas-subtle: #f6f6fb; + --tn-canvas-inset: #f6f6fb; + --tn-border: #dcdce7; + --tn-border-muted: #e6e6ef; + --tn-fg: #282a36; + --tn-fg-muted: #5a5f7a; + --tn-accent: #6f42c1; + --tn-success: #1a7f37; + --tn-warning: #9a6700; + --tn-btn: #7c3aed; + --tn-btn-hover: #6d28d9; + --tn-btn-fg: #ffffff; + + --md-default-bg-color: var(--tn-canvas); + --md-default-fg-color: var(--tn-fg); + --md-default-fg-color--light: var(--tn-fg-muted); + + /* Material paints the header background from --md-primary-fg-color and + * its text from --md-primary-bg-color. The names read backwards; get + * them the wrong way round in the light scheme and you get white text + * on a white header, which is invisible rather than merely ugly. */ + --md-primary-fg-color: var(--tn-canvas-subtle); + --md-primary-bg-color: var(--tn-fg); + --md-accent-fg-color: var(--tn-accent); + + --md-typeset-a-color: var(--tn-accent); + --md-code-bg-color: var(--tn-canvas-subtle); + + /* Material's footer is dark in both schemes by default, and only the + * slate block below was overriding it, so the light site ended on a + * near-black slab. Given the same light surface as the rest of the + * page, with the hairline above carrying the edge: 13.22 for the text, + * 5.81 for the muted line and 6.04 for the links. */ + --md-footer-bg-color: var(--tn-canvas-subtle); + --md-footer-bg-color--dark: var(--tn-canvas-subtle); + --md-footer-fg-color: var(--tn-fg); + --md-footer-fg-color--light: var(--tn-fg-muted); + --md-footer-fg-color--lighter: var(--tn-fg-muted); +} + +/* Material draws the header from the primary color; GitHub's is a flat + * dark bar with a hairline rather than a saturated block. */ +.md-header { + background-color: var(--tn-canvas-subtle); + border-bottom: 1px solid var(--tn-border); + box-shadow: none; +} + +/* The footer sits at 1.109 contrast against the page: a real difference + * and far too small to read as an edge, so the two ran together. A + * hairline in the border color separates them, and mirrors the rule under + * the header so the page is bounded the same way top and bottom. */ +.md-footer-meta { + border-top: 1px solid var(--tn-border); +} + +.md-tabs { + background-color: var(--tn-canvas-subtle); + border-bottom: 1px solid var(--tn-border); +} + +/* Material spaces the header generously: 1rem between the logo and the + * title, and 1.4rem between the search field and the repo block. With the + * repo block now only as wide as its contents, both read as drift rather + * than rhythm. + * + * The logo gap took three passes because three separate things sat in it, + * not one: the button's own 9.6px of right padding, the title's margin, + * and dead space inside the SVG. Measuring the button's box rather than + * the drawn ink hid two of them and reported 19px for a gap that was + * really 36. Trimming the SVG's viewBox fixed the first, and these two + * rules the rest, ending at 20px from ink to text. + * + * Every selector here carries the direction attribute because Material's + * own do, and a bare class loses to them: written without it, the rules + * applied to the right elements and changed nothing. The source margin is + * also set twice in Material, at two breakpoints; one rule outside any + * media query beats both on source order. */ +[dir="ltr"] .md-header__button.md-logo { + padding-right: 0.2rem; +} + +[dir="ltr"] .md-header__title { + margin-left: 0.3rem; +} + +/* The three gaps along the right of the header measured 14px from the + * palette toggle to the search field and 34px from the field to the + * GitHub mark. Both are now 19px, measured glyph to glyph rather than box + * to box: each control carries its own padding, so the boxes touch while + * the marks inside them sit far apart. */ +[dir="ltr"] .md-header__option { + margin-right: 0.2rem; +} + +[dir="ltr"] .md-header__source { + margin-left: 0.2rem; +} + +/* Material puts two titles in the header and cross-fades them on scroll: + * the site name until you pass the first heading, the current page's + * title after. So "torrnado" became "Home" a few lines down, and the + * brand vanished from every page the moment anyone read it. + * + * The page title is already named in the tab, the breadcrumb and the + * heading itself, so the header keeps the site name at all times: the + * second topic never appears, and the first is exempted from the fade + * that the active state would otherwise apply to it. */ +.md-header__topic + .md-header__topic { + display: none; +} + +/* The name is set in the code face wherever it appears as a wordmark, in + * the header and in the hero, because the thing it names is a terminal + * program. Inter is left to the prose. */ +.md-header__topic:first-child .md-ellipsis { + font-family: var(--md-code-font-family, monospace); +} + +.md-header__title--active .md-header__topic:first-child { + opacity: 1; + transform: none; + pointer-events: auto; + z-index: 0; +} + +/* Material gives the header's repo block a fixed 11.7rem, whatever is in + * it. "lestex/torrnado" measures 169px, so about 112px of the block was + * empty and the name sat that far short of the header's right edge - + * left-aligned inside a box wider than its contents, which reads as the + * whole thing being misplaced rather than merely padded. + * + * Sized to its contents instead, so it ends where the name ends and a + * longer or shorter repo name simply moves the left edge. max-width goes + * with it, or the fixed value would still cap and ellipsise the name. */ +.md-header__source { + width: auto; + max-width: none; +} + +/* The name inside is capped at 100% of that block, which once the block + * sizes to its contents is circular: the box shrinks to the text, then + * clips the text to the box, and "lestex/torrnado" came out ellipsised. + * The block is only rendered above Material's tablet breakpoint, so there + * is no narrow-screen case for the cap to protect. */ +.md-source__repository { + max-width: none; +} + +[data-md-color-scheme="slate"] .md-typeset code, +[data-md-color-scheme="default"] .md-typeset code { + border: 1px solid var(--tn-border-muted); + border-radius: 6px; +} + +.md-typeset pre > code { + border: 1px solid var(--tn-border); +} + +.md-typeset table:not([class]) th { + background-color: var(--tn-canvas-subtle); +} + +/* Links in the footer copyright inherit the surrounding color, so the + * author link was the same muted gray as the text around it and looked + * like prose. Given the accent, which reads as a link and clears contrast + * against the footer at 6.55. + * + * The selector is this long on purpose: Material forces footer links to + * the muted color with `html .md-footer-meta.md-typeset a`, deliberately + * specific, and a plain `.md-copyright a` loses to it and silently does + * nothing. */ +html .md-footer-meta.md-typeset .md-copyright a { + color: var(--tn-accent); +} + +/* No underline on hover: the accent already marks these as links, and an + * underline appearing under one of three links in a single sentence is + * more movement than the footer wants. Focus keeps a visible ring from + * the browser default, so keyboard users are not left without one. */ +html .md-footer-meta.md-typeset .md-copyright a:hover, +html .md-footer-meta.md-typeset .md-copyright a:focus { + color: var(--tn-accent); + text-decoration: none; +} + +/* Material sizes the footer's social icons at 0.8rem inside a 1.6rem hit + * area, which leaves the GitHub mark noticeably smaller than the text it + * sits beside. The link keeps its size so the target stays comfortable; + * only the glyph grows. */ +.md-social__link { + height: 1.8rem; + width: 1.8rem; +} + +.md-social__link svg { + max-height: 1.1rem; +} + +/* --------------------------------------------------------------- landing */ + +/* The landing page hides both sidebars, which otherwise leave it wider + * than every other page and visibly off-center: hiding only the table of + * contents collapses the right column while the nav keeps the left, + * pushing the content 138px right of center and stretching it from 883px + * to 1174px. + * + * With both gone the content is free to fill the grid, so it is pinned + * back to the measure the other pages use. That figure is not a guess: + * Material's grid is 61rem and each sidebar 12.1rem, so an interior + * page's content column is exactly 61 - 12.1 - 12.1 rem. Expressed in + * rem it tracks the root font size, which Material scales. + * + * Selected via :has() because Material offers no per-page hook, and the + * hero is the thing that makes this page different. Browsers without + * :has() fall back to a full-width landing page, which is untidy rather + * than broken. + */ +.md-content:has(.tn-hero) { + max-width: 36.8rem; + margin: 0 auto; +} + +/* The edit pencil and the one-item "Home" breadcrumb sit in a row of + * their own above the hero, which reads as a stray line of furniture + * rather than navigation. Nobody edits the landing page from a link on + * the landing page. */ +.md-content:has(.tn-hero) .md-content__button { + display: none; +} + +/* The hero and everything under it stay inside Material's normal content + * column - the width does not change on this page, only the styling. */ + +.tn-hero { + text-align: center; + padding: 3.2rem 0 1.6rem; + position: relative; +} + +/* The glow behind the wordmark, in the accent. Sits behind content and + * never intercepts a click. */ +.tn-hero::before { + content: ""; + position: absolute; + left: 50%; + top: 0; + /* Never wider than the hero. It used to be 130%, which on a narrow + * screen reached past the viewport and scrolled the landing page + * sideways; clipping that overflow only traded the scroll for a hard + * vertical edge where the gradient was cut. Sized to fit instead, so + * the gradient reaches transparent on its own and there is no edge to + * see. */ + width: min(44rem, 100%); + height: 22rem; + transform: translateX(-50%); + background: radial-gradient( + ellipse at center, + rgba(189, 147, 249, 0.18) 0%, + rgba(189, 147, 249, 0.06) 40%, + transparent 70% + ); + pointer-events: none; + z-index: 0; +} + +[data-md-color-scheme="default"] .tn-hero::before { + background: radial-gradient( + ellipse at center, + rgba(111, 66, 193, 0.10) 0%, + transparent 70% + ); +} + +.tn-hero > * { + position: relative; + z-index: 1; +} + +/* The wordmark is the page title, so Material hangs a permalink pilcrow + * off it - a stray para-mark in the middle of a hero. */ +.md-typeset .tn-hero__title .headerlink { + display: none; +} + +.md-typeset .tn-hero__title { + font-family: var(--md-code-font-family, monospace); + font-size: 3.6rem; + line-height: 1.1; + font-weight: 700; + /* Monospace carries its own spacing, and the -0.04em that kept Inter + * from looking loose at this size pulls a mono face into a huddle. + * Slightly negative rather than tight, and a step smaller, because a + * fixed-pitch face sets wider at the same nominal size. */ + letter-spacing: -0.02em; + margin: 0 0 0.6rem; + background: linear-gradient(180deg, var(--tn-fg) 30%, var(--tn-fg-muted) 130%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.md-typeset .tn-hero__tagline { + font-size: 0.92rem; + line-height: 1.7; + color: var(--tn-fg-muted); + margin: 0 auto 1.6rem; + max-width: 34rem; +} + +/* CTAs ------------------------------------------------------------------ */ + +.tn-cta { + display: flex; + gap: 0.6rem; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 2.4rem; +} + +.md-typeset .tn-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 1.1rem; + border-radius: 6px; + font-size: 0.72rem; + font-weight: 600; + text-decoration: none; + border: 1px solid transparent; + transition: background-color 120ms, border-color 120ms; +} + +.md-typeset .tn-btn--primary { + background-color: var(--tn-btn); + border-color: rgba(0, 0, 0, 0.15); + color: var(--tn-btn-fg); +} + +.md-typeset .tn-btn--primary:hover { + background-color: var(--tn-btn-hover); + color: var(--tn-btn-fg); +} + +.md-typeset .tn-btn--ghost { + background-color: var(--tn-canvas-subtle); + border-color: var(--tn-border); + color: var(--tn-fg); +} + +.md-typeset .tn-btn--ghost:hover { + background-color: var(--tn-border-muted); + border-color: var(--tn-fg-muted); + color: var(--tn-fg); +} + +/* Terminal frame -------------------------------------------------------- */ + +/* Wider than the text column it sits in. The prose stays at the measure + * every other page uses; only the screenshot breaks out, which also buys + * back some scale - the capture goes from a 3.6x downscale to about + * 2.8x, so the TUI text is that much less of a smudge. + * + * Centered by margin arithmetic rather than a half-width translate: a + * transform moves where the box is painted but not where it is laid out, + * so the layout box kept sticking out to the right and the landing page + * scrolled sideways on a phone while looking perfectly centered. Here the + * left margin is half the container minus half the element, which is the + * same result with no phantom overflow. + * + * The min() keeps it inside the viewport on a narrow screen, where 44rem + * would be wider than the phone. + * + * No title bar: traffic lights and a title would be decoration imitating + * a window this screenshot was not taken in. + */ +.tn-terminal { + width: min(44rem, 92vw); + margin: 0 0 2.4rem calc(50% - min(22rem, 46vw)); + border: 1px solid var(--tn-border); + border-radius: 8px; + overflow: hidden; + background-color: var(--tn-canvas-inset); +} + +/* A real screenshot of the running TUI rather than ASCII art. Box-drawing + * glyphs never rendered as clean rules here: the character advance works + * out fractional, so every column starts mid-pixel and the vertical + * borders rasterize unevenly row to row. + * + * width and height are also set on the element itself, so the frame + * reserves the right space before the image loads and nothing below it + * jumps. */ +.tn-terminal img { + display: block; + width: 100%; + height: auto; +} + +/* Stat bar -------------------------------------------------------------- */ + +.tn-stats { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem 1.4rem; + padding: 0.9rem 1rem; + margin: 0 0 3.2rem; + border-top: 1px solid var(--tn-border); + border-bottom: 1px solid var(--tn-border); + font-size: 0.68rem; + color: var(--tn-fg-muted); +} + +/* Separators are drawn rather than typed, so adding or removing an item + * cannot leave a dangling dot. */ +.tn-stats > span:not(:last-child)::after { + content: "·"; + margin-left: 1.4rem; + color: var(--tn-border); +} + +.tn-stats b { + color: var(--tn-fg); + font-weight: 600; +} + +/* Section labels -------------------------------------------------------- */ + +.md-typeset .tn-label { + display: flex; + align-items: center; + gap: 0.8rem; + font-family: var(--md-code-font-family, monospace); + font-size: 0.7rem; + color: var(--tn-fg-muted); + margin: 0 0 1.4rem; + font-weight: 400; +} + +.md-typeset .tn-label::after { + content: ""; + flex: 1; + height: 1px; + background: var(--tn-border); +} + +/* Feature grid ---------------------------------------------------------- */ + +.tn-features { + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--tn-border); + border-radius: 8px; + overflow: hidden; + margin: 0 0 3.2rem; +} + +.tn-feature { + padding: 1.4rem 1.3rem; + border-right: 1px solid var(--tn-border); + border-bottom: 1px solid var(--tn-border); + transition: background-color 120ms; +} + +/* Rather than nth-child arithmetic that breaks whenever a card is added, + * strip the trailing edges: the right border on every third card, and the + * bottom border on the last row. */ +.tn-feature:nth-child(3n) { border-right: 0; } +.tn-feature:nth-last-child(-n + 3) { border-bottom: 0; } + +.tn-feature:hover { + background-color: var(--tn-canvas-subtle); +} + +.md-typeset .tn-feature h3 { + margin: 0 0 0.5rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--tn-fg); +} + +/* Cards are not link targets, and a hovering pilcrow in the middle of a + * grid reads as a rendering fault rather than an affordance. */ +.md-typeset .tn-feature h3 .headerlink { + display: none; +} + +.md-typeset .tn-feature h3::before { + content: ">"; + color: var(--tn-success); + margin-right: 0.4rem; + font-family: var(--md-code-font-family, monospace); +} + +.md-typeset .tn-feature p { + margin: 0; + font-size: 0.72rem; + line-height: 1.65; + color: var(--tn-fg-muted); +} + +/* Install block --------------------------------------------------------- */ + +.tn-install { + margin-bottom: 2rem; +} + +.tn-install .tabbed-set > label { + font-size: 0.7rem; + color: var(--tn-fg-muted); +} + +.tn-install .tabbed-set > input:checked + label { + color: var(--tn-fg); + border-color: var(--tn-success); +} + +.md-typeset .tn-after-install { + font-size: 0.72rem; + color: var(--tn-fg-muted); +} + +/* Responsive ------------------------------------------------------------ */ + +@media screen and (max-width: 76.1875em) { + .tn-features { grid-template-columns: repeat(2, 1fr); } + .tn-feature:nth-child(3n) { border-right: 1px solid var(--tn-border); } + .tn-feature:nth-child(2n) { border-right: 0; } + .tn-feature:nth-last-child(-n + 3) { border-bottom: 1px solid var(--tn-border); } + .tn-feature:nth-last-child(-n + 2) { border-bottom: 0; } +} + +@media screen and (max-width: 44.9375em) { + .md-typeset .tn-hero__title { font-size: 2.8rem; } + .tn-features { grid-template-columns: 1fr; } + .tn-feature { border-right: 0 !important; border-bottom: 1px solid var(--tn-border) !important; } + .tn-feature:last-child { border-bottom: 0 !important; } +} diff --git a/internal/branding/logo.go b/internal/branding/logo.go new file mode 100644 index 0000000..9026b6e --- /dev/null +++ b/internal/branding/logo.go @@ -0,0 +1,63 @@ +// Package branding holds torrnado's mark in the forms a terminal can +// draw it. +// +// The mark is a spiral: a vortex seen from above, for a client named +// after a storm. It exists in three renderings that are meant to stay the +// same shape rather than three separate designs. The vector original is +// overrides/.icons/torrnado/logo.svg, a real Archimedean spiral that +// strokes currentColor so it takes the color of whatever draws it; +// docs/assets/favicon.svg is the same path with the accent baked in, +// because a browser tab has no page for currentColor to inherit from. +// The constants here approximate that curve in character cells. +// +// This is its own package rather than a corner of internal/tui because +// the TUI is not the only thing that will want it: a web or desktop front +// end reaches for the SVG, and anything that prints a banner reaches for +// these. Neither should have to import a bubbletea model to find out what +// the logo is. +package branding + +import "strings" + +// Logo is the mark at full size, five rows by seven columns. Sized for a +// screen with room to spare, such as a help overlay or a splash. +// +// Drawn with the rounded box-drawing set (U+256D..U+2570) because the +// square corners make the same path read as a maze rather than a curve. +// A terminal font without them degrades to missing glyphs rather than to +// something wrong, which is the failure worth having. +const Logo = "" + + "╭─────╮\n" + + "│ ╭─╮ │\n" + + "│ │ ╰─╯\n" + + "│ ╰───╯\n" + + "╰──────" + +// LogoSmall is the mark at four columns by three rows, for a sidebar or +// anywhere else a full-size one would crowd the content around it. +// +// The turn count drops from two to one: at this size a second turn is a +// solid block rather than a spiral, which is the same reason the SVG uses +// two turns and not the three that look best on paper. +const LogoSmall = "" + + "╭──╮\n" + + "│╭╯\n" + + "╰╯" + +// LogoLines splits a mark into rows, for a caller that lays out line by +// line rather than printing a block. +func LogoLines(logo string) []string { + return strings.Split(logo, "\n") +} + +// Width reports the widest row of a mark, in cells. Every glyph used is +// single width, so a count of runes is the printed width. +func Width(logo string) int { + w := 0 + for _, line := range LogoLines(logo) { + if n := len([]rune(line)); n > w { + w = n + } + } + return w +} diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index c89345a..2db6d12 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" + "github.com/lestex/torrnado/internal/branding" "github.com/lestex/torrnado/internal/engine" ) @@ -59,6 +60,17 @@ func (m Model) renderSidebar(p panes) string { w := p.sidebarContentW var lines []string + + // The mark above the name, in the title's own color so the two read as + // one block. Dropped entirely when the sidebar is too narrow to hold + // it, rather than truncated: half a spiral is not a smaller spiral, it + // is a broken box. + if w >= branding.Width(branding.LogoSmall) { + for _, row := range branding.LogoLines(branding.LogoSmall) { + lines = append(lines, m.styles.SidebarTitle.Render(row)) + } + } + lines = append(lines, m.styles.SidebarTitle.Render(truncate("torrnado", w)), "", diff --git a/mkdocs.yml b/mkdocs.yml index 4da2427..fc40fde 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,13 +4,25 @@ site_url: https://torrnado.dev repo_url: https://github.com/lestex/torrnado repo_name: lestex/torrnado edit_uri: edit/main/docs/ -copyright: MIT-licensed. Built with anacrolix/torrent and bubbletea. +# Year and holder match LICENSE, so the two cannot drift. +copyright: 'MIT-licensed. Built with anacrolix/torrent and bubbletea.
© 2026 @lestex' docs_dir: docs theme: name: material + # The spiral, drawn once and used everywhere: the header, the favicon, + # and internal/branding's character-cell version for the TUI. + # + # The header mark goes through icon rather than logo. `logo` emits an + # , and an SVG in an has no page to inherit from, so its + # stroke="currentColor" resolved to black and the mark disappeared into + # the dark header. `icon` inlines the file instead, so currentColor + # picks up the header's own text color in both schemes. + custom_dir: overrides + favicon: assets/favicon.svg icon: + logo: torrnado/logo repo: fontawesome/brands/github features: - navigation.tabs @@ -25,18 +37,21 @@ theme: - toc.follow palette: # The terminal is usually dark, so the site starts that way and can - # be switched. + # be switched. Both schemes are `custom`, which tells Material to take + # its colors from the CSS variables docs/stylesheets/torrnado.css + # sets rather than from a named palette - that file carries GitHub's + # Primer tokens, which no built-in palette matches. - media: "(prefers-color-scheme: dark)" scheme: slate - primary: deep purple - accent: deep purple + primary: custom + accent: custom toggle: icon: material/weather-sunny name: Switch to light mode - media: "(prefers-color-scheme: light)" scheme: default - primary: deep purple - accent: deep purple + primary: custom + accent: custom toggle: icon: material/weather-night name: Switch to dark mode @@ -62,7 +77,13 @@ markdown_extensions: - pymdownx.tabbed: alternate_style: true +extra_css: + - stylesheets/torrnado.css + extra: + # Drops "Made with Material for MkDocs" from the footer. A supported + # switch in the theme's own copyright partial, not a CSS workaround. + generator: false social: - icon: fontawesome/brands/github link: https://github.com/lestex/torrnado diff --git a/overrides/.icons/torrnado/logo.svg b/overrides/.icons/torrnado/logo.svg new file mode 100644 index 0000000..d05cd18 --- /dev/null +++ b/overrides/.icons/torrnado/logo.svg @@ -0,0 +1 @@ +torrnado