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. 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, 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 above the content, with no background gap. Card lift parameters are unchanged;
and art scale together at 1.08; buttons, avatars, chips and picker focus are cast portraits and episode tiles now receive that lift too. The stroke and art
unchanged. Title shelves reserve vertical drawing room for the scaled ring. 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 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. 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`, `LibraryCaptureTest.spacingShowsFirstFocused`,
`HomeCaptureTest.spacingNextUpFirstFocused` and `HomeCaptureTest.spacingNextUpFirstFocused` and
`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states `TitleCaptureTest.spacingCastFirstFocused` render the amended reference states
at native resolution. Their files are `library/spacing-shows.png`, at native resolution. Their files are `library/spacing-shows.png`,
`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`. `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 ## Title pages and people

View file

@ -41,8 +41,8 @@ fun LibraryPoster(
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
val progress = title.progress val progress = title.progress
if (progress > 0f && progress < 1f) { if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) { .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, 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 headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) { val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density,
val row = index.coerceAtLeast(0) / grid.columns grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
if (grid.toolbar || row == 0 && active) { if (grid.toolbar || focusedRow == 0 && active) {
animateLibraryScroll(view, headerCount, 0f, density) animateLibraryScroll(view, headerCount, 0f, density)
} else if (row > 0) { } else if (focusedRow > 0) {
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid 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 * focusedRow - L.pinnedLine).toPx() }
animateLibraryScroll(view, headerCount, target, density) 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)
}
}
}