From 74033ee1da402396e61b4e0dc0502ecd43000d74 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 16:28:09 +0100 Subject: [PATCH 1/4] Match the settings pill border to the prototype --- docker/README.md | 5 ++++ wolfskin-design/shell-tokens.json | 26 +++++++++++++++++++ .../wolfskin/design/SettingsList.kt | 21 +++++++++++---- .../wolfskin/design/WolfskinTokens.kt | 5 ++++ wolfskin-design/tokens.css | 3 +++ wolfskin-design/tokens.sha256 | 6 ++--- 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/docker/README.md b/docker/README.md index 205f190a..0ccf48aa 100644 --- a/docker/README.md +++ b/docker/README.md @@ -77,6 +77,11 @@ resolves consistently in native captures; the approved JSON weights stay unchang Nav, picker names, labels and pill weights use that same mapping. Nav label colour fades for 200 ms ease-out; the blob retains its critically damped spring. +Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring. +The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box +padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width +ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. + The small gear under the final person opens Settings with Requests focused. Settings groups hand off to existing upstream pages; the Requests entry explicitly has no request data yet. **Advanced settings → Wolfskin screens** is the persistent diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index d5e0c688..b24e8e97 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -215,6 +215,32 @@ "dp": 20.0, "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" }, + "settingsPillStroke": { + "$type": "dimension", + "px": 2, + "dp": 1.0, + "source": "index.html:122; pill border 2 px inside its 40 px border-box" + }, + "settingsPillDashRatio": { + "$type": "number", + "$value": 3, + "source": "st-091 frozen browser top border: 6 px dashes at 2 px stroke width" + }, + "settingsPillDashGapRatio": { + "$type": "number", + "$value": 2, + "source": "st-091 frozen browser top border: 4 px gaps at 2 px stroke width" + }, + "settingsPillBorder": { + "$type": "color", + "$value": "#ffffff38", + "source": "index.html:122; white at 22 percent opacity, rounded to 8-bit alpha" + }, + "settingsPillFocusedBorder": { + "$type": "color", + "$value": "#0b0b1066", + "source": "index.html:328; focused pill ink at 40 percent opacity" + }, "settingsPillPadding": { "$type": "dimension", "px": 16, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 9823c319..91bf3bb3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -24,8 +24,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity @@ -108,10 +110,19 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi @Composable private fun SettingsPill(label: String, focused: Boolean) { val foreground = if (focused) C.onFocus else C.dim - Box(Modifier.height(S.settingsPillHeight).drawBehind { - val stroke = WolfskinTokens.Focus.ringButtonRing.toPx() - drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2)))) - }.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) { + val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder + Box(Modifier.height(S.settingsPillHeight).drawWithCache { + val stroke = S.settingsPillStroke.toPx() + val inset = Offset(stroke / 2, stroke / 2) + val bounds = Size(size.width - stroke, size.height - stroke) + val outline = Stroke(stroke, pathEffect = PathEffect.dashPathEffect( + floatArrayOf(stroke * S.settingsPillDashRatio, stroke * S.settingsPillDashGapRatio), + )) + val radius = CornerRadius(WolfskinTokens.Radius.pill.toPx()) + onDrawBehind { + drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline) + } + }.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) { Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 234a5be8..e4071964 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -266,6 +266,11 @@ object WolfskinTokens { val settingsRowGap = 12.0.dp val settingsIcon = 18.0.dp val settingsPillHeight = 20.0.dp + val settingsPillStroke = 1.0.dp + const val settingsPillDashRatio = 3.0f + const val settingsPillDashGapRatio = 2.0f + val settingsPillBorder = ComposeColor(0x38FFFFFF) + val settingsPillFocusedBorder = ComposeColor(0x660B0B10) val settingsPillPadding = 8.0.dp val settingsPillGap = 6.0.dp val settingsChevron = 15.0.dp diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 120ab55e..47b010b0 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -163,6 +163,9 @@ --shell-settingsRowGap: 24.0px; --shell-settingsIcon: 36.0px; --shell-settingsPillHeight: 40.0px; + --shell-settingsPillStroke: 2.0px; + --shell-settingsPillBorder: #ffffff38; + --shell-settingsPillFocusedBorder: #0b0b1066; --shell-settingsPillPadding: 16.0px; --shell-settingsPillGap: 12.0px; --shell-settingsChevron: 30.0px; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 8816c8ee..128b08d1 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,5 +1,5 @@ 1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py -6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json -58131daf2bf889dcc2cf2a55f4a179fd6ad9bafa2da798b9a49a65ae3d58ae71 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -9143548adf883d934ee86d5d4dd140a9a2d8116c6c8a905a41d2bf7d828bc371 tokens.css +408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json +1037c46c9d804f9349e927257de13ba32cbd1c05e208ec0bc1d345f4b465f1eb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +c133085ef60612df9753ef3681446d9ccb2eb29b1ee1955b86254714c67525e3 tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json From 3b7249888fbf2cef3e286201d6324652bb465bcc Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 16:50:32 +0100 Subject: [PATCH 2/4] Match Home focus and recovery details --- docker/README.md | 5 ++++ wolfskin-design/home-tokens.json | 15 ++++++++++- .../justinvisser/wolfskin/design/Caption.kt | 2 +- .../org/justinvisser/wolfskin/design/Focus.kt | 24 +++++++++++++++++ .../wolfskin/design/HomeHeader.kt | 8 +++--- .../wolfskin/design/HomeScreen.kt | 2 +- .../justinvisser/wolfskin/design/HomeShelf.kt | 4 +-- .../justinvisser/wolfskin/design/NavBar.kt | 9 ++++--- .../wolfskin/design/WhosWatching.kt | 3 ++- .../wolfskin/design/WolfskinTokens.kt | 13 +++++++++ .../src/main/res/drawable/wolfskin_grid.xml | 5 +++- .../wolfskin/design/HomeKeysTest.kt | 27 +++++++++++++++++++ wolfskin-design/tokens.css | 10 +++++++ wolfskin-design/tokens.sha256 | 6 ++--- 14 files changed, 115 insertions(+), 18 deletions(-) diff --git a/docker/README.md b/docker/README.md index a00dbe66..649b1699 100644 --- a/docker/README.md +++ b/docker/README.md @@ -153,6 +153,11 @@ the next shelf. Rating plates use the TV locale, start at 14 sp, shrink to 12 sp and drop trailing entries rather than wrap. Their uncached read gate is 350 ms; cached focus returns without that delay. +Focus rings and native lift draw outside fixed slots. The Saved chip uses its +own measured geometry rather than the smaller Settings pill; caption separators +use the approved 40% opacity token. Vertical moves clamp to the short target row, +including a final See all card, rather than preserving an invalid column. + `make test` includes `HomeCaptureTest` (23 native acceptance states) and the data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover 4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses. diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json index 6efb379a..b9b7c885 100644 --- a/wolfskin-design/home-tokens.json +++ b/wolfskin-design/home-tokens.json @@ -317,6 +317,19 @@ "px": 16, "dp": 8.0, "source": "locked index.html:191" - } + }, + "hairline": { "$type": "dimension", "px": 2, "dp": 1.0, "source": "locked index.html:87,146,194" }, + "savedHeight": { "$type": "dimension", "px": 52, "dp": 26.0, "source": "locked index.html:87" }, + "savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" }, + "savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" }, + "savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" }, + "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }, + "cardLiftOffset": { "$type": "dimension", "px": 18, "dp": 9.0, "source": "locked index.html:134" }, + "cardLiftBlur": { "$type": "dimension", "px": 50, "dp": 25.0, "source": "locked index.html:134" }, + "cardLiftOpacity": { "$type": "number", "$value": 0.65, "source": "locked index.html:134" }, + "buttonLiftOffset": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:110" }, + "buttonLiftBlur": { "$type": "dimension", "px": 40, "dp": 20.0, "source": "locked index.html:110" }, + "buttonLiftOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:110" }, + "factSeparatorOpacity": { "$type": "number", "$value": 0.4, "source": "approved spec 4.4" } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index 0fad5ff3..de280bac 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -25,7 +25,7 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.captionOffset)) { val entries = facts.split(" · ") + listOfNotNull(availability) entries.forEachIndexed { index, fact -> - if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = .4f), style = WolfskinTokens.Type.caption) + if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = WolfskinTokens.Home.factSeparatorOpacity), style = WolfskinTokens.Type.caption) Text(fact, color = if (availability != null && index == entries.lastIndex) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index aa5ab138..a1e4a468 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -13,11 +13,15 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Dp /** Only transforms animate: focus never changes measured geometry. */ @@ -46,6 +50,26 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: } } +/** Native lift draws outside the slot without allocating an offscreen layer per card. */ +@Composable +fun Modifier.focusLift(focused: Boolean, radius: Dp, offset: Dp, blur: Dp, opacity: Float): Modifier { + val alpha by animateFloatAsState(if (focused) 1f else 0f, + tween(WolfskinTokens.Motion.fast), label = "focus-lift") + return drawWithCache { + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = WolfskinTokens.Color.bg.toArgb() + setShadowLayer(blur.toPx(), 0f, offset.toPx(), WolfskinTokens.Color.sheetScrim.copy(alpha = opacity).toArgb()) + } + val corner = radius.toPx() + onDrawBehind { + if (alpha > 0f) { + paint.alpha = (alpha * 255).toInt() + drawIntoCanvas { it.nativeCanvas.drawRoundRect(0f, 0f, size.width, size.height, corner, corner, paint) } + } + } + } +} + /** Pointing changes the same logical focus as the D-pad without activating an action. */ @Composable fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index a5805f05..85eb0198 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -44,8 +44,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List Unit, onHover: () -> Unit) { Box(Modifier.fillMaxWidth().height(L.row_state)) { - Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) { + Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) { Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon)) Text(row.title, color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold)) @@ -86,7 +86,7 @@ fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () Spacer(Modifier.height(H.stateGap)) Text(row.text, color = C.muted, style = T.body) Spacer(Modifier.height(H.stateTextBottom)) - PickerButton("Try again", focused, row.calm, onFocus = onHover, onClick = retry) + PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, onClick = retry) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 93baadd3..9cb26163 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -94,7 +94,7 @@ fun HomeScreen( Text(item.text, color = if (item.calm) C.muted else C.warn, style = WolfskinTokens.Type.body) } HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) { - PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }) { activate(0) } + PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true) { activate(0) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index f54ac505..0f37daf9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -49,7 +49,7 @@ fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> val selected = focused && column == index - Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) { + Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusLift(selected, WolfskinTokens.Radius.card, H.cardLiftOffset, H.cardLiftBlur, H.cardLiftOpacity).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) { Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card))) { when { row.skeleton -> Box(Modifier.fillMaxSize().background(C.surface)) @@ -95,7 +95,7 @@ private fun TileOverlay(title: HomeTitle) { @Composable private fun SeeAll(total: Int) { - Column(Modifier.fillMaxSize().background(C.surface).border(WolfskinTokens.Focus.ringCardRing, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { + Column(Modifier.fillMaxSize().background(C.surface).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { Glyph("interface", C.accent, Modifier.size(H.seeAllIcon)) Text("See all", color = C.fg, style = T.listTitle) Text("$total", color = C.muted, style = T.label) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 1a3f1ef8..b316f2df 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S @@ -100,10 +101,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) } } state.view.savedAt?.let { time -> - Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.buttonGap, S.navItemTop + (L.navItemHeight - S.settingsPillHeight) / 2) - .height(S.settingsPillHeight).border(WolfskinTokens.Focus.ringCardRing / 2, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill)) - .padding(horizontal = S.settingsPillPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsPillGap)) { - Glyph("wifi", C.warn, Modifier.size(S.settingsPillHeight - S.settingsPillGap)) + Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2) + .height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius)) + .padding(horizontal = H.savedPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) { + Glyph("wifi", C.warn, Modifier.size(H.savedIcon)) Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 4dbd6021..11250233 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -102,9 +102,10 @@ private fun RecoveryPicker(state: ShellState) { /** Recovery actions remain focusable while profiles cannot be read. */ @Composable -fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { +fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false, onClick: () -> Unit) { val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.button, WolfskinTokens.Home.buttonLiftOffset, WolfskinTokens.Home.buttonLiftBlur, WolfskinTokens.Home.buttonLiftOpacity) else Modifier) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent) .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index d38a7dc0..d936c34d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -334,5 +334,18 @@ object WolfskinTokens { val heroWashInset = 40.0.dp val noteTop = 7.0.dp val noteGap = 8.0.dp + val hairline = 1.0.dp + val savedHeight = 26.0.dp + val savedPadding = 10.0.dp + val savedGap = 5.0.dp + val savedIcon = 14.0.dp + val savedRadius = 14.0.dp + val cardLiftOffset = 9.0.dp + val cardLiftBlur = 25.0.dp + const val cardLiftOpacity = 0.65f + val buttonLiftOffset = 6.0.dp + val buttonLiftBlur = 20.0.dp + const val buttonLiftOpacity = 0.5f + const val factSeparatorOpacity = 0.4f } } diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml index e19e2857..98f45c91 100644 --- a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml +++ b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml @@ -1,3 +1,6 @@ - + + + + diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index b427812c..0d7ef6ae 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -105,6 +105,33 @@ class HomeKeysTest { assertNull(action) } + @Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } + val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards + val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards) + val short = long.copy(id = "short", cards = cards.take(2), total = 2) + val tail = short.copy(id = "tail", total = 20) + shell.view.cards[long.id] = cards.lastIndex + var action: HomeAction? = null + rule.setContent { WolfskinTheme { + ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } } + } } + press("DOWN") + assertEquals(short.id, listOf(long, short, tail)[shell.view.row].id) + assertEquals(1, shell.view.cards[short.id]) + rule.onNodeWithTag("home-card:short:1").assertIsDisplayed() + press("RIGHT") + assertEquals(1, shell.view.cards[short.id]) + press("DOWN") + press("RIGHT") + press("DOWN") + assertEquals(2, shell.view.row) + assertEquals(2, shell.view.cards[tail.id]) + rule.onNodeWithTag("home-card:tail:2").assertIsDisplayed() + press("OK") + assertEquals(tail.id, (action as HomeAction.SeeAll).row.id) + } + private fun press(name: String) { val key = when (name) { "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index f10baeed..202d1d24 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -226,4 +226,14 @@ --home-heroWashInset: 80.0px; --home-noteTop: 14.0px; --home-noteGap: 16.0px; + --home-hairline: 2.0px; + --home-savedHeight: 52.0px; + --home-savedPadding: 20.0px; + --home-savedGap: 10.0px; + --home-savedIcon: 28.0px; + --home-savedRadius: 28.0px; + --home-cardLiftOffset: 18.0px; + --home-cardLiftBlur: 50.0px; + --home-buttonLiftOffset: 12.0px; + --home-buttonLiftBlur: 40.0px; } diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 795e6f8d..579c9cbf 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,6 +1,6 @@ 28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py -77a489b428b038a874624c9d7d881e69b9f20ca478961743061b76ce31b7bd31 home-tokens.json +73398fc0d3d6b780170fa6d7ef9246f788c800c499981fde092fe790330c8ad1 home-tokens.json 6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json -b051a021d1fc9bb7d03287891f4a7eb62e4eaee4058d54e13bbff108924c6fa0 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -853f22d6f2572f55a2c8a0cac7e5ba11647d3d70e1b2c0be70d23cf06c7f2e58 tokens.css +6e0fc478d40589aa552b880a91940361e9f6c85ef720103ffa8eb87358e296ac src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +7c8bc2d2db273e67a0b45cacdbbe9046bb4f4224b05d83f8e103dedb95138888 tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json From 2b65ee7b530c5dc50ca688727d975097578d6e5f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 16:57:20 +0100 Subject: [PATCH 3/4] Preserve the person identity guard on Home --- .../org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt | 3 +++ .../java/org/justinvisser/wolfskin/tv/WolfskinApp.kt | 2 +- wolfskin-design/src/main/res/drawable/wolfskin_grid.xml | 5 +---- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt index 78690396..d9e88778 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt @@ -18,11 +18,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsNotFocused import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey @@ -131,6 +133,7 @@ class UpstreamContentFocusTest { rule.runOnIdle { WolfskinScreenPreference.enabled = true } rule.mainClock.advanceTimeBy(1500) rule.waitForIdle() + rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed() val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900] val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 700d0b9d..a974a72e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -185,7 +185,7 @@ fun WolfskinApp( val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier -> when { - upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) + (upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml index 98f45c91..e19e2857 100644 --- a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml +++ b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml @@ -1,6 +1,3 @@ - - - - + From b54bcc148c362a7332c502d96643b541eb2f7b83 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 17:16:06 +0100 Subject: [PATCH 4/4] Follow visible card centers and remember Home tracks --- docker/README.md | 9 ++++- .../justinvisser/wolfskin/design/HomeFocus.kt | 38 ++++++++++++++++++- .../wolfskin/design/HomeScreen.kt | 5 ++- .../justinvisser/wolfskin/design/HomeShelf.kt | 7 ++-- .../wolfskin/design/HomeViewMemory.kt | 2 + .../wolfskin/design/HomeKeysTest.kt | 20 ++++++++++ 6 files changed, 73 insertions(+), 8 deletions(-) diff --git a/docker/README.md b/docker/README.md index 45b80ff2..8200f3bb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -130,12 +130,15 @@ The final action opens Discover at its top. Discover (`discover`) and See all later screens. Title navigation remains the shell's later-slice target. All Home components and immutable screen models live in `:wolfskin-design`. -The shell's remembered view owns vertical position, each row's card and the -bounded six-hour ratings session cache. Back preserves the viewport; returning +The shell's remembered view owns vertical position, each row's card and first +visible card, and the bounded six-hour ratings session cache. Back preserves the viewport; returning to Home does not re-run focus-follow over a manually scrolled saved view. The next arrow re-enters visible content when scrolling moved focus off-screen. Pointing changes focus without activation, cards activate on key-up, and held OK events cannot activate twice. +Between shelves, an unvisited row selects the nearest visible card center even +when card widths differ. A visited row restores its column. Horizontal movement +keeps the track still while focus remains in its visible middle. `HomeReadFacade` is the only Home code importing Jellyfin API clients. Release variants read the selected user's Resume, Next up, newest titles and destination @@ -144,6 +147,8 @@ disk cache. A missing saved library becomes a recovery panel; a saved library stays visible when Jellyfin is unreachable. Playback resolves an actual film or episode and checks Jellyfin before opening upstream's player. No season, recommendation stub or different person's session is promoted to playback. +An unresolved person switch shows the same sign-in guard for Home as for native +library content, before mounting Home's source reads. Debug variants additionally package the locked fixture and local artwork under `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index fdb70c71..5a7b0393 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -1,5 +1,7 @@ package org.justinvisser.wolfskin.design +import kotlin.math.abs + /** Home navigation mutates the shell's per-person view, including each row's remembered column. */ class HomeFocus( private val shell: ShellState, @@ -15,7 +17,10 @@ class HomeFocus( shell.navFocused = false shell.view.row = index shell.view.home.focusedKey = rows[index].id - shell.view.cards[rows[index].id] = column.coerceIn(0, (rows[index].count - 1).coerceAtLeast(0)) + val target = rows[index] + val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0)) + shell.view.cards[target.id] = bounded + if (target.kind == HomeRowKind.Shelf) shell.view.home.firstCards[target.id] = target.adjustedFirst(bounded, shell.view.home.firstCards[target.id] ?: 0) } fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean { @@ -43,7 +48,7 @@ class HomeFocus( while (next in rows.indices && !rows[next].focusable) next += direction if (next in rows.indices) { val target = rows[next] - val nearest = (column * pitch(current) / pitch(target)).toInt() + val nearest = nearest(current, target) focus(next, shell.view.cards[target.id] ?: nearest) } else if (direction < 0) { shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0) @@ -84,9 +89,38 @@ class HomeFocus( } } + private fun nearest(from: HomeRow, to: HomeRow): Int { + if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt() + val fromFirst = if (from.kind == HomeRowKind.Shelf) shell.view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0 + val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2 + val first = (shell.view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0)) + val last = minOf(to.count - 1, first + to.focusSlots - 1) + var best = first + var distance = Float.POSITIVE_INFINITY + for (index in first..last) { + val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x) + if (candidate < distance) { best = index; distance = candidate } + } + return best + } + private fun pitch(row: HomeRow): Float = when (row.cardKind) { HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value } } + +internal val HomeRow.focusSlots: Int get() = when (cardKind) { + HomeCardKind.Poster -> 6 + HomeCardKind.Tile -> 4 + HomeCardKind.Wide -> 3 +} + +/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */ +internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int { + var first = previous + if (column > first + focusSlots - 2) first = column - (focusSlots - 2) + if (column < first + 1) first = (column - 1).coerceAtLeast(0) + return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0)) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 9cb26163..b6c67d27 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -36,6 +36,9 @@ fun HomeScreen( onDispose { shell.contentInput = null } } LaunchedEffect(snapshot.rows, view) { + snapshot.rows.forEach { item -> + view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) } + } val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable } if (remembered >= 0) view.row = remembered if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab } @@ -86,7 +89,7 @@ fun HomeScreen( fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) } when (item.kind) { HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) } - HomeRowKind.Shelf -> HomeShelf(item, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) } + HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) } HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt) HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) } HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index 0f37daf9..f0ceeaa0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -31,12 +32,12 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T /** Every lazy row and card uses a stable source identity and an explicit content type. */ @Composable -fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { +fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { val width = row.cardWidth val height = row.cardHeight val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero - val slots = if (row.cardKind == HomeCardKind.Poster) 6 else if (row.cardKind == HomeCardKind.Tile) 4 else 3 - val first = (column - (slots - 2)).coerceIn(0, (row.count - slots).coerceAtLeast(0)) + val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) + SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } val track = rememberLazyListState(first) val density = LocalDensity.current LaunchedEffect(first) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt index e178c993..cbac4dba 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt @@ -2,11 +2,13 @@ package org.justinvisser.wolfskin.design import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.setValue /** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */ class HomeViewMemory { val ratings = HomeRatingCache() + val firstCards = mutableStateMapOf() var focusedKey: String? = null var positionedKey: String? = null var resetScroll by mutableIntStateOf(0) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index 0d7ef6ae..dad590b4 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -132,6 +132,26 @@ class HomeKeysTest { assertEquals(tail.id, (action as HomeAction.SeeAll).row.id) } + @Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } + val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards + val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards) + val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile) + shell.view.cards[posters.id] = 3 + rule.setContent { WolfskinTheme { + ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} } + } } + press("DOWN") + assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id]) + repeat(4) { press("RIGHT") } + val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left + press("LEFT") + assertEquals("Moving back within the visible middle must not shift the track", left, + rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left, .1f) + press("UP") + assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id]) + } + private fun press(name: String) { val key = when (name) { "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown