From c2f7ccc438a623ef2bff9b858ea6591a675ab481 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 03:53:45 +0100 Subject: [PATCH] Fix focused card captions and leave loading bands blank --- data/tv-spec/spec.md | 6 +- docker/README.md | 12 +- .../wolfskin/design/CardCaption.kt | 2 +- .../justinvisser/wolfskin/design/HomeShelf.kt | 18 +-- .../wolfskin/design/LoadingSurface.kt | 12 +- .../wolfskin/design/WolfskinTokens.kt | 2 - .../wolfskin/design/CardBottomPixelTest.kt | 49 +++++++ .../wolfskin/design/CardCaptionMotionTest.kt | 121 ++++++++++++++++++ .../wolfskin/design/CardGeometryTest.kt | 54 ++++++-- wolfskin-design/tokens.json | 12 +- wolfskin-design/tokens.sha256 | 4 +- 11 files changed, 248 insertions(+), 44 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index fb248a33..913f31f5 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -87,7 +87,7 @@ Native **1920×1080** before/after frames and the frame-budget inventory are pub Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration. -Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve the same title/subtitle slots and bands; each shelf still shares the motion lane's single pulse layer. +Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve identical **blank** caption bands: only art-sized blocks are painted, with no title/subtitle pills. Each shelf still shares the motion lane's single pulse layer. | Geometry at 1920×1080 | Pixels | |---|---:| @@ -100,9 +100,11 @@ Movies, Shows, Anime, Home See all and person filmography always show their card One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them. +Completed Home progress (`resumeSeconds / runtimeSeconds >= 1`) follows the same state precedence as library/title cards: it is no longer resuming, so a played title uses the 3 px watched line and an unplayed title has no state line. + Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid. -Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances from native surfaces. Visible reference differences are listed in the gallery and remain merge blockers unless explicitly approved. +Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers. ## Sources and verification diff --git a/docker/README.md b/docker/README.md index 8b979eb3..5cd6f6fa 100644 --- a/docker/README.md +++ b/docker/README.md @@ -564,15 +564,19 @@ single runtime. Resuming cards have no check. The poster caption band remains 96 px; Home poster captions are focus-only. Home landscapes retain titles on the art and reserve 64 px for focused metadata below it, making wide/tile shelf pitches 522/441 px; poster shelves remain 632 px and grids remain 524 px. -Loading rows reserve the same caption slots and share one pulse layer. +Loading rows reserve the same blank caption bands and share one pulse layer; +only art-sized blocks are painted, with no title/subtitle placeholder pills. `CardBottomPixelTest` samples full native bottom rows on four real posters at rest and individually focused, including white Annie Hall and dark Locke. `CardGeometryTest` measures 210/327 px caption fitting, card-width titles, -24 px checks, row pitches, pinned rows and ring clearances. The -[native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/) +24 px checks, row pitches, pinned rows and ring clearances to 0.1 px, and checks +that every reserved loading-caption pixel remains background-coloured. +`CardCaptionMotionTest` checks visible native glyph pixels when a still-focused +caption changes title directly or while moving across a Home shelf. +The [native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/) contains the same illustrative watch-state data as the approved frames and -lists reference deviations; unapproved deviations block merge. +records renderer/fixture artifacts; unapproved design changes block merge. `LibrarySpacingRegressionTest` retains the deep focused index across the 199↔200-title letter-rail boundary. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt index c87fe4ba..9d6f28a8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt @@ -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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index 64d7d367..d1827758 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -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()) + } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt index 60411752..e7607e04 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt @@ -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)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index ce1d5f2c..6c014755 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -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 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt index dd1c97c2..5bbc98ce 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt @@ -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() } + } + } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt new file mode 100644 index 00000000..ee5b9d57 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt @@ -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() + + @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() } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt index 27b462bb..be217089 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt @@ -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() } + } } diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 6e3e09e0..d7db186e 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -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, diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 4e8986e6..a45b7f17 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -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