Round 2 candidate 2: merge origin/main

This commit is contained in:
root 2026-11-07 22:34:09 +01:00
commit ed103bcb96
74 changed files with 4267 additions and 201 deletions

View file

@ -0,0 +1,47 @@
DejaVu Sans and DejaVu Sans Bold
https://dejavu-fonts.github.io/
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.

View file

@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
@Composable
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
val exact = remember { CssColumnHeight() }
Layout(content, modifier.then(exact)) { children, constraints ->
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }

View file

@ -11,6 +11,9 @@ import androidx.tv.material3.Icon
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"gear" -> R.drawable.wolfskin_gear
"updates" -> R.drawable.wolfskin_update
"settings-list" -> R.drawable.wolfskin_settings_list
"settings-right" -> R.drawable.wolfskin_settings_chevron
"requests" -> R.drawable.wolfskin_tray
"interface" -> R.drawable.wolfskin_grid
"play", "playback" -> R.drawable.wolfskin_play

View file

@ -21,19 +21,24 @@ fun personColor(key: String): Color = when (key.lowercase()) {
else -> WolfskinTokens.Color.accent
}
/** Updates has its own locked avatar swatch; other destinations retain household colours. */
fun resolveUpdateAvatarColor(person: ShellPerson): Color =
if (person.key.equals("justin", ignoreCase = true)) WolfskinTokens.Update.avatarJustin else personColor(person.key)
/** The same mark anchors the picker and the persistent navigation. */
@Composable
fun Wordmark(modifier: Modifier = Modifier) {
fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) {
Row(modifier) {
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle)
Text("skin", color = WolfskinTokens.Color.accent, style = textStyle)
}
}
/** Initials are drawn over opaque person colour at the exact slot size. */
@Composable
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) {
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus,
backgroundColor: Color = personColor(person.key)) {
Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) {
Text(person.initial, color = letterColor, style = textStyle)
}
}

View file

@ -40,15 +40,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
fun navBoxes(tabs: List<String>): List<NavBox> {
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox> {
val updates = WolfskinTokens.Update
var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap
return tabs.map { tab ->
val width = when (tab) {
"home" -> S.navHomeWidth
"discover" -> S.navDiscoverWidth
"movies" -> S.navMoviesWidth
"shows" -> S.navShowsWidth
else -> S.navAnimeWidth
"home" -> if (isUpdatesLayout) updates.navHomeWidth else S.navHomeWidth
"discover" -> if (isUpdatesLayout) updates.navDiscoverWidth else S.navDiscoverWidth
"movies" -> if (isUpdatesLayout) updates.navMoviesWidth else S.navMoviesWidth
"shows" -> if (isUpdatesLayout) updates.navShowsWidth else S.navShowsWidth
else -> if (isUpdatesLayout) updates.navAnimeWidth else S.navAnimeWidth
}
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
@ -57,7 +58,8 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
/** There is one blob, including when focus wraps around the nav avatar. */
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val hasSelectedTab = state.route != "settings" && state.route != "updates"
val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) }
val reduced = reducedMotion()
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
val index = if (state.navFocused) state.navIndex else state.currentTab
@ -79,14 +81,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
Box(modifier.fillMaxWidth().height(L.navBand)) {
Canvas(Modifier.matchParentSize()) {
if (scrolled) drawRect(C.bg) else drawRect(band)
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
}
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf",
if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark)
state.tabs.forEachIndexed { itemIndex, tab ->
val item = boxes[itemIndex]
val focused = state.navFocused && state.navIndex == itemIndex
val labelColor by animateColorAsState(
if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted,
if (focused) C.onFocus else if (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted,
animationSpec = labelFade,
label = "nav-label-$tab",
)
@ -95,8 +98,8 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
.clickable {
state.focusNav(itemIndex); state.key(ShellKey.Ok)
}, contentAlignment = Alignment.Center) {
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
if (itemIndex == state.currentTab && !focused) {
Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor)
if (hasSelectedTab && itemIndex == state.currentTab && !focused) {
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
}
}
@ -111,10 +114,11 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
}
}
val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
.focusProperties { canFocus = false }
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) },
backgroundColor = if (hasSelectedTab) personColor(state.person.key) else resolveUpdateAvatarColor(state.person))
}
}

View file

@ -9,13 +9,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
@ -36,50 +35,53 @@ import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.UpdateTypography as T
/** Settings routes identify real upstream destinations rather than stand-in panels. */
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
data class SettingsGroup(val key: String, val title: String, val description: String)
val SETTINGS_GROUPS = listOf(
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
SettingsGroup("requests", "Requests", "Coming in: what the household asked for"),
SettingsGroup("updates", "Updates", "Check and install"),
SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"),
SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"),
SettingsGroup("profile", "User profile settings", "Protection · Home · Language"),
SettingsGroup("about", "About", "Installed version · Wolfskin about"),
SettingsGroup("seerr", "Seerr integration", "Manage the Seerr server"),
SettingsGroup("advanced", "Advanced settings", "Playback backend · Logs · Image cache"),
SettingsGroup("classic", "Classic settings (trial)", "Original settings, including About → Update"),
)
/** The count describes returned request history, not an inferred queue of active downloads. */
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
val list = state.view.listState
val reduced = reducedMotion()
val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) {
if (state.navFocused) return@LaunchedEffect
val y = L.row_lhead + L.row_srow * state.settingsRow
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
}
LazyColumn(modifier.fillMaxSize(), state = list,
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
item(key = "settings-header") {
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg)
Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
Box(modifier.fillMaxSize()) {
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(state.person))
Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") requestCount else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop)
.height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)),
state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) {
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
val display = if (group.key == "updates") group.copy(description = "$installedVersion · Check and install") else group
SettingsRow(display, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") requestCount else null, Modifier.offset(L.safeSide)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
}
}
}
}
@ -94,15 +96,14 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Glyph(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) {
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (group.wolfskin) SettingsPill("Wolfskin", focused)
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused)
Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
}
}
@ -122,6 +123,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
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 = T.caption.copy(fontWeight = T.label.fontWeight))
}
}

View file

@ -265,9 +265,13 @@ class ShellState(
}
} else if (route == "settings") {
val group = SETTINGS_GROUPS[settingsRow].key
if (group == "requests") openRequests() else {
handOff = group
onAction(ShellAction.HandOff(group))
when (group) {
"requests" -> openRequests()
"updates" -> onAction(ShellAction.Destination("updates"))
else -> {
handOff = group
onAction(ShellAction.HandOff(group))
}
}
}
else contentFocus?.key(ShellKey.Ok)
@ -309,6 +313,7 @@ class ShellState(
pickerLaunch = false
gearFocused = true
} else if (route !in tabs) {
if (route == "updates") navFocused = false
onAction(ShellAction.Pop)
} else if (!navFocused) {
navFocused = true

View file

@ -56,6 +56,7 @@ fun ShellSurface(
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsRequestCount: Int? = null,
settingsInstalledVersion: String = "",
overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
@ -95,7 +96,7 @@ fun ShellSurface(
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
overlay()

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */
object UpdateTypography {
val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold))
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private val platform = PlatformTextStyle(includeFontPadding = false)
private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform)
val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle)
val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle)
val heading = resolveStyle(WolfskinTokens.Update.heading)
val nav = resolveStyle(WolfskinTokens.Type.nav)
val wordmark = resolveStyle(WolfskinTokens.Type.wordmark)
val listTitle = resolveStyle(WolfskinTokens.Type.listTitle)
val body = resolveStyle(WolfskinTokens.Type.body)
val button = resolveStyle(WolfskinTokens.Type.button)
val label = resolveStyle(WolfskinTokens.Type.label)
val caption = resolveStyle(WolfskinTokens.Type.caption)
val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav)
val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader)
}

View file

@ -221,6 +221,9 @@ object WolfskinTokens {
const val fillInverse = "background color.fg, text color.onFocus"
}
object Motion {
const val updateScan = 1800
const val updateSheen = 1600
val linear = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
const val skeletonCycle = 1400
const val skeletonLowAlpha = 0.55f
const val focusMove = 180
@ -703,4 +706,48 @@ object WolfskinTokens {
const val ambientSaturation = 1.2f
const val ambientBrightness = 0.55f
}
object Update {
val avatarJustin = ComposeColor(0xFF7AB8FF)
val wordmarkWidth = 81.796875.dp
val navHomeWidth = 74.6640625.dp
val navDiscoverWidth = 99.0703125.dp
val navMoviesWidth = 85.2578125.dp
val navShowsWidth = 80.578125.dp
val navAnimeWidth = 79.234375.dp
val notesListTop = 14.0.dp
val notesListBottom = 14.0.dp
val notesMarkerOffset = 5.5.dp
val notesMarkerWidth = 14.5.dp
val heading = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 20.160000000000004.sp, letterSpacing = -0.02.em)
const val settingsVisibleRows = 6.0f
val headerTop = 75.0.dp
val headerHeight = 42.0.dp
val rowsTop = 139.0.dp
val rowsViewport = 362.0.dp
val contentTop = 149.0.dp
val width = 650.0.dp
val padding = 20.0.dp
val border = 1.0.dp
val radius = 11.0.dp
val titleBottom = 8.0.dp
val paragraphBottom = 7.0.dp
val actionsTop = 18.0.dp
val actionGap = 12.0.dp
val progressTop = 15.0.dp
val progressBottom = 11.0.dp
val progressHeight = 6.0.dp
val progressRadius = 4.0.dp
val autoTop = 431.0.dp
val toggleWidth = 40.0.dp
val toggleHeight = 21.0.dp
val toggleThumb = 15.0.dp
val toggleInset = 3.0.dp
val notesHeight = 256.0.dp
val notesGap = 9.0.dp
val notesScrollStep = 40.0.dp
val backTop = 92.0.dp
const val scanFraction = 0.28f
const val scanEnd = 4.6f
const val sheenAlpha = 0.35f
}
}

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M12,6 L22,16 L12,26" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M5,8 H27 M5,16 H27 M5,24 H27" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M26,14 A11,11 0,1 0,25 23 M26,5 V14 H17" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -1,66 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import java.io.File
import org.junit.Assert.*
import org.junit.Test
/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */
class OwnedSourceAuditTest {
@Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() {
val roots = listOf(File("src/main/java"), File("../app/src/wolfskin"))
roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) }
val violations = roots.flatMap { root ->
root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source ->
audit(source.name, source.readText()).map { "$source:$it" }
}.toList()
}
assertTrue(violations.joinToString("\n"), violations.isEmpty())
}
@Test fun plantedViolationsFailEachDoneBarRule() {
val cases = listOf(
Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"),
Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"),
Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"),
Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"),
Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"),
Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"),
Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"),
Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"),
Triple("Screen.kt", "Text(\"DGiC\")", "wording"),
Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"),
Triple("Screen.kt", "Text(\"Next page\")", "wording"),
Triple("Screen.kt", "Text(\"Previous page\")", "wording"),
Triple("Screen.kt", "Text(\"Page 2\")", "wording"),
)
cases.forEach { (name, source, rule) ->
assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") })
}
assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty())
assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty())
}
private fun audit(name: String, source: String): List<String> {
val generated = name == "WolfskinTokens.kt"
return source.lineSequence().mapIndexedNotNull { index, line ->
val code = line.trimStart()
if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null
val rules = buildList {
if (!generated && units.containsMatchIn(line)) add("unit")
if (!generated && colors.containsMatchIn(line)) add("color")
if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor")
if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording")
}
rules.map { "${index + 1}:$it" }
}.flatten().toList()
}
companion object {
private val units = Regex("\\.(?:dp|sp)\\b")
private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)")
private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b")
private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"")
private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE)
}
}

View file

@ -68,7 +68,7 @@ class ShellCaptureTest {
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 5 }
}
/** Tests and debug builds share the same shell-only projection of the locked fixture. */

View file

@ -46,6 +46,22 @@ class ShellKeysTest {
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
}
@Test fun updatesUseTwoPressesFromRequestsWithoutClassicHandOff() {
val actions = mutableListOf<ShellAction>()
val shell = ShellState(fixturePeople(), "justin", actions::add).apply {
pickerVisible = false
enterDestination("settings")
}
rule.setContent { WolfskinTheme { ShellSurface(shell) } }
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
pressKey(Key.DirectionDown)
pressKey(Key.DirectionCenter)
}
assertEquals(1, shell.settingsRow)
assertNull(shell.handOff)
assertEquals(listOf(ShellAction.Destination("updates")), actions)
}
@Test fun settingsBackBackInTenPresses() {
replay(settings + listOf(Key.Escape, Key.Escape), 10)
assertEquals("home", state.route)
@ -54,10 +70,10 @@ class ShellKeysTest {
assertEquals(state.tabs.size, state.navIndex)
}
@Test fun playbackHandOffInElevenPresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
@Test fun playbackHandOffInTwelvePresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12)
assertEquals("playback", state.handOff)
assertEquals(2, state.settingsRow)
assertEquals(3, state.settingsRow)
}
@Test fun rootBackAndNavDownPreserveViewAndWrap() {