Compare commits

...

6 commits

Author SHA1 Message Date
b54bcc148c Follow visible card centers and remember Home tracks
Some checks failed
review/design-claude Request changes: separator dot colour, vertical rhythm, focus entry after load
2026-11-05 17:16:06 +01:00
2b65ee7b53 Preserve the person identity guard on Home 2026-11-05 16:57:20 +01:00
5a0497c9ec Merge the reviewed shell from main 2026-11-05 16:50:59 +01:00
3b7249888f Match Home focus and recovery details 2026-11-05 16:50:32 +01:00
7a878b2d81 Merge pull request 'Build the Wolfskin TV shell and navigation' (#29) from fm/tv-slice2-shell into main 2026-11-05 16:49:28 +01:00
74033ee1da Match the settings pill border to the prototype
All checks were successful
review/design-claude B1/B2/N1/N4 passed Sonnet re-review at 2ea93010; B3 pill border one-frame re-check by firstmate: dash rhythm and 1dp stroke match, delta additive (6 files, pill only)
2026-11-05 16:28:09 +01:00
19 changed files with 244 additions and 32 deletions

View file

@ -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)

View file

@ -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)

View file

@ -78,6 +78,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
@ -125,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
@ -139,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.
@ -153,6 +163,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.

View file

@ -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" }
}
}

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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))
}

View file

@ -44,8 +44,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }) { onButton(1) }
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true) { onButton(1) }
}
}
} else {
@ -78,7 +78,7 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
@Composable
fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> 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)
}
}
}

View file

@ -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)) {
@ -94,7 +97,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) }
}
}
}

View file

@ -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<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, 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) {
@ -49,7 +50,7 @@ fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRat
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
itemsIndexed(cards, key = { index, title -> 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 +96,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)

View file

@ -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<String, Int>()
var focusedKey: String? = null
var positionedKey: String? = null
var resetScroll by mutableIntStateOf(0)

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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)

View file

@ -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
@ -334,5 +339,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
}
}

View file

@ -105,6 +105,53 @@ 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)
}
@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

View file

@ -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;
@ -226,4 +229,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;
}

View file

@ -1,6 +1,6 @@
28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py
77a489b428b038a874624c9d7d881e69b9f20ca478961743061b76ce31b7bd31 home-tokens.json
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
b051a021d1fc9bb7d03287891f4a7eb62e4eaee4058d54e13bbff108924c6fa0 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
853f22d6f2572f55a2c8a0cac7e5ba11647d3d70e1b2c0be70d23cf06c7f2e58 tokens.css
73398fc0d3d6b780170fa6d7ef9246f788c800c499981fde092fe790330c8ad1 home-tokens.json
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json
3ff67d4867c2735ed2ad7ffc66bbfb04dad5f0000072daa2f5e98ed1b7059dde src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
7ded358afa758c8b0dd191d650d06dc0bcfc8dcc2baebba2c07a3e70c435ff5a tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json