Implement option B card metadata and solid bottom edges
Some checks failed
review/code-claude Blocking: CardCaption focus-only caption invisible after first focus move in a shelf
review/design-claude Design review: 1 blocking (unframed skeleton caption bars); other differences are renderer/fixture artifacts

This commit is contained in:
justinzeus 2026-11-07 03:06:26 +01:00
parent fdff85c8a1
commit fe44a3c24f
27 changed files with 536 additions and 234 deletions

View file

@ -114,12 +114,6 @@
"dp": 18.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"captionWidth": {
"$type": "dimension",
"px": 640,
"dp": 320.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"plateTop": {
"$type": "dimension",
"px": 14,
@ -240,18 +234,6 @@
"dp": 30.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"progressInset": {
"$type": "dimension",
"px": 18,
"dp": 9.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"progressBottom": {
"$type": "dimension",
"px": 10,
"dp": 5.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"endTop": {
"$type": "dimension",
"px": 40,
@ -316,7 +298,6 @@
"savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" },
"savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" },
"savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" },
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" },
"posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" }
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }
}
}

View file

@ -367,9 +367,6 @@
"chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"},
"chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"},
"chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"},
"captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"},
"captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"},
"captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"},
"badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},

View file

@ -10,18 +10,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.tv.material3.Text
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.constrainHeight
/** Captions rise inside the reserved band without changing measured geometry. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
val alpha = remember(title, facts) { Animatable(0f) }
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
@ -32,47 +25,10 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
if (visible) entryProgress.floatValue = value
}
}
val titleStyle = WolfskinTokens.Type.button
val factsStyle = WolfskinTokens.Type.caption
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
}
}
/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */
@Composable
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) {
val parts = remember(facts) { facts.split(" · ") }
val muted = WolfskinTokens.Color.muted
val colour = WolfskinTokens.Color.accentText
Layout(
content = {
parts.forEachIndexed { index, part ->
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip)
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style))
}
},
modifier = Modifier.fillMaxWidth().clipToBounds(),
) { children, constraints ->
val gap = WolfskinTokens.Library.captionFactsGap.roundToPx()
val basis = IntArray(children.size)
var fixed = gap * (children.size - 1).coerceAtLeast(0)
var flexible = 0
fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast)
children.forEachIndexed { index, child ->
val width = child.maxIntrinsicWidth(constraints.maxHeight)
basis[index] = width
if (shrinks(index)) flexible += width else fixed += width
}
val available = (constraints.maxWidth - fixed).coerceAtLeast(0)
val ratio = if (flexible > available) available.toFloat() / flexible else 1f
val measured = children.mapIndexed { index, child ->
val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index]
child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0))
}
layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) {
var x = 0
measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap }
}
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}

View file

@ -0,0 +1,81 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.CardMetadata as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
/** Card-width titles never displace runtime or watched metadata into a neighbouring slot. */
@Composable
fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean, focused: Boolean,
modifier: Modifier = Modifier, focusOnly: Boolean = false, showTitle: Boolean = true) {
val transition = if (focusOnly) {
val alpha = remember(title) { Animatable(0f) }
val entryProgress = remember(title) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
LaunchedEffect(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
}
}
Modifier.graphicsLayer {
this.alpha = alpha.value
translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue)
}
} else Modifier
Column(modifier.then(transition)) {
if (showTitle) {
val style = if (focused || focusOnly) M.titleFocus else M.titleRest
Text(title, color = if (focused || focusOnly) C.fg else C.muted, style = style,
modifier = Modifier.fillMaxWidth().exactLineBox(style), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
CardMetadata(year, duration, watched)
}
}
/** Measured gaps tighten before any fixed-size year, runtime or check can be clipped. */
@Composable
fun CardMetadata(year: Int?, duration: String?, watched: Boolean, modifier: Modifier = Modifier) {
Layout(content = {
if (year != null) Text(year.toString(), color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
if (duration != null) Text(duration, color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
if (watched) Icon(painterResource(R.drawable.card_watched_check), "Watched", Modifier.size(M.check), tint = C.muted)
}, modifier = modifier.fillMaxWidth().clipToBounds()) { children, constraints ->
val sizes = IntArray(children.size) { children[it].maxIntrinsicWidth(constraints.maxHeight) }
val gaps = (children.size - 1).coerceAtLeast(0)
val textWidth = sizes.sum()
val gap = when {
textWidth + gaps * M.gap.roundToPx() <= constraints.maxWidth -> M.gap.roundToPx()
textWidth + gaps * M.compactGap.roundToPx() <= constraints.maxWidth -> M.compactGap.roundToPx()
else -> M.minimumGap.roundToPx()
}
val measured = Array(children.size) { children[it].measure(Constraints.fixedWidth(sizes[it])) }
val height = with(this) { M.subtitle.lineHeight.toPx().toInt() }
layout(constraints.maxWidth, height) {
var left = 0
measured.forEach { child ->
child.placeRelative(left, (height - child.height) / 2)
left += child.width + gap
}
}
}
}

View file

@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
} else if (lane.pending) {
items(slots, key = { "pending:$it" }) {
Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
PosterCaptionSkeleton()
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
@ -80,15 +80,16 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
}
}
if (!services && active && !lane.pending) {
if (!services && !lane.pending) {
val item = lane.titles.getOrNull(column)
Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
)
val captionModifier = Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(K.posterW)
if (item != null) {
val card = item.libraryCard(state)
CardCaption(item.title, card.year, card.duration, false, active, captionModifier, focusOnly = true)
} else Caption(
if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
active, captionModifier)
}
}
}
@ -108,7 +109,3 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: ()
else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick)
}
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {
val card = title.libraryCard(state)
return (if (hidden) "Hidden from your picks · " else "") + card.facts(true)
}

View file

@ -38,7 +38,6 @@ data class HomeTitle(
}
val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left"
val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}"
val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ")
}
/** Only observed, range-checked scores reach this model; formatting follows the TV locale. */

View file

@ -119,7 +119,7 @@ fun HomeScreen(
private fun rowHeight(row: HomeRow) = when (row.kind) {
HomeRowKind.Hero -> L.row_hero
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand) + L.rowGapAfter
HomeRowKind.Section -> L.row_section
HomeRowKind.State -> L.row_state
HomeRowKind.Note -> L.row_note

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
@ -20,7 +19,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
@ -36,7 +34,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth
val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
@ -50,39 +48,34 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
val selected = !row.skeleton && focused && column == index
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 (title == null && !row.skeleton) {
if (row.skeleton) {
PosterCaptionSkeleton(width, height, caption, row.cardKind == HomeCardKind.Poster)
} else if (title == null) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
if (title == null) Box(Modifier.fillMaxSize().background(C.surface2)) else {
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) }
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
}
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
}
} 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())
}
}
}
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
if (!row.skeleton) {
val title = row.cards.getOrNull(column)
val availability = when {
title == null -> null
row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" }
title.played -> "Watched"
title.resumeSeconds > 0 -> title.remaining
else -> null
}
val facts = remember(title, row.total, availability) {
(title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty()
}
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis)
val captionModifier = Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(width)
if (title == null) {
if (row.cardKind == HomeCardKind.Poster) Caption("See all ${row.title}", "${row.total} titles", focused, captionModifier)
} else CardCaption(title.displayTitle, if (title.isEpisode) null else title.year,
title.takeIf { it.type == "movie" || it.isEpisode }?.runtimeSeconds?.takeIf { it > 0 }?.let { title.runtime },
!row.catalogue && title.played && title.resumeSeconds <= 0, focused, captionModifier,
focusOnly = true, showTitle = row.cardKind == HomeCardKind.Poster)
}
}
}
@ -90,9 +83,11 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
@Composable
private fun TileOverlay(title: HomeTitle) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
val metadata = WolfskinTokens.CardMetadata
Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth()
.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f))))
.padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}

View file

@ -18,6 +18,7 @@ interface PosterContent {
val poster: String?
val ratings: List<LibraryRating>
val progress: Float
val posterWatched: Boolean
val badge: String?
}
@ -40,18 +41,15 @@ data class LibraryTitle(
) : PosterContent {
override val posterTitle: String get() = title
override val posterIsMovie: Boolean get() = type == "movie"
override val posterWatched: Boolean get() = played
val duration: String? get() = if (type == "movie" || type == "episode") runtimeSeconds?.takeIf { it > 0 }?.let {
val minutes = (it + 30) / 60
if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
} else null
val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
val catalogueKey: String = "$type:${tmdbId ?: id}"
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0
fun facts(catalogue: Boolean): String = buildList {
year?.let { add(it.toString()) }
add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series")
if (catalogue) { if (availability.isNotEmpty()) add(availability) }
else if (played) add("Watched")
else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left")
}.joinToString(" · ")
}
/** Ordered rating entries are formatted after the active TV locale is known. */

View file

@ -25,9 +25,12 @@ fun LibraryPoster(
catalogue: Boolean,
modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null,
showWatchState: Boolean = !catalogue,
) {
val poster = title.poster
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
val progress = if (showWatchState) title.progress else 0f
TitleCard(focused, modifier.testTag("poster-${title.id}"), watched = showWatchState && title.posterWatched,
progress = if (progress >= 1f) 0f else progress) {
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (poster != null && offlineArt == null) {
@ -39,13 +42,6 @@ fun LibraryPoster(
onSuccess = { ready = true }, onError = { ready = false })
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
val progress = title.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
}
}
if (catalogue && title.badge != null) {
val held = title.badge == "held"
val color = if (title.badge == "failed") C.fail else C.accent

View file

@ -121,7 +121,7 @@ fun LibraryScreen(
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) }
repeat(grid.columns) { PosterCaptionSkeleton() }
}
}
}
@ -139,6 +139,7 @@ fun LibraryScreen(
val title = items.getOrNull(index) ?: break
key(if (grid.catalogue) title.catalogueKey else title.id) {
val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index
Box(Modifier.width(K.posterW)) {
LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue,
Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) {
awaitPointerEventScope {
@ -150,15 +151,13 @@ fun LibraryScreen(
}
}
}.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt)
CardCaption(title.title, if (title.type == "episode") null else title.year, title.duration,
!grid.catalogue && title.played && !(title.progress > 0f && title.progress < 1f), isFocused,
Modifier.offset(y = K.posterH + L.captionOffset).width(K.posterW))
}
}
}
}
if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) {
val title = items.getOrNull(grid.index)
if (title != null) Caption(title.title, title.facts(grid.catalogue), true,
Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth),
accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f)
}
}
}
if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") {
@ -240,7 +239,7 @@ private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide))
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(K.posterW))
}
}

View file

@ -46,7 +46,7 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
Column {
Spacer(Modifier.height(heading))
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) }
repeat(K.posterSlots) { PosterCaptionSkeleton() }
}
}
}
@ -55,3 +55,19 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) {
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
/** Loading and real grid rows reserve the identical two-line caption band. */
@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) {
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() })
}
}
}
}

View file

@ -148,7 +148,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
row.forEachIndexed { column, item ->
val index = rowIndex * columns + column
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), alwaysShowCaption = true, onFocus = { state.index = index }) { state.click(index) }
}
}
}

View file

@ -9,15 +9,51 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
// Precompose before packing into Color: packing first loses the browser's truncated channel fractions.
private val opaqueProgressTrack = WolfskinTokens.Color.progressTrack.let { track ->
val backing = WolfskinTokens.Color.bg
val inverse = 1f - track.alpha
Color(
((track.red * track.alpha + backing.red * inverse) * 255).toInt(),
((track.green * track.alpha + backing.green * inverse) * 255).toInt(),
((track.blue * track.alpha + backing.blue * inverse) * 255).toInt(),
)
}
/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */
@Composable
fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW,
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
.drawWithCache {
val edge = WolfskinTokens.CardMetadata.bottom.toPx()
val fill = when {
progress > 0f -> WolfskinTokens.CardMetadata.resumeLine.toPx()
watched -> WolfskinTokens.CardMetadata.watchedLine.toPx()
else -> 0f
}
val backing = edge + if (fill > 0f) fill + WolfskinTokens.CardMetadata.separator.toPx() else 0f
onDrawWithContent {
clipRect(bottom = size.height - backing) { this@onDrawWithContent.drawContent() }
drawRect(WolfskinTokens.Color.bg, Offset(0f, size.height - backing), Size(size.width, backing))
if (fill > 0f) {
val top = Offset(0f, size.height - edge - fill)
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
drawRect(WolfskinTokens.Color.accent, top,
Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill))
}
}
}, content = content)
}
}

View file

@ -30,6 +30,7 @@ data class TitleItem(
) : PosterContent {
override val posterTitle: String get() = name
override val posterIsMovie: Boolean get() = kind == "movie"
override val posterWatched: Boolean get() = watched
override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f
override val badge: String? get() = if (held) "held" else null
val episodeLabel: String get() = "S$season E$episode"

View file

@ -4,7 +4,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -107,53 +106,36 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
}
}
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */
@Composable
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current
Column(modifier.zIndex(if (focused) 1f else 0f)) {
if (!tile) {
val bitmaps = LocalTitleBitmaps.current
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched,
progress = item.progress.takeIf { it < 1f } ?: 0f) {
TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
val progress = item.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom)
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
}
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0)
}
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
}
}
if (!tile) {
val availability = androidx.compose.runtime.remember(item, catalogue) {
if (catalogue) {
if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }
} else if (item.watched) "Watched"
else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left"
else null
}
val facts = androidx.compose.runtime.remember(item, availability) {
listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(),
if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series",
availability).joinToString(" · ")
}
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth),
accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank())
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand).width(WolfskinTokens.Card.posterW)) {
CardCaption(item.name, item.year, if (item.kind == "movie" && item.runtimeSeconds > 0) item.runtime else null,
item.watched && item.resumeSeconds <= 0, focused,
Modifier.offset(y = L.captionOffset).width(WolfskinTokens.Card.posterW), focusOnly = !alwaysShowCaption)
}
}
}

View file

@ -11,6 +11,20 @@ import androidx.compose.ui.unit.sp
/** The locked TV design, converted at the source density. */
object WolfskinTokens {
object CardMetadata {
val titleRest = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(500), lineHeight = 16.0.sp, letterSpacing = 0.0.em)
val titleFocus = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.0.sp, letterSpacing = 0.0.em)
val subtitle = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 13.0.sp, letterSpacing = 0.0.em)
val check = 12.0.dp
val gap = 6.0.dp
val compactGap = 4.0.dp
val minimumGap = 2.0.dp
val bottom = 1.0.dp
val separator = 1.0.dp
val watchedLine = 1.5.dp
val resumeLine = 3.0.dp
val landscapeBand = 32.0.dp
}
object Color {
val bg = ComposeColor(0xFF07070A)
val surface = ComposeColor(0xFF13131A)
@ -173,13 +187,13 @@ object WolfskinTokens {
val tileH = 112.5.dp
val tileGap = 20.0.dp
val tilePitch = 220.0.dp
val tileCaptionBand = 0.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 = 0.0.dp
val wideCaptionBand = 32.0.dp
const val wideSlots = 3
val entryW = 128.0.dp
val entryH = 72.0.dp
@ -379,9 +393,6 @@ object WolfskinTokens {
val chipGap = 8.0.dp
val chipTop = 8.0.dp
val chipIcon = 13.0.dp
val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val captionFactsGap = 9.0.dp
val badgeIcon = 14.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
@ -465,7 +476,6 @@ object WolfskinTokens {
val personGap = 24.0.dp
val personNameBottom = 5.0.dp
val personNoteBottom = 3.0.dp
val captionWidth = 320.0.dp
val sheetPaddingTop = 40.0.dp
val sheetPaddingSide = 32.0.dp
val sheetGap = 9.0.dp
@ -552,8 +562,6 @@ object WolfskinTokens {
val tileOverlaySide = 11.0.dp
val tileOverlayBottom = 9.0.dp
const val cardOverlayOpacity = 0.92f
val cardProgressInset = 9.0.dp
val tileProgressBottom = 5.0.dp
val ambientInset = 40.0.dp
const val ambientScale = 1.1f
val posterShadowRadius = 40.0.dp
@ -591,7 +599,6 @@ object WolfskinTokens {
val statePaddingY = 17.0.dp
val stateGap = 4.0.dp
val stateIcon = 18.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateFade = 23.0.dp
@ -612,8 +619,6 @@ object WolfskinTokens {
val heroSheetTop = 75.0.dp
val cardBlur = 13.0.dp
val heroBlur = 30.0.dp
val progressInset = 9.0.dp
val progressBottom = 5.0.dp
val endTop = 20.0.dp
val frameBottom = 40.0.dp
val toastPadding = 14.0.dp
@ -634,7 +639,6 @@ object WolfskinTokens {
val savedGap = 5.0.dp
val savedIcon = 14.0.dp
val savedRadius = 14.0.dp
val posterProgressBottom = 6.0.dp
}
object Discover {
val shelfHeadingBand = 52.0.dp

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5 12l4 4l10 -10" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.5" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,86 @@
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.mutableStateOf
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.unit.dp
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
/** Real white, dark and colourful art cannot leak through the card's opaque bottom edge. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CardBottomPixelTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun realPosterBottomRowsStayOpaqueAtRestAndFocused() {
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 focused = mutableStateOf(-1)
val showArt = mutableStateOf(true)
val state = mutableStateOf("unwatched")
val names = listOf("annie-hall-1977", "locke-2014", "apocalypse-now-1979", "avatar-aang-the-last-airbender-2026")
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
names.forEachIndexed { index, name ->
LibraryPoster(LibraryTitle(name, name, "movie", poster = if (showArt.value) "/library/assets/movie-$name-poster.jpg" else null,
played = state.value == "watched", progress = if (state.value == "resume") .43f else 0f),
focused.value == index, false, false, Modifier.offset((48 + 148 * index).dp, 130.dp), ::fixtureArt)
}
}
} }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
for (watchState in listOf("unwatched", "watched", "resume")) for (selection in -1..3) {
rule.runOnIdle { state.value = watchState; focused.value = selection; showArt.value = false }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val referenceFile = File(directory, "reference.png")
rule.onRoot().captureRoboImage(referenceFile.absolutePath)
val reference = BitmapFactory.decodeFile(referenceFile.absolutePath)
rule.runOnIdle { showArt.value = true }
rule.waitForIdle()
val file = File(directory, "$watchState-focus-$selection.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
names.forEachIndexed { index, name ->
val focus = selection == index
val left = 96 + 296 * index
val bottom = if (focus) 659 else 643
val edgeLeft = if (focus) left - 10 else left
val edgeRight = if (focus) left + 266 else left + 256
// The reference supplies only rounded-corner/ring coverage, never poster colours.
for (x in edgeLeft until edgeRight) assertEquals("$name $watchState focus=$focus full bottom row ($x,$bottom)",
reference.getPixel(x, bottom), bitmap.getPixel(x, bottom))
val expected = if (focus) WolfskinTokens.Color.accent.toArgb() else WolfskinTokens.Color.bg.toArgb()
for (x in left + 32 until left + 224) assertEquals("$name $watchState focus=$focus straight edge ($x,$bottom)",
expected, bitmap.getPixel(x, bottom))
if (watchState != "unwatched") {
val fillY = if (focus) 655 else 640
assertEquals("$name $watchState fill", WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(left + 32, fillY))
val right = if (watchState == "resume") androidx.compose.ui.graphics.Color(0xFF3D3D3F) else WolfskinTokens.Color.accent
assertEquals("$name $watchState track", right.toArgb(), bitmap.getPixel(left + 224, fillY))
}
}
reference.recycle()
bitmap.recycle()
}
}
}

View file

@ -0,0 +1,131 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Caption fitting and row pitches are measured from the same surfaces used by the TV. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class CardGeometryTest {
@get:Rule val rule = createComposeRule()
@Test fun narrowCaptionsEllipsiseOnlyTheTitleAndKeepRuntimeAndCheck() {
val width = mutableStateOf(105.dp)
rule.setContent { WolfskinTheme {
CardCaption("Steins;Gate: The Movie - Load Region of Déjà Vu", 2007, "999h 59m", true, true,
Modifier.width(width.value).testTag("caption"))
} }
for (pixels in listOf(210, 327)) {
rule.runOnIdle { width.value = (pixels / 2f).dp }
rule.waitForIdle()
val layouts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().map { node ->
val results = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results)
results.single()
}
val title = layouts.single { it.layoutInput.text.text.startsWith("Steins;") }
assertEquals(1, title.lineCount)
assertTrue("Only the long title ellipsises at $pixels", title.isLineEllipsized(0))
for (facts in layouts.filter { it !== title }) {
assertFalse("Metadata must not overflow at $pixels", facts.hasVisualOverflow)
assertFalse(facts.isLineEllipsized(0))
}
val caption = rule.onNodeWithTag("caption").fetchSemanticsNode().boundsInRoot
assertEquals(58f, caption.height, .1f)
assertEquals(pixels.toFloat(), caption.width, .1f)
val check = rule.onNodeWithContentDescription("Watched").fetchSemanticsNode().boundsInRoot
assertEquals(24f, check.width, .1f)
assertEquals(24f, check.height, .1f)
assertTrue(check.right <= caption.right)
}
}
@Test fun unfocusedLibraryTitlesKeepCardWidthAndGridPitch() {
val titles = List(12) { LibraryTitle("$it", "A long title occupying only one card column ${it.toString().padStart(2, '0')}", "movie", 2007, 10560, poster = "/library/assets/movie-annie-hall-1977-poster.jpg") }
val shell = ShellState(fixturePeople(), "shared").apply { pickerVisible = false; enterDestination("movies") }
val grid = LibraryGridState("Movies", titles)
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it, offlineArt = ::fixtureArt) } } }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
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)
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)
}
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)
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)
}
@Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() {
val kind = mutableStateOf(HomeCardKind.Poster)
val loading = mutableStateOf(false)
val memory = HomeViewMemory()
rule.setContent { WolfskinTheme {
Column {
Box(Modifier.testTag("shelf")) {
HomeShelf(HomeRow("fixture", HomeRowKind.Shelf, "Fixture", listOf(HomeTitle("one", "One", "movie", 2007, 10560)),
kind.value, skeleton = loading.value), memory, false, 0, emptyList(), {}, {})
}
Box(Modifier.size(1.dp).testTag("next"))
}
} }
for ((cardKind, expected) in listOf(HomeCardKind.Poster to 632f, HomeCardKind.Wide to 522f, HomeCardKind.Tile to 441f)) {
for (pending in listOf(false, true)) {
rule.runOnIdle { kind.value = cardKind; loading.value = pending }
rule.waitForIdle()
assertEquals("$cardKind loading=$pending", expected, rule.onNodeWithTag("shelf").fetchSemanticsNode().boundsInRoot.height, .1f)
assertEquals(expected, rule.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.top, .1f)
}
}
}
@Test fun filmographyKeepsUnfocusedMetadataAndDistinguishesSeriesFromFilmRuntime() {
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val item = mutableStateOf(TitleItem("credit", "A filmography credit", "movie", 2007, 10560,
poster = path, held = true, watched = true))
val art = mapOf(path to fixtureArt(path)!!)
rule.setContent { WolfskinTheme {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
TitlePoster(item.value, false, alwaysShowCaption = true) {}
}
} }
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
rule.onNodeWithText("2007").assertIsDisplayed()
rule.onNodeWithText("2h 56m").assertIsDisplayed()
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
rule.runOnIdle { item.value = item.value.copy(kind = "tv", year = 2016, runtimeSeconds = 123456) }
rule.onNodeWithText("2016").assertIsDisplayed()
rule.onNodeWithText("2h 56m").assertDoesNotExist()
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
rule.runOnIdle { item.value = item.value.copy(resumeSeconds = 10) }
rule.onNodeWithContentDescription("Watched").assertDoesNotExist()
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
}
}

View file

@ -11,23 +11,12 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */
/** Live letter-rail changes preserve deep selection and poster geometry. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class LibrarySpacingRegressionTest {
@get:Rule val rule = createComposeRule()
@Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() {
val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f)
rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } }
val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot
val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot
assertEquals(256f, poster.width, .1f)
assertEquals(220f, track.width, .1f)
assertEquals(18f, track.left - poster.left, .1f)
assertEquals(18f, poster.right - track.right, .1f)
}
@Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() {
val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") }
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") }

View file

@ -23,7 +23,6 @@
"personGap": {"dp": 24, "px": 48, "source": "index.html:293"},
"personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"},
"personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"},
"captionWidth": {"dp": 320, "px": 640, "source": "index.html:1242"},
"sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"},
"sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"},
"sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"},
@ -110,8 +109,6 @@
"tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"},
"tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"},
"cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"},
"cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"},
"tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"},
"ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"},
"ambientScale": {"$value": 1.1, "source": "index.html:96"},
"posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"},

View file

@ -1,5 +1,17 @@
/* Generated from the locked TV token sources. */
:root {
--cardMetadata-titleRest: 28.0px;
--cardMetadata-titleFocus: 28.0px;
--cardMetadata-subtitle: 24.0px;
--cardMetadata-check: 24.0px;
--cardMetadata-gap: 12.0px;
--cardMetadata-compactGap: 8.0px;
--cardMetadata-minimumGap: 4.0px;
--cardMetadata-bottom: 2.0px;
--cardMetadata-separator: 2.0px;
--cardMetadata-watchedLine: 3.0px;
--cardMetadata-resumeLine: 6.0px;
--cardMetadata-landscapeBand: 64.0px;
--color-bg: #07070a;
--color-surface: #13131a;
--color-surface2: #1b1b25;
@ -242,8 +254,6 @@
--library-chipGap: 16.0px;
--library-chipTop: 16.0px;
--library-chipIcon: 26.0px;
--library-captionWidth: 640.0px;
--library-captionFactsGap: 18.0px;
--library-badgeIcon: 28.0px;
--library-plateTop: 14.0px;
--library-plateSide: 16.0px;
@ -303,7 +313,6 @@
--title-personGap: 48.0px;
--title-personNameBottom: 10.0px;
--title-personNoteBottom: 6.0px;
--title-captionWidth: 640.0px;
--title-sheetPaddingTop: 80.0px;
--title-sheetPaddingSide: 64.0px;
--title-sheetGap: 18.0px;
@ -384,8 +393,6 @@
--title-tileOverlayTop: 54.0px;
--title-tileOverlaySide: 22.0px;
--title-tileOverlayBottom: 18.0px;
--title-cardProgressInset: 18.0px;
--title-tileProgressBottom: 10.0px;
--title-ambientInset: 80.0px;
--title-posterShadowRadius: 80.0px;
--title-posterShadowY: 30.0px;
@ -415,7 +422,6 @@
--home-statePaddingY: 34.0px;
--home-stateGap: 8.0px;
--home-stateIcon: 36.0px;
--home-captionWidth: 640.0px;
--home-plateTop: 14.0px;
--home-plateSide: 16.0px;
--home-plateFade: 46.0px;
@ -436,8 +442,6 @@
--home-heroSheetTop: 150.0px;
--home-cardBlur: 26.0px;
--home-heroBlur: 60.0px;
--home-progressInset: 18.0px;
--home-progressBottom: 10.0px;
--home-endTop: 40.0px;
--home-frameBottom: 80.0px;
--home-toastPadding: 28.0px;
@ -457,7 +461,6 @@
--home-savedGap: 10.0px;
--home-savedIcon: 28.0px;
--home-savedRadius: 28.0px;
--home-posterProgressBottom: 12.0px;
--discover-shelfHeadingBand: 104.0px;
--discover-sheetPaddingX: 64.0px;
--discover-sheetPaddingY: 80.0px;

View file

@ -1,5 +1,5 @@
{
"$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
"$description": "Wolfskin TV design tokens, spec revision 6: approved loading/motion and option-B card metadata/state lines. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
"meta": {
"density": 2.0,
"stagePx": [
@ -15,6 +15,20 @@
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py",
"feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json"
},
"cardMetadata": {
"titleRest": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":500,"$value":{"fontWeight":500}},
"titleFocus": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":700,"$value":{"fontWeight":700}},
"subtitle": {"$type":"typography","px":24,"sp":12,"lineHeight":1.0833333333333333,"weight":400,"$value":{"fontWeight":400}},
"check": {"$type":"dimension","px":24,"dp":12},
"gap": {"$type":"dimension","px":12,"dp":6},
"compactGap": {"$type":"dimension","px":8,"dp":4},
"minimumGap": {"$type":"dimension","px":4,"dp":2},
"bottom": {"$type":"dimension","px":2,"dp":1},
"separator": {"$type":"dimension","px":2,"dp":1},
"watchedLine": {"$type":"dimension","px":3,"dp":1.5},
"resumeLine": {"$type":"dimension","px":6,"dp":3},
"landscapeBand": {"$type":"dimension","px":64,"dp":32}
},
"color": {
"bg": {
"$type": "color",
@ -1045,7 +1059,7 @@
"px": 38,
"dp": 19,
"source": "index.html:1242",
"usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640"
"usedBy": "card-width captions start 38 px below unscaled art; library titles and subtitles remain visible, Home poster captions are focus-only"
},
"plateInset": {
"$type": "dimension",
@ -1202,7 +1216,7 @@
"ratio": "2:3",
"fullSlotsPerRow": 6,
"source": "index.html:656",
"usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest"
"usedBy": "every poster shelf and six-column library, See all and filmography grid; library captions always visible, shelf captions focus-only"
},
"tile": {
"$type": "card",
@ -1211,14 +1225,14 @@
"h": 225,
"gap": 40,
"pitch": 440,
"captionBand": 0
"captionBand": 64
},
"dp": {
"w": 200.0,
"h": 112.5,
"gap": 20.0,
"pitch": 220.0,
"captionBand": 0.0
"captionBand": 32.0
},
"ratio": "16:9",
"fullSlotsPerRow": 4,
@ -1232,14 +1246,14 @@
"h": 306,
"gap": 40,
"pitch": 584,
"captionBand": 0
"captionBand": 64
},
"dp": {
"w": 272.0,
"h": 153.0,
"gap": 20.0,
"pitch": 292.0,
"captionBand": 0.0
"captionBand": 32.0
},
"ratio": "16:9",
"fullSlotsPerRow": 3,

View file

@ -1,8 +1,8 @@
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css
f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
c04055d064795e5c7d7eef74756fe1386337002e66ef5e6597951a10cd7952dd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
fe84a56f7e570e1ff8c0ed0b347955b7d5421a75719fbbeffe251811ea2e1ae7 tokens.json