mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
docs: android review ledger, design tokens, test notes
B14-B42 record the Android review the theme and lifecycle work came out of. DESIGN-TOKENS.md states what both clients agree on and where each keeps its copy.
This commit is contained in:
1 parent
4ef62255c0
commit
dc85890a9f
4 files changed
+247
No files matched your search
@@ -146,6 +146,104 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
|||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
### B52 — "paint the accent as a gradient" did nothing on Android
|
||||||
|
|
||||||
|
**Symptom.** The appearance page on Android offers a *Paint the accent as a
|
||||||
|
gradient* switch, stores it, and repaints the app when it is flipped — and
|
||||||
|
nothing on the screen changed either way. The same stored setting visibly
|
||||||
|
changed the desktop client, so one file meant two things depending on which
|
||||||
|
client read it. `PinkSky`, whose whole point is that it is a gradient before it
|
||||||
|
is a colour, arrived on Android as one flat pink.
|
||||||
|
|
||||||
|
**Cause.** No gradient was ever drawn. Nothing under `android/app/src/main`
|
||||||
|
referenced `Brush`, `linearGradient` or `verticalGradient`. `accent_alt` reached
|
||||||
|
Compose only as `secondary`, where it fills tonal containers — so the second hue
|
||||||
|
existed in the scheme but never sat next to the first one.
|
||||||
|
|
||||||
|
**Fix.** `ui/theme/Accent.kt` draws the ramp on the surfaces the desktop draws it
|
||||||
|
on: the primary button, the seek bar, the watch-progress bar under a tile and
|
||||||
|
under an episode row. Nothing there reads the toggle, because it does not need
|
||||||
|
to — `Palette::resolve` already sets `accent_alt` equal to `accent` when
|
||||||
|
gradients are off, so the ramp collapses to a flat fill by itself and the two
|
||||||
|
clients cannot disagree about what the setting means.
|
||||||
|
|
||||||
|
Two surfaces are deliberately not ramps. A download in flight keeps a solid
|
||||||
|
`tertiary`, because 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. The navigation
|
||||||
|
pill is solid `primary`: `NavigationSuiteScaffold` takes its indicator as a
|
||||||
|
`Color` with no slot to draw into, so a gradient there would mean reimplementing
|
||||||
|
the bar — but the accent itself is still nearer the desktop's rule than
|
||||||
|
`secondaryContainer`, which is the accent taken most of the way back to the page.
|
||||||
|
|
||||||
|
**Verified.** `./gradlew :app:testDebugUnitTest :app:lintDebug`. Still wants a
|
||||||
|
device: the ramp is the one thing here a screenshot on Proton and on Latte
|
||||||
|
settles, and the panel-banding case the toggle exists for cannot be judged from
|
||||||
|
a build at all.
|
||||||
|
|
||||||
|
### B51 — the Android client wore only part of the palette it was given
|
||||||
|
|
||||||
|
**Symptom.** Every flavour looked half-applied on Android. The page was the
|
||||||
|
flavour's colour and the tiles on it were not: the library grid, the
|
||||||
|
continue-watching row, every episode row, every share, every download and the
|
||||||
|
mini transport were all a flat neutral grey that belonged to no flavour and did
|
||||||
|
not change when one was picked. Latte additionally drew white status-bar icons
|
||||||
|
onto its near-white page, and every cold start flashed a dark blue-grey before
|
||||||
|
the app appeared — including for viewers who had chosen Latte.
|
||||||
|
|
||||||
|
**Cause.** Four, all in how the resolved palette reached the platform.
|
||||||
|
|
||||||
|
`schemeOf` (`ui/theme/Theme.kt`) built a `ColorScheme` by copying
|
||||||
|
`darkColorScheme()`/`lightColorScheme()` and overriding 22 roles. Material names
|
||||||
|
roughly fifty. A role nobody overrides keeps Material's own baseline value, and
|
||||||
|
`Card`'s container is `surfaceContainerHighest`
|
||||||
|
(`FilledCardTokens.ContainerColor`), which was one of the twenty-seven nobody
|
||||||
|
overrode. `Card` is what draws every repeated surface in the app, so the app's
|
||||||
|
most common surface was the one surface that ignored the theme. `inverseSurface`
|
||||||
|
and `inverseOnSurface` — the snackbar, which is the client's only error channel
|
||||||
|
— and `scrim`, the dim behind every dialog, were in the same set. `surfaceTint`
|
||||||
|
was too, so raised surfaces were tinted with Material's baseline purple.
|
||||||
|
|
||||||
|
`themes.xml` hardcoded `windowBackground` to `#1E1E2E`, which is Catppuccin
|
||||||
|
Mocha's base: not the shipped default, and not the viewer's choice either. It
|
||||||
|
also pinned `windowLightStatusBar` and `windowLightNavigationBar` to `false`,
|
||||||
|
and nothing anywhere synced either to `palette.light`.
|
||||||
|
|
||||||
|
`ProtonStreamTheme` read the palette in a `LaunchedEffect` that hopped to
|
||||||
|
`Dispatchers.IO`, so the first composition was always painted in a hardcoded
|
||||||
|
dark default — the same flash-of-the-wrong-theme the desktop client had on
|
||||||
|
Windows, for the same reason.
|
||||||
|
|
||||||
|
**Fix.** `schemeOf` maps every role Material names. Two colours the palette does
|
||||||
|
not name are derived in Rust so the blend behind them is the shared, tested one:
|
||||||
|
`elevated`, which continues the flavour's own `card` → `card_hover` step by
|
||||||
|
extrapolating past it — so a light flavour's ladder descends and a dark one's
|
||||||
|
climbs — and `danger_dim`, an error *container* rather than error ink. Both are
|
||||||
|
new fields on `PaletteRecord`. `outline` and `outlineVariant` were also swapped
|
||||||
|
to match what Material draws with each: the visible border takes `muted`, the
|
||||||
|
divider takes the fainter `border`. `surfaceTint` is transparent, because the
|
||||||
|
desktop draws no elevation tint and its rule is that the accent is the only
|
||||||
|
strong colour in the window.
|
||||||
|
|
||||||
|
A new `stored_palette` bridge function resolves the stored choice without
|
||||||
|
building an engine — no SQLite, no Tokio runtime, no Keystore unlock — so
|
||||||
|
`MainActivity.onCreate` can seed the palette before `setContent` rather than one
|
||||||
|
frame after it. It also sets the window background and the system bars' icon
|
||||||
|
polarity from that palette, and re-applies both whenever the flavour changes, so
|
||||||
|
picking Latte turns the status-bar icons dark in the same frame the page turns
|
||||||
|
light. `themes.xml` keeps only the shipped default's background, as a first-ever
|
||||||
|
launch has no theme file to read.
|
||||||
|
|
||||||
|
**Verified.** `cargo test --workspace --locked`, `cargo clippy --workspace
|
||||||
|
--all-targets -- -D warnings`, `./gradlew :app:testDebugUnitTest :app:lintDebug`
|
||||||
|
(lint clean; the two unmatched baseline entries it reports pre-date this change
|
||||||
|
— the same run on the unmodified tree reports them). The Kotlin side is pinned
|
||||||
|
by `SchemeRolesTest`, which sweeps `ColorScheme` reflectively rather than from a
|
||||||
|
list and fails if any role carries a value the palette did not supply, so a role
|
||||||
|
added by a future Material version fails the day the dependency moves. The Rust
|
||||||
|
side asserts the derived rung continues the ladder in all five flavours, both
|
||||||
|
polarities. The appearance itself still wants a device: screenshots on Proton
|
||||||
|
and on Latte.
|
||||||
|
|
||||||
### B50 — every episode of a show was named after the show
|
### B50 — every episode of a show was named after the show
|
||||||
|
|
||||||
**Symptom.** All fifty-nine Attack on Titan rows read `Attack On Titan` on both
|
**Symptom.** All fifty-nine Attack on Titan rows read `Attack On Titan` on both
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 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
|
||||||
|
`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** 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`), 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 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -170,6 +170,38 @@ reads an external subtitle through the same protocol); the work is in
|
|||||||
rustls. Separately a browser cannot reach the Proton API at all (CORS) without
|
rustls. Separately a browser cannot reach the Proton API at all (CORS) without
|
||||||
a proxy server, which the fat-client decision rules out.
|
a proxy server, which the fat-client decision rules out.
|
||||||
|
|
||||||
|
- **One palette, one type ramp, one shape radius — named, not repeated.**
|
||||||
|
`pstr_core::appearance` already settled colour for both clients;
|
||||||
|
`pstr_app::theme::Role` settles size the same way. A `.size(17.0)` at a call
|
||||||
|
site is not a scale, it is one of forty independent decisions that happen to
|
||||||
|
sit near each other, and it is why the two clients read as different apps even
|
||||||
|
when their colours agreed. Anything new asks for a role.
|
||||||
|
- **Depth and motion are the theme's, not the widget's.** Shadows come from
|
||||||
|
`theme::tile_shadow` / `theme::bar_shadow`, which halve themselves on a light
|
||||||
|
flavour — the same black that separates a card from a dark page is a smudge on
|
||||||
|
a near-white one. Transitions go through `Context::animate_*`, which is driven
|
||||||
|
by wall-clock time and keeps no state of its own, so a hover that fades needs
|
||||||
|
no field anywhere.
|
||||||
|
- **The grid flexes; `CARD_WIDTH` is a minimum.** `ui::columns` hands back the
|
||||||
|
column count *and* the width they should be drawn at. Dividing by the constant
|
||||||
|
and flooring leaves up to a full card of dead space at the right of every row.
|
||||||
|
- **Android copies the ramp; a test says so.** `ui/theme/Type.kt` and
|
||||||
|
`ui/theme/Shape.kt` are `Role` and radius 8 restated for Compose, because
|
||||||
|
Material's own defaults are a different scale (57sp displays, a five-step
|
||||||
|
corner ramp ending in a fully-round button) and there is no bridge to resolve
|
||||||
|
type the way `appearance` resolves colour. `TypeRampTest` names all fifteen
|
||||||
|
Material styles and fails on a sixteenth, so the copy cannot drift quietly.
|
||||||
|
Note that Material's filled buttons read their own shape token rather than
|
||||||
|
`MaterialTheme.shapes` — that is what `AccentButton`/`TonalButton`/
|
||||||
|
`EdgedButton`/`QuietButton` in `ui/theme/Accent.kt` exist for, and new call
|
||||||
|
sites should use them rather than Material's directly.
|
||||||
|
- **The gradient toggle is not read where the gradient is drawn.**
|
||||||
|
`Palette::resolve` sets `accent_alt` equal to `accent` when gradients are off,
|
||||||
|
so a two-stop brush collapses to a flat fill on its own. Both clients rely on
|
||||||
|
that instead of branching on the flag, which is what keeps them from
|
||||||
|
disagreeing about what the stored setting means (see
|
||||||
|
[B52](BUGS.md#b52--paint-the-accent-as-a-gradient-did-nothing-on-android)).
|
||||||
|
|
||||||
## Traps
|
## Traps
|
||||||
|
|
||||||
- **Block sizes are not uniform.** Always `RevisionReader::block_sizes()`; never
|
- **Block sizes are not uniform.** Always `RevisionReader::block_sizes()`; never
|
||||||
|
|||||||
@@ -202,6 +202,15 @@ the two decode fallbacks that are easy to get backwards — a missing `label`
|
|||||||
falling back to the link id, and a blank `error` reading as *no* error rather
|
falling back to the link id, and a blank `error` reading as *no* error rather
|
||||||
than as a permanently failed download.
|
than as a permanently failed download.
|
||||||
|
|
||||||
|
The other two host tests pin the half of `docs/DESIGN-TOKENS.md` that no bridge
|
||||||
|
enforces. `SchemeRolesTest` sweeps `ColorScheme` reflectively and fails on any
|
||||||
|
role carrying a value the palette did not supply — including a role a future
|
||||||
|
Material version adds, the day the dependency moves. `TypeRampTest` names all
|
||||||
|
fifteen Material text styles against the shared ramp and fails on a sixteenth,
|
||||||
|
and checks the corner radius the desktop draws at. Both are lists of the *other*
|
||||||
|
client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
|
||||||
|
across fails here rather than being noticed in a screenshot.
|
||||||
|
|
||||||
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
||||||
equivalent:
|
equivalent:
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user