Compare commits

...

6 commits

Author SHA1 Message Date
60a0da13a2 Document visible tail navigation and library typography
Some checks failed
review/design-claude Header avatar initial must be muted like the locked frames; tail Down and separator dot fixed
2026-11-05 16:55:12 +01:00
e5bb4ec47f Merge remote-tracking branch 'origin/main' into fm/tv-slice4-libraries 2026-11-05 16:50:24 +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
b24c404cc8 Merge the reviewed settings pill border correction
# Conflicts:
#	wolfskin-design/tokens.sha256
2026-11-05 16:44:52 +01:00
a6dd04f390 Keep library focus on visible rows and match caption facts 2026-11-05 16:44:21 +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
10 changed files with 130 additions and 17 deletions

View file

@ -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 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. 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. The small gear under the final person opens Settings with Requests focused.
Settings groups hand off to existing upstream pages; the Requests entry explicitly Settings groups hand off to existing upstream pages; the Requests entry explicitly
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
@ -122,6 +127,13 @@ lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserv
the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail
exists only at 200 titles with A–Z selected. Card OK activates on key-up through exists only at 200 titles with A–Z selected. Card OK activates on key-up through
the same shell hold guard, without a long-press menu. the same shell hold guard, without a long-press menu.
Down clamps a short final title row before entering the separate Try again / Show
more row; an empty slot cannot activate recovery. An unavailable library hides
its unknown item count.
Caption separators use 40% of the existing muted colour's alpha. The rail follows
the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px,
overriding the spec table's generic caption style for this component.
The debug-only `wolfskin-library.json` and `library/assets/` contain the locked The debug-only `wolfskin-library.json` and `library/assets/` contain the locked
prototype's titles and local poster art. Release libraries read through prototype's titles and local poster art. Release libraries read through

View file

@ -215,6 +215,32 @@
"dp": 20.0, "dp": 20.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" "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": { "settingsPillPadding": {
"$type": "dimension", "$type": "dimension",
"px": 16, "px": 16,
@ -298,6 +324,7 @@
"chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"}, "chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"},
"chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, "chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"},
"captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"}, "captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"},
"captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"},
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},
"plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"}, "plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"},
@ -312,6 +339,8 @@
"railWidth": {"$type":"dimension","px":52,"dp":26,"source":"index.html:204"}, "railWidth": {"$type":"dimension","px":52,"dp":26,"source":"index.html:204"},
"railLetterHeight": {"$type":"dimension","px":30,"dp":15,"source":"index.html:205"}, "railLetterHeight": {"$type":"dimension","px":30,"dp":15,"source":"index.html:205"},
"railFocusHeight": {"$type":"dimension","px":44,"dp":22,"source":"index.html:208"}, "railFocusHeight": {"$type":"dimension","px":44,"dp":22,"source":"index.html:208"},
"railLetter": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:205; prototype wins over spec 4.3 caption weight"},
"railFocusedLetter": {"$type":"typography","px":28,"sp":14,"weight":700,"$value":{"fontWeight":700},"source":"index.html:208; prototype wins over spec 4.3 caption size"},
"panelPaddingX": {"$type":"dimension","px":40,"dp":20,"source":"index.html:194"}, "panelPaddingX": {"$type":"dimension","px":40,"dp":20,"source":"index.html:194"},
"panelPaddingY": {"$type":"dimension","px":34,"dp":17,"source":"index.html:194"}, "panelPaddingY": {"$type":"dimension","px":34,"dp":17,"source":"index.html:194"},
"panelTextGap": {"$type":"dimension","px":24,"dp":12,"source":"index.html:198"}, "panelTextGap": {"$type":"dimension","px":24,"dp":12,"source":"index.html:198"},

View file

@ -12,7 +12,6 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
/** Captions fade without moving the reserved band under the focused poster. */ /** Captions fade without moving the reserved band under the focused poster. */
@Composable @Composable
@ -23,10 +22,25 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
} }
Column(modifier.graphicsLayer { this.alpha = alpha.value }) { Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1) Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1)
val styledFacts = buildAnnotatedString { val styledFacts = remember(facts, accentLastFact) {
val suffix = if (accentLastFact) facts.lastIndexOf(" · ").takeIf { it >= 0 }?.plus(" · ".length) ?: facts.length else facts.length buildAnnotatedString {
append(facts.substring(0, suffix)) val separator = " · "
withStyle(SpanStyle(color = WolfskinTokens.Color.accentText)) { append(facts.substring(suffix)) } append(facts)
if (accentLastFact) {
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
}
var start = facts.indexOf(separator)
if (start >= 0) {
val muted = WolfskinTokens.Color.muted
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
val dotOffset = separator.indexOf('·')
while (start >= 0) {
addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1)
start = facts.indexOf(separator, start + separator.length)
}
}
}
} }
Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1) Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
} }

View file

@ -109,10 +109,12 @@ class LibraryGridState(
} }
if (toolbar) return toolbarKey(key) if (toolbar) return toolbarKey(key)
val currentItems = items val currentItems = items
val last = if (tail != GridTail.None) tailIndex else currentItems.lastIndex
when (key) { when (key) {
ShellKey.Up -> if (index >= columns) index -= columns else if (hasToolbar) toolbar = true else return false ShellKey.Up -> if (index >= columns) index -= columns else if (hasToolbar) toolbar = true else return false
ShellKey.Down -> if (index + columns <= last) index = (index + columns).coerceAtMost(last) else if (index / columns < last / columns) index = last ShellKey.Down -> when {
index / columns < currentItems.lastIndex / columns -> index = (index + columns).coerceAtMost(currentItems.lastIndex)
tail != GridTail.None && index < tailIndex -> index = tailIndex
}
ShellKey.Left -> if (index % columns > 0) index-- ShellKey.Left -> if (index % columns > 0) index--
ShellKey.Right -> if (index % columns < columns - 1 && index < currentItems.lastIndex) index++ else if (railVisible) { inRail = true; railReturn = index; railLetter = currentItems[index].letter } ShellKey.Right -> if (index % columns < columns - 1 && index < currentItems.lastIndex) index++ else if (railVisible) { inRail = true; railReturn = index; railLetter = currentItems[index].letter }
ShellKey.Ok -> currentItems.getOrNull(index)?.let(onTitle) ?: if (pagination != null) pageRevision++ else onRecover() ShellKey.Ok -> currentItems.getOrNull(index)?.let(onTitle) ?: if (pagination != null) pageRevision++ else onRecover()

View file

@ -162,7 +162,7 @@ private fun LibraryHeader(grid: LibraryGridState, person: ShellPerson) {
Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader) Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
Text(grid.title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle) Text(grid.title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text(if (grid.status == LibraryStatus.Loading) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}", modifier = Modifier.alignByBaseline(), color = C.muted, style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal)) Text(if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}", modifier = Modifier.alignByBaseline(), color = C.muted, style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal))
grid.savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) } grid.savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
} }
} }
@ -245,7 +245,8 @@ private fun BoxScope.LibraryRail(grid: LibraryGridState) {
Box(Modifier.width(G.railWidth).height(if (current && grid.inRail) G.railFocusHeight else G.railLetterHeight) Box(Modifier.width(G.railWidth).height(if (current && grid.inRail) G.railFocusHeight else G.railLetterHeight)
.background(if (current && grid.inRail) C.fg else androidx.compose.ui.graphics.Color.Transparent, CircleShape) .background(if (current && grid.inRail) C.fg else androidx.compose.ui.graphics.Color.Transparent, CircleShape)
.clickable(enabled = letter in letters) { grid.railLetter = letter; grid.inRail = true; grid.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { .clickable(enabled = letter in letters) { grid.railLetter = letter; grid.inRail = true; grid.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Text(letter.toString(), color = if (current) if (grid.inRail) C.onFocus else C.accent else if (letter in letters) C.muted else C.railEmpty, style = WolfskinTokens.Type.caption) Text(letter.toString(), color = if (current) if (grid.inRail) C.onFocus else C.accent else if (letter in letters) C.muted else C.railEmpty,
style = if (current && grid.inRail) G.railFocusedLetter else G.railLetter)
} }
} }
} }

View file

@ -24,8 +24,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -108,10 +110,19 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
@Composable @Composable
private fun SettingsPill(label: String, focused: Boolean) { private fun SettingsPill(label: String, focused: Boolean) {
val foreground = if (focused) C.onFocus else C.dim val foreground = if (focused) C.onFocus else C.dim
Box(Modifier.height(S.settingsPillHeight).drawBehind { val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder
val stroke = WolfskinTokens.Focus.ringButtonRing.toPx() Box(Modifier.height(S.settingsPillHeight).drawWithCache {
drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2)))) val stroke = S.settingsPillStroke.toPx()
}.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) { 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)) Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
} }
} }

View file

@ -266,6 +266,11 @@ object WolfskinTokens {
val settingsRowGap = 12.0.dp val settingsRowGap = 12.0.dp
val settingsIcon = 18.0.dp val settingsIcon = 18.0.dp
val settingsPillHeight = 20.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 settingsPillPadding = 8.0.dp
val settingsPillGap = 6.0.dp val settingsPillGap = 6.0.dp
val settingsChevron = 15.0.dp val settingsChevron = 15.0.dp
@ -287,6 +292,7 @@ object WolfskinTokens {
val chipTop = 8.0.dp val chipTop = 8.0.dp
val chipIcon = 13.0.dp val chipIcon = 13.0.dp
val captionWidth = 320.0.dp val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val plateTop = 7.0.dp val plateTop = 7.0.dp
val plateSide = 8.0.dp val plateSide = 8.0.dp
val plateBottom = 23.0.dp val plateBottom = 23.0.dp
@ -301,6 +307,8 @@ object WolfskinTokens {
val railWidth = 26.0.dp val railWidth = 26.0.dp
val railLetterHeight = 15.0.dp val railLetterHeight = 15.0.dp
val railFocusHeight = 22.0.dp val railFocusHeight = 22.0.dp
val railLetter = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val railFocusedLetter = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val panelPaddingX = 20.0.dp val panelPaddingX = 20.0.dp
val panelPaddingY = 17.0.dp val panelPaddingY = 17.0.dp
val panelTextGap = 12.0.dp val panelTextGap = 12.0.dp

View file

@ -141,6 +141,37 @@ class LibraryKeysTest {
assertEquals(8, grid.items.size) assertEquals(8, grid.items.size)
} }
@Test fun downClampsShortLastRowBeforeEnteringRecoveryTail() {
var recoveries = 0
val grid = LibraryGridState("Trending",
(0 until 20).map { LibraryTitle("$it", "Title $it", "movie") },
catalogue = true, onRecover = { recoveries++ },
).apply { tail = GridTail.Retry; index = 14 }
shell.pickerVisible = false
shell.enterDestination("discover")
rule.setContent {
WolfskinTheme {
ShellSurface(shell) { modifier ->
LibraryScreen(shell, grid, modifier)
}
}
}
press(Key.DirectionDown)
assertEquals(19, grid.index)
assertEquals("Title 19", grid.focusedTitle!!.title)
assertEquals(0, recoveries)
press(Key.DirectionDown)
assertEquals(24, grid.index)
assertEquals("grid-tail", grid.cardKey)
press(Key.DirectionDown)
assertEquals(24, grid.index)
press(Key.DirectionCenter)
assertEquals(1, recoveries)
press(Key.DirectionUp)
assertEquals(18, grid.index)
assertEquals("Title 18", grid.focusedTitle!!.title)
}
@Test fun scoresFollowLocaleAndUnratedSortsLast() { @Test fun scoresFollowLocaleAndUnratedSortsLast() {
assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL"))) assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL")))
assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US)) assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US))

View file

@ -163,6 +163,9 @@
--shell-settingsRowGap: 24.0px; --shell-settingsRowGap: 24.0px;
--shell-settingsIcon: 36.0px; --shell-settingsIcon: 36.0px;
--shell-settingsPillHeight: 40.0px; --shell-settingsPillHeight: 40.0px;
--shell-settingsPillStroke: 2.0px;
--shell-settingsPillBorder: #ffffff38;
--shell-settingsPillFocusedBorder: #0b0b1066;
--shell-settingsPillPadding: 16.0px; --shell-settingsPillPadding: 16.0px;
--shell-settingsPillGap: 12.0px; --shell-settingsPillGap: 12.0px;
--shell-settingsChevron: 30.0px; --shell-settingsChevron: 30.0px;
@ -186,6 +189,8 @@
--library-railWidth: 52.0px; --library-railWidth: 52.0px;
--library-railLetterHeight: 30.0px; --library-railLetterHeight: 30.0px;
--library-railFocusHeight: 44.0px; --library-railFocusHeight: 44.0px;
--library-railLetter: 24.0px;
--library-railFocusedLetter: 28.0px;
--library-panelPaddingX: 40.0px; --library-panelPaddingX: 40.0px;
--library-panelPaddingY: 34.0px; --library-panelPaddingY: 34.0px;
--library-panelTextGap: 24.0px; --library-panelTextGap: 24.0px;

View file

@ -1,5 +1,5 @@
1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py 1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
9d37e43db493a52cc5be6c9418d0c3c132dae875f70cfd0cd1bf97608e5a1425 shell-tokens.json 1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json
3f8a3a996f1dd1743dac970e9815431df5c72336f338598e8f6608aae6e16377 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt f380580c85d518e70f76991632b1c0be394f2d237c07097b2eec362950a14d20 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
25c87d3e0cf602764b20df29248b091764709c3b9b8bf6e7f966ed30ac8f7435 tokens.css 900a8d995b74083cdf911fc4055c1c90827384fb1b0f5799c8823a8d0f0e48b2 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json