Merge pull request 'Remove the unfocused watched control ring' (#69) from fm/tv-watched-ring into fm/tv-round2

This commit is contained in:
justinzeus 2026-11-07 23:28:01 +01:00
commit 4af1898fd2
4 changed files with 39 additions and 1 deletions

View file

@ -24,6 +24,8 @@ import org.json.JSONObject
import org.junit.Before import org.junit.Before
import org.junit.After import org.junit.After
import org.junit.Rule import org.junit.Rule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.LocalTitleBitmaps import org.justinvisser.wolfskin.design.LocalTitleBitmaps
@ -225,4 +227,27 @@ class TitleCaptureTest {
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days") @Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) }) @Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) }) @Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
@Test fun watchedRingBelongsOnlyToFocus() {
lateinit var state: TitleState
capture(126) { current, _ -> state = current; state.watched = false; option(state, "tracks") }
val edges = mutableListOf<List<Int>>()
for ((index, configuration) in listOf(false to false, true to false, false to true, true to true).withIndex()) {
val (watched, focused) = configuration
rule.runOnIdle { state.watched = watched; option(state, if (focused) "watched" else "tracks") }
save(126 + index)
val bounds = rule.onNodeWithTag("title-option-watched").fetchSemanticsNode().boundsInRoot
val file = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "st-${126 + index}.png")
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val edge = (-8..8).map { offset -> bitmap.getPixel(bounds.left.toInt() + offset, bounds.center.y.toInt()) }
edges.add(edge)
bitmap.recycle()
}
val accent = WolfskinTokens.Color.accent.toArgb()
assertEquals("Unwatched at rest has no ring", 0, edges[0].count { it == accent })
assertEquals("Watched at rest has no ring", 0, edges[1].count { it == accent })
assertEquals("Toggling watched must not change the resting edge", edges[0], edges[1])
assertTrue("Keyboard focus keeps its accent ring", edges[2].any { it == accent })
assertEquals("Watched keeps exactly the same focused ring", edges[2], edges[3])
}
} }

View file

@ -140,6 +140,12 @@ Justin's additional correction is binding even where a reference frame leaks: **
Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers. Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers.
## Details watched control
The on state has no unfocused ring: Justin (2026-11-07) read it as active/selected. This overrides the older IconButton “on = inset 3 px accent ring” and Watched “inset accent ring” descriptions in the written specification. The existing open-eye versus crossed-eye glyph, “Watched” header text, and “Mark as unwatched” action caption distinguish the state without a new colour or token. The focus ring, scale, fill and selected accessibility semantics are unchanged.
Source — Justin (2026-11-07): “the watches icon on details pages has a border, making it look like it's active/selected.”
## Sources and verification ## Sources and verification
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.

View file

@ -988,6 +988,14 @@ appear only in the header facts line, not again beside progress. Explicit episod
pages use the opened episode's own set, including its `Ep` score; movie and series pages use the opened episode's own set, including its `Ep` score; movie and series
pages keep their own sets. pages keep their own sets.
The watched on state has no unfocused ring: Justin (2026-11-07) read it as
active/selected — “the watches icon on details pages has a border, making it
look like it's active/selected.” The open-eye/crossed-eye glyph, Watched header
and Mark as unwatched caption still communicate the state. The shared focus
ring is unchanged. `TitleCaptureTest.watchedRingBelongsOnlyToFocus` records the
same Details row in all four watched/focus combinations and checks its rendered
edge pixels.
Episode shelf skeletons use the same width, height and pitch as ready cards. Episode shelf skeletons use the same width, height and pitch as ready cards.
Person filmography and related-title shelves remain posters; Home continuation Person filmography and related-title shelves remain posters; Home continuation
tiles are a separate component and retain their series-name context. tiles are a separate component and retain their series-name context.

View file

@ -39,7 +39,6 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
.background(background, shape) .background(background, shape)
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions), .clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))