feat: hold every screen's content to a readable line, and centre it in the window

Phase 6, step 5, first half. Every one of the 40 screens rendered a single column
that filled whatever width it was given, so on a 1800dp desktop window a
paragraph became a 1800dp line -- long enough that the eye loses the start of the
next one -- and a six-character text field stretched to 1700dp.

M3: *"across all breakpoints, adjust margins and type styles to keep text between
40–60 characters per line."*

**The measure is derived, not written down.** `readableContentWidth()` is
`bodyLarge`'s font size converted through the current density, times half an em
per character, times sixty: 480dp at the default text size. Writing `480.dp`
instead would be the same number today and wrong for anybody who has turned text
size up -- at 200% the same column holds thirty characters, silently, because the
text still fits. Deriving it means the column widens with the type and keeps its
sixty. `AverageCharacterAdvance` is the one estimate in it, named and documented,
because a proportional face has no character width and half an em is the standard
figure for mixed-case Latin prose.

Only the ceiling is enforced. The floor needs nothing: a 400dp compact window
less its two 16dp margins holds about 46 characters, which is inside the range,
and no cap can add characters to a window that has none. There is a test for
exactly that, so the claim is checked rather than asserted in a comment.

**The column is centred; the text is not.** Those are opposite things and it is
worth being explicit, because "centre it" is how the second one gets done by
accident. A centred column still has one straight leading edge for every row,
avatar and icon to align to, which is what the grids-and-spacing page asks for.
Centred text has none. The 91 `TextAlign.Center` uses are a separate question and
a separate commit.

**Applied at 49 sites in one pass**, at the point every screen consumes its
`Scaffold`'s padding -- the one place in each file that is reliably the top of the
content. Below 480dp it is not a cap, an inset or a centring; it is nothing, so
no phone layout moves.

**Verified by measuring a real composition, not by reading the code.**
`readableContent()` is `fillMaxWidth` then `wrapContentWidth` then `widthIn`, and
every permutation of those three compiles and renders something that looks right
in a phone-width preview. This needed `compose.desktop.uiTestJUnit4` in `jvmTest`
-- pinned to the same 1.11.1 as the rest of Compose Multiplatform, test-only --
and `runDesktopComposeUiTest(width = 1400)`, which gives a window that genuinely
is 1400 pixels across at density 1.

Four assertions, and they bite: swapping the last two modifiers makes the
1400dp case report `Actual width is 1400.0.dp, expected 480.0.dp`, which is the
"centred but never capped" failure the doc comment names. The same test also
pins `currentBreakpoint()` to the real window at all five widths -- 400, 700,
1000, 1400, 1800 -- with the screen margin following. A version of it that
measured the parent's constraints rather than the window would answer `Compact`
everywhere and pass every unit test in the suite.

37 theme tests green; android and desktop both compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 07:31:33 +02:00
parent b5bb0042b1
commit 05e80bf099
44 changed files with 380 additions and 29 deletions

View File

@@ -0,0 +1,70 @@
package press.mantra.compose.ui.theme
import androidx.compose.ui.unit.dp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The reading measure against M3's 4060 characters per line.
*
* The whole point of deriving the cap rather than writing `480.dp` is that it follows the
* reader's text size, and that is the property no screenshot catches: a hardcoded column
* still looks correct at 200% text scale, it just holds thirty characters instead of
* sixty.
*/
class ReadableMeasureTest {
@Test
fun `sixty characters of the default body size is 480dp`() {
// bodyLarge is 16sp in the baseline scale, and 1sp is 1dp at the default text
// size. Half an em per character times sixty.
assertEquals(480.dp, readableWidthFor(16.dp))
}
@Test
fun `the measure follows the text size rather than the window`() {
// A reader at 200% text size gets a column twice as wide, and still sixty
// characters. This is the assertion a constant cannot pass, and the reason the
// function takes a font size at all.
assertEquals(
readableWidthFor(16.dp) * 2f,
readableWidthFor(32.dp),
"doubling the text size did not double the measure",
)
}
@Test
fun `the cap is the top of M3's range and not the bottom`() {
// 40 is the floor and 60 the ceiling; a max-width enforces the ceiling. Capping
// at 40 would be the same mistake in the other direction -- a column too narrow
// to read comfortably on any window wide enough to matter.
assertEquals(MaxCharactersPerLine, 60)
assertTrue(readableWidthFor(16.dp, 40) < readableWidthFor(16.dp, 60))
}
@Test
fun `a phone window holds a line inside the range`() {
// The check that the floor needs no enforcement. A 400dp compact window less its
// two 16dp margins is 368dp, which at 8dp a character is 46 -- inside 40 to 60,
// so the cap is a no-op there and nothing has to widen anything.
val compactContent = 400.dp - spacingFor(Breakpoint.Compact).screenMargin * 2f
val charactersPerLine = compactContent / (readableWidthFor(16.dp) / 60f)
assertTrue(
charactersPerLine in 40f..60f,
"a 400dp window fits $charactersPerLine characters, outside M3's 40-60",
)
assertTrue(
compactContent < readableWidthFor(16.dp),
"the measure is narrower than a phone, so it would crop rather than cap",
)
}
@Test
fun `a desktop window is held to the measure rather than filled`() {
// The case this exists for. 1800dp of window, 480dp of text, and the remaining
// 1320dp becomes margin instead of a line nobody can track back to its start.
assertTrue(readableWidthFor(16.dp) < 1800.dp)
}
}