Preserve poster progress and focus across library rail changes
All checks were successful
review/code-gpt GPT review blockers fixed and read by firstmate: poster progress inset back to Title.cardProgressInset (220 px track) with test tag + regression; library focus-follow effect keyed on columns/focusedRow/header/rowHeight with a 199<->200 regression; token values/ring bounds/toolbar gap passed review on c17447c5; walked on the real box

This commit is contained in:
justinzeus 2026-11-06 13:21:06 +01:00
parent b9ea593735
commit 92c5661be0
4 changed files with 75 additions and 12 deletions

View file

@ -358,18 +358,28 @@ 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 and the existing card lift. 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.
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/`.
Platform Roboto metrics and fixture ordering are not browser pixel parity.
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

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_chipsGrid 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)
}
}

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)
}
}
}