Files
proton-stream/docs/DESIGN-TOKENS.md
T
nvrl 255e463fc8 feat(app,android,ios): display type, artwork fade-in, shimmer, lifting tiles and paged shelves
A first pass at making the three clients look like a streaming app rather
than a file browser, with no new data behind it.

- Type: a Hero rung (34) set in Inter Display Bold, the optical size Inter
  draws for large text, for the banner's name and a title page's name where
  no logo stands in. Desktop `Role::Hero` and `theme::display`; Android and
  iOS `displayLarge`, at a tighter 1.1 leading. The font has its private use
  mappings stripped like the other two cuts.
- Artwork fades in over its skeleton on Android and iOS, as it already did
  on desktop cards; art already decoded draws at once. Desktop episode
  stills and logos fade in too.
- Skeletons shimmer: a soft diagonal highlight instead of a pulse.
- Desktop tiles lift towards the viewer under the pointer, capped so a
  lifted tile never reaches its neighbour, and keyboard focus gets a ring
  of its own.
- Desktop shelves share one `ui::shelf`: edges fade into the page and, under
  the pointer, round buttons page the row along.
- Genres are chips on Android and iOS; quality (4K, HDR, 10-bit, Dual audio)
  moves out of the facts line into small outlined marks on all three.
- Desktop title page and hero buttons use a tonal style beside the accent
  one, and the player's top bar a dark glass style, instead of egui's stock
  buttons. Episode numbers leave monospace for the type ramp.
- The phone players' top and bottom bars are gradients rather than flat
  bands, and a double-tap seek is felt as well as seen.
2026-10-30 20:36:09 +01:00

7.5 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::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

Ten 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
Hero 34 the banner's name and a title page's name, where no logo stands in
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. Hero is set in Inter Display Bold, the optical size Inter draws for large text (theme::display); Android and iOS map it to displayLarge. Compose derives line height as 1.35 × size rather than naming it ten more times, except Hero at 1.1, where the text leading pushes a two-line name apart.

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.