Files
proton-stream/docs/DESIGN-TOKENS.md
T
nvrl 4027ab5fb3 feat(app): polish pass - toasts, shortcuts, redesigned pages, cheaper frames
- toasts replace the status line; keyboard shortcuts with a ? overlay
- library: filters, sort, row virtualisation, cached search, skeleton
  tiles until the catalog loads, gradient placeholder art
- title page: season pills, episode rows with stills and one download
  glyph, portrait poster, clamped overview, resume time in the button
- player: fading chrome, smooth scrims, subtitles lifted over the
  controls, drag-following seek knob, OSD, speed menu, auto-skip
- settings as grouped cards with toggles and palette previews
- shares: confirm before removing, inline link validation, form kept
  until the add succeeds; downloads scroll and show rate and ETA
- downloads pipeline four blocks; the next episode is warmed near the end
- a film behind another page repaints at 4 Hz, not its frame rate

Fixes B55-B60.
2026-10-02 19:27:28 +02:00

6.0 KiB
Raw Blame History

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 ui/mod.rs 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 —
Motion Context::animate_* 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: egui has one weight, and Compose derives line height as 1.35 × size rather than naming it nine more times.

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 for everything either client paints: cards, buttons, the tab pill, progress bars, dialogs. 4 for a badge over artwork. 16 for an Android bottom sheet, which has no desktop counterpart.

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 in use are 0.05 s for a press, 0.10–0.12 s for a hover, 0.16 s for the tab pill's travel and 0.22 s for artwork arriving. 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.