Reserve Home hero primary action space
All checks were successful
review/code-claude Code APPROVE: delta f375d880 fine
review/design-claude Design PASS: gear restored, hero buttons stationary

This commit is contained in:
justinzeus 2026-11-07 19:46:17 +01:00
parent 7c67596cb6
commit f375d88030
3 changed files with 73 additions and 4 deletions

View file

@ -34,6 +34,10 @@ An unvisited Home row starts at its leftmost card rather than inheriting horizon
Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band, as Justin requested. **Implementation choice for the amount:** title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp. The existing approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry.
Home hero actions retain the approved primary + secondary Details layout: [st-007, Mindel’s Resume hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-007-mindel-resume-in-the-hero.jpg) and [st-006, Justin’s Play-next hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-006-justin-no-resume-hero-falls-back-to-next-up.jpg). Cached Home can paint before fresh watch data verifies playback. During that interval, measure and reserve the real primary button’s footprint without placing, drawing or enabling it; keep Details in its final secondary position and available for browsing. Playback readiness reveals the primary with the existing 200 ms late-line fade, never a lateral layout shift. The copy layer and button/spacing tokens do not change.
Source — Justin (2026-11-07): “the resume button for the home hero item only appears after a second or two, it appearing causes the details button to shift places, this needs to be fixed as well”.
## Episode artwork and dimensions

View file

@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
@ -46,10 +47,21 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val detailsOnly = row.heroKind == "pick" || !row.watchReady
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
val detailsOnly = row.heroKind == "pick"
if (!detailsOnly) Layout(
modifier = if (row.watchReady) Modifier.lateLineFade(title.id) else Modifier,
content = {
val primary = (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && row.watchReady && column == 0, true, "playback",
onFocus = { if (row.watchReady) onHover(0) }, textStyle = T.button) { if (row.watchReady) onButton(0) }
},
) { measurables, constraints ->
val button = measurables.single().measure(constraints)
// Reserve the real primary width; an unplaced button is neither drawn nor interactive.
layout(button.width, button.height) { if (row.watchReady) button.placeRelative(0, 0) }
}
val detailsColumn = if (detailsOnly || !row.watchReady) 0 else 1
PickerButton("Details", focused && column == detailsColumn, detailsOnly, "about", onFocus = { onHover(detailsColumn) }, textStyle = T.button) { onButton(detailsColumn) }
}
}
}

View file

@ -0,0 +1,53 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Fresh watch data may enable playback, but cannot move an already-visible Details action. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeHeroActionsTest {
@get:Rule val rule = createComposeRule()
@Test fun latePlaybackReadinessKeepsDetailsBoundsForResumePlayAndEpisodeLabels() {
val title = HomeTitle("arrival", "Arrival", "movie", overview = "A linguist investigates a mysterious arrival.")
val episode = title.copy(id = "episode", type = "episode", season = 3, episode = 7)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "hero-actions").apply { mkdirs() }
val current = mutableStateOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(title), heroKind = "resume", watchReady = false))
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
HomeHero(current.value, false, 0, emptyList(), {}, {})
}
} }
for ((kind, item) in listOf("resume" to episode, "new" to title, "next" to episode)) {
rule.runOnIdle { current.value = current.value.copy(cards = listOf(item), heroKind = kind, watchReady = false) }
rule.mainClock.advanceTimeBy(1000)
val before = rule.onNodeWithText("Details").fetchSemanticsNode().boundsInRoot
val primary = (if (kind == "resume") "Resume" else "Play") + if (item.isEpisode) " ${item.episodeLabel}" else ""
rule.onNodeWithText(primary).assertIsNotDisplayed()
rule.onRoot().captureRoboImage(File(directory, "$kind-pending.png").absolutePath)
rule.runOnIdle { current.value = current.value.copy(watchReady = true) }
rule.mainClock.advanceTimeBy(400)
val after = rule.onNodeWithText("Details").fetchSemanticsNode().boundsInRoot
rule.onRoot().captureRoboImage(File(directory, "$kind-ready.png").absolutePath)
assertEquals("Late $primary must not move Details", before, after)
}
}
}