Merge the reviewed shell updates
This commit is contained in:
commit
c07bb2ca1d
11 changed files with 255 additions and 53 deletions
|
|
@ -0,0 +1,141 @@
|
||||||
|
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]
|
||||||
|
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
|
||||||
|
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)
|
||||||
|
org.junit.Assert.assertEquals("Owned background green", background.green, on.green, 1f / 255f)
|
||||||
|
org.junit.Assert.assertEquals("Owned background blue", background.blue, on.blue, 1f / 255f)
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "review-upstream-on.png").absolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,36 +156,47 @@ 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.Home -> HomeDestination(state, server, user, contentModifier)
|
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
|
||||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
if (destination.fullScreen) {
|
||||||
else -> ShellPlaceholder(route, contentModifier)
|
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.Home -> HomeDestination(state, server, user, contentModifier)
|
||||||
|
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
else -> ShellPlaceholder(route, contentModifier)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,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
|
||||||
Wolfskin Home; later-slice screens remain explicitly labelled navigation targets.
|
Settings list. Native Up traversal stays in content until the first focusable row,
|
||||||
|
then enters the nav. Turning the switch on renders Wolfskin Home; later-slice
|
||||||
|
screens remain explicitly labelled navigation targets.
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -99,7 +106,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
|
||||||
|
|
|
||||||
|
|
@ -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};")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -21,10 +23,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
|
||||||
|
|
@ -53,6 +57,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
|
||||||
|
|
@ -78,10 +83,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))
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +111,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) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
e3ceb0c3b80797a163a4a439cbf86d747e2d6562d559eb1e04709fc0ed6b3c7e generate_tokens.py
|
28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py
|
||||||
77a489b428b038a874624c9d7d881e69b9f20ca478961743061b76ce31b7bd31 home-tokens.json
|
77a489b428b038a874624c9d7d881e69b9f20ca478961743061b76ce31b7bd31 home-tokens.json
|
||||||
a066eaaa68e01e6235ffcd525818f0cb20b44e0aed1687cb645e528d8947aa5d shell-tokens.json
|
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
|
||||||
e65d665e1ac2aad070eea70c68b9234c7a7371636ac8e55af8a45b96b2f42302 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
b051a021d1fc9bb7d03287891f4a7eb62e4eaee4058d54e13bbff108924c6fa0 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
1f44bdf41eabde0061cd9c6608c77f8c64ca435362e4760f1d11f15a57487e36 tokens.css
|
853f22d6f2572f55a2c8a0cac7e5ba11647d3d70e1b2c0be70d23cf06c7f2e58 tokens.css
|
||||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue