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.
7.5 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::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), 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 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 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.