Implement option B card metadata (stacked on https://git.justinvisser.org/justinzeus/wolfskin/pulls/51) #54

Merged
justinzeus merged 3 commits from fm/tv-card-meta-build into main 2026-11-07 16:01:07 +01:00
11 changed files with 248 additions and 44 deletions
Showing only changes of commit c2f7ccc438 - Show all commits

View file

@ -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

View file

@ -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.

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