feat: adopt MaterialExpressiveTheme, and give shape, type and motion a named home
Phase 1, steps 3, 4 and 6 of docs/material-design-conformance.md. `TorchTheme` passed
`MaterialTheme` a colour scheme and a typography and nothing else, so shape and motion were
whatever the library defaulted to and there was nowhere to write down what any of it was
for.
**Expressive, by decision rather than by drift.** The plan deliberately left
`MaterialExpressiveTheme` vs `MaterialTheme` open, because it changes component defaults
app-wide and is a product call. Put to the product owner on 2026-09-08 and answered
expressive. The pinned material3 1.10.0-alpha05 ships the whole set -- `ButtonGroupKt`,
`SplitButtonKt`, `FloatingToolbarKt`, `LoadingIndicatorKt`, `ShortNavigationBarKt`,
`WideNavigationRail`, `MaterialShapesKt` -- and the tree already opts into
`ExperimentalMaterial3ExpressiveApi` in 66 places, so this makes explicit what the imports
had already assumed.
**All four slots are passed explicitly, and that is the point.**
`MaterialExpressiveTheme` defaults its colour scheme to `expressiveLightColorScheme()` and
its shapes and typography likewise -- Material's values, not this app's. Leaving any slot
to that default is the same class of accident as the twelve unassigned fixed roles fixed
two commits ago: it compiles, it renders, and it renders somebody else's design.
**No visual change on the screens checked, and that is worth stating rather than
assuming.** Measured on the API 36 emulator: the "Invite a Friend" button is byte-identical
before and after -- same fill `#4E5E8B`, same 357px box at the same y -- because the
expressive default for a `Button` at default size matches the baseline in this version.
What expressive actually buys is elsewhere: `LocalUsingExpressiveTheme` gating component
behaviour, the three increased shape steps, the fifteen `...Emphasized` type roles, and the
components phases 5 to 7 are built on.
**`MantraShapes` is baseline `Shapes()`, on evidence.** The corners hand-written across the
tree already land on the M3 scale --
RoundedCornerShape(4.dp) x3 = extraSmall
RoundedCornerShape(12.dp) x11 = medium
RoundedCornerShape(16.dp) x2 = large
RoundedCornerShape(30.dp) x1 ~ extraLarge (28dp)
-- so overriding the scale would restyle the app for no reason. What is wrong is that they
are literals, which is how the last one drifted two units off the scale and why none of
them can move per breakpoint later. `Shape.kt` documents the eight steps and what each is
for; migrating those seventeen call sites is a later phase, and this is what they migrate
onto. Declaring it explicitly rather than relying on the default gives the note somewhere
to live.
**`MotionScheme.expressive()` is wired and unused.** Nothing in the app animates today --
one `animateScrollToPage`, no `AnimatedVisibility`, no navigation transitions -- so this
buys nothing yet. It is here so that when the motion phase starts, every spec comes from
the scheme rather than from a literal `tween`, and the app's feel is one decision instead
of forty.
**`Type.kt` left `com.example.ui.theme`.** It has been declaring that package while living
under `press/mantra/compose/ui/theme/`, one of three namespaces holding live UI code in
this tree. The move is mechanical; the doc comment on it is not. It records what each type
family is *for* -- `display*` for a screen's identity, `headline*` for section tops,
`title*` for headers and list headlines, `body*` for anything read as a sentence, `label*`
for **component text only** -- because the audit's finding is not that the scale is wrong
but that 92 of 240 reads are `label*` while `display*` and `headline*` carry 9 between them
across 43 screens. A UI at one pitch. The file stays baseline; the rule now has a home for
the sweep that fixes the call sites.
**The desktop unlock screen renders in the app's theme for the first time.**
`PassphraseGate` sat in the `else` branch beside `MantraApp`, which applies `TorchTheme`
itself -- so the gate composed under the default `MaterialTheme` and its
`colorScheme.error` and `typography.headlineSmall` were baseline M3. It is the first screen
a desktop user sees. `TorchTheme` now wraps both branches.
That wraps the unlocked branch twice, deliberately. `MantraApp` keeps its own `TorchTheme`
because android and ios enter through it and would lose the theme entirely if it moved out;
a second application of identical values costs one `CompositionLocalProvider` composition.
The comment says so, since the redundancy looks like an oversight.
**Dynamic colour stays on, by decision.** Also put to the product owner: on Android 12+
`dynamicColor = true` wins unconditionally, so the six schemes are used only below Android
12, on ios and on desktop, and a modern phone paints the wallpaper palette. Answered keep
as-is. A comment on the selection in `TorchTheme` now says this outright, because otherwise
the next person to change `Color.kt` and see nothing happen on their phone will assume the
change did not work.
**Tests.** 926 pass, 586 jvm over 71 classes and 340 android over 43, unchanged -- this
commit adds no assertions, because what it changes is either a library default (nothing to
assert that the compiler does not) or a doc comment. `:composeApp:compileDebugKotlinAndroid`
and `:composeApp:compileKotlinJvm` build, the debug apk installs and runs on emulator-5554
under the expressive theme, `m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.material3.Shapes
|
||||
|
||||
/**
|
||||
* The shape scale, and which corner belongs to what.
|
||||
*
|
||||
* Baseline M3, unmodified, for the same reason [AuxTypography] is: the values are already
|
||||
* right for this app. The corners hand-written across the tree land on the scale almost
|
||||
* exactly —
|
||||
*
|
||||
* RoundedCornerShape(4.dp) x3 = extraSmall
|
||||
* RoundedCornerShape(12.dp) x11 = medium
|
||||
* RoundedCornerShape(16.dp) x2 = large
|
||||
* RoundedCornerShape(30.dp) x1 ~ extraLarge (28dp)
|
||||
*
|
||||
* — so nothing needs restyling. What is missing is that they are literals, which is why the
|
||||
* 30dp one drifted two units from the scale and why nothing can be adjusted per breakpoint
|
||||
* or per density later. Migrating those seventeen call sites onto `MaterialTheme.shapes` is
|
||||
* a later phase; this file is what they migrate onto.
|
||||
*
|
||||
* **The scale, and what each step is for.** Under `MaterialExpressiveTheme` there are eight
|
||||
* steps rather than five:
|
||||
*
|
||||
* - `extraSmall` (4dp) — small containers whose corners should barely read: text field
|
||||
* indicators, small badges, snackbar edges.
|
||||
* - `small` (8dp) — chips and other compact controls.
|
||||
* - `medium` (12dp) — cards, and the default for a container holding a group of content.
|
||||
* - `large` (16dp) — bigger surfaces: bottom sheet tops, large cards, dialogs.
|
||||
* - `largeIncreased` (20dp) — expressive only. `large` where the container is prominent
|
||||
* enough to want the extra roundness.
|
||||
* - `extraLarge` (28dp) — extended FABs, prominent containers.
|
||||
* - `extraLargeIncreased` (32dp) — expressive only.
|
||||
* - `extraExtraLarge` (48dp) — expressive only. Very large surfaces, hero containers.
|
||||
*
|
||||
* A fully round shape is `CircleShape`, not a step on this scale.
|
||||
*
|
||||
* Declared explicitly rather than left to `MaterialTheme`'s default so that every slot of
|
||||
* the theme has a named home, and so this note has somewhere to live.
|
||||
*/
|
||||
val MantraShapes = Shapes()
|
||||
@@ -2,13 +2,14 @@ package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MotionScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.example.ui.theme.AuxTypography
|
||||
|
||||
// The six schemes are `internal` rather than `private` so ColorSchemeContrastTest can
|
||||
// walk the real objects. Testing a copy rebuilt in the test from Color.kt would assert
|
||||
@@ -422,6 +423,22 @@ internal fun appColorScheme(darkTheme: Boolean, contrast: ThemeContrast): ColorS
|
||||
if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's theme: colour, shape, type and motion, in one place.
|
||||
*
|
||||
* **Expressive, deliberately.** `MaterialExpressiveTheme` rather than `MaterialTheme` is a
|
||||
* product decision taken 2026-09-08. The pinned material3 1.10.0-alpha05 ships the whole
|
||||
* expressive set -- button groups, split buttons, floating toolbars, wide navigation rails,
|
||||
* loading indicators, the eight-step shape scale, thirty type roles -- and the tree already
|
||||
* opts into `ExperimentalMaterial3ExpressiveApi` in 66 places. The visible effect is that
|
||||
* components take their expressive defaults: rounder, larger, more motion. It is one
|
||||
* function name to change back.
|
||||
*
|
||||
* All four slots are passed explicitly. `MaterialExpressiveTheme` would otherwise supply
|
||||
* `expressiveLightColorScheme()` and friends, which are Material's palette rather than this
|
||||
* app's -- the same class of accident as the twelve unassigned fixed roles.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun TorchTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
@@ -435,11 +452,22 @@ fun TorchTheme(
|
||||
// `system_*` palette resources shift with it, so `dynamicLightColorScheme` needs
|
||||
// no help from `contrast`. Everywhere else the app's schemes answer, and that is
|
||||
// where `contrast` decides which of the six.
|
||||
//
|
||||
// Note what this means in practice: on Android 12+ the six schemes above are not
|
||||
// used at all, and the app takes the wallpaper palette. That is the intended
|
||||
// behaviour -- see "What this plan does not cover" in
|
||||
// docs/material-design-conformance.md -- but it is why a change to Color.kt shows
|
||||
// up on desktop and ios and not on a modern phone.
|
||||
val colorScheme = dynamicColorScheme(darkTheme, dynamicColor)
|
||||
?: appColorScheme(darkTheme, contrast)
|
||||
|
||||
MaterialTheme(
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
// Every animation in the app should come from here rather than from a literal
|
||||
// `tween`, so that the whole app's feel is one decision. Nothing reads it yet;
|
||||
// the motion phase is what puts it to work.
|
||||
motionScheme = MotionScheme.expressive(),
|
||||
shapes = MantraShapes,
|
||||
typography = AuxTypography,
|
||||
content = content
|
||||
)
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
package com.example.ui.theme
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
/**
|
||||
* The type scale, and which role carries what.
|
||||
*
|
||||
* The values are M3's baseline -- `Typography()` with nothing overridden -- which is a
|
||||
* deliberate starting point rather than an omission: the scale itself is well judged, and
|
||||
* this app has no brand typeface to substitute. What it lacks is not different numbers but
|
||||
* a rule about which of the roles to reach for, and the audit says so plainly.
|
||||
*
|
||||
* **Roles, and what belongs in each.** M3 gives fifteen roles in three sizes across five
|
||||
* families, and material3 1.10 adds an `…Emphasized` variant of every one, so thirty in
|
||||
* total. They are not interchangeable sizes; each family has a job:
|
||||
*
|
||||
* - `display*` — a screen's own identity. A number or a word that *is* the screen.
|
||||
* Sparing: at most one per screen, often none.
|
||||
* - `headline*` — the top of a screen or a major section. What a heading level 1 or 2
|
||||
* would be.
|
||||
* - `title*` — section headers, card headers, app bar titles, list item headlines.
|
||||
* - `body*` — prose. Anything the user reads a sentence of. **This is the default**; when
|
||||
* in doubt about a run of text, it is `bodyMedium` or `bodyLarge`.
|
||||
* - `label*` — **component text only**. Button labels, tab labels, chip labels, field
|
||||
* labels, badges, timestamps. Never a sentence.
|
||||
*
|
||||
* The `…Emphasized` variants are the same size at heavier weight, for the one line in a
|
||||
* block that carries the weight -- a sender's name above their message, the active item in
|
||||
* a list. They are not a second, bolder scale to reach for freely.
|
||||
*
|
||||
* **Why this is written down.** 92 of the app's 240 typography reads are `label*`, which is
|
||||
* the smallest and tightest family and is meant for component text, while `display*` and
|
||||
* `headline*` together carry 9 uses across 43 screens. The effect is a UI at one pitch:
|
||||
* body copy set in a label role reads as cramped, and nothing establishes a hierarchy
|
||||
* because the roles that would are unused. That is a call-site problem, not a scale
|
||||
* problem, which is why this file stays baseline while the rule lives here for the sweep
|
||||
* that fixes them.
|
||||
*
|
||||
* Until this commit this file declared `package com.example.ui.theme`, one of three package
|
||||
* namespaces holding live UI code in this tree.
|
||||
*/
|
||||
val AuxTypography = Typography()
|
||||
|
||||
@@ -37,6 +37,7 @@ import press.mantra.compose.MantraApp
|
||||
import press.mantra.compose.MantraGlobal
|
||||
import press.mantra.compose.PlatformContext
|
||||
import press.mantra.compose.defaultMantraDir
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -62,17 +63,28 @@ fun main() = application {
|
||||
) {
|
||||
var unlocked by remember { mutableStateOf(false) }
|
||||
|
||||
if (unlocked) {
|
||||
val navController = rememberNavController()
|
||||
MantraApp(
|
||||
navController = navController,
|
||||
mantraGlobal = MantraGlobal(platformContext = PlatformContext(appDir)),
|
||||
phoenixGlobal = PhoenixGlobal(
|
||||
ctx = fr.acinq.phoenix.utils.PlatformContext(applicationDir = appDir),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
PassphraseGate(appDir = appDir, onUnlocked = { unlocked = true })
|
||||
// TorchTheme wraps both branches, not just the app. PassphraseGate used to sit
|
||||
// beside MantraApp -- which applies the theme itself -- so it composed under the
|
||||
// default MaterialTheme and its colorScheme and typography were baseline M3
|
||||
// rather than this app's. It is the first screen a desktop user sees.
|
||||
//
|
||||
// MantraApp still applies TorchTheme, so the unlocked branch is wrapped twice.
|
||||
// That is deliberate: android and ios enter through MantraApp and would lose the
|
||||
// theme entirely if it moved out, and a second application of the same values
|
||||
// costs one composition of a CompositionLocalProvider.
|
||||
TorchTheme {
|
||||
if (unlocked) {
|
||||
val navController = rememberNavController()
|
||||
MantraApp(
|
||||
navController = navController,
|
||||
mantraGlobal = MantraGlobal(platformContext = PlatformContext(appDir)),
|
||||
phoenixGlobal = PhoenixGlobal(
|
||||
ctx = fr.acinq.phoenix.utils.PlatformContext(applicationDir = appDir),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
PassphraseGate(appDir = appDir, onUnlocked = { unlocked = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,23 +439,27 @@ find-and-replace.
|
||||
pixel of the "Skip for now" label reads `#848484` → `#A7A7A7` → `#D0D0D0` as the
|
||||
setting moves, the three declared values exactly, **without the app restarting**.
|
||||
|
||||
3. **Give `MaterialTheme` its other three slots.** `Shapes`, `Typography` and a
|
||||
`MotionScheme` are all parameters of the overload the app already calls:
|
||||
3. **Give the theme its other three slots.** *Built.* The expressive-vs-standard
|
||||
question was put to the product owner on 2026-09-08 and answered
|
||||
**expressive**, so `TorchTheme` calls `MaterialExpressiveTheme` with all four
|
||||
slots passed explicitly — colour, `MotionScheme.expressive()`, `MantraShapes`,
|
||||
`AuxTypography`. Explicitly, because `MaterialExpressiveTheme` would otherwise
|
||||
default them to `expressiveLightColorScheme()` and friends, which is Material's
|
||||
palette rather than this app's: the same class of accident as the twelve
|
||||
unassigned fixed roles.
|
||||
|
||||
```kotlin
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
motionScheme = MotionScheme.expressive(), // or .standard()
|
||||
shapes = MantraShapes,
|
||||
typography = MantraTypography,
|
||||
content = content,
|
||||
)
|
||||
```
|
||||
No visual change on the screens checked. The "Invite a Friend" button measures
|
||||
byte-identical before and after — same fill, same 357×… box — because the
|
||||
expressive default for a `Button` at default size matches the baseline in this
|
||||
version. What expressive buys is elsewhere: `LocalUsingExpressiveTheme`, the
|
||||
three increased shape steps, the fifteen `…Emphasized` type roles, and the
|
||||
components later phases need (button groups, split buttons, floating toolbars,
|
||||
wide navigation rails, `LoadingIndicator`).
|
||||
|
||||
Decide `MaterialExpressiveTheme` vs `MaterialTheme` here and once. The app
|
||||
already opts into `ExperimentalMaterial3ExpressiveApi` in 66 places, so the
|
||||
expressive default is the honest choice; it is also what makes the
|
||||
`…Emphasized` type roles and the increased shape sizes meaningful.
|
||||
`MantraShapes` is baseline `Shapes()`, and `Shape.kt` records why: the corners
|
||||
hand-written across the tree already land on the scale — 4dp ×3 = `extraSmall`,
|
||||
12dp ×11 = `medium`, 16dp ×2 = `large`, and one 30dp that drifted two units off
|
||||
`extraLarge`. Nothing needs restyling; seventeen literals need migrating, later.
|
||||
|
||||
4. **Move and fill typography.** `Type.kt` moves from `com.example.ui.theme` to
|
||||
`press.mantra.compose.ui.theme`. It stays baseline-derived, but it becomes a
|
||||
@@ -468,8 +472,15 @@ find-and-replace.
|
||||
`container`/`on`/`onContainer`. `CreateProfileScreen.kt:311` and `:329` then
|
||||
stop pairing them with `Color.White`/`Color.DarkGray` by eye.
|
||||
|
||||
6. **Wrap the desktop gate.** `Main.kt` moves `TorchTheme` outside the
|
||||
`unlocked` branch so `PassphraseGate` composes inside it.
|
||||
6. **Wrap the desktop gate.** *Built.* `Main.kt` moves `TorchTheme` outside the
|
||||
`unlocked` branch, so `PassphraseGate` — the first screen a desktop user sees —
|
||||
stops composing under the default `MaterialTheme`. `MantraApp` keeps its own
|
||||
`TorchTheme`, so the unlocked branch is wrapped twice: android and ios enter
|
||||
through `MantraApp` and would lose the theme entirely if it moved out, and a
|
||||
second application of the same values costs one `CompositionLocalProvider`.
|
||||
|
||||
`Type.kt` also left `com.example.ui.theme` for `press.mantra.compose.ui.theme`,
|
||||
which removes one of the three package namespaces the UI was living across.
|
||||
|
||||
**Done when** every role in `ColorScheme` is explicitly assigned in both
|
||||
schemes; the contrast test still passes and now covers six schemes; `Type.kt`
|
||||
|
||||
Reference in New Issue
Block a user