Clamp Discover text boxes and preserve recovery button height
All checks were successful
review/design-claude B1-B3 re-measured by firstmate from the lane measurement file against the locked numbers (all within 2 px); delta is the three scoped fixes plus an additive main merge; N1/N2 3-4 px offsets remain as listed follow-ups

This commit is contained in:
justinzeus 2026-11-05 20:22:47 +01:00
parent bfd4002dd1
commit 130697c2eb
5 changed files with 56 additions and 7 deletions

View file

@ -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 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. 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 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 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 Discover in 4 presses, requesting for yourself in 7, requesting for Mindel in 9 and

View file

@ -54,8 +54,10 @@ fun DiscoverOverlay(state: DiscoverState) {
} }
} }
is DiscoverOverlayState.Restore -> { is DiscoverOverlayState.Restore -> {
Text(overlay.title.title, color = C.fg, style = DiscoverType.sheetTitle) Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text("Hidden from ${state.shell.person.name}’s picks.", color = C.muted, style = DiscoverType.body) 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)) { Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) } DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape 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) Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
} }
Spacer(Modifier.height(D.heroKickerGap)) 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)) Spacer(Modifier.height(D.heroTitleGap))
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) { 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) 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) { private fun DiscoverStatePanel(state: DiscoverState) {
val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn
Box(Modifier.fillMaxWidth().height(L.row_state)) { 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)) .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) .border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) { .padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) {

View file

@ -1,6 +1,12 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design 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.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle 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 entry = WolfskinTokens.Discover.entryText.copy(lineHeightStyle = lineBox, platformStyle = platform)
val ratings = WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum", 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)
}
}

View file

@ -14,6 +14,8 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.SemanticsMatcher 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.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
@ -26,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.Assert.assertTrue
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -89,7 +92,14 @@ class DiscoverCaptureTest {
@Test fun st029_discover() = capture("st-029") @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 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 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 -> @Test fun st033_failedBadge() = capture("st-033") { state ->
val lane = state.lanes.first { it.id == "pk-movie" } val lane = state.lanes.first { it.id == "pk-movie" }
state.requests[lane.titles[1].key] = listOf(DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed", requester = "justin")) 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.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore))
state.collection!!.index = state.collection!!.tailIndex state.collection!!.index = state.collection!!.tailIndex
} }
@Test fun st036_needsSignIn() = capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) }) @Test fun st036_needsSignIn() {
@Test fun st037_series() = capture("st-037", person = "mindel") { it.filter = "tv" } 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<TextLayoutResult>()
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 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 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()) } @Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) }