Merge pull request 'Give TV cards roomy spacing and edge-straddling focus rings' (#40) from fm/tv-spacing-b7 into main
This commit is contained in:
commit
f37ad0f0fe
20 changed files with 280 additions and 149 deletions
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue