Fix focused card captions and leave loading bands blank
All checks were successful
review/code-claude Blocker fixed on c2f7ccc4, delta reviewed
review/design-claude Blocker fixed on c2f7ccc4, delta reviewed

This commit is contained in:
justinzeus 2026-11-07 03:53:45 +01:00
parent fe44a3c24f
commit c2f7ccc438
11 changed files with 248 additions and 44 deletions

View file

@ -31,7 +31,7 @@ fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean,
val entryProgress = remember(title) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
LaunchedEffect(focused, reduced) {
LaunchedEffect(alpha, focused, reduced) {
if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
if (focused) entryProgress.floatValue = value

View file

@ -54,16 +54,18 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
if (row.skeleton) {
PosterCaptionSkeleton(width, height, caption, row.cardKind == HomeCardKind.Poster)
PosterCaptionSkeleton(width, height, caption)
} else if (title == null) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
} else TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height,
watched = !row.catalogue && title.played,
progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat()) {
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
} else {
val progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat()
TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height,
watched = !row.catalogue && title.played, progress = if (progress >= 1f) 0f else progress) {
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
}
}
}
}

View file

@ -56,18 +56,12 @@ private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.c
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
/** Loading and real grid rows reserve the identical two-line caption band. */
/** Loading reserves the ready caption band without painting unapproved text placeholders. */
@Composable
internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW,
height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand,
showTitle: Boolean = true) {
height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand) {
Column(Modifier.width(width)) {
SkeletonBlock(width, height)
Box(Modifier.height(band)) {
Column(Modifier.offset(y = L.captionOffset)) {
if (showTitle) SkeletonBlock(width, with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.CardMetadata.titleRest.lineHeight.toDp() })
SkeletonBlock(width / 2, with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.CardMetadata.subtitle.lineHeight.toDp() })
}
}
Spacer(Modifier.height(band))
}
}

View file

@ -187,13 +187,11 @@ object WolfskinTokens {
val tileH = 112.5.dp
val tileGap = 20.0.dp
val tilePitch = 220.0.dp
val tileCaptionBand = 32.0.dp
const val tileSlots = 4
val wideW = 272.0.dp
val wideH = 153.0.dp
val wideGap = 20.0.dp
val widePitch = 292.0.dp
val wideCaptionBand = 32.0.dp
const val wideSlots = 3
val entryW = 128.0.dp
val entryH = 72.0.dp

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
@ -83,4 +84,52 @@ class CardBottomPixelTest {
bitmap.recycle()
}
}
@Test fun completedHomeProgressUsesTheSameWatchedStripAsLibraryCards() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val fraction = mutableStateOf(.5f)
val art = mapOf(path to fixtureArt(path)!!)
val memory = HomeViewMemory()
rule.setContent { WolfskinTheme {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
val title = HomeTitle("home", "Home film", "movie", runtimeSeconds = 100,
poster = path, played = true, resumeSeconds = fraction.value * 100.0)
HomeShelf(HomeRow("progress", HomeRowKind.Shelf, "Progress", listOf(title)),
memory, false, 0, emptyList(), {}, {})
LibraryPoster(LibraryTitle("reference", "Reference", "movie", poster = path,
played = true, progress = fraction.value), false, false, false,
Modifier.offset(240.dp, 42.dp), ::fixtureArt)
}
}
} }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
for (progress in listOf(.5f, 1f, 1.5f)) {
rule.runOnIdle { fraction.value = progress }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val home = rule.onNodeWithTag("home-card:progress:0").fetchSemanticsNode().boundsInRoot
val reference = rule.onNodeWithTag("poster-reference").fetchSemanticsNode().boundsInRoot
assertEquals(reference.width, home.width, .1f)
assertEquals(reference.bottom, home.bottom, .1f)
val file = File(directory, "home-progress-${(progress * 100).toInt()}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
val bottom = home.bottom.toInt()
val band = if (progress >= 1f) 7 else 10
for (y in bottom - band until bottom) for (x in 0 until home.width.toInt()) {
assertEquals("Home/library strip progress=$progress ($x,$y)",
bitmap.getPixel(reference.left.toInt() + x, y), bitmap.getPixel(home.left.toInt() + x, y))
}
val separator = if (progress >= 1f) WolfskinTokens.Color.bg else WolfskinTokens.Color.accent
assertEquals("Completion changes the resume strip to the 3px watched strip",
separator.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 7))
assertEquals(WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 4))
} finally { bitmap.recycle() }
}
}
}

View file

@ -0,0 +1,121 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
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
/** Changing a shelf's selected identity must restart its focus-only caption reveal. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CardCaptionMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
}
@Test fun changingTitleWhileStillFocusedRestoresOpaqueCaptionPixels() {
val title = mutableStateOf("Initial caption")
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
CardCaption(title.value, 2007, "2h 56m", true, true,
Modifier.offset(40.dp, 80.dp).width(128.dp).testTag("animated"), focusOnly = true)
CardCaption(title.value, 2007, "2h 56m", true, true,
Modifier.offset(40.dp, 180.dp).width(128.dp).testTag("reference"))
}
} }
for ((index, selected) in listOf("Initial caption", "Second caption", "Third caption").withIndex()) {
update { title.value = selected }
rule.mainClock.advanceTimeBy(400)
assertCaptionPixels("component-$index",
rule.onNodeWithTag("animated").fetchSemanticsNode().boundsInRoot,
rule.onNodeWithTag("reference").fetchSemanticsNode().boundsInRoot)
}
}
@Test fun movingAcrossAFocusedHomeShelfKeepsEachSelectedCaptionVisible() {
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val cards = listOf(HomeTitle("first", "First shelf caption", "movie", 2007, 10560, poster = path),
HomeTitle("second", "Second shelf caption", "movie", 2008, 11520, poster = path))
val row = HomeRow("captions", HomeRowKind.Shelf, "Shelf", cards)
val column = mutableStateOf(0)
val memory = HomeViewMemory()
val art = mapOf(path to fixtureArt(path)!!)
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
Box(Modifier.testTag("home-shelf")) {
HomeShelf(row, memory, true, column.value, emptyList(), {}, {})
}
val selected = cards[column.value]
CardCaption(selected.title, selected.year, selected.runtime, false, true,
Modifier.offset(48.dp, 400.dp).width(128.dp).testTag("reference"))
}
}
} }
for ((step, selected) in listOf(0, 1, 0).withIndex()) {
update { column.value = selected }
rule.mainClock.advanceTimeBy(400)
val title = cards[selected].title
val actual = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("home-shelf")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val reference = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("reference")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertCaptionPixels("home-$step", actual, reference)
}
}
private fun update(block: () -> Unit) {
rule.runOnIdle { block(); Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
}
private fun assertCaptionPixels(name: String, actual: Rect, reference: Rect) {
assertEquals(reference.width, actual.width, .1f)
assertEquals(reference.height, actual.height, .1f)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
val file = File(directory, "$name.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
val foreground = WolfskinTokens.Color.fg.toArgb()
var compared = 0
for (y in 0 until reference.height.toInt()) for (x in 0 until reference.width.toInt()) {
if (bitmap.getPixel(reference.left.toInt() + x, reference.top.toInt() + y) == foreground) {
assertEquals("$name opaque title pixel ($x,$y)", foreground,
bitmap.getPixel(actual.left.toInt() + x, actual.top.toInt() + y))
compared++
}
}
assertTrue("The reference must contain opaque title glyphs", compared >= 24)
} finally { bitmap.recycle() }
}
}

View file

@ -1,6 +1,11 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.toArgb
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
@ -64,23 +69,23 @@ class CardGeometryTest {
val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
// Focus scales around the unchanged slot centre.
assertEquals(372f, first.top + (first.height - 384f) / 2, 1f)
assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), 1f)
assertEquals(372f, first.top + (first.height - 384f) / 2, .1f)
assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), .1f)
for (index in 0..5) {
val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode()
assertEquals(256f, caption.boundsInRoot.width, .1f)
assertEquals(794f, caption.boundsInRoot.top, 1f)
assertEquals(794f, caption.boundsInRoot.top, .1f)
}
val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, 1f)
assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), 1f)
assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, .1f)
assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), .1f)
rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } }
rule.waitForIdle()
val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
assertEquals(168f, pinnedFirst.top, 1f)
assertEquals(692f, pinnedSecond.top, 1f)
assertEquals(1076f, pinnedSecond.bottom, 1f)
assertEquals(168f, pinnedFirst.top, .1f)
assertEquals(692f, pinnedSecond.top, .1f)
assertEquals(1076f, pinnedSecond.bottom, .1f)
}
@Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() {
@ -128,4 +133,37 @@ class CardGeometryTest {
rule.onNodeWithContentDescription("Watched").assertDoesNotExist()
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
}
@Test fun loadingCaptionBandsReserveSpaceWithoutPaintingBars() {
val slots = listOf("poster" to Triple(128.dp, 192.dp, 48.dp),
"wide" to Triple(272.dp, 153.dp, 32.dp), "tile" to Triple(200.dp, 112.5.dp, 32.dp))
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
Row(Modifier.offset(48.dp, 42.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
slots.forEach { (name, geometry) ->
Box(Modifier.testTag("loading-$name")) {
PosterCaptionSkeleton(geometry.first, geometry.second, geometry.third)
}
}
}
}
} }
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
val file = File(directory, "loading-bands.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
for ((name, geometry) in slots) {
val bounds = rule.onNodeWithTag("loading-$name").fetchSemanticsNode().boundsInRoot
val band = geometry.third.value * 2
assertEquals((geometry.second.value + geometry.third.value) * 2, bounds.height, .1f)
for (y in (bounds.bottom - band).toInt() until bounds.bottom.toInt()) {
for (x in bounds.left.toInt() until bounds.right.toInt()) {
assertEquals("$name reserved caption band ($x,$y)", WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(x, y))
}
}
}
} finally { bitmap.recycle() }
}
}

View file

@ -1224,15 +1224,13 @@
"w": 400,
"h": 225,
"gap": 40,
"pitch": 440,
"captionBand": 64
"pitch": 440
},
"dp": {
"w": 200.0,
"h": 112.5,
"gap": 20.0,
"pitch": 220.0,
"captionBand": 32.0
"pitch": 220.0
},
"ratio": "16:9",
"fullSlotsPerRow": 4,
@ -1245,15 +1243,13 @@
"w": 544,
"h": 306,
"gap": 40,
"pitch": 584,
"captionBand": 64
"pitch": 584
},
"dp": {
"w": 272.0,
"h": 153.0,
"gap": 20.0,
"pitch": 292.0,
"captionBand": 32.0
"pitch": 292.0
},
"ratio": "16:9",
"fullSlotsPerRow": 3,

View file

@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
c04055d064795e5c7d7eef74756fe1386337002e66ef5e6597951a10cd7952dd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
fe84a56f7e570e1ff8c0ed0b347955b7d5421a75719fbbeffe251811ea2e1ae7 tokens.json
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json