Restore upstream content focus and deterministic shell typography

This commit is contained in:
justinzeus 2026-11-05 15:39:56 +01:00
parent 9be55c570a
commit 4b89a73908
11 changed files with 251 additions and 52 deletions

View file

@ -0,0 +1,138 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.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.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The rollout-off path must preserve native content focus and its backdrop beneath the shell. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpstreamContentFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun prepare() {
WolfskinScreenPreference.initialize(rule.activity)
WolfskinScreenPreference.enabled = false
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
@After fun restore() { WolfskinScreenPreference.enabled = true }
private fun shellState() = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
navFocused = false
}
@Test fun switchOffUpMovesBetweenContentRowsBeforeEnteringNav() {
val state = shellState()
val first = FocusRequester()
val second = FocusRequester()
rule.setContent {
val upstream = resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, emptyMap())
WolfskinTheme {
ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier ->
Column(contentModifier.padding(start = 100.dp, top = 150.dp), verticalArrangement = Arrangement.spacedBy(32.dp)) {
Box(Modifier.size(200.dp, 60.dp).testTag("upstream-first").focusRequester(first).focusable())
Box(Modifier.size(200.dp, 60.dp).testTag("upstream-second").focusRequester(second).focusable())
}
}
}
}
rule.runOnIdle { second.requestFocus() }
rule.onNodeWithTag("upstream-second").assertIsFocused()
rule.onNodeWithTag("upstream-second").performKeyInput { pressKey(Key.DirectionUp) }
rule.onNodeWithTag("upstream-first").assertIsFocused()
rule.runOnIdle { assertFalse("Up from the second row must not enter nav", state.navFocused) }
rule.onNodeWithTag("upstream-first").performKeyInput { pressKey(Key.DirectionUp) }
rule.runOnIdle { assertTrue("Only the first row exits upward to nav", state.navFocused) }
rule.onNodeWithTag("upstream-first").assertIsNotFocused()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
rule.onNodeWithTag("upstream-first").assertIsFocused()
}
@Test fun switchOffShowsRealUpstreamBackdropAndSwitchOnRestoresOwnedBackground() {
val state = shellState()
val model = mockk<WolfskinShellViewModel>(relaxed = true)
every { model.session } returns WolfskinSession().also { it.state = state }
every { model.libraryDestinations } returns emptyMap()
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
every { model.signedInPerson } returns "unavailable"
every { model.backdropFlow } returns MutableStateFlow(BackdropResult("fixture", null, Color.Red, Color.Red, Color.Red))
coEvery { model.load(any(), any()) } returns Unit
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val defaults = AppPreferences.getDefaultInstance()
val preferences = UserPreferences(defaults.toBuilder().setInterfacePreferences(defaults.interfacePreferences.toBuilder().setBackdropStyle(BackdropStyle.BACKDROP_DYNAMIC_COLOR)).build(), null)
val navigation = NavigationManager()
rule.setContent { WolfskinApp(server, user, navigation, preferences, Modifier.fillMaxSize(), model) }
rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle()
val off = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
assertTrue("The real dynamic backdrop must remain visible through native content", off.red > .4f && off.green < .1f && off.blue < .1f)
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "review-upstream-off.png").absolutePath)
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle()
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
assertTrue("Owned screens must cover the upstream backdrop", on.red < .02f && on.green < .02f && on.blue < .02f)
rule.onRoot().captureRoboImage(File(directory, "review-upstream-on.png").absolutePath)
}
}

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -15,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
@ -23,10 +25,12 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.nav.Backdrop
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.DestinationContent import com.github.damontecres.wholphin.ui.nav.DestinationContent
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -35,6 +39,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.PeopleStatus import org.justinvisser.wolfskin.design.PeopleStatus
@ -66,6 +71,7 @@ class WolfskinShellViewModel @Inject constructor(
private val backdrop: BackdropService, private val backdrop: BackdropService,
private val libraries: LibraryReadFacade, private val libraries: LibraryReadFacade,
) : ViewModel() { ) : ViewModel() {
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap()) var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
private set private set
var signedInPerson by mutableStateOf("") var signedInPerson by mutableStateOf("")
@ -150,35 +156,46 @@ fun WolfskinApp(
val state = viewModel.session.state ?: loading val state = viewModel.session.state ?: loading
WolfskinTheme { WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state) { CompositionLocalProvider(LocalShellState provides state) {
NavDisplay( Box(modifier) {
backStack = navigationManager.backStack, val current = navigationManager.backStack.last()
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
entryProvider = { destination -> Backdrop(
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { backdrop = backdrop,
val route = when (destination) { drawerIsOpen = false,
is Destination.Home -> "home" backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle,
is Destination.Wolfskin -> destination.route )
else -> "pushed" }
} NavDisplay(
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } backStack = navigationManager.backStack,
if (destination.fullScreen) { onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
} else { entryProvider = { destination ->
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val route = when (destination) {
ShellSurface(state, modifier, handlesContentKeys = upstream == null) { contentModifier -> is Destination.Home -> "home"
when { is Destination.Wolfskin -> destination.route
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) else -> "pushed"
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) }
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
else -> ShellPlaceholder(route, contentModifier) if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
} else {
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
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 -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
else -> ShellPlaceholder(route, contentModifier)
}
} }
} }
} }
} },
}, )
) }
} }
} }
} }

View file

@ -72,12 +72,19 @@ outputs. Editing any of them without regenerating fails the build. The shell use
the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old
numeric value is reserved. numeric value is reserved.
Compose output maps source weights 600/650 to the platform Bold (700) face, which
resolves consistently in native captures; the approved JSON weights stay unchanged.
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.
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
rollout switch: turning it off renders upstream Home and library destinations, rollout switch: turning it off renders upstream Home and library destinations with
while retaining Wolfskin's nav, picker and Settings list. Turning it on renders the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
the labelled placeholders until the corresponding screen slices ship. Settings list. Native Up traversal stays in content until the first focusable row,
then enters the nav. Turning the switch on restores the labelled placeholders
until the corresponding screen slices ship.
Debug people, tabs and initials come from the shell-only projection of the locked Debug people, tabs and initials come from the shell-only projection of the locked
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
@ -98,7 +105,11 @@ the container's ephemeral home. A cache miss still needs Maven Central; rate-lim
hosts can seed the existing cache from SHA512-verified local Maven artifacts without hosts can seed the existing cache from SHA512-verified local Maven artifacts without
changing the wrapper or adding volumes. changing the wrapper or adding volumes.
Branding strings and launcher artwork live in `app/src/default/res/`. Debug Default-flavour launcher artwork and English, Hungarian and Polish string overrides
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
`main` resource folders have not yet been moved; non-default `appstore`/`firetv`
flavours therefore combine upstream icons with some legacy Wolfskin text. Those
flavours and a full locale migration are not certified by this slice. Debug
assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public
artifact provider and AAPT2. It checks all string, plural and array configurations, artifact provider and AAPT2. It checks all string, plural and array configurations,
rejecting user-visible upstream branding while preserving the upstream legal rejecting user-visible upstream branding while preserving the upstream legal

View file

@ -21,8 +21,13 @@ def property_name(name: str) -> str:
return name.replace("-", "_") return name.replace("-", "_")
def compose_weight(weight: int) -> str:
"""Use the platform bold face for intermediate weights that resolve to Regular in native captures."""
return "FontWeight.Bold" if 600 <= weight <= 700 else f"FontWeight({weight})"
def render_tokens(tokens: dict) -> tuple[str, str]: def render_tokens(tokens: dict) -> tuple[str, str]:
"""Translate typed source values without choosing new design values.""" """Translate locked values into platform-resolvable Compose and CSS tokens."""
lines = [ lines = [
"// Generated by generate_tokens.py; edit the JSON sources instead.", "// Generated by generate_tokens.py; edit the JSON sources instead.",
"package org.justinvisser.wolfskin.design", "package org.justinvisser.wolfskin.design",
@ -58,11 +63,11 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
weight = token.get("weight", token["$value"]["fontWeight"]) weight = token.get("weight", token["$value"]["fontWeight"])
height = size * token.get("lineHeight", 1.3) height = size * token.get("lineHeight", 1.3)
tracking = token.get("letterSpacingEm", 0) tracking = token.get("letterSpacingEm", 0)
lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = FontWeight({weight}), lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)") lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = {compose_weight(weight)}, lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)")
css.append(f" --{group_name}-{name}: {number(token['px'])}px;") css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
elif kind == "typography" and "sizes" in token: elif kind == "typography" and "sizes" in token:
for context, pixels in token["sizes"].items(): for context, pixels in token["sizes"].items():
lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = FontWeight({token['weight']}))") lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})")
elif kind == "duration": elif kind == "duration":
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}") lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
css.append(f" --{group_name}-{name}: {value};") css.append(f" --{group_name}-{name}: {value};")

View file

@ -1,5 +1,10 @@
{ {
"shell": { "shell": {
"navLabelFade": {
"$type": "duration",
"$value": "200ms",
"source": "index.html:81; nav label color transition uses 200 ms ease-out"
},
"zero": { "zero": {
"$type": "dimension", "$type": "dimension",
"px": 0, "px": 0,

View file

@ -2,6 +2,8 @@ package org.justinvisser.wolfskin.design
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -16,10 +18,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -48,6 +52,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
@Composable @Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
val index = if (state.navFocused) state.navIndex else state.currentTab val index = if (state.navFocused) state.navIndex else state.currentTab
val box = boxes[index] val box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size val avatarFocused = state.navFocused && index == state.tabs.size
@ -73,10 +78,17 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
state.tabs.forEachIndexed { itemIndex, tab -> state.tabs.forEachIndexed { itemIndex, tab ->
val item = boxes[itemIndex] val item = boxes[itemIndex]
val focused = state.navFocused && state.navIndex == itemIndex val focused = state.navFocused && state.navIndex == itemIndex
Box(Modifier.offset(item.left, item.top).size(item.width, item.height).clickable { val labelColor by animateColorAsState(
state.focusNav(itemIndex); state.key(ShellKey.Ok) if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted,
}, contentAlignment = Alignment.Center) { animationSpec = labelFade,
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted) label = "nav-label-$tab",
)
Box(Modifier.offset(item.left, item.top).size(item.width, item.height)
.focusProperties { canFocus = false }
.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) { if (itemIndex == state.currentTab && !focused) {
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent)) Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
} }
@ -86,6 +98,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top) Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
.focusProperties { canFocus = false }
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
} }
} }

View file

@ -96,7 +96,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else "" val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle.copy(fontWeight = FontWeight.Bold), maxLines = 1) Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
if (group.wolfskin) SettingsPill("Wolfskin", focused) if (group.wolfskin) SettingsPill("Wolfskin", focused)
@ -112,6 +112,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
val stroke = WolfskinTokens.Focus.ringButtonRing.toPx() 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)))) 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) { }.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) {
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.SemiBold)) Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
} }
} }

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@ -24,6 +25,8 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
@ -56,11 +59,12 @@ fun ShellSurface(
) { ) {
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus() else contentFocus.requestFocus()
} }
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-shell") Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" } .semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
.focusRequester(focus) .focusRequester(focus)
.onPreviewKeyEvent { .onPreviewKeyEvent {
@ -68,7 +72,11 @@ fun ShellSurface(
} }
.onKeyEvent { .onKeyEvent {
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
state.navFocused = true; state.navIndex = state.currentTab; true if (!focusManager.moveFocus(FocusDirection.Up)) {
state.navFocused = true
state.navIndex = state.currentTab
}
true
} else false } else false
}.focusable()) { }.focusable()) {
if (state.pickerVisible) { if (state.pickerVisible) {

View file

@ -65,15 +65,15 @@ object WolfskinTokens {
val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em) val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em)
val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em) val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em)
val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(700), lineHeight = 22.1.sp, letterSpacing = -0.01.em) val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = -0.01.em)
val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(650), lineHeight = 22.1.sp, letterSpacing = 0.0.em) val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = 0.0.em)
val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(600), lineHeight = 19.5.sp, letterSpacing = 0.0.em) val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em)
val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(650), lineHeight = 19.5.sp, letterSpacing = 0.0.em) val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em)
val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em) val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 18.2.sp, letterSpacing = 0.0.em) val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(650), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 16.8.sp, letterSpacing = 0.0.em) val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.8.sp, letterSpacing = 0.0.em)
val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800)) val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800))
val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800)) val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800))
val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800)) val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
@ -230,6 +230,7 @@ object WolfskinTokens {
const val comingInVisibleRows = 5.0f const val comingInVisibleRows = 5.0f
} }
object Shell { object Shell {
const val navLabelFade = 200
val zero = 0.0.dp val zero = 0.0.dp
val stageWidth = 960.0.dp val stageWidth = 960.0.dp
val stageHeight = 540.0.dp val stageHeight = 540.0.dp

View file

@ -127,6 +127,7 @@
--motion-scroll: 180ms; --motion-scroll: 180ms;
--motion-ratingDwell: 350ms; --motion-ratingDwell: 350ms;
--motion-toast: 3200ms; --motion-toast: 3200ms;
--shell-navLabelFade: 200ms;
--shell-zero: 0.0px; --shell-zero: 0.0px;
--shell-stageWidth: 1920.0px; --shell-stageWidth: 1920.0px;
--shell-stageHeight: 1080.0px; --shell-stageHeight: 1080.0px;

View file

@ -1,5 +1,5 @@
252ce7eb35cc07ef2661cba5cec6c2046e0207c17584a5d83ffe038a365e7cdb generate_tokens.py 1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
a066eaaa68e01e6235ffcd525818f0cb20b44e0aed1687cb645e528d8947aa5d shell-tokens.json 6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
d7cc75e1643ebf79204fd5df94e03ad182f7451761d48ca54da140616dd957bc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 58131daf2bf889dcc2cf2a55f4a179fd6ad9bafa2da798b9a49a65ae3d58ae71 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
bb99f1f3b51028bbe87b245b4a78a59e55d4b519bc947afe56e5db11406db092 tokens.css 9143548adf883d934ee86d5d4dd140a9a2d8116c6c8a905a41d2bf7d828bc371 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json