diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt new file mode 100644 index 00000000..bf3a13af --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt @@ -0,0 +1,90 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * M3's spacing scale, and the semantic names layered over it. + * + * `MaterialTheme` has no spacing slot, so this rides a composition local beside it, the + * same way [ExtendedColors] does. [TorchTheme] provides it. + * + * **The scale** is M3's own, an 8dp system where `space100 = 8dp`, including the sub-8 + * nested units Material defines because its components need them. Numbers are from + * m3.material.io/m3/pages/spacing/tokens, read September 2026; see + * docs/material-design-conformance.md for the full table. + * + * **Why a `data class` and not constants.** Nothing scales today, but two things are + * coming that need to: spacing adapts across breakpoints, and M3 has a density setting + * for data-heavy views. Both are a matter of providing a different [Spacing] instance + * rather than of touching a call site, and that is only true if the values arrive through + * the local. A file of top-level `val`s would read the same and adapt to nothing. + */ +@Immutable +data class Spacing( + val space0: Dp = 0.dp, + val space25: Dp = 2.dp, + val space50: Dp = 4.dp, + val space75: Dp = 6.dp, + val space100: Dp = 8.dp, + val space125: Dp = 10.dp, + val space150: Dp = 12.dp, + val space175: Dp = 14.dp, + val space200: Dp = 16.dp, + val space250: Dp = 20.dp, + val space300: Dp = 24.dp, + val space400: Dp = 32.dp, + val space450: Dp = 36.dp, + val space500: Dp = 40.dp, + val space600: Dp = 48.dp, + val space700: Dp = 56.dp, + val space800: Dp = 64.dp, + val space900: Dp = 72.dp, +) { + // ----------------------------------------------------------------------- + // Semantic names + // ----------------------------------------------------------------------- + // + // `space125` at a call site is no more readable than `10.dp` -- it says the size and + // not the job. These say the job, and they are what call sites should reach for; the + // raw scale is for the cases none of them fits. + // + // The distinction M3 draws, and the reason the names are split this way: + // + // padding space inside an element, between its edge and its content + // gap space between elements in a row, column or grid + // margin space outside an element, between it and its parent or the screen + // + // The spec is explicit that margins are a last resort -- "define padding and gaps on + // the parent container", "avoid defining margins on child elements as they usually + // aren't uniform, and require more tokens" -- so there is exactly one margin here, + // for the screen edge, and everything else is padding or a gap. + + /** Screen edge to content. The one margin; everything inside a screen is padding or a gap. */ + val screenMargin: Dp get() = space200 + + /** Inside a card, dialog, sheet or list row: container edge to its content. */ + val containerPadding: Dp get() = space200 + + /** Inside a compact container -- a chip, a badge, a dense row. */ + val compactPadding: Dp get() = space100 + + /** Between two elements that belong to the same thought: a label and its value. */ + val relatedGap: Dp get() = space50 + + /** The default gap between items in a list or column. */ + val itemGap: Dp get() = space100 + + /** Between one group of content and the next within a screen. */ + val sectionGap: Dp get() = space300 + + /** Around a lone element that needs to stand apart -- an empty state, a hero action. */ + val emphasisGap: Dp get() = space500 + + /** Between adjacent touch targets, which M3 asks to be at least 8dp apart. */ + val targetGap: Dp get() = space100 +} + +val LocalSpacing = staticCompositionLocalOf { Spacing() } 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 761a44ab..c6594078 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 @@ -497,7 +497,13 @@ fun TorchTheme( val colorScheme = dynamicColorScheme(darkTheme, dynamicColor) ?: appColorScheme(darkTheme, contrast) - CompositionLocalProvider(LocalExtendedColors provides extendedColorsFor(darkTheme)) { + CompositionLocalProvider( + LocalExtendedColors provides extendedColorsFor(darkTheme), + // Nothing scales it yet. It rides the theme now so that the breakpoint phase can + // provide a wider instance without touching a call site -- which is the whole + // reason for tokenising spacing rather than leaving it in literals. + LocalSpacing provides Spacing(), + ) { MaterialExpressiveTheme( colorScheme = colorScheme, // Every animation in the app should come from here rather than from a literal diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/SpacingScaleTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/SpacingScaleTest.kt new file mode 100644 index 00000000..e72dbb57 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/SpacingScaleTest.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The spacing scale against M3's published token values. + * + * A scale is only worth having if it is the same scale everyone else is using. These are + * transcribed from m3.material.io/m3/pages/spacing/tokens, and the point of asserting them + * is that a transcription error is invisible: `space175 = 15.dp` would look entirely + * plausible in the source, would compile, and would put every call site that reached for + * it one unit off the grid. + */ +class SpacingScaleTest { + + private val spacing = Spacing() + + /** M3's system spacing tokens, token name to value. */ + private val published: List> = listOf( + "space0" to 0.dp, + "space25" to 2.dp, + "space50" to 4.dp, + "space75" to 6.dp, + "space100" to 8.dp, + "space125" to 10.dp, + "space150" to 12.dp, + "space175" to 14.dp, + "space200" to 16.dp, + "space250" to 20.dp, + "space300" to 24.dp, + "space400" to 32.dp, + "space450" to 36.dp, + "space500" to 40.dp, + "space600" to 48.dp, + "space700" to 56.dp, + "space800" to 64.dp, + "space900" to 72.dp, + ) + + private val declared: List> = listOf( + "space0" to spacing.space0, + "space25" to spacing.space25, + "space50" to spacing.space50, + "space75" to spacing.space75, + "space100" to spacing.space100, + "space125" to spacing.space125, + "space150" to spacing.space150, + "space175" to spacing.space175, + "space200" to spacing.space200, + "space250" to spacing.space250, + "space300" to spacing.space300, + "space400" to spacing.space400, + "space450" to spacing.space450, + "space500" to spacing.space500, + "space600" to spacing.space600, + "space700" to spacing.space700, + "space800" to spacing.space800, + "space900" to spacing.space900, + ) + + @Test + fun `every stop matches its published value`() { + declared.zip(published).forEach { (mine, theirs) -> + assertEquals(theirs.second, mine.second, "${mine.first} is ${mine.second}, M3 says ${theirs.second}") + } + } + + @Test + fun `the token name is the value's relation to space100`() { + // The naming rule, which is what makes the scale readable: the number after + // "space" is the value as a percentage of the 8dp base. space250 is 20dp because + // 20 is 250% of 8. A stop that does not obey it is a stop nobody can predict. + val base = 8.0 + declared.forEach { (name, value) -> + val percent = name.removePrefix("space").toInt() + assertEquals( + base * percent / 100.0, + value.value.toDouble(), + absoluteTolerance = 0.001, + message = "$name should be ${base * percent / 100.0}dp to match its name, is $value", + ) + } + } + + @Test + fun `the scale rises`() { + declared.zipWithNext().forEach { (a, b) -> + assertTrue(b.second > a.second, "${b.first} (${b.second}) is not greater than ${a.first} (${a.second})") + } + } + + @Test + fun `every semantic name resolves to a stop on the scale`() { + // The semantic layer exists so call sites say the job rather than the size. It + // stops being a scale the moment one of them is given a literal instead, which is + // an easy thing to do and an invisible thing to review. + val stops = declared.map { it.second }.toSet() + val semantic = listOf( + "screenMargin" to spacing.screenMargin, + "containerPadding" to spacing.containerPadding, + "compactPadding" to spacing.compactPadding, + "relatedGap" to spacing.relatedGap, + "itemGap" to spacing.itemGap, + "sectionGap" to spacing.sectionGap, + "emphasisGap" to spacing.emphasisGap, + "targetGap" to spacing.targetGap, + ) + + semantic.forEach { (name, value) -> + assertTrue(value in stops, "$name is $value, which is not on the scale") + } + } + + @Test + fun `adjacent touch targets are held at least 8dp apart`() { + // M3: "targets separated by 8dp of space or more promote balanced information + // density and usability." targetGap is what the phase 3 sweep applies between + // icon buttons, so it is the one semantic name with an external floor. + assertTrue( + spacing.targetGap >= 8.dp, + "targetGap is ${spacing.targetGap}, below M3's 8dp minimum separation", + ) + } + + @Test + fun `a scaled instance moves every stop and every semantic name with it`() { + // What the data class is for. The breakpoint phase provides a wider Spacing at + // larger windows; if a semantic name were a hardcoded Dp rather than a reference + // to a stop, it would stay behind and the layout would half-adapt. + val wide = Spacing(space200 = 24.dp, space300 = 32.dp) + + assertEquals(24.dp, wide.screenMargin, "screenMargin did not follow space200") + assertEquals(24.dp, wide.containerPadding, "containerPadding did not follow space200") + assertEquals(32.dp, wide.sectionGap, "sectionGap did not follow space300") + assertEquals(spacing.itemGap, wide.itemGap, "itemGap moved without space100 moving") + } +}