Files
proton-stream/docs/DESIGN-TOKENS.md
T
nvrl c6d303f269 feat(app,android,ios): full-bleed hero carousel with ambient colour
The library's banner now runs edge to edge and turns through up to five
titles: the one watched last, then titles with a wide picture, starting
from a different one each day. It moves on every eight seconds, holds
still under the pointer, and has dots to pick a title. A part-watched
title shows how far in the viewer is under its buttons.

The veils over the banner and over a title page's band are tinted with
the picture's own colour. The weighting and the veil live in
pstr_core::appearance (mood, mood_veil), so the desktop and both phones,
which reach them through the bridge, land on the same colour.

On Android the banner is a HorizontalPager under a clear bar that fills
in as it scrolls away; iOS uses a paged TabView the same way.
2026-10-30 21:19:37 +01:00

8.3 KiB
Raw Permalink 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 library's banner is square: it runs edge to edge. 16 for an Android bottom sheet, which has no desktop counterpart.

A picture's colour

The veils over a title's backdrop — the library's banner, the top of a title's page — are the page colour tinted by the picture under them, so each title carries its own light. All three clients get the same colour from the same code: pstr_core::appearance::mood averages the picture, squeezed to a few hundred pixels, counting a saturated pixel up to five times a grey one (MOOD_CHROMA_WEIGHT), and mood_veil turns that into the veil. On a dark palette the mood is darkened until its brightest channel is 60 (MOOD_DEPTH) and the page moves 55% of the way to it (MOOD_DARK); a light palette moves 12% (MOOD_LIGHT). The phones reach both through the bridge, as pictureMood and moodVeil. The fade at the bottom stays the page colour, because that is what it ends in.

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.