Give TV cards roomy spacing and edge-straddling focus rings
Some checks failed
review/code-gpt Spacing review: progress-bar inset regression; rail transition focus invalidation

This commit is contained in:
justinzeus 2026-11-06 12:53:51 +01:00
parent 9e15dbee8c
commit c17447c528
18 changed files with 210 additions and 142 deletions

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

@ -94,7 +94,7 @@ fun LibraryScreen(
if (grid.toolbar || row == 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() }
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * row - 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

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