# 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 `TextStyle`s, 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](BUGS.md#b52--paint-the-accent-as-a-gradient-did-nothing-on-android) 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.