From c5fecb296fd25a8ab698c148c3616b7b3ccf7d5f Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 01:51:13 -0400 Subject: [PATCH 1/2] Give an icon button its own ring of space, and let that be the spacing Every gap around a header icon now comes out of the button's own padding: one ring to the screen edge, two where a button meets its neighbour. The box was the size of the mark (28dp) and the separation was bolted on beside it, so on the session header the two marks stood 31dp apart while the outer one was 14dp from the edge of the screen -- a pair that acts on one screen reading as two unrelated marks, one of them falling off it. Measured on the emulator at 40dp: 23dp between the marks, 27dp to the edge, and the same ring above and below. The mark was also not square, which is why an arrow looked taller than it was wide. `Glyph` is a `Text`, and it inherited the body style's 24sp line height around a 17sp mark; this font's ascent and descent add up to exactly one em, so a line height of the point size is the square the glyph draws in. That leading is also what a button's padding had to be measured through. 40dp is Material's own icon-button state layer, and it is what the pressed-state ripple draws -- at 28dp that circle was inscribed in the mark's corners, and beside a title it arrived at the first letter. The touch target comes up with it, from well under the platform's 48dp minimum to within 8dp of it; going the rest of the way would put the marks back 31dp apart. Co-Authored-By: Claude Opus 5 --- .../kotlin/com/example/aiapp/MainScreen.kt | 6 +- .../kotlin/com/example/aiapp/NerdIcons.kt | 72 +++++++++++++------ .../kotlin/com/example/aiapp/SessionScreen.kt | 8 ++- .../com/example/aiapp/SettingsScreen.kt | 2 +- 4 files changed, 60 insertions(+), 28 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt index 4c65b18..0423f36 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -1,7 +1,6 @@ package com.example.aiapp import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -99,7 +98,10 @@ fun MainScreen( // keep its own line. They sit on the title's row because they act on the whole // screen -- everything below this row is one tab's business, and a control belongs // with the thing it acts on. - Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) { + // Flush against each other: a glyph button carries its own padding, so two of them + // side by side already have two rings between their marks and one ring plus this + // row's padding to the screen edge. + Row { GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ }) GlyphButton(SETTINGS_GLYPH, "Settings", onSettings) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 16eaec8..fa50b37 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -107,21 +107,48 @@ val BELL_GLYPH = glyph(0xF009A) val USAGE_GLYPH = glyph(0xF201) /** - * The size of a glyph button's box, which is the size of the glyph itself. + * The size an icon draws at beside a line of text. * - * Not the 48dp of a default `IconButton`: that box centres its drawing inside itself, so aligning - * the box against a title aligns nothing a reader can see, and the pressed-state ripple lands on - * the box rather than on the mark. A box that *is* the glyph aligns like any other content and - * takes its ripple with it. The cost is the touch target -- `minimumInteractiveComponentSize` is - * applied inside the caller's modifier, so a size set here wins over it. + * 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at + * most 0.83 em of its point size and most filled a good deal less, so the number was standing in + * for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and + * keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked + * for. This is what the largest of them already drew at. */ -private val GLYPH_BUTTON_SIZE = 28.dp +private val GLYPH_SIZE = 17.sp /** - * Restores the separation the 48dp boxes used to provide, now that the boxes are the size of what - * they draw: two of these plus the gap come to the same 48dp centre-to-centre spacing. + * The same measurement in dp: a glyph's em box is its point size, and a layout is laid out in dp. */ -val GLYPH_BUTTON_GAP = 48.dp - GLYPH_BUTTON_SIZE +private val GLYPH_EXTENT = GLYPH_SIZE.value.dp + +/** + * The square a glyph button occupies: the mark, plus the same ring of padding on all four sides. + * + * The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to + * the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of + * its own, and a mark cannot end up further from the button beside it than from the edge of the + * screen. That is what it was: the box was the size of the mark (28dp) and the separation was + * bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the + * edge, so a pair that acts on one screen read as two unrelated marks with one falling off it. + * + * 40dp is Material's own icon-button state layer, and it is also what the pressed-state ripple + * draws: at 28dp that circle was inscribed in the mark's own corners. The touch target grows with + * it, from well under the platform's 48dp minimum to within 8dp of it. + */ +private val GLYPH_BUTTON_SIZE = 40.dp + +/** + * The ring itself, for putting something that is *not* a glyph button next to one -- a title beside + * a back arrow. + * + * Two glyph buttons need nothing between them: each brings its own ring and the two add up, which + * is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for. + * Without it the pressed-state circle, which fills the whole square, arrives at the first letter of + * the title -- and the gap a reader sees between the mark and that title is then half the one + * between the two marks at the other end of the same row. + */ +val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2 /** * A glyph you can press: the icon equivalent of a `TextButton`. @@ -166,16 +193,17 @@ fun Glyph( colour: Color = MaterialTheme.colorScheme.primary, size: TextUnit = GLYPH_SIZE, ) { - Text(glyph, fontFamily = NerdIcons, fontSize = size, color = colour, modifier = modifier) + // Line height of the point size, which for this font is the square the glyph draws in: its + // ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em. + // Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a + // glyph took a seventh more vertical space than horizontal wherever one is drawn without a box + // around it -- and where there is a box, that leading is what its padding is measured through. + Text( + glyph, + fontFamily = NerdIcons, + fontSize = size, + lineHeight = size, + color = colour, + modifier = modifier, + ) } - -/** - * The size an icon draws at beside a line of text. - * - * 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at - * most 0.83 em of its point size and most filled a good deal less, so the number was standing in - * for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and - * keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked - * for. This is what the largest of them already drew at. - */ -private val GLYPH_SIZE = 17.sp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 1462ddc..61630f5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1389,10 +1389,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () Column(Modifier.fillMaxSize()) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), ) { GlyphButton(BACK_GLYPH, "Back", onBack) - Spacer(Modifier.width(8.dp)) + // A ring's worth, which is what the arrow already keeps on its other three sides -- + // the pair of glyph buttons at the far end of this row get theirs from each other. + Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.titleMedium) // Machine first, then what runs on it -- the same order and the same wording @@ -1424,7 +1426,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // yellow or red near a limit -- and the theme's plain control colour whenever there // is no measurement, since blue is the low end of the scale here and would read as // "checked, and fine" about a machine nobody could reach. - Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) { + Row { GlyphButton( USAGE_GLYPH, "Usage", diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt index 3714478..bb9c216 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -87,7 +87,7 @@ fun SettingsScreen( // a capability being withheld but a promise it could not keep. if (onBack != null) { GlyphButton(BACK_GLYPH, "Back", onBack) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) } Text( "Server", From 4dd1e6ed8ce348eca83b7dea50d501b4622787c0 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 01:52:12 -0400 Subject: [PATCH 2/2] Drop the import the icon row no longer needs ktfmt's, after the merge: nothing in the session screen arranges a row by hand any more. Co-Authored-By: Claude Opus 5 --- .../src/main/kotlin/com/example/aiapp/SessionScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index bf56da4..af6ec12 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -5,7 +5,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column