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
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
@ -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
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.
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
prototype's titles and local poster art. Release libraries read through

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,
@ -298,6 +324,7 @@
"chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"},
"chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"},
"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"},
"plateSide": {"$type":"dimension","px":16,"dp":8,"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"},
"railLetterHeight": {"$type":"dimension","px":30,"dp":15,"source":"index.html:205"},
"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"},
"panelPaddingY": {"$type":"dimension","px":34,"dp":17,"source":"index.html:194"},
"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.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
@ -23,10 +22,25 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
}
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1)
val styledFacts = buildAnnotatedString {
val suffix = if (accentLastFact) facts.lastIndexOf(" · ").takeIf { it >= 0 }?.plus(" · ".length) ?: facts.length else facts.length
append(facts.substring(0, suffix))
withStyle(SpanStyle(color = WolfskinTokens.Color.accentText)) { append(facts.substring(suffix)) }
val styledFacts = remember(facts, accentLastFact) {
buildAnnotatedString {
val separator = " · "
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)
}

View file

@ -109,10 +109,12 @@ class LibraryGridState(
}
if (toolbar) return toolbarKey(key)
val currentItems = items
val last = if (tail != GridTail.None) tailIndex else currentItems.lastIndex
when (key) {
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.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()

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) {
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
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) }
}
}
@ -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)
.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) {
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.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

@ -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
@ -287,6 +292,7 @@ object WolfskinTokens {
val chipTop = 8.0.dp
val chipIcon = 13.0.dp
val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateBottom = 23.0.dp
@ -301,6 +307,8 @@ object WolfskinTokens {
val railWidth = 26.0.dp
val railLetterHeight = 15.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 panelPaddingY = 17.0.dp
val panelTextGap = 12.0.dp

View file

@ -141,6 +141,37 @@ class LibraryKeysTest {
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() {
assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL")))
assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US))

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;
@ -186,6 +189,8 @@
--library-railWidth: 52.0px;
--library-railLetterHeight: 30.0px;
--library-railFocusHeight: 44.0px;
--library-railLetter: 24.0px;
--library-railFocusedLetter: 28.0px;
--library-panelPaddingX: 40.0px;
--library-panelPaddingY: 34.0px;
--library-panelTextGap: 24.0px;

View file

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