diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Shape.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Shape.kt new file mode 100644 index 00000000..80eb881e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Shape.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index b3e5c885..499efe98 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -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 ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Type.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Type.kt index bc939ab7..21409f9b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Type.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Type.kt @@ -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() diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt index 81e4ded9..925c3bcd 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt @@ -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 }) + } } } } diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md index 48626aee..19be2fde 100644 --- a/docs/material-design-conformance.md +++ b/docs/material-design-conformance.md @@ -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`