mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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.
166 lines
8.3 KiB
Markdown
166 lines
8.3 KiB
Markdown
# 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.
|