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:
@@ -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 40–60 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user