Merge library slice and reuse shared image policy
Some checks failed
review/design-claude Changes requested: 7 design-parity findings (separator opacity, line-box rhythm, stage gap, mark chips, avatar, count, thumb fringe)

This commit is contained in:
justinzeus 2026-11-05 17:41:43 +01:00
commit 439eea3ff8
629 changed files with 2202 additions and 22 deletions

View file

@ -0,0 +1,89 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.Locale
import org.junit.Before
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Each native screen state uses offline prototype data and real shell focus drawing. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class LibraryCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val previousLocale = Locale.getDefault()
private val fixture by lazy { libraryFixture() }
@Before fun fullscreen() {
Locale.setDefault(Locale.US)
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
private fun capture(name: String, person: String = "shared", destination: String = "movies", catalogue: String? = null, ratings: Boolean = true, reachable: Boolean = true, cached: Boolean = false, configure: (LibraryGridState) -> Unit = {}) {
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(destination) }
val source = when (catalogue) {
"picks" -> fixture.picks(person, "movie")
"trending" -> fixture.trending()
"duplicate" -> fixture.duplicatePage()
else -> fixture.library(person, destination)
}
val grid = LibraryGridState(if (catalogue == "picks") "Movies for you" else if (catalogue != null) "Trending" else destination.replaceFirstChar { it.uppercase() }, source, catalogue != null)
configure(grid)
shell.view.library = grid
rule.setContent {
WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
LibraryScreen(shell, grid, modifier, ratingsSignedIn = ratings, ratingsReachable = reachable,
cachedRatings = if (cached) setOf(grid.items[grid.index].id) else emptySet(), offlineArt = ::fixtureArt)
}
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
@Test fun st021_sharedMovies() = capture("st-021")
@Test fun st022_ratingSort() = capture("st-022") { it.sort = 2 }
@Test fun st023_rowEndClamps() = capture("st-023") { it.index = 5; it.key(ShellKey.Right) }
@Test fun st024_anime() = capture("st-024", "justin", "anime")
@Test fun st025_unwatched() = capture("st-025") { it.unwatched = true }
@Test fun st026_empty() = capture("st-026", "mindel") { it.source = emptyList() }
@Test fun st027_unavailable() = capture("st-027", "mindel", "shows") { it.status = LibraryStatus.Unavailable }
@Test fun st028_loading() = capture("st-028") { it.status = LibraryStatus.Loading }
@Test fun st030_fullPicks() = capture("st-030", "justin", "discover", "picks")
@Test fun st035_duplicateOnlyTail() = capture("st-035", "justin", "discover", "duplicate") { it.tail = GridTail.ShowMore; it.index = it.tailIndex }
@Test fun st039_trending() = capture("st-039", "justin", "discover", "trending")
@Test fun st095_widestPosterPlate() = capture("st-095") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Grave of the Fireflies" } }
@Test fun st097_criticsPlate() = capture("st-097")
@Test fun st098_tmdbPlate() = capture("st-098", "justin", "anime") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Chainsaw Man" } }
@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)
}
fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/")
fun fixtureArt(path: String): ImageBitmap? = LibraryCaptureTest::class.java.getResourceAsStream(path)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() }

View file

@ -0,0 +1,187 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.runtime.remember
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
/** Acceptance data drives Android keys through the real shell and grid controller. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class LibraryKeysTest {
@get:Rule val rule = createComposeRule()
private val shell = ShellState(fixturePeople(), "justin")
private val fixture by lazy { libraryFixture() }
private var opened: LibraryTitle? = null
private fun render() {
rule.setContent {
WolfskinTheme {
ShellSurface(shell) { modifier ->
if (shell.route in listOf("movies", "shows", "anime")) {
val view = shell.view
val grid = remember(shell.person.key, shell.route, view) {
view.library ?: LibraryGridState(shell.route, fixture.library(shell.person.key, shell.route), onTitle = { opened = it }).also { view.library = it }
}
LibraryScreen(shell, grid, modifier, offlineArt = ::fixtureArt)
} else ShellPlaceholder(shell.route, modifier)
}
}
}
}
private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }; rule.waitForIdle() }
private fun press(keys: List<Key>) { keys.forEach(::press) }
@Test fun spiritedAwayInApprovedTwentyPresses() {
val document = LibraryKeysTest::class.java.getResourceAsStream("/library-screen.json")!!.bufferedReader().use { JSONObject(it.readText()) }
val paths = document.getJSONArray("pressPaths")
render()
for (index in 0 until paths.length()) {
val path = paths.getJSONObject(index)
val keys = path.getJSONArray("keys")
assertEquals(path.getInt("presses"), keys.length())
for (keyIndex in 0 until keys.length()) press(when (keys.getString(keyIndex)) {
"UP" -> Key.DirectionUp
"DOWN" -> Key.DirectionDown
"LEFT" -> Key.DirectionLeft
"RIGHT" -> Key.DirectionRight
"OK" -> Key.DirectionCenter
else -> error("Unknown acceptance key")
})
assertEquals("shared", shell.person.key)
assertEquals("movies", shell.route)
assertFalse(shell.navFocused)
assertEquals("Spirited Away", shell.view.library!!.focusedTitle!!.title)
}
}
@Test fun rowEdgesToolbarSortAndBackKeepExactCard() {
render()
press(listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter))
val grid = shell.view.library!!
press(Key.DirectionLeft)
assertEquals(0, grid.index)
repeat(6) { press(Key.DirectionRight) }
assertEquals(5, grid.index)
assertFalse(grid.inRail)
press(listOf(Key.DirectionDown, Key.Back))
assertTrue(shell.navFocused)
assertEquals(11, grid.index)
press(Key.DirectionDown)
assertEquals(11, grid.index)
press(listOf(Key.DirectionUp, Key.DirectionUp))
assertTrue(grid.toolbar)
press(Key.DirectionCenter)
assertEquals(1, grid.sort)
assertTrue(grid.toolbar)
press(Key.DirectionCenter)
assertEquals(2, grid.sort)
assertEquals("Grave of the Fireflies", grid.items.first().title)
}
@Test fun cardActsOnReleaseAndHeldRepeatCannotOpenTwice() {
render()
press(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter))
rule.runOnIdle {
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
assertNull(opened)
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 1)))
assertNull(opened)
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
assertEquals(shell.view.library!!.focusedTitle, opened)
opened = null
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
assertNull(opened)
}
}
@Test fun railGateJumpAndBackRespectOriginalCard() {
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.key(ShellKey.Right)
assertTrue(grid.inRail)
grid.key(ShellKey.Down)
assertEquals('B', grid.railLetter)
grid.key(ShellKey.Back)
assertEquals(5, grid.index)
assertFalse(grid.inRail)
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.source = titles; grid.sort = 1
assertFalse(grid.railVisible)
}
@Test fun mixedSeeAllPreservesFullListAndClampsShortRow() {
val grid = LibraryGridState("Full list", (0 until 8).map { LibraryTitle("$it", "Title $it", if (it % 2 == 0) "movie" else "tv") }, catalogue = true)
grid.index = 5
grid.key(ShellKey.Down)
assertEquals(7, grid.index)
grid.key(ShellKey.Up); grid.key(ShellKey.Up)
assertTrue(grid.toolbar)
grid.key(ShellKey.Right); grid.key(ShellKey.Ok)
assertEquals(listOf("0", "2", "4", "6"), grid.items.map { it.id })
grid.key(ShellKey.Left); grid.key(ShellKey.Ok)
assertEquals(8, grid.items.size)
}
@Test fun downClampsShortLastRowBeforeEnteringRecoveryTail() {
var recoveries = 0
val grid = LibraryGridState("Trending",
(0 until 20).map { LibraryTitle("$it", "Title $it", "movie") },
catalogue = true, onRecover = { recoveries++ },
).apply { tail = GridTail.Retry; index = 14 }
shell.pickerVisible = false
shell.enterDestination("discover")
rule.setContent {
WolfskinTheme {
ShellSurface(shell) { modifier ->
LibraryScreen(shell, grid, modifier)
}
}
}
press(Key.DirectionDown)
assertEquals(19, grid.index)
assertEquals("Title 19", grid.focusedTitle!!.title)
assertEquals(0, recoveries)
press(Key.DirectionDown)
assertEquals(24, grid.index)
assertEquals("grid-tail", grid.cardKey)
press(Key.DirectionDown)
assertEquals(24, grid.index)
press(Key.DirectionCenter)
assertEquals(1, recoveries)
press(Key.DirectionUp)
assertEquals(18, grid.index)
assertEquals("Title 18", grid.focusedTitle!!.title)
}
@Test fun scoresFollowLocaleAndUnratedSortsLast() {
assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL")))
assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US))
assertEquals("87%", LibraryRating("rtCritics", 87.9).formatted(Locale.forLanguageTag("nl-NL")))
val grid = LibraryGridState("Movies", listOf(
LibraryTitle("none", "The Apple", "movie"),
LibraryTitle("imdb", "A Bee", "movie", ratings = listOf(LibraryRating("imdb", 8.0))),
LibraryTitle("rt", "An Ant", "movie", ratings = listOf(LibraryRating("rtCritics", 80.0))),
))
grid.sort = 2
assertEquals(listOf("rt", "imdb", "none"), grid.items.map { it.id })
}
}

View file

@ -0,0 +1,55 @@
package org.justinvisser.wolfskin.design
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
/** Paging never loops through repeated pages or loses the readable list after a failure. */
class LibraryPaginationTest {
private fun title(id: Int) = LibraryTitle("$id", "Title $id", "movie")
@Test fun duplicateOnlyReadWaitsForExplicitRetryThenAppendsUniqueTitles() = runBlocking {
val grid = LibraryGridState("Trending", (0 until 20).map(::title), catalogue = true)
var calls = 0
val pager = LibraryPagination(grid) { page ->
calls++
when (page) {
2 -> LibraryPage((0 until 20).map(::title), true)
3 -> LibraryPage((18 until 25).map(::title), false)
else -> error("Unexpected extra read")
}
}
grid.index = 9
pager.readAhead()
assertEquals(0, calls)
grid.index = 10
pager.readAhead()
assertEquals(GridTail.ShowMore, grid.tail)
pager.readAhead()
assertEquals(1, calls)
pager.readAhead(force = true)
assertEquals((0 until 25).map { "$it" }, grid.source.map { it.id })
assertEquals(GridTail.None, grid.tail)
pager.readAhead(force = true)
assertEquals(2, calls)
}
@Test fun failedReadRetainsTitlesAndRetriesSamePage() = runBlocking {
val grid = LibraryGridState("Trending", (0 until 12).map(::title), catalogue = true)
var reachable = false
val pages = mutableListOf<Int>()
val pager = LibraryPagination(grid) { page ->
pages += page
if (!reachable) throw java.io.IOException("offline")
LibraryPage(listOf(title(12)), false)
}
grid.index = 11
pager.readAhead()
assertEquals(GridTail.Retry, grid.tail)
assertEquals((0 until 12).map { "$it" }, grid.source.map { it.id })
reachable = true
pager.readAhead(force = true)
assertEquals(listOf(2, 2), pages)
assertEquals("12", grid.source.last().id)
}
}

View file

@ -0,0 +1,494 @@
{
"id": "library",
"title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)",
"specSection": "6.4",
"source": "index.html:1403-1426, 1563-1611",
"layout": [
{
"id": "lhead",
"type": "lhead",
"token": null,
"title": "Movies",
"yPx": 0,
"heightPx": 236,
"cards": null,
"focusable": false,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "ltools",
"type": "chips",
"token": null,
"title": "",
"yPx": 236,
"heightPx": 104,
"cards": null,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g0",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 340,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g1",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 828,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g2",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 1316,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g3",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 1804,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g4",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 2292,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g5",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 2780,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g6",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 3268,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g7",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 3756,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g8",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 4244,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g9",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 4732,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g10",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 5220,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g11",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 5708,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g12",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 6196,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g13",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 6684,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
}
],
"states": [
{
"id": "st-021",
"name": "Shared Movies: 84, continuous grid",
"statesGroup": "Libraries",
"capture": "st-021-shared-movies-84-continuous-grid.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-021-shared-movies-84-continuous-grid.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Shared Movies: 84, continuous grid · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
},
{
"id": "st-022",
"name": "Shared Movies sorted by Rating (G10)",
"statesGroup": "Libraries",
"capture": "st-022-shared-movies-sorted-by-rating-g10.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-022-shared-movies-sorted-by-rating-g10.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Shared Movies sorted by Rating (G10) · Person: Shared · Screen: Movies · Focus: g0 › Grave of the Fireflies"
},
{
"id": "st-023",
"name": "Right at a row end clamps (no letter rail below 200)",
"statesGroup": "Libraries",
"capture": "st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Right at a row end clamps (no letter rail below 200) · Person: Shared · Screen: Movies · Focus: g0 › Bohemian Rhapsody"
},
{
"id": "st-024",
"name": "Justin Anime: 18 series",
"statesGroup": "Libraries",
"capture": "st-024-justin-anime-18-series.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-024-justin-anime-18-series.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "justin",
"focusReadout": "State: Justin Anime: 18 series · Person: Justin · Screen: Anime · Focus: g0 › Bleach"
},
{
"id": "st-025",
"name": "Unwatched filter",
"statesGroup": "Libraries",
"capture": "st-025-unwatched-filter.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-025-unwatched-filter.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Unwatched filter · Person: Shared · Screen: Movies · Focus: g0 › Annie Hall"
},
{
"id": "st-026",
"name": "Empty destination",
"statesGroup": "Libraries",
"capture": "st-026-empty-destination.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-026-empty-destination.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "mindel",
"focusReadout": "State: Empty destination · Person: Mindel · Screen: Movies · Focus: Nothing in Movies yet › Open Discover"
},
{
"id": "st-027",
"name": "Jellyfin unreachable (error)",
"statesGroup": "Libraries",
"capture": "st-027-jellyfin-unreachable-error.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-027-jellyfin-unreachable-error.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "mindel",
"focusReadout": "State: Jellyfin unreachable (error) · Person: Mindel · Screen: Shows · Focus: Couldn’t reach your library › Try again"
},
{
"id": "st-028",
"name": "Loading (skeleton, no saved copy)",
"statesGroup": "Libraries",
"capture": "st-028-loading-skeleton-no-saved-copy.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-028-loading-skeleton-no-saved-copy.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "nav",
"person": "shared",
"focusReadout": "State: Loading (skeleton, no saved copy) · Person: Shared · Screen: Movies · Focus: nav › Movies"
},
{
"id": "st-095",
"name": "Plate: widest real set on a 256 px poster",
"statesGroup": "Ratings: one-line plate (round 3)",
"capture": "st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Plate: widest real set on a 256 px poster · Person: Shared · Screen: Movies · Focus: g4 › Grave of the Fireflies"
},
{
"id": "st-097",
"name": "Poster: RT critics on a library grid (Shared Movies)",
"statesGroup": "Ratings (round 2)",
"capture": "st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Poster: RT critics on a library grid (Shared Movies) · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
},
{
"id": "st-098",
"name": "Poster: TMDB only (Justin Anime)",
"statesGroup": "Ratings (round 2)",
"capture": "st-098-poster-tmdb-only-justin-anime.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-098-poster-tmdb-only-justin-anime.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "justin",
"focusReadout": "State: Poster: TMDB only (Justin Anime) · Person: Justin · Screen: Anime · Focus: g0 › Chainsaw Man"
},
{
"id": "st-112",
"name": "Conductarr down: a cached rating still shows",
"statesGroup": "Ratings (round 2)",
"capture": "st-112-conductarr-down-a-cached-rating-still-shows.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-112-conductarr-down-a-cached-rating-still-shows.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Conductarr down: a cached rating still shows · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
},
{
"id": "st-113",
"name": "Conductarr down: an uncached title shows nothing",
"statesGroup": "Ratings (round 2)",
"capture": "st-113-conductarr-down-an-uncached-title-shows-nothing.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-113-conductarr-down-an-uncached-title-shows-nothing.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Conductarr down: an uncached title shows nothing · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
}
],
"focusMap": {
"entry": "first grid card (not the toolbar); returning: remembered card per person",
"zones": {
"toolbar": {
"up": "nav",
"down": "grid",
"left": "previous chip",
"right": "next chip",
"ok": "Sort cycles A–Z -> Recently added -> Rating; Filter toggles All / Unwatched; focus returns to the toolbar",
"back": "nav",
"source": "index.html:445"
},
"grid": {
"up": "row above, same column (top row -> toolbar)",
"down": "row below, same column (shorter last row: its last card)",
"left": "previous column (column 0: stays)",
"right": "next column; at the row end: stays (no rail below 200 titles)",
"ok": "title page (key-up)",
"back": "nav, place kept",
"ratings": "one-line plate after 350 ms",
"source": "index.html:446"
},
"letterRail": "only at >= 200 titles and A–Z sort: Right at a row end enters; Up/Down move letters and scroll live; OK or Left drops focus on that letter's first title; Back returns to where you were (index.html:1586-1605)"
},
"exit": [
"Up from toolbar -> nav",
"Back -> nav"
]
},
"pressPaths": [
{
"goal": "Reach “Spirited Away” (68th of 84) in Shared Movies by scrolling",
"keys": [
"RIGHT",
"RIGHT",
"OK",
"UP",
"RIGHT",
"RIGHT",
"OK",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"RIGHT",
"RIGHT"
],
"presses": 20,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
}
],
"data": [
{
"component": "Library grid",
"source": "Jellyfin /Users/{id}/Items?ParentId=…&SortBy=SortName over the destination's libraries, read in full in 200-item chunks ahead of focus",
"fields": [
"SortName",
"ProductionYear",
"ImageTags.Primary",
"UserData.Played",
"UserData.PlaybackPositionTicks",
"ProviderIds.Tmdb"
],
"cache": "LKG snapshot per destination; refresh on open if older than 10 min",
"states": [
"empty",
"Jellyfin unreachable (LKG + Saved chip, or state row)",
"missing art"
],
"pageRow": "index.html:469"
},
{
"component": "Destinations",
"source": "Conductarr GET /curation/library-audiences (owner, media_type, is_anime, libraries[], users[]); until sign-in, the name rule (parked board mapping-source = name-rule)",
"pageRow": "index.html:468"
}
],
"strings": {
"count": "{n} films | {n} series",
"empty": [
"Nothing in {Destination} yet",
"Titles you or others request for you land here.",
"Open Discover"
],
"emptyUnwatched": [
"Nothing unwatched in {Destination}",
"Everything here has been watched.",
"Show all"
],
"down": [
"Couldn’t reach your library",
"Jellyfin isn’t answering. Nothing here is lost; try again in a moment.",
"Try again"
],
"sort": [
"A–Z",
"Recently added",
"Rating"
],
"filter": [
"All",
"Unwatched"
]
},
"ratingScoreFormat": {
"rule": "TMDB, IMDb and \"Ep\" scores (0-10) are formatted with the TV locale and exactly one decimal, e.g. String.format(Locale.getDefault(), \"%.1f\", x): 7,8 on a Dutch-locale box, 7.8 on an English one. RT percentages unchanged (87%). The page prints 7.8 always: a stand-in for the English locale, not authoritative.",
"acceptance": "on a Dutch-locale emulator a TMDB 7.8 renders 7,8 and the one-line plate rule (spec 4.8) still holds because the width is unchanged [INFERENCE: comma and point advance widths not measured]; on an English-locale emulator it renders 7.8 as in the captures",
"source": "Justin 2026-11-05, corr=c248698d8546e1ab; spec 4.7 rule 5, 10.1 check 8"
},
"$schema": "wolfskin-tv-spec/screen@1",
"tokens": "data/tv-spec/tokens.json",
"captures": [
"st-021-shared-movies-84-continuous-grid.jpg",
"st-022-shared-movies-sorted-by-rating-g10.jpg",
"st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
"st-024-justin-anime-18-series.jpg",
"st-025-unwatched-filter.jpg",
"st-026-empty-destination.jpg",
"st-027-jellyfin-unreachable-error.jpg",
"st-028-loading-skeleton-no-saved-copy.jpg",
"st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
"st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
"st-098-poster-tmdb-only-justin-anime.jpg",
"st-112-conductarr-down-a-cached-rating-still-shows.jpg",
"st-113-conductarr-down-an-uncached-title-shows-nothing.jpg"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Some files were not shown because too many files have changed in this diff Show more