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-0 … bg-3 |
ground, cards, inputs, hover |
| Lines | line, line-2 |
row hairlines, control borders |
| Ink | fg-0 … fg-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-3is the floor for text.fg-disis 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-2clears 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-visiblerule 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
ProgressBarrenders 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.