From 130697c2eb0e79c6257be20362c27572dd374eab Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 20:22:47 +0100 Subject: [PATCH] Clamp Discover text boxes and preserve recovery button height --- docker/README.md | 6 ++++ .../wolfskin/design/DiscoverOverlay.kt | 6 ++-- .../wolfskin/design/DiscoverScreen.kt | 5 ++-- .../wolfskin/design/DiscoverType.kt | 17 +++++++++++ .../wolfskin/design/DiscoverCaptureTest.kt | 29 +++++++++++++++++-- 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docker/README.md b/docker/README.md index 93ed2a0d..1c633a7d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -111,6 +111,12 @@ wired to a live server. Unknown request outcomes force a fresh title-requests re instead of blindly submitting again, including after closing a sheet or choosing the same uncertain recipient from another chooser. Whole-series requests require every season. +Discover hero and Restore text use explicit per-line CSS allocations so native +paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles +retain the sheet's negative top margin and bottom gap. Recovery panels may +overflow their fixed-height scroll row so sign-in copy never shrinks the retry +button. Native regressions cover those three layout boundaries. + Discover's native capture suite covers its 28 named acceptance states and emits text-layout metrics beside the PNGs. Data-driven Android key replays cover opening Discover in 4 presses, requesting for yourself in 7, requesting for Mindel in 9 and diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index 2a01136c..83cacb79 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -54,8 +54,10 @@ fun DiscoverOverlay(state: DiscoverState) { } } is DiscoverOverlayState.Restore -> { - Text(overlay.title.title, color = C.fg, style = DiscoverType.sheetTitle) - Text("Hidden from ${state.shell.person.name}’s picks.", color = C.muted, style = DiscoverType.body) + Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) + Text("Hidden from ${state.shell.person.name}’s picks.", + Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), + color = C.muted, style = DiscoverType.body) Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) } DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 629fdc0e..68ac7436 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape @@ -125,7 +126,7 @@ private fun DiscoverHero(state: DiscoverState) { Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label) } Spacer(Modifier.height(D.heroKickerGap)) - Text(title.title, Modifier.width(L.heroCopyWidth), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(title.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis) Spacer(Modifier.height(D.heroTitleGap)) Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) { Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body) @@ -166,7 +167,7 @@ private fun DiscoverChips(state: DiscoverState) { private fun DiscoverStatePanel(state: DiscoverState) { val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn Box(Modifier.fillMaxWidth().height(L.row_state)) { - Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth) + Column(Modifier.padding(start = L.safeSide, top = D.stateTop).wrapContentHeight(Alignment.Top, unbounded = true).width(L.stateWidth) .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) .border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) .padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt index 05a3a675..45df9669 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt @@ -1,6 +1,12 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.TextStyle +import kotlin.math.roundToInt import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineHeightStyle @@ -22,3 +28,14 @@ internal object DiscoverType { val entry = WolfskinTokens.Discover.entryText.copy(lineHeightStyle = lineBox, platformStyle = platform) val ratings = WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum", lineHeightStyle = lineBox, platformStyle = platform) } + +/** Paragraph overflow must not expand the CSS block height. */ +internal fun Modifier.discoverLineBox(style: TextStyle): Modifier = layout { measurable, constraints -> + val paragraph = measurable.measure(constraints) + val lineHeight = style.lineHeight.toPx() + val lines = ((paragraph[LastBaseline] - paragraph[FirstBaseline]) / lineHeight).roundToInt() + 1 + val height = (lines * lineHeight).roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight) + layout(paragraph.width, height) { + paragraph.placeRelative(0, (height - paragraph.height) / 2) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt index 50e8401c..032f59f8 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.text.TextLayoutResult @@ -26,6 +28,7 @@ import kotlinx.coroutines.Dispatchers import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.Assert.assertTrue import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -89,7 +92,14 @@ class DiscoverCaptureTest { @Test fun st029_discover() = capture("st-029") @Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) } @Test fun st031_hidden() = capture("st-031") { hidden(it) } - @Test fun st032_restore() = capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) } + @Test fun st032_restore() { + capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) } + val title = rule.onAllNodesWithText(fixture.snapshot("justin").picks[7].title, useUnmergedTree = true) + .fetchSemanticsNodes().maxBy { it.boundsInRoot.left }.boundsInRoot + val sub = rule.onNodeWithText("Hidden from Justin’s picks.", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val gap = with(rule.density) { (WolfskinTokens.Discover.sheetGap + WolfskinTokens.Discover.sheetSubTop).toPx() } + assertTrue("Restore subtitle must retain the negative top margin", kotlin.math.abs(sub.top - title.bottom - gap) <= 1f) + } @Test fun st033_failedBadge() = capture("st-033") { state -> val lane = state.lanes.first { it.id == "pk-movie" } state.requests[lane.titles[1].key] = listOf(DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed", requester = "justin")) @@ -103,8 +113,21 @@ class DiscoverCaptureTest { state.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore)) state.collection!!.index = state.collection!!.tailIndex } - @Test fun st036_needsSignIn() = capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) }) - @Test fun st037_series() = capture("st-037", person = "mindel") { it.filter = "tv" } + @Test fun st036_needsSignIn() { + capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) }) + val button = rule.onNodeWithText("Try again").fetchSemanticsNode().boundsInRoot + val minimum = with(rule.density) { WolfskinTokens.Layout.buttonHeight.toPx() } + assertTrue("The sign-in button must not shrink to fit the scroll row", button.height >= minimum) + } + @Test fun st037_series() { + capture("st-037", person = "mindel") { it.filter = "tv" } + val title = rule.onNodeWithText("56 Days", useUnmergedTree = true).fetchSemanticsNode() + val layouts = mutableListOf() + title.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) + val layout = layouts.single() + val cssHeight = with(rule.density) { DiscoverType.hero.lineHeight.toPx() } * layout.lineCount + assertTrue("Natural font height must not inflate the hero CSS line box", kotlin.math.abs(title.boundsInRoot.height - cssHeight) <= 1f) + } @Test fun st038_noSeeds() = capture("st-038", snapshotTransform = { it.copy(status = DiscoverStatus.NoSeeds) }) @Test fun st039_trendingCollection() = capture("st-039") { state -> state.openCollection(state.lanes.first { it.id == "trending" }) } @Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) }