From f375d88030abd14bf7b6b262792af6f5e20b3acf Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 19:46:17 +0100 Subject: [PATCH] Reserve Home hero primary action space --- data/tv-spec/spec.md | 4 ++ .../wolfskin/design/HomeHeader.kt | 20 +++++-- .../wolfskin/design/HomeHeroActionsTest.kt | 53 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index ec13f225..adcb0c6c 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index 8057e033..e191c804 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -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 + 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) } } } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt new file mode 100644 index 00000000..57552aac --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt @@ -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) + } + } +}