feat(theme): tabular figures on the roles that carry a running number

M0 said the big-readout typography would be settled once there was a
stopwatch to settle it against. `ClockulaTypography` now puts `tnum` on the
display, headline and title roles, which is every role a counting number
uses, and `ClockulaReadoutDefaults` names the three sizes the readouts share.

Digits stop changing width as they change, so the timer row, the setup panel
and the live pill stop twitching too — none of them needed a call-site
change to get it.
This commit is contained in:
2026-09-22 10:23:54 +02:00
parent b667e46877
commit 2aaae5dde8
@@ -1,10 +1,71 @@
package de.jeanlucmakiola.clockula.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
/** Fixed-width digits. One string, one place, and a build rule that keeps it there (M7 D12). */
private const val TABULAR_FIGURES = "tnum"
private fun TextStyle.tabular(): TextStyle = copy(fontFeatureSettings = TABULAR_FIGURES)
/**
* Default Material 3 Expressive typography. A clock app is mostly one enormous
* number, so the readout scale is the real typographic decision here — it gets
* settled against a working stopwatch in M7, not guessed at in the scaffolding.
* The Material 3 Expressive default with **tabular figures** on the display,
* headline and title roles. A clock app is mostly one enormous number, and a
* readout whose glyph columns change width as its digits change jitters on
* every tick — so every existing readout (the timer row's `headlineLarge`, the
* setup panel's `displayMedium`, the live pill's `titleMedium`) is fixed here,
* with no call-site change anywhere.
*
* Body and label roles keep proportional figures: prose is not a column of
* numbers, and proportional digits read better inside a sentence.
*/
val ClockulaTypography = Typography()
val ClockulaTypography: Typography = Typography().run {
copy(
displayLarge = displayLarge.tabular(),
displayMedium = displayMedium.tabular(),
displaySmall = displaySmall.tabular(),
headlineLarge = headlineLarge.tabular(),
headlineMedium = headlineMedium.tabular(),
headlineSmall = headlineSmall.tabular(),
titleLarge = titleLarge.tabular(),
titleMedium = titleMedium.tabular(),
titleSmall = titleSmall.tabular(),
)
}
/**
* The three roles a readout uses, named once for the whole app — M0's written
* promise that the big-readout scale would be settled against a working
* stopwatch rather than guessed at in the scaffolding (M7 D12).
*
* The M3 `*Defaults` shape — composable getters over `MaterialTheme.typography`
* rather than constants — so a theme change still flows through them. No new
* font, no size outside the M3 scale, and no weight above Medium: `PLAN.md` §8's
* "Expressive ≠ big or bold" applies hardest to the one screen most tempted to
* break it.
*/
object ClockulaReadoutDefaults {
/** The stopwatch's big number. */
val Hero: TextStyle
@Composable @ReadOnlyComposable
get() = MaterialTheme.typography.displayLarge.copy(fontWeight = FontWeight.Medium)
/**
* The hundredths beside [Hero], about half its size. A readout whose
* hundredths are as large as its seconds makes the fastest-changing glyphs
* the loudest ones and drags the eye off the number that matters (D5).
*/
val HeroFraction: TextStyle
@Composable @ReadOnlyComposable
get() = MaterialTheme.typography.headlineMedium
/** A figure inside a table row — the one place body-scale text *is* a column of numbers. */
val Figure: TextStyle
@Composable @ReadOnlyComposable
get() = MaterialTheme.typography.bodyLarge.tabular()
}