feat(android): palette resolved before the first frame

storedPalette() reads and resolves the theme file without building the
engine, so onCreate can set the window background and system-bar icon
polarity before anything draws. Previously the background was a hex literal
in themes.xml and the bar icons were pinned dark, which meant every cold
start flashed one flavour's colour and Latte drew white icons on a
near-white page.

Adds the Material scheme mapping, type ramp and shape tokens the palette
feeds, plus unit tests for both.
This commit is contained in:
nvrl committed 2026-08-10 01:10:08 +02:00
1 parent cb03f96eca
commit 1a9a4624f8
11 files changed
+918 -31

No files matched your search

@@ -25,19 +25,25 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonStreamTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import uniffi.pstr_android.PaletteRecord
class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
@@ -85,7 +91,19 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before anything draws. The palette decides the window's own
// background and which way round the system bars' icons go, and both
// are wrong for a frame if they are set from a composition instead.
// Infallible by construction — an unreadable theme file resolves to the
// shipped default rather than throwing.
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
enableEdgeToEdge()
// Re-applied on every change, not just at startup: picking Latte from
// the settings page has to turn the status bar's icons dark in the same
// frame the page behind them turns light.
lifecycleScope.launch {
AppearanceState.palette.filterNotNull().distinctUntilChanged().collect(::dressWindow)
}
bound = bindService(
Intent(this, PlaybackService::class.java),
playbackConnection,
@@ -121,6 +139,26 @@ class MainActivity : ComponentActivity() {
}
}
/**
* The parts of the window Compose does not reach: the background behind the
* whole activity, and the polarity of the system bars' icons.
*
* The background matters for the frames before and between compositions —
* the launch frame, and a rotation. It used to be a hex literal in
* `themes.xml`, which meant every cold start showed a colour from one
* flavour regardless of which one was chosen.
*
* The icons matter because the app ships a light flavour. Pinned dark, as
* they were, Latte drew white status-bar icons onto a near-white page.
*/
private fun dressWindow(palette: PaletteRecord) {
window.setBackgroundDrawable(palette.background.toInt().toDrawable())
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = palette.light
isAppearanceLightNavigationBars = palette.light
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// The backstop for launchers that still send this instead of arming
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidPaths
import uniffi.pstr_android.PaletteRecord
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
object NativeRuntime {
@@ -36,14 +37,24 @@ object NativeRuntime {
}
}
private fun createEngine(): AndroidEngine {
/**
* The stored palette, without building the engine.
*
* Callable from the main thread, and meant to be: it is the only palette
* read early enough to decide what the first frame is painted in, and
* [engine] is not — that opens SQLite, spins a Tokio runtime and unlocks a
* Keystore key. This reads one small JSON file and resolves it.
*/
fun storedPalette(): PaletteRecord = uniffi.pstr_android.storedPalette(paths())
private fun paths(): AndroidPaths {
check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
val config = File(appContext.noBackupFilesDir, "config")
val data = File(appContext.filesDir, "data")
val cache = File(appContext.cacheDir, "stream")
return AndroidEngine(
AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath),
KeystoreSecretStore(appContext),
)
return AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath)
}
private fun createEngine(): AndroidEngine =
AndroidEngine(paths(), KeystoreSecretStore(appContext))
}
@@ -0,0 +1,223 @@
package io.narl.protonstream.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
import androidx.compose.material3.SliderState
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
/**
* The accent, as a gradient when there is one to draw.
*
* The desktop paints the accent as a two-hue ramp on everything that is a
* control — the seek bar, the primary buttons, the tile progress bar — and the
* viewer's "paint the accent as a gradient" toggle is what turns it off. That
* toggle was already stored, already offered on this screen, and drawn nowhere
* on Android, so the same stored setting meant two different things depending
* on which client read it.
*
* Nothing here reads the toggle. It does not need to: when gradients are off,
* `Palette::resolve` sets `accent_alt` equal to `accent`, so the ramp collapses
* to a flat fill on its own. `primary` and `secondary` are those two colours —
* see [schemeOf] — which is why this asks Material for them rather than
* reaching past it for the palette.
*/
@Composable
internal fun accentBrush(): Brush {
val scheme = MaterialTheme.colorScheme
return remember(scheme.primary, scheme.secondary) {
Brush.horizontalGradient(listOf(scheme.primary, scheme.secondary))
}
}
/**
* The primary button: the accent ramp, at the shared corner radius.
*
* Material's filled button has its own shape token (`CornerFull`) that does not
* read `MaterialTheme.shapes`, so the radius has to be handed to it — which is
* the whole reason the plain buttons below exist as well. They add nothing but
* that.
*/
@Composable
internal fun AccentButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
val shape = MaterialTheme.shapes.small
Button(
onClick = onClick,
// Only while it is live: a disabled button keeps Material's flat
// disabled container, because a greyed control that still wears the
// one strong colour in the window reads as pressable.
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier,
enabled = enabled,
shape = shape,
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
content = content,
)
}
@Composable
internal fun TonalButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = FilledTonalButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
@Composable
internal fun EdgedButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
@Composable
internal fun QuietButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = TextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
/**
* How far along something is, drawn in the accent.
*
* Hand-drawn rather than `LinearProgressIndicator` because that one takes a
* `Color` and not a `Brush`, and because Material 1.4 draws a gap and a stop
* indicator either side of the bar — detail at a size where the desktop draws a
* plain bar. [fill] is a parameter for the one bar that is deliberately not the
* accent: a download in flight sits directly under the watch-progress bar of
* the same row, and two ramps in the same colour stacked two pixels apart say
* nothing.
*/
@Composable
internal fun AccentProgress(
progress: () -> Float,
modifier: Modifier = Modifier,
fill: Brush? = null,
) {
val brush = fill ?: accentBrush()
val track = MaterialTheme.colorScheme.surfaceContainerHighest
Canvas(modifier.fillMaxWidth().height(PROGRESS_HEIGHT.dp)) {
val radius = CornerRadius(size.height / 2)
drawRoundRect(color = track, cornerRadius = radius)
val done = size.width * progress().coerceIn(0f, 1f)
// Below one bar's width there is nothing to round, and a rounded rect
// narrower than its own corners draws as a wrong-looking pill.
if (done >= size.height) {
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
}
}
}
/**
* The seek bar's track, in the accent ramp.
*
* Passed to `Slider`'s `track` slot, so the thumb, the gestures and the
* accessibility semantics stay Material's — this replaces the two rectangles it
* would have drawn and nothing else.
*
* The spent side is the accent and the rest is white at a third: the seek bar
* is the one accent surface that sits over arbitrary film footage, where a
* palette colour for the *unplayed* remainder would be a coloured line over a
* picture rather than the absence of one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
val brush = accentBrush()
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
val radius = CornerRadius(size.height / 2)
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
val done = size.width * state.coercedValueAsFraction
if (done >= size.height) {
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
}
}
}
/**
* The navigation pill, in the accent.
*
* Solid rather than a ramp, and this is the one place the two clients cannot
* agree exactly: `NavigationSuiteScaffold` takes the indicator as a `Color`,
* with no slot to draw into, so a gradient here would mean reimplementing the
* bar. The accent itself is still the right answer — the desktop's rule is that
* the accent marks the thing you are on — and it is nearer to that than
* `secondaryContainer`, which is the accent taken most of the way back to the
* page.
*/
@Composable
internal fun accentNavigationColors(): NavigationSuiteItemColors {
val scheme = MaterialTheme.colorScheme
return NavigationSuiteDefaults.itemColors(
navigationBarItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onSurface,
indicatorColor = scheme.primary,
),
navigationRailItemColors = NavigationRailItemDefaults.colors(
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onSurface,
indicatorColor = scheme.primary,
),
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = scheme.primary,
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onPrimary,
),
)
}
/** A solid fill, for the callers of [AccentProgress] that want one colour. */
internal fun solid(color: Color): Brush = SolidColor(color)
/** Thin enough to sit under a row without becoming part of it. */
private const val PROGRESS_HEIGHT = 4f
/** The seek bar is dragged with a finger, so it is the thicker of the two. */
private const val TRACK_HEIGHT = 6f
@@ -0,0 +1,36 @@
package io.narl.protonstream.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* One corner radius, which is the desktop client's corner radius.
*
* `pstr-app` rounds every surface it paints to 8 — cards, buttons, the tab
* pill, the progress bars — and only a badge over artwork is tighter, at 4.
* Material's default ramp runs 4 / 8 / 12 / 16 / 28, so a stock Compose screen
* puts a 28dp bottom sheet next to a 12dp card next to a fully-round button,
* and none of those is the shape the other client draws.
*
* Flat rather than a ramp, therefore, with the two ends kept for the two cases
* where a single radius is wrong: `extraSmall` for something small enough that
* 8 would eat it, and `extraLarge` for a sheet whose corners meet the edge of
* the screen and need to read as a lifted sheet rather than a seam.
*/
internal val ProtonShapes = Shapes(
extraSmall = RoundedCornerShape(CORNER_TIGHT.dp),
small = RoundedCornerShape(CORNER.dp),
medium = RoundedCornerShape(CORNER.dp),
large = RoundedCornerShape(CORNER.dp),
extraLarge = RoundedCornerShape(CORNER_SHEET.dp),
)
/** The radius `pstr-app` draws everything at. */
internal const val CORNER = 8f
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */
internal const val CORNER_TIGHT = 4f
/** A bottom sheet, which has no desktop counterpart. */
internal const val CORNER_SHEET = 16f
@@ -29,8 +29,29 @@ object AppearanceState {
private val current = MutableStateFlow<PaletteRecord?>(null)
val palette: StateFlow<PaletteRecord?> = current.asStateFlow()
/** Read the stored choice. Safe to call repeatedly; the last read wins. */
/**
* The palette read before the first frame, from
* [io.narl.protonstream.native.NativeRuntime.storedPalette].
*
* Separate from [load] because it runs on the main thread during
* `onCreate`: it is the only read that is early enough to decide what the
* first composition is painted in, and it is cheap enough to be allowed
* there.
*/
fun seed(palette: PaletteRecord) {
current.value = palette
}
/**
* Read the stored choice through the engine. Safe to call repeatedly; the
* last read wins.
*
* A no-op once [seed] has run — it resolves the same file through the same
* Rust, so the only thing a second read can do is build the engine earlier
* than the first screen needed it.
*/
suspend fun load() {
if (current.value != null) return
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
@@ -48,23 +69,48 @@ fun ProtonStreamTheme(content: @Composable () -> Unit) {
// with — a theme file that cannot be read must never be a reason the
// library does not open.
LaunchedEffect(Unit) { AppearanceState.load() }
MaterialTheme(colorScheme = palette?.let(::schemeOf) ?: ProtonColors, content = content)
MaterialTheme(
colorScheme = schemeOf(palette ?: ProtonFallback),
// The other two thirds of what the clients share. Colour was already
// one resolved answer for both; type and shape were Material's defaults
// here and named constants there, which is most of why the two read as
// different apps even where the colours agreed.
typography = ProtonTypography,
shapes = ProtonShapes,
content = content,
)
}
/**
* One resolved palette as Material's roles.
* One resolved palette as Material's roles — **all** of them.
*
* Every role Material names has to be answered here, because a role left out
* does not fall back to something neutral: it keeps the value
* `darkColorScheme()` / `lightColorScheme()` gave it, which is Material's own
* baseline purple-and-grey and belongs to no flavour this app ships.
*
* That is not hypothetical. `surfaceContainerHighest` is what `Card` fills with
* (`FilledCardTokens.ContainerColor`), and while it was unmapped every tile in
* the library grid, every episode row, every share and every download was drawn
* in baseline neutral on top of a flavoured page. `inverseSurface` is the
* snackbar — the app's only error channel — and `scrim` is the dim behind every
* dialog.
*
* Material names more roles than a palette has colours, so several are answered
* by the same one — `surfaceVariant` and `card` above all, which is what makes
* a tile and a form field the same shade in both clients.
* by the same one, and two (`elevated`, `dangerDim`) are derived in Rust so the
* blend behind them is the shared one. `SchemeRolesTest` asserts that nothing
* here is left at its baseline.
*/
private fun schemeOf(palette: PaletteRecord): ColorScheme {
internal fun schemeOf(palette: PaletteRecord): ColorScheme {
val base = if (palette.light) lightColorScheme() else darkColorScheme()
return base.copy(
primary = color(palette.accent),
onPrimary = color(palette.onAccent),
primaryContainer = color(palette.accentDim),
onPrimaryContainer = color(palette.text),
// Readable *on* `inverseSurface`, which is the ink colour: `accentDim`
// is the accent taken towards the page, so it inverts with the rest.
inversePrimary = color(palette.accentDim),
secondary = color(palette.accentAlt),
onSecondary = color(palette.onAccent),
// The accent taken back towards the page, not the card's hover shade.
@@ -74,35 +120,97 @@ private fun schemeOf(palette: PaletteRecord): ColorScheme {
// a dark grey row, readable as text but not as controls.
secondaryContainer = color(palette.accentDim),
onSecondaryContainer = color(palette.text),
// The palette has two hues, not three. Tertiary takes the second one —
// the gradient partner — rather than inventing a third that no flavour
// chose.
tertiary = color(palette.accentAlt),
onTertiary = color(palette.onAccent),
tertiaryContainer = color(palette.accentDim),
onTertiaryContainer = color(palette.text),
background = color(palette.background),
onBackground = color(palette.text),
surface = color(palette.surface),
onSurface = color(palette.text),
surfaceVariant = color(palette.card),
onSurfaceVariant = color(palette.muted),
surfaceContainer = color(palette.card),
surfaceContainerHigh = color(palette.cardHover),
surfaceContainerLow = color(palette.sunken),
outline = color(palette.border),
outlineVariant = color(palette.muted),
// No elevation tint. Material tints a raised surface with `primary`,
// and the desktop draws no such tint at all — its rule is that the
// accent is the only strong colour in the window and everything wearing
// it is something to click. A card that is faintly accent-coloured
// because it happens to be raised breaks that.
surfaceTint = Color.Transparent,
// Genuinely inverted, both ways round: on a dark flavour this is a pale
// snackbar with dark ink, on Latte a dark one with pale ink.
inverseSurface = color(palette.text),
inverseOnSurface = color(palette.background),
error = color(palette.danger),
onError = color(palette.background),
errorContainer = color(palette.dangerDim),
onErrorContainer = color(palette.text),
// Swapped from what was here before, to match what Material draws with
// each: `outline` is a border meant to be seen (an outlined button,
// a swatch that is not selected), `outlineVariant` is a divider meant
// to be barely there. `border` is the fainter of the two.
outline = color(palette.muted),
outlineVariant = color(palette.border),
// Black in both of Material's own schemes, and for the same reason: a
// scrim is not a colour, it is an absence of light.
scrim = Color.Black,
surfaceBright = color(palette.elevated),
surfaceDim = color(palette.background),
// The containment ladder, in the order the palette already runs: the
// deepest colour in the flavour at the bottom, `elevated` — one step
// past the card's hover shade — at the top.
surfaceContainerLowest = color(palette.sunken),
surfaceContainerLow = color(palette.surface),
surfaceContainer = color(palette.card),
surfaceContainerHigh = color(palette.cardHover),
surfaceContainerHighest = color(palette.elevated),
// The fixed roles are Material's "the same in light and dark", which a
// per-flavour palette has no equivalent of. They take the accent pair
// rather than a baseline hue, so a component that reaches for one still
// lands inside the flavour.
primaryFixed = color(palette.accentDim),
primaryFixedDim = color(palette.accent),
onPrimaryFixed = color(palette.text),
onPrimaryFixedVariant = color(palette.muted),
secondaryFixed = color(palette.accentDim),
secondaryFixedDim = color(palette.accentAlt),
onSecondaryFixed = color(palette.text),
onSecondaryFixedVariant = color(palette.muted),
tertiaryFixed = color(palette.accentDim),
tertiaryFixedDim = color(palette.accentAlt),
onTertiaryFixed = color(palette.text),
onTertiaryFixedVariant = color(palette.muted),
)
}
/** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */
private fun color(argb: UInt) = Color(argb.toInt())
/** The shipped default, spelled out for the frames before the store is read. */
private val ProtonColors = darkColorScheme(
primary = Color(0xFF7D4DFF),
onPrimary = Color(0xFFEAEAF0),
secondary = Color(0xFF3F6FE0),
background = Color(0xFF0E0E12),
surface = Color(0xFF17171D),
surfaceVariant = Color(0xFF1E1E26),
onBackground = Color(0xFFEAEAF0),
onSurface = Color(0xFFEAEAF0),
outline = Color(0xFF2A2A35),
error = Color(0xFFE05561),
/**
* The shipped default, spelled out for the case where Rust cannot be asked.
*
* A `PaletteRecord` rather than a `ColorScheme`, so that it goes through
* [schemeOf] like any other palette and inherits every mapping above — a
* fallback that answered only ten roles would be the same bug in a smaller
* place. The values are `pstr_core::appearance::Palette::PROTON`.
*/
internal val ProtonFallback = PaletteRecord(
background = 0xFF0E0E12u,
surface = 0xFF17171Du,
sunken = 0xFF0A0A0Du,
card = 0xFF1E1E26u,
cardHover = 0xFF2A2A35u,
border = 0xFF262630u,
text = 0xFFEAEAF0u,
muted = 0xFF8E8E9Cu,
accent = 0xFF7D4DFFu,
accentAlt = 0xFF3F6FE0u,
accentDim = 0xFF5333ADu,
onAccent = 0xFFEAEAF0u,
danger = 0xFFE05561u,
elevated = 0xFF333340u,
dangerDim = 0xFF7F2F37u,
light = false,
)
@@ -0,0 +1,75 @@
package io.narl.protonstream.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* The type ramp, which is the desktop client's type ramp.
*
* `pstr_app::theme::Role` names nine sizes and the desktop asks for a role
* rather than a number at every call site. Material names fifteen styles, so
* several of them land on the same rung — that is the point. The two clients
* were built to share one palette; sharing one scale is the other half of why
* they should read as one app.
*
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
*
* Where a role maps is decided by what actually draws with it here, not by
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
* a phone's lock screen and not for anything in a library grid. `bodyLarge` is
* what an unstyled `Text` gets, so it takes the desktop's body rung and the two
* smaller body styles step down from there.
*
* [TypeRampTest] pins every one of these against the Rust ramp.
*/
internal val ProtonTypography = Typography(
displayLarge = role(DISPLAY, FontWeight.SemiBold),
displayMedium = role(DISPLAY, FontWeight.SemiBold),
displaySmall = role(TITLE, FontWeight.SemiBold),
// The name of the title you opened — the desktop's display line.
headlineLarge = role(DISPLAY, FontWeight.SemiBold),
headlineMedium = role(DISPLAY, FontWeight.SemiBold),
// A dialog's heading — `AlertDialog` titles draw with this one.
headlineSmall = role(HEADING, FontWeight.SemiBold),
// A page heading: "Offline downloads", "Settings".
titleLarge = role(TITLE, FontWeight.SemiBold),
// A run of content under a heading: "Continue watching", "Playback".
titleMedium = role(SECTION, FontWeight.Medium),
// A row that groups the rows under it — a season header.
titleSmall = role(SUBHEAD, FontWeight.Medium),
bodyLarge = role(BODY, FontWeight.Normal),
bodyMedium = role(LABEL, FontWeight.Normal),
// Context rather than content: counts, sizes, the note under a field.
bodySmall = role(CAPTION, FontWeight.Normal),
// The text on a button.
labelLarge = role(LABEL, FontWeight.Medium),
labelMedium = role(CAPTION, FontWeight.Medium),
// The smallest thing that stays legible.
labelSmall = role(MICRO, FontWeight.Medium),
)
internal const val DISPLAY = 26f
internal const val TITLE = 20f
internal const val HEADING = 18f
internal const val SECTION = 17f
internal const val SUBHEAD = 15f
internal const val BODY = 14f
internal const val LABEL = 13f
internal const val CAPTION = 12f
internal const val MICRO = 11f
/**
* One rung as a `TextStyle`.
*
* Line height is derived rather than named per style: a ramp whose leading was
* chosen nine separate times is the same problem the sizes had. 1.35 is loose
* enough for the two-line title names in the grid and tight enough that a
* label on a control does not float.
*/
private fun role(size: Float, weight: FontWeight) = TextStyle(
fontSize = size.sp,
lineHeight = (size * 1.35f).sp,
fontWeight = weight,
)