From 3b7249888fbf2cef3e286201d6324652bb465bcc Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 16:50:32 +0100 Subject: [PATCH] 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