Under the hood

Design System

Flume’s interface is designed rather than assembled. This page is the rulebook for anyone adding UI: what the vocabulary is, where it lives, and which parts are not open to interpretation.

The single source is src/app/globals.css. Everything below is defined there.

The direction

Quiet precision. Warm-neutral surfaces — never blue-grey. One accent colour doing all the interactive work. Status colours reserved for status. Every number in tabular monospace. Generous hairlines instead of heavy borders.

New UI extends this vocabulary. It does not introduce a second one.

Colour

Tailwind utilities are named after the tokens, one for one: bg-bg-1, text-fg-2, border-line, bg-acc-deep. There are deliberately no friendlier aliases — two vocabularies is how --flume-line and “gray-800” end up meaning the same thing to different people.

Role Token Used for
Surfaces bg-0bg-3 ground, cards, inputs, hover
Lines line, line-2 row hairlines, control borders
Ink fg-0fg-3, fg-dis primary through 10px labels, then disabled
Accent acc, acc-dim, acc-deep the one interactive colour
Accent ink and hover on-acc, acc-hi label on an accent fill; hover step
Status ok, ok-deep, warn, err verdicts only, never a series colour
Chart series chart-down, chart-up throughput plots

Never introduce a colour that is not a token. If a step is genuinely missing, derive it in OKLCH at fixed chroma and hue — the palette was built that way and converted to sRGB, so interpolating between two existing values in sRGB gives the wrong answer.

Themes

Dark is the default. Light is a re-step of the same roles, not an inversion, which is why both palettes are written out in full rather than one computed from the other.

The theme swaps at runtime by flipping data-theme on <html>. "system" removes the attribute entirely so the prefers-color-scheme media query stays authoritative, and the app follows the OS live. The light palette is declared twice on purpose: an explicit choice has to beat the system preference, and one combined selector cannot express both without one overriding the other wrongly. src/app/tokens.test.ts asserts the two copies stay in sync.

Type

Two families, both vendored as woff2 under src/app/fonts/ — no CDN, no build-time fetch, and the app renders correctly with no network at all.

  • Instrument Sans for UI text. Variable weight axis, 400–700.
  • IBM Plex Mono for every number. Weights 400, 500, 600, latin only.

Base is 13px / 1.45. The rest of the ramp is measured against it, so changing it moves every screen.

Every number uses flume-num (mono plus tabular figures). This is not stylistic — without tabular figures, columns jitter on every 1 Hz tick as digit widths change.

Sizes and rates are decimal — GB, MB/s — because that is what disks and ISPs quote. Piece length is the only binary figure, rendered MiB, because that is what the wire format uses.

Controls

Token Value Used for
h-chip 28px chips, icon buttons
h-control 30px chrome buttons, inputs
h-primary 34px a sheet’s primary action
r-sm 4px chips, tags, small controls
r-md 6px buttons, inputs, nav items
r-lg 9px cards, panels

Do not round these to a framework scale. The spacing was chosen against the type ramp, and snapping it to a 4/8 grid visibly degrades the result.

These are pointer targets, not touch targets. A remote web UI would have to re-scale to a 44px minimum rather than ship desktop sizes to a phone.

Icons

Stroked SVG on a 16×16 grid, held at a constant 1.5px optical weight — Icon scales stroke-width by the grid-to-size ratio, so a 20px glyph sits beside a 14px one at the same weight rather than thickening as it shrinks.

No emoji, no icon font, and nothing filled. A solid glyph beside stroked ones reads as a different weight class, which is why even pause is drawn as two strokes.

Three glyphs — play, trash, settings — have no design and are marked [undesigned] in src/components/Icon.tsx. Treat them as provisional.

Accessibility

These were designed in and are easy to break by accident. src/app/tokens.test.ts enforces them.

  • fg-3 is the floor for text. fg-dis is for disabled controls only and must never carry text the user needs to read — the test asserts it stays below 4.5:1 so nobody “fixes” it into looking enabled.
  • line-2 clears 3:1, so control borders and unchecked checkboxes are actually visible. Do not lighten it.
  • Status is never colour alone. Every state carries a dot, a word, and a sentence. The pill is only the adjective; the sentence explaining what to do belongs to whatever the pill labels.
  • Download and upload are always labelled. The two chart series separate cleanly under normal, protan and deutan vision but converge under tritanopia, so colour alone can never carry the distinction.
  • Visible focus on every control. The :focus-visible rule is global.
  • A progress bar always ships with its number. At 5px tall a 3% fill and a 0% fill are the same two pixels, so ProgressBar renders the percentage itself rather than trusting call sites to remember.

Known contrast gaps

Recorded rather than silently corrected, because closing them would mean putting a colour in the app that is in no palette. Pinned in tokens.test.ts so they cannot get worse:

Theme Pair Measured Stated floor
dark fg-3 on bg-2 4.20:1 4.5:1
dark fg-3 on bg-3 3.73:1 4.5:1
dark line-2 on bg-2 2.82:1 3:1
dark line-2 on bg-3 2.50:1 3:1
light line-2 on bg-3 2.88:1 3:1
light warn as text 3.60:1 4.5:1
light ok as text 4.34:1 4.5:1

All of these hold on the ground (bg-0) and on cards (bg-1), which is where the design actually places small labels and control borders. They fall short only on the raised steps. Until they are resolved, do not put a 10px fg-3 label or a line-2 border on bg-2 or bg-3, and do not rely on warn or ok as text colour in the light theme.

Storybook

npm run storybook

Every primitive, in every state, in both themes, with axe running beside it. The theme toolbar flips data-theme exactly as the app does, so what you see is the real mechanism rather than a Storybook-only wrapper.

npm run storybook:build produces a static build. It is deliberately not part of npm run check — stories are already typechecked by tsc, and a full static build on every run costs more than the config churn it would catch.

First run

Shown when no settings file exists — the absence of the file is the signal, decided once at startup and never re-read. The screen writes settings as the user answers it, so a freshly-read value would flip to false halfway through and take the screen away mid-question.

Three questions: where downloads go, how much of the connection Flume may use, and light or dark. Everything on it is also in Settings; it exists for the handful of choices that are irritating to discover later, not as a second settings screen.

The import card

Flume detects Transmission, qBittorrent and Deluge by looking for their torrent stores under the user’s home directory, and reads each one’s download folder out of its config. A client is only offered if its store holds at least one .torrent — an installed but empty client has nothing to give, and listing it would advertise work that does not exist.

Importing adds every .torrent with librqbit’s overwrite, which is what makes “nothing is downloaded again” true rather than aspirational: the engine hashes what is already at that path and keeps every piece that verifies, so a torrent the other client had finished arrives complete and starts seeding.

Reading another client’s config is best-effort throughout. It is not an API — the file may be missing, half-written, or from a version that spelled the key differently — so every read degrades to “could not tell” rather than failing the scan. A client whose config is unreadable is still offered; its torrents just land in Flume’s own folder, and the card says so.

Client Format Wrinkle
Transmission JSON none
qBittorrent INI the key moved between versions; both spellings accepted
Deluge JSON two objects back to back — a version header, then the settings

Deluge’s defeats both from_str (trailing data) and “parse from the first brace” (that is the header), so its config is read as a stream of values — which also survives the header gaining fields.

What is not imported

Categories and seeding rules. The design asks for them, but Flume has no category model and no per-torrent rules, so there is nowhere to put them. They are not read at all, rather than read and dropped, and the card does not claim to bring them across.

Two of the design’s three questions also changed, for the same kind of reason: the measured-bandwidth caption would need a speed test, which is a surprising network call to make on first launch, and “which connection should torrent traffic use” needs interface binding, which is not a setting Flume has. The third question is light-or-dark instead.

The library window

The main screen is a two-column, two-row grid: a 248px rail beside 1fr, under a 44px title bar. The main column stacks a 56px toolbar, a 28px column header, the scrolling list, and a 116px dock.

Row columns, in order and at these exact widths:

Column Width
State 18
Name 1fr
Progress 180
Down 86
Up 86
Peers 78
Swarm health 124

Rows are 58px comfortable, 40px compact, 0 18px padding, 16px gap. Density is a data-density attribute on <html>, so one attribute re-lays the whole list rather than every row branching on a prop. Compact removes the meta line rather than shrinking it — at 40px there is no room, and a squeezed sentence is the first thing to become unreadable.

The one per-platform difference

The title bar reserves 88px at the left on macOS for the traffic lights, and 138px at the right on Windows and Linux. That inset is the only thing in the entire app that differs between platforms; everything inside the window is identical on all three.

The expanded row

Clicking a row opens a panel beneath it, indented past the status column so it reads as belonging to that row rather than floating between two. It holds five stats, three actions, the piece strip, the top contributors, and the note.

The note is the point. Every other thing in a row is a number; the note says what the numbers mean and what to do about them. It is derived in Rust (src-tauri/src/engine/note.rs) because the engine is the only thing that knows why a torrent is in the state it is in — a frontend rebuilding that reasoning from summary fields would drift from it. Severity is ok / warn / err / neutral, and neutral is a claim rather than an absence: a paused torrent needs to say that nothing is wrong, loudly enough that the user does not think something broke.

Detail is polled while a row is open, not pushed. It is per-torrent and several times the size of a summary; broadcasting it for every torrent every second so one expanded row can read it would be the wrong trade. At most one row is open, so this is one extra call per second in total, and none at all when the list is collapsed.

The piece strip answers “which parts do I have”, which overall progress cannot: 60% with a solid head and an empty tail is a torrent downloading in order, and 60% scattered evenly is one pulling rarest-first — they behave differently when the swarm thins. The engine downsamples to up to 1600 buckets for the inspector’s full-width map; the row’s 96-cell strip averages those down rather than asking for a second resolution.

Top contributors are ranked by bytes that passed verification, not by connection order. The design shows an instantaneous per-peer rate; librqbit’s per-peer counters are cumulative totals with no rate among them, so the column shows the total each peer has supplied — arguably the better answer to “who is contributing”, since it does not swing with the tick.

The inspector

Header 68px, a stat strip 88px, tabs 42px, then 1fr content beside a 352px rail. Four tabs: Overview, Files, Peers, Trackers. The overview holds a per-torrent throughput trace, the piece map, the note, and the torrent’s identity.

The torrent is re-read from telemetry on every tick while the inspector is open, so its numbers move rather than freezing at whatever the row held when it was clicked.

The bottleneck panel, and what is still absent

The design’s centrepiece — ranking every constraint on a download and marking exactly one as binding — is built. It ranks only what Flume can measure.

Factor Status
Peer upload Shown. Binding by elimination — see below.
Your download cap Shown. Rate against the configured cap, measured exactly.
Piece availability Shown. Not in the design’s list; the fork supplies it.
Connection slots Absent — peer_limit is unset, so there is no ceiling.
Disk writes Absent — librqbit exposes no write-queue depth.
Hash checking Absent — no CPU accounting.

“Peer upload is binding” is a deduction, not a guess. The rate a swarm will supply cannot be observed, but its complement can: if the configured cap is not saturated and no piece is missing, nothing on this machine is holding the transfer back, so the peers are. It ranks last for that reason — it is the residual, claimed only once the measurable constraints are ruled out.

A factor whose ceiling cannot be measured carries no bar and reads “Not measured”. An empty bar would say “plenty of headroom”, which is exactly the claim Flume cannot make.

Of the four smaller surfaces that leaned on availability, two shipped with the fork:

Surface Status
Seeds count in the swarm summary Built. Peers holding every piece.
Availability figure (“4.31×”) Built, always beside the rarest-piece count.
Availability histogram under the piece map Built. Peers holding each region, by minimum.
Trackers tab’s plain-English verdict Still blocked — needs per-tracker announce status.

The histogram under the piece map shares the completion strip’s bucketing, so a column in one describes the same pieces as the column above it, and it buckets by minimum rather than mean — a region averaging eight copies while holding one piece nobody has is exactly what a mean would hide. A region no peer holds draws the tallest bar, in the error colour, with the caption saying so: without the height cap on held regions it would be distinguished by colour alone whenever availability is flat.

Availability is never shown on its own. A mean of 4.0 reads reassuring and can still hide a piece nobody holds, so the rarest-piece count sits beside it and says so outright when it is zero.

The verdict is still withheld where the data runs out: no peer bitfields yet means unknown, rendered as “Connected”, not as a guess between thin and healthy. Issue #79 records the history and ikatson/rqbit#643 is the upstream ask that would let the fork be dropped.

Settings

The whole screen is generated from SETTING_DEFS in src/lib/settings/defs.ts. Hand-written rows kill search within a month: the field has to cover every label, key and description, and a screen assembled by hand grows rows the search does not know about. One table makes that impossible by construction, and a test asserts every field of Settings has a definition.

Every setting carries a consequence — a sentence computed from the current value, not static help text. That is the feature:

Value What the row says
speed.download = 5 MB/s “Held to 5.0 MB/s — a 4.70 GB ISO would take about 15 min 40 s.”
speed.download = unlimited “No cap. Downloads take whatever the connection will give them…”
net.dht = off “Magnet links will not work at all — they have no file list without the DHT.”

A setting without one does not ship, and a test checks that a consequence actually changes with its value rather than being static text in disguise.

Search covers labels, config keys, section names, hand-written synonyms, and the consequence sentences as they currently read. Someone who remembers only “magnet links will not work” can find the setting that said it. Results keep table order rather than reordering by relevance — a list that reshuffles as you type is a list you cannot aim at — and each carries a section crumb so a match found by search still says where it lives.

No OK, Cancel or Apply. Changes take effect as they are made and stack in the footer, each individually undoable. A settings screen with an Apply button asks the user to predict what a setting will do; one that applies immediately lets them see it and change their mind. A write the engine rejects puts the control back — leaving it showing a value that was not saved is the one outcome worse than the failure.

Settings that rebuild the librqbit session are marked as such on the row.

The add sheet

Adding a torrent is a review you read, not a modal you dismiss. That only works if the review has something to say, so the sheet answers the questions worth asking before tens of gigabytes start arriving.

Four pre-flight tiles. Three recalculate as files are toggled — bytes selected, the volume afterwards, and the estimated finish. The fourth reports how many peers answered while the file list was fetched, and does not change; re-measuring it on every checkbox click would be both wrong and pointless.

Every tile states its basis in the line underneath. A number whose derivation is invisible is a number the user has to take on trust, which is the thing a review sheet exists to avoid.

Already on disk. Files present at the right name and length are detected, tagged, and deselected by default, with the footer saying why. Re-select one and the footer switches to naming the bytes it would fetch again. Length is checked, not content — hashing 46 GB to answer a question asked before the download starts would take longer than the download, and every piece is verified on arrival anyway.

Tri-state folders. partial is what makes folder checkboxes usable: without a third mark, a folder with one file deselected looks identical to one with everything deselected. It is exposed as aria-checked="mixed", the real ARIA value. Clicking a partly-selected folder completes it rather than clearing — the other reading throws away the selection the user just built.

Files sort folders-first then numerically, so “part2” precedes “part10”. Torrents are full of numbered parts, and lexicographic order gets every one of them wrong.

Two things the design specifies that Flume reports differently, because librqbit does not expose the underlying data: the swarm tile shows peers seen rather than a seeds/leechers scrape, and the finish estimate uses this session’s average rather than a persisted seven-day one. Both say which they are using.

The dock chart

Sixty samples at 1 Hz, two series on one shared scale — a chart that gave upload its own axis would draw a trickle and a torrent at the same height.

The history lives in useThroughputHistory, not in the engine. It is presentation state: the chart is the only thing that wants it, and pushing sixty samples across IPC every tick to redraw a chart that already holds fifty-nine of them is per-tick waste for nothing. Samples are keyed on the session’s uptime, because two consecutive ticks can carry byte-identical rates and React can legitimately re-run an effect with the same snapshot.

The ceiling is the configured rate limit when there is one — the useful question then is how close you are to it, which a rescaling axis cannot answer. Otherwise it is 1, 2 or 5 times a power of ten above the busiest sample, with a floor of 1 MB/s so an idle session does not magnify background chatter into dramatic peaks.

Segments are smooth-stepped, flat out of one sample and flat into the next. A straight line between two readings claims the rate moved evenly between them, which is a measurement nobody took.

A partial window is right-aligned: ten seconds of history draws ten seconds of line at the right edge rather than stretching across the full width and implying a minute it does not have.

Swarm health

The column reports a verdict, not a peer count. What it can say today:

Verdict Means Shown as
seeding Complete and serving Seeding
none No reachable peer holds the remainder No seeds
idle Paused, checking or errored — not trying Idle
unknown No peer bitfields yet, so coverage is unknown Connected
healthy The swarm holds every piece, comfortably Healthy
thin Every piece is held, but only just Thin swarm

thin is the only one of the two that carries a tint: the download is viable but losing a peer could strand it. healthy says the same structural thing with room to spare and stays quiet.

unknown is not a plumbing gap — it means there was nothing to judge from, and the verdict is withheld rather than guessed. Never derive a swarm verdict from peer counts alone: counts give the mean copies per piece, and the verdict needs the minimum. See issue #79.

Components

src/components/ holds the shared primitives. Check whether one already exists, with its states defined, before building a new one.

Component Notes
Button primary / secondary / ghost / danger; control and dialog sizes
IconButton 28px, always has an accessible name
Icon 16 grid, stroked, constant optical weight
ProgressBar 5px, colour by state, always with its percentage
StatusPill dot plus word; tint reserved for states wanting attention
StatCard label above, mono value, caption below; dock and strip sizes

danger on Button has no design and is built from the system’s vocabulary. Treat it as provisional, like the three undesigned icons.