Give TV cards roomy spacing and edge-straddling focus rings #40

Merged
justinzeus merged 3 commits from fm/tv-spacing-b7 into main 2026-11-06 13:24:16 +01:00
20 changed files with 280 additions and 149 deletions

View file

@ -87,7 +87,7 @@ class TitleCaptureTest {
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) }
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
}
}
return requests.distinctBy { it.first }.associate { (path, width, height) ->
@ -215,4 +215,5 @@ class TitleCaptureTest {
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
}

View file

@ -310,9 +310,10 @@ and device deployment remain separate.
## Libraries and See all
Movies, Shows and Anime use the shell's retained view and a continuous six-column
lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserves
the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail
exists only at 200 titles with A–Z selected. Card OK activates on key-up through
lazy grid, reduced to five columns while the letter rail is visible so scaled
posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav;
Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating.
The letter rail exists only at 200 titles with A–Z selected. Card OK activates on key-up through
the same shell hold guard, without a long-press menu.
Down clamps a short final title row before entering the separate Try again / Show
more row; an empty slot cannot activate recovery. An unavailable library hides
@ -347,6 +348,39 @@ music keep their upstream destinations.
failure/retry, stale copies, and person/server isolation. Library captures are written
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
### Roomy card spacing
The generated design tokens use 40 px card gaps (44 px for cast), 84 px shelf
heading bands, 68 px shelf bottom gaps and 524 px grid rows at 1920×1080.
Posters remain 256×384 px; tiles are 400×225 px and wide cards 544×306 px,
preserving six/four/three shelf slots. Library and See all toolbars occupy 136 px:
chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear).
Discover and season-chip rows retain their 104 px height.
Cards and cast circles draw a single 4 px accent stroke centred on the art edge,
above the content, with no background gap. Card lift parameters are unchanged;
cast portraits and episode tiles now receive that lift too. The stroke and art
scale together at 1.08; buttons, avatars, chips and picker focus are unchanged.
Title shelves reserve vertical drawing room for the scaled ring.
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
Poster progress uses the independent 18 px title-card inset, not caption spacing:
the track remains 220 px wide on a 256 px poster. Library focus-follow restarts
when the effective column count, focused row or header geometry changes, retaining
deep focus across live snapshots that add or remove the letter rail.
`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged
focused index across the 199↔200-title boundary.
`LibraryCaptureTest.spacingShowsFirstFocused`,
`HomeCaptureTest.spacingNextUpFirstFocused` and
`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states
at native resolution. Their files are `library/spacing-shows.png`,
`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`.
These captures are recordings, not automated reference-pixel assertions; pair
measurements are a separate check. Platform Roboto metrics and fixture ordering
are not browser pixel parity.
## Title pages and people
`TitleSurface` owns the film/series/episode header, season chips, episode tiles,

View file

@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true)
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))

View file

@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
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.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true)
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {

View file

@ -16,11 +16,13 @@ import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
/** Only transforms animate: focus never changes measured geometry. */
@ -37,9 +39,9 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
/** Content lifts are distinct from the white person/gear rings. */
enum class FocusLift { None, Card, Button }
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
/** Card rings straddle the art edge above content; control rings retain their outside gap. */
@Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier {
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None, straddle: Boolean = false): Modifier {
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
val shadow = if (lift == FocusLift.None) this else {
@ -54,6 +56,17 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
}
}
if (straddle) return shadow.drawWithCache {
val stroke = Stroke(width.toPx())
val corner = CornerRadius(radius.toPx())
onDrawWithContent {
drawContent()
if (ringAlpha > 0f) {
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha),
cornerRadius = corner, style = stroke)
}
}
}
return shadow.drawBehind {
if (ringAlpha > 0f) {
val outer = (gap + width).toPx()

View file

@ -91,7 +91,7 @@ class LibraryGridState(
var inRail by mutableStateOf(false)
var railLetter by mutableStateOf('#')
private var railReturn = 0
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
val hasToolbar: Boolean get() = !catalogue || mixed
val items: List<LibraryTitle> by derivedStateOf {

View file

@ -41,8 +41,8 @@ fun LibraryPoster(
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.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
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))
}
}

View file

@ -88,13 +88,13 @@ fun LibraryScreen(
}
val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index
val row = index.coerceAtLeast(0) / grid.columns
if (grid.toolbar || row == 0 && active) {
val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns
LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density,
grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
if (grid.toolbar || focusedRow == 0 && active) {
animateLibraryScroll(view, headerCount, 0f, density)
} else if (row > 0) {
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() }
} else if (focusedRow > 0) {
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * focusedRow - L.pinnedLine).toPx() }
animateLibraryScroll(view, headerCount, target, density)
}
}
@ -186,7 +186,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
@Composable
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All")
labels.forEachIndexed { index, label ->
val focused = active && grid.toolbar && grid.tool == index
@ -266,7 +266,7 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
val current = with(density) {
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
if (first < headerCount) WolfskinTokens.Shell.zero else
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
(if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
} + view.listState.firstVisibleItemScrollOffset
animateFrameScroll(view.listState, target - current)
}

View file

@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
height: Dp = WolfskinTokens.Card.posterH, 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.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) {
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card, straddle = true)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
}
}

View file

@ -51,9 +51,10 @@ fun TitleCast(state: TitleState, active: Boolean) {
val focusOwner = LocalTitleFocusOwner.current
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
Column(Modifier.height(D.castHeight)) {
Box(Modifier.height(D.castHeight)) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
if (state.data.castStatus == TitleReadStatus.Loading) {
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
@ -63,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true).clip(CircleShape))
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
@ -85,9 +86,11 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
@ -112,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))

View file

@ -112,9 +112,9 @@ object WolfskinTokens {
val navSolidAfter = 20.0.dp
val pinnedLine = 84.0.dp
val windowAhead = 350.0.dp
val rowHeadingBand = 30.0.dp
val rowGapAfter = 22.0.dp
val gridRowGap = 14.0.dp
val rowHeadingBand = 42.0.dp
val rowGapAfter = 34.0.dp
val gridRowGap = 22.0.dp
val row_hero = 410.0.dp
val row_phero = 370.0.dp
val row_section = 75.0.dp
@ -146,7 +146,7 @@ object WolfskinTokens {
val settingsRowWidth = 650.0.dp
val settingsRowHeight = 52.0.dp
val stateWidth = 550.0.dp
val captionOffset = 9.0.dp
val captionOffset = 19.0.dp
val badge = 24.0.dp
val badgeInset = 7.0.dp
val progressBar = 4.0.dp
@ -159,36 +159,37 @@ object WolfskinTokens {
val personPortrait = 120.0.dp
val thumbWidth = 48.0.dp
val thumbHeight = 72.0.dp
val row_chipsGrid = 68.0.dp
}
object Card {
val posterW = 128.0.dp
val posterH = 192.0.dp
val posterGap = 12.0.dp
val posterPitch = 140.0.dp
val posterCaptionBand = 38.0.dp
val posterGap = 20.0.dp
val posterPitch = 148.0.dp
val posterCaptionBand = 48.0.dp
const val posterSlots = 6
val tileW = 208.0.dp
val tileH = 117.0.dp
val tileGap = 12.0.dp
val tileW = 200.0.dp
val tileH = 112.5.dp
val tileGap = 20.0.dp
val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp
const val tileSlots = 4
val wideW = 280.0.dp
val wideH = 157.5.dp
val wideGap = 12.0.dp
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
const val wideSlots = 3
val entryW = 128.0.dp
val entryH = 72.0.dp
val entryGap = 12.0.dp
val entryPitch = 140.0.dp
val entryGap = 20.0.dp
val entryPitch = 148.0.dp
val entryCaptionBand = 0.0.dp
const val entrySlots = 6
val personW = 100.0.dp
val personH = 145.0.dp
val personGap = 14.0.dp
val personPitch = 114.0.dp
val personH = 155.0.dp
val personGap = 22.0.dp
val personPitch = 122.0.dp
val personCaptionBand = 0.0.dp
const val personSlots = 7
}
@ -196,7 +197,7 @@ object WolfskinTokens {
const val scaleCard = 1.08f
const val scaleControl = 1.05f
const val scaleRow = 1.02f
val ringCardGap = 2.0.dp
val ringCardGap = 0.0.dp
val ringCardRing = 2.0.dp
val ringButtonGap = 2.0.dp
val ringButtonRing = 2.0.dp
@ -429,8 +430,8 @@ object WolfskinTokens {
val progressBottom = 15.0.dp
val progressGap = 9.0.dp
val progressLift = -7.0.dp
val castHeight = 197.0.dp
val castNameTop = 108.0.dp
val castHeight = 231.0.dp
val castNameTop = 118.0.dp
val castNameSide = 6.0.dp
val personTop = 85.0.dp
val personGap = 24.0.dp
@ -456,7 +457,7 @@ object WolfskinTokens {
val infoRowGap = 3.0.dp
val infoColumnGap = 10.0.dp
val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val gridHeight = 244.0.dp
val gridHeight = 262.0.dp
val minimumEndPadding = 40.0.dp
val ratingGap = 10.0.dp
val ratingMarkGap = 4.0.dp

View file

@ -115,5 +115,6 @@ class HomeCaptureTest {
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2)
@Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
@Test fun st103_heroRatings() = capture("st-103")
@Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu")
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
}

View file

@ -82,6 +82,14 @@ class LibraryCaptureTest {
@Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true)
@Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false)
@Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false)
@Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows")
@Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid ->
val original = grid.source
grid.source = List(200) { index ->
original[index % original.size].copy(id = "rail-$index", title = "${('A'.code + index / 10).toChar()} Title $index")
}
grid.index = 4
}
}
fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/")

View file

@ -112,18 +112,25 @@ class LibraryKeysTest {
val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") }
val grid = LibraryGridState("Movies", titles)
assertTrue(grid.railVisible)
grid.index = 5
grid.index = 4
grid.key(ShellKey.Right)
assertTrue(grid.inRail)
grid.key(ShellKey.Down)
assertEquals('B', grid.railLetter)
grid.key(ShellKey.Back)
assertEquals(5, grid.index)
assertEquals(4, grid.index)
assertFalse(grid.inRail)
grid.key(ShellKey.Down)
assertEquals(9, grid.index)
grid.key(ShellKey.Up)
assertEquals(4, grid.index)
grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok)
assertEquals('B', grid.focusedTitle!!.letter)
grid.source = titles.take(199)
assertFalse(grid.railVisible)
grid.index = 4
grid.key(ShellKey.Right)
assertEquals(5, grid.index)
grid.source = titles; grid.sort = 1
assertFalse(grid.railVisible)
}

View file

@ -0,0 +1,53 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
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
/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */
@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") }
val grid = LibraryGridState("Shows", titles.take(199)).apply { index = 198 }
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val poster = rule.onNodeWithTag("poster-198")
poster.assertIsDisplayed()
val original = poster.fetchSemanticsNode().boundsInRoot
for (count in listOf(200, 199)) {
rule.runOnIdle { grid.source = titles.take(count) }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
poster.assertIsDisplayed()
val current = poster.fetchSemanticsNode().boundsInRoot
assertEquals(198, grid.index)
assertEquals("198", grid.focusedTitle!!.id)
assertEquals(original.top, current.top, 1f)
assertEquals(original.height, current.height, 1f)
}
}
}

View file

@ -1,6 +1,6 @@
{
"id": "library",
"title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)",
"title": "Libraries: Movies, Shows, Anime (continuous grid; five columns with letter rail)",
"specSection": "6.4",
"source": "index.html:1403-1426, 1563-1611",
"layout": [
@ -22,7 +22,7 @@
"token": null,
"title": "",
"yPx": 236,
"heightPx": 104,
"heightPx": 136,
"cards": null,
"focusable": true,
"endsInSeeAll": false,
@ -33,8 +33,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 340,
"heightPx": 488,
"yPx": 372,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -45,8 +45,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 828,
"heightPx": 488,
"yPx": 896,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -57,8 +57,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 1316,
"heightPx": 488,
"yPx": 1420,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -69,8 +69,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 1804,
"heightPx": 488,
"yPx": 1944,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -81,8 +81,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 2292,
"heightPx": 488,
"yPx": 2468,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -93,8 +93,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 2780,
"heightPx": 488,
"yPx": 2992,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -105,8 +105,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 3268,
"heightPx": 488,
"yPx": 3516,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -117,8 +117,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 3756,
"heightPx": 488,
"yPx": 4040,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -129,8 +129,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 4244,
"heightPx": 488,
"yPx": 4564,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -141,8 +141,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 4732,
"heightPx": 488,
"yPx": 5088,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -153,8 +153,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 5220,
"heightPx": 488,
"yPx": 5612,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -165,8 +165,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 5708,
"heightPx": 488,
"yPx": 6136,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -177,8 +177,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 6196,
"heightPx": 488,
"yPx": 6660,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
@ -189,8 +189,8 @@
"type": "grid",
"token": "poster",
"title": "",
"yPx": 6684,
"heightPx": 488,
"yPx": 7184,
"heightPx": 524,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,

View file

@ -14,8 +14,8 @@
"progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"},
"progressGap": {"dp": 9, "px": 18, "source": "index.html:217"},
"progressLift": {"dp": -7, "px": -14, "source": "index.html:217"},
"castHeight": {"dp": 197, "px": 394, "source": "index.html:1018"},
"castNameTop": {"dp": 108, "px": 216, "source": "index.html:289"},
"castHeight": {"dp": 231, "px": 462, "source": "index.html:1018"},
"castNameTop": {"dp": 118, "px": 236, "source": "index.html:289"},
"castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"},
"personTop": {"dp": 85, "px": 170, "source": "index.html:293"},
"personGap": {"dp": 24, "px": 48, "source": "index.html:293"},
@ -41,7 +41,7 @@
"infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"},
"infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"},
"infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"},
"gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"},
"gridHeight": {"dp": 262, "px": 524, "source": "index.html:1019"},
"minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"},
"ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"},
"ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"},

View file

@ -81,9 +81,9 @@
--layout-navSolidAfter: 40.0px;
--layout-pinnedLine: 168.0px;
--layout-windowAhead: 700.0px;
--layout-rowHeadingBand: 60.0px;
--layout-rowGapAfter: 44.0px;
--layout-gridRowGap: 28.0px;
--layout-rowHeadingBand: 84.0px;
--layout-rowGapAfter: 68.0px;
--layout-gridRowGap: 44.0px;
--layout-row_hero: 820.0px;
--layout-row_phero: 740.0px;
--layout-row_section: 150.0px;
@ -111,7 +111,7 @@
--layout-sheetWidth: 800.0px;
--layout-sheetRow: 100.0px;
--layout-stateWidth: 1100.0px;
--layout-captionOffset: 18.0px;
--layout-captionOffset: 38.0px;
--layout-badge: 48.0px;
--layout-badgeInset: 14.0px;
--layout-progressBar: 8.0px;
@ -122,6 +122,7 @@
--layout-navAvatar: 64.0px;
--layout-castCircle: 200.0px;
--layout-personPortrait: 240.0px;
--layout-row_chipsGrid: 136.0px;
--motion-fast: 140ms;
--motion-base: 160ms;
--motion-scroll: 180ms;
@ -273,8 +274,8 @@
--title-progressBottom: 30.0px;
--title-progressGap: 18.0px;
--title-progressLift: -14.0px;
--title-castHeight: 394.0px;
--title-castNameTop: 216.0px;
--title-castHeight: 462.0px;
--title-castNameTop: 236.0px;
--title-castNameSide: 12.0px;
--title-personTop: 170.0px;
--title-personGap: 48.0px;
@ -300,7 +301,7 @@
--title-infoRowGap: 6.0px;
--title-infoColumnGap: 20.0px;
--title-infoKey: 24.0px;
--title-gridHeight: 488.0px;
--title-gridHeight: 524.0px;
--title-minimumEndPadding: 80.0px;
--title-ratingGap: 20.0px;
--title-ratingMarkGap: 8.0px;

View file

@ -1,5 +1,5 @@
{
"$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it).",
"$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (originally data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...; now amended in data/tv-v2/spacing-2026-11-06/index.html, sha256 fcc5d879...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it). Amended 2026-11-06 (spec revision 3, Level 2 spacing + card ring; data/tv-card-spacing/answers.md): card, layout.row*/gridRowGap and focus.ringCard changed; every other token is as extracted. The prototype source line numbers in `source` fields predate the amendment and drift by a few lines after index.html:8.",
"meta": {
"density": 2.0,
"stagePx": [
@ -10,8 +10,8 @@
960,
540
],
"source": "data/tv-v2/opus-final-2026-11-05/index.html",
"sourceSha256": "ee8844fc6d0237a3727de0d9454db8e5cf3bb14083fb7877d7bb0f4233c6e23d",
"source": "data/tv-v2/spacing-2026-11-06/index.html",
"sourceSha256": "fcc5d879c34da868b817f8d9d306193743d3933db3096da605bda659d8e633b2",
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py"
},
"color": {
@ -773,27 +773,27 @@
},
"rowHeadingBand": {
"$type": "dimension",
"$value": "30dp",
"px": 60,
"dp": 30.0,
"$value": "42dp",
"px": 84,
"dp": 42.0,
"source": "index.html:1018",
"usedBy": "shelf row: heading band (44 px heading at top 0) before the cards start at y + 60"
"usedBy": "shelf row: heading band (44 px heading at top 0, then 40 px) before the cards start at y + 84 (HEAD_OFF)"
},
"rowGapAfter": {
"$type": "dimension",
"$value": "34dp",
"px": 68,
"dp": 34.0,
"source": "index.html:1018",
"usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 84 + card h + caption + 68"
},
"gridRowGap": {
"$type": "dimension",
"$value": "22dp",
"px": 44,
"dp": 22.0,
"source": "index.html:1018",
"usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 60 + card h + caption + 44"
},
"gridRowGap": {
"$type": "dimension",
"$value": "14dp",
"px": 28,
"dp": 14.0,
"source": "index.html:1019",
"usedBy": "grid row height = 384 + 76 + 28 = 488 (library, See all, person)"
"usedBy": "grid row height = 384 + 96 + 44 = 524 (library, See all, person)"
},
"row_hero": {
"$type": "dimension",
@ -825,7 +825,7 @@
"px": 104,
"dp": 52.0,
"source": "index.html:1021",
"usedBy": "chip row (Discover filters, library toolbar, See all/service type chips, seasons)"
"usedBy": "chip row above shelves (Discover filters, service type chips, seasons); 104 px"
},
"row_note": {
"$type": "dimension",
@ -1041,10 +1041,10 @@
},
"captionOffset": {
"$type": "dimension",
"px": 18,
"dp": 9,
"px": 38,
"dp": 19,
"source": "index.html:1242",
"usedBy": "caption under a focused poster starts 18 px below the card bottom (capHtml top = headH + h + 18); width 640"
"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"
},
"plateInset": {
"$type": "dimension",
@ -1171,6 +1171,14 @@
},
"source": "index.html:204",
"usedBy": "letter rail (only for destinations with >= 200 titles)"
},
"row_chipsGrid": {
"$type": "dimension",
"$value": "68dp",
"px": 136,
"dp": 68.0,
"source": "index.html:rowH",
"usedBy": "chip row directly above a grid (library toolbar, See all type chips): 136 px, so 52 px from the chips to the first card row (was 104 / 20 px)"
}
},
"card": {
@ -1179,16 +1187,16 @@
"px": {
"w": 256,
"h": 384,
"gap": 24,
"pitch": 280,
"captionBand": 76
"gap": 40,
"pitch": 296,
"captionBand": 96
},
"dp": {
"w": 128.0,
"h": 192.0,
"gap": 12.0,
"pitch": 140.0,
"captionBand": 38.0
"gap": 20.0,
"pitch": 148.0,
"captionBand": 48.0
},
"ratio": "2:3",
"fullSlotsPerRow": 6,
@ -1198,16 +1206,16 @@
"tile": {
"$type": "card",
"px": {
"w": 416,
"h": 234,
"gap": 24,
"w": 400,
"h": 225,
"gap": 40,
"pitch": 440,
"captionBand": 0
},
"dp": {
"w": 208.0,
"h": 117.0,
"gap": 12.0,
"w": 200.0,
"h": 112.5,
"gap": 20.0,
"pitch": 220.0,
"captionBand": 0.0
},
@ -1219,16 +1227,16 @@
"wide": {
"$type": "card",
"px": {
"w": 560,
"h": 315,
"gap": 24,
"w": 544,
"h": 306,
"gap": 40,
"pitch": 584,
"captionBand": 0
},
"dp": {
"w": 280.0,
"h": 157.5,
"gap": 12.0,
"w": 272.0,
"h": 153.0,
"gap": 20.0,
"pitch": 292.0,
"captionBand": 0.0
},
@ -1242,15 +1250,15 @@
"px": {
"w": 256,
"h": 144,
"gap": 24,
"pitch": 280,
"gap": 40,
"pitch": 296,
"captionBand": 0
},
"dp": {
"w": 128.0,
"h": 72.0,
"gap": 12.0,
"pitch": 140.0,
"gap": 20.0,
"pitch": 148.0,
"captionBand": 0.0
},
"ratio": "16:9",
@ -1262,22 +1270,22 @@
"$type": "card",
"px": {
"w": 200,
"h": 290,
"gap": 28,
"pitch": 228,
"h": 310,
"gap": 44,
"pitch": 244,
"captionBand": 0
},
"dp": {
"w": 100.0,
"h": 145.0,
"gap": 14.0,
"pitch": 114.0,
"h": 155.0,
"gap": 22.0,
"pitch": 122.0,
"captionBand": 0.0
},
"ratio": "1:1 circle",
"fullSlotsPerRow": 7,
"source": "index.html:660",
"usedBy": "Cast row: 200 px circle + name + character, 290 px tall"
"usedBy": "Cast row: 200 px circle + name + character, 310 tall (name starts 236 below the top)"
}
},
"focus": {
@ -1318,16 +1326,17 @@
},
"ringCard": {
"$type": "shadow",
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 18px 50px rgb(0 0 0 / .65)",
"gapPx": 4,
"$value": "0 0 0 2px var(--accent), inset 0 0 0 2px var(--accent) (drawn above the art), 0 18px 50px rgb(0 0 0 / .65)",
"gapPx": 0,
"ringPx": 4,
"gapDp": 2,
"gapDp": 0,
"ringDp": 2,
"gapColor": "color.bg",
"ringColor": "color.accent",
"liftShadow": "0 18px 50px rgb(0 0 0 / .65)",
"source": "index.html:134",
"usedBy": "focused card: a 4 px bg-coloured gap then a 4 px lilac ring OUTSIDE the art (never covers it), plus a lift shadow; drawn on the circle for cast cards"
"usedBy": "focused card (poster, tile, wide, entry) and cast circle: one 4 px lilac ring centred on the art edge, 2 px outside and 2 px over the art, no gap, plus a lift shadow (Justin 2026-11-06: \"2px inset, 4px ring\")",
"outsidePx": 2,
"insidePx": 2
},
"ringButton": {
"$type": "shadow",
@ -1589,4 +1598,4 @@
"usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus"
}
}
}
}

View file

@ -2,7 +2,7 @@
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json
3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
4f19801799936db4c126cd7442942ff8552774f141af0de42eb5b3c1ecac92fc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json
b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css
6764fa02146c3f37cc60e298f7b13c5d2186fb2be575a39e45c7b207da5429af tokens.json