- 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.
6.0 KiB
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), 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, 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
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.
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 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.
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.