Compare commits

..

No commits in common. "60a0da13a2d222dc177ed85ad465d8cbedb3c541" and "3da635b4cb26593521ae2ae89612e9edfad18214" have entirely different histories.

10 changed files with 17 additions and 130 deletions

View file

@ -77,11 +77,6 @@ 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
@ -127,13 +122,6 @@ 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,32 +215,6 @@
"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,
@ -324,7 +298,6 @@
"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"},
@ -339,8 +312,6 @@
"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,6 +12,7 @@ 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
@ -22,25 +23,10 @@ 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 = remember(facts, accentLastFact) { val styledFacts = buildAnnotatedString {
buildAnnotatedString { val suffix = if (accentLastFact) facts.lastIndexOf(" · ").takeIf { it >= 0 }?.plus(" · ".length) ?: facts.length else facts.length
val separator = " · " append(facts.substring(0, suffix))
append(facts) withStyle(SpanStyle(color = WolfskinTokens.Color.accentText)) { append(facts.substring(suffix)) }
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,12 +109,10 @@ 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 -> when { ShellKey.Down -> if (index + columns <= last) index = (index + columns).coerceAtMost(last) else if (index / columns < last / columns) index = last
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.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)) 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))
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,8 +245,7 @@ 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, 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)
style = if (current && grid.inRail) G.railFocusedLetter else G.railLetter)
} }
} }
} }

View file

@ -24,10 +24,8 @@ 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.drawWithCache import androidx.compose.ui.draw.drawBehind
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
@ -110,19 +108,10 @@ 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
val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder Box(Modifier.height(S.settingsPillHeight).drawBehind {
Box(Modifier.height(S.settingsPillHeight).drawWithCache { val stroke = WolfskinTokens.Focus.ringButtonRing.toPx()
val stroke = S.settingsPillStroke.toPx() drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2))))
val inset = Offset(stroke / 2, stroke / 2) }.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) {
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,11 +266,6 @@ 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
@ -292,7 +287,6 @@ 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
@ -307,8 +301,6 @@ 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,37 +141,6 @@ 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,9 +163,6 @@
--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;
@ -189,8 +186,6 @@
--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
1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json 9d37e43db493a52cc5be6c9418d0c3c132dae875f70cfd0cd1bf97608e5a1425 shell-tokens.json
f380580c85d518e70f76991632b1c0be394f2d237c07097b2eec362950a14d20 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 3f8a3a996f1dd1743dac970e9815431df5c72336f338598e8f6608aae6e16377 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
900a8d995b74083cdf911fc4055c1c90827384fb1b0f5799c8823a8d0f0e48b2 tokens.css 25c87d3e0cf602764b20df29248b091764709c3b9b8bf6e7f966ed30ac8f7435 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json