Skip to content

Design Tokens

github-actions[bot] edited this page Oct 17, 2026 · 1 revision

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 —

Type

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.

Shape

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.

Spacing

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), not MaterialTheme.shapes. Handing them a shape is what AccentButton / TonalButton / EdgedButton / QuietButton (ui/theme/Accent.kt) are for. Use those, not Material's, at a new call site.

The accent, and what gradients means

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 primary rather than a ramp.
  • A download in flight is solid tertiary by 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.

Depth and motion

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.

Desktop widgets

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 replaces ui.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 pattern proton-drive-linux uses).
  • 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.

Adding a token

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.

Clone this wiki locally