Remove the unfocused watched control ring
All checks were successful
review/code-claude Code APPROVE: one-line ring removal, only Watched uses on
review/design-claude Design PASS: on state readable by glyph, focus ring unchanged

This commit is contained in:
justinzeus 2026-11-07 23:23:39 +01:00
parent 1dee89975e
commit d73796742c
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.After
import org.junit.Rule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
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 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 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.
## 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
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

@ -819,6 +819,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 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.
Person filmography and related-title shelves remain posters; Home continuation
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 }
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
.background(background, shape)
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
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))