Repository navigation
Design Tokens
What the desktop and Android clients agree on, and where each one keeps its copy.
Colour needs no spec: pstr_core::appearance resolves one Palette and both
clients read it, so a flavour is right in both places or wrong in both. The rest
of the token set has no such bridge — Compose cannot consume a Rust constant, and
type and shape do not survive a round trip through PaletteRecord — so this file
is the contract, and each side's copy is asserted against it by a test.
| Token | Owner | Android's copy | Pinned by |
|---|---|---|---|
| Colour roles | pstr_core::appearance::Palette |
ui/theme/Theme.kt schemeOf
|
SchemeRolesTest |
| Type ramp | pstr_app::theme::Role |
ui/theme/Type.kt |
TypeRampTest |
| Corner radius | pstr_app::theme::radius |
ui/theme/Shape.kt |
TypeRampTest |
| Gradient rule | Palette::resolve |
— (derived, see below) |
appearance.rs tests |
| Depth | pstr_app::theme::{tile_shadow, bar_shadow} |
Material elevation | — |
| Spacing | pstr_app::theme::space |
Compose dp literals |
— |
| Motion | pstr_app::theme::motion |
Compose defaults | — |
Nine rungs. The desktop names them; Android restates them because Material's own
scale is a different one (displayLarge is 57sp, which is a size for a lock
screen and not for anything in a library grid).
| Role | Size | What it is |
|---|---|---|
| Display | 26 | the name of the thing you are looking at, once per page |
| Title | 20 | an empty state, or a page heading with no display line |
| Heading | 18 | the brand in the nav bar; a dialog's heading |
| Section | 17 | a run of content under a rule |
| Subhead | 15 | a row that groups the rows under it — a season header |
| Body | 14 | everything that is prose |
| Label | 13 | the text on a small control |
| Caption | 12 | context rather than content: counts, sizes, errors under a field |
| Micro | 11 | the smallest thing that stays legible — a badge over artwork |
Weight and leading are each side's own. The desktop bundles Inter
(pstr-app/assets/fonts, OFL) and sets Display, Title, Heading and Section in
its semibold cut, which egui sees as a family of its own (theme::semibold);
everything below Section is the regular cut. Compose derives line height as
1.35 × size rather than naming it nine more times.
Icons on the desktop come from the Phosphor icon font (egui-phosphor,
regular weight), installed right behind Inter so an icon can sit inside an
ordinary label. Do not use Unicode symbols such as ⏭ or 🔊 as icons: they come
out of whichever fallback font has them, at that font's size and baseline.
Both clients point their framework's default styles at the ramp — egui's five
TextStyles, Compose's fifteen — so a stock widget nobody restyled still lands
on it. Material 1.4 additionally carries an Emphasized copy of all fifteen
styles for its expressive components; those are internal, cannot be set from
this module, and keep Material's sizes. Nothing in the app draws with them.
Radius 8 (radius::MD) for everything either client paints: cards,
buttons, the tab pill. 4 (BADGE) for a badge over artwork. The desktop
also has 3 (BAR) for progress bars and edge strips, 6 (SM) for a
small control inside something else, and 12 (LG) for what floats — row
cards, dialogs, toasts, the hero. 16 for an Android bottom sheet, which has
no desktop counterpart.
The desktop spaces things from one scale, theme::space: 2, 4, 6, 8, 12, 16,
24 (XXS to XXL). Use a step of the scale for a gap, not a literal. Sizes of
things — a still, a button, a column — are not spacing and stay where they are
defined.
Two traps, both Android's:
- Material's default shape ramp is 4 / 8 / 12 / 16 / 28, so an unstyled screen puts a 28dp sheet beside a 12dp card beside a fully-round button.
- Material's buttons read their own shape token (
CornerFull), notMaterialTheme.shapes. Handing them a shape is whatAccentButton/TonalButton/EdgedButton/QuietButton(ui/theme/Accent.kt) are for. Use those, not Material's, at a new call site.
The accent is the only strong colour in the window. Something wearing it is
something to interact with; nothing wears it for decoration. Neither client
draws an elevation tint (surfaceTint is transparent on Android) — a surface
that is faintly accent-coloured because it happens to be raised breaks that rule.
Where the accent is a ramp rather than a flat fill, in both clients:
- the primary button
- the seek bar's spent side
- the progress bar under a tile and under an episode row
- the selected navigation pill (desktop only — see below)
Neither client branches on the stored gradients flag to decide this.
Palette::resolve sets accent_alt equal to accent when the flag is off, so a
two-stop brush collapses to a flat fill by itself. That is deliberate: a flag
read in two places is a flag that will eventually mean two things, which is
exactly what B52
was.
Known divergences, both Android's, both because the component takes a Color
and offers no slot to draw into:
- The navigation pill is solid
primaryrather than a ramp. - A download in flight is solid
tertiaryby choice, not by limitation: it is drawn directly under the watch-progress bar of the same row, and two ramps in the same colour two pixels apart say nothing.
The desktop casts shadows from theme::tile_shadow / theme::bar_shadow, which
halve on a light flavour — the same black that separates a card from a dark
page is a smudge on a near-white one. Android gets Material's elevation, which
already adapts. Neither uses a tint to say "raised".
Transitions on the desktop go through Context::animate_*: wall-clock driven,
frame-rate independent, no state held by the caller. Durations come from
theme::motion: PRESS 0.05 s, HOVER 0.12 s, STATE 0.15 s (a switch, the
tab pill's travel, a page change), FADE 0.18 s (player chrome, toasts) and
ARRIVE 0.24 s (artwork over its placeholder). A hand-driven transition eases
with motion::ease_out. Compose's defaults are close enough that nothing here
overrides them.
pstr-app/src/ui/widgets.rs holds the hand-painted controls. Use them instead
of egui's own in new code:
-
toggle: an on/off switch. It replacesui.checkbox, whose unchecked box is filled with the card colour and so disappears on a card. -
settings_group/settings_row: one card per subject, with the name and description on the left and the control on the right (the libadwaita preferences patternproton-drive-linuxuses). -
segmented: pills on a shared track, for picking one of a few values (library filter, season). -
chip: a small rounded fact, such as a genre. -
watched_mark,download_button: the round icon buttons on an episode row.
Messages go through ui::toast::Toasts (via App::note), not a status line.
Destructive actions ask through ui::confirm.
Name it in the owning client first, restate it on the other side, and extend the test that pins the copy. A token that lives in only one place is not a token — it is a literal with a comment.
Documentation of proton-stream 1.6.1, generated from docs/ at c1487c3. To change a page, edit it in the repository.
Start here
Using it
Reference
When something goes wrong
Contributing